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:
parent
84d6ac6e7b
commit
cca423dac3
10 changed files with 269 additions and 15 deletions
|
|
@ -46,6 +46,15 @@ FASTER_WHISPER_MODEL=base # tiny|base|small|medium|large-v3
|
||||||
FASTER_WHISPER_DEVICE=auto # auto|cpu|cuda
|
FASTER_WHISPER_DEVICE=auto # auto|cpu|cuda
|
||||||
FASTER_WHISPER_COMPUTE_TYPE=default # default|int8|float16|int8_float16
|
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) ------------
|
# --- Resilienz: Fallback-Ketten (kommaseparierte Provider-Namen) ------------
|
||||||
# Faellt der primaere Provider aus, uebernimmt der naechste.
|
# Faellt der primaere Provider aus, uebernimmt der naechste.
|
||||||
# STT_FALLBACK=faster-whisper
|
# STT_FALLBACK=faster-whisper
|
||||||
|
|
|
||||||
|
|
@ -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
|
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.
|
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)
|
## B2. Soundquelle & Ausgabe-Gerät wechseln (Mikrofon, Lautsprecher, Bluetooth, Handy)
|
||||||
|
|
||||||
> **Wichtig — aktueller Stand:** Die Geräte-Endpunkte **im Gateway**
|
> **Wichtig — aktueller Stand:** Die Geräte-Endpunkte **im Gateway**
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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.
|
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.
|
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
|
**Datenschutz (querschnittlich, ab sofort mitdenken):** Senioren-Sprachdaten sind
|
||||||
hochsensibel (oft gesundheitsbezogen → DSGVO Art. 9). EU-Datenresidenz,
|
hochsensibel (oft gesundheitsbezogen → DSGVO Art. 9). EU-Datenresidenz,
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ Praktische Bedienung: [`BEDIENUNGSANLEITUNG.md`](BEDIENUNGSANLEITUNG.md).
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Pipeline mit getrennter Semantik/Sprache:** STT → Input-Cleaner → LLM → Spoken-Adapter → TTS-Normalizer → TTS
|
- **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`)
|
- **Geschichtete Konfiguration** mit Profilen (`local-dev` / `hybrid` / `cloud`)
|
||||||
- **Routing auf jeder Ebene:** Default → Profil → Nutzer → Session → Request
|
- **Routing auf jeder Ebene:** Default → Profil → Nutzer → Session → Request
|
||||||
- **Authentifizierung** (Bearer-Token) + persistente Nutzer/Sessions (SQLite)
|
- **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) |
|
| `WS /ws/voice` | Echtzeit-Sprache (Audio rein → Transkript → Antwort) |
|
||||||
| `GET /api/metrics` | Metriken (JSON, oder `?format=prometheus`) |
|
| `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
|
```bash
|
||||||
curl -X POST http://localhost:8080/api/speak \
|
curl -X POST http://localhost:8080/api/speak \
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,11 @@ class Settings(BaseSettings):
|
||||||
faster_whisper_model: str = "base" # tiny|base|small|medium|large-v3
|
faster_whisper_model: str = "base" # tiny|base|small|medium|large-v3
|
||||||
faster_whisper_device: str = "auto" # auto|cpu|cuda
|
faster_whisper_device: str = "auto" # auto|cpu|cuda
|
||||||
faster_whisper_compute_type: str = "default" # default|int8|float16|int8_float16
|
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")
|
db_path: str = str(BASE_DIR / "data" / "voice-assistant.db")
|
||||||
admin_api_key: str = ""
|
admin_api_key: str = ""
|
||||||
auth_enabled: bool = True
|
auth_enabled: bool = True
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,9 @@ TTS_REGISTRY = {
|
||||||
s.openrouter_api_key, s.openrouter_tts_model, s.openrouter_tts_voice
|
s.openrouter_api_key, s.openrouter_tts_model, s.openrouter_tts_voice
|
||||||
),
|
),
|
||||||
"chatterbox": lambda s: ChatterboxTTSProvider(),
|
"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
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
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):
|
class PiperTTSProvider(TTSProvider):
|
||||||
async def synthesize(self, text: str, voice: str | None = None, audio_format: str = "pcm") -> bytes:
|
def __init__(
|
||||||
return b""
|
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
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
import app.dependencies as deps
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
|
|
@ -67,17 +68,23 @@ def test_tenant_isolation_returns_403(monkeypatch):
|
||||||
|
|
||||||
def test_user_prefs_applied_to_route(monkeypatch):
|
def test_user_prefs_applied_to_route(monkeypatch):
|
||||||
_enable_auth(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")
|
token = _create_user("Pref")
|
||||||
auth = {"Authorization": f"Bearer {token}"}
|
auth = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
client.put(
|
client.put(
|
||||||
"/api/me/prefs",
|
"/api/me/prefs",
|
||||||
headers=auth,
|
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"})
|
resp = client.post("/api/speak", headers=auth, json={"text": "hallo"})
|
||||||
assert resp.status_code == 200
|
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"
|
assert resp.headers["X-Output-Endpoint"] == "loopback"
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,15 +9,25 @@ from tests.conftest import loopback_output
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
def test_speak_loopback_collects_chunks():
|
def _stub_tts(monkeypatch, name="stub-tts", audio=b""):
|
||||||
# piper-Stub liefert b"" -> kein Netzcall; Loopback sammelt den Chunk.
|
"""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(
|
resp = client.post(
|
||||||
"/api/speak",
|
"/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.status_code == 200
|
||||||
assert resp.headers["X-Output-Endpoint"] == "loopback"
|
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
|
assert len(loopback_output().chunks) == 1
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -35,15 +45,16 @@ def test_unknown_provider_returns_422():
|
||||||
assert resp.status_code == 422
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
def test_session_route_applies():
|
def test_session_route_applies(monkeypatch):
|
||||||
|
tts = _stub_tts(monkeypatch)
|
||||||
client.post(
|
client.post(
|
||||||
"/api/sessions/s1/route",
|
"/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"})
|
resp = client.post("/api/speak?session_id=s1", json={"text": "hallo"})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.headers["X-Output-Endpoint"] == "loopback"
|
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):
|
def test_chat_per_request_override_and_loopback(monkeypatch):
|
||||||
|
|
|
||||||
76
tests/test_piper_tts.py
Normal file
76
tests/test_piper_tts.py
Normal 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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue