Files
AI-Profile-Router/router/tts_worker.py
T
Mikei386 d399d2b4f7 TTS: Kokoro → XTTS-v2 (CPU-only, Claribel Dervla)
- Neues xtts_worker.py: Coqui XTTS-v2, HTTP-API auf Port 8085
- Router: TTS_WORKER_URL → 8085, TTS_MODEL → xtts-v2, TTS_VOICES → claribel
- deploy: mike-ai-xtts.service, install.sh + deploy.sh aktualisiert
- Tests: 54/54 bestanden (mock_tts_worker + test_local.sh auf XTTS umgestellt)
- README: TTS-Section auf XTTS-v2 aktualisiert
- Kokoro-Service gestoppt und deaktiviert (Dateien bleiben als Backup)
2026-08-19 22:01:58 +02:00

333 lines
12 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).
Nutzt den offiziellen Kikiri-Deutsche-Inferenzpfad:
- semidark/kokoro-Fork (0.9.4) mit lang_code='d'
- semidark/misaki-Fork (0.9.4) mit misaki.de.DEG2P
(Text-Normalisierung + espeak-ng + Aussprache-Overrides)
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",
},
"eva": {
"config": "eva-k/config.json",
"model": "eva-k/kokoro_german_converted.pth",
"voice": "eva-k/eva_k.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) via offiziellem Kikiri-Deutsche-Pfad.
Nutzt den semidark/kokoro-Fork (0.9.4) mit lang_code='d' und
den semidark/misaki-Fork (0.9.4) mit misaki.de.DEG2P
(Text-Normalisierung + espeak-ng + Aussprache-Overrides).
"""
import torch
from kokoro import KModel, KPipeline
for name, cfg in VOICES.items():
t0 = time.monotonic()
try:
kmodel = KModel(
repo_id="hexgrad/Kokoro-82M",
config=os.path.join(MODEL_DIR, cfg["config"]),
model=os.path.join(MODEL_DIR, cfg["model"]),
).to("cpu").eval()
pipeline = KPipeline(
lang_code="d",
repo_id="hexgrad/Kokoro-82M",
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()