"""Schlanker OpenAI-kompatibler TTS-Dienst (`/v1/audio/speech`) für CPU-Engines wie Piper. Der Befehl ist als Vorlage konfigurierbar, weil sich die Piper-CLI zwischen Versionen unterscheidet: KNASTER_TTS_CMD Standard: "piper -m {model} -f {out}" (Text kommt über stdin) KNASTER_TTS_VOICES "thorsten=/voices/de_DE-thorsten-medium.onnx,kerstin=/voices/de_DE-kerstin-low.onnx" KNASTER_TTS_DEFAULT_VOICE Name der Standardstimme (sonst die erste) """ from __future__ import annotations import os import shlex import subprocess import tempfile import threading from pathlib import Path from fastapi import FastAPI, Header, HTTPException, Response from pydantic import BaseModel, Field DEFAULT_CMD = "piper -m {model} -f {out}" MAX_CHARS = 5000 class SpeechIn(BaseModel): model: str = "tts-de" input: str = Field(min_length=1, max_length=MAX_CHARS) voice: str = "default" response_format: str = "wav" class CommandBackend: def __init__(self, voices: dict[str, str], default_voice: str | None = None, cmd: str = DEFAULT_CMD, timeout: float = 120.0, max_parallel: int = 2): if not voices: raise ValueError("Mindestens eine Stimme konfigurieren") self.voices = voices self.default_voice = default_voice or next(iter(voices)) self.cmd = cmd self.timeout = timeout self._slots = threading.Semaphore(max_parallel) # CPU nicht überbuchen @classmethod def from_env(cls) -> CommandBackend: raw = os.environ.get("KNASTER_TTS_VOICES", "") voices = dict(item.split("=", 1) for item in raw.split(",") if "=" in item) return cls(voices, os.environ.get("KNASTER_TTS_DEFAULT_VOICE") or None, os.environ.get("KNASTER_TTS_CMD", DEFAULT_CMD), max_parallel=int(os.environ.get("KNASTER_TTS_PARALLEL", "2"))) def synthesize(self, text: str, voice: str = "default") -> bytes: name = self.default_voice if voice in ("default", "", None) else voice if name not in self.voices: raise KeyError(f"Stimme {name} unbekannt (verfügbar: {', '.join(self.voices)})") with self._slots, tempfile.TemporaryDirectory() as tmp: out = Path(tmp) / "out.wav" args = [part.format(model=self.voices[name], out=str(out)) for part in shlex.split(self.cmd)] proc = subprocess.run(args, input=text.encode("utf-8"), capture_output=True, timeout=self.timeout) if proc.returncode != 0 or not out.exists(): raise RuntimeError(f"TTS-Befehl fehlgeschlagen ({proc.returncode}): " f"{proc.stderr.decode('utf-8', 'replace')[-300:]}") return out.read_bytes() def create_tts_app(backend: CommandBackend | None = None, token: str | None = None) -> FastAPI: backend = backend or CommandBackend.from_env() token = os.environ.get("KNASTER_TTS_TOKEN", "") if token is None else token app = FastAPI(title="knaster-tts") @app.get("/health") def health() -> dict: return {"status": "ok", "voices": list(backend.voices), "default": backend.default_voice} @app.post("/v1/audio/speech") def speech(body: SpeechIn, authorization: str | None = Header(default=None)) -> Response: if token and authorization != f"Bearer {token}": raise HTTPException(401, "unauthorized") if body.response_format != "wav": raise HTTPException(400, "nur response_format=wav") try: audio = backend.synthesize(body.input, body.voice) except KeyError as exc: raise HTTPException(400, str(exc)) from exc except (RuntimeError, subprocess.TimeoutExpired) as exc: raise HTTPException(502, str(exc)) from exc return Response(audio, media_type="audio/wav") return app