diff --git a/.gitignore b/.gitignore index e00fe4c..479d860 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,7 @@ venv/ 3 TODO.md + +# Local voice models +config/voices/*.onnx +config/voices/*.onnx.json diff --git a/app/api/chat.py b/app/api/chat.py index f56a0db..b0a3fa6 100644 --- a/app/api/chat.py +++ b/app/api/chat.py @@ -13,6 +13,7 @@ from app.dependencies import ( resolve_output_endpoint, get_store, piper_voice_for_language, + voice_for_route, ) from app.core.memory_extractor import maybe_schedule_extraction from app.quota import enforce_quota, record_usage, QuotaExceededError @@ -96,7 +97,7 @@ async def chat( 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) + voice = payload.voice or voice_for_route(route.tts_provider, route.language, route.voice_gender) try: trace, audio = await orchestrator.chat_text( diff --git a/app/api/config.py b/app/api/config.py index 6b1f1e5..c939955 100644 --- a/app/api/config.py +++ b/app/api/config.py @@ -1,3 +1,4 @@ +import httpx from fastapi import APIRouter from app.config import settings, active_profile @@ -12,14 +13,60 @@ from app.dependencies import ( router = APIRouter() +async def _chatterbox_reachable() -> bool: + try: + async with httpx.AsyncClient(timeout=1.5) as client: + r = await client.get(f"{settings.chatterbox_base_url.rstrip('/')}/status") + return r.status_code < 500 + except Exception: + return False + + +async def _cartesia_available() -> bool: + key = settings.cartesia_api_key.strip() + voice_id = settings.cartesia_voice_id.strip() + if not key or not voice_id: + return False + try: + async with httpx.AsyncClient(timeout=5.0) as client: + r = await client.get( + "https://api.cartesia.ai/voices", + headers={"X-API-Key": key, "Cartesia-Version": "2024-06-10"}, + ) + return r.status_code < 400 + except Exception: + return False + + +async def _openrouter_tts_available() -> bool: + """Prüft ob das konfigurierte TTS-Modell auf OpenRouter via /audio/speech erreichbar ist.""" + key = settings.openrouter_api_key.strip() + model = settings.openrouter_tts_model.strip() + if not key or not model: + return False + try: + async with httpx.AsyncClient(timeout=5.0) as client: + r = await client.post( + "https://openrouter.ai/api/v1/audio/speech", + headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"}, + json={"model": model, "input": "Test", "voice": settings.openrouter_tts_voice or "alloy", + "response_format": "mp3"}, + ) + # 200 = Audio OK, 4xx mit "does not exist" = Modell fehlt + if r.status_code == 200: + return True + body = r.text + return "does not exist" not in body and r.status_code < 500 + except Exception: + return False + + @router.get("/config") async def get_config(): - """Zeigt aktives Profil, aufgeloeste Default-Route und verfuegbare Bausteine. - - Bewusst OHNE Secrets - API-Keys werden nur als 'gesetzt/nicht gesetzt' gemeldet. - """ + """Zeigt aktives Profil, aufgeloeste Default-Route und verfuegbare Bausteine.""" route = resolve_route() audio_router = get_audio_router() + chatterbox_ok, openrouter_tts_ok, cartesia_ok = await _chatterbox_reachable(), await _openrouter_tts_available(), await _cartesia_available() return { "profile": active_profile(), "app_env": settings.app_env, @@ -30,6 +77,9 @@ async def get_config(): "tts_providers": sorted(TTS_REGISTRY), "input_endpoints": [c.model_dump() for c in await audio_router.list_inputs()], "output_endpoints": [c.model_dump() for c in await audio_router.list_outputs()], + "chatterbox_reachable": chatterbox_ok, + "openrouter_tts_available": openrouter_tts_ok, + "cartesia_available": cartesia_ok, }, "secrets": { "openrouter_api_key_set": bool(settings.openrouter_api_key.strip()), diff --git a/app/api/speak.py b/app/api/speak.py index a39e8d1..e93428f 100644 --- a/app/api/speak.py +++ b/app/api/speak.py @@ -12,6 +12,7 @@ from app.dependencies import ( resolve_output_endpoint, get_store, piper_voice_for_language, + voice_for_route, ) from app.quota import enforce_quota, record_usage, QuotaExceededError from app.schemas import SpeakRequest @@ -36,7 +37,7 @@ async def speak( 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) + voice = payload.voice or voice_for_route(route.tts_provider, route.language, route.voice_gender) orchestrator = build_orchestrator(route) output = await resolve_output_endpoint(route) except SessionOwnershipError as exc: diff --git a/app/api/ws.py b/app/api/ws.py index 18d03ad..4199f5c 100644 --- a/app/api/ws.py +++ b/app/api/ws.py @@ -27,6 +27,7 @@ from app.dependencies import ( build_orchestrator, resolve_output_endpoint, piper_voice_for_language, + voice_for_route, ) from app.store import ANONYMOUS_USER_ID, SessionOwnershipError from app.audio.vad import EnergyVAD @@ -122,7 +123,7 @@ async def _run_turn( elif effective_voice is not None: voice = effective_voice else: - voice = piper_voice_for_language(route.tts_provider, route.language) + voice = voice_for_route(route.tts_provider, route.language, route.voice_gender) 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")) @@ -233,15 +234,15 @@ async def _voice_turn(websocket, store, user, session_id, audio, fmt, options): 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 + effective_voice = voice_for_route( + route.tts_provider, detected_language or route.language, route.voice_gender ) 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) + effective_voice = voice_for_route(route.tts_provider, route.language, route.voice_gender) except Exception as exc: await websocket.send_json({"type": "error", "status": 502, "detail": str(exc)}) return diff --git a/app/config.py b/app/config.py index 14ce23f..bb2109a 100644 --- a/app/config.py +++ b/app/config.py @@ -141,6 +141,11 @@ class Settings(BaseSettings): piper_voice: str = "de_DE-thorsten-high" # Stimmmodell-Name (ohne .onnx) oder voller Pfad tts_sample_rate: int = 24000 # Ziel-Sample-Rate (das Gateway erwartet 24000 Hz) # --- Chatterbox-TTS (hohe Qualitaet + Voice-Cloning, eigener HTTP-Dienst) - + # --- Cartesia TTS (Cloud, sehr niedrige Latenz) ------------------------- + cartesia_api_key: str = "" + cartesia_voice_id: str = "" # UUID aus cartesia.ai/voices (Fallback) + cartesia_tts_model: str = "sonic-turbo" + cartesia_voices: str = "" # "lang:gender:uuid:name" je Zeile, # = Kommentar # sonic-2 | sonic-multilingual chatterbox_base_url: str = "http://127.0.0.1:9999" chatterbox_voice: str = "" # Pfad zu Referenz-WAV (Voice-Cloning) oder leer chatterbox_lang: str = "de" diff --git a/app/dependencies.py b/app/dependencies.py index 9179ae5..b58407d 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -18,6 +18,7 @@ from app.providers.stt.faster_whisper import FasterWhisperProvider from app.providers.llm.local_openai_compatible import LocalOpenAICompatibleLLM from app.providers.llm.openrouter import OpenRouterLLMProvider from app.providers.tts.openrouter import OpenRouterTTSProvider +from app.providers.tts.cartesia import CartesiaTTSProvider from app.providers.tts.chatterbox import ChatterboxTTSProvider from app.providers.tts.piper import PiperTTSProvider from app.providers.fallback import ( @@ -80,6 +81,10 @@ TTS_REGISTRY = { s.chatterbox_timeout, voices_dir=s.chatterbox_voices_dir, ), + "cartesia": lambda s: CartesiaTTSProvider( + s.cartesia_api_key, s.cartesia_voice_id, s.cartesia_tts_model, s.tts_sample_rate, + voices_str=s.cartesia_voices, + ), "piper": lambda s: PiperTTSProvider( s.piper_bin, s.piper_voices_dir, s.piper_voice, s.tts_sample_rate ), @@ -151,32 +156,74 @@ ROUTE_KEYS = ( "tts_provider", "language", "language_mode", + "voice_gender", ) -# Stimm-Auswahl nach Sprache: (effektive) Sprache → beste Piper-Stimme. -# Gilt in BEIDEN Modi — die gesprochene Stimme folgt immer der Antwortsprache. +# Stimm-Auswahl nach Sprache: (effektive) Sprache → beste Piper-Stimme (gender-agnostisch). 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", + "pt": "pt_BR-faber-medium", + "pl": "pl_PL-darkman-medium", + "ar": "ar_JO-kareem-medium", "ru": "ru_RU-irina-medium", "zh": "zh_CN-huayan-medium", "cmn": "zh_CN-huayan-medium", } +# Geschlechtsspezifische Piper-Stimmen für Sprachen mit m+f Varianten. +# Präzedenz: PIPER_VOICE_GENDERED > LANG_TO_PIPER_VOICE. +# ES sharvard ist ein Multi-Speaker-Modell: #0=M, #1=F (speaker_id_map: {'M':0,'F':1}) +PIPER_VOICE_GENDERED: dict[tuple[str, str], str] = { + ("de", "f"): "de_DE-kerstin-low", + ("de", "m"): "de_DE-thorsten-high", + ("en", "f"): "en_US-amy-medium", + ("en", "m"): "en_US-lessac-high", + ("fr", "f"): "fr_FR-siwis-medium", + ("fr", "m"): "fr_FR-tom-medium", + ("es", "f"): "es_ES-sharvard-medium#1", + ("es", "m"): "es_ES-sharvard-medium#0", + ("it", "f"): "it_IT-paola-medium", + ("it", "m"): "it_IT-riccardo-x_low", + ("ru", "f"): "ru_RU-irina-medium", + ("ru", "m"): "ru_RU-ruslan-medium", + ("pl", "f"): "pl_PL-gosia-medium", + ("pl", "m"): "pl_PL-darkman-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). + """Kompatibilitäts-Alias für voice_for_route ohne gender.""" + return voice_for_route(tts_provider, language, "any") - 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. + +def voice_for_route( + tts_provider: str, language: str | None, voice_gender: str = "any" +) -> str | None: + """Liefert den voice-Parameter für synthesize() passend zum Provider. + + - piper: Piper-Stimmmodell-Name; geschlechtsspezifisch wenn Varianten vorhanden + - cartesia: Geschlechts-Code ("m"/"f"/"any") → CartesiaTTSProvider löst UUID auf + - andere: None (Provider nutzt seinen eigenen Default) """ if tts_provider == "piper" and language: - return LANG_TO_PIPER_VOICE.get(language) + lang = language.lower() + gender = (voice_gender or "any").lower() + # Geschlechtsspezifische Stimme: bevorzugte Richtung, dann Gegenteil + if gender in ("m", "f"): + candidates = [gender, "f" if gender == "m" else "m"] + else: # "any" → weiblich bevorzugt (konsistent mit Cartesia) + candidates = ["f", "m"] + for g in candidates: + voice = PIPER_VOICE_GENDERED.get((lang, g)) + if voice: + return voice + return LANG_TO_PIPER_VOICE.get(lang) + if tts_provider == "cartesia": + return voice_gender or "any" return None @@ -189,6 +236,7 @@ class ResolvedRoute: tts_provider: str language: str language_mode: str = "fix" + voice_gender: str = "any" def as_dict(self) -> dict: return { @@ -199,6 +247,7 @@ class ResolvedRoute: "tts_provider": self.tts_provider, "language": self.language, "language_mode": self.language_mode, + "voice_gender": self.voice_gender, } diff --git a/app/providers/llm/openrouter.py b/app/providers/llm/openrouter.py index 408afa7..f29864a 100644 --- a/app/providers/llm/openrouter.py +++ b/app/providers/llm/openrouter.py @@ -1,3 +1,5 @@ +import asyncio +import time from collections.abc import AsyncIterator import httpx @@ -9,6 +11,37 @@ from app.providers.llm.base import ( sse_delta, ) +_RETRY_STATUS = {404, 429, 500, 502, 503} + + +def _retry_wait(status: int, headers: httpx.Headers, attempt: int, body: str = "") -> float: + """Wartezeit: JSON retry_after_seconds > Retry-After > X-RateLimit-Reset > exponentiell.""" + if status == 429: + if body: + try: + import json as _json + d = _json.loads(body) + secs = d.get("error", {}).get("metadata", {}).get("retry_after_seconds") + if secs: + return max(1.0, min(float(secs) + 0.5, 120.0)) + except Exception: + pass + retry_after = headers.get("Retry-After") + if retry_after: + try: + return max(1.0, min(float(retry_after) + 0.5, 120.0)) + except ValueError: + pass + reset_ms = headers.get("X-RateLimit-Reset") + if reset_ms: + try: + wait = (int(reset_ms) / 1000.0) - time.time() + 0.5 + return max(1.0, min(wait, 120.0)) + except ValueError: + pass + return 30.0 + return 2.0 * attempt + SYSTEM_PROMPT = """ You are a voice assistant for spoken conversations with older adults. @@ -52,9 +85,10 @@ Always optimize your answer for listening, not for reading. class OpenRouterLLMProvider(LLMProvider): - def __init__(self, api_key: str, model: str): + def __init__(self, api_key: str, model: str, max_retries: int = 5): self.api_key = (api_key or "").strip() self.model = (model or "").strip() + self.max_retries = max(1, max_retries) def _build_messages( self, text: str, history: list[dict] | None, language: str | None = None @@ -74,7 +108,6 @@ class OpenRouterLLMProvider(LLMProvider): messages = [{"role": "system", "content": system_content}] if history: messages.extend(history) - # 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 @@ -89,40 +122,52 @@ class OpenRouterLLMProvider(LLMProvider): "model": self.model, "messages": self._build_messages(text, history, language=language), } - timeout = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0) + last_error: Exception | None = None - async with httpx.AsyncClient(timeout=timeout) as client: + for attempt in range(1, self.max_retries + 1): + async with httpx.AsyncClient(timeout=timeout) as client: + try: + response = await client.post( + "https://openrouter.ai/api/v1/chat/completions", + headers={ + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + }, + json=payload, + ) + if response.status_code in _RETRY_STATUS and attempt < self.max_retries: + last_error = RuntimeError( + f"OpenRouter LLM error {response.status_code}: {response.text}" + ) + await asyncio.sleep(_retry_wait(response.status_code, response.headers, attempt, response.text)) + continue + response.raise_for_status() + except httpx.HTTPStatusError as exc: + last_error = RuntimeError( + f"OpenRouter LLM error {exc.response.status_code}: {exc.response.text}" + ) + if exc.response.status_code in _RETRY_STATUS and attempt < self.max_retries: + await asyncio.sleep(_retry_wait(exc.response.status_code, exc.response.headers, attempt, exc.response.text)) + continue + raise last_error from exc + except httpx.TimeoutException as exc: + raise RuntimeError("OpenRouter LLM timeout") from exc + except httpx.HTTPError as exc: + raise RuntimeError(f"OpenRouter LLM transport error: {exc}") from exc + + data = response.json() try: - response = await client.post( - "https://openrouter.ai/api/v1/chat/completions", - 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 LLM error {exc.response.status_code}: {exc.response.text}" - ) from exc - except httpx.TimeoutException as exc: - raise RuntimeError("OpenRouter LLM timeout") from exc - except httpx.HTTPError as exc: - raise RuntimeError(f"OpenRouter LLM transport error: {exc}") from exc + content = data["choices"][0]["message"]["content"] + except (KeyError, IndexError, TypeError) as exc: + raise RuntimeError(f"Unexpected OpenRouter LLM response: {data}") from exc - data = response.json() + if not content or not str(content).strip(): + raise RuntimeError("OpenRouter LLM returned empty content") - try: - content = data["choices"][0]["message"]["content"] - except (KeyError, IndexError, TypeError) as exc: - raise RuntimeError(f"Unexpected OpenRouter LLM response: {data}") from exc + return str(content).strip() - if not content or not str(content).strip(): - raise RuntimeError("OpenRouter LLM returned empty content") - - return str(content).strip() + raise last_error or RuntimeError("OpenRouter LLM: all attempts failed") async def stream( self, @@ -137,30 +182,42 @@ class OpenRouterLLMProvider(LLMProvider): "stream": True, } timeout = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0) + last_error: Exception | None = None - async with httpx.AsyncClient(timeout=timeout) as client: - try: - async with client.stream( - "POST", - "https://openrouter.ai/api/v1/chat/completions", - headers={ - "Authorization": f"Bearer {self.api_key}", - "Content-Type": "application/json", - }, - json=payload, - ) as response: - if response.status_code >= 400: - body = await response.aread() - raise RuntimeError( - f"OpenRouter LLM error {response.status_code}: " - f"{body.decode(errors='replace')}" - ) - async for line in response.aiter_lines(): - delta = sse_delta(line) - if delta: - yield delta - except httpx.TimeoutException as exc: - raise RuntimeError("OpenRouter LLM timeout") from exc - except httpx.HTTPError as exc: - raise RuntimeError(f"OpenRouter LLM transport error: {exc}") from exc + for attempt in range(1, self.max_retries + 1): + async with httpx.AsyncClient(timeout=timeout) as client: + try: + async with client.stream( + "POST", + "https://openrouter.ai/api/v1/chat/completions", + headers={ + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + }, + json=payload, + ) as response: + if response.status_code in _RETRY_STATUS and attempt < self.max_retries: + body = await response.aread() + last_error = RuntimeError( + f"OpenRouter LLM error {response.status_code}: " + f"{body.decode(errors='replace')}" + ) + await asyncio.sleep(_retry_wait(response.status_code, response.headers, attempt, body.decode(errors='replace'))) + continue + if response.status_code >= 400: + body = await response.aread() + raise RuntimeError( + f"OpenRouter LLM error {response.status_code}: " + f"{body.decode(errors='replace')}" + ) + async for line in response.aiter_lines(): + delta = sse_delta(line) + if delta: + yield delta + return + except httpx.TimeoutException as exc: + raise RuntimeError("OpenRouter LLM timeout") from exc + except httpx.HTTPError as exc: + raise RuntimeError(f"OpenRouter LLM transport error: {exc}") from exc + raise last_error or RuntimeError("OpenRouter LLM: all attempts failed") diff --git a/app/providers/tts/cartesia.py b/app/providers/tts/cartesia.py new file mode 100644 index 0000000..fbcbe21 --- /dev/null +++ b/app/providers/tts/cartesia.py @@ -0,0 +1,142 @@ +import httpx + +from app.providers.tts.base import TTSProvider + +_API_URL = "https://api.cartesia.ai/tts/bytes" +_API_VERSION = "2024-06-10" + +# Sprachen, die sonic-turbo unterstützt (Stand 2026-06). +_LANG_MAP = { + "de": "de", "en": "en", "fr": "fr", "es": "es", + "zh": "zh", "cmn": "zh", "pt": "pt", "ja": "ja", "ko": "ko", "hi": "hi", +} + +# sonic-turbo unterstützt diese Sprachen nicht — Fallback auf Piper empfohlen. +_UNSUPPORTED_LANGS = {"ar", "it", "ru", "nl", "pl", "sv", "tr"} + +_LANG_NAMES = { + "ar": "Arabisch", "it": "Italienisch", "ru": "Russisch", + "nl": "Niederländisch", "pl": "Polnisch", "sv": "Schwedisch", "tr": "Türkisch", +} + +_GENDER_CODES = {"m", "f", "any"} + + +def _parse_voice_map(voices_str: str) -> dict[tuple[str, str], dict]: + """Parst 'lang:gender:uuid:name'-Zeilen in ein (lang,gender)→{uuid,name}-Dict.""" + result = {} + for line in (voices_str or "").splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + parts = line.split(":", 3) + if len(parts) != 4: + continue + lang, gender, uuid, name = [p.strip() for p in parts] + if lang and gender in ("m", "f") and uuid: + result[(lang, gender)] = {"uuid": uuid, "name": name} + return result + + +class CartesiaTTSProvider(TTSProvider): + def __init__( + self, + api_key: str, + voice_id: str, + model: str = "sonic-2", + sample_rate: int = 24000, + voices_str: str = "", + ): + self.api_key = (api_key or "").strip() + self.default_voice_id = (voice_id or "").strip() + self.model = (model or "sonic-2").strip() + self.sample_rate = int(sample_rate) + self.voice_map = _parse_voice_map(voices_str) + + def _resolve_voice_id(self, language: str | None, gender_hint: str | None) -> str: + """Wählt UUID aus Voice-Map. Fallback-Kette: f→m→any→default_voice_id.""" + lang = (language or "de").lower() + gender = (gender_hint or "any").strip().lower() + + # Präferenz-Reihenfolge: "any" → weiblich bevorzugt + if gender == "any" or gender not in ("m", "f"): + candidates = ["f", "m"] + elif gender == "f": + candidates = ["f", "m"] + else: + candidates = ["m", "f"] + + for g in candidates: + entry = self.voice_map.get((lang, g)) + if entry: + return entry["uuid"] + + return self.default_voice_id + + async def synthesize( + self, + text: str, + voice: str | None = None, + audio_format: str = "pcm", + language: str | None = None, + ) -> bytes: + if not self.api_key: + raise ValueError("CARTESIA_API_KEY is empty") + if not text or not text.strip(): + raise ValueError("TTS input text is empty") + + # voice ist entweder ein Geschlechts-Code ("m"/"f"/"any") oder eine direkte UUID + if voice and voice not in _GENDER_CODES: + voice_id = voice # direkte UUID-Übergabe + else: + voice_id = self._resolve_voice_id(language, voice) + + if not voice_id: + raise ValueError("CARTESIA_VOICE_ID ist nicht konfiguriert — bitte UUID in voice-assistant.toml eintragen") + + lang_key = (language or "de").lower() + if lang_key in _UNSUPPORTED_LANGS: + lang_label = _LANG_NAMES.get(lang_key, lang_key) + raise RuntimeError( + f"Cartesia unterstützt {lang_label} ({lang_key.upper()}) noch nicht als TTS-Sprache" + " — bitte Piper oder einen anderen Provider wählen" + ) + lang_code = _LANG_MAP.get(lang_key, "de") + + payload = { + "model_id": self.model, + "transcript": text.strip(), + "voice": {"mode": "id", "id": voice_id}, + "output_format": { + "container": "raw", + "encoding": "pcm_s16le", + "sample_rate": self.sample_rate, + }, + "language": lang_code, + } + + timeout = httpx.Timeout(connect=10.0, read=60.0, write=10.0, pool=10.0) + async with httpx.AsyncClient(timeout=timeout) as client: + try: + response = await client.post( + _API_URL, + headers={ + "X-API-Key": self.api_key, + "Cartesia-Version": _API_VERSION, + "Content-Type": "application/json", + }, + json=payload, + ) + response.raise_for_status() + except httpx.HTTPStatusError as exc: + raise RuntimeError( + f"Cartesia TTS error {exc.response.status_code}: {exc.response.text}" + ) from exc + except httpx.TimeoutException as exc: + raise RuntimeError("Cartesia TTS timeout") from exc + except httpx.HTTPError as exc: + raise RuntimeError(f"Cartesia TTS transport error: {exc}") from exc + + if not response.content: + raise RuntimeError("Cartesia TTS returned empty audio") + return response.content diff --git a/app/providers/tts/piper.py b/app/providers/tts/piper.py index e1d102c..f19cd04 100644 --- a/app/providers/tts/piper.py +++ b/app/providers/tts/piper.py @@ -104,6 +104,17 @@ class PiperTTSProvider(TTSProvider): except (OSError, KeyError, ValueError, TypeError): return self.target_rate # Konfig unlesbar -> Resampling überspringen + @staticmethod + def _parse_speaker(voice: str | None) -> tuple[str | None, int | None]: + """Trennt 'stimme#speaker_id' auf. Liefert (stimme, int_id) oder (stimme, None).""" + if voice and "#" in voice: + name, sid = voice.rsplit("#", 1) + try: + return name, int(sid) + except ValueError: + pass + return voice, None + async def synthesize( self, text: str, @@ -114,9 +125,10 @@ class PiperTTSProvider(TTSProvider): if not text or not text.strip(): raise ValueError("TTS input text is empty") - model, config = self._model_paths(voice) + voice_name, speaker_id = self._parse_speaker(voice) + model, config = self._model_paths(voice_name) pcm, native_rate = await asyncio.to_thread( - self._synthesize_sync, str(model), str(config), text.strip() + self._synthesize_sync, str(model), str(config), text.strip(), speaker_id ) if native_rate != self.target_rate: pcm = await asyncio.to_thread(_resample, pcm, native_rate, self.target_rate) @@ -125,26 +137,30 @@ class PiperTTSProvider(TTSProvider): return _wrap_wav(pcm, self.target_rate) return pcm - def _synthesize_sync(self, model: str, config: str, text: str) -> tuple[bytes, int]: + def _synthesize_sync(self, model: str, config: str, text: str, speaker_id: int | None = None) -> tuple[bytes, int]: if _PIPER_LIB: - voice = _load_voice(model) + from piper.config import SynthesisConfig + piper_voice = _load_voice(model) + syn_cfg = SynthesisConfig(speaker_id=speaker_id) if speaker_id is not None else None pcm = bytearray() rate = self.target_rate - for chunk in voice.synthesize(text): + for chunk in piper_voice.synthesize(text, syn_config=syn_cfg): pcm += chunk.audio_int16_bytes rate = chunk.sample_rate if not pcm: raise RuntimeError("Piper lieferte kein Audio") return bytes(pcm), rate # Fallback: piper-Binary (Subprozess pro Aufruf, langsamer). - return self._run_piper_binary(model, config, text) + return self._run_piper_binary(model, config, text, speaker_id) - def _run_piper_binary(self, model: str, config: str, text: str) -> tuple[bytes, int]: + def _run_piper_binary(self, model: str, config: str, text: str, speaker_id: int | None = None) -> tuple[bytes, int]: import subprocess if not (shutil.which(self.bin_path) or Path(self.bin_path).exists()): raise RuntimeError(f"Piper-Binary nicht gefunden: {self.bin_path}") cmd = [self.bin_path, "-m", model, "-c", config, "--output-raw"] + if speaker_id is not None: + cmd += ["--speaker", str(speaker_id)] proc = subprocess.run(cmd, input=text.encode("utf-8"), capture_output=True) if proc.returncode != 0: detail = proc.stderr.decode("utf-8", "replace").strip()[:300] diff --git a/app/web/app.js b/app/web/app.js index a7ce676..234caa9 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -20,6 +20,29 @@ function langOverrides() { } // Nutzer-Präferenzen als Request-Overrides für jeden Turn. +// Geschlechtspräferenz: 'm', 'f', oder 'any' (= weiblich bevorzugt) +let _voiceGender = 'f'; + +function applyGenderChoice(gender) { + // 'any' aus alten Prefs → weiblich als Standardwert + _voiceGender = (gender === 'm') ? 'm' : 'f'; + const btnM = document.getElementById('gender-m'); + const btnF = document.getElementById('gender-f'); + if (!btnM || !btnF) return; + const mActive = (_voiceGender === 'm'); + const fActive = (_voiceGender === 'f'); + // Aktiv: blauer Hintergrund + weiße Schrift + // Inaktiv: kein Hintergrund + dunkle Schrift (explizit, damit kein Vererbungsproblem) + btnM.classList.toggle('bg-blue-600', mActive); + btnM.classList.toggle('text-white', mActive); + btnM.classList.toggle('border-blue-600', mActive); + btnM.classList.toggle('text-slate-700', !mActive); + btnF.classList.toggle('bg-blue-600', fActive); + btnF.classList.toggle('text-white', fActive); + btnF.classList.toggle('border-blue-600', fActive); + btnF.classList.toggle('text-slate-700', !fActive); +} + function overrides() { const out = { ...langOverrides() }; if (isDeviceMode()) { @@ -27,6 +50,7 @@ function overrides() { } else if (ttsSel && ttsSel.value) { out.tts_provider = ttsSel.value; } + out.voice_gender = _voiceGender; return out; } @@ -45,7 +69,7 @@ async function savePrefs(obj) { const TTS_SUPPORTED = typeof window !== "undefined" && "speechSynthesis" in window; const LANG_BCP47 = { de: "de-DE", en: "en-US", fr: "fr-FR", es: "es-ES", - it: "it-IT", nl: "nl-NL", ru: "ru-RU", zh: "zh-CN", + it: "it-IT", pt: "pt-BR", pl: "pl-PL", ar: "ar-SA", ru: "ru-RU", zh: "zh-CN", }; function isMobile() { @@ -179,6 +203,69 @@ function refreshDevicePreset() { syncPresetUI(); } +// Graut "Hohe Qualität" (Chatterbox) aus, wenn kein GPU-Backend erreichbar ist. +async function refreshChatterboxPreset() { + const btn = document.querySelector('.tts-preset[data-tts="chatterbox"]'); + if (!btn || !ttsSel) return; + try { + const cfg = await fetch("/api/config", { cache: "no-store" }).then(r => r.json()); + const available = cfg.available?.chatterbox_reachable === true; + btn.disabled = !available; + btn.classList.toggle("opacity-50", !available); + btn.classList.toggle("cursor-not-allowed", !available); + const sub = btn.querySelector(".tts-sub"); + if (sub) sub.textContent = available + ? "Chatterbox · natürliche Stimme" + : "Chatterbox · kein GPU-Backend erreichbar"; + if (!available && ttsSel.value === "chatterbox") { + ttsSel.value = "piper"; + syncPresetUI(); + } + } catch (e) { /* ignorieren */ } +} + +// Graut "Cartesia" aus, wenn API-Key oder Voice-ID fehlt. +async function refreshCartesiaPreset() { + const btn = document.querySelector('.tts-preset[data-tts="cartesia"]'); + if (!btn || !ttsSel) return; + try { + const cfg = await fetch("/api/config", { cache: "no-store" }).then(r => r.json()); + const available = cfg.available?.cartesia_available === true; + btn.disabled = !available; + btn.classList.toggle("opacity-50", !available); + btn.classList.toggle("cursor-not-allowed", !available); + const sub = btn.querySelector(".tts-sub"); + if (sub) sub.textContent = available + ? "Cartesia Sonic · natürlich & schnell" + : "Cartesia · kein API-Key konfiguriert"; + if (!available && ttsSel.value === "cartesia") { + ttsSel.value = "piper"; + syncPresetUI(); + } + } catch (e) { /* ignorieren */ } +} + +// Graut "Cloud" (OpenRouter TTS) aus, wenn das TTS-Modell nicht verfügbar ist. +async function refreshOpenrouterTtsPreset() { + const btn = document.querySelector('.tts-preset[data-tts="openrouter"]'); + if (!btn || !ttsSel) return; + try { + const cfg = await fetch("/api/config", { cache: "no-store" }).then(r => r.json()); + const available = cfg.available?.openrouter_tts_available === true; + btn.disabled = !available; + btn.classList.toggle("opacity-50", !available); + btn.classList.toggle("cursor-not-allowed", !available); + const sub = btn.querySelector(".tts-sub"); + if (sub) sub.textContent = available + ? "OpenRouter · benötigt Internet" + : "OpenRouter · kein TTS-Modell verfügbar"; + if (!available && ttsSel.value === "openrouter") { + ttsSel.value = "piper"; + syncPresetUI(); + } + } catch (e) { /* ignorieren */ } +} + // Markiert das aktive Ton-Preset (Häkchen + Rahmen) passend zu #tts. function syncPresetUI() { const val = ttsSel ? ttsSel.value : ""; @@ -241,9 +328,10 @@ function loadMe() { // Flex-Modus -> "flex", sonst die feste Sprache (Fallback: Flex). langSel.value = prefs.language_mode === "flex" ? "flex" - : (prefs.language || "flex"); + : (prefs.language || "de"); } applyPlaybackChoice(prefs.tts_provider); + applyGenderChoice(prefs.voice_gender || 'f'); return me; } catch (e) { $("#identity").textContent = "Verbindung fehlgeschlagen"; @@ -291,6 +379,7 @@ function addMessage(role, text, meta = {}) { div.className = BUBBLE[role] || BUBBLE.assistant; const span = document.createElement("span"); span.className = "bubble-text"; + span.dir = "auto"; span.textContent = text; div.appendChild(span); div._textEl = span; @@ -684,7 +773,25 @@ if (ttsSel) { }); } + +// Gender-Buttons +document.addEventListener("DOMContentLoaded", () => { + const btnM = document.getElementById("gender-m"); + const btnF = document.getElementById("gender-f"); + if (btnM) btnM.addEventListener("click", () => { + applyGenderChoice("m"); + savePrefs({ voice_gender: "m" }); + }); + if (btnF) btnF.addEventListener("click", () => { + applyGenderChoice("f"); + savePrefs({ voice_gender: "f" }); + }); +}); + loadMe(); +refreshChatterboxPreset(); +refreshCartesiaPreset(); +refreshOpenrouterTtsPreset(); // ═══════════════════════════════════════════════════════════════ // ADMIN PANEL @@ -1570,12 +1677,12 @@ const FIELD_META = { default_stt_provider: { ui: "combo", opts: ["openrouter","faster-whisper"], test: "stt" }, default_llm_provider: { ui: "combo", opts: ["openrouter","local-openai-compatible"], test: "llm" }, default_tts_provider: { ui: "combo", opts: ["openrouter","piper","chatterbox"], test: "tts" }, - default_language: { ui: "combo", opts: ["de","en","fr","es","it","nl"], - labels: { de:"Deutsch",en:"Englisch",fr:"Französisch",es:"Spanisch",it:"Italienisch",nl:"Niederländisch" } }, + default_language: { ui: "combo", opts: ["de","en","fr","es","it","pt","pl","ar"], + labels: { de:"Deutsch",en:"Englisch",fr:"Französisch",es:"Spanisch",it:"Italienisch",pt:"Portugiesisch",pl:"Polnisch",ar:"Arabisch" } }, openrouter_llm_model: { ui: "combo", opts: ["google/gemini-3.1-flash-lite","openai/gpt-4.1-mini","google/gemini-2.5-flash","anthropic/claude-haiku-4-5-20251001","meta-llama/llama-3.3-70b-instruct"], test: "llm" }, openrouter_tts_model: { ui: "combo", opts: ["google/gemini-3.1-flash-tts-preview","openai/gpt-4o-mini-tts"], test: "tts", testProvider: "openrouter" }, openrouter_tts_voice: { ui: "combo", opts: ["Zephyr","Puck","Charon","Kore","Fenrir","Leda","Orus","Aoede","Callirrhoe","Enceladus","Algieba","Despina","Achernar"], test: "tts", testProvider: "openrouter" }, - piper_voice: { ui: "combo", opts: ["de_DE-thorsten-high","en_US-ryan-high","en_US-lessac-high","en_GB-cori-high","es_ES-sharvard-medium","fr_FR-siwis-medium","it_IT-paola-medium","nl_NL-mls-medium","ru_RU-irina-medium","zh_CN-huayan-medium"], test: "tts", testProvider: "piper" }, + piper_voice: { ui: "combo", opts: ["de_DE-thorsten-high","de_DE-kerstin-low","en_US-lessac-high","en_US-amy-medium","en_US-ryan-high","en_GB-cori-high","fr_FR-siwis-medium","fr_FR-tom-medium","es_ES-sharvard-medium","it_IT-paola-medium","it_IT-riccardo-x_low","pt_BR-faber-medium","pl_PL-gosia-medium","pl_PL-darkman-medium","ar_JO-kareem-medium","ru_RU-irina-medium","ru_RU-ruslan-medium","zh_CN-huayan-medium"], test: "tts", testProvider: "piper" }, local_llm_system_prompt: { ui: "textarea", test: "llm" }, local_llm_temperature: { ui: "range", min: 0, max: 2, step: 0.1, test: "llm" }, local_llm_top_p: { ui: "range", min: 0, max: 1, step: 0.05, test: "llm" }, diff --git a/app/web/index.html b/app/web/index.html index fafb5b0..ea91fc4 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -163,7 +163,9 @@ Französisch (fr) Spanisch (es) Italienisch (it) - Niederländisch (nl) + Portugiesisch (pt) + Polnisch (pl) + Arabisch (ar) Russisch (ru) Chinesisch (zh) @@ -225,7 +227,9 @@ 🇫🇷 FR 🇪🇸 ES 🇮🇹 IT - 🇳🇱 NL + 🇧🇷 PT + 🇵🇱 PL + 🇸🇦 AR 🇷🇺 RU 🇨🇳 ZH @@ -244,6 +248,7 @@ Im Gerät Server Beste Qualität + Cartesia Cloud @@ -278,6 +283,13 @@ Chatterbox · natürliche Stimme ✓ + + 🎙 + Cartesia + Cartesia Sonic · natürlich & schnell + ✓ + ☁ @@ -288,6 +300,22 @@ + + + + Stimme + + + ♂ Männlich + + + ♀ Weiblich + + + + 🎤 -
Stimme