#!/usr/bin/env python3 """Kokoro TTS Worker – langlebiger HTTP-Service für deutsche Sprachausgabe. Lädt die Kokoro-82M-Feintuning-Modelle (kikiri-german-martin, kikiri-german-victoria) einmalig beim Start und hält sie dauerhaft im RAM (CPU-only, niedrige Warm-Start-Latenz). Wird als eigener systemd-Service betrieben und vom AI Profile Router über HTTP angesprochen (POST /v1/audio/speech -> POST /tts). API: GET /status -> {"status":"ok","ready":bool,"voices":[...],...} POST /tts -> {"text":"...","voice":"martin","speed":1.0, "format":"wav|mp3|flac|pcm"} -> binäres Audio Logging nach stderr (journald). stdout bleibt frei. """ from __future__ import annotations import json import logging import os import sys import threading import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer # --------------------------------------------------------------------------- # Konfiguration (über Umgebungsvariablen, vgl. systemd-Unit) # --------------------------------------------------------------------------- HOST = os.environ.get("KOKORO_HOST", "127.0.0.1") PORT = int(os.environ.get("KOKORO_PORT", "8082")) MODEL_DIR = os.environ.get("KOKORO_MODEL_DIR", "/opt/mike-ai/models/kokoro") # Stimmen: Name -> Pfade relativ zu MODEL_DIR. # Beide Modelle sind eigenständige Kokoro-82M-Feintunings (Apache 2.0). VOICES = { "martin": { "config": "kikiri-german-martin/config.json", "model": "kikiri-german-martin/kikiri_german_martin_ep10.pth", "voice": "kikiri-german-martin/voices/martin.pt", }, "victoria": { "config": "kikiri-german-victoria/config.json", "model": "kikiri-german-victoria/kikiri_german_victoria_ep10.pth", "voice": "kikiri-german-victoria/voices/victoria.pt", }, } DEFAULT_VOICE = "martin" SAMPLE_RATE = 24000 # Kokoro-nativ SPEED_MIN, SPEED_MAX = 0.5, 2.0 MAX_TEXT_LEN = 8000 # Zeichen pro Request log = logging.getLogger("kokoro-tts") # --------------------------------------------------------------------------- # Worker # --------------------------------------------------------------------------- class TTSWorker: """Hält die geladenen Pipelines und serialisiert die Synthese.""" def __init__(self) -> None: self.pipelines: dict[str, tuple] = {} # name -> (KPipeline, voice) self.load_errors: list[str] = [] self.ready = False self.started = time.time() self._lock = threading.Lock() self.last_seconds: float | None = None self.last_voice: str | None = None self.last_error: str | None = None self.total_requests = 0 def load(self) -> None: """Lädt alle Stimmen (CPU-only).""" import torch from kokoro import KModel, KPipeline # Deutsche Sprachunterstützung (kokoro PR #340): 'de' -> espeak-ng G2P. from kokoro import pipeline as _pipeline_mod _pipeline_mod.ALIASES.setdefault("de", "d") _pipeline_mod.LANG_CODES.setdefault("d", "de") for name, cfg in VOICES.items(): t0 = time.monotonic() try: kmodel = KModel( config=os.path.join(MODEL_DIR, cfg["config"]), model=os.path.join(MODEL_DIR, cfg["model"]), ).to("cpu").eval() pipeline = KPipeline(lang_code="de", model=kmodel) voice = torch.load( os.path.join(MODEL_DIR, cfg["voice"]), map_location="cpu", weights_only=True) self.pipelines[name] = (pipeline, voice) log.info("Stimme geladen: %s (%.1f s)", name, time.monotonic() - t0) except Exception as e: self.load_errors.append(f"{name}: {e}") log.error("Stimme %s nicht ladbar: %s", name, e) self.ready = True def synthesize(self, text: str, voice: str, speed: float) -> bytes: """Synthetisiert Audio und liefert es als WAV-Bytes (24 kHz).""" with self._lock: if voice not in self.pipelines: raise ValueError(f"unbekannte Stimme: {voice!r} " f"(erlaubt: {', '.join(VOICES)})") pipeline, voice_pack = self.pipelines[voice] t0 = time.monotonic() chunks = list(pipeline(text, voice=voice_pack, speed=speed)) audios = [c[2] for c in chunks if c[2] is not None] if not audios: raise RuntimeError("keine Audio-Daten erzeugt") import torch audio = torch.cat(audios, dim=0) seconds = time.monotonic() - t0 self.last_seconds = seconds self.last_voice = voice self.total_requests += 1 return _to_wav_bytes(audio) def _to_wav_bytes(audio) -> bytes: """1D-Tensor (24 kHz) -> WAV-Bytes (16-bit PCM).""" import io import numpy as np import soundfile as sf arr = audio.detach().cpu().numpy().astype("float32") buf = io.BytesIO() sf.write(buf, arr, SAMPLE_RATE, format="WAV", subtype="PCM_16") return buf.getvalue() def _wav_to_format(wav_bytes: bytes, fmt: str) -> bytes: """WAV-Bytes in das Zielformat konvertieren.""" if fmt == "wav": return wav_bytes import io import numpy as np import soundfile as sf data, sr = sf.read(io.BytesIO(wav_bytes), dtype="float32") if fmt == "flac": buf = io.BytesIO() sf.write(buf, data, sr, format="FLAC") return buf.getvalue() if fmt == "pcm": # 16-bit PCM, little-endian, mono pcm = (np.clip(data, -1.0, 1.0) * 32767).astype(" dict: return { "status": "ok", "ready": WORKER.ready, "voices": list(VOICES), "default_voice": DEFAULT_VOICE, "load_errors": WORKER.load_errors, "sample_rate": SAMPLE_RATE, "uptime_seconds": round(time.time() - WORKER.started, 1), "total_requests": WORKER.total_requests, "last_seconds": WORKER.last_seconds, "last_voice": WORKER.last_voice, "last_error": WORKER.last_error, } def _tts(self) -> None: length = int(self.headers.get("Content-Length") or 0) try: data = json.loads(self.rfile.read(length)) except ValueError: self._send_json(400, {"error": "ungültiges JSON"}) return if not isinstance(data, dict): self._send_json(400, {"error": "Request muss ein JSON-Objekt sein"}) return text = data.get("text") if not isinstance(text, str) or not text.strip(): self._send_json(400, {"error": "'text' fehlt oder ist leer"}) return if len(text) > MAX_TEXT_LEN: self._send_json(400, {"error": f"'text' zu lang (max {MAX_TEXT_LEN})"}) return voice = data.get("voice", DEFAULT_VOICE) if voice not in VOICES: self._send_json(400, {"error": f"unbekannte Stimme: {voice!r}"}) return speed = data.get("speed", 1.0) try: speed = float(speed) except (TypeError, ValueError): self._send_json(400, {"error": "'speed' muss eine Zahl sein"}) return if not SPEED_MIN <= speed <= SPEED_MAX: self._send_json(400, {"error": f"'speed' muss zwischen " f"{SPEED_MIN} und {SPEED_MAX} sein"}) return fmt = data.get("format", "wav") if fmt not in ("wav", "mp3", "flac", "pcm"): self._send_json(400, {"error": f"ungültiges Format: {fmt!r}"}) return if not WORKER.ready: self._send_json(503, {"error": "TTS-Worker lädt noch"}) return if not WORKER.pipelines: self._send_json(503, {"error": "keine Stimme geladen", "load_errors": WORKER.load_errors}) return try: wav = WORKER.synthesize(text.strip(), voice, speed) audio = _wav_to_format(wav, fmt) except (ValueError, RuntimeError) as e: WORKER.last_error = str(e) self._send_json(500, {"error": str(e)}) return content_type = { "wav": "audio/wav", "mp3": "audio/mpeg", "flac": "audio/flac", "pcm": "application/octet-stream", }[fmt] self._send_bytes(200, audio, content_type) def _send_json(self, code: int, payload: dict) -> None: body = json.dumps(payload, ensure_ascii=False).encode() self.send_response(code) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.send_header("Connection", "close") self.end_headers() self.wfile.write(body) def _send_bytes(self, code: int, data: bytes, content_type: str) -> None: self.send_response(code) self.send_header("Content-Type", content_type) self.send_header("Content-Length", str(len(data))) self.send_header("Connection", "close") self.end_headers() self.wfile.write(data) # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- def main() -> None: logging.basicConfig( level=os.environ.get("LOG_LEVEL", "INFO"), format="%(asctime)s %(levelname)s %(message)s", stream=sys.stderr, ) log.info("Kokoro TTS Worker startet: %s:%s (Modelle: %s)", HOST, PORT, ", ".join(VOICES)) WORKER.load() if not WORKER.pipelines: log.error("Keine Stimme geladen – Worker bleibt trotzdem erreichbar " "(/status zeigt load_errors)") server = ThreadingHTTPServer((HOST, PORT), Handler) server.daemon_threads = True log.info("Kokoro TTS Worker bereit (%d Stimmen)", len(WORKER.pipelines)) try: server.serve_forever() except KeyboardInterrupt: pass finally: server.server_close() if __name__ == "__main__": main()