diff --git a/.gitignore b/.gitignore index fdc2a92..e00fe4c 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,8 @@ data/ # Generierte Audio-Ausgaben (z. B. chat_client.py) *.wav +# Ausnahme: native Referenz-Stimmen je Sprache (versioniert, Feature-Asset) +!config/voices/*.wav # Python __pycache__/ @@ -25,3 +27,8 @@ venv/ *.bak *.patch *.orig + +# Dateien +3 +TODO.md + diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 82c34a4..2191a4a 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -570,45 +570,71 @@ VA_PROFILE=local-dev make run # alles lokal (STT/TTS in-process, LLM via Ollam ### 4.7 Zwischen llama.cpp und Ollama wechseln -Beide Server können nicht gleichzeitig auf demselben GPU-Speicher laufen. Vor dem -Wechsel muss der jeweils andere Backend-Prozess beendet werden. +Beide nutzen denselben Gateway-Provider `local-openai-compatible` (OpenAI-kompatible +API). Der Wechsel besteht daher aus drei Dingen: **(A)** den jeweils anderen +Backend-Prozess stoppen (beide teilen sich den GPU-Speicher), **(B)** das gewünschte +Backend starten, **(C)** die drei `LOCAL_LLM_*`-Zeilen in `.env` umstellen und das +Gateway neu starten. `DEFAULT_LLM_PROVIDER` bleibt unverändert. **Merkhilfe:** -- llama.cpp = Docker-Container `va_llm` → stoppen mit `make llm-down` -- Ollama = systemd-Dienst → stoppen mit `sudo systemctl stop ollama` +- llama.cpp = Docker-Container `va_llm` → `make llm-up` / `make llm-down` (Port 8001) +- Ollama = systemd-Dienst → `sudo systemctl start/stop ollama` (Port 11434) -#### Von Ollama → llama.cpp wechseln - -```bash -# 1) Ollama stoppen -sudo systemctl stop ollama -# falls manuell gestartet (ollama serve im Vordergrund): -pkill -f "ollama serve" 2>/dev/null || true - -# 2) llama.cpp starten und warten -make llm-up -make llm-status # warten bis „Modell bereit" + HTTP OK erscheint - -# 3) Gateway starten -VA_PROFILE=hybrid make run -``` +> **Wichtig:** `.env`-Änderungen werden erst bei einem **Gateway-Neustart** wirksam +> (uvicorn `--reload` lädt nur bei Code-Änderungen neu, nicht bei `.env`). Als Dienst: +> `systemctl --user restart voice-assistant.service`. #### Von llama.cpp → Ollama wechseln ```bash -# 1) llama.cpp stoppen -make llm-down -# alternativ direkt: -docker rm -f va_llm +# 1) llama.cpp stoppen (GPU freigeben) +make llm-down # alternativ: docker rm -f va_llm -# 2) Ollama starten +# 2) Ollama starten und Modell sicherstellen sudo systemctl start ollama -ollama ps # prüfen ob Modell aktiv (oder leer — wird beim ersten Request geladen) +ollama list # exakten Modellnamen ablesen (z. B. gemma4:12b) +ollama pull gemma4:12b # nur falls noch nicht vorhanden -# 3) Gateway starten -VA_PROFILE=hybrid make run +# 3) .env umstellen: +# LOCAL_LLM_BASE_URL=http://127.0.0.1:11434/v1 +# LOCAL_LLM_API_KEY=ollama +# LOCAL_LLM_MODEL=gemma4:12b # exakter Name aus 'ollama list' + +# 4) Gateway neu starten +VA_PROFILE=hybrid make run # oder: systemctl --user restart voice-assistant.service ``` +#### Von Ollama → llama.cpp wechseln + +```bash +# 1) Ollama stoppen (GPU freigeben) +sudo systemctl stop ollama +pkill -f "ollama serve" 2>/dev/null || true # falls manuell im Vordergrund gestartet + +# 2) llama.cpp starten und warten +make llm-up +make llm-status # warten bis „Modell bereit" + HTTP OK + +# 3) .env umstellen: +# LOCAL_LLM_BASE_URL=http://127.0.0.1:8001/v1 +# LOCAL_LLM_API_KEY=dummy +# LOCAL_LLM_MODEL=va_llm + +# 4) Gateway neu starten +VA_PROFILE=hybrid make run # oder: systemctl --user restart voice-assistant.service +``` + +**Prüfen** (egal welche Richtung): +```bash +curl http://127.0.0.1:11434/v1/models # Ollama (bzw. :8001 für llama.cpp) +# danach im Admin → Status den LLM-Provider/das Modell kontrollieren oder kurz testen +``` + +> **Reasoning/Latenz:** Das Gateway sendet `enable_thinking:false`; **Ollama ignoriert** +> das. Modelle mit eingebautem „Thinking" (z. B. `gemma4:12b`) liefern die Antwort sauber +> im `content`, denken aber intern mit → höhere Latenz. Für reinen Smalltalk ggf. ein +> kleineres/nicht-reasonendes Modell wählen. + --- ### 4.8 Alles stoppen @@ -692,7 +718,7 @@ https://va.beispiel.de/ ← remote über Reverse-Proxy (alles, inkl. Mikr ``` ┌─────────────────────────────────────────────────────────┐ -│ Voice Assistant [☀️/🌙] Angemeldet als … │ +│ Voice Assistant [🇩🇪 ▾] [Qualität ▾] [⚙️] [🌙] │ ├─────────────────────────────────────────────────────────┤ │ │ │ Nachrichtenverlauf │ @@ -700,7 +726,7 @@ https://va.beispiel.de/ ← remote über Reverse-Proxy (alles, inkl. Mikr │ (Assistent: graue Blase links) │ │ │ ├─────────────────────────────┬───────────────────────────┤ -│ Texteingabe … [Senden] │ [🎤] [Stimme ▾] │ +│ Texteingabe … [Senden] │ [🎤] │ └─────────────────────────────┴───────────────────────────┘ Statuszeile: „denkt …" / „verarbeite Sprache …" / leer ``` @@ -709,7 +735,8 @@ https://va.beispiel.de/ ← remote über Reverse-Proxy (alles, inkl. Mikr |---------|----------| | **Texteingabe + Senden** | Text tippen, dann Enter oder „Senden" | | **🎤 Mikrofon-Button** | **Idle (grün 🎤):** Tippen → Aufnahme startet · **Aufnahme (rot pulsierend 🎤):** Tippen → Aufnahme stoppt und wird gesendet · **KI antwortet (amber ⏹):** Tippen → Antwort sofort unterbrechen (Barge-in) | -| **Stimme ▾** | TTS-Provider wählen: leer = Server-Default, `chatterbox` = neuronale Stimme, `openrouter` = Cloud-TTS | +| **Sprache ▾** | Antwortsprache wählen (→ § 6.6): `🔄 Flex` = folgt automatisch der gesprochenen Sprache · feste Sprache (🇩🇪/🇬🇧/…) = Eingabe wird in diese Sprache übersetzt. Die vorlesende Stimme folgt der Auswahl. | +| **Qualität ▾** | Vorlese-Quelle wählen: `📱 Gerät` = das Handy/der Browser liest selbst vor (Web Speech API, kein Server-Audio → spart Daten/Kosten) · `Schnell` = piper (lokal) · `Hoch` = chatterbox (neuronal) · `Cloud` = openrouter | | **☀️ / 🌙** | Tag-/Nacht-Modus; folgt sonst automatisch dem Betriebssystem | | **⚙️** (Admin) | Öffnet das Admin-Panel — nur für Admin-Nutzer sichtbar (→ § 7.5) | | **Angemeldet als …** | SSO-Identität; „Gast" wenn AUTH deaktiviert oder kein SSO-Cookie | @@ -957,6 +984,27 @@ LOCAL_LLM_SYSTEM_PROMPT= ### 6.5 TTS-Einstellungen (Sprachsynthese) +Die Vorlese-Quelle wählt der Nutzer im Kopfzeilen-Menü **Qualität** (→ § 5.1.2). Es gibt +vier Optionen: das **Gerät** selbst (§ 6.5.0) oder einen der drei Server-Provider +**piper** (§ 6.5.2), **chatterbox** (§ 6.5.3) bzw. **OpenRouter** (§ 6.5.1). + +#### 6.5.0 Geräte-TTS (Web Speech API) — „📱 Gerät" + +Wählt der Nutzer **„📱 Gerät"**, liest das Endgerät die Antwort **selbst** vor (iPhone: +Safari/Siri-Stimmen, Android: System-TTS). Der Server erzeugt und überträgt dann **kein +Audio** — er schickt nur den Text. Das spart Mobilfunk-Daten und TTS-Rechenzeit/Kosten. + +- **Vorteile:** keine Audio-Bytes, geringere Latenz, funktioniert auch ohne laufenden + TTS-Dienst. +- **Grenzen:** Stimme/Qualität hängen vom Gerät ab; deine eigenen Stimmen (Klon, + FLEURS-Referenzen) und der Aussprache-Normalizer/das Wörterbuch (§ 6.5.4) greifen **nicht**. +- **Verhalten:** Auf Mobilgeräten ist „Gerät" voreingestellt (solange nichts gewählt wurde). + Die Wahl ist **geräte-lokal** gespeichert (kein Server-Pref), da On-Device-Stimmen pro + Gerät verschieden sind. Browser ohne Web Speech API blenden die Option aus. +- **Technik:** Das Frontend sendet `text_only:true`; die Antwortsprache je Bubble steuert + `SpeechSynthesisUtterance.lang`. iOS erlaubt Sprachausgabe erst nach einer Nutzergeste — + das Frontend schaltet sie beim ersten Tippen/Senden frei. + #### 6.5.1 Cloud-TTS (OpenRouter) — Stimmen wählen Das Gateway reicht den Stimmennamen unverändert an OpenRouter weiter. Welche @@ -1050,10 +1098,22 @@ Konfiguration in `.env`: ```bash CHATTERBOX_BASE_URL=http://127.0.0.1:9999 CHATTERBOX_VOICE=/pfad/zu/referenz_stimme.wav # leer = Standardstimme -CHATTERBOX_LANG=de +CHATTERBOX_LANG=de # Fallback-Sprache (Gesprächssprache gewinnt) CHATTERBOX_SPEED=1.0 +CHATTERBOX_VOICES_DIR=config/voices # native Referenz-Stimmen je Sprache ``` +**Mehrsprachig + native Stimme je Sprache.** Chatterbox ist mehrsprachig (de, en, fr, +es, it, nl, ru, zh u. a.) und klont **cross-lingual**: Die Antwortsprache (→ § 6.6) +wird automatisch an den Dienst übergeben, und die passende Referenz-Stimme wird aus +`CHATTERBOX_VOICES_DIR` nach Konvention `.wav` gewählt (z. B. `fr.wav`, `zh.wav`). +So spricht jede Sprache mit einer muttersprachlichen Stimme statt deutsch-akzentuiert. + +- Reihenfolge der Stimm-Auswahl: explizit angefragte `voice` (WAV-Pfad) → `config/voices/.wav` → `CHATTERBOX_VOICE` (persönlicher Klon) → Standardstimme des Dienstes. +- Deutsch nutzt bewusst keine Datei in `config/voices/`, sondern `CHATTERBOX_VOICE`. +- Die mitgelieferten Referenz-Clips stammen aus dem FLEURS-Datensatz (CC-BY 4.0) — + Quelle/Lizenz/Austausch siehe `config/voices/README.md`. + #### 6.5.4 Aussprache verbessern (TTS-Normalizer) Vor dem TTS läuft ein Normalizer, der Ausspracheprobleme des Phonemizers behebt: @@ -1232,20 +1292,49 @@ print(result) # → 'Chluteur kommt.' --- -### 6.6 Sprache wechseln +### 6.6 Sprache wechseln (Fix / Flex) + +Die Antwortsprache wird in der Web-Oberfläche über **ein einziges Dropdown** („Sprache") +gesteuert. Es kennt zwei Arten von Werten: + +| Auswahl | Bedeutung | Whisper (STT) | LLM-Antwort + Stimme | +|---------|-----------|---------------|----------------------| +| **🔄 Flex** | keine feste Sprache — folgt automatisch der gesprochenen | erkennt die Sprache, **übersetzt nicht** | in der **erkannten** Sprache (blaue Blase zeigt das Original) | +| **🇩🇪 / 🇬🇧 / … (feste Sprache)** | „Fix": System bleibt bei dieser Sprache | bekommt die feste Sprache → **übersetzt** die Eingabe | immer in der **festen** Sprache, egal worin gefragt wurde | + +**Beispiele** (Eingabe auf Französisch gesprochen): + +- **Flex** → blaue Blase: französischer Originaltext · Antwort + Stimme: Französisch. +- **🇩🇪 DE** → blaue Blase: deutsche Übersetzung · Antwort + Stimme: Deutsch. + +Die vorlesende Piper-Stimme folgt immer der Antwortsprache automatisch (z. B. `thorsten` +für Deutsch, `siwis` für Französisch — Zuordnung → `LANG_TO_PIPER_VOICE`). Bei Text-Chat +im Flex-Modus (keine Audio-Erkennung möglich) bekommt das LLM **keine** Sprachvorgabe und +antwortet von selbst in der Sprache der Eingabe; als Fallback gilt `DEFAULT_LANGUAGE`. + +> **Hinweis:** Es gibt kein separates „Fix/Flex"-Menü mehr — eine konkrete Sprache zu +> wählen *ist* der Fix-Modus, „🔄 Flex" der flexible. Intern bleiben zwei Felder +> erhalten: `language` (ISO-Code) und `language_mode` (`fix` | `flex`). + +**Konfiguration außerhalb der Web-UI:** ```bash -DEFAULT_LANGUAGE=de # global in .env +DEFAULT_LANGUAGE=de # global in .env (Fallback-/Standardsprache) +DEFAULT_LANGUAGE_MODE=fix # global: fix | flex (Admin: Tab „⚙ Einstellungen") ``` -Pro Nutzer: +Pro Nutzer (dauerhaft): ```bash -curl -X PUT $URL/api/me/prefs \ - -H "Authorization: Bearer $TOKEN" \ - -H 'Content-Type: application/json' -d '{"language":"en"}' +# Feste Sprache (Fix): +curl -X PUT $URL/api/me/prefs -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' -d '{"language":"en","language_mode":"fix"}' + +# Flex (Sprache folgt automatisch): +curl -X PUT $URL/api/me/prefs -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' -d '{"language_mode":"flex"}' ``` -Pro Aufruf: `{"text":"…","language":"en"}` im Body. +Pro Aufruf: `{"text":"…","language":"en","language_mode":"fix"}` im Body. --- @@ -1564,6 +1653,7 @@ TRUSTED_AUTH_COOKIE_CLAIM=user TRUSTED_PROXY_IPS=192.168.179.10 ADMIN_USERS=atoor,dieterschlueter,dschlueter SSO_LOGOUT_URL=https://linix.de/yunohost/sso/?action=logout +SSO_LOGIN_URL=https://linix.de/yunohost/sso/ # unauth. Seitenaufrufe -> hierhin umleiten # Optional: Signaturprüfung des JWT-Cookies TRUSTED_AUTH_JWT_SECRET= @@ -1574,6 +1664,17 @@ TRUSTED_AUTH_HEADER=X-Remote-User Vollständige Anleitung: [deploy/README.md](deploy/README.md). +**Zugriffsschutz der Web-UI (mehrstufig):** +1. **YunoHost SSOwat (primär):** Die App-Berechtigung darf die Gruppe „visitors" nicht + enthalten → unauthentifizierte Besucher werden zum Portal umgeleitet, bevor sie das + Gateway erreichen: `yunohost user permission update .main --remove visitors --add all_users`. +2. **Gateway, API/WS:** Anfragen über den Proxy ohne gültiges `yunohost.portal`-Cookie + bekommen 401 (auch bei `AUTH_ENABLED=false` — das betrifft nur den LAN-Direktzugriff). +3. **Gateway, statische Seite:** Unauthentifizierte Seitenaufrufe werden auf `SSO_LOGIN_URL` + umgeleitet (bzw. 401, falls nicht gesetzt) — Defense-in-Depth, falls SSOwat umgangen wird. +4. **Port:** Das Gateway sollte nicht offen im Netz lauschen (`HOST=127.0.0.1` bzw. Firewall + auf die Proxy-IP), damit der SSO-Weg nicht per Direktzugriff umgangen werden kann. + ### 7.5 Admin-Web-Panel > 🔧 Admin — erreichbar über den **⚙️-Button** im Web-Interface (nur für Admin-Nutzer sichtbar) @@ -2073,13 +2174,15 @@ Wichtige Body-Felder für `/api/chat` und `/api/speak`: | Feld | Typ | Bedeutung | |------|-----|-----------| | `text` | string | Eingabe-Text (Pflicht) | -| `language` | string | Sprache, z. B. `de`, `en` | +| `language` | string | Sprache, z. B. `de`, `en` (im Fix-Modus maßgeblich) | +| `language_mode` | string | `fix` (feste Sprache, Eingabe wird übersetzt) oder `flex` (folgt der erkannten Sprache) — → § 6.6 | | `stt_provider` | string | Provider für diese Anfrage | | `llm_provider` | string | Provider für diese Anfrage | | `tts_provider` | string | Provider für diese Anfrage | | `voice` | string | TTS-Stimme für diese Anfrage | | `stream` | bool | LLM-Token-Streaming (nur WebSocket) | | `audio_stream` | bool | Satzweises Audio-Streaming (nur WebSocket) | +| `text_only` | bool | Kein Server-Audio erzeugen/senden (nur Text) — fürs Geräte-TTS (→ § 6.5.0) | ## B.3 Sessions und Routing @@ -2095,7 +2198,7 @@ Body-Felder: `input_endpoint`, `output_endpoint`, `stt_provider`, `llm_provider` | Methode | Pfad | Beschreibung | |---------|------|--------------| | `GET` | `/api/me` | Aktueller Nutzer + Präferenzen | -| `PUT` | `/api/me/prefs` | Dauerhafte Routing-Präferenzen setzen | +| `PUT` | `/api/me/prefs` | Dauerhafte Routing-Präferenzen setzen (Merge; Felder u. a. `language`, `language_mode`, `tts_provider` — → § 6.6) | | `GET` | `/api/me/memories` | Alle Langzeit-Erinnerungen | | `POST` | `/api/me/memories` | Erinnerung hinzufügen | | `DELETE` | `/api/me/memories/{id}` | Erinnerung löschen | @@ -2190,7 +2293,9 @@ in `app/dependencies.py` + Implementierung in `app/providers/`. → [Architektur | Fallback-Ketten | § 9.1, Anhang A.3 | | faster-whisper | § 6.3, Anhang C | | Fehlerbehebung | § 13 | +| Fix / Flex (Sprachmodus) | § 6.6 | | Gedächtnis (Sitzung) | § 8.1 | +| Geräte-TTS (Web Speech API) | § 6.5.0 | | Hybrid-Profil | § 3.2 | | Installation | § 2 | | Konfigurationsebenen / Priorität | § 6.1 | @@ -2212,8 +2317,10 @@ in `app/dependencies.py` + Implementierung in `app/providers/`. → [Architektur | Remote-Zugang / HTTPS / SSO | § 11 | | Sachregister | Anhang D | | Sitzungsgedächtnis | § 8.1 | +| Sprache wechseln (Fix/Flex) | § 6.6 | | Sprech-Loop | § 5.2 | | Stimmen (TTS) | § 6.5.1, § 6.5.2 | +| Stimme folgt Sprache (Flex) | § 6.6 | | STT-Einstellungen | § 6.3 | | Streaming (Audio/Token/VAD) | § 6.8, Anhang B.6 | | Tests | § 12 | diff --git a/app/api/admin.py b/app/api/admin.py index 89b4ac7..ece441d 100644 --- a/app/api/admin.py +++ b/app/api/admin.py @@ -16,7 +16,7 @@ router = APIRouter() _CONFIG_DIR = Path(__file__).resolve().parents[2] / "config" _ALLOWED_SECTIONS = {"abbreviations", "units", "terms"} -_ALLOWED_LANGS = {"de", "en"} +_ALLOWED_LANGS = {"de", "en", "fr", "es", "it", "nl", "ru", "zh"} class PronunciationEntry(BaseModel): @@ -231,6 +231,10 @@ async def add_pronunciation(lang: str, entry: PronunciationEntry): raise HTTPException(status_code=422, detail="key und value duerfen nicht leer sein.") data = _read_pronunciation(lang) data[entry.section][entry.key.strip()] = entry.value.strip() + # Nach jedem Einfügen: Sektion alphabetisch aufsteigend nach Schlüssel sortieren. + data[entry.section] = dict( + sorted(data[entry.section].items(), key=lambda kv: kv[0].lower()) + ) _write_pronunciation(lang, data) return {"section": entry.section, "key": entry.key.strip(), "value": entry.value.strip()} @@ -314,6 +318,26 @@ async def admin_log_ws(websocket: WebSocket, key: str | None = None): return await websocket.accept() + + # Hinweis, falls die laufende Instanz NICHT der systemd-Dienst ist (z. B. manueller + # `uvicorn --reload`-Start). Dann hat das Journal dieser Unit keine aktuellen Zeilen, + # und der Log-Tab bliebe sonst kommentarlos leer. + try: + check = await asyncio.create_subprocess_exec( + "systemctl", "--user", "is-active", "voice-assistant.service", + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL, + ) + out, _ = await check.communicate() + if out.decode().strip() != "active": + await websocket.send_text( + "⚠ Der systemd-Dienst 'voice-assistant.service' ist nicht aktiv — " + "die laufende Instanz wurde vermutlich manuell gestartet (uvicorn --reload). " + "Live-Logs erscheinen hier nur, wenn das Gateway als Dienst läuft " + "(systemctl --user start voice-assistant.service). Manuelle Starts loggen ins Terminal." + ) + except Exception: + pass + proc = await asyncio.create_subprocess_exec( "journalctl", "--user", "-f", "-u", "voice-assistant.service", "-n", "100", "--no-pager", "-o", "short", diff --git a/app/api/chat.py b/app/api/chat.py index 38c5610..f56a0db 100644 --- a/app/api/chat.py +++ b/app/api/chat.py @@ -12,6 +12,7 @@ from app.dependencies import ( build_orchestrator, resolve_output_endpoint, get_store, + piper_voice_for_language, ) from app.core.memory_extractor import maybe_schedule_extraction from app.quota import enforce_quota, record_usage, QuotaExceededError @@ -48,13 +49,11 @@ async def chat( "input_endpoint": payload.input_endpoint, "output_endpoint": payload.output_endpoint, "language": payload.language, + "language_mode": payload.language_mode, "stt_provider": payload.stt_provider, "llm_provider": payload.llm_provider, "tts_provider": payload.tts_provider, } - # None -> der gewaehlte TTS-Provider nimmt seinen eigenen Default. - voice = payload.voice - store = get_store() try: @@ -96,6 +95,9 @@ async def chat( except QuotaExceededError as exc: raise HTTPException(status_code=429, detail=str(exc)) + # Explizit angeforderte Stimme gewinnt; sonst folgt sie der Routensprache. + voice = payload.voice or piper_voice_for_language(route.tts_provider, route.language) + try: trace, audio = await orchestrator.chat_text( payload.text, @@ -103,6 +105,8 @@ async def chat( voice=voice, output=output, history=llm_context, + language_mode=route.language_mode, + text_only=bool(payload.text_only), ) except Exception as exc: raise HTTPException(status_code=502, detail=str(exc)) diff --git a/app/api/me.py b/app/api/me.py index 0e87f7a..f270820 100644 --- a/app/api/me.py +++ b/app/api/me.py @@ -23,9 +23,10 @@ async def get_me(user: User = Depends(require_user)): @router.put("/me/prefs") async def set_my_prefs(payload: UserPrefs, user: User = Depends(require_user)): - """Setzt die dauerhaften Routing-Praeferenzen des Nutzers (nur gesetzte Felder).""" - prefs = {k: v for k, v in payload.model_dump().items() if v is not None} - updated = get_store().set_user_prefs(user.id, prefs) + """Setzt dauerhafter Routing-Präferenzen des Nutzers. Merge mit bestehenden Prefs.""" + new = {k: v for k, v in payload.model_dump().items() if v is not None} + merged = {**user.prefs, **new} + updated = get_store().set_user_prefs(user.id, merged) return {"user_id": updated.id, "prefs": updated.prefs} diff --git a/app/api/speak.py b/app/api/speak.py index 7092a23..a39e8d1 100644 --- a/app/api/speak.py +++ b/app/api/speak.py @@ -11,6 +11,7 @@ from app.dependencies import ( build_orchestrator, resolve_output_endpoint, get_store, + piper_voice_for_language, ) from app.quota import enforce_quota, record_usage, QuotaExceededError from app.schemas import SpeakRequest @@ -32,11 +33,10 @@ async def speak( "language": payload.language, "tts_provider": payload.tts_provider, } - # None -> der gewaehlte TTS-Provider nimmt seinen eigenen Default. - voice = payload.voice - try: route = resolve_route(user, session_id, overrides) + # Explizit angefragte Stimme gewinnt; sonst folgt sie der Sprache (Piper-Parität zu /chat). + voice = payload.voice or piper_voice_for_language(route.tts_provider, route.language) orchestrator = build_orchestrator(route) output = await resolve_output_endpoint(route) except SessionOwnershipError as exc: diff --git a/app/api/ws.py b/app/api/ws.py index a9a43e7..18d03ad 100644 --- a/app/api/ws.py +++ b/app/api/ws.py @@ -26,6 +26,7 @@ from app.dependencies import ( resolve_route, build_orchestrator, resolve_output_endpoint, + piper_voice_for_language, ) from app.store import ANONYMOUS_USER_ID, SessionOwnershipError from app.audio.vad import EnergyVAD @@ -40,6 +41,7 @@ _OVERRIDE_KEYS = ( "input_endpoint", "output_endpoint", "language", + "language_mode", "stt_provider", "llm_provider", "tts_provider", @@ -62,7 +64,10 @@ async def _resolve(user, session_id, options): return route, orchestrator, output -async def _run_turn(websocket, store, user, session_id, route, orchestrator, output, text, options): +async def _run_turn( + websocket, store, user, session_id, route, orchestrator, output, text, options, + detected_language: str | None = None, effective_voice: str | None = None, +): """Faehrt einen Antwort-Turn und streamt die Events an den Client.""" conversation = ( store.get_recent_messages(session_id, settings.history_max_messages) @@ -108,13 +113,24 @@ async def _run_turn(websocket, store, user, session_id, route, orchestrator, out await websocket.send_json({"type": "ack", "route": route.as_dict()}) - # Ohne explizite Stimme None lassen -> jeder TTS-Provider nimmt SEINEN Default - # (OpenRouter: OPENROUTER_TTS_VOICE, piper: PIPER_VOICE). - voice = options.get("voice") + # Stimme: explizit angeforderte gewinnt, sonst die vom Caller (Sprach-Turn) + # vorberechnete sprachpassende Stimme, sonst folgt sie der Routensprache + # (greift v. a. beim Text-Chat ohne Spracherkennung). + explicit_voice = options.get("voice") + if explicit_voice: + voice = explicit_voice + elif effective_voice is not None: + voice = effective_voice + else: + voice = piper_voice_for_language(route.tts_provider, route.language) stream = bool(options.get("stream")) + # text_only: Geräte-TTS (Web Speech API) spricht selbst -> kein Server-Audio erzeugen/senden. + text_only = bool(options.get("text_only")) # audio_stream: explizite Anfrage gewinnt, sonst der serverseitige Default (Admin). - audio_stream = bool(options["audio_stream"]) if "audio_stream" in options \ + audio_stream = False if text_only else ( + bool(options["audio_stream"]) if "audio_stream" in options else settings.audio_stream_default + ) on_token = None if stream: @@ -141,6 +157,9 @@ async def _run_turn(websocket, store, user, session_id, route, orchestrator, out history=llm_context, on_token=on_token, on_audio=on_audio, + language_mode=route.language_mode, + detected_language=detected_language, + text_only=text_only, ) else: trace, audio = await orchestrator.chat_text( @@ -149,6 +168,9 @@ async def _run_turn(websocket, store, user, session_id, route, orchestrator, out voice=voice, output=output, history=llm_context, + language_mode=route.language_mode, + detected_language=detected_language, + text_only=text_only, ) except Exception as exc: await websocket.send_json({"type": "error", "status": 502, "detail": str(exc)}) @@ -164,7 +186,8 @@ async def _run_turn(websocket, store, user, session_id, route, orchestrator, out await websocket.send_json( {"type": "semantic", "text": trace.semantic_response, "spoken": trace.spoken_response} ) - if not audio_stream: + # Im text_only-Modus kommt kein Audio (Gerät spricht selbst). + if not audio_stream and not text_only: await websocket.send_bytes(audio) await websocket.send_json({"type": "done", "audio_format": "pcm", "sample_rate": 24000}) @@ -202,19 +225,42 @@ async def _voice_turn(websocket, store, user, session_id, audio, fmt, options): except RoutingError as exc: await websocket.send_json({"type": "error", "status": 422, "detail": str(exc)}) return + try: - transcript = await orchestrator.stt.transcribe(audio, fmt=fmt, language=route.language) + if route.language_mode == "flex": + # Flex: Sprache auto-erkennen, kein Übersetzen durch Whisper. + transcript, detected_language = await orchestrator.stt.transcribe_detect( + audio, fmt=fmt, language=None + ) + # Stimme folgt der erkannten Sprache (Fallback: Routensprache). + effective_voice = piper_voice_for_language( + route.tts_provider, detected_language or route.language + ) + else: + # Fix: konfigurierte Sprache an Whisper → Whisper übersetzt automatisch. + transcript = await orchestrator.stt.transcribe(audio, fmt=fmt, language=route.language) + detected_language = None + # Stimme folgt der konfigurierten Sprache. + effective_voice = piper_voice_for_language(route.tts_provider, route.language) except Exception as exc: await websocket.send_json({"type": "error", "status": 502, "detail": str(exc)}) return - await websocket.send_json({"type": "transcript", "text": transcript}) + + await websocket.send_json({ + "type": "transcript", + "text": transcript, + "detected_language": detected_language, + }) if not transcript or not transcript.strip(): await websocket.send_json({ "type": "error", "detail": "Keine Sprache erkannt — bitte erneut sprechen.", }) return - await _run_turn(websocket, store, user, session_id, route, orchestrator, output, transcript, options) + await _run_turn( + websocket, store, user, session_id, route, orchestrator, output, transcript, options, + detected_language=detected_language, effective_voice=effective_voice, + ) @router.websocket("/ws/chat") diff --git a/app/config.py b/app/config.py index 42de5e2..3070375 100644 --- a/app/config.py +++ b/app/config.py @@ -112,6 +112,7 @@ class Settings(BaseSettings): openrouter_tts_voice: str = "alloy" openrouter_llm_model: str = "openai/gpt-4.1-mini" default_language: str = "de" + default_language_mode: str = "fix" default_input_endpoint: str = "local-default" default_output_endpoint: str = "local-default" default_stt_provider: str = "openrouter" @@ -144,6 +145,9 @@ class Settings(BaseSettings): chatterbox_lang: str = "de" chatterbox_speed: float = 1.0 chatterbox_timeout: int = 180 + # Verzeichnis mit nativen Referenz-WAVs je Sprache (Konvention .wav, z. B. fr.wav). + # Greift cross-lingual: pro Sprache eine muttersprachliche Stimme. Leer -> nur chatterbox_voice. + chatterbox_voices_dir: str = str(BASE_DIR / "config" / "voices") db_path: str = str(BASE_DIR / "data" / "voice-assistant.db") admin_api_key: str = "" auth_enabled: bool = True @@ -161,6 +165,9 @@ class Settings(BaseSettings): trusted_proxy_ips: str = "" # kommasepariert; IP(s) des Reverse-Proxys admin_users: str = "" # kommaseparierte SSO-Usernamen mit Admin-Rechten sso_logout_url: str = "" # Logout-Link fuers Frontend (SSO-Portal) + # Login-/Portal-URL: unauthentifizierte Seitenaufrufe werden hierhin umgeleitet + # (Defense-in-Depth zusaetzlich zu SSOwat). Leer -> stattdessen HTTP 401. + sso_login_url: str = "" history_max_messages: int = 10 # Automatische Erinnerungs-Extraktion: das LLM destilliert dauerhafte Fakten # aus dem Gespraech und legt sie als Nutzer-Erinnerungen ab (best-effort, diff --git a/app/core/orchestrator.py b/app/core/orchestrator.py index d5ca8b3..19cd11c 100644 --- a/app/core/orchestrator.py +++ b/app/core/orchestrator.py @@ -78,7 +78,7 @@ class Orchestrator: spoken, language=language, level=self.normalize_level ) with _stage("tts"): - audio = await self.tts.synthesize(normalized, voice=voice) + audio = await self.tts.synthesize(normalized, voice=voice, language=language) await self._emit_to_output(audio, output) return audio @@ -89,35 +89,48 @@ class Orchestrator: voice: str | None = None, output=None, history: list[dict] | None = None, + language_mode: str = "fix", + detected_language: str | None = None, + text_only: bool = False, ): trace = PipelineTrace() trace.raw_transcript = text trace.cleaned_transcript = await self.input_cleaner.run(text or "") + # Flex: die erkannte Sprache bestimmt die Ausgabe. Gibt es keine (z. B. Text-Chat + # ohne Audio), bleibt sie None -> das LLM antwortet von selbst in der Eingabesprache. + # Fix: immer die konfigurierte Sprache (Whisper hat die Eingabe übersetzt). + effective_language = detected_language if language_mode == "flex" else language + with _stage("llm"): trace.semantic_response = await self.llm.complete( trace.cleaned_transcript or "", history=history, - language=language, + language=effective_language, ) if not trace.semantic_response: raise RuntimeError("LLM returned an empty response") trace.spoken_response = await self.spoken_adapter.run( trace.semantic_response, - language=language, + language=effective_language, ) trace.tts_ready_text = await self.tts_normalizer.run( trace.spoken_response, - language=language, + language=effective_language, level=self.normalize_level, ) + # text_only: Geräte-TTS (Web Speech API) übernimmt das Sprechen -> kein Server-Audio. + if text_only: + return trace, b"" + with _stage("tts"): audio = await self.tts.synthesize( trace.tts_ready_text, voice=voice, + language=effective_language, ) await self._emit_to_output(audio, output) return trace, audio @@ -131,6 +144,9 @@ class Orchestrator: history: list[dict] | None = None, on_token=None, on_audio=None, + language_mode: str = "fix", + detected_language: str | None = None, + text_only: bool = False, ): """Wie chat_text, aber gestreamt. @@ -142,24 +158,29 @@ class Orchestrator: trace.raw_transcript = text trace.cleaned_transcript = await self.input_cleaner.run(text or "") - chunker = SentenceChunker() if on_audio else None + # Siehe chat_text: Flex folgt der erkannten Sprache (None -> LLM spiegelt Eingabe), + # Fix nutzt die konfigurierte Sprache. + effective_language = detected_language if language_mode == "flex" else language + + # text_only: kein Server-Audio (Geräte-TTS spricht selbst) -> kein Chunked-TTS. + chunker = SentenceChunker() if (on_audio and not text_only) else None audio_parts: list[bytes] = [] async def _emit_sentence(sentence: str) -> None: - spoken = await self.spoken_adapter.run(sentence, language=language) + spoken = await self.spoken_adapter.run(sentence, language=effective_language) ready = await self.tts_normalizer.run( - spoken, language=language, level=self.normalize_level + spoken, language=effective_language, level=self.normalize_level ) if not ready.strip(): return - chunk = await self.tts.synthesize(ready, voice=voice) + chunk = await self.tts.synthesize(ready, voice=voice, language=effective_language) audio_parts.append(chunk) await on_audio(chunk) parts: list[str] = [] stream_fn = getattr(self.llm, "stream", None) if stream_fn is not None: - async for delta in stream_fn(trace.cleaned_transcript or "", history=history, language=language): + async for delta in stream_fn(trace.cleaned_transcript or "", history=history, language=effective_language): parts.append(delta) if on_token: await on_token(delta) @@ -167,8 +188,7 @@ class Orchestrator: for sentence in chunker.feed(delta): await _emit_sentence(sentence) else: - # Provider ohne Streaming -> komplette Antwort als ein Token. - result = await self.llm.complete(trace.cleaned_transcript or "", history=history, language=language) + result = await self.llm.complete(trace.cleaned_transcript or "", history=history, language=effective_language) parts.append(result) if on_token: await on_token(result) @@ -182,21 +202,26 @@ class Orchestrator: trace.spoken_response = await self.spoken_adapter.run( trace.semantic_response, - language=language, + language=effective_language, ) trace.tts_ready_text = await self.tts_normalizer.run( trace.spoken_response, - language=language, + language=effective_language, level=self.normalize_level, ) + if text_only: + return trace, b"" + if chunker: tail = chunker.flush() if tail: await _emit_sentence(tail) audio = b"".join(audio_parts) else: - audio = await self.tts.synthesize(trace.tts_ready_text, voice=voice) + audio = await self.tts.synthesize( + trace.tts_ready_text, voice=voice, language=effective_language + ) await self._emit_to_output(audio, output) return trace, audio diff --git a/app/dependencies.py b/app/dependencies.py index bfeec70..f8cc8a7 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -77,6 +77,7 @@ TTS_REGISTRY = { s.chatterbox_speed, s.tts_sample_rate, s.chatterbox_timeout, + voices_dir=s.chatterbox_voices_dir, ), "piper": lambda s: PiperTTSProvider( s.piper_bin, s.piper_voices_dir, s.piper_voice, s.tts_sample_rate @@ -148,8 +149,35 @@ ROUTE_KEYS = ( "llm_provider", "tts_provider", "language", + "language_mode", ) +# Stimm-Auswahl nach Sprache: (effektive) Sprache → beste Piper-Stimme. +# Gilt in BEIDEN Modi — die gesprochene Stimme folgt immer der Antwortsprache. +LANG_TO_PIPER_VOICE: dict[str, str] = { + "de": "de_DE-thorsten-high", + "en": "en_US-lessac-high", + "fr": "fr_FR-siwis-medium", + "es": "es_ES-sharvard-medium", + "it": "it_IT-paola-medium", + "nl": "nl_NL-mls-medium", + "ru": "ru_RU-irina-medium", + "zh": "zh_CN-huayan-medium", + "cmn": "zh_CN-huayan-medium", +} + + +def piper_voice_for_language(tts_provider: str, language: str | None) -> str | None: + """Liefert die zur Sprache passende Piper-Stimme (sonst None → Provider-Default). + + Nur für Piper sinnvoll: Cloud-TTS (OpenRouter) und Chatterbox haben eigene + Stimmnamen (z. B. "Zephyr") und steuern die Sprache anders. Bei None nimmt der + jeweilige Provider seine konfigurierte Default-Stimme. + """ + if tts_provider == "piper" and language: + return LANG_TO_PIPER_VOICE.get(language) + return None + @dataclass class ResolvedRoute: @@ -159,6 +187,7 @@ class ResolvedRoute: llm_provider: str tts_provider: str language: str + language_mode: str = "fix" def as_dict(self) -> dict: return { @@ -168,6 +197,7 @@ class ResolvedRoute: "llm_provider": self.llm_provider, "tts_provider": self.tts_provider, "language": self.language, + "language_mode": self.language_mode, } @@ -208,6 +238,7 @@ def resolve_route( "llm_provider": cfg.default_llm_provider, "tts_provider": cfg.default_tts_provider, "language": cfg.default_language, + "language_mode": cfg.default_language_mode, } user_prefs = user.prefs if user is not None else {} diff --git a/app/main.py b/app/main.py index 6d37576..02da98b 100644 --- a/app/main.py +++ b/app/main.py @@ -5,7 +5,10 @@ from pathlib import Path from fastapi import FastAPI, Request from fastapi.staticfiles import StaticFiles +from starlette.responses import RedirectResponse, JSONResponse +from app.config import settings +from app.auth import authenticate, _bearer_token from app.core.warmup import warmup_local_models from app.metrics import metrics from app.api.health import router as health_router @@ -48,6 +51,32 @@ async def record_metrics(request: Request, call_next): return response +# Pfade mit eigener Auth, Health-Checks und Favicons: nicht gaten. +_PUBLIC_PREFIXES = ("/api", "/ws", "/health", "/favicon", "/apple-touch-icon") + + +@app.middleware("http") +async def gate_web_ui(request: Request, call_next): + """Schuetzt die statische Web-UI: unauthentifizierte Seitenaufrufe -> SSO-Login (sonst 401). + + Defense-in-Depth zusaetzlich zu SSOwat: Selbst wenn der Reverse-Proxy einen + unauthentifizierten Request durchliesse (oder jemand den Port direkt trifft), + bekommt er die Seite nicht ausgeliefert. API/WS haben ihre eigene Auth (require_user + -> 401/JSON), Health-Checks bleiben offen. Bei abgeschalteter Auth (LAN-Dev) liefert + authenticate() einen anonymen Nutzer -> die Seite wird wie bisher ausgeliefert. + """ + path = request.url.path + if not path.startswith(_PUBLIC_PREFIXES): + client_host = request.client.host if request.client else "" + token = _bearer_token(request.headers.get("authorization")) + if authenticate(request.headers, client_host, token) is None: + login = settings.sso_login_url.strip() + if login: + return RedirectResponse(login, status_code=302) + return JSONResponse({"detail": "Authentication required"}, status_code=401) + return await call_next(request) + + app.include_router(health_router) app.include_router(chat_router, prefix="/api") app.include_router(transcribe_router, prefix="/api") diff --git a/app/providers/fallback.py b/app/providers/fallback.py index 3cfb122..5682f2d 100644 --- a/app/providers/fallback.py +++ b/app/providers/fallback.py @@ -35,6 +35,19 @@ class FallbackSTTProvider(_Chain): self._on_error(name) raise last_exc + async def transcribe_detect(self, audio_bytes, fmt, language=None) -> tuple[str, str | None]: + last_exc = None + for index, (name, provider) in enumerate(self.entries): + try: + result = await provider.transcribe_detect(audio_bytes, fmt, language=language) + if index > 0: + self._on_fallback() + return result + except Exception as exc: # noqa: BLE001 + last_exc = exc + self._on_error(name) + raise last_exc + class FallbackLLMProvider(_Chain): async def complete(self, text, history=None, session_id=None, language=None) -> str: @@ -75,11 +88,13 @@ class FallbackLLMProvider(_Chain): class FallbackTTSProvider(_Chain): - async def synthesize(self, text, voice=None, audio_format="pcm") -> bytes: + async def synthesize(self, text, voice=None, audio_format="pcm", language=None) -> bytes: last_exc = None for index, (name, provider) in enumerate(self.entries): try: - result = await provider.synthesize(text, voice=voice, audio_format=audio_format) + result = await provider.synthesize( + text, voice=voice, audio_format=audio_format, language=language + ) if index > 0: self._on_fallback() return result diff --git a/app/providers/llm/base.py b/app/providers/llm/base.py index bf566c5..a72a1e7 100644 --- a/app/providers/llm/base.py +++ b/app/providers/llm/base.py @@ -16,11 +16,28 @@ _LANG_NAMES: dict[str, str] = { def lang_instruction(language: str | None) -> str | None: - """'Respond in X.' instruction for the given ISO language code, or None.""" + """Sprach-Anweisung für den ISO-Code, oder None. + + Bewusst nachdrücklich formuliert: Eine lange anderssprachige History (z. B. der + Nutzer hat zwischendurch englisch gesprochen) soll die gewünschte Sprache NICHT + überstimmen. Wird zusätzlich nah an die Generierung gesetzt (siehe with_lang_reminder). + """ if not language: return None name = _LANG_NAMES.get(language, language) - return f"Respond in {name}." + return f"Respond ONLY in {name}, regardless of the language used earlier in this conversation." + + +def with_lang_reminder(text: str, language: str | None) -> str: + """Hängt eine knappe Sprach-Erinnerung an die letzte Nutzer-Nachricht. + + Direkt vor der Generierung platziert wirkt sie stärker als der (weit zurückliegende) + System-Prompt, wenn die bisherige Unterhaltung in einer anderen Sprache lief. + """ + if not language: + return text + name = _LANG_NAMES.get(language, language) + return f"{text}\n\n[Bitte ausschließlich auf {name} antworten.]" def sse_delta(line: str) -> str | None: diff --git a/app/providers/llm/local_openai_compatible.py b/app/providers/llm/local_openai_compatible.py index 713d825..cef4352 100644 --- a/app/providers/llm/local_openai_compatible.py +++ b/app/providers/llm/local_openai_compatible.py @@ -2,7 +2,12 @@ from collections.abc import AsyncIterator import httpx -from app.providers.llm.base import LLMProvider, lang_instruction, sse_delta +from app.providers.llm.base import ( + LLMProvider, + lang_instruction, + with_lang_reminder, + sse_delta, +) class LocalOpenAICompatibleLLM(LLMProvider): @@ -50,7 +55,8 @@ class LocalOpenAICompatibleLLM(LLMProvider): if system_parts: messages.append({"role": "system", "content": "\n\n".join(system_parts)}) messages.extend(rest) - messages.append({"role": "user", "content": text}) + # Sprach-Erinnerung direkt an der letzten Nutzer-Nachricht (schlägt History-Trägheit). + messages.append({"role": "user", "content": with_lang_reminder(text, language)}) return messages def _payload( diff --git a/app/providers/llm/openrouter.py b/app/providers/llm/openrouter.py index ab8116d..408afa7 100644 --- a/app/providers/llm/openrouter.py +++ b/app/providers/llm/openrouter.py @@ -2,7 +2,12 @@ from collections.abc import AsyncIterator import httpx -from app.providers.llm.base import LLMProvider, lang_instruction, sse_delta +from app.providers.llm.base import ( + LLMProvider, + lang_instruction, + with_lang_reminder, + sse_delta, +) SYSTEM_PROMPT = """ @@ -69,7 +74,8 @@ class OpenRouterLLMProvider(LLMProvider): messages = [{"role": "system", "content": system_content}] if history: messages.extend(history) - messages.append({"role": "user", "content": text.strip()}) + # Sprach-Erinnerung direkt an der letzten Nutzer-Nachricht (schlägt History-Trägheit). + messages.append({"role": "user", "content": with_lang_reminder(text.strip(), language)}) return messages async def complete( diff --git a/app/providers/stt/base.py b/app/providers/stt/base.py index 79a3844..420e0b3 100644 --- a/app/providers/stt/base.py +++ b/app/providers/stt/base.py @@ -1,5 +1,17 @@ from abc import ABC, abstractmethod + class STTProvider(ABC): @abstractmethod async def transcribe(self, audio_bytes: bytes, fmt: str, language: str | None = None) -> str: ... + + async def transcribe_detect( + self, audio_bytes: bytes, fmt: str, language: str | None = None + ) -> tuple[str, str | None]: + """Transkribiert und liefert (text, detected_language). + + Standardimplementierung delegiert an transcribe(); detected_language=None. + Provider mit Spracherkennung überschreiben diese Methode. + """ + text = await self.transcribe(audio_bytes, fmt, language=language) + return text, None diff --git a/app/providers/stt/faster_whisper.py b/app/providers/stt/faster_whisper.py index ae9e157..6cd4399 100644 --- a/app/providers/stt/faster_whisper.py +++ b/app/providers/stt/faster_whisper.py @@ -38,12 +38,24 @@ class FasterWhisperProvider(STTProvider): self.device = device or settings.faster_whisper_device self.compute_type = compute_type or settings.faster_whisper_compute_type - def _transcribe_sync(self, audio_bytes: bytes, language: str | None) -> str: + def _transcribe_sync( + self, audio_bytes: bytes, language: str | None + ) -> tuple[str, str | None]: model = _load_model(self.model_size, self.device, self.compute_type) - segments, _info = model.transcribe(io.BytesIO(audio_bytes), language=language) - return "".join(segment.text for segment in segments).strip() + segments, info = model.transcribe(io.BytesIO(audio_bytes), language=language) + text = "".join(segment.text for segment in segments).strip() + detected = getattr(info, "language", None) + return text, detected async def transcribe(self, audio_bytes: bytes, fmt: str, language: str | None = None) -> str: + if not audio_bytes: + raise ValueError("STT input audio is empty") + text, _ = await asyncio.to_thread(self._transcribe_sync, audio_bytes, language) + return text + + async def transcribe_detect( + self, audio_bytes: bytes, fmt: str, language: str | None = None + ) -> tuple[str, str | None]: if not audio_bytes: raise ValueError("STT input audio is empty") return await asyncio.to_thread(self._transcribe_sync, audio_bytes, language) diff --git a/app/providers/stt/openrouter.py b/app/providers/stt/openrouter.py index 45f02dc..0b359d9 100644 --- a/app/providers/stt/openrouter.py +++ b/app/providers/stt/openrouter.py @@ -52,4 +52,48 @@ class OpenRouterSTTProvider(STTProvider): except httpx.HTTPError as exc: raise RuntimeError(f"OpenRouter STT transport error: {exc}") from exc - return response.json().get("text", "") + data = response.json() + return data.get("text", "") + + async def transcribe_detect( + self, audio_bytes: bytes, fmt: str, language: str | None = None + ) -> tuple[str, str | None]: + if not self.api_key: + raise ValueError("OPENROUTER_API_KEY is empty") + if not self.model: + raise ValueError("OPENROUTER_STT_MODEL is empty") + if not audio_bytes: + raise ValueError("STT input audio is empty") + + payload = { + "model": self.model, + "input_audio": { + "data": base64.b64encode(audio_bytes).decode("utf-8"), + "format": fmt, + }, + } + if language: + payload["language"] = language + + timeout = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0) + async with httpx.AsyncClient(timeout=timeout) as client: + try: + response = await client.post( + "https://openrouter.ai/api/v1/audio/transcriptions", + headers={ + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + }, + json=payload, + ) + response.raise_for_status() + except httpx.HTTPStatusError as exc: + raise RuntimeError( + f"OpenRouter STT error {exc.response.status_code}: {exc.response.text}" + ) from exc + except httpx.TimeoutException as exc: + raise RuntimeError("OpenRouter STT timeout") from exc + except httpx.HTTPError as exc: + raise RuntimeError(f"OpenRouter STT transport error: {exc}") from exc + data = response.json() + return data.get("text", ""), data.get("language") diff --git a/app/providers/tts/base.py b/app/providers/tts/base.py index 8f17bae..c7c6cba 100644 --- a/app/providers/tts/base.py +++ b/app/providers/tts/base.py @@ -2,4 +2,10 @@ from abc import ABC, abstractmethod class TTSProvider(ABC): @abstractmethod - async def synthesize(self, text: str, voice: str | None = None, audio_format: str = "pcm") -> bytes: ... + async def synthesize( + self, + text: str, + voice: str | None = None, + audio_format: str = "pcm", + language: str | None = None, + ) -> bytes: ... diff --git a/app/providers/tts/chatterbox.py b/app/providers/tts/chatterbox.py index 84d2156..0711aff 100644 --- a/app/providers/tts/chatterbox.py +++ b/app/providers/tts/chatterbox.py @@ -15,6 +15,7 @@ import asyncio import io import time import wave +from pathlib import Path import httpx @@ -31,6 +32,7 @@ class ChatterboxTTSProvider(TTSProvider): speed: float = 1.0, target_rate: int = 24000, timeout: int = 180, + voices_dir: str = "", ): self.base_url = base_url.rstrip("/") self.voice = (voice or "").strip() # Pfad zu Referenz-WAV (Voice-Cloning) oder leer @@ -38,32 +40,51 @@ class ChatterboxTTSProvider(TTSProvider): self.speed = speed self.target_rate = int(target_rate) self.timeout = timeout + # Absolut auflösen: der Chatterbox-Dienst liest den Pfad mit eigenem CWD. + self.voices_dir = Path(voices_dir).expanduser().resolve() if voices_dir else None - def _ref_voice(self, voice: str | None) -> str | None: - # Eine angefragte Stimme gilt nur, wenn sie wie ein WAV-Pfad aussieht - # (Cloud-Stimmennamen wie "Zephyr"/"alloy" ignorieren -> Default nehmen). + def _lang_ref(self, lang: str) -> str | None: + """Native Referenz-WAV der Sprache (Konvention /.wav), falls vorhanden.""" + if not self.voices_dir: + return None + path = self.voices_dir / f"{lang}.wav" + return str(path) if path.exists() else None + + def _ref_voice(self, voice: str | None, lang: str) -> str | None: + # 1. Explizit angefragte Stimme nur, wenn sie wie ein WAV-Pfad aussieht + # (Cloud-Stimmennamen wie "Zephyr"/"alloy" ignorieren). cand = (voice or "").strip() if cand.endswith(".wav"): return cand - return self.voice or None + # 2. Native Stimme der jeweiligen Sprache, sonst die konfigurierte Default-Stimme. + return self._lang_ref(lang) or self.voice or None + + # Chatterbox ist mehrsprachig und klont die Stimme cross-lingual: die Referenz-WAV + # liefert das Timbre, `lang` die Aussprache. So spricht die Klon-Stimme jede Sprache. + _LANG_ALIASES = {"cmn": "zh"} async def synthesize( self, text: str, voice: str | None = None, audio_format: str = "pcm", + language: str | None = None, ) -> bytes: if not text or not text.strip(): raise ValueError("TTS input text is empty") + # Gesprächssprache bevorzugen; ohne Angabe der konfigurierte Default. + lang = (language or self.lang or "de").strip() + lang = self._LANG_ALIASES.get(lang, lang) + payload = { "text": text.strip(), - "lang": self.lang, + "lang": lang, "speed": self.speed, "keep_audio": True, "no_playback": True, } - ref = self._ref_voice(voice) + ref = self._ref_voice(voice, lang) if ref: payload["voice"] = ref diff --git a/app/providers/tts/openrouter.py b/app/providers/tts/openrouter.py index d12fe9f..dda8e5f 100644 --- a/app/providers/tts/openrouter.py +++ b/app/providers/tts/openrouter.py @@ -21,6 +21,7 @@ class OpenRouterTTSProvider(TTSProvider): text: str, voice: str | None = None, audio_format: str = "pcm", + language: str | None = None, # Cloud-TTS steuert Sprache über Stimme/Modell ) -> bytes: if not self.api_key: raise ValueError("OPENROUTER_API_KEY is empty") diff --git a/app/providers/tts/piper.py b/app/providers/tts/piper.py index a9b8b0e..e1d102c 100644 --- a/app/providers/tts/piper.py +++ b/app/providers/tts/piper.py @@ -109,6 +109,7 @@ class PiperTTSProvider(TTSProvider): text: str, voice: str | None = None, audio_format: str = "pcm", + language: str | None = None, # ignoriert: die Piper-Stimme kodiert die Sprache ) -> bytes: if not text or not text.strip(): raise ValueError("TTS input text is empty") diff --git a/app/runtime_config.py b/app/runtime_config.py index 79b0c47..712472d 100644 --- a/app/runtime_config.py +++ b/app/runtime_config.py @@ -18,8 +18,9 @@ from app.config import Settings, settings as _base RUNTIME_SETTABLE: dict[str, tuple[str, str, str]] = { "default_stt_provider": ("STT-Provider (Standard)", "str", "openrouter | faster-whisper"), "default_llm_provider": ("LLM-Provider (Standard)", "str", "openrouter | local-openai-compatible"), - "default_tts_provider": ("TTS-Provider (Standard)", "str", "openrouter | piper | chatterbox"), - "default_language": ("Sprache (Standard)", "str", "de | en | …"), + "default_tts_provider": ("TTS-Provider (Standard)", "str", "openrouter | piper | chatterbox — Standard, pro Nutzer überschreibbar"), + "default_language": ("Sprache (Standard)", "str", "de | en | … — Standard, pro Nutzer überschreibbar"), + "default_language_mode": ("Sprachmodus (Standard)", "str", "fix | flex — Standard, pro Nutzer überschreibbar"), "openrouter_llm_model": ("LLM-Modell (OpenRouter)", "str", "z.B. google/gemini-3.1-flash-lite"), "openrouter_tts_model": ("TTS-Modell (OpenRouter)", "str", "z.B. google/gemini-3.1-flash-tts-preview"), "openrouter_tts_voice": ("TTS-Stimme (OpenRouter)", "str", "z.B. Zephyr, Puck, Kore"), diff --git a/app/schemas.py b/app/schemas.py index 8ed149d..8fb6e6e 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -46,10 +46,12 @@ class ChatRequest(BaseModel): input_endpoint: str | None = None output_endpoint: str | None = None language: str | None = None + language_mode: Literal["fix", "flex"] | None = None voice: str | None = None stt_provider: str | None = None llm_provider: str | None = None tts_provider: str | None = None + text_only: bool | None = None # True -> kein Server-Audio (Geräte-TTS spricht selbst) class SessionRouteRequest(BaseModel): @@ -91,6 +93,7 @@ class UserPrefs(BaseModel): llm_provider: str | None = None tts_provider: str | None = None language: str | None = None + language_mode: Literal["fix", "flex"] | None = None class MemoryCreate(BaseModel): diff --git a/app/web/app.js b/app/web/app.js index bd4318c..2ed4863 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -8,11 +8,121 @@ const promptEl = $("#prompt"); const formEl = $("#prompt-form"); const micBtn = $("#mic"); const ttsSel = $("#tts"); +const langSel = $("#lang-sel"); -// Gewählter TTS-Provider (leer = Default/piper) als Request-Override. +// "Flex" ist im Sprachmenü nur ein weiterer Wert: keine feste Sprache, sondern +// automatische Erkennung. Eine konkrete Sprache = Fix-Modus (Eingabe wird übersetzt). +// Beide internen Felder (language, language_mode) werden aus dieser einen Auswahl abgeleitet. +function langOverrides() { + const sel = langSel && langSel.value; + if (!sel || sel === "flex") return { language_mode: "flex" }; + return { language: sel, language_mode: "fix" }; +} + +// Nutzer-Präferenzen als Request-Overrides für jeden Turn. function overrides() { - const t = ttsSel && ttsSel.value; - return t ? { tts_provider: t } : {}; + const out = { ...langOverrides() }; + if (isDeviceMode()) { + out.text_only = true; // kein Server-Audio -> Gerät spricht selbst + } else if (ttsSel && ttsSel.value) { + out.tts_provider = ttsSel.value; + } + return out; +} + +async function savePrefs(obj) { + try { + await fetch("/api/me/prefs", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(obj), + }); + } catch (e) { console.error("savePrefs", e); } +} + +// ---------- Geräte-TTS (Web Speech API) ---------- +// "Gerät" liest die Antwort lokal vor -> der Server schickt kein Audio (spart Bytes/Kosten). +const TTS_SUPPORTED = typeof window !== "undefined" && "speechSynthesis" in window; +const LANG_BCP47 = { + de: "de-DE", en: "en-US", fr: "fr-FR", es: "es-ES", + it: "it-IT", nl: "nl-NL", ru: "ru-RU", zh: "zh-CN", +}; + +function isMobile() { + const ua = navigator.userAgent || ""; + return /Android|iPhone|iPad|iPod/i.test(ua) || + (navigator.maxTouchPoints > 1 && /Macintosh/.test(ua)); // iPad ab iOS 13 +} + +function isDeviceMode() { return !!ttsSel && ttsSel.value === "device"; } + +let _voices = []; +function loadVoices() { _voices = TTS_SUPPORTED ? speechSynthesis.getVoices() : []; } +if (TTS_SUPPORTED) { + loadVoices(); + speechSynthesis.addEventListener("voiceschanged", loadVoices); +} + +function pickVoice(bcp) { + if (!_voices.length) loadVoices(); + const lc = bcp.toLowerCase(); + const two = lc.slice(0, 2); + return _voices.find((v) => v.lang && v.lang.toLowerCase() === lc) || + _voices.find((v) => v.lang && v.lang.toLowerCase().startsWith(two)) || null; +} + +function _makeUtterance(text, lang) { + const u = new SpeechSynthesisUtterance(text); + u.lang = LANG_BCP47[lang] || lang || "de-DE"; + const v = pickVoice(u.lang); + if (v) u.voice = v; + return u; +} + +// Vorlesen ohne Button-Status (automatisches Vorlesen der Antwort). +function deviceSpeak(text, lang) { + if (!TTS_SUPPORTED || !text) return; + speechSynthesis.cancel(); + speechSynthesis.speak(_makeUtterance(text, lang)); +} + +// Vorlesen mit Button-Status (Replay): Icon -> ⏹ während des Sprechens, danach zurück. +function deviceSpeakBtn(text, lang, btn) { + if (!TTS_SUPPORTED || !text) { setReplayPlaying(btn, false); return; } + speechSynthesis.cancel(); + const u = _makeUtterance(text, lang); + u.onend = u.onerror = () => setReplayPlaying(btn, false); + setReplayPlaying(btn, true); + speechSynthesis.speak(u); +} + +// iOS: speechSynthesis darf nur nach einer Nutzergeste starten -> beim ersten Tap freischalten. +let _ttsUnlocked = false; +function unlockTTS() { + if (_ttsUnlocked || !TTS_SUPPORTED) return; + try { speechSynthesis.speak(new SpeechSynthesisUtterance("")); _ttsUnlocked = true; } catch (e) {} +} + +const PLAYBACK_KEY = "va-playback"; // geräte-lokal: "device" wenn Geräte-TTS gewählt + +// Setzt das Qualität-Dropdown beim Laden. Vorrang: +// 1. lokale Wahl (localStorage), 2. Server-Pref (tts_provider), 3. Mobil-Default = Gerät. +function applyPlaybackChoice(serverProvider) { + if (!ttsSel) return; + // "Gerät"-Option ausblenden, wenn der Browser keine Web Speech API hat. + if (!TTS_SUPPORTED) { + const opt = ttsSel.querySelector('option[value="device"]'); + if (opt) opt.remove(); + } + const local = localStorage.getItem(PLAYBACK_KEY); + if (local === "device" && TTS_SUPPORTED) { + ttsSel.value = "device"; + } else if (serverProvider) { + ttsSel.value = serverProvider; + } else if (TTS_SUPPORTED && isMobile() && local === null) { + ttsSel.value = "device"; // Mobil-Default, solange nichts gewählt wurde + localStorage.setItem(PLAYBACK_KEY, "device"); + } } let busy = false; @@ -23,6 +133,8 @@ let activeSources = []; // laufende AudioBufferSourceNodes (stoppbar per Barge- // /api/me abgeleitet; bis dahin null -> ensureSession() wartet auf loadMe(). let sessionId = null; let mePromise = null; +let currentUserId = null; +const sessionKey = (uid) => "va-session-" + uid; // ---------- Identitaet / Menue ---------- function loadMe() { @@ -34,7 +146,9 @@ function loadMe() { return null; } const me = await res.json(); - sessionId = "web-" + me.user_id; + currentUserId = me.user_id; + // Zuletzt aktive (ggf. frische) Session bevorzugen, sonst die Basis-Session. + sessionId = localStorage.getItem(sessionKey(me.user_id)) || ("web-" + me.user_id); $("#identity").textContent = "Angemeldet als " + (me.display_name || me.external_id || "Gast"); if (me.sso_logout_url) { const logout = $("#logout"); @@ -44,6 +158,15 @@ function loadMe() { if (me.is_admin) { $("#admin-toggle").classList.remove("hidden"); } + // Gespeicherte Nutzer-Präferenzen in die Dropdowns laden. + const prefs = me.prefs || {}; + if (langSel) { + // Flex-Modus -> "flex", sonst die feste Sprache (Fallback: Flex). + langSel.value = prefs.language_mode === "flex" + ? "flex" + : (prefs.language || "flex"); + } + applyPlaybackChoice(prefs.tts_provider); return me; } catch (e) { $("#identity").textContent = "Verbindung fehlgeschlagen"; @@ -76,19 +199,98 @@ const BUBBLE = { emergency: "mx-auto max-w-[90%] rounded-lg bg-red-100 px-3 py-1.5 text-sm font-semibold text-red-800 dark:bg-red-900/40 dark:text-red-200", }; -function addMessage(role, text) { +// Lautsprecher-/Stop-Icons als SVG (currentColor → per CSS einfärbbar, anders als Emoji). +const ICON_SPEAKER = + ''; +const ICON_STOP = + '' + + ''; + +// meta.lang: Sprache, in der diese Bubble vorgelesen werden soll (für Replay). +function addMessage(role, text, meta = {}) { const div = document.createElement("div"); div.className = BUBBLE[role] || BUBBLE.assistant; - div.textContent = text; + const span = document.createElement("span"); + span.className = "bubble-text"; + span.textContent = text; + div.appendChild(span); + div._textEl = span; + // Vorlese-Symbol nur an Nutzer-/Assistenten-Bubbles (nicht System/Notfall). + if (role === "user" || role === "assistant") { + div.classList.add("relative", "pr-7"); + div._replay = { lang: meta.lang || null, chunks: null }; // chunks: gecachtes PCM (Assistent) + const btn = document.createElement("button"); + btn.type = "button"; + btn.innerHTML = ICON_SPEAKER; + btn.title = "Vorlesen"; + btn.setAttribute("aria-label", "Vorlesen"); + // Kontrastreiche Farbe je Bubble: weiß auf Blau; im Dark-Mode hell auf Dunkelgrau. + const replayColor = role === "user" + ? "text-white/80 hover:text-white" + : "text-slate-500 hover:text-slate-700 dark:text-slate-300 dark:hover:text-white"; + btn.className = "replay-btn absolute top-1.5 right-1.5 transition-colors " + replayColor; + btn.addEventListener("click", () => replayBubble(div)); + div._replayBtn = btn; + div.appendChild(btn); + } messagesEl.appendChild(div); scrollToBottom(); return div; } +// ---------- Vorlesen / Replay ---------- +function setReplayPlaying(btn, on) { + if (!btn) return; + btn.innerHTML = on ? ICON_STOP : ICON_SPEAKER; + btn.dataset.playing = on ? "1" : ""; +} + +function startReplay(chunks, btn) { + stopAudio(); // Barge-in: laufendes Audio (auch andere Replays) stoppen + setReplayPlaying(btn, true); + playPcm(chunks, 24000, () => setReplayPlaying(btn, false)); +} + +async function speakBubble(text, lang, btn) { + try { + const body = { text }; + if (lang) body.language = lang; + if (ttsSel && ttsSel.value) body.tts_provider = ttsSel.value; + const r = await fetch("/api/speak", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!r.ok) throw new Error("HTTP " + r.status); + startReplay([await r.arrayBuffer()], btn); + } catch (e) { + setReplayPlaying(btn, false); + statusEl.textContent = "Vorlesen fehlgeschlagen"; + } +} + +function replayBubble(div) { + const btn = div._replayBtn; + if (btn && btn.dataset.playing) { stopAudio(); setReplayPlaying(btn, false); return; } // Toggle + const info = div._replay || {}; + const text = (div._textEl && div._textEl.textContent) || ""; + if (info.chunks && info.chunks.length) { + startReplay(info.chunks, btn); // Assistent: gecachtes PCM, sofort & gratis + } else if (isDeviceMode()) { + stopAudio(); + deviceSpeakBtn(text, info.lang, btn); // Geräte-TTS: lokal vorlesen, gratis + } else { + speakBubble(text, info.lang, btn); // Server-TTS via /api/speak + } +} + // ---------- Barge-in-Hilfsfunktionen ---------- function stopAudio() { activeSources.forEach((s) => { try { s.stop(); } catch (e) {} }); activeSources = []; + if (TTS_SUPPORTED) { try { speechSynthesis.cancel(); } catch (e) {} } // Geräte-TTS stoppen } function setMicIdle() { @@ -113,8 +315,8 @@ function setMicBusy() { // ---------- Audio-Wiedergabe (PCM s16le) ---------- let audioCtx = null; -function playPcm(chunks, sampleRate) { - if (!chunks.length) return; +function playPcm(chunks, sampleRate, onended) { + if (!chunks.length) { if (onended) onended(); return; } const total = chunks.reduce((n, c) => n + c.byteLength, 0); const merged = new Uint8Array(total); let off = 0; @@ -122,6 +324,7 @@ function playPcm(chunks, sampleRate) { const view = new DataView(merged.buffer); const n = Math.floor(merged.byteLength / 2); audioCtx = audioCtx || new (window.AudioContext || window.webkitAudioContext)(); + if (audioCtx.state === "suspended") audioCtx.resume(); const buf = audioCtx.createBuffer(1, n, sampleRate || 24000); const ch = buf.getChannelData(0); for (let i = 0; i < n; i++) ch[i] = view.getInt16(i * 2, true) / 32768; @@ -129,7 +332,10 @@ function playPcm(chunks, sampleRate) { src.buffer = buf; src.connect(audioCtx.destination); activeSources.push(src); - src.onended = () => { activeSources = activeSources.filter((s) => s !== src); }; + src.onended = () => { + activeSources = activeSources.filter((s) => s !== src); + if (onended) onended(); + }; src.start(); } @@ -148,6 +354,10 @@ function runTurn(path, onopen) { const pcm = []; let answerEl = null; let sampleRate = 24000; + // Sprache dieses Turns (für den Replay je Bubble): Flex folgt erkannter Sprache. + let routeLang = null, routeMode = null, detectedLang = null; + const turnLang = () => + (routeMode === "flex" ? (detectedLang || routeLang) : routeLang) || null; ws.onopen = () => onopen(ws); ws.onerror = () => { statusEl.textContent = "Verbindungsfehler"; resolve(); }; @@ -158,17 +368,25 @@ function runTurn(path, onopen) { let msg; try { msg = JSON.parse(event.data); } catch { return; } switch (msg.type) { + case "ack": + routeLang = (msg.route && msg.route.language) || null; + routeMode = (msg.route && msg.route.language_mode) || null; + break; case "transcript": - if (msg.text) addMessage("user", msg.text); + detectedLang = msg.detected_language || null; + if (msg.text) addMessage("user", msg.text, { lang: turnLang() }); break; case "token": - if (!answerEl) answerEl = addMessage("assistant", ""); - answerEl.textContent += msg.text || ""; + if (!answerEl) answerEl = addMessage("assistant", "", { lang: turnLang() }); + answerEl._textEl.textContent += msg.text || ""; scrollToBottom(); break; case "semantic": - if (!answerEl) answerEl = addMessage("assistant", ""); - if (msg.text) answerEl.textContent = msg.text; + if (!answerEl) answerEl = addMessage("assistant", "", { lang: turnLang() }); + if (msg.text) answerEl._textEl.textContent = msg.text; + // Sprache final festhalten (für Replay) und im Geräte-Modus lokal vorlesen. + if (answerEl._replay) answerEl._replay.lang = turnLang(); + if (isDeviceMode() && msg.text) deviceSpeak(msg.text, turnLang()); scrollToBottom(); break; case "emergency": @@ -176,7 +394,11 @@ function runTurn(path, onopen) { break; case "done": sampleRate = msg.sample_rate || 24000; - playPcm(pcm, sampleRate); + // Geräte-Modus: kein Server-Audio -> nichts abspielen/cachen (Gerät hat schon gesprochen). + if (!isDeviceMode()) { + if (answerEl && answerEl._replay) answerEl._replay.chunks = pcm.slice(); // Replay-Cache + playPcm(pcm, sampleRate); + } statusEl.textContent = ""; scrollToBottom(); ws.close(); @@ -200,10 +422,13 @@ async function sendText(text) { busy = true; setMicBusy(); await ensureSession(); - addMessage("user", text); + // Bei fester Sprache wird der eigene Text in dieser Sprache vorgelesen; bei Flex offen. + const lo = langOverrides(); + addMessage("user", text, { lang: lo.language_mode === "fix" ? lo.language : null }); statusEl.textContent = "denkt …"; + const audioStream = !isDeviceMode(); // Geräte-Modus: kein Server-Audio anfordern await runTurn("/ws/chat", (ws) => { - ws.send(JSON.stringify({ text, stream: true, audio_stream: true, ...overrides() })); + ws.send(JSON.stringify({ text, stream: true, audio_stream: audioStream, ...overrides() })); }); busy = false; setMicIdle(); @@ -211,6 +436,7 @@ async function sendText(text) { formEl.addEventListener("submit", (e) => { e.preventDefault(); + unlockTTS(); // iOS: Geräte-TTS bei der Nutzergeste freischalten const text = promptEl.value; promptEl.value = ""; sendText(text); @@ -272,8 +498,9 @@ async function sendVoice(bytes, fmt = "webm") { setMicBusy(); await ensureSession(); statusEl.textContent = "verarbeite Sprache …"; + const audioStream = !isDeviceMode(); // Geräte-Modus: kein Server-Audio anfordern await runTurn("/ws/voice", (ws) => { - ws.send(JSON.stringify({ type: "start", format: fmt, stream: true, audio_stream: true, ...overrides() })); + ws.send(JSON.stringify({ type: "start", format: fmt, stream: true, audio_stream: audioStream, ...overrides() })); ws.send(bytes); ws.send(JSON.stringify({ type: "end" })); }); @@ -282,6 +509,7 @@ async function sendVoice(bytes, fmt = "webm") { } micBtn.addEventListener("click", () => { + unlockTTS(); // iOS: Geräte-TTS bei der Nutzergeste freischalten if (busy) { // Barge-in: laufende KI-Antwort sofort unterbrechen if (activeWs) activeWs.send(JSON.stringify({ type: "interrupt" })); @@ -297,6 +525,23 @@ micBtn.addEventListener("click", () => { $("#admin-toggle").addEventListener("click", openAdminPanel); +// ---------- Neues Gespräch ---------- +// Startet eine frische Session (neuer session_id) -> der bisherige Verlauf wird nicht +// mehr als LLM-Kontext genutzt. Behebt "Sprache springt nicht um", wenn die History +// in einer anderen Sprache dominiert. Nicht-destruktiv: alte Session bleibt in der DB. +function newConversation() { + const base = currentUserId || "anon"; + sessionId = "web-" + base + "-" + Date.now(); + if (currentUserId) localStorage.setItem(sessionKey(currentUserId), sessionId); + stopAudio(); + messagesEl.innerHTML = ""; + statusEl.textContent = "Neues Gespräch begonnen"; + setTimeout(() => { + if (statusEl.textContent === "Neues Gespräch begonnen") statusEl.textContent = ""; + }, 2500); +} +$("#new-chat").addEventListener("click", newConversation); + // Tag/Nacht: Umschalter (manuell) + automatisch dem System folgen, solange nichts gewählt. const themeBtn = $("#theme-toggle"); const setThemeIcon = () => @@ -321,6 +566,21 @@ if (window.visualViewport) { window.addEventListener("resize", scrollToBottom); promptEl.addEventListener("focus", () => setTimeout(scrollToBottom, 300)); +// Dropdown-Änderungen dauerhaft als Nutzer-Präferenz speichern. +if (langSel) langSel.addEventListener("change", () => savePrefs(langOverrides())); +if (ttsSel) { + ttsSel.addEventListener("change", () => { + unlockTTS(); // Auswahl ist eine Nutzergeste -> Geräte-TTS gleich freischalten + if (ttsSel.value === "device") { + // Geräte-TTS ist geräte-lokal (nicht server-seitig) -> nur lokal merken. + localStorage.setItem(PLAYBACK_KEY, "device"); + } else { + localStorage.removeItem(PLAYBACK_KEY); + savePrefs({ tts_provider: ttsSel.value || null }); // echter Provider -> Server-Pref + } + }); +} + loadMe(); // ═══════════════════════════════════════════════════════════════ @@ -854,13 +1114,14 @@ function buildWortSection(title, section, entries, lang) { wrap.className = "bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4"; const rows = Object.entries(entries).map(([k, v]) => ` - + ${escHtml(k)} → ${escHtml(v)} + data-key="${escHtml(k)}" title="Löschen">✕ `).join(""); @@ -885,8 +1146,24 @@ function buildWortSection(title, section, entries, lang) { `; + const form = wrap.querySelector(".add-form"); + const [keyInp, valInp] = form.querySelectorAll("input"); + const submitBtn = form.querySelector('button[type="submit"]'); + + // Zeile anklicken -> Begriffspaar in die Editier-Felder laden, Button wird zu „Speichern". + wrap.querySelectorAll("tr.row-edit").forEach((tr) => + tr.addEventListener("click", () => { + keyInp.value = tr.dataset.key; + valInp.value = tr.dataset.val; + form.dataset.editKey = tr.dataset.key; // Originalschlüssel merken (für Umbenennen) + submitBtn.textContent = "Speichern"; + valInp.focus(); + }) + ); + wrap.querySelectorAll(".del-btn").forEach((btn) => - btn.addEventListener("click", async () => { + btn.addEventListener("click", async (e) => { + e.stopPropagation(); // nicht zugleich die Zeile zum Bearbeiten laden const key = btn.dataset.key; if (!confirm(`Eintrag „${key}" löschen?`)) return; const res = await adminFetch( @@ -896,14 +1173,22 @@ function buildWortSection(title, section, entries, lang) { }) ); - wrap.querySelector(".add-form").addEventListener("submit", async (e) => { + form.addEventListener("submit", async (e) => { e.preventDefault(); - const [keyInp, valInp] = e.target.querySelectorAll("input"); const key = keyInp.value.trim(); const value = valInp.value.trim(); if (!key || !value) return; + const editKey = form.dataset.editKey || ""; + // Hinzufügen ODER Aktualisieren (gleicher Schlüssel wird überschrieben; Backend sortiert). const res = await adminFetch(`/api/admin/pronunciation/${lang}`, "POST", { section, key, value }); - if (res !== null) { keyInp.value = ""; valInp.value = ""; loadWoerterbuch(); } + if (res === null) return; + // Umbenannt -> alten Schlüssel entfernen (neuer wurde oben bereits angelegt). + if (editKey && editKey !== key) { + await adminFetch( + `/api/admin/pronunciation/${lang}/${section}/${encodeURIComponent(editKey)}`, "DELETE" + ); + } + loadWoerterbuch(); // neu laden -> sortierte Liste, Formular zurück auf „+ Hinzufügen" }); return wrap; diff --git a/app/web/apple-touch-icon.png b/app/web/apple-touch-icon.png new file mode 100644 index 0000000..7cb4f37 Binary files /dev/null and b/app/web/apple-touch-icon.png differ diff --git a/app/web/favicon-32.png b/app/web/favicon-32.png new file mode 100644 index 0000000..70955e7 Binary files /dev/null and b/app/web/favicon-32.png differ diff --git a/app/web/favicon.svg b/app/web/favicon.svg new file mode 100644 index 0000000..751dfb9 --- /dev/null +++ b/app/web/favicon.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/web/index.html b/app/web/index.html index e27ab54..e188d7e 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -4,8 +4,17 @@ Voice Assistant + + + + + diff --git a/config/pronunciation.de.yaml b/config/pronunciation.de.yaml index 265b65c..9b18e97 100644 --- a/config/pronunciation.de.yaml +++ b/config/pronunciation.de.yaml @@ -1,34 +1,17 @@ -# Aussprache-Lexikon (Deutsch) für die TTS-Normalisierung vor Piper. -# -# Dies ist das GRUNDGERÜST – Inhalte werden später schrittweise ergänzt. -# Einträge hier ERWEITERN/ÜBERSCHREIBEN die eingebauten Defaults des Normalizers. -# Wirkt nur bei lokalem TTS (piper, Stufe "full"); Cloud-TTS bleibt unangetastet. -# -# Format: -# abbreviations: ganze Token werden wortgrenzen-sicher ersetzt (Punkt mitschreiben!) -# units: werden nur DIREKT NACH EINER ZAHL ersetzt (z. B. "10 kg") -# terms: Fachwörter / Eigennamen / englische Begriffe -# -# Beispiele (auskommentiert – bei Bedarf aktivieren/ergänzen): - -abbreviations: - # "ggf.": "gegebenenfalls" - # "inkl.": "inklusive" - -units: - # "kWh": "Kilowattstunden" - -# Aussprache-Korrekturen: espeak-ng (in Piper) verkürzt bei einigen Wortformen -# fälschlich einen langen Vokal. Umschreibung mit "h" -> langer Vokal. -# Alle Einträge mit `espeak-ng -v de -x ""` verifiziert (Y:/E:/o: = lang). -# Matching ist case-insensitiv (deckt auch Satzanfänge ab). +abbreviations: {} +units: {} terms: - "strömt": "ströhmt" - "strömte": "ströhmte" - "löst": "löhst" - "tönt": "töhnt" - "bläst": "blähst" - "büßt": "bühßt" - "grüßt": "grühßt" - "Mond": "Mohnd" - # "Linux": "Linuks" # Beispiel für Eigennamen/IT-Begriffe + bläst: blähst + büßt: bühßt + Chat: Tschätt + Dornröschen: Dornröhß-chen + Glycerin: Glüzeriehn + grüßt: grühßt + Iljitsch: Illjitsch + löst: löhst + Mond: Mohnd + Piotr: Pjottr + strömt: ströhmt + strömte: ströhmte + Tchaikovsky: Tschai'kowski + tönt: töhnt diff --git a/config/pronunciation.en.yaml b/config/pronunciation.en.yaml index 7403e2e..b4fed3a 100644 --- a/config/pronunciation.en.yaml +++ b/config/pronunciation.en.yaml @@ -1,17 +1,5 @@ -# Pronunciation lexicon (English) for TTS normalization before piper. -# Grundgerüst – entries here extend/override the normalizer's built-in defaults. -# Only applied for local TTS (piper, level "full"). - -abbreviations: - # "approx.": "approximately" - -units: - # "kWh": "kilowatt hours" - +abbreviations: {} +units: {} terms: - # "SQL": "sequel" - # Name des Systembetreibers: phonetische Anpassung für englische Aussprache. - # "ie" in EN → /aɪ/ ("die"); "Deeter" ergibt /ˈdiːtər/. - # "Schlueter" = eingedeutschte Schreibung, EN-espeak: /ˈʃluːtər/. - "Dieter": "Deeter" - "Schlüter": "Schlueter" + Dieter: Deeter + Schlüter: Shleeter diff --git a/config/pronunciation.fr.yaml b/config/pronunciation.fr.yaml index 6f421ca..0fbab40 100644 --- a/config/pronunciation.fr.yaml +++ b/config/pronunciation.fr.yaml @@ -1,16 +1,5 @@ -# Lexique de prononciation (Français) pour la normalisation TTS avant Piper. -# Format identique à pronunciation.de.yaml. -# Wirkt nur bei lokalem TTS (piper, Stufe "full"). - -abbreviations: - # "etc.": "et cetera" - -units: - # "km": "kilomètres" - +abbreviations: {} +units: {} terms: - # Nom du responsable système — approximation phonétique pour espeak-ng fr. - # FR: "ch"=/ʃ/, "u"=/y/ (= Umlaut ü!), "eur"=/œʁ/ → /ʃlytœʁ/ ≈ allemand /ʃlyːtɐ/. - "Schlüter": "Chluteur" - # "Dieter" → FR espeak donne /djɛtɛʁ/, acceptable; décommenter si trop éloigné. - # "Dieter": "Diéter" + Dieter: Diter + Schlüter: Chluteur diff --git a/config/voices/README.md b/config/voices/README.md new file mode 100644 index 0000000..b01db61 --- /dev/null +++ b/config/voices/README.md @@ -0,0 +1,37 @@ +# Native Referenz-Stimmen (Chatterbox cross-lingual) + +Je Sprache eine kurze Referenz-WAV (~6–8 s). Der Chatterbox-TTS-Provider wählt +sie automatisch nach der Antwortsprache aus (Konvention `.wav`, siehe +`CHATTERBOX_VOICES_DIR`). Das Timbre der Referenz wird cross-lingual auf die +jeweilige Sprache übertragen. Fehlt eine Datei, greift `CHATTERBOX_VOICE` +(persönlicher Klon) bzw. die Standardstimme des Dienstes. + +Deutsch (`de`) hat bewusst **keine** Datei hier — dafür gilt der persönliche +Klon aus `CHATTERBOX_VOICE`. + +## Quelle & Lizenz + +Die Clips stammen aus dem **FLEURS**-Datensatz (Google), Split `validation`: + +| Datei | FLEURS-Config | Sprache | +|-------|---------------|---------| +| en.wav | en_us | Englisch (US) | +| fr.wav | fr_fr | Französisch | +| es.wav | es_419 | Spanisch (Lateinamerika) | +| it.wav | it_it | Italienisch | +| nl.wav | nl_nl | Niederländisch | +| ru.wav | ru_ru | Russisch | +| zh.wav | cmn_hans_cn | Mandarin-Chinesisch | + +Die Clips wurden auf einheitliche Lautheit normalisiert (EBU R128, `loudnorm` +I=-16 LUFS, TP=-1.5 dB) — die FLEURS-Originalpegel waren stark uneinheitlich +(z. B. ru/en/nl deutlich zu leise). Nur Pegelanpassung, kein inhaltlicher Eingriff. + +**Lizenz: CC-BY 4.0** — Namensnennung erforderlich. + +> FLEURS: Conneau et al., "FLEURS: Few-shot Learning Evaluation of Universal +> Representations of Speech" (Google Research). Datensatz: `google/fleurs` +> auf Hugging Face. Lizenz: Creative Commons Attribution 4.0 (CC-BY 4.0). + +Zum Austauschen einfach eine andere `.wav` ablegen (Pfad muss für den +Chatterbox-Dienst lesbar sein; der Provider löst ihn absolut auf). diff --git a/config/voices/en.wav b/config/voices/en.wav new file mode 100644 index 0000000..aae052c Binary files /dev/null and b/config/voices/en.wav differ diff --git a/config/voices/es.wav b/config/voices/es.wav new file mode 100644 index 0000000..0468022 Binary files /dev/null and b/config/voices/es.wav differ diff --git a/config/voices/fr.wav b/config/voices/fr.wav new file mode 100644 index 0000000..e3d9f45 Binary files /dev/null and b/config/voices/fr.wav differ diff --git a/config/voices/it.wav b/config/voices/it.wav new file mode 100644 index 0000000..376845d Binary files /dev/null and b/config/voices/it.wav differ diff --git a/config/voices/nl.wav b/config/voices/nl.wav new file mode 100644 index 0000000..58f4328 Binary files /dev/null and b/config/voices/nl.wav differ diff --git a/config/voices/ru.wav b/config/voices/ru.wav new file mode 100644 index 0000000..44b41e8 Binary files /dev/null and b/config/voices/ru.wav differ diff --git a/config/voices/zh.wav b/config/voices/zh.wav new file mode 100644 index 0000000..5f316d3 Binary files /dev/null and b/config/voices/zh.wav differ diff --git a/tests/test_auth.py b/tests/test_auth.py index 07e31da..2a62e53 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -70,7 +70,7 @@ def test_user_prefs_applied_to_route(monkeypatch): _enable_auth(monkeypatch) class StubTTS: - async def synthesize(self, text, voice=None, audio_format="pcm"): + async def synthesize(self, text, voice=None, audio_format="pcm", language=None): return b"" monkeypatch.setitem(deps.TTS_REGISTRY, "stub-tts", lambda s: StubTTS()) diff --git a/tests/test_chatterbox_tts.py b/tests/test_chatterbox_tts.py index a134cd2..55add06 100644 --- a/tests/test_chatterbox_tts.py +++ b/tests/test_chatterbox_tts.py @@ -77,6 +77,15 @@ def test_empty_text_raises(monkeypatch): def test_ref_voice_selection(): p = ChatterboxTTSProvider("http://x:9999", voice="/default.wav") - assert p._ref_voice(None) == "/default.wav" - assert p._ref_voice("Zephyr") == "/default.wav" # Cloud-Name -> Default - assert p._ref_voice("/custom.wav") == "/custom.wav" # Pfad -> uebernommen + assert p._ref_voice(None, "de") == "/default.wav" + assert p._ref_voice("Zephyr", "de") == "/default.wav" # Cloud-Name -> Default + assert p._ref_voice("/custom.wav", "de") == "/custom.wav" # Pfad -> uebernommen + + +def test_ref_voice_per_language(tmp_path): + # Native Sprach-Referenz gewinnt über die Default-Stimme, fehlt sie -> Default. + (tmp_path / "fr.wav").write_bytes(b"RIFF") + p = ChatterboxTTSProvider("http://x:9999", voice="/default.wav", voices_dir=str(tmp_path)) + assert p._ref_voice(None, "fr") == str(tmp_path / "fr.wav") # native fr-Stimme + assert p._ref_voice(None, "de") == "/default.wav" # keine de.wav -> Default + assert p._ref_voice("/custom.wav", "fr") == "/custom.wav" # expliziter Pfad gewinnt diff --git a/tests/test_endpoints_e2e.py b/tests/test_endpoints_e2e.py index 699717d..8593723 100644 --- a/tests/test_endpoints_e2e.py +++ b/tests/test_endpoints_e2e.py @@ -12,7 +12,7 @@ client = TestClient(app) def _stub_tts(monkeypatch, name="stub-tts", audio=b""): """Registriert einen deterministischen TTS-Stub (kein Netz, kein lokales Binary).""" class StubTTS: - async def synthesize(self, text, voice=None, audio_format="pcm"): + async def synthesize(self, text, voice=None, audio_format="pcm", language=None): return audio monkeypatch.setitem(deps.TTS_REGISTRY, name, lambda s: StubTTS()) return name @@ -63,7 +63,7 @@ def test_chat_per_request_override_and_loopback(monkeypatch): return "Mir geht es gut, danke." class StubTTS: - async def synthesize(self, text, voice=None, audio_format="pcm"): + async def synthesize(self, text, voice=None, audio_format="pcm", language=None): return b"AUDIO" monkeypatch.setitem(deps.LLM_REGISTRY, "stub", lambda s: StubLLM()) diff --git a/tests/test_forward_auth.py b/tests/test_forward_auth.py index 15b6d2a..d83f168 100644 --- a/tests/test_forward_auth.py +++ b/tests/test_forward_auth.py @@ -72,6 +72,37 @@ def test_admin_users_endpoint_gated(forward_auth): def test_static_index_served(): + # Auth aus (LAN-Dev) -> anonymer Nutzer -> Seite wird ausgeliefert. r = client.get("/") assert r.status_code == 200 assert "Voice Assistant" in r.text + + +def test_static_index_gated_without_auth(monkeypatch): + # Auth an, keine Identitaet -> Seite ist gesperrt (kein Login-URL -> 401). + monkeypatch.setattr(settings, "auth_enabled", True) + monkeypatch.setattr(settings, "trusted_auth_header", "") + monkeypatch.setattr(settings, "trusted_auth_cookie", "") + monkeypatch.setattr(settings, "sso_login_url", "") + r = client.get("/", follow_redirects=False) + assert r.status_code == 401 + + +def test_static_index_redirects_to_sso(monkeypatch): + # Auth an, keine Identitaet, Login-URL gesetzt -> Redirect zum SSO-Portal. + monkeypatch.setattr(settings, "auth_enabled", True) + monkeypatch.setattr(settings, "trusted_auth_header", "") + monkeypatch.setattr(settings, "trusted_auth_cookie", "") + monkeypatch.setattr(settings, "sso_login_url", "https://linix.de/yunohost/sso/") + r = client.get("/", follow_redirects=False) + assert r.status_code == 302 + assert r.headers["location"].startswith("https://linix.de/yunohost/sso/") + + +def test_health_open_without_auth(monkeypatch): + # Health-Check bleibt auch bei aktiver Auth ohne Identitaet erreichbar. + monkeypatch.setattr(settings, "auth_enabled", True) + monkeypatch.setattr(settings, "trusted_auth_header", "") + monkeypatch.setattr(settings, "trusted_auth_cookie", "") + r = client.get("/health") + assert r.status_code == 200 diff --git a/tests/test_memory.py b/tests/test_memory.py index fceb038..314afbe 100644 --- a/tests/test_memory.py +++ b/tests/test_memory.py @@ -13,7 +13,7 @@ def _install_stubs(monkeypatch, captured): return f"Antwort auf: {text}" class StubTTS: - async def synthesize(self, text, voice=None, audio_format="pcm"): + async def synthesize(self, text, voice=None, audio_format="pcm", language=None): return b"AUDIO" monkeypatch.setitem(deps.LLM_REGISTRY, "mem", lambda s: MemLLM()) diff --git a/tests/test_quota_safety.py b/tests/test_quota_safety.py index fcc2cd5..0d20ad6 100644 --- a/tests/test_quota_safety.py +++ b/tests/test_quota_safety.py @@ -14,7 +14,7 @@ def _stub_chat(monkeypatch, answer="ok"): return answer class StubTTS: - async def synthesize(self, text, voice=None, audio_format="pcm"): + async def synthesize(self, text, voice=None, audio_format="pcm", language=None): return b"A" monkeypatch.setitem(deps.LLM_REGISTRY, "l", lambda s: StubLLM()) diff --git a/tests/test_realtime.py b/tests/test_realtime.py index a1c4b1b..9f0cc53 100644 --- a/tests/test_realtime.py +++ b/tests/test_realtime.py @@ -51,7 +51,7 @@ def _install_slow_stream(monkeypatch): yield f"t{i} " class StubTTS: - async def synthesize(self, text, voice=None, audio_format="pcm"): + async def synthesize(self, text, voice=None, audio_format="pcm", language=None): return b"A" monkeypatch.setitem(deps.LLM_REGISTRY, "slow", lambda s: SlowLLM()) @@ -93,7 +93,7 @@ def _install_voice_stubs(monkeypatch): return "antwort" class TTS: - async def synthesize(self, text, voice=None, audio_format="pcm"): + async def synthesize(self, text, voice=None, audio_format="pcm", language=None): return b"A" monkeypatch.setitem(deps.STT_REGISTRY, "s", lambda x: STT()) diff --git a/tests/test_resilience.py b/tests/test_resilience.py index 5af6cfd..85c5ad6 100644 --- a/tests/test_resilience.py +++ b/tests/test_resilience.py @@ -82,7 +82,7 @@ def test_config_llm_fallback_applied(monkeypatch): return "rescued" class StubTTS: - async def synthesize(self, text, voice=None, audio_format="pcm"): + async def synthesize(self, text, voice=None, audio_format="pcm", language=None): return b"A" monkeypatch.setitem(deps.LLM_REGISTRY, "bad", lambda s: BadLLM()) @@ -106,7 +106,7 @@ def test_metrics_endpoint_records_requests_and_stages(monkeypatch): return "hi" class StubTTS: - async def synthesize(self, text, voice=None, audio_format="pcm"): + async def synthesize(self, text, voice=None, audio_format="pcm", language=None): return b"A" monkeypatch.setitem(deps.LLM_REGISTRY, "l", lambda s: StubLLM()) diff --git a/tests/test_streaming.py b/tests/test_streaming.py index b388c98..363a912 100644 --- a/tests/test_streaming.py +++ b/tests/test_streaming.py @@ -59,7 +59,7 @@ def _install_streaming(monkeypatch, tokens): yield tok class StubTTS: - async def synthesize(self, text, voice=None, audio_format="pcm"): + async def synthesize(self, text, voice=None, audio_format="pcm", language=None): return b"AUD" monkeypatch.setitem(deps.LLM_REGISTRY, "stream", lambda s: StreamLLM()) @@ -99,7 +99,7 @@ def test_ws_stream_fallback_for_nonstreaming_llm(monkeypatch): return "komplett" class StubTTS: - async def synthesize(self, text, voice=None, audio_format="pcm"): + async def synthesize(self, text, voice=None, audio_format="pcm", language=None): return b"X" monkeypatch.setitem(deps.LLM_REGISTRY, "oc", lambda s: OnlyComplete()) @@ -126,7 +126,7 @@ def test_ws_audio_stream_sends_chunks_per_sentence(monkeypatch): yield tok class CountTTS: - async def synthesize(self, text, voice=None, audio_format="pcm"): + async def synthesize(self, text, voice=None, audio_format="pcm", language=None): tts_calls.append(text) return b"A" * len(tts_calls) diff --git a/tests/test_warmup.py b/tests/test_warmup.py index cdc51af..3ab8426 100644 --- a/tests/test_warmup.py +++ b/tests/test_warmup.py @@ -12,7 +12,7 @@ def test_warmup_loads_piper(monkeypatch): calls = {} class FakePiperTTSProvider: # Name muss zur Typpruefung im warmup passen - async def synthesize(self, text, voice=None, audio_format="pcm"): + async def synthesize(self, text, voice=None, audio_format="pcm", language=None): calls["tts"] = text return b"AUDIO" diff --git a/tests/test_ws.py b/tests/test_ws.py index 5a81041..655b8d2 100644 --- a/tests/test_ws.py +++ b/tests/test_ws.py @@ -16,7 +16,7 @@ def _install_stubs(monkeypatch, captured): return f"Echo: {text}" class StubTTS: - async def synthesize(self, text, voice=None, audio_format="pcm"): + async def synthesize(self, text, voice=None, audio_format="pcm", language=None): return b"WSAUDIO" monkeypatch.setitem(deps.LLM_REGISTRY, "mem", lambda s: MemLLM()) @@ -78,7 +78,7 @@ def _install_voice_stubs(monkeypatch): return f"Antwort zu {text}" class StubTTS: - async def synthesize(self, text, voice=None, audio_format="pcm"): + async def synthesize(self, text, voice=None, audio_format="pcm", language=None): return b"VOICEAUD" monkeypatch.setitem(deps.STT_REGISTRY, "ss", lambda s: StubSTT())