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.
This commit is contained in:
Mikei386
2026-08-19 12:05:09 +02:00
parent fdaaef98cc
commit 6db10fbc3f
8 changed files with 962 additions and 22 deletions
+148
View File
@@ -20,6 +20,13 @@ Bildgenerierung (FLUX.2 [klein] 4B Base):
GET /images (Liste)
GET /images/<datei> (PNG-Download)
Sprachausgabe (Kokoro-82M, deutsch, CPU-only):
POST /v1/audio/speech (OpenAI-kompatibel)
Der TTS-Worker (mike-ai-kokoro.service) läuft als separater, langlebiger
Prozess mit eigenem Venv und hält die Modelle dauerhaft im RAM. Der
Router leitet /v1/audio/speech per HTTP an den Worker weiter.
Der Router agiert als Modell-Orchestrator: vor der Generierung wird
llama.cpp gestoppt, der Bild-Worker lädt FLUX, generiert und entlädt
das Modell wieder; danach wird das vorherige Qwen-Profil wiederher-
@@ -91,6 +98,16 @@ IMAGE_QUALITY = {"standard": 30, "high": 50}
IMAGE_DEFAULT_QUALITY = "standard"
IMAGE_MAX_N = 4
# --- Sprachausgabe (Kokoro-82M, deutsch, CPU-only) ---
TTS_WORKER_URL = os.environ.get("TTS_WORKER_URL", "http://127.0.0.1:8082")
TTS_TIMEOUT = float(os.environ.get("TTS_TIMEOUT", "300")) # s, pro Synthese
TTS_CONNECT_TIMEOUT = float(os.environ.get("TTS_CONNECT_TIMEOUT", "5"))
TTS_MODEL = "kokoro-german" # virtuelles Modell für /v1/audio/speech
TTS_VOICES = ("martin", "victoria")
TTS_DEFAULT_VOICE = "martin"
TTS_FORMATS = ("mp3", "wav", "flac", "pcm")
TTS_DEFAULT_FORMAT = "mp3"
# Chat-Waiting: Während eines Image-Jobs oder Profilwechsels ist Qwen
# down. Chat-Requests warten (statt 502) bis Qwen wieder bereit ist.
CHAT_WAIT_TIMEOUT = float(os.environ.get("CHAT_WAIT_TIMEOUT", "300")) # s, max. Warten
@@ -189,6 +206,56 @@ def _set_qwen_unavailable(unavailable: bool) -> None:
# Upstream (llama.cpp)
# ---------------------------------------------------------------------------
def tts_status() -> dict:
"""Prüft den TTS-Worker: erreichbar? bereit? welche Stimmen?"""
hostport = TTS_WORKER_URL.split("://", 1)[-1]
host, _, port = hostport.partition(":")
try:
conn = http.client.HTTPConnection(host, int(port) if port else 80,
timeout=TTS_CONNECT_TIMEOUT)
conn.request("GET", "/status")
resp = conn.getresponse()
data = json.loads(resp.read())
conn.close()
return {"reachable": True, **data}
except (OSError, ValueError) as e:
return {"reachable": False, "error": str(e)}
def tts_synthesize(text: str, voice: str, speed: float,
fmt: str) -> tuple[bytes, str]:
"""Synthetisiert Audio über den TTS-Worker.
Liefert (audio_bytes, content_type). Wirft RuntimeError bei Fehler.
"""
hostport = TTS_WORKER_URL.split("://", 1)[-1]
host, _, port = hostport.partition(":")
payload = json.dumps({"text": text, "voice": voice,
"speed": speed, "format": fmt}).encode()
try:
conn = http.client.HTTPConnection(host, int(port) if port else 80,
timeout=TTS_CONNECT_TIMEOUT)
conn.request("POST", "/tts", body=payload,
headers={"Content-Type": "application/json"})
conn.sock.settimeout(TTS_TIMEOUT)
resp = conn.getresponse()
body = resp.read()
conn.close()
except (OSError, http.client.HTTPException) as e:
raise RuntimeError(f"TTS-Worker nicht erreichbar: {e}")
if resp.status != 200:
try:
err = json.loads(body)
msg = err.get("error", str(err))
except ValueError:
msg = body.decode(errors="replace")[:200]
raise RuntimeError(f"TTS-Fehler ({resp.status}): {msg}")
content_type = {"mp3": "audio/mpeg", "wav": "audio/wav",
"flac": "audio/flac",
"pcm": "application/octet-stream"}[fmt]
return body, content_type
def upstream_status() -> dict:
"""Prüft llama.cpp: erreichbar? welches Modell? welcher Kontext?"""
try:
@@ -617,6 +684,8 @@ class Handler(BaseHTTPRequestHandler):
self._send_json(200, self._status_payload())
elif path == "/v1/images/generations" and self.command == "POST":
self._image_generate()
elif path == "/v1/audio/speech" and self.command == "POST":
self._speech()
elif path == "/images" and self.command == "GET":
self._images_list()
elif path.startswith("/images/") and self.command == "GET":
@@ -689,6 +758,7 @@ class Handler(BaseHTTPRequestHandler):
"last_seconds": img.last_seconds,
"last_error": img.last_error,
},
"tts": tts_status(),
}
# ---------- Bildgenerierung ----------
@@ -854,6 +924,84 @@ class Handler(BaseHTTPRequestHandler):
self.end_headers()
self.wfile.write(data)
# ---------- Sprachausgabe (Kokoro) ----------
def _speech(self) -> None:
length = int(self.headers.get("Content-Length") or 0)
try:
data = json.loads(self.rfile.read(length))
except ValueError:
self._send_error(400, "ungültiges JSON",
"invalid_request_error", "invalid_json")
return
if not isinstance(data, dict):
self._send_error(400, "Request muss ein JSON-Objekt sein",
"invalid_request_error", "invalid_request")
return
# input (OpenAI) – auch 'text' akzeptieren (bequemer für curl)
text = data.get("input", data.get("text"))
if not isinstance(text, str) or not text.strip():
self._send_error(400, "'input' fehlt oder ist leer",
"invalid_request_error", "missing_input")
return
if len(text) > 8000:
self._send_error(400, "'input' zu lang (max 8000 Zeichen)",
"invalid_request_error", "input_too_long")
return
voice = data.get("voice", TTS_DEFAULT_VOICE)
if voice not in TTS_VOICES:
self._send_error(
400, f"ungültige Stimme: {voice!r} "
f"(erlaubt: {', '.join(TTS_VOICES)})",
"invalid_request_error", "invalid_voice")
return
fmt = data.get("response_format", TTS_DEFAULT_FORMAT)
if fmt not in TTS_FORMATS:
self._send_error(
400, f"ungültiges response_format: {fmt!r} "
f"(erlaubt: {', '.join(TTS_FORMATS)})",
"invalid_request_error", "invalid_format")
return
speed = data.get("speed", 1.0)
try:
speed = float(speed)
except (TypeError, ValueError):
self._send_error(400, "'speed' muss eine Zahl sein",
"invalid_request_error", "invalid_speed")
return
if not 0.5 <= speed <= 2.0:
self._send_error(400, "'speed' muss zwischen 0.5 und 2.0 sein",
"invalid_request_error", "invalid_speed")
return
# Modell-Name optional; falls angegeben, muss es kokoro-german sein.
model = data.get("model")
if model is not None and model != TTS_MODEL:
self._send_error(400, f"unbekanntes Modell: {model!r} "
f"(erwartet: {TTS_MODEL})",
"invalid_request_error", "unknown_model")
return
self.timeout = None # Synthese kann dauern
try:
audio, content_type = tts_synthesize(
text.strip(), voice, speed, fmt)
except RuntimeError as e:
self._send_error(503, str(e), "server_error", "tts_failed")
return
self._last_code = 200
self.send_response(200)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(audio)))
self.send_header("Connection", "close")
self.end_headers()
self.wfile.write(audio)
def _switch(self, profile: str) -> None:
if profile not in PROFILES:
self._send_error(400, f"unbekanntes Profil: {profile}",