"""TTS über den lokalen Chatterbox-HTTP-Service (Resemble AI, hohe Qualitaet + Voice-Cloning). Architektur wie beim llama.cpp-LLM: ein separater Dienst (eigene Conda-Env, GPU) haelt das Modell geladen; dieser Provider ruft ihn per HTTP auf. Der Dienst ist job-basiert: POST /speak -> /status pollen -> GET /audio/{id} (WAV). Wichtig: `no_playback=true` -> der Dienst spielt NICHT lokal ab, sondern liefert nur die WAV (fuer Remote-/Gateway-Nutzung). Chatterbox ist neural und ~Echtzeit langsam -> als QUALITAETS-Provider gedacht (piper bleibt der schnelle Default). """ from __future__ import annotations import asyncio import io import time import wave from pathlib import Path import httpx from app.providers.tts.base import TTSProvider from app.providers.tts.piper import _resample, _wrap_wav class ChatterboxTTSProvider(TTSProvider): def __init__( self, base_url: str = "http://127.0.0.1:9999", voice: str = "", lang: str = "de", speed: float = 1.0, target_rate: int = 24000, timeout: int = 180, voices_dir: str = "", ): self.base_url = base_url.rstrip("/") self.voice = (voice or "").strip() # Pfad zu Referenz-WAV (Voice-Cloning) oder leer self.lang = lang self.speed = speed self.target_rate = int(target_rate) self.timeout = timeout # Absolut auflösen: der Chatterbox-Dienst liest den Pfad mit eigenem CWD. self.voices_dir = Path(voices_dir).expanduser().resolve() if voices_dir else None def _lang_ref(self, lang: str) -> str | None: """Native Referenz-WAV der Sprache (Konvention /.wav), falls vorhanden.""" if not self.voices_dir: return None path = self.voices_dir / f"{lang}.wav" return str(path) if path.exists() else None def _ref_voice(self, voice: str | None, lang: str) -> str | None: # 1. Explizit angefragte Stimme nur, wenn sie wie ein WAV-Pfad aussieht # (Cloud-Stimmennamen wie "Zephyr"/"alloy" ignorieren). cand = (voice or "").strip() if cand.endswith(".wav"): return cand # 2. Native Stimme der jeweiligen Sprache, sonst die konfigurierte Default-Stimme. return self._lang_ref(lang) or self.voice or None # Chatterbox ist mehrsprachig und klont die Stimme cross-lingual: die Referenz-WAV # liefert das Timbre, `lang` die Aussprache. So spricht die Klon-Stimme jede Sprache. _LANG_ALIASES = {"cmn": "zh"} async def synthesize( self, text: str, voice: str | None = None, audio_format: str = "pcm", language: str | None = None, ) -> bytes: if not text or not text.strip(): raise ValueError("TTS input text is empty") # Gesprächssprache bevorzugen; ohne Angabe der konfigurierte Default. lang = (language or self.lang or "de").strip() lang = self._LANG_ALIASES.get(lang, lang) payload = { "text": text.strip(), "lang": lang, "speed": self.speed, "keep_audio": True, "no_playback": True, } ref = self._ref_voice(voice, lang) if ref: payload["voice"] = ref async with httpx.AsyncClient(timeout=self.timeout) as client: resp = await client.post(f"{self.base_url}/speak", json=payload) resp.raise_for_status() job_id = resp.json()["job_id"] wav_bytes = await self._await_audio(client, job_id) pcm, rate = self._wav_to_pcm(wav_bytes) if rate != self.target_rate: pcm = await asyncio.to_thread(_resample, pcm, rate, self.target_rate) if audio_format == "wav": return _wrap_wav(pcm, self.target_rate) return pcm async def _await_audio(self, client: httpx.AsyncClient, job_id: str) -> bytes: """Wartet (via /status) bis der Job fertig ist und laedt dann die WAV.""" deadline = time.monotonic() + self.timeout while True: status = await client.get(f"{self.base_url}/status") status.raise_for_status() match = next( (j for j in status.json().get("recent_jobs", []) if j["id"] == job_id), None, ) if match: if match["status"] == "done": break raise RuntimeError( f"Chatterbox-Job {match['status']}: {match.get('error') or ''}" ) if time.monotonic() > deadline: raise RuntimeError("Chatterbox-Timeout (Job nicht rechtzeitig fertig)") await asyncio.sleep(0.3) audio = await client.get(f"{self.base_url}/audio/{job_id}") if audio.status_code != 200 or not audio.content: raise RuntimeError(f"Chatterbox-Audio {audio.status_code}: {audio.text[:200]}") return audio.content @staticmethod def _wav_to_pcm(wav_bytes: bytes) -> tuple[bytes, int]: with wave.open(io.BytesIO(wav_bytes)) as w: rate = w.getframerate() frames = w.readframes(w.getnframes()) return frames, rate