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

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