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

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