diff --git a/.env.example b/.env.example index 0ed5059..ce07d4d 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/Docs/voice-assistant-architecture.md b/Docs/voice-assistant-architecture.md index 4ccb46f..66b5315 100644 --- a/Docs/voice-assistant-architecture.md +++ b/Docs/voice-assistant-architecture.md @@ -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 diff --git a/README.md b/README.md index d7cafc5..bff10b8 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/app/config.py b/app/config.py index 66afebb..42de5e2 100644 --- a/app/config.py +++ b/app/config.py @@ -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 diff --git a/app/dependencies.py b/app/dependencies.py index 4c8b0ed..7da78ed 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -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 ), diff --git a/app/providers/tts/chatterbox.py b/app/providers/tts/chatterbox.py index abdf298..84d2156 100644 --- a/app/providers/tts/chatterbox.py +++ b/app/providers/tts/chatterbox.py @@ -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 diff --git a/tests/test_chatterbox_tts.py b/tests/test_chatterbox_tts.py new file mode 100644 index 0000000..a134cd2 --- /dev/null +++ b/tests/test_chatterbox_tts.py @@ -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