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

+149
View File
@@ -0,0 +1,149 @@
#!/usr/bin/env python3
"""Mock-TTS-Worker für lokale Tests (gleiche HTTP-API wie tts_worker.py).
Erzeugt ein kurzes, leises WAV statt echten Audios.
API:
GET /status -> {"status":"ok","ready":true,"voices":[...],...}
POST /tts -> {"text":"...","voice":"...","speed":1.0,"format":"wav"}
-> binäres Audio (WAV/MP3/FLAC/PCM)
Optionen (Umgebungsvariablen):
MOCK_TTS_PORT Port (Default 8082)
MOCK_TTS_DELAY Sekunden pro Synthese (Default 0.2)
MOCK_TTS_LOG Datei, in die die Requests geloggt werden (JSON-Zeilen)
Sonder-Texte:
"FAIL" -> Worker antwortet mit 500 (simulierter Fehler)
"SLOW" -> Worker schläft 5 s
"""
import io
import json
import os
import struct
import sys
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
PORT = int(os.environ.get("MOCK_TTS_PORT", "8082"))
DELAY = float(os.environ.get("MOCK_TTS_DELAY", "0.2"))
LOG_FILE = os.environ.get("MOCK_TTS_LOG", "")
SAMPLE_RATE = 24000
def _make_wav(seconds: float = 0.1) -> bytes:
"""Erzeugt ein kurzes WAV (16-bit, mono, 24 kHz) mit Sinus-Ton."""
import math
n = int(SAMPLE_RATE * seconds)
samples = b"".join(
struct.pack("<h", int(8000 * math.sin(2 * math.pi * 440 * i / SAMPLE_RATE)))
for i in range(n)
)
header = (
b"RIFF" + struct.pack("<I", 36 + len(samples)) + b"WAVE"
+ b"fmt " + struct.pack("<IHHIIHH", 16, 1, 1, SAMPLE_RATE,
SAMPLE_RATE * 2, 2, 16)
+ b"data" + struct.pack("<I", len(samples))
)
return header + samples
def _log_request(req: dict) -> None:
if not LOG_FILE:
return
try:
with open(LOG_FILE, "a", encoding="utf-8") as f:
f.write(json.dumps(req) + "\n")
except OSError:
pass
class Handler(BaseHTTPRequestHandler):
server_version = "MockKokoroTTS/1.0"
timeout = 60
def log_message(self, fmt, *args): # noqa: N802
pass # leise
def do_GET(self): # noqa: N802
if self.path.split("?", 1)[0] == "/status":
self._send_json(200, {
"status": "ok",
"ready": True,
"voices": ["martin", "victoria"],
"default_voice": "martin",
"load_errors": [],
"sample_rate": SAMPLE_RATE,
"uptime_seconds": 1.0,
"total_requests": 0,
"last_seconds": None,
"last_voice": None,
"last_error": None,
})
else:
self._send_json(404, {"error": "not found"})
def do_POST(self): # noqa: N802
if self.path.split("?", 1)[0] != "/tts":
self._send_json(404, {"error": "not found"})
return
length = int(self.headers.get("Content-Length") or 0)
try:
req = json.loads(self.rfile.read(length))
except ValueError:
self._send_json(400, {"error": "ungültiges JSON"})
return
_log_request(req)
text = req.get("text", "")
if text == "SLOW":
time.sleep(5.0)
else:
time.sleep(DELAY)
if text == "FAIL":
self._send_json(500, {"error": "simulierter TTS-Fehler"})
return
fmt = req.get("format", "wav")
if fmt == "wav":
data, ctype = _make_wav(), "audio/wav"
elif fmt == "mp3":
# Minimales MP3-Frame (silence) – reicht für Format-Tests.
data = b"\xff\xfb\x90\x00" + b"\x00" * 417
ctype = "audio/mpeg"
elif fmt == "flac":
data = b"fLaC" + b"\x00" * 100
ctype = "audio/flac"
else: # pcm
data = b"\x00" * 4800
ctype = "application/octet-stream"
self.send_response(200)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(data)))
self.send_header("Connection", "close")
self.end_headers()
self.wfile.write(data)
def _send_json(self, code: int, payload: dict) -> None:
body = json.dumps(payload).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 main() -> None:
server = ThreadingHTTPServer(("127.0.0.1", PORT), Handler)
server.daemon_threads = True
print(f"Mock-TTS-Worker auf Port {PORT}", file=sys.stderr)
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()
if __name__ == "__main__":
main()
+99 -1
View File
@@ -6,13 +6,14 @@ cd "$(dirname "$0")/.."
UP_PORT=18080
RT_PORT=18081
TTS_PORT=18082
BASE="http://127.0.0.1:$RT_PORT"
FAKE_DIR="$PWD/dev/fake-profile-dir"
PASS=0
FAIL=0
cleanup() {
kill "${MOCK_PID:-}" "${ROUTER_PID:-}" 2>/dev/null || true
kill "${MOCK_PID:-}" "${ROUTER_PID:-}" "${TTS_PID:-}" 2>/dev/null || true
rm -f /tmp/mock_pid2 /tmp/mock_upstream_pid
wait 2>/dev/null || true
}
@@ -52,11 +53,21 @@ IMAGE_DIR=/tmp/test-images \
IMAGE_WORKER_LOG=/tmp/test_worker.log \
IMAGE_GEN_TIMEOUT=30 \
MOCK_WORKER_LOG=/tmp/test_worker_requests.jsonl \
TTS_WORKER_URL="http://127.0.0.1:$TTS_PORT" \
python3 router/ai_profile_router.py >/tmp/router_test.log 2>&1 &
ROUTER_PID=$!
sleep 0.5
rm -f /tmp/test_worker_requests.jsonl
# --- Mock-TTS-Worker starten ----------------------------------------------------
echo "== Starte Mock-TTS-Worker (Port $TTS_PORT)"
MOCK_TTS_PORT="$TTS_PORT" MOCK_TTS_DELAY=0.1 \
MOCK_TTS_LOG=/tmp/test_tts_requests.jsonl \
python3 dev/mock_tts_worker.py >/tmp/mock_tts.log 2>&1 &
TTS_PID=$!
sleep 0.5
rm -f /tmp/test_tts_requests.jsonl
# --- 1. /v1/models -------------------------------------------------------------
echo "== Test 1: /v1/models"
RESP=$(curl -sf "$BASE/v1/models")
@@ -395,6 +406,93 @@ wait $IMG_PID
[ "$CODE" = "200" ] && [ "$ELAPSED" -ge 2 ] \
&& ok "Chat wartete ${ELAPSED}s (kein 502), dann 200" || bad "Chat: Code $CODE, ${ELAPSED}s"
# --- 27. TTS: /status zeigt tts-Section ---------------------------------------------------------------
echo "== Test 27: /status mit tts-Section"
RESP=$(curl -sf "$BASE/status")
echo "$RESP" | python3 -m json.tool
echo "$RESP" | python3 -c '
import json,sys
d=json.load(sys.stdin)
tts=d["tts"]
assert tts["reachable"] is True, tts
assert tts["ready"] is True, tts
assert set(tts["voices"])=={"martin","victoria"}, tts
' && ok "Status: TTS erreichbar, bereit, 2 Stimmen" || bad "Status tts-Section"
# --- 28. TTS: POST /v1/audio/speech (wav) ---------------------------------------------------------------
echo "== Test 28: POST /v1/audio/speech (wav)"
CODE=$(curl -s -o /tmp/tts28.wav -w "%{http_code}" -D /tmp/hdr28.txt \
"$BASE/v1/audio/speech" -H "Content-Type: application/json" \
-d '{"model":"kokoro-german","input":"Hallo Welt","voice":"martin","response_format":"wav"}')
CTYPE=$(grep -i content-type /tmp/hdr28.txt | tr -d "\r")
[ "$CODE" = "200" ] && [ -s /tmp/tts28.wav ] && echo "$CTYPE" | grep -qi "audio/wav" \
&& ok "TTS wav (200, $CTYPE, $(stat -f%z /tmp/tts28.wav 2>/dev/null || stat -c%s /tmp/tts28.wav) Bytes)" \
|| bad "TTS wav (Code $CODE, $CTYPE)"
# --- 29. TTS: POST /v1/audio/speech (mp3, Default) -------------------------------------------------------
echo "== Test 29: POST /v1/audio/speech (mp3, Default)"
CODE=$(curl -s -o /tmp/tts29.mp3 -w "%{http_code}" -D /tmp/hdr29.txt \
"$BASE/v1/audio/speech" -H "Content-Type: application/json" \
-d '{"input":"Guten Tag","voice":"victoria"}')
CTYPE=$(grep -i content-type /tmp/hdr29.txt | tr -d "\r")
[ "$CODE" = "200" ] && [ -s /tmp/tts29.mp3 ] && echo "$CTYPE" | grep -qi "audio/mpeg" \
&& ok "TTS mp3 (200, $CTYPE)" || bad "TTS mp3 (Code $CODE, $CTYPE)"
# --- 30. TTS: Validierung --------------------------------------------------------------------------------
echo "== Test 30: TTS-Validierung"
CODE=$(curl -s -o /tmp/err30a.json -w "%{http_code}" "$BASE/v1/audio/speech" \
-H "Content-Type: application/json" -d '{"voice":"martin"}')
cat /tmp/err30a.json; echo
[ "$CODE" = "400" ] && ok "400 bei fehlendem input" || bad "erwartet 400, bekam $CODE"
CODE=$(curl -s -o /tmp/err30b.json -w "%{http_code}" "$BASE/v1/audio/speech" \
-H "Content-Type: application/json" -d '{"input":"x","voice":"bogus"}')
cat /tmp/err30b.json; echo
[ "$CODE" = "400" ] && ok "400 bei ungültiger Stimme" || bad "erwartet 400, bekam $CODE"
CODE=$(curl -s -o /tmp/err30c.json -w "%{http_code}" "$BASE/v1/audio/speech" \
-H "Content-Type: application/json" -d '{"input":"x","response_format":"ogg"}')
cat /tmp/err30c.json; echo
[ "$CODE" = "400" ] && ok "400 bei ungültigem Format" || bad "erwartet 400, bekam $CODE"
CODE=$(curl -s -o /tmp/err30d.json -w "%{http_code}" "$BASE/v1/audio/speech" \
-H "Content-Type: application/json" -d '{"input":"x","model":"gpt-4"}')
cat /tmp/err30d.json; echo
[ "$CODE" = "400" ] && ok "400 bei unbekanntem Modell" || bad "erwartet 400, bekam $CODE"
# --- 31. TTS: Worker-Fehler → 503 ------------------------------------------------------------------------
echo "== Test 31: TTS-Worker-Fehler → 503"
CODE=$(curl -s -o /tmp/err31.json -w "%{http_code}" "$BASE/v1/audio/speech" \
-H "Content-Type: application/json" -d '{"input":"FAIL","voice":"martin"}')
cat /tmp/err31.json; echo
[ "$CODE" = "503" ] && ok "503 bei TTS-Worker-Fehler" || bad "erwartet 503, bekam $CODE"
# --- 32. TTS: Worker down → 503 ---------------------------------------------------------------------------
echo "== Test 32: TTS-Worker down → 503"
kill "$TTS_PID" 2>/dev/null || true
sleep 0.5
CODE=$(curl -s -o /tmp/err32.json -w "%{http_code}" "$BASE/v1/audio/speech" \
-H "Content-Type: application/json" -d '{"input":"Hallo","voice":"martin"}')
cat /tmp/err32.json; echo
[ "$CODE" = "503" ] && ok "503 bei downem TTS-Worker" || bad "erwartet 503, bekam $CODE"
RESP=$(curl -sf "$BASE/status")
echo "$RESP" | python3 -c '
import json,sys
d=json.load(sys.stdin)
assert d["tts"]["reachable"] is False, d["tts"]
' && ok "Status: TTS nicht erreichbar" || bad "Status nach TTS-Down"
# --- 33. TTS: Worker-Neustart → Recovery -------------------------------------------------------------------
echo "== Test 33: TTS-Worker-Neustart → Recovery"
MOCK_TTS_PORT="$TTS_PORT" MOCK_TTS_DELAY=0.1 \
python3 dev/mock_tts_worker.py >/tmp/mock_tts2.log 2>&1 &
TTS_PID=$!
sleep 0.5
CODE=$(curl -s -o /tmp/tts33.wav -w "%{http_code}" "$BASE/v1/audio/speech" \
-H "Content-Type: application/json" -d '{"input":"Wieder da","voice":"martin","response_format":"wav"}')
[ "$CODE" = "200" ] && [ -s /tmp/tts33.wav ] \
&& ok "TTS nach Neustart wieder verfügbar" || bad "TTS-Recovery (Code $CODE)"
# --- Ergebnis --------------------------------------------------------------------------------------------
echo
echo "== Ergebnis: $PASS bestanden, $FAIL fehlgeschlagen =="