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:
Dieter Schlüter 2026-06-18 09:44:31 +02:00
commit 5f01607b79
7 changed files with 223 additions and 7 deletions

View file

@ -81,6 +81,13 @@ PIPER_VOICES_DIR=~/.local/share/piper/voices # Verzeichnis der .onnx-St
PIPER_VOICE=de_DE-thorsten-high # Stimmmodell (ohne .onnx) oder voller Pfad
TTS_SAMPLE_RATE=24000 # Ziel-Sample-Rate (ffmpeg resampelt bei Bedarf)
# --- Chatterbox-TTS (hohe Qualitaet + Voice-Cloning; eigener Dienst) ---------
# Wählbar via tts_provider=chatterbox. Dienst: deploy/README.md. Langsamer als piper.
# CHATTERBOX_BASE_URL=http://127.0.0.1:9999
# CHATTERBOX_VOICE=/pfad/zu/referenz_stimme.wav # leer = Chatterbox-Standardstimme
# CHATTERBOX_LANG=de
# CHATTERBOX_SPEED=1.0
# --- Resilienz: Fallback-Ketten (kommaseparierte Provider-Namen) ------------
# Faellt der primaere Provider aus, uebernimmt der naechste.
# STT_FALLBACK=faster-whisper

View file

@ -205,8 +205,7 @@ remote" als auch eine **voll-lokale** Konstellation möglich (live verifiziert).
**Platzhalter (Gerüst):** Audio-Endpunkte (`local-default`, `bluetooth`,
`mobile-ws`, `mobile-webrtc`) liefern leere Chunks — nur Auswahl/Lifecycle sind
verdrahtet, kein echtes Hardware-I/O. Der TTS-Provider `chatterbox` ist noch ein
Stub. `transport_router.py` (Ebene 4) existiert, ist aber noch nicht aktiv
verdrahtet, kein echtes Hardware-I/O. `transport_router.py` (Ebene 4) existiert, ist aber noch nicht aktiv
(lokal/remote trägt vorerst der Provider-Name).
## 9. Roadmap / bewusste nächste Schritte
@ -219,7 +218,7 @@ Reihenfolge der Weiterentwicklung:
4. **(weitgehend erledigt)** Echtzeit: WebSocket-Streaming-Chat (`/ws/chat`), **Token-Level-LLM-Streaming (SSE, `stream:true`)**, **Audio-Streaming (chunked TTS satzweise, `audio_stream:true`)**, **Audio-Eingang (`/ws/voice`)**, **Barge-in/Turn-Manager (`interrupt` bricht laufende Antwort ab)** und **VAD-Aeusserungserkennung (energie-basiert, opt-in)** sind umgesetzt. Offen: **echte partielle Live-Transkripte (Streaming-STT-Dienst, wortweise)** und **WebRTC (aiortc)** — beide brauchen schwere Abhaengigkeiten/Dienste. Heute laeuft STT pro Aeusserung.
5. **(weitgehend erledigt)** Resilienz: Fallback-Ketten je Modul (`*_FALLBACK`, Provider faellt aus → naechster) und In-Memory-Metriken (`/api/metrics`: Request/Latenz, Pipeline-Stufen, Fallback/Fehler; JSON + Prometheus). Offen: verteiltes Tracing, Alerting.
6. **(weitgehend erledigt)** Betrieb: Tageskontingent pro Nutzer (`DAILY_REQUEST_LIMIT`, 429) und **zweistufige** Notfall-Eskalation: (1) schnelle Stichwort-Heuristik im Hot-Path (0 Latenz) + (2) **LLM-Klassifikation** (`app/safety/llm_classifier.py`) als Hintergrund-Task, der laeuft, wenn die Heuristik nichts fand — faengt verpasste Formulierungen (z. B. metaphorisch geaeusserte Suizidalitaet, Schlaganfall-Symptome ohne Stichwort) mit Konfidenz-Schwelle, ohne die Antwortlatenz zu erhoehen. Eskalation jeweils -> Log + Metrik (`source`: keyword/llm) + optionaler Webhook + Event. Offen: Telefon-/Angehoerigen-Integration, Abrechnung.
7. **(weitgehend erledigt)** Lokale Provider: STT via `faster-whisper` (`.[local]`) und **TTS via `piper`** (lokales Neural-TTS, **in-process** mit gecachtem Stimmmodell, In-Process-Resampling auf 24000 Hz; lokale Modelle werden beim Start vorgeladen) sind echt — eine **voll-lokale Konstellation** (STT+LLM+TTS lokal, keine API-Kosten, max. Datenschutz) ist damit möglich. Offen: `chatterbox`-TTS (noch Stub), höhere Sprachqualität als piper.
7. **(weitgehend erledigt)** Lokale Provider: STT via `faster-whisper` (`.[local]`) und **TTS via `piper`** (lokales Neural-TTS, **in-process** mit gecachtem Stimmmodell, In-Process-Resampling auf 24000 Hz; lokale Modelle werden beim Start vorgeladen) sind echt — eine **voll-lokale Konstellation** (STT+LLM+TTS lokal, keine API-Kosten, max. Datenschutz) ist damit möglich. **`chatterbox`-TTS** (Resemble AI, eigener GPU-HTTP-Dienst, hohe Qualität + Voice-Cloning) ist als **wählbarer** Provider angebunden (job-basiert: `/speak``/status``/audio`, `no_playback`-Modus liefert nur Bytes). Offen: Streaming-Synthese für niedrigere Latenz.
8. **TransportRouter** als eigene lokal/remote-Achse aktivieren; echte Geräte-Endpunkte (PipeWire/Bluetooth) — heute OS-Ebene.
**Datenschutz (querschnittlich, ab sofort mitdenken):** Senioren-Sprachdaten sind

View file

@ -12,7 +12,7 @@ Praktische Bedienung: [`BEDIENUNGSANLEITUNG.md`](BEDIENUNGSANLEITUNG.md).
## Features
- **Pipeline mit getrennter Semantik/Sprache:** STT → Input-Cleaner → LLM → Spoken-Adapter → TTS-Normalizer → TTS
- **Provider austauschbar** über Registry (OpenRouter remote; lokales STT via faster-whisper `.[local]`; **lokales TTS via piper**; chatterbox-TTS noch Stub)
- **Provider austauschbar** über Registry (OpenRouter remote; lokales STT via faster-whisper `.[local]`; **lokales TTS via piper** (schnell) **und chatterbox** (hohe Qualität + Voice-Cloning, eigener Dienst))
- **Aussprache-Normalisierung** vor dem TTS (Ordinalia/Einheiten/Abkürzungen + YAML-Lexikon, provider-abhängig `TTS_NORMALIZE_LEVEL`); Pflege per `scripts/add_pronunciation.py`
- **Geschichtete Konfiguration** mit Profilen (`local-dev` / `hybrid` / `cloud`)
- **Routing auf jeder Ebene:** Default → Profil → Nutzer → Session → Request
@ -154,6 +154,13 @@ früher startete piper als Subprozess **pro Satz** und zahlte jedes Mal ~2 s Mod
Zusätzlich werden lokale Modelle **beim Serverstart vorgeladen** (Warm-up), sodass auch der
erste Nutzer keinen Kaltstart spürt. Messung (lokales Setup): erster Ton **5,8 s → ~1,6 s**.
**Höhere Sprachqualität — chatterbox (optional):** Für deutlich natürlichere, **klonbare**
Stimmen gibt es den Provider `chatterbox` (Resemble AI, eigener HTTP-Dienst auf GPU, siehe
`deploy/README.md`). Wählbar pro Request/Session via `tts_provider=chatterbox` (`piper` bleibt
der schnelle Default). Chatterbox ist neural und ~echtzeit-langsam → besser für Qualität als
für minimale Latenz. Konfig: `CHATTERBOX_BASE_URL`, `CHATTERBOX_VOICE` (Referenz-WAV fürs
Cloning), `CHATTERBOX_SPEED`.
### Komplett lokal: Profil `local-dev`
`VA_PROFILE=local-dev` betreibt **alle** KI-Module ohne Cloud. Die Route löst auf zu:

View file

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

View file

@ -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
),

View file

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

View file

@ -0,0 +1,82 @@
import io
import wave
import httpx
import pytest
import app.providers.tts.chatterbox as cb
from app.providers.tts.chatterbox import ChatterboxTTSProvider
def _wav(rate=24000, payload=b"\x01\x02" * 2400) -> bytes:
buf = io.BytesIO()
with wave.open(buf, "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(rate)
w.writeframes(payload)
return buf.getvalue()
def _install(monkeypatch, handler):
real = httpx.AsyncClient # echte Klasse sichern (vor dem Patch)
def make_client(*args, **kwargs):
kwargs.pop("timeout", None)
return real(transport=httpx.MockTransport(handler))
monkeypatch.setattr(cb.httpx, "AsyncClient", make_client)
def _ok_handler(status="done", wav_rate=24000):
def handler(request: httpx.Request) -> httpx.Response:
p = request.url.path
if request.method == "POST" and p == "/speak":
return httpx.Response(200, json={"job_id": "j1", "status": "pending"})
if p == "/status":
return httpx.Response(200, json={"recent_jobs": [{"id": "j1", "status": status,
"error": "boom" if status == "error" else None}]})
if p == "/audio/j1":
return httpx.Response(200, content=_wav(wav_rate), headers={"content-type": "audio/wav"})
return httpx.Response(404)
return handler
def test_synthesize_returns_pcm(monkeypatch):
_install(monkeypatch, _ok_handler())
p = ChatterboxTTSProvider("http://x:9999", voice="/ref.wav")
pcm = __import__("asyncio").run(p.synthesize("Hallo Welt"))
assert pcm == b"\x01\x02" * 2400 # 24000 Hz -> kein Resampling, Frames unveraendert
def test_synthesize_wav_format(monkeypatch):
_install(monkeypatch, _ok_handler())
p = ChatterboxTTSProvider("http://x:9999")
wav = __import__("asyncio").run(p.synthesize("Hallo", audio_format="wav"))
assert wav.startswith(b"RIFF") and b"WAVE" in wav[:16]
def test_resample_when_rate_differs(monkeypatch):
_install(monkeypatch, _ok_handler(wav_rate=22050))
p = ChatterboxTTSProvider("http://x:9999", target_rate=24000)
pcm = __import__("asyncio").run(p.synthesize("Hallo"))
assert isinstance(pcm, bytes) and len(pcm) > 0
def test_job_error_raises(monkeypatch):
_install(monkeypatch, _ok_handler(status="error"))
p = ChatterboxTTSProvider("http://x:9999")
with pytest.raises(RuntimeError):
__import__("asyncio").run(p.synthesize("Hallo"))
def test_empty_text_raises(monkeypatch):
p = ChatterboxTTSProvider("http://x:9999")
with pytest.raises(ValueError):
__import__("asyncio").run(p.synthesize(" "))
def test_ref_voice_selection():
p = ChatterboxTTSProvider("http://x:9999", voice="/default.wav")
assert p._ref_voice(None) == "/default.wav"
assert p._ref_voice("Zephyr") == "/default.wav" # Cloud-Name -> Default
assert p._ref_voice("/custom.wav") == "/custom.wav" # Pfad -> uebernommen