Jamulix: Optimierungen übernehmen, lokale Voice-Modelle ignorieren
This commit is contained in:
parent
e99508a727
commit
91b9ef3374
12 changed files with 555 additions and 94 deletions
|
|
@ -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")
|
||||
|
|
|
|||
142
app/providers/tts/cartesia.py
Normal file
142
app/providers/tts/cartesia.py
Normal 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
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue