my_voice_assistant_v2/app/providers/tts/piper.py

154 lines
5.9 KiB
Python
Raw Permalink Normal View History

"""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="<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,
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",
feat: Geräte-TTS, native Stimmen, Favicon, Auth-Gate, UI-Fixes Web-UI / TTS: - Geräte-TTS ("📱 Gerät"): Antwort wird on-device vorgelesen (Web Speech API), Server sendet nur Text (text_only) -> spart Bandbreite/Kosten. Mobil-Default, geräte-lokale Speicherung, iOS-Autoplay-Freischaltung. - Vorlese-Symbol (🔊) je Bubble: Hybrid-Replay (Assistent-PCM gecacht, Eingabe via /api/speak); SVG-Icon mit kontrastreicher Farbe. - Kombiniertes Sprachmenü (Flex + feste Sprachen) statt separatem Modus-Menü. - "Neues Gespräch"-Button (frische Session gegen Sprach-Trägheit). - Dark-Mode: lesbare <option>-Popups (Kontrast-Fix). - Favicon (SVG + PNG-Fallbacks) aus mund.png. TTS-Backend: - Sprache wird an alle TTS-Provider durchgereicht; Piper-Stimme folgt der Sprache; Chatterbox mehrsprachig + cross-lingual. - Native Referenz-Stimmen je Sprache (config/voices/<lang>.wav, FLEURS CC-BY), loudness-normalisiert. LLM-Sprache: - Antwort folgt zuverlässig der gewählten Sprache (verstärkte Anweisung + Erinnerung an der letzten Nutzer-Nachricht gegen History-Trägheit). Admin / Auth: - Wörterbuch: alle 8 Sprachen, Zeilen editierbar, alphabetische Sortierung. - Web-UI hinter Auth-Gate (Redirect auf SSO_LOGIN_URL / 401); Favicons offen. - Log-Tab: Hinweis, wenn der systemd-Dienst nicht aktiv ist. - Einstellungen: Hinweis "pro Nutzer überschreibbar" bei Sprache/Modus/Qualität. Doku (BEDIENUNGSANLEITUNG.md): Geräte-TTS §6.5.0, Fix/Flex §6.6, native Stimmen §6.5.3, llama.cpp<->Ollama-Wechsel §4.7, Auth/SSO §7.4. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 13:12:04 +02:00
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))