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:
@@ -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}",
|
||||
|
||||
Reference in New Issue
Block a user