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

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:
transcript = await orchestrator.stt.transcribe(audio, fmt=fmt, language=route.language)
if route.language_mode == "flex":
# Flex: Sprache auto-erkennen, kein Übersetzen durch Whisper.
transcript, detected_language = await orchestrator.stt.transcribe_detect(
audio, fmt=fmt, language=None
)
# Stimme folgt der erkannten Sprache (Fallback: Routensprache).
effective_voice = piper_voice_for_language(
route.tts_provider, detected_language or route.language
)
else:
# Fix: konfigurierte Sprache an Whisper → Whisper übersetzt automatisch.
transcript = await orchestrator.stt.transcribe(audio, fmt=fmt, language=route.language)
detected_language = None
# Stimme folgt der konfigurierten Sprache.
effective_voice = piper_voice_for_language(route.tts_provider, route.language)
except Exception as exc:
await websocket.send_json({"type": "error", "status": 502, "detail": str(exc)})
return
await websocket.send_json({"type": "transcript", "text": transcript})
await websocket.send_json({
"type": "transcript",
"text": transcript,
"detected_language": detected_language,
})
if not transcript or not transcript.strip():
await websocket.send_json({
"type": "error",
"detail": "Keine Sprache erkannt — bitte erneut sprechen.",
})
return
await _run_turn(websocket, store, user, session_id, route, orchestrator, output, transcript, options)
await _run_turn(
websocket, store, user, session_id, route, orchestrator, output, transcript, options,
detected_language=detected_language, effective_voice=effective_voice,
)
@router.websocket("/ws/chat")