Jamulix: Optimierungen übernehmen, lokale Voice-Modelle ignorieren

This commit is contained in:
Dieter Schlüter 2026-06-24 22:01:38 +02:00
commit 91b9ef3374
12 changed files with 555 additions and 94 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -163,7 +163,9 @@
<option value="fr">Französisch (fr)</option>
<option value="es">Spanisch (es)</option>
<option value="it">Italienisch (it)</option>
<option value="nl">Niederländisch (nl)</option>
<option value="pt">Portugiesisch (pt)</option>
<option value="pl">Polnisch (pl)</option>
<option value="ar">Arabisch (ar)</option>
<option value="ru">Russisch (ru)</option>
<option value="zh">Chinesisch (zh)</option>
</select>
@ -225,7 +227,9 @@
<option value="fr">🇫🇷 FR</option>
<option value="es">🇪🇸 ES</option>
<option value="it">🇮🇹 IT</option>
<option value="nl">🇳🇱 NL</option>
<option value="pt">🇧🇷 PT</option>
<option value="pl">🇵🇱 PL</option>
<option value="ar">🇸🇦 AR</option>
<option value="ru">🇷🇺 RU</option>
<option value="zh">🇨🇳 ZH</option>
</select>
@ -244,6 +248,7 @@
<option value="device">Im Gerät</option>
<option value="piper">Server</option>
<option value="chatterbox">Beste Qualität</option>
<option value="cartesia">Cartesia</option>
<option value="openrouter">Cloud</option>
</select>
@ -278,6 +283,13 @@
<span class="block text-xs text-slate-500 dark:text-slate-400">Chatterbox · natürliche Stimme</span></span>
<span class="check text-blue-600 dark:text-blue-400 font-bold opacity-0"></span>
</button>
<button type="button" data-tts="cartesia"
class="tts-preset w-full flex items-center gap-3 rounded-lg border border-slate-200 dark:border-slate-700 px-3 py-2 text-left hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors">
<span class="text-lg shrink-0">🎙</span>
<span class="flex-1 min-w-0"><span class="block text-sm font-medium">Cartesia</span>
<span class="tts-sub block text-xs text-slate-500 dark:text-slate-400">Cartesia Sonic · natürlich &amp; schnell</span></span>
<span class="check text-blue-600 dark:text-blue-400 font-bold opacity-0"></span>
</button>
<button type="button" data-tts="openrouter"
class="tts-preset w-full flex items-center gap-3 rounded-lg border border-slate-200 dark:border-slate-700 px-3 py-2 text-left hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors">
<span class="text-lg shrink-0"></span>
@ -288,6 +300,22 @@
</div>
</div>
<!-- Geschlechtspräferenz (wirkt bei Cartesia) -->
<div id="gender-pref-row">
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400 mb-1.5 px-1">Stimme</p>
<div class="flex gap-2">
<button type="button" id="gender-m"
class="gender-btn flex-1 rounded-lg border border-slate-200 dark:border-slate-700 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50 dark:hover:bg-slate-700/50">
♂ Männlich
</button>
<button type="button" id="gender-f"
class="gender-btn flex-1 rounded-lg border border-slate-200 dark:border-slate-700 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50 dark:hover:bg-slate-700/50">
♀ Weiblich
</button>
</div>
</div>
<!-- Aktionen -->
<div class="border-t border-slate-100 dark:border-slate-700 pt-2 space-y-1">
<button id="new-chat" type="button"
@ -314,7 +342,7 @@
<form id="prompt-form" class="flex items-center gap-2 w-full max-w-3xl mx-auto">
<button type="button" id="mic" title="Mikrofon" aria-label="Mikrofon"
class="shrink-0 h-11 w-11 grid place-items-center rounded-full bg-emerald-600 text-white text-lg">🎤</button>
<input id="prompt" type="text" autocomplete="off" placeholder="Nachricht …"
<input id="prompt" type="text" autocomplete="off" dir="auto" placeholder="Nachricht …"
class="flex-1 min-w-0 rounded-xl border border-slate-300 dark:border-slate-600 bg-transparent px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500" />
<button type="submit" id="send" title="Senden" aria-label="Senden"
class="shrink-0 h-11 w-11 grid place-items-center rounded-full bg-blue-600 hover:bg-blue-700 text-white transition-colors">