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