From 7ca69e10499584d34b4c3c73a8997b3f690054cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Thu, 18 Jun 2026 08:22:04 +0200 Subject: [PATCH] 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 --- app/providers/tts/piper.py | 117 ++++++++++++++++++++++--------------- pyproject.toml | 6 +- tests/test_piper_tts.py | 12 +++- 3 files changed, 85 insertions(+), 50 deletions(-) diff --git a/app/providers/tts/piper.py b/app/providers/tts/piper.py index 5cc77ed..a9b8b0e 100644 --- a/app/providers/tts/piper.py +++ b/app/providers/tts/piper.py @@ -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=" 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)) diff --git a/pyproject.toml b/pyproject.toml index 49311d0..b1ec74f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,9 +17,11 @@ dependencies = [ test = [ "pytest>=8.0" ] -# Lokale KI-Module (optional, schwergewichtig): lokales STT via faster-whisper. +# Lokale KI-Module (optional, schwergewichtig): lokales STT via faster-whisper, +# lokales TTS via piper (in-process -> Modell bleibt geladen, kein Start pro Satz). local = [ - "faster-whisper>=1.0" + "faster-whisper>=1.0", + "piper-tts>=1.2" ] [build-system] diff --git a/tests/test_piper_tts.py b/tests/test_piper_tts.py index 4860c19..7e841e7 100644 --- a/tests/test_piper_tts.py +++ b/tests/test_piper_tts.py @@ -7,10 +7,18 @@ import stat import pytest +import app.providers.tts.piper as piper_mod 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. + +@pytest.fixture(autouse=True) +def _force_binary_path(monkeypatch): + """Diese Tests pruefen den Binary-Fallback (Fake-piper). Ist die piper-Python-Lib + installiert, wuerde sonst der In-Process-Pfad das Fake-Modell zu laden versuchen.""" + monkeypatch.setattr(piper_mod, "_PIPER_LIB", False) + + +# PCM-Nutzlast des Fake-Binaries. 4000 Bytes = 2000 s16le-Samples. FAKE_PCM = b"\x01\x02" * 2000