2026-06-17 22:11:20 +02:00
|
|
|
"""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
|
|
|
|
|
|
2026-06-17 01:48:56 +02:00
|
|
|
from app.providers.tts.base import TTSProvider
|
|
|
|
|
|
2026-06-17 22:11:20 +02:00
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
|
|
|
|
|
|
2026-06-17 01:48:56 +02:00
|
|
|
class PiperTTSProvider(TTSProvider):
|
2026-06-17 22:11:20 +02:00
|
|
|
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
|