Cleanup: alte Kokoro-Dateien entfernt
This commit is contained in:
1 parent
d399d2b4f7
commit
800d83a91b
2 files changed
-353
No files matched your search
@@ -1,21 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Kokoro TTS Worker (deutsche Sprachausgabe, CPU-only)
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=root
|
|
||||||
WorkingDirectory=/opt/mike-ai/ai-profile-router
|
|
||||||
Environment=KOKORO_HOST=127.0.0.1
|
|
||||||
Environment=KOKORO_PORT=8082
|
|
||||||
Environment=KOKORO_MODEL_DIR=/opt/mike-ai/models/kokoro
|
|
||||||
Environment=LOG_LEVEL=INFO
|
|
||||||
ExecStart=/opt/mike-ai/kokoro/venv/bin/python /opt/mike-ai/ai-profile-router/tts_worker.py
|
|
||||||
Restart=always
|
|
||||||
RestartSec=5
|
|
||||||
# CPU-only: keine GPU-Bindung, keine VRAM-Belegung
|
|
||||||
StandardOutput=journal
|
|
||||||
StandardError=journal
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -1,332 +0,0 @@
|
|||||||
#!/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()
|
|
||||||
Reference in new issue
Block a user