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.
AI Profile Router
Kleiner OpenAI-kompatibler Proxy (Python, nur Standardbibliothek) vor einem lokalen llama.cpp-Server. Er leitet normale OpenAI-Requests transparent weiter (Streaming, Tool Calls, JSON), schaltet zwischen drei festen llama.cpp-Profilen um, orchestriert lokale Bildgenerierung mit FLUX.2 [klein] 4B Base (GPU-Hotswap: Qwen stoppen → FLUX laden → Bild → FLUX entladen → Qwen wiederherstellen) und stellt lokale deutsche Sprachausgabe bereit (Kokoro-82M, CPU-only, OpenAI-kompatibel).
Zielsystem
| Host | 192.168.1.196 |
| SSH | root mit Key lmstudio_unraid |
| llama.cpp | http://127.0.0.1:8080 (Service mike-ai-llama-ui.service) |
| Profil-Skript | /usr/local/bin/llama-profile {fast|medium|long} |
| Router-Port | 8081 |
| Router-Service | mike-ai-profile-router.service |
| TTS-Worker | http://127.0.0.1:8082 (Service mike-ai-kokoro.service) |
Profile / virtuelle Modelle
| Profil | Modell | Kontext |
|---|---|---|
fast |
qwen-fast |
73728 |
medium |
qwen-medium |
94208 |
long |
qwen-long |
131072 |
Endpunkte
| Endpunkt | Beschreibung |
|---|---|
GET /v1/models |
Die drei virtuellen Modelle inkl. context_length/context_window |
GET /status |
Aktives Profil, Upstream-Zustand, Modell, Kontext, Uptime |
POST /fast /medium /long |
Profilwechsel (auch GET möglich) |
POST /v1/chat/completions |
Weiterleitung an llama.cpp (Streaming + Tool Calls) |
POST /v1/images/generations |
Bildgenerierung (FLUX.2 [klein] 4B Base, OpenAI-kompatibel) |
GET /images |
Liste der gespeicherten Bilder (max. 200) |
GET /images/<datei> |
PNG-Download (nur images/-Verzeichnis, validiert) |
POST /v1/audio/speech |
Deutsche Sprachausgabe (Kokoro-82M, OpenAI-kompatibel) |
| alles andere | Transparente Weiterleitung an llama.cpp |
Verhalten
- Virtuelles Modell (
qwen-fast/qwen-medium/qwen-longinchat.completions): Der Router stellt sicher, dass das passende Profil aktiv ist (notfalls Wechsel + Warten), ersetzt das Modell durch das echte llama.cpp-Modell und leitet weiter. - Profilwechsel (
POST /fast…): Führt/usr/local/bin/llama-profile <profil>aus (ohne Shell, feste Argumente → keine Injection), wartet dann, bis llama.cpp wieder erreichbar ist, und liefert erst dann200. - Impliziter Wechsel bei downem llama.cpp: Ein Chat-Request mit virtuellem
Modell liefert sofort
502, wenn das Profil bereits aktiv ist, aber llama.cpp down ist (kein stiller Neustart). Der Neustart wird explizit überPOST /<profil>angestoßen. - Ungültige Profile/Modelle:
POST /<anderes>→400;qwen-<anderes>als Modell →400. Nur die drei festen Profile sind schaltbar. - Fehlerformat: OpenAI-kompatibel (
{"error": {"message", "type", "code"}}).
Bildgenerierung (FLUX.2 [klein] 4B Base)
Der Router orchestriert lokale Bildgenerierung mit
black-forest-labs/FLUX.2-klein-base-4B (Apache 2.0, ~13 GB, bf16 +
CPU-Offload). Da Qwen (llama.cpp) und FLUX denselben GPU/VRAM teilen, macht
der Router einen GPU-Hotswap:
- Zentrales GPU/Modell-Lock übernehmen (Profilwechsel und Bild teilen sich dasselbe Lock → kein Race).
- Aktives Qwen-Profil merken.
mike-ai-llama-ui.servicestoppen, warten bis Port + VRAM frei sind.- FLUX-Worker starten (eigener Prozess,
Flux2KleinPipeline, bf16 +enable_model_cpu_offload()), Bild generieren, PNG speichern. - Worker beenden (nicht nur entladen), VRAM-Freiheit verifizieren.
- Vorheriges Qwen-Profil exakt wiederherstellen, Readiness-Check (Modell geladen + Kontext passt).
- Erst dann antworten und das GPU-Lock freigeben.
Endpunkt POST /v1/images/generations
OpenAI-kompatibel. Unterstützt prompt, size, n, seed, quality,
response_format.
| Parameter | Werte | Default |
|---|---|---|
prompt |
Text (Pflicht) | – |
size |
1024x1024, 1536x1024, 1024x1536, 1920x1088, 1088x1920 |
1024x1024 |
n |
1–4 | 1 |
seed |
int (reproduzierbar) | zufällig |
quality |
standard (30 Steps), high (50 Steps) |
standard |
response_format |
url (Default), b64_json |
url |
Beispiel:
curl -s http://192.168.1.196:8081/v1/images/generations \
-H 'Content-Type: application/json' \
-d '{"prompt":"ein roter Würfel auf weißem Grund","size":"1024x1024","quality":"standard"}'
Die Antwort enthält data[].url (absolute URL, über den Router abrufbar)
und data[].b64_json (optional). Jedes Bild wird unter
/opt/mike-ai/ai-profile-router/images/ gespeichert (kollisionsfreie Namen,
img-<zeitstempel>-<seed>-<i>.png) und ist über GET /images/<datei>
abrufbar.
Verhalten während eines Bild-Jobs
- Chat-Requests warten (kein 502): Der Router merkt sich, dass Qwen
vorübergehend nicht verfügbar ist (
qwen.available=false), und Chat-Requests warten, bis Qwen wieder bereit ist (TimeoutCHAT_WAIT_TIMEOUT, Default 300 s). So gibt es keine502 llama.cpp nicht erreichbarwährend des Hotswaps. - Profilwechsel warten: Ein Profilwechsel während eines Bild-Jobs blockiert auf dem GPU-Lock, bis der Bild-Job fertig ist (kein Race).
/statuszeigt den aktuellen Zustand:image.phase(idle,stopping-qwen,loading-image,generating,unloading-image,restoring-qwen),image.worker,image.model_loaded,image.last_image,image.last_seconds,image.last_error,qwen.available,qwen.active_chats.
Recovery (robust)
try/finally: Qwen wird immer wiederhergestellt, egal ob die Bildgenerierung erfolgreich war, fehlgeschlagen ist (OOM, Python-Fehler, ungültiger Prompt, Speichern-Fehler, Client-Disconnect, Timeout) oder der Worker abstürzt.- Worker-Beendigung: Nach jedem Job wird der Worker beendet (SIGTERM → SIGKILL), nicht nur entladen. So wird der VRAM (inkl. CUDA-Kontext) frei.
- VRAM-Check: Nach dem Worker-Beenden wartet der Router, bis der VRAM
unter 1000 MiB fällt (
nvidia-smi), bevor Qwen neu startet. - Qwen-Readiness: Nach dem Neustart wartet der Router, bis llama.cpp erreichbar ist, das Modell geladen ist und der Kontext zum Profil passt.
qwen.available: Bleibtfalse, wenn die Wiederherstellung fehlschlägt (Chat-Requests warten weiter, statt 502 zu liefern). Der Fehler wird inimage.last_errorund im Log protokolliert.
Benchmarks (RTX 5080, 16 GB, CPU-Offload, gemessen)
| Auflösung | Steps | Zeit | Peak-VRAM (torch) |
|---|---|---|---|
| 512×512 | 10 | ~9.3 s | ~8.4 GB |
| 1024×1024 | 30 | ~31.3 s | ~8.4 GB |
| 1024×1024 | 50 | ~45.3 s | ~8.4 GB |
| 1920×1088 | 50 | ~91 s | ~8.9 GB |
Entscheidung: standard = 30 Steps (Default, ~31 s bei 1024×1024),
high = 50 Steps (maximale Qualität, ~45 s bei 1024×1024). Ab 20–30 Steps
ist der Qualitätsgewinn bei einfachen Motiven gering; 50 Steps lohnt sich
für komplexe Szenen.
Hinweis: FLUX.2 [klein] 4B Base passt nicht vollständig GPU-resident
in 16 GB (OOM bei ~15.5 GB). Deshalb wird enable_model_cpu_offload()
verwendet (Modelle werden pro Layer zwischen CPU und GPU gewechselt).
Hotswap-Gesamtzeit: Ein vollständiger Bild-Job (Qwen stoppen → FLUX laden → Bild → FLUX entladen → Qwen wiederherstellen) dauert ~41–42 s bei 1024×1024 / 30 Steps (davon ~31 s Generierung, ~10 s Qwen-Stop/Start + VRAM-Check).
Erforderliche Python-Pakete (im Venv)
torch(2.11.0+cu128, CUDA 12.8)diffusers(0.40.0.dev0, fürFlux2KleinPipeline)transformers(5.15.0)accelerate(1.14.0, fürenable_model_cpu_offload())
Das Venv liegt unter /opt/mike-ai/ai-profile-router/venv/ und wird von
install.sh automatisch angelegt/aktualisiert.
Sprachausgabe (Kokoro-82M, deutsch, CPU-only)
Der Router stellt lokale deutsche Sprachausgabe bereit. Die Synthese läuft
in einem separaten, langlebigen Worker (mike-ai-kokoro.service), der
die Kokoro-Modelle einmalig beim Start lädt und dauerhaft im RAM hält
(niedrige Warm-Start-Latenz). Der Worker ist CPU-only und blockiert weder
Qwen/llama.cpp noch FLUX/GPU – er teilt sich nur den Prozessor.
- Modell: Kokoro-82M (hexgrad) mit zwei deutschen Feintunings
(
kikiri-tts/kikiri-german-martin,kikiri-tts/kikiri-german-victoria, beide Apache 2.0, je ~327 MB). - G2P: deutsche Phonemisierung über
espeak-ng(phonemizer), kein spacy nötig. Deutsche Sprachunterstützung per Patch (kokoro PR #340). - Venv: eigenes Venv unter
/opt/mike-ai/kokoro/venv/mit CPU-onlytorch(keine CUDA-Abhängigkeit, kein Konflikt mit dem Bild-Venv). - Modelle:
/opt/mike-ai/models/kokoro/(persistent).
Endpunkt POST /v1/audio/speech
OpenAI-kompatibel. Unterstützt input (oder text), voice, speed,
response_format, model.
| Parameter | Werte | Default |
|---|---|---|
input |
Text (Pflicht, max. 8000 Zeichen) | – |
voice |
martin, victoria |
martin |
speed |
0.5–2.0 | 1.0 |
response_format |
mp3 (Default), wav, flac, pcm |
mp3 |
model |
kokoro-german (optional) |
– |
Die Antwort ist binäres Audio (nicht JSON) mit passendem
Content-Type (audio/mpeg, audio/wav, audio/flac,
application/octet-stream).
Beispiele:
# MP3 (Default), Stimme martin
curl -s http://192.168.1.196:8081/v1/audio/speech \
-H 'Content-Type: application/json' \
-d '{"input":"Hallo, dies ist ein Test.","voice":"martin"}' -o out.mp3
# WAV, Stimme victoria, 1.5x Tempo
curl -s http://192.168.1.196:8081/v1/audio/speech \
-H 'Content-Type: application/json' \
-d '{"input":"Guten Tag.","voice":"victoria","speed":1.5,"response_format":"wav"}' -o out.wav
Lange Texte: Das Modell verarbeitet pro Segment max. 510 Phoneme
(~25–30 s). Längere Texte werden mit Zeilenumbrüchen (\n) in Segmente
teilt; jedes Segment wird einzeln synthetisiert und die Audios
zusammengeführt.
Verhalten
- Kein GPU-Lock: TTS läuft CPU-only und greift nicht in den GPU-Hotswap (Bild) oder Profilwechsel (Qwen) ein. TTS-Requests können parallel zu Chats laufen.
- Serialisierte Synthese: Der Worker synthetisiert nacheinander (CPU-bound), parallele Requests werden intern gewartet.
/statuszeigttts.reachable,tts.ready,tts.voices,tts.load_errors,tts.last_seconds,tts.last_voice,tts.last_error.- Fehler: Worker down →
503(tts_failed); ungültige Parameter →400. OpenAI-kompatibles Fehlerformat.
Hörproben
Zwei deutsche Hörproben (je ~40 s) liegen unter
/opt/mike-ai/ai-profile-router/samples/:
martin_lang.wav(Stimme martin)victoria_lang.wav(Stimme victoria)
Benchmark (CPU-only, gemessen)
| Textlänge | Audio | Synthese | RTF |
|---|---|---|---|
| ~5 s | 3.4 s | 0.6 s | 0.19× |
| ~15 s | 14.1 s | 3.4 s | 0.24× |
| ~40 s | 33.5 s | 7.5 s | 0.23× |
RTF ~0.23 bedeutet: Synthese ist ~4.3× schneller als Echtzeit. RAM-Belegung des Workers: ~3.5 GB (inkl. torch, beide Modelle).
Erforderliche Python-Pakete (im Kokoro-Venv)
torch(CPU-only,--index-url https://download.pytorch.org/whl/cpu)kokoro(0.7.16, mit--no-depsinstalliert)misaki(G2P, ohne[en]-Extra → kein thinc 9.x / spacy)phonemizer+ System-Paketespeak-ng(deutsche G2P)soundfile,lameenc(Audio-Formate wav/mp3/flac/pcm)huggingface-hub,loguru,scipy,transformers,regex,num2words
Das Venv liegt unter /opt/mike-ai/kokoro/venv/ und wird von install.sh
automatisch angelegt (nur wenn noch nicht vorhanden).
Hinweis (Python 3.13): kokoro verlangt offiziell misaki[en], das
spacy-curated-transformers → thinc 9.x zieht – für Python 3.13 gibt es
keine thinc-9-Wheels. Deshalb wird kokoro mit --no-deps und misaki
ohne [en] installiert; die deutsche G2P läuft über espeak-ng und braucht
spacy nicht.
Repository-Struktur
router/ai_profile_router.py # der Router (einzige Laufzeit-Datei)
router/image_worker.py # FLUX-Worker (eigener Prozess, JSON-Protokoll)
router/tts_worker.py # Kokoro-TTS-Worker (eigener Prozess, HTTP-API)
deploy/mike-ai-profile-router.service # systemd-Unit (Router)
deploy/mike-ai-kokoro.service # systemd-Unit (TTS-Worker)
deploy/install.sh # läuft auf dem Zielsystem (per SSH)
deploy/deploy.sh # läuft lokal: SCP + SSH
dev/ # lokale Tests (Mock-llama.cpp, Mock-Worker, Benchmarks)
Entwicklungsdateien (dev/) und Deployment-Dateien (router/, deploy/)
sind getrennt. Auf dem Zielsystem landet nur router/ + deploy/.
Deployment
Voraussetzung: SSH-Key ~/.ssh/lmstudio_unraid (bereits vorhanden).
./deploy/deploy.sh
Das Skript:
- Überträgt
ai_profile_router.py,image_worker.py,tts_worker.py,install.shund beide systemd-Units per SCP nach/tmp/ai-profile-router/auf dem Zielsystem. - Führt
install.shper SSH aus, das:- den alten Router (
mike-ai-local-llm-router.service+/opt/mike-ai/local-llm-router) mit Backup entfernt, - den neuen Router + Worker nach
/opt/mike-ai/ai-profile-router/installiert, - ein Python-Venv mit
torch,diffusers,transformers,accelerateanlegt (nur wenn noch nicht vorhanden), - das FLUX-Modell nach
/opt/mike-ai/models/FLUX.2-klein-base-4Blädt (nur wenn noch nicht vorhanden, ~15 GB), espeak-nginstalliert (nur wenn noch nicht vorhanden),- das Kokoro-Venv unter
/opt/mike-ai/kokoro/venv/anlegt (nur wenn noch nicht vorhanden, CPU-only torch + kokoro + misaki + phonemizer + soundfile + lameenc), - die Kokoro-Modelle nach
/opt/mike-ai/models/kokoro/lädt (nur wenn noch nicht vorhanden, ~660 MB), mike-ai-profile-router.serviceundmike-ai-kokoro.serviceaktivieren (Start beim Boot) und starten,GET /statusverifiziert.
- den alten Router (
Die Installation ist idempotent (Update = erneut ausführen).
Konfiguration (Umgebungsvariablen in der systemd-Unit)
| Variable | Default | Bedeutung |
|---|---|---|
ROUTER_HOST |
0.0.0.0 |
Bind-Adresse |
ROUTER_PORT |
8081 |
Port |
UPSTREAM_URL |
http://127.0.0.1:8080 |
llama.cpp |
PROFILE_SCRIPT |
/usr/local/bin/llama-profile |
Profil-Skript |
PROFILE_DIR |
/etc/systemd/system/mike-ai-llama-ui.service.d |
Ort der override.conf |
SWITCH_TIMEOUT |
600 |
Warten auf llama.cpp nach Wechsel (s) |
REQUEST_TIMEOUT |
600 |
Read-Timeout für Upstream-Requests (s) |
CONNECT_TIMEOUT |
10 |
Connect-Timeout Upstream (s) |
POLL_INTERVAL |
2 |
Polling-Intervall (s) |
LOG_LEVEL |
INFO |
Logging-Level |
LLAMA_SERVICE |
mike-ai-llama-ui.service |
llama.cpp-Service (für Bild-Hotswap) |
IMAGE_WORKER |
<router-dir>/image_worker.py |
FLUX-Worker-Skript |
IMAGE_PYTHON |
sys.executable |
Python für den Worker (venv mit torch) |
IMAGE_DIR |
/opt/mike-ai/ai-profile-router/images |
Bild-Speicherort |
IMAGE_WORKER_LOG |
/opt/mike-ai/ai-profile-router/worker.log |
Worker-Log |
IMAGE_GEN_TIMEOUT |
600 |
Timeout pro Bild (s) |
IMAGE_VRAM_FREE_TIMEOUT |
120 |
Warten auf VRAM-Freiheit (s) |
CHAT_WAIT_TIMEOUT |
300 |
Chat wartet auf Qwen (s) |
TTS_WORKER_URL |
http://127.0.0.1:8082 |
TTS-Worker (Router-Seite) |
TTS_TIMEOUT |
300 |
Timeout pro Synthese (s) |
TTS_CONNECT_TIMEOUT |
5 |
Connect-Timeout TTS-Worker (s) |
TTS-Worker (mike-ai-kokoro.service):
| Variable | Default | Bedeutung |
|---|---|---|
KOKORO_HOST |
127.0.0.1 |
Bind-Adresse (nur lokal, Router proxyt) |
KOKORO_PORT |
8082 |
Port |
KOKORO_MODEL_DIR |
/opt/mike-ai/models/kokoro |
Modell-Verzeichnis |
LOG_LEVEL |
INFO |
Logging-Level |
Lokale Tests
./dev/test_local.sh
Startet einen Mock-llama.cpp, einen Mock-Bild-Worker, einen Mock-TTS-Worker
und den Router mit einem Fake-Profil-Skript und prüft: /v1/models,
/status, Forwarding, Streaming, Tool Calls, Profilwechsel
(fast→medium→fast), virtuelles Modell triggert Wechsel, ungültige Profile,
Upstream down → 502, Recovery, Bildgenerierung (standard→30 Steps,
high→50 Steps, Validierung, Image-Fehler→Qwen wiederhergestellt,
Fast/Medium/Long→Image→gleiches Profil, /status während Bild-Job,
paralleler Chat während Bild-Job wartet statt 502) und Sprachausgabe
(/status mit tts-Section, POST /v1/audio/speech wav/mp3, Validierung,
Worker-Fehler→503, Worker down→503, Worker-Neustart→Recovery).
Aktuell: 43 Tests (32 bestehende + 11 TTS-Assertions).
Betrieb
systemctl status mike-ai-profile-router
systemctl status mike-ai-kokoro
journalctl -u mike-ai-profile-router -f
journalctl -u mike-ai-kokoro -f
curl -s http://192.168.1.196:8081/status | python3 -m json.tool
curl -s -X POST http://192.168.1.196:8081/medium
# TTS-Test
curl -s http://192.168.1.196:8081/v1/audio/speech \
-H 'Content-Type: application/json' \
-d '{"input":"Hallo","voice":"martin"}' -o test.mp3
Sicherheit
- Keine Shell-Aufrufe: Profil-Skript wird mit
subprocess.run([script, profil])aufgerufen,profilist Whitelist-geprüft (fast|medium|long). - Keine Secrets/Tokens im Code oder in der Unit.
- systemd-Hardening:
NoNewPrivileges=true,PrivateTmp=true. - Die bestehende llama.cpp-/Profil-Konfiguration wird nicht verändert; der
Router nutzt nur das vorhandene
llama-profile-Skript und liest dieoverride.conf.