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)
*.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

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
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 `<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)
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=<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).
**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
> 🔧 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 |

View file

@ -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",

View file

@ -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))

View file

@ -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}

View file

@ -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:

View file

@ -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:
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")

View file

@ -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 <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")
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,

View file

@ -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

View file

@ -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 {}

View file

@ -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")

View file

@ -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

View file

@ -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:

View file

@ -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(

View file

@ -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(

View file

@ -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

View file

@ -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)

View file

@ -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")

View file

@ -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: ...

View file

@ -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 <voices_dir>/<lang>.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

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