From 800d83a91b8965ca86301ddc03d5d8823ac9374a Mon Sep 17 00:00:00 2001 From: Mikei386 <44135113+Mikei386@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:02:49 +0200 Subject: [PATCH] Cleanup: alte Kokoro-Dateien entfernt --- deploy/mike-ai-kokoro.service | 21 --- router/tts_worker.py | 332 ---------------------------------- 2 files changed, 353 deletions(-) delete mode 100644 deploy/mike-ai-kokoro.service delete mode 100644 router/tts_worker.py diff --git a/deploy/mike-ai-kokoro.service b/deploy/mike-ai-kokoro.service deleted file mode 100644 index ee187d3..0000000 --- a/deploy/mike-ai-kokoro.service +++ /dev/null @@ -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 diff --git a/router/tts_worker.py b/router/tts_worker.py deleted file mode 100644 index 64ad2c8..0000000 --- a/router/tts_worker.py +++ /dev/null @@ -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(" 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()