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:
parent
dc8ac80505
commit
878bf785dd
51 changed files with 985 additions and 202 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue