perf(tts): piper in-process mit gecachtem Modell statt Subprozess pro Satz
Profiling zeigte ~2,2 s Fixkosten pro Satz durch Modell-Start (piper-Binary je Aufruf). Der Provider nutzt jetzt die piper-Python-API: Stimmmodell wird einmal geladen (lru_cache), Synthese laeuft im Thread. Resampling in-process (audioop, numpy-Fallback fuer Py3.13) statt ffmpeg-Subprozess. Binary bleibt als Fallback. Messung (lokales Setup): erster Ton 5,84 s -> 2,66 s, Turn-Ende 9,82 s -> 4,04 s. - pyproject: piper-tts zum [local]-Extra - Tests pruefen weiter den Binary-Fallback (erzwingen _PIPER_LIB=False) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
b3c8114469
commit
7ca69e1049
3 changed files with 83 additions and 48 deletions
|
|
@ -1,8 +1,12 @@
|
|||
"""Lokales TTS über das piper-Binary (CPU-freundlich, niedrige Latenz, offline).
|
||||
"""Lokales TTS über piper (CPU-freundlich, 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).
|
||||
Bevorzugt die **in-process** piper-Python-API: Das Stimmmodell wird EINMAL geladen
|
||||
und prozessweit zwischengespeichert (lru_cache) - so entfaellt der teure Modell-Start
|
||||
pro Satz (~2 s), der bei einem Subprozess-pro-Aufruf anfiel. Fehlt das Paket
|
||||
(`pip install -e .[local]`), wird automatisch auf das piper-Binary zurueckgefallen.
|
||||
|
||||
piper liefert s16le-Mono-PCM in der Sample-Rate des Modells; das Gateway erwartet
|
||||
24000 Hz -> bei Abweichung wird resampelt (in-process via audioop, sonst ffmpeg).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -12,10 +16,19 @@ import io
|
|||
import json
|
||||
import shutil
|
||||
import wave
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from app.providers.tts.base import TTSProvider
|
||||
|
||||
try: # bevorzugter Pfad: in-process, Modell bleibt geladen
|
||||
from piper import PiperVoice # type: ignore
|
||||
|
||||
_PIPER_LIB = True
|
||||
except Exception: # pragma: no cover - Paket optional
|
||||
PiperVoice = None # type: ignore
|
||||
_PIPER_LIB = False
|
||||
|
||||
|
||||
def _wrap_wav(pcm: bytes, sample_rate: int) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
|
|
@ -27,6 +40,30 @@ def _wrap_wav(pcm: bytes, sample_rate: int) -> bytes:
|
|||
return buf.getvalue()
|
||||
|
||||
|
||||
@lru_cache(maxsize=4)
|
||||
def _load_voice(model_path: str):
|
||||
"""Laedt ein piper-Stimmmodell einmalig (prozessweit gecacht)."""
|
||||
return PiperVoice.load(model_path)
|
||||
|
||||
|
||||
def _resample(pcm: bytes, src_rate: int, dst_rate: int) -> bytes:
|
||||
"""Resampelt s16le-Mono in-process. audioop (stdlib) bevorzugt, sonst numpy."""
|
||||
try:
|
||||
import audioop # in Python 3.13 entfernt -> Fallback unten
|
||||
|
||||
converted, _ = audioop.ratecv(pcm, 2, 1, src_rate, dst_rate, None)
|
||||
return converted
|
||||
except Exception:
|
||||
import numpy as np
|
||||
|
||||
src = np.frombuffer(pcm, dtype="<i2").astype(np.float32)
|
||||
n_out = max(1, round(len(src) * dst_rate / src_rate))
|
||||
x_old = np.arange(len(src))
|
||||
x_new = np.linspace(0, len(src) - 1, n_out)
|
||||
out = np.interp(x_new, x_old, src)
|
||||
return np.clip(out, -32768, 32767).astype("<i2").tobytes()
|
||||
|
||||
|
||||
class PiperTTSProvider(TTSProvider):
|
||||
def __init__(
|
||||
self,
|
||||
|
|
@ -75,54 +112,42 @@ class PiperTTSProvider(TTSProvider):
|
|||
) -> 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())
|
||||
pcm, native_rate = await asyncio.to_thread(
|
||||
self._synthesize_sync, str(model), str(config), text.strip()
|
||||
)
|
||||
if native_rate != self.target_rate:
|
||||
pcm = await self._resample(pcm, native_rate, self.target_rate)
|
||||
pcm = await asyncio.to_thread(_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
|
||||
def _synthesize_sync(self, model: str, config: str, text: str) -> tuple[bytes, int]:
|
||||
if _PIPER_LIB:
|
||||
voice = _load_voice(model)
|
||||
pcm = bytearray()
|
||||
rate = self.target_rate
|
||||
for chunk in voice.synthesize(text):
|
||||
pcm += chunk.audio_int16_bytes
|
||||
rate = chunk.sample_rate
|
||||
if not pcm:
|
||||
raise RuntimeError("Piper lieferte kein Audio")
|
||||
return bytes(pcm), rate
|
||||
# Fallback: piper-Binary (Subprozess pro Aufruf, langsamer).
|
||||
return self._run_piper_binary(model, config, text)
|
||||
|
||||
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
|
||||
def _run_piper_binary(self, model: str, config: str, text: str) -> tuple[bytes, int]:
|
||||
import subprocess
|
||||
|
||||
if not (shutil.which(self.bin_path) or Path(self.bin_path).exists()):
|
||||
raise RuntimeError(f"Piper-Binary nicht gefunden: {self.bin_path}")
|
||||
cmd = [self.bin_path, "-m", model, "-c", config, "--output-raw"]
|
||||
proc = subprocess.run(cmd, input=text.encode("utf-8"), capture_output=True)
|
||||
if proc.returncode != 0:
|
||||
detail = proc.stderr.decode("utf-8", "replace").strip()[:300]
|
||||
raise RuntimeError(f"Piper-Fehler ({proc.returncode}): {detail}")
|
||||
if not proc.stdout:
|
||||
raise RuntimeError("Piper lieferte kein Audio")
|
||||
return proc.stdout, self._native_rate(Path(config))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue