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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue