"""Lokales TTS über piper (CPU-freundlich, offline). 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 import asyncio 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() with wave.open(buf, "wb") as w: w.setnchannels(1) w.setsampwidth(2) w.setframerate(sample_rate) w.writeframes(pcm) 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=" 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", language: str | None = None, # ignoriert: die Piper-Stimme kodiert die Sprache ) -> bytes: if not text or not text.strip(): raise ValueError("TTS input text is empty") model, config = self._model_paths(voice) pcm, native_rate = await asyncio.to_thread( self._synthesize_sync, str(model), str(config), text.strip() ) if 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 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) 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))