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 committed 2026-08-19 12:05:09 +02:00
1 parent fdaaef98cc
commit 6db10fbc3f
8 files changed
+962 -22

No files matched your search

+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}",
+316
View File
@@ -0,0 +1,316 @@
#!/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()