feat: Geräte-TTS, native Stimmen, Favicon, Auth-Gate, UI-Fixes

Web-UI / TTS:
- Geräte-TTS ("📱 Gerät"): Antwort wird on-device vorgelesen (Web Speech
  API), Server sendet nur Text (text_only) -> spart Bandbreite/Kosten.
  Mobil-Default, geräte-lokale Speicherung, iOS-Autoplay-Freischaltung.
- Vorlese-Symbol (🔊) je Bubble: Hybrid-Replay (Assistent-PCM gecacht,
  Eingabe via /api/speak); SVG-Icon mit kontrastreicher Farbe.
- Kombiniertes Sprachmenü (Flex + feste Sprachen) statt separatem Modus-Menü.
- "Neues Gespräch"-Button (frische Session gegen Sprach-Trägheit).
- Dark-Mode: lesbare <option>-Popups (Kontrast-Fix).
- Favicon (SVG + PNG-Fallbacks) aus mund.png.

TTS-Backend:
- Sprache wird an alle TTS-Provider durchgereicht; Piper-Stimme folgt der
  Sprache; Chatterbox mehrsprachig + cross-lingual.
- Native Referenz-Stimmen je Sprache (config/voices/<lang>.wav, FLEURS CC-BY),
  loudness-normalisiert.

LLM-Sprache:
- Antwort folgt zuverlässig der gewählten Sprache (verstärkte Anweisung +
  Erinnerung an der letzten Nutzer-Nachricht gegen History-Trägheit).

Admin / Auth:
- Wörterbuch: alle 8 Sprachen, Zeilen editierbar, alphabetische Sortierung.
- Web-UI hinter Auth-Gate (Redirect auf SSO_LOGIN_URL / 401); Favicons offen.
- Log-Tab: Hinweis, wenn der systemd-Dienst nicht aktiv ist.
- Einstellungen: Hinweis "pro Nutzer überschreibbar" bei Sprache/Modus/Qualität.

Doku (BEDIENUNGSANLEITUNG.md): Geräte-TTS §6.5.0, Fix/Flex §6.6, native
Stimmen §6.5.3, llama.cpp<->Ollama-Wechsel §4.7, Auth/SSO §7.4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-20 13:12:04 +02:00
commit 878bf785dd
51 changed files with 985 additions and 202 deletions

7
.gitignore vendored
View file

@ -9,6 +9,8 @@ data/
# Generierte Audio-Ausgaben (z. B. chat_client.py) # Generierte Audio-Ausgaben (z. B. chat_client.py)
*.wav *.wav
# Ausnahme: native Referenz-Stimmen je Sprache (versioniert, Feature-Asset)
!config/voices/*.wav
# Python # Python
__pycache__/ __pycache__/
@ -25,3 +27,8 @@ venv/
*.bak *.bak
*.patch *.patch
*.orig *.orig
# Dateien
3
TODO.md

View file

@ -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 ### 4.7 Zwischen llama.cpp und Ollama wechseln
Beide Server können nicht gleichzeitig auf demselben GPU-Speicher laufen. Vor dem Beide nutzen denselben Gateway-Provider `local-openai-compatible` (OpenAI-kompatible
Wechsel muss der jeweils andere Backend-Prozess beendet werden. 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:** **Merkhilfe:**
- llama.cpp = Docker-Container `va_llm` → stoppen mit `make llm-down` - llama.cpp = Docker-Container `va_llm` → `make llm-up` / `make llm-down` (Port 8001)
- Ollama = systemd-Dienst → stoppen mit `sudo systemctl stop ollama` - Ollama = systemd-Dienst → `sudo systemctl start/stop ollama` (Port 11434)
#### Von Ollama → llama.cpp wechseln > **Wichtig:** `.env`-Änderungen werden erst bei einem **Gateway-Neustart** wirksam
> (uvicorn `--reload` lädt nur bei Code-Änderungen neu, nicht bei `.env`). Als Dienst:
```bash > `systemctl --user restart voice-assistant.service`.
# 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
```
#### Von llama.cpp → Ollama wechseln #### Von llama.cpp → Ollama wechseln
```bash ```bash
# 1) llama.cpp stoppen # 1) llama.cpp stoppen (GPU freigeben)
make llm-down make llm-down # alternativ: docker rm -f va_llm
# alternativ direkt:
docker rm -f va_llm
# 2) Ollama starten # 2) Ollama starten und Modell sicherstellen
sudo systemctl start ollama 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 # 3) .env umstellen:
VA_PROFILE=hybrid make run # 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 ### 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 │ │ Nachrichtenverlauf │
@ -700,7 +726,7 @@ https://va.beispiel.de/ ← remote über Reverse-Proxy (alles, inkl. Mikr
│ (Assistent: graue Blase links) │ │ (Assistent: graue Blase links) │
│ │ │ │
├─────────────────────────────┬───────────────────────────┤ ├─────────────────────────────┬───────────────────────────┤
│ Texteingabe … [Senden] │ [🎤] [Stimme ▾] │ │ Texteingabe … [Senden] │ [🎤] │
└─────────────────────────────┴───────────────────────────┘ └─────────────────────────────┴───────────────────────────┘
Statuszeile: „denkt …" / „verarbeite Sprache …" / leer 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" | | **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) | | **🎤 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 | | **☀️ / 🌙** | Tag-/Nacht-Modus; folgt sonst automatisch dem Betriebssystem |
| **⚙️** (Admin) | Öffnet das Admin-Panel — nur für Admin-Nutzer sichtbar (→ § 7.5) | | **⚙️** (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 | | **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) ### 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 #### 6.5.1 Cloud-TTS (OpenRouter) — Stimmen wählen
Das Gateway reicht den Stimmennamen unverändert an OpenRouter weiter. Welche Das Gateway reicht den Stimmennamen unverändert an OpenRouter weiter. Welche
@ -1050,10 +1098,22 @@ Konfiguration in `.env`:
```bash ```bash
CHATTERBOX_BASE_URL=http://127.0.0.1:9999 CHATTERBOX_BASE_URL=http://127.0.0.1:9999
CHATTERBOX_VOICE=/pfad/zu/referenz_stimme.wav # leer = Standardstimme 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_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 `<lang>.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/<lang>.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) #### 6.5.4 Aussprache verbessern (TTS-Normalizer)
Vor dem TTS läuft ein Normalizer, der Ausspracheprobleme des Phonemizers behebt: 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 ```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 ```bash
curl -X PUT $URL/api/me/prefs \ # Feste Sprache (Fix):
-H "Authorization: Bearer $TOKEN" \ curl -X PUT $URL/api/me/prefs -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d '{"language":"en"}' -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 TRUSTED_PROXY_IPS=192.168.179.10
ADMIN_USERS=atoor,dieterschlueter,dschlueter ADMIN_USERS=atoor,dieterschlueter,dschlueter
SSO_LOGOUT_URL=https://linix.de/yunohost/sso/?action=logout 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 # Optional: Signaturprüfung des JWT-Cookies
TRUSTED_AUTH_JWT_SECRET=<hs256-secret aus /etc/yunohost/.ssowat_cookie_secret> TRUSTED_AUTH_JWT_SECRET=<hs256-secret aus /etc/yunohost/.ssowat_cookie_secret>
@ -1574,6 +1664,17 @@ TRUSTED_AUTH_HEADER=X-Remote-User
Vollständige Anleitung: [deploy/README.md](deploy/README.md). 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 <APP>.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 ### 7.5 Admin-Web-Panel
> 🔧 Admin — erreichbar über den **⚙️-Button** im Web-Interface (nur für Admin-Nutzer sichtbar) > 🔧 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 | | Feld | Typ | Bedeutung |
|------|-----|-----------| |------|-----|-----------|
| `text` | string | Eingabe-Text (Pflicht) | | `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 | | `stt_provider` | string | Provider für diese Anfrage |
| `llm_provider` | string | Provider für diese Anfrage | | `llm_provider` | string | Provider für diese Anfrage |
| `tts_provider` | string | Provider für diese Anfrage | | `tts_provider` | string | Provider für diese Anfrage |
| `voice` | string | TTS-Stimme für diese Anfrage | | `voice` | string | TTS-Stimme für diese Anfrage |
| `stream` | bool | LLM-Token-Streaming (nur WebSocket) | | `stream` | bool | LLM-Token-Streaming (nur WebSocket) |
| `audio_stream` | bool | Satzweises Audio-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 ## B.3 Sessions und Routing
@ -2095,7 +2198,7 @@ Body-Felder: `input_endpoint`, `output_endpoint`, `stt_provider`, `llm_provider`
| Methode | Pfad | Beschreibung | | Methode | Pfad | Beschreibung |
|---------|------|--------------| |---------|------|--------------|
| `GET` | `/api/me` | Aktueller Nutzer + Präferenzen | | `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 | | `GET` | `/api/me/memories` | Alle Langzeit-Erinnerungen |
| `POST` | `/api/me/memories` | Erinnerung hinzufügen | | `POST` | `/api/me/memories` | Erinnerung hinzufügen |
| `DELETE` | `/api/me/memories/{id}` | Erinnerung löschen | | `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 | | Fallback-Ketten | § 9.1, Anhang A.3 |
| faster-whisper | § 6.3, Anhang C | | faster-whisper | § 6.3, Anhang C |
| Fehlerbehebung | § 13 | | Fehlerbehebung | § 13 |
| Fix / Flex (Sprachmodus) | § 6.6 |
| Gedächtnis (Sitzung) | § 8.1 | | Gedächtnis (Sitzung) | § 8.1 |
| Geräte-TTS (Web Speech API) | § 6.5.0 |
| Hybrid-Profil | § 3.2 | | Hybrid-Profil | § 3.2 |
| Installation | § 2 | | Installation | § 2 |
| Konfigurationsebenen / Priorität | § 6.1 | | Konfigurationsebenen / Priorität | § 6.1 |
@ -2212,8 +2317,10 @@ in `app/dependencies.py` + Implementierung in `app/providers/`. → [Architektur
| Remote-Zugang / HTTPS / SSO | § 11 | | Remote-Zugang / HTTPS / SSO | § 11 |
| Sachregister | Anhang D | | Sachregister | Anhang D |
| Sitzungsgedächtnis | § 8.1 | | Sitzungsgedächtnis | § 8.1 |
| Sprache wechseln (Fix/Flex) | § 6.6 |
| Sprech-Loop | § 5.2 | | Sprech-Loop | § 5.2 |
| Stimmen (TTS) | § 6.5.1, § 6.5.2 | | Stimmen (TTS) | § 6.5.1, § 6.5.2 |
| Stimme folgt Sprache (Flex) | § 6.6 |
| STT-Einstellungen | § 6.3 | | STT-Einstellungen | § 6.3 |
| Streaming (Audio/Token/VAD) | § 6.8, Anhang B.6 | | Streaming (Audio/Token/VAD) | § 6.8, Anhang B.6 |
| Tests | § 12 | | Tests | § 12 |

View file

@ -16,7 +16,7 @@ router = APIRouter()
_CONFIG_DIR = Path(__file__).resolve().parents[2] / "config" _CONFIG_DIR = Path(__file__).resolve().parents[2] / "config"
_ALLOWED_SECTIONS = {"abbreviations", "units", "terms"} _ALLOWED_SECTIONS = {"abbreviations", "units", "terms"}
_ALLOWED_LANGS = {"de", "en"} _ALLOWED_LANGS = {"de", "en", "fr", "es", "it", "nl", "ru", "zh"}
class PronunciationEntry(BaseModel): 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.") raise HTTPException(status_code=422, detail="key und value duerfen nicht leer sein.")
data = _read_pronunciation(lang) data = _read_pronunciation(lang)
data[entry.section][entry.key.strip()] = entry.value.strip() 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) _write_pronunciation(lang, data)
return {"section": entry.section, "key": entry.key.strip(), "value": entry.value.strip()} 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 return
await websocket.accept() 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( proc = await asyncio.create_subprocess_exec(
"journalctl", "--user", "-f", "-u", "voice-assistant.service", "journalctl", "--user", "-f", "-u", "voice-assistant.service",
"-n", "100", "--no-pager", "-o", "short", "-n", "100", "--no-pager", "-o", "short",

View file

@ -12,6 +12,7 @@ from app.dependencies import (
build_orchestrator, build_orchestrator,
resolve_output_endpoint, resolve_output_endpoint,
get_store, get_store,
piper_voice_for_language,
) )
from app.core.memory_extractor import maybe_schedule_extraction from app.core.memory_extractor import maybe_schedule_extraction
from app.quota import enforce_quota, record_usage, QuotaExceededError from app.quota import enforce_quota, record_usage, QuotaExceededError
@ -48,13 +49,11 @@ async def chat(
"input_endpoint": payload.input_endpoint, "input_endpoint": payload.input_endpoint,
"output_endpoint": payload.output_endpoint, "output_endpoint": payload.output_endpoint,
"language": payload.language, "language": payload.language,
"language_mode": payload.language_mode,
"stt_provider": payload.stt_provider, "stt_provider": payload.stt_provider,
"llm_provider": payload.llm_provider, "llm_provider": payload.llm_provider,
"tts_provider": payload.tts_provider, "tts_provider": payload.tts_provider,
} }
# None -> der gewaehlte TTS-Provider nimmt seinen eigenen Default.
voice = payload.voice
store = get_store() store = get_store()
try: try:
@ -96,6 +95,9 @@ async def chat(
except QuotaExceededError as exc: except QuotaExceededError as exc:
raise HTTPException(status_code=429, detail=str(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: try:
trace, audio = await orchestrator.chat_text( trace, audio = await orchestrator.chat_text(
payload.text, payload.text,
@ -103,6 +105,8 @@ async def chat(
voice=voice, voice=voice,
output=output, output=output,
history=llm_context, history=llm_context,
language_mode=route.language_mode,
text_only=bool(payload.text_only),
) )
except Exception as exc: except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) raise HTTPException(status_code=502, detail=str(exc))

View file

@ -23,9 +23,10 @@ async def get_me(user: User = Depends(require_user)):
@router.put("/me/prefs") @router.put("/me/prefs")
async def set_my_prefs(payload: UserPrefs, user: User = Depends(require_user)): async def set_my_prefs(payload: UserPrefs, user: User = Depends(require_user)):
"""Setzt die dauerhaften Routing-Praeferenzen des Nutzers (nur gesetzte Felder).""" """Setzt dauerhafter Routing-Präferenzen des Nutzers. Merge mit bestehenden Prefs."""
prefs = {k: v for k, v in payload.model_dump().items() if v is not None} new = {k: v for k, v in payload.model_dump().items() if v is not None}
updated = get_store().set_user_prefs(user.id, prefs) merged = {**user.prefs, **new}
updated = get_store().set_user_prefs(user.id, merged)
return {"user_id": updated.id, "prefs": updated.prefs} return {"user_id": updated.id, "prefs": updated.prefs}

View file

@ -11,6 +11,7 @@ from app.dependencies import (
build_orchestrator, build_orchestrator,
resolve_output_endpoint, resolve_output_endpoint,
get_store, get_store,
piper_voice_for_language,
) )
from app.quota import enforce_quota, record_usage, QuotaExceededError from app.quota import enforce_quota, record_usage, QuotaExceededError
from app.schemas import SpeakRequest from app.schemas import SpeakRequest
@ -32,11 +33,10 @@ async def speak(
"language": payload.language, "language": payload.language,
"tts_provider": payload.tts_provider, "tts_provider": payload.tts_provider,
} }
# None -> der gewaehlte TTS-Provider nimmt seinen eigenen Default.
voice = payload.voice
try: try:
route = resolve_route(user, session_id, overrides) 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) orchestrator = build_orchestrator(route)
output = await resolve_output_endpoint(route) output = await resolve_output_endpoint(route)
except SessionOwnershipError as exc: except SessionOwnershipError as exc:

View file

@ -26,6 +26,7 @@ from app.dependencies import (
resolve_route, resolve_route,
build_orchestrator, build_orchestrator,
resolve_output_endpoint, resolve_output_endpoint,
piper_voice_for_language,
) )
from app.store import ANONYMOUS_USER_ID, SessionOwnershipError from app.store import ANONYMOUS_USER_ID, SessionOwnershipError
from app.audio.vad import EnergyVAD from app.audio.vad import EnergyVAD
@ -40,6 +41,7 @@ _OVERRIDE_KEYS = (
"input_endpoint", "input_endpoint",
"output_endpoint", "output_endpoint",
"language", "language",
"language_mode",
"stt_provider", "stt_provider",
"llm_provider", "llm_provider",
"tts_provider", "tts_provider",
@ -62,7 +64,10 @@ async def _resolve(user, session_id, options):
return route, orchestrator, output 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.""" """Faehrt einen Antwort-Turn und streamt die Events an den Client."""
conversation = ( conversation = (
store.get_recent_messages(session_id, settings.history_max_messages) 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()}) await websocket.send_json({"type": "ack", "route": route.as_dict()})
# Ohne explizite Stimme None lassen -> jeder TTS-Provider nimmt SEINEN Default # Stimme: explizit angeforderte gewinnt, sonst die vom Caller (Sprach-Turn)
# (OpenRouter: OPENROUTER_TTS_VOICE, piper: PIPER_VOICE). # vorberechnete sprachpassende Stimme, sonst folgt sie der Routensprache
voice = options.get("voice") # (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")) 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: 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 else settings.audio_stream_default
)
on_token = None on_token = None
if stream: if stream:
@ -141,6 +157,9 @@ async def _run_turn(websocket, store, user, session_id, route, orchestrator, out
history=llm_context, history=llm_context,
on_token=on_token, on_token=on_token,
on_audio=on_audio, on_audio=on_audio,
language_mode=route.language_mode,
detected_language=detected_language,
text_only=text_only,
) )
else: else:
trace, audio = await orchestrator.chat_text( trace, audio = await orchestrator.chat_text(
@ -149,6 +168,9 @@ async def _run_turn(websocket, store, user, session_id, route, orchestrator, out
voice=voice, voice=voice,
output=output, output=output,
history=llm_context, history=llm_context,
language_mode=route.language_mode,
detected_language=detected_language,
text_only=text_only,
) )
except Exception as exc: except Exception as exc:
await websocket.send_json({"type": "error", "status": 502, "detail": str(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( await websocket.send_json(
{"type": "semantic", "text": trace.semantic_response, "spoken": trace.spoken_response} {"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_bytes(audio)
await websocket.send_json({"type": "done", "audio_format": "pcm", "sample_rate": 24000}) 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: except RoutingError as exc:
await websocket.send_json({"type": "error", "status": 422, "detail": str(exc)}) await websocket.send_json({"type": "error", "status": 422, "detail": str(exc)})
return return
try: try:
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) 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: except Exception as exc:
await websocket.send_json({"type": "error", "status": 502, "detail": str(exc)}) await websocket.send_json({"type": "error", "status": 502, "detail": str(exc)})
return 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(): if not transcript or not transcript.strip():
await websocket.send_json({ await websocket.send_json({
"type": "error", "type": "error",
"detail": "Keine Sprache erkannt — bitte erneut sprechen.", "detail": "Keine Sprache erkannt — bitte erneut sprechen.",
}) })
return 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") @router.websocket("/ws/chat")

View file

@ -112,6 +112,7 @@ class Settings(BaseSettings):
openrouter_tts_voice: str = "alloy" openrouter_tts_voice: str = "alloy"
openrouter_llm_model: str = "openai/gpt-4.1-mini" openrouter_llm_model: str = "openai/gpt-4.1-mini"
default_language: str = "de" default_language: str = "de"
default_language_mode: str = "fix"
default_input_endpoint: str = "local-default" default_input_endpoint: str = "local-default"
default_output_endpoint: str = "local-default" default_output_endpoint: str = "local-default"
default_stt_provider: str = "openrouter" default_stt_provider: str = "openrouter"
@ -144,6 +145,9 @@ class Settings(BaseSettings):
chatterbox_lang: str = "de" chatterbox_lang: str = "de"
chatterbox_speed: float = 1.0 chatterbox_speed: float = 1.0
chatterbox_timeout: int = 180 chatterbox_timeout: int = 180
# Verzeichnis mit nativen Referenz-WAVs je Sprache (Konvention <lang>.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") db_path: str = str(BASE_DIR / "data" / "voice-assistant.db")
admin_api_key: str = "" admin_api_key: str = ""
auth_enabled: bool = True auth_enabled: bool = True
@ -161,6 +165,9 @@ class Settings(BaseSettings):
trusted_proxy_ips: str = "" # kommasepariert; IP(s) des Reverse-Proxys trusted_proxy_ips: str = "" # kommasepariert; IP(s) des Reverse-Proxys
admin_users: str = "" # kommaseparierte SSO-Usernamen mit Admin-Rechten admin_users: str = "" # kommaseparierte SSO-Usernamen mit Admin-Rechten
sso_logout_url: str = "" # Logout-Link fuers Frontend (SSO-Portal) 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 history_max_messages: int = 10
# Automatische Erinnerungs-Extraktion: das LLM destilliert dauerhafte Fakten # Automatische Erinnerungs-Extraktion: das LLM destilliert dauerhafte Fakten
# aus dem Gespraech und legt sie als Nutzer-Erinnerungen ab (best-effort, # aus dem Gespraech und legt sie als Nutzer-Erinnerungen ab (best-effort,

View file

@ -78,7 +78,7 @@ class Orchestrator:
spoken, language=language, level=self.normalize_level spoken, language=language, level=self.normalize_level
) )
with _stage("tts"): 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) await self._emit_to_output(audio, output)
return audio return audio
@ -89,35 +89,48 @@ class Orchestrator:
voice: str | None = None, voice: str | None = None,
output=None, output=None,
history: list[dict] | None = None, history: list[dict] | None = None,
language_mode: str = "fix",
detected_language: str | None = None,
text_only: bool = False,
): ):
trace = PipelineTrace() trace = PipelineTrace()
trace.raw_transcript = text trace.raw_transcript = text
trace.cleaned_transcript = await self.input_cleaner.run(text or "") 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"): with _stage("llm"):
trace.semantic_response = await self.llm.complete( trace.semantic_response = await self.llm.complete(
trace.cleaned_transcript or "", trace.cleaned_transcript or "",
history=history, history=history,
language=language, language=effective_language,
) )
if not trace.semantic_response: if not trace.semantic_response:
raise RuntimeError("LLM returned an empty response") raise RuntimeError("LLM returned an empty response")
trace.spoken_response = await self.spoken_adapter.run( trace.spoken_response = await self.spoken_adapter.run(
trace.semantic_response, trace.semantic_response,
language=language, language=effective_language,
) )
trace.tts_ready_text = await self.tts_normalizer.run( trace.tts_ready_text = await self.tts_normalizer.run(
trace.spoken_response, trace.spoken_response,
language=language, language=effective_language,
level=self.normalize_level, 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"): with _stage("tts"):
audio = await self.tts.synthesize( audio = await self.tts.synthesize(
trace.tts_ready_text, trace.tts_ready_text,
voice=voice, voice=voice,
language=effective_language,
) )
await self._emit_to_output(audio, output) await self._emit_to_output(audio, output)
return trace, audio return trace, audio
@ -131,6 +144,9 @@ class Orchestrator:
history: list[dict] | None = None, history: list[dict] | None = None,
on_token=None, on_token=None,
on_audio=None, on_audio=None,
language_mode: str = "fix",
detected_language: str | None = None,
text_only: bool = False,
): ):
"""Wie chat_text, aber gestreamt. """Wie chat_text, aber gestreamt.
@ -142,24 +158,29 @@ class Orchestrator:
trace.raw_transcript = text trace.raw_transcript = text
trace.cleaned_transcript = await self.input_cleaner.run(text or "") 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] = [] audio_parts: list[bytes] = []
async def _emit_sentence(sentence: str) -> None: 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( 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(): if not ready.strip():
return return
chunk = await self.tts.synthesize(ready, voice=voice) chunk = await self.tts.synthesize(ready, voice=voice, language=effective_language)
audio_parts.append(chunk) audio_parts.append(chunk)
await on_audio(chunk) await on_audio(chunk)
parts: list[str] = [] parts: list[str] = []
stream_fn = getattr(self.llm, "stream", None) stream_fn = getattr(self.llm, "stream", None)
if stream_fn is not 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) parts.append(delta)
if on_token: if on_token:
await on_token(delta) await on_token(delta)
@ -167,8 +188,7 @@ class Orchestrator:
for sentence in chunker.feed(delta): for sentence in chunker.feed(delta):
await _emit_sentence(sentence) await _emit_sentence(sentence)
else: else:
# Provider ohne Streaming -> komplette Antwort als ein Token. result = await self.llm.complete(trace.cleaned_transcript or "", history=history, language=effective_language)
result = await self.llm.complete(trace.cleaned_transcript or "", history=history, language=language)
parts.append(result) parts.append(result)
if on_token: if on_token:
await on_token(result) await on_token(result)
@ -182,21 +202,26 @@ class Orchestrator:
trace.spoken_response = await self.spoken_adapter.run( trace.spoken_response = await self.spoken_adapter.run(
trace.semantic_response, trace.semantic_response,
language=language, language=effective_language,
) )
trace.tts_ready_text = await self.tts_normalizer.run( trace.tts_ready_text = await self.tts_normalizer.run(
trace.spoken_response, trace.spoken_response,
language=language, language=effective_language,
level=self.normalize_level, level=self.normalize_level,
) )
if text_only:
return trace, b""
if chunker: if chunker:
tail = chunker.flush() tail = chunker.flush()
if tail: if tail:
await _emit_sentence(tail) await _emit_sentence(tail)
audio = b"".join(audio_parts) audio = b"".join(audio_parts)
else: 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) await self._emit_to_output(audio, output)
return trace, audio return trace, audio

View file

@ -77,6 +77,7 @@ TTS_REGISTRY = {
s.chatterbox_speed, s.chatterbox_speed,
s.tts_sample_rate, s.tts_sample_rate,
s.chatterbox_timeout, s.chatterbox_timeout,
voices_dir=s.chatterbox_voices_dir,
), ),
"piper": lambda s: PiperTTSProvider( "piper": lambda s: PiperTTSProvider(
s.piper_bin, s.piper_voices_dir, s.piper_voice, s.tts_sample_rate s.piper_bin, s.piper_voices_dir, s.piper_voice, s.tts_sample_rate
@ -148,8 +149,35 @@ ROUTE_KEYS = (
"llm_provider", "llm_provider",
"tts_provider", "tts_provider",
"language", "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 @dataclass
class ResolvedRoute: class ResolvedRoute:
@ -159,6 +187,7 @@ class ResolvedRoute:
llm_provider: str llm_provider: str
tts_provider: str tts_provider: str
language: str language: str
language_mode: str = "fix"
def as_dict(self) -> dict: def as_dict(self) -> dict:
return { return {
@ -168,6 +197,7 @@ class ResolvedRoute:
"llm_provider": self.llm_provider, "llm_provider": self.llm_provider,
"tts_provider": self.tts_provider, "tts_provider": self.tts_provider,
"language": self.language, "language": self.language,
"language_mode": self.language_mode,
} }
@ -208,6 +238,7 @@ def resolve_route(
"llm_provider": cfg.default_llm_provider, "llm_provider": cfg.default_llm_provider,
"tts_provider": cfg.default_tts_provider, "tts_provider": cfg.default_tts_provider,
"language": cfg.default_language, "language": cfg.default_language,
"language_mode": cfg.default_language_mode,
} }
user_prefs = user.prefs if user is not None else {} user_prefs = user.prefs if user is not None else {}

View file

@ -5,7 +5,10 @@ from pathlib import Path
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles 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.core.warmup import warmup_local_models
from app.metrics import metrics from app.metrics import metrics
from app.api.health import router as health_router from app.api.health import router as health_router
@ -48,6 +51,32 @@ async def record_metrics(request: Request, call_next):
return response 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(health_router)
app.include_router(chat_router, prefix="/api") app.include_router(chat_router, prefix="/api")
app.include_router(transcribe_router, prefix="/api") app.include_router(transcribe_router, prefix="/api")

View file

@ -35,6 +35,19 @@ class FallbackSTTProvider(_Chain):
self._on_error(name) self._on_error(name)
raise last_exc 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): class FallbackLLMProvider(_Chain):
async def complete(self, text, history=None, session_id=None, language=None) -> str: async def complete(self, text, history=None, session_id=None, language=None) -> str:
@ -75,11 +88,13 @@ class FallbackLLMProvider(_Chain):
class FallbackTTSProvider(_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 last_exc = None
for index, (name, provider) in enumerate(self.entries): for index, (name, provider) in enumerate(self.entries):
try: 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: if index > 0:
self._on_fallback() self._on_fallback()
return result return result

View file

@ -16,11 +16,28 @@ _LANG_NAMES: dict[str, str] = {
def lang_instruction(language: str | None) -> str | None: 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: if not language:
return None return None
name = _LANG_NAMES.get(language, language) 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: def sse_delta(line: str) -> str | None:

View file

@ -2,7 +2,12 @@ from collections.abc import AsyncIterator
import httpx 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): class LocalOpenAICompatibleLLM(LLMProvider):
@ -50,7 +55,8 @@ class LocalOpenAICompatibleLLM(LLMProvider):
if system_parts: if system_parts:
messages.append({"role": "system", "content": "\n\n".join(system_parts)}) messages.append({"role": "system", "content": "\n\n".join(system_parts)})
messages.extend(rest) 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 return messages
def _payload( def _payload(

View file

@ -2,7 +2,12 @@ from collections.abc import AsyncIterator
import httpx 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 = """ SYSTEM_PROMPT = """
@ -69,7 +74,8 @@ class OpenRouterLLMProvider(LLMProvider):
messages = [{"role": "system", "content": system_content}] messages = [{"role": "system", "content": system_content}]
if history: if history:
messages.extend(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 return messages
async def complete( async def complete(

View file

@ -1,5 +1,17 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
class STTProvider(ABC): class STTProvider(ABC):
@abstractmethod @abstractmethod
async def transcribe(self, audio_bytes: bytes, fmt: str, language: str | None = None) -> str: ... 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

View file

@ -38,12 +38,24 @@ class FasterWhisperProvider(STTProvider):
self.device = device or settings.faster_whisper_device self.device = device or settings.faster_whisper_device
self.compute_type = compute_type or settings.faster_whisper_compute_type 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) model = _load_model(self.model_size, self.device, self.compute_type)
segments, _info = model.transcribe(io.BytesIO(audio_bytes), language=language) segments, info = model.transcribe(io.BytesIO(audio_bytes), language=language)
return "".join(segment.text for segment in segments).strip() 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: 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: if not audio_bytes:
raise ValueError("STT input audio is empty") raise ValueError("STT input audio is empty")
return await asyncio.to_thread(self._transcribe_sync, audio_bytes, language) return await asyncio.to_thread(self._transcribe_sync, audio_bytes, language)

View file

@ -52,4 +52,48 @@ class OpenRouterSTTProvider(STTProvider):
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
raise RuntimeError(f"OpenRouter STT transport error: {exc}") from 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")

View file

@ -2,4 +2,10 @@ from abc import ABC, abstractmethod
class TTSProvider(ABC): class TTSProvider(ABC):
@abstractmethod @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: ...

View file

@ -15,6 +15,7 @@ import asyncio
import io import io
import time import time
import wave import wave
from pathlib import Path
import httpx import httpx
@ -31,6 +32,7 @@ class ChatterboxTTSProvider(TTSProvider):
speed: float = 1.0, speed: float = 1.0,
target_rate: int = 24000, target_rate: int = 24000,
timeout: int = 180, timeout: int = 180,
voices_dir: str = "",
): ):
self.base_url = base_url.rstrip("/") self.base_url = base_url.rstrip("/")
self.voice = (voice or "").strip() # Pfad zu Referenz-WAV (Voice-Cloning) oder leer self.voice = (voice or "").strip() # Pfad zu Referenz-WAV (Voice-Cloning) oder leer
@ -38,32 +40,51 @@ class ChatterboxTTSProvider(TTSProvider):
self.speed = speed self.speed = speed
self.target_rate = int(target_rate) self.target_rate = int(target_rate)
self.timeout = timeout 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: def _lang_ref(self, lang: str) -> str | None:
# Eine angefragte Stimme gilt nur, wenn sie wie ein WAV-Pfad aussieht """Native Referenz-WAV der Sprache (Konvention <voices_dir>/<lang>.wav), falls vorhanden."""
# (Cloud-Stimmennamen wie "Zephyr"/"alloy" ignorieren -> Default nehmen). 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() cand = (voice or "").strip()
if cand.endswith(".wav"): if cand.endswith(".wav"):
return cand 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( async def synthesize(
self, self,
text: str, text: str,
voice: str | None = None, voice: str | None = None,
audio_format: str = "pcm", audio_format: str = "pcm",
language: str | None = None,
) -> bytes: ) -> bytes:
if not text or not text.strip(): if not text or not text.strip():
raise ValueError("TTS input text is empty") 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 = { payload = {
"text": text.strip(), "text": text.strip(),
"lang": self.lang, "lang": lang,
"speed": self.speed, "speed": self.speed,
"keep_audio": True, "keep_audio": True,
"no_playback": True, "no_playback": True,
} }
ref = self._ref_voice(voice) ref = self._ref_voice(voice, lang)
if ref: if ref:
payload["voice"] = ref payload["voice"] = ref

Some files were not shown because too many files have changed in this diff Show more