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>
128 lines
4.8 KiB
Python
128 lines
4.8 KiB
Python
"""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):
|
|
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
|