feat(tts): Chatterbox-Provider (hohe Qualitaet + Voice-Cloning) anbinden
Loest #7. Der chatterbox-Stub wird durch eine echte Anbindung an den lokalen Chatterbox-HTTP-Dienst ersetzt (POST /speak -> /status pollen -> GET /audio, WAV). no_playback=true -> der Dienst spielt nicht lokal ab, liefert nur Bytes. WAV->PCM (24 kHz, Resampling bei Bedarf). Waehlbar via tts_provider=chatterbox; piper bleibt der schnelle Default (chatterbox ist ~echtzeit-langsam, dafuer klonbare Stimme). - config: CHATTERBOX_BASE_URL/_VOICE/_LANG/_SPEED/_TIMEOUT; Registry verdrahtet - Tests: gemockter httpx (Synthese, WAV/Resample, Job-Fehler, Stimmenwahl) - Doku: README, Architektur (#7), deploy/README (GPU-Pinning per UUID, no_playback) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
77026bff1b
commit
5f01607b79
7 changed files with 223 additions and 7 deletions
|
|
@ -138,6 +138,12 @@ class Settings(BaseSettings):
|
|||
piper_voices_dir: str = str(Path.home() / ".local" / "share" / "piper" / "voices")
|
||||
piper_voice: str = "de_DE-thorsten-high" # Stimmmodell-Name (ohne .onnx) oder voller Pfad
|
||||
tts_sample_rate: int = 24000 # Ziel-Sample-Rate (das Gateway erwartet 24000 Hz)
|
||||
# --- Chatterbox-TTS (hohe Qualitaet + Voice-Cloning, eigener HTTP-Dienst) -
|
||||
chatterbox_base_url: str = "http://127.0.0.1:9999"
|
||||
chatterbox_voice: str = "" # Pfad zu Referenz-WAV (Voice-Cloning) oder leer
|
||||
chatterbox_lang: str = "de"
|
||||
chatterbox_speed: float = 1.0
|
||||
chatterbox_timeout: int = 180
|
||||
db_path: str = str(BASE_DIR / "data" / "voice-assistant.db")
|
||||
admin_api_key: str = ""
|
||||
auth_enabled: bool = True
|
||||
|
|
|
|||
|
|
@ -69,7 +69,14 @@ TTS_REGISTRY = {
|
|||
"openrouter": lambda s: OpenRouterTTSProvider(
|
||||
s.openrouter_api_key, s.openrouter_tts_model, s.openrouter_tts_voice
|
||||
),
|
||||
"chatterbox": lambda s: ChatterboxTTSProvider(),
|
||||
"chatterbox": lambda s: ChatterboxTTSProvider(
|
||||
s.chatterbox_base_url,
|
||||
s.chatterbox_voice,
|
||||
s.chatterbox_lang,
|
||||
s.chatterbox_speed,
|
||||
s.tts_sample_rate,
|
||||
s.chatterbox_timeout,
|
||||
),
|
||||
"piper": lambda s: PiperTTSProvider(
|
||||
s.piper_bin, s.piper_voices_dir, s.piper_voice, s.tts_sample_rate
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,113 @@
|
|||
"""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
|
||||
|
||||
import httpx
|
||||
|
||||
from app.providers.tts.base import TTSProvider
|
||||
from app.providers.tts.piper import _resample, _wrap_wav
|
||||
|
||||
|
||||
class ChatterboxTTSProvider(TTSProvider):
|
||||
async def synthesize(self, text: str, voice: str | None = None, audio_format: str = "pcm") -> bytes:
|
||||
return b""
|
||||
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,
|
||||
):
|
||||
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
|
||||
|
||||
def _ref_voice(self, voice: str | None) -> str | None:
|
||||
# Eine angefragte Stimme gilt nur, wenn sie wie ein WAV-Pfad aussieht
|
||||
# (Cloud-Stimmennamen wie "Zephyr"/"alloy" ignorieren -> Default nehmen).
|
||||
cand = (voice or "").strip()
|
||||
if cand.endswith(".wav"):
|
||||
return cand
|
||||
return self.voice or None
|
||||
|
||||
async def synthesize(
|
||||
self,
|
||||
text: str,
|
||||
voice: str | None = None,
|
||||
audio_format: str = "pcm",
|
||||
) -> bytes:
|
||||
if not text or not text.strip():
|
||||
raise ValueError("TTS input text is empty")
|
||||
|
||||
payload = {
|
||||
"text": text.strip(),
|
||||
"lang": self.lang,
|
||||
"speed": self.speed,
|
||||
"keep_audio": True,
|
||||
"no_playback": True,
|
||||
}
|
||||
ref = self._ref_voice(voice)
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue