Files
AI-Profile-Router/router/tts_worker.py
T
Mikei386 6db10fbc3f router: deutsche Sprachausgabe mit Kokoro-82M (CPU-only)
Fügt einen OpenAI-kompatiblen TTS-Endpunkt POST /v1/audio/speech hinzu.
Die Synthese läuft in einem separaten, langlebigen Worker
(mike-ai-kokoro.service) mit eigenem Venv (CPU-only torch) und hält die
Modelle dauerhaft im RAM (niedrige Warm-Start-Latenz).

- Zwei deutsche Stimmen: kikiri-german-martin, kikiri-german-victoria
  (Apache 2.0, je ~327 MB) unter /opt/mike-ai/models/kokoro/
- Deutsche G2P über espeak-ng (phonemizer), kein spacy/thinc 9.x nötig
  (kokoro mit --no-deps + misaki ohne [en], Python 3.13-kompatibel)
- Formate: mp3 (Default), wav, flac, pcm; speed 0.5-2.0
- /status um tts.*-Felder erweitert (reachable, ready, voices, ...)
- TTS ohne GPU-Lock: blockiert weder Qwen/llama.cpp noch FLUX
- systemd-Unit mike-ai-kokoro.service (Start beim Boot)
- install.sh/deploy.sh um Kokoro-Venv + Modell-Download erweitert
- Mock-TTS-Worker + 11 TTS-Tests (insgesamt 43, alle bestanden)
- Hörproben (je ~40 s) + Benchmark (RTF ~0.23, ~4.3x Echtzeit)

Kein Push – erst nach User-Freigabe.
2026-08-19 12:05:09 +02:00

317 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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("<i2")
return pcm.tobytes()
if fmt == "mp3":
import lameenc
pcm = (np.clip(data, -1.0, 1.0) * 32767).astype("<i2")
encoder = lameenc.Encoder()
encoder.set_bit_rate(128)
encoder.set_in_sample_rate(sr)
encoder.set_channels(1)
mp3 = encoder.encode(pcm.tobytes())
mp3 += encoder.flush()
return mp3
raise ValueError(f"unbekanntes Format: {fmt!r}")
# ---------------------------------------------------------------------------
# HTTP-Handler
# ---------------------------------------------------------------------------
WORKER = TTSWorker()
class Handler(BaseHTTPRequestHandler):
server_version = "KokoroTTS/1.0"
timeout = 300 # s, Synthese kann dauern
def log_message(self, fmt, *args): # noqa: N802
log.info("%s %s", self.address_string(), fmt % args)
def do_GET(self): # noqa: N802
if self.path.split("?", 1)[0] == "/status":
self._send_json(200, self._status_payload())
else:
self._send_json(404, {"error": "not found"})
def do_POST(self): # noqa: N802
if self.path.split("?", 1)[0] == "/tts":
self._tts()
else:
self._send_json(404, {"error": "not found"})
def _status_payload(self) -> 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()