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 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-17 22:11:20 +02:00
commit cca423dac3
10 changed files with 269 additions and 15 deletions

View file

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

View file

@ -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 (<name>.onnx + <name>.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**

View file

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

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]`; 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 \

View file

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

View file

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

View file

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

View file

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

View file

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

76
tests/test_piper_tts.py Normal file
View file

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