From cca423dac367f472e8434a113ba4545c7b091708 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Wed, 17 Jun 2026 22:11:20 +0200 Subject: [PATCH] feat(tts): echtes lokales TTS via piper (kein Stub mehr) PiperTTSProvider ruft das piper-Binary (--output-raw) async auf, liest die native Sample-Rate aus der .onnx.json und resampelt per ffmpeg auf 24000 Hz (Gateway-Norm). Nicht passende Stimmen (z. B. Cloud-Stimme 'Zephyr' aus der Route) fallen auf die konfigurierte Default-Stimme zurueck. Damit ist eine voll-lokale Konstellation (faster-whisper + Ollama + piper) moeglich -> keine API-Kosten, max. Datenschutz. - config: PIPER_BIN/PIPER_VOICES_DIR/PIPER_VOICE/TTS_SAMPLE_RATE (+ .env.example) - dependencies: piper-Factory mit Settings verdrahtet - tests: tests/test_piper_tts.py (offline, Fake-Binary; Resample-Test skippt ohne ffmpeg); e2e/auth-Tests nutzen jetzt einen Stub-TTS statt 'piper' als Pseudo-Stub - docs: README, BEDIENUNGSANLEITUNG (voll-lokal-Beispiel), Architektur-Roadmap Co-Authored-By: Claude Opus 4.8 --- .env.example | 9 ++ BEDIENUNGSANLEITUNG.md | 20 +++++ Docs/voice-assistant-architecture.md | 3 +- README.md | 4 +- app/config.py | 5 ++ app/dependencies.py | 4 +- app/providers/tts/piper.py | 127 ++++++++++++++++++++++++++- tests/test_auth.py | 11 ++- tests/test_endpoints_e2e.py | 25 ++++-- tests/test_piper_tts.py | 76 ++++++++++++++++ 10 files changed, 269 insertions(+), 15 deletions(-) create mode 100644 tests/test_piper_tts.py diff --git a/.env.example b/.env.example index fa41003..326f357 100644 --- a/.env.example +++ b/.env.example @@ -46,6 +46,15 @@ FASTER_WHISPER_MODEL=base # tiny|base|small|medium|large-v3 FASTER_WHISPER_DEVICE=auto # auto|cpu|cuda FASTER_WHISPER_COMPUTE_TYPE=default # default|int8|float16|int8_float16 +# --- Lokales TTS (piper; Binary + Stimmmodell noetig) ------------------------ +# Aktivieren z. B. mit DEFAULT_TTS_PROVIDER=piper (oder --tts-provider piper). +# Stimmen liegen als .onnx (+ .onnx.json) im Voices-Verzeichnis. +# Modelle laden: python -m piper.download_voices de_DE-thorsten-high (o. manuell). +PIPER_BIN=piper # Pfad/Name des piper-Binaries +PIPER_VOICES_DIR=~/.local/share/piper/voices # Verzeichnis der .onnx-Stimmen +PIPER_VOICE=de_DE-thorsten-high # Stimmmodell (ohne .onnx) oder voller Pfad +TTS_SAMPLE_RATE=24000 # Ziel-Sample-Rate (ffmpeg resampelt bei Bedarf) + # --- Resilienz: Fallback-Ketten (kommaseparierte Provider-Namen) ------------ # Faellt der primaere Provider aus, uebernimmt der naechste. # STT_FALLBACK=faster-whisper diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index cfbb516..bf0d14b 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -241,6 +241,26 @@ Erster Turn ist langsamer (Whisper- und Ollama-Modell laden), danach zügig. STT und Gerät steuern `FASTER_WHISPER_MODEL`/`FASTER_WHISPER_DEVICE` in `.env`. Satzweises Vorlesen ist Standard (früher Ton); `--stream-text` zeigt den Text live dazu. +**Voll-lokal-Beispiel** (STT + LLM + TTS **alles lokal** — kein API-Geld, maximaler +Datenschutz). TTS läuft hier über **piper** (lokales, CPU-freundliches Neural-TTS): +```bash +# einmalig: lokales STT installieren +pip install -e .[local] +# piper-Binary + Stimme bereitstellen: die Stimm-Dateien (.onnx + .onnx.json) +# liegen im PIPER_VOICES_DIR (Default ~/.local/share/piper/voices). Deutsche Stimmen z. B. +# von huggingface 'rhasspy/piper-voices' (de_DE-thorsten-high, de_DE-kerstin-low). +# Verfügbare Stimmen prüfen: ls ~/.local/share/piper/voices/*.onnx +# Sprech-Loop voll-lokal (ReSpeaker = plughw:6,0): +python scripts/voice_loop.py --recorder arecord --device plughw:6,0 --session lokal \ + --stt-provider faster-whisper \ + --llm-provider local-openai-compatible \ + --tts-provider piper +``` +piper-Stimme/Verzeichnis steuern `PIPER_VOICE`/`PIPER_VOICES_DIR` in `.env` (Default +`de_DE-thorsten-high`). Die Stimme klingt etwas synthetischer als das Cloud-TTS, kostet +aber **nichts** und verlässt den Rechner nie. Liefert eine Stimme nicht 24000 Hz (z. B. +`de_DE-thorsten-high` = 22050 Hz), resampelt das Gateway automatisch per `ffmpeg`. + ## B2. Soundquelle & Ausgabe-Gerät wechseln (Mikrofon, Lautsprecher, Bluetooth, Handy) > **Wichtig — aktueller Stand:** Die Geräte-Endpunkte **im Gateway** diff --git a/Docs/voice-assistant-architecture.md b/Docs/voice-assistant-architecture.md index b4df551..fd2c6dd 100644 --- a/Docs/voice-assistant-architecture.md +++ b/Docs/voice-assistant-architecture.md @@ -215,7 +215,8 @@ 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 heuristische Notfall-Eskalation (Erkennung -> Log + optionaler Webhook + Flag/Event). Offen: echte Klassifikation statt Schluesselwort-Heuristik, Telefon-/Angehoerigen-Integration, Abrechnung. -7. **TransportRouter** als eigene lokal/remote-Achse aktivieren. +7. **(weitgehend erledigt)** Lokale Provider: STT via `faster-whisper` (`.[local]`) und **TTS via `piper`** (lokales Neural-TTS, ffmpeg-Resampling auf 24000 Hz) 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. +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 hochsensibel (oft gesundheitsbezogen → DSGVO Art. 9). EU-Datenresidenz, diff --git a/README.md b/README.md index 4dcf103..f140c19 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]`; piper/chatterbox-TTS noch Stubs) +- **Provider austauschbar** über Registry (OpenRouter remote; lokales STT via faster-whisper `.[local]`; **lokales TTS via piper**; chatterbox-TTS noch Stub) - **Geschichtete Konfiguration** mit Profilen (`local-dev` / `hybrid` / `cloud`) - **Routing auf jeder Ebene:** Default → Profil → Nutzer → Session → Request - **Authentifizierung** (Bearer-Token) + persistente Nutzer/Sessions (SQLite) @@ -103,7 +103,7 @@ Aktive Konfiguration prüfen: `curl http://localhost:8080/api/config`. | `WS /ws/voice` | Echtzeit-Sprache (Audio rein → Transkript → Antwort) | | `GET /api/metrics` | Metriken (JSON, oder `?format=prometheus`) | -Beispiel (Sprachausgabe an den Test-Loopback, lokaler TTS-Stub): +Beispiel (Sprachausgabe an den Test-Loopback; `piper` = lokales TTS): ```bash curl -X POST http://localhost:8080/api/speak \ diff --git a/app/config.py b/app/config.py index 6011091..44e6198 100644 --- a/app/config.py +++ b/app/config.py @@ -123,6 +123,11 @@ class Settings(BaseSettings): faster_whisper_model: str = "base" # tiny|base|small|medium|large-v3 faster_whisper_device: str = "auto" # auto|cpu|cuda faster_whisper_compute_type: str = "default" # default|int8|float16|int8_float16 + # --- Lokales TTS (piper) ------------------------------------------------- + piper_bin: str = "piper" # Pfad/Name des piper-Binaries + 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) 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 c686400..f0050e9 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -64,7 +64,9 @@ TTS_REGISTRY = { s.openrouter_api_key, s.openrouter_tts_model, s.openrouter_tts_voice ), "chatterbox": lambda s: ChatterboxTTSProvider(), - "piper": lambda s: PiperTTSProvider(), + "piper": lambda s: PiperTTSProvider( + s.piper_bin, s.piper_voices_dir, s.piper_voice, s.tts_sample_rate + ), } diff --git a/app/providers/tts/piper.py b/app/providers/tts/piper.py index 7a8e9b9..5cc77ed 100644 --- a/app/providers/tts/piper.py +++ b/app/providers/tts/piper.py @@ -1,5 +1,128 @@ +"""Lokales TTS über das piper-Binary (CPU-freundlich, niedrige Latenz, offline). + +piper synthetisiert rohes s16le-Mono-PCM in der Sample-Rate des Stimmmodells. Das +Gateway erwartet 24000 Hz -> bei Abweichung wird mit ffmpeg resampelt. So bleibt eine +voll-lokale Konstellation (STT + LLM + TTS lokal) möglich (Datenschutz, keine API-Kosten). +""" + +from __future__ import annotations + +import asyncio +import io +import json +import shutil +import wave +from pathlib import Path + from app.providers.tts.base import TTSProvider + +def _wrap_wav(pcm: bytes, sample_rate: int) -> bytes: + buf = io.BytesIO() + with wave.open(buf, "wb") as w: + w.setnchannels(1) + w.setsampwidth(2) + w.setframerate(sample_rate) + w.writeframes(pcm) + return buf.getvalue() + + class PiperTTSProvider(TTSProvider): - async def synthesize(self, text: str, voice: str | None = None, audio_format: str = "pcm") -> bytes: - return b"" + def __init__( + self, + bin_path: str = "piper", + voices_dir: str = "", + voice: str = "", + target_rate: int = 24000, + ): + self.bin_path = (bin_path or "piper").strip() + self.voices_dir = Path(voices_dir).expanduser() if voices_dir else Path.cwd() + self.voice = (voice or "").strip() + self.target_rate = int(target_rate) + + def _model_paths(self, voice: str | None) -> tuple[Path, Path]: + # Angeforderte Stimme zuerst; passt sie nicht (z. B. eine Cloud-Stimme wie + # "Zephyr"/"alloy" aus der Route), auf die konfigurierte Default-Stimme zurueckfallen. + if not (voice or self.voice or "").strip(): + raise ValueError("Piper-Stimme ist nicht gesetzt (PIPER_VOICE)") + tried: list[str] = [] + for candidate in (voice, self.voice): + name = (candidate or "").strip() + if not name or name in tried: + continue + tried.append(name) + model = Path(name).expanduser() + if not model.suffix: # nur ein Name -> im Voices-Verzeichnis suchen + model = self.voices_dir / f"{name}.onnx" + if model.exists(): + return model, Path(f"{model}.json") + raise RuntimeError( + f"Piper-Stimmmodell nicht gefunden (versucht: {', '.join(tried)}) in {self.voices_dir}" + ) + + def _native_rate(self, config: Path) -> int: + try: + with open(config) as fh: + return int(json.load(fh)["audio"]["sample_rate"]) + except (OSError, KeyError, ValueError, TypeError): + return self.target_rate # Konfig unlesbar -> Resampling überspringen + + 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") + if not (shutil.which(self.bin_path) or Path(self.bin_path).exists()): + raise RuntimeError(f"Piper-Binary nicht gefunden: {self.bin_path}") + + model, config = self._model_paths(voice) + native_rate = self._native_rate(config) + + pcm = await self._run_piper(model, config, text.strip()) + if native_rate != self.target_rate: + pcm = await self._resample(pcm, native_rate, self.target_rate) + + if audio_format == "wav": + return _wrap_wav(pcm, self.target_rate) + return pcm + + async def _run_piper(self, model: Path, config: Path, text: str) -> bytes: + cmd = [self.bin_path, "-m", str(model), "-c", str(config), "--output-raw"] + proc = await asyncio.create_subprocess_exec( + *cmd, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + out, err = await proc.communicate(text.encode("utf-8")) + if proc.returncode != 0: + detail = err.decode("utf-8", "replace").strip()[:300] + raise RuntimeError(f"Piper-Fehler ({proc.returncode}): {detail}") + if not out: + raise RuntimeError("Piper lieferte kein Audio") + return out + + async def _resample(self, pcm: bytes, src_rate: int, dst_rate: int) -> bytes: + if not shutil.which("ffmpeg"): + raise RuntimeError( + f"ffmpeg fehlt: Piper-Stimme liefert {src_rate} Hz, benötigt werden {dst_rate} Hz" + ) + cmd = [ + "ffmpeg", "-loglevel", "quiet", + "-f", "s16le", "-ar", str(src_rate), "-ac", "1", "-i", "pipe:0", + "-f", "s16le", "-ar", str(dst_rate), "-ac", "1", "pipe:1", + ] + proc = await asyncio.create_subprocess_exec( + *cmd, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + out, err = await proc.communicate(pcm) + if proc.returncode != 0 or not out: + detail = err.decode("utf-8", "replace").strip()[:300] + raise RuntimeError(f"ffmpeg-Resampling fehlgeschlagen: {detail}") + return out diff --git a/tests/test_auth.py b/tests/test_auth.py index ba3959b..07e31da 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,5 +1,6 @@ from fastapi.testclient import TestClient +import app.dependencies as deps from app.main import app from app.config import settings @@ -67,17 +68,23 @@ def test_tenant_isolation_returns_403(monkeypatch): def test_user_prefs_applied_to_route(monkeypatch): _enable_auth(monkeypatch) + + class StubTTS: + async def synthesize(self, text, voice=None, audio_format="pcm"): + return b"" + monkeypatch.setitem(deps.TTS_REGISTRY, "stub-tts", lambda s: StubTTS()) + token = _create_user("Pref") auth = {"Authorization": f"Bearer {token}"} client.put( "/api/me/prefs", headers=auth, - json={"tts_provider": "piper", "output_endpoint": "loopback"}, + json={"tts_provider": "stub-tts", "output_endpoint": "loopback"}, ) resp = client.post("/api/speak", headers=auth, json={"text": "hallo"}) assert resp.status_code == 200 - assert resp.headers["X-TTS-Provider"] == "piper" + assert resp.headers["X-TTS-Provider"] == "stub-tts" assert resp.headers["X-Output-Endpoint"] == "loopback" diff --git a/tests/test_endpoints_e2e.py b/tests/test_endpoints_e2e.py index f8c680a..7cac465 100644 --- a/tests/test_endpoints_e2e.py +++ b/tests/test_endpoints_e2e.py @@ -9,15 +9,25 @@ from tests.conftest import loopback_output client = TestClient(app) -def test_speak_loopback_collects_chunks(): - # piper-Stub liefert b"" -> kein Netzcall; Loopback sammelt den Chunk. +def _stub_tts(monkeypatch, name="stub-tts", audio=b""): + """Registriert einen deterministischen TTS-Stub (kein Netz, kein lokales Binary).""" + class StubTTS: + async def synthesize(self, text, voice=None, audio_format="pcm"): + return audio + monkeypatch.setitem(deps.TTS_REGISTRY, name, lambda s: StubTTS()) + return name + + +def test_speak_loopback_collects_chunks(monkeypatch): + # Stub-TTS liefert b"" -> kein Netzcall; Loopback sammelt den Chunk. + tts = _stub_tts(monkeypatch) resp = client.post( "/api/speak", - json={"text": "Hallo Welt", "tts_provider": "piper", "output_endpoint": "loopback"}, + json={"text": "Hallo Welt", "tts_provider": tts, "output_endpoint": "loopback"}, ) assert resp.status_code == 200 assert resp.headers["X-Output-Endpoint"] == "loopback" - assert resp.headers["X-TTS-Provider"] == "piper" + assert resp.headers["X-TTS-Provider"] == tts assert len(loopback_output().chunks) == 1 @@ -35,15 +45,16 @@ def test_unknown_provider_returns_422(): assert resp.status_code == 422 -def test_session_route_applies(): +def test_session_route_applies(monkeypatch): + tts = _stub_tts(monkeypatch) client.post( "/api/sessions/s1/route", - json={"tts_provider": "piper", "output_endpoint": "loopback"}, + json={"tts_provider": tts, "output_endpoint": "loopback"}, ) resp = client.post("/api/speak?session_id=s1", json={"text": "hallo"}) assert resp.status_code == 200 assert resp.headers["X-Output-Endpoint"] == "loopback" - assert resp.headers["X-TTS-Provider"] == "piper" + assert resp.headers["X-TTS-Provider"] == tts def test_chat_per_request_override_and_loopback(monkeypatch): diff --git a/tests/test_piper_tts.py b/tests/test_piper_tts.py new file mode 100644 index 0000000..4860c19 --- /dev/null +++ b/tests/test_piper_tts.py @@ -0,0 +1,76 @@ +"""Tests fuer den lokalen Piper-TTS-Provider (offline, mit Fake-Binary).""" + +import asyncio +import json +import shutil +import stat + +import pytest + +from app.providers.tts.piper import PiperTTSProvider + +# PCM-Nutzlast des Fake-Binaries: gross genug, dass der ffmpeg-Resampler Samples ausgibt +# (bei sehr kurzen Eingaben puffert er und liefert nichts). 4000 Bytes = 2000 s16le-Samples. +FAKE_PCM = b"\x01\x02" * 2000 + + +def _run(coro): + return asyncio.run(coro) + + +def _make_fake_voice(tmp_path, sample_rate): + """Legt ein Fake-piper-Binary + Stimmmodell (.onnx/.onnx.json) an.""" + voices = tmp_path / "voices" + voices.mkdir() + (voices / "de_DE-test.onnx").write_bytes(b"fake-model") + (voices / "de_DE-test.onnx.json").write_text( + json.dumps({"audio": {"sample_rate": sample_rate}}) + ) + + # Fake-Binary: ignoriert Argumente, schreibt feste Roh-PCM-Bytes nach stdout. + binp = tmp_path / "piper" + binp.write_text( + "#!/usr/bin/env python3\n" + "import sys\n" + f"sys.stdout.buffer.write({FAKE_PCM!r})\n" + ) + binp.chmod(binp.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) + return str(binp), str(voices) + + +def test_piper_returns_pcm_without_resampling(tmp_path): + # Native Rate == Ziel-Rate -> kein ffmpeg noetig, Bytes unveraendert. + binp, voices = _make_fake_voice(tmp_path, sample_rate=24000) + provider = PiperTTSProvider(binp, voices, "de_DE-test", target_rate=24000) + pcm = _run(provider.synthesize("Hallo Welt")) + assert pcm == FAKE_PCM + + +def test_piper_wraps_wav_on_request(tmp_path): + binp, voices = _make_fake_voice(tmp_path, sample_rate=24000) + provider = PiperTTSProvider(binp, voices, "de_DE-test", target_rate=24000) + wav = _run(provider.synthesize("Hallo", audio_format="wav")) + assert wav.startswith(b"RIFF") and b"WAVE" in wav[:16] + + +def test_piper_rejects_empty_text(tmp_path): + binp, voices = _make_fake_voice(tmp_path, sample_rate=24000) + provider = PiperTTSProvider(binp, voices, "de_DE-test") + with pytest.raises(ValueError): + _run(provider.synthesize(" ")) + + +def test_piper_unknown_voice_raises(tmp_path): + binp, voices = _make_fake_voice(tmp_path, sample_rate=24000) + provider = PiperTTSProvider(binp, voices, "gibt-es-nicht") + with pytest.raises(RuntimeError): + _run(provider.synthesize("Hallo")) + + +@pytest.mark.skipif(not shutil.which("ffmpeg"), reason="ffmpeg nicht installiert") +def test_piper_resamples_when_rates_differ(tmp_path): + # Native 16000 -> Ziel 24000: ffmpeg laeuft, Ergebnis ist gueltiges (nicht leeres) PCM. + binp, voices = _make_fake_voice(tmp_path, sample_rate=16000) + provider = PiperTTSProvider(binp, voices, "de_DE-test", target_rate=24000) + pcm = _run(provider.synthesize("Hallo")) + assert isinstance(pcm, bytes) and len(pcm) > 0