diff --git a/README.md b/README.md index 5a74391..b682d42 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Sprachausgabe bereit (Kokoro-82M, CPU-only, OpenAI-kompatibel). | Router-Port | **8081** | | Router-Service | `mike-ai-profile-router.service` | | TTS-Worker | `http://127.0.0.1:8082` (Service `mike-ai-kokoro.service`) | +| STT-Worker | `http://127.0.0.1:8084` (Service `mike-ai-whisper.service`) | ## Profile / virtuelle Modelle @@ -40,6 +41,9 @@ Sprachausgabe bereit (Kokoro-82M, CPU-only, OpenAI-kompatibel). | `GET /images` | Liste der gespeicherten Bilder (max. 200) | | `GET /images/` | PNG-Download (nur `images/`-Verzeichnis, validiert) | | `POST /v1/audio/speech` | Deutsche Sprachausgabe (Kokoro-82M, OpenAI-kompatibel) | +| `POST /v1/audio/transcriptions` | Deutsche Spracherkennung (whisper.cpp, OpenAI-kompatibel) | +| `GET /v1/audio/models` | Verfügbare Audio-Modelle (STT + TTS) | +| `GET /v1/audio/voices` | Verfügbare TTS-Stimmen | | alles andere | Transparente Weiterleitung an llama.cpp | ### Verhalten @@ -280,14 +284,106 @@ Pfad `phonemizer-fork` + `espeakng-loader` (kein spacy-curated-transformers, kein thinc 9.x). `spacy` wird nur für den `misaki.en`-Import benötigt (Englisch), nicht für den deutschen Pfad. +## Spracherkennung (whisper.cpp, deutsch, CPU-only) + +Der Router stellt lokale deutsche Spracherkennung bereit. Die Transkription +läuft in einem **separaten, langlebigen Worker** (`mike-ai-whisper.service`), +der `whisper-cli` als Subprozess aufruft. Der Worker ist CPU-only und +blockiert weder Qwen/llama.cpp noch FLUX/GPU – er teilt sich nur den +Prozessor. + +- **Modell:** Whisper large-v3-turbo (ggml, ~1.6 GB, Vollpräzision) +- **Build:** whisper.cpp CPU-only (AVX2+FMA, 8 Threads) +- **Audio-Vorbereitung:** ffmpeg konvertiert WebM/Opus/M4A/AAC → 16 kHz mono WAV +- **Nativ unterstützt:** WAV, MP3, OGG, FLAC +- **Kein GPU-Lock:** STT läuft vollständig auf CPU, parallel zu Qwen (GPU) + und TTS (CPU) + +### Endpunkt `POST /v1/audio/transcriptions` + +OpenAI-kompatibel (Multipart-Form-Data). Unterstützt `file`, `model`, +`language`, `prompt`, `temperature`, `response_format`. + +| Parameter | Werte | Default | +|---|---|---| +| `file` | Audio-Datei (Pflicht: webm, wav, mp3, m4a, ogg, flac) | – | +| `model` | `whisper-1` (oder `whisper`) | `whisper-1` | +| `language` | `de`, `en`, … (optional) | Auto-Detektion | +| `prompt` | Kontext-Hinweis (optional) | – | +| `temperature` | 0.0–1.0 (optional) | 0.0 | +| `response_format` | `json` (Default), `verbose_json` | `json` | + +Die Antwort ist **JSON** mit `text` (und optional `language`, `duration` +bei `verbose_json`). + +Beispiele: + +```bash +# WebM/Opus (z.B. aus Open WebUI-Mikrofon) +curl -s http://192.168.1.196:8081/v1/audio/transcriptions \ + -F "file=@aufnahme.webm" \ + -F "model=whisper-1" + +# WAV mit expliziter Sprache +curl -s http://192.168.1.196:8081/v1/audio/transcriptions \ + -F "file=@aufnahme.wav" \ + -F "model=whisper-1" \ + -F "language=de" + +# Verbose-Format +curl -s http://192.168.1.196:8081/v1/audio/transcriptions \ + -F "file=@aufnahme.wav" \ + -F "model=whisper-1" \ + -F "response_format=verbose_json" +``` + +### Discovery-Endpunkte + +- `GET /v1/audio/models` – listet verfügbare Audio-Modelle + (`whisper-1` für STT, `kokoro-german` für TTS) +- `GET /v1/audio/voices` – listet verfügbare TTS-Stimmen + (`martin`, `victoria`) + +### Verhalten + +- **Kein GPU-Lock:** STT läuft CPU-only und greift nicht in den + GPU-Hotswap (Bild) oder Profilwechsel (Qwen) ein. STT-Requests können + parallel zu Chats, TTS und Bildgenerierung laufen. +- **Serialisierte Transkription:** Der Worker transkribiert nacheinander + (CPU-bound), parallele Requests werden intern gewartet. +- **`/status`** zeigt `stt.reachable`, `stt.ready`, `stt.model`, + `stt.threads`, `stt.language`, `stt.ffmpeg_exists`. +- **Fehler:** Worker down → `503` (`stt_failed`); ungültige Parameter → + `400`. OpenAI-kompatibles Fehlerformat. + +### Benchmark (CPU-only, gemessen) + +| Audio-Dauer | Transkription | RTF | +|---|---|---| +| 7.3 s | 8.9 s | 1.22× | +| 30 s | 16.8 s | 0.56× | +| 50 s | 18.5 s | 0.37× | + +RTF < 1.0 bedeutet: Transkription ist schneller als Echtzeit. +RAM-Belegung des Workers: ~1.7 GB (inkl. Modell). + +### Erforderliche Komponenten + +- `whisper.cpp` (CPU-only Build, `/opt/mike-ai/whisper.cpp/build-cpu/`) +- `ggml-large-v3-turbo.bin` (`/opt/mike-ai/models/whisper/`) +- `ffmpeg` (für WebM/Opus/M4A/AAC-Konvertierung) +- Python 3.13 (nur Standardbibliothek, kein Venv nötig) + ## 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) +router/stt_worker.py # Whisper-STT-Worker (eigener Prozess, HTTP-API) deploy/mike-ai-profile-router.service # systemd-Unit (Router) deploy/mike-ai-kokoro.service # systemd-Unit (TTS-Worker) +deploy/mike-ai-whisper.service # systemd-Unit (STT-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) diff --git a/deploy/deploy.sh b/deploy/deploy.sh index 82305b6..bb2e347 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -10,8 +10,9 @@ STAGE="/tmp/ai-profile-router-$$" mkdir -p "$STAGE" cp router/ai_profile_router.py router/image_worker.py router/tts_worker.py \ + router/stt_worker.py \ deploy/install.sh deploy/mike-ai-profile-router.service \ - deploy/mike-ai-kokoro.service "$STAGE/" + deploy/mike-ai-kokoro.service deploy/mike-ai-whisper.service "$STAGE/" echo "== Übertrage Dateien nach ${TARGET}:/tmp/ai-profile-router/" ssh -i "$SSH_KEY" "$TARGET" 'mkdir -p /tmp/ai-profile-router' diff --git a/deploy/install.sh b/deploy/install.sh index 5f6c4f6..7c3660e 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -10,6 +10,7 @@ DIR="$(cd "$(dirname "$0")" && pwd)" INSTALL_DIR=/opt/mike-ai/ai-profile-router SERVICE=mike-ai-profile-router.service KOKORO_SERVICE=mike-ai-kokoro.service +WHISPER_SERVICE=mike-ai-whisper.service OLD_SERVICE=mike-ai-local-llm-router.service OLD_DIR=/opt/mike-ai/local-llm-router BACKUP_DIR=/opt/mike-ai/.backup-ai-profile-router-$(date +%Y%m%d-%H%M%S) @@ -40,8 +41,10 @@ mkdir -p "$INSTALL_DIR" "$IMAGE_DIR" install -m 0755 "$DIR/ai_profile_router.py" "$INSTALL_DIR/ai_profile_router.py" install -m 0755 "$DIR/image_worker.py" "$INSTALL_DIR/image_worker.py" install -m 0755 "$DIR/tts_worker.py" "$INSTALL_DIR/tts_worker.py" +install -m 0755 "$DIR/stt_worker.py" "$INSTALL_DIR/stt_worker.py" install -m 0644 "$DIR/${SERVICE}" "/etc/systemd/system/${SERVICE}" install -m 0644 "$DIR/${KOKORO_SERVICE}" "/etc/systemd/system/${KOKORO_SERVICE}" +install -m 0644 "$DIR/${WHISPER_SERVICE}" "/etc/systemd/system/${WHISPER_SERVICE}" # --- 3. Python-Venv mit Bild-Abhängigkeiten --------------------------------- if [ ! -x "$VENV/bin/python" ]; then @@ -134,8 +137,9 @@ fi # --- 6. Services aktivieren und starten --------------------------------------- systemctl daemon-reload -systemctl enable "$SERVICE" "$KOKORO_SERVICE" +systemctl enable "$SERVICE" "$KOKORO_SERVICE" "$WHISPER_SERVICE" systemctl restart "$KOKORO_SERVICE" +systemctl restart "$WHISPER_SERVICE" systemctl restart "$SERVICE" # --- 7. Verifikation ---------------------------------------------------------- @@ -152,5 +156,11 @@ if ! systemctl is-active --quiet "$KOKORO_SERVICE"; then exit 1 fi echo "-- Kokoro-Service läuft" +if ! systemctl is-active --quiet "$WHISPER_SERVICE"; then + echo "-- FEHLER: Whisper-Service läuft nicht" >&2 + journalctl -u "$WHISPER_SERVICE" -n 20 --no-pager >&2 + exit 1 +fi +echo "-- Whisper-Service läuft" curl -sf "http://127.0.0.1:8081/status" | python3 -m json.tool echo "== Fertig ==" diff --git a/deploy/mike-ai-whisper.service b/deploy/mike-ai-whisper.service new file mode 100644 index 0000000..ce9cac9 --- /dev/null +++ b/deploy/mike-ai-whisper.service @@ -0,0 +1,25 @@ +[Unit] +Description=Whisper STT Worker (deutsche Spracherkennung, CPU-only) +After=network.target + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/mike-ai/ai-profile-router +Environment=WHISPER_HOST=127.0.0.1 +Environment=WHISPER_PORT=8084 +Environment=WHISPER_CLI=/opt/mike-ai/whisper.cpp/build-cpu/bin/whisper-cli +Environment=WHISPER_MODEL=/opt/mike-ai/models/whisper/ggml-large-v3-turbo.bin +Environment=WHISPER_THREADS=8 +Environment=WHISPER_LANGUAGE=de +Environment=FFMPEG_BIN=/usr/bin/ffmpeg +Environment=LOG_LEVEL=INFO +ExecStart=/usr/bin/python3 /opt/mike-ai/ai-profile-router/stt_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/dev/mock_stt_worker.py b/dev/mock_stt_worker.py new file mode 100644 index 0000000..e4fef06 --- /dev/null +++ b/dev/mock_stt_worker.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""Mock-STT-Worker für lokale Tests. + +Simuliert den Whisper-STT-Worker: + GET /status → ready: true + POST /transcribe → liefert festes Transkript + +Konfiguration: + MOCK_STT_PORT Port (Default: 18083) + MOCK_STT_DELAY Verzögerung in Sekunden (Default: 0.1) + MOCK_STT_LOG JSONL-Log für Requests (optional) +""" + +import json +import os +import sys +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +PORT = int(os.environ.get("MOCK_STT_PORT", "18083")) +DELAY = float(os.environ.get("MOCK_STT_DELAY", "0.1")) +LOG_FILE = os.environ.get("MOCK_STT_LOG", "") + + +class MockSTTHandler(BaseHTTPRequestHandler): + server_version = "MockSTT/1.0" + + def log_message(self, fmt, *args): + pass + + def _send_json(self, code: int, obj: dict) -> None: + body = json.dumps(obj, ensure_ascii=False).encode("utf-8") + self.send_response(code) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(body) + + def _read_body(self) -> bytes: + length = int(self.headers.get("Content-Length", 0)) + return self.rfile.read(length) if length > 0 else b"" + + def do_GET(self): + if self.path == "/status": + self._send_json(200, { + "ready": True, + "model": "mock-whisper-large-v3-turbo", + "model_exists": True, + "whisper_cli_exists": True, + "threads": 8, + "language": "de", + "ffmpeg_exists": True, + }) + else: + self._send_json(404, {"error": "nicht gefunden"}) + + def do_POST(self): + if self.path != "/transcribe": + self._send_json(404, {"error": "nicht gefunden"}) + return + + data = self._read_body() + content_type = self.headers.get("Content-Type", "") + + # Multipart parsen (einfach) + filename = "" + language = None + prompt = None + temperature = None + file_data = b"" + + if "multipart/form-data" in content_type: + # Boundary extrahieren + boundary = None + for part in content_type.split(";"): + part = part.strip() + if part.startswith("boundary="): + boundary = part[len("boundary="):] + break + if boundary: + boundary_bytes = boundary.encode("utf-8") + parts = data.split(b"--" + boundary_bytes) + for part in parts: + if part in (b"", b"--", b"--\r\n", b"\r\n"): + continue + if b"\r\n\r\n" not in part: + continue + header_part, body_part = part.split(b"\r\n\r\n", 1) + if body_part.endswith(b"\r\n"): + body_part = body_part[:-2] + header_text = header_part.decode("utf-8", errors="replace") + for line in header_text.split("\r\n"): + if "name=" in line and "filename=" in line: + for kv in line.split(";"): + kv = kv.strip() + if kv.startswith("filename="): + filename = kv[len("filename="):].strip('"') + file_data = body_part + elif "name=" in line: + name = line.split("name=")[1].strip().strip('"') + if name == "language": + language = body_part.decode("utf-8", errors="replace") + elif name == "prompt": + prompt = body_part.decode("utf-8", errors="replace") + elif name == "temperature": + temperature = body_part.decode("utf-8", errors="replace") + + # Log + if LOG_FILE: + entry = { + "timestamp": time.time(), + "filename": filename, + "file_size": len(file_data), + "language": language, + "prompt": prompt, + "temperature": temperature, + } + with open(LOG_FILE, "a") as f: + f.write(json.dumps(entry) + "\n") + + time.sleep(DELAY) + + # Simuliertes Transkript + text = "Hallo, dies ist ein Test der deutschen Spracherkennung." + if language == "de": + text = "Hallo, dies ist ein Test der deutschen Spracherkennung." + elif language == "en": + text = "Hello, this is a test of English speech recognition." + + self._send_json(200, { + "text": text, + "language": language or "de", + "duration_ms": int(DELAY * 1000), + "audio_duration_ms": 7300, + }) + + +def main(): + print(f"Mock-STT-Worker lauscht auf Port {PORT}", flush=True) + server = ThreadingHTTPServer(("127.0.0.1", PORT), MockSTTHandler) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() + + +if __name__ == "__main__": + main() diff --git a/dev/test_local.sh b/dev/test_local.sh index df8a576..fe70565 100755 --- a/dev/test_local.sh +++ b/dev/test_local.sh @@ -7,13 +7,14 @@ cd "$(dirname "$0")/.." UP_PORT=18080 RT_PORT=18081 TTS_PORT=18082 +STT_PORT=18083 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:-}" "${TTS_PID:-}" 2>/dev/null || true + kill "${MOCK_PID:-}" "${ROUTER_PID:-}" "${TTS_PID:-}" "${STT_PID:-}" 2>/dev/null || true rm -f /tmp/mock_pid2 /tmp/mock_upstream_pid wait 2>/dev/null || true } @@ -54,6 +55,7 @@ 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" \ +STT_WORKER_URL="http://127.0.0.1:$STT_PORT" \ python3 router/ai_profile_router.py >/tmp/router_test.log 2>&1 & ROUTER_PID=$! sleep 0.5 @@ -68,6 +70,15 @@ TTS_PID=$! sleep 0.5 rm -f /tmp/test_tts_requests.jsonl +# --- Mock-STT-Worker starten ---------------------------------------------------- +echo "== Starte Mock-STT-Worker (Port $STT_PORT)" +MOCK_STT_PORT="$STT_PORT" MOCK_STT_DELAY=0.1 \ +MOCK_STT_LOG=/tmp/test_stt_requests.jsonl \ + python3 dev/mock_stt_worker.py >/tmp/mock_stt.log 2>&1 & +STT_PID=$! +sleep 0.5 +rm -f /tmp/test_stt_requests.jsonl + # --- 1. /v1/models ------------------------------------------------------------- echo "== Test 1: /v1/models" RESP=$(curl -sf "$BASE/v1/models") @@ -493,6 +504,144 @@ CODE=$(curl -s -o /tmp/tts33.wav -w "%{http_code}" "$BASE/v1/audio/speech" \ [ "$CODE" = "200" ] && [ -s /tmp/tts33.wav ] \ && ok "TTS nach Neustart wieder verfügbar" || bad "TTS-Recovery (Code $CODE)" +# --- 34. STT: /status zeigt stt-Section --------------------------------------------------------------- +echo "== Test 34: /status mit stt-Section" +RESP=$(curl -sf "$BASE/status") +echo "$RESP" | python3 -m json.tool +echo "$RESP" | python3 -c ' +import json,sys +d=json.load(sys.stdin) +stt=d["stt"] +assert stt["reachable"] is True, stt +assert stt["ready"] is True, stt +' && ok "Status: STT erreichbar, bereit" || bad "Status stt-Section" + +# --- 35. STT: POST /v1/audio/transcriptions (WAV) --------------------------------------------------------------- +echo "== Test 35: POST /v1/audio/transcriptions (WAV)" +# Test-WAV erstellen (leere WAV-Header + Daten) +python3 -c " +import struct, wave +with wave.open('/tmp/stt_test.wav', 'w') as w: + w.setnchannels(1) + w.setsampwidth(2) + w.setframerate(16000) + w.writeframes(b'\x00' * 16000 * 3) # 3 Sekunden Stille +" +CODE=$(curl -s -o /tmp/stt35.json -w "%{http_code}" \ + "$BASE/v1/audio/transcriptions" \ + -F "file=@/tmp/stt_test.wav" \ + -F "model=whisper-1") +cat /tmp/stt35.json; echo +[ "$CODE" = "200" ] && python3 -c "import json; d=json.load(open('/tmp/stt35.json')); assert 'text' in d" \ + && ok "STT WAV (200, text vorhanden)" || bad "STT WAV (Code $CODE)" + +# --- 36. STT: POST /v1/audio/transcriptions (language=de) ------------------------------------------------------- +echo "== Test 36: POST /v1/audio/transcriptions (language=de)" +CODE=$(curl -s -o /tmp/stt36.json -w "%{http_code}" \ + "$BASE/v1/audio/transcriptions" \ + -F "file=@/tmp/stt_test.wav" \ + -F "model=whisper-1" \ + -F "language=de") +cat /tmp/stt36.json; echo +[ "$CODE" = "200" ] && python3 -c "import json; d=json.load(open('/tmp/stt36.json')); assert 'text' in d" \ + && ok "STT language=de (200)" || bad "STT language=de (Code $CODE)" + +# --- 37. STT: unbekanntes Modell → 400 --------------------------------------------------------------------------- +echo "== Test 37: STT unbekanntes Modell → 400" +CODE=$(curl -s -o /tmp/err37.json -w "%{http_code}" \ + "$BASE/v1/audio/transcriptions" \ + -F "file=@/tmp/stt_test.wav" \ + -F "model=gpt-4") +cat /tmp/err37.json; echo +[ "$CODE" = "400" ] && ok "400 bei unbekanntem STT-Modell" || bad "erwartet 400, bekam $CODE" + +# --- 38. STT: Worker down → 503 ----------------------------------------------------------------------------------- +echo "== Test 38: STT Worker down → 503" +kill "$STT_PID" 2>/dev/null || true +sleep 0.5 +CODE=$(curl -s -o /tmp/err38.json -w "%{http_code}" \ + "$BASE/v1/audio/transcriptions" \ + -F "file=@/tmp/stt_test.wav" \ + -F "model=whisper-1") +cat /tmp/err38.json; echo +[ "$CODE" = "503" ] && ok "503 bei downem STT-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["stt"]["reachable"] is False, d["stt"] +' && ok "Status: STT nicht erreichbar" || bad "Status nach STT-Down" + +# --- 39. STT: Worker-Neustart → Recovery --------------------------------------------------------------------------- +echo "== Test 39: STT Worker-Neustart → Recovery" +MOCK_STT_PORT="$STT_PORT" MOCK_STT_DELAY=0.1 \ + python3 dev/mock_stt_worker.py >/tmp/mock_stt2.log 2>&1 & +STT_PID=$! +sleep 0.5 +CODE=$(curl -s -o /tmp/stt39.json -w "%{http_code}" \ + "$BASE/v1/audio/transcriptions" \ + -F "file=@/tmp/stt_test.wav" \ + -F "model=whisper-1") +[ "$CODE" = "200" ] && ok "STT nach Neustart wieder verfügbar" || bad "STT-Recovery (Code $CODE)" + +# --- 40. /v1/audio/models ------------------------------------------------------------------------------------------ +echo "== Test 40: GET /v1/audio/models" +RESP=$(curl -sf "$BASE/v1/audio/models") +echo "$RESP" | python3 -m json.tool +echo "$RESP" | python3 -c ' +import json,sys +d=json.load(sys.stdin) +ids={m["id"] for m in d["data"]} +assert "whisper-1" in ids, ids +assert "kokoro-german" in ids, ids +' && ok "Audio-Modelle: whisper-1 + kokoro-german" || bad "Audio-Modelle" + +# --- 41. /v1/audio/voices ------------------------------------------------------------------------------------------ +echo "== Test 41: GET /v1/audio/voices" +RESP=$(curl -sf "$BASE/v1/audio/voices") +echo "$RESP" | python3 -m json.tool +echo "$RESP" | python3 -c ' +import json,sys +d=json.load(sys.stdin) +ids={v["id"] for v in d["data"]} +assert "martin" in ids, ids +assert "victoria" in ids, ids +' && ok "Audio-Voices: martin + victoria" || bad "Audio-Voices" + +# --- 42. STT + Qwen parallel ---------------------------------------------------------------------------------------- +echo "== Test 42: STT + Qwen parallel" +# STT-Request im Hintergrund +curl -sf "$BASE/v1/audio/transcriptions" \ + -F "file=@/tmp/stt_test.wav" \ + -F "model=whisper-1" >/tmp/stt42.json 2>&1 & +STT_PID42=$! +sleep 0.2 +# Qwen-Request +RESP=$(curl -sf "$BASE/v1/chat/completions" -H "Content-Type: application/json" \ + -d '{"model":"qwen-fast","messages":[{"role":"user","content":"Hallo"}]}') +wait $STT_PID42 +echo "$RESP" | python3 -c ' +import json,sys +d=json.load(sys.stdin) +assert "Mock-Antwort" in d["choices"][0]["message"]["content"], d +' && ok "STT + Qwen parallel (beide 200)" || bad "STT + Qwen parallel" + +# --- 43. STT + TTS parallel ------------------------------------------------------------------------------------------ +echo "== Test 43: STT + TTS parallel" +# STT-Request im Hintergrund +curl -sf "$BASE/v1/audio/transcriptions" \ + -F "file=@/tmp/stt_test.wav" \ + -F "model=whisper-1" >/tmp/stt43.json 2>&1 & +STT_PID43=$! +sleep 0.2 +# TTS-Request +CODE=$(curl -s -o /tmp/tts43.mp3 -w "%{http_code}" \ + "$BASE/v1/audio/speech" -H "Content-Type: application/json" \ + -d '{"input":"Hallo","voice":"martin"}') +wait $STT_PID43 +[ "$CODE" = "200" ] && [ -s /tmp/tts43.mp3 ] \ + && ok "STT + TTS parallel (beide 200)" || bad "STT + TTS parallel (TTS Code $CODE)" + # --- Ergebnis -------------------------------------------------------------------------------------------- echo echo "== Ergebnis: $PASS bestanden, $FAIL fehlgeschlagen ==" diff --git a/router/ai_profile_router.py b/router/ai_profile_router.py index 4838445..5b67122 100755 --- a/router/ai_profile_router.py +++ b/router/ai_profile_router.py @@ -22,10 +22,16 @@ Bildgenerierung (FLUX.2 [klein] 4B Base): Sprachausgabe (Kokoro-82M, deutsch, CPU-only): POST /v1/audio/speech (OpenAI-kompatibel) + GET /v1/audio/voices (verfügbare Stimmen) -Der TTS-Worker (mike-ai-kokoro.service) läuft als separater, langlebiger -Prozess mit eigenem Venv und hält die Modelle dauerhaft im RAM. Der -Router leitet /v1/audio/speech per HTTP an den Worker weiter. +Spracherkennung (whisper.cpp, deutsch, CPU-only): + POST /v1/audio/transcriptions (OpenAI-kompatibel) + GET /v1/audio/models (verfügbare Audio-Modelle) + +Der TTS-Worker (mike-ai-kokoro.service) und der STT-Worker +(mike-ai-whisper.service) laufen als separate, langlebige Prozesse. +Der Router leitet /v1/audio/speech und /v1/audio/transcriptions +per HTTP an die Worker weiter. Der Router agiert als Modell-Orchestrator: vor der Generierung wird llama.cpp gestoppt, der Bild-Worker lädt FLUX, generiert und entlädt @@ -48,6 +54,7 @@ import subprocess import sys import threading import time +import uuid import http.client from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer @@ -108,6 +115,12 @@ TTS_DEFAULT_VOICE = "martin" TTS_FORMATS = ("mp3", "wav", "flac", "pcm") TTS_DEFAULT_FORMAT = "mp3" +# --- Spracherkennung (whisper.cpp, deutsch, CPU-only) --- +STT_WORKER_URL = os.environ.get("STT_WORKER_URL", "http://127.0.0.1:8084") +STT_TIMEOUT = float(os.environ.get("STT_TIMEOUT", "120")) # s, pro Transkription +STT_CONNECT_TIMEOUT = float(os.environ.get("STT_CONNECT_TIMEOUT", "5")) +STT_MODEL = "whisper-1" # virtuelles Modell für /v1/audio/transcriptions + # Chat-Waiting: Während eines Image-Jobs oder Profilwechsels ist Qwen # down. Chat-Requests warten (statt 502) bis Qwen wieder bereit ist. CHAT_WAIT_TIMEOUT = float(os.environ.get("CHAT_WAIT_TIMEOUT", "300")) # s, max. Warten @@ -256,6 +269,72 @@ def tts_synthesize(text: str, voice: str, speed: float, return body, content_type +def stt_status() -> dict: + """Prüft den STT-Worker: erreichbar? bereit?""" + hostport = STT_WORKER_URL.split("://", 1)[-1] + host, _, port = hostport.partition(":") + try: + conn = http.client.HTTPConnection(host, int(port) if port else 80, + timeout=STT_CONNECT_TIMEOUT) + conn.request("GET", "/status") + resp = conn.getresponse() + data = json.loads(resp.read()) + conn.close() + return {"reachable": True, **data} + except (OSError, ValueError) as e: + return {"reachable": False, "error": str(e)} + + +def stt_transcribe(file_data: bytes, filename: str, + language: str | None = None, + prompt: str | None = None, + temperature: float | None = None) -> dict: + """Transkribiert Audio über den STT-Worker. + + Liefert dict mit 'text'. Wirft RuntimeError bei Fehler. + """ + hostport = STT_WORKER_URL.split("://", 1)[-1] + host, _, port = hostport.partition(":") + + # Multipart-Form-Data bauen + boundary = "----STTBoundary" + uuid.uuid4().hex[:16] + parts = [] + parts.append( + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n' + f"Content-Type: application/octet-stream\r\n\r\n".encode("utf-8") + ) + parts.append(file_data) + parts.append(b"\r\n") + for key, value in [("language", language), ("prompt", prompt), + ("temperature", temperature)]: + if value is not None: + parts.append( + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="{key}"\r\n\r\n' + f"{value}\r\n".encode("utf-8") + ) + parts.append(f"--{boundary}--\r\n".encode("utf-8")) + body = b"".join(parts) + + try: + conn = http.client.HTTPConnection(host, int(port) if port else 80, + timeout=STT_CONNECT_TIMEOUT) + conn.request("POST", "/transcribe", body=body, + headers={"Content-Type": + f"multipart/form-data; boundary={boundary}"}) + conn.sock.settimeout(STT_TIMEOUT) + resp = conn.getresponse() + data = json.loads(resp.read()) + conn.close() + except (OSError, http.client.HTTPException) as e: + raise RuntimeError(f"STT-Worker nicht erreichbar: {e}") + if resp.status != 200: + msg = data.get("error", str(data)) if isinstance(data, dict) else str(data) + raise RuntimeError(f"STT-Fehler ({resp.status}): {msg}") + return data + + def upstream_status() -> dict: """Prüft llama.cpp: erreichbar? welches Modell? welcher Kontext?""" try: @@ -682,10 +761,16 @@ class Handler(BaseHTTPRequestHandler): self._send_json(200, self._models_payload()) elif path == "/status": self._send_json(200, self._status_payload()) + elif path == "/v1/audio/models" and self.command == "GET": + self._send_json(200, self._audio_models_payload()) + elif path == "/v1/audio/voices" and self.command == "GET": + self._send_json(200, self._audio_voices_payload()) elif path == "/v1/images/generations" and self.command == "POST": self._image_generate() elif path == "/v1/audio/speech" and self.command == "POST": self._speech() + elif path == "/v1/audio/transcriptions" and self.command == "POST": + self._transcribe() elif path == "/images" and self.command == "GET": self._images_list() elif path.startswith("/images/") and self.command == "GET": @@ -759,6 +844,7 @@ class Handler(BaseHTTPRequestHandler): "last_error": img.last_error, }, "tts": tts_status(), + "stt": stt_status(), } # ---------- Bildgenerierung ---------- @@ -1002,6 +1088,151 @@ class Handler(BaseHTTPRequestHandler): self.end_headers() self.wfile.write(audio) + # ---------- Audio-Discovery ---------- + + def _audio_models_payload(self) -> dict: + """Listet verfügbare Audio-Modelle (STT + TTS).""" + tts = tts_status() + stt = stt_status() + models = [] + if stt.get("ready"): + models.append({ + "id": STT_MODEL, + "object": "model", + "owned_by": "whisper.cpp", + "type": "transcription", + }) + if tts.get("ready"): + models.append({ + "id": TTS_MODEL, + "object": "model", + "owned_by": "kokoro", + "type": "speech", + }) + return {"object": "list", "data": models} + + def _audio_voices_payload(self) -> dict: + """Listet verfügbare TTS-Stimmen.""" + tts = tts_status() + voices = [] + for v in tts.get("voices", []): + voices.append({ + "id": v, + "object": "voice", + "language": "de", + }) + return {"object": "list", "data": voices} + + # ---------- STT (Spracherkennung) ---------- + + def _parse_multipart(self, data: bytes, content_type: str + ) -> tuple[bytes, str, dict]: + """Parst multipart/form-data. Liefert (file_data, filename, fields).""" + boundary = None + for part in content_type.split(";"): + part = part.strip() + if part.startswith("boundary="): + boundary = part[len("boundary="):] + break + if not boundary: + raise ValueError("Kein Boundary in Content-Type") + + boundary_bytes = boundary.encode("utf-8") + file_data = b"" + filename = "" + fields = {} + + parts = data.split(b"--" + boundary_bytes) + for part in parts: + if part in (b"", b"--", b"--\r\n", b"\r\n"): + continue + if b"\r\n\r\n" not in part: + continue + header_part, body_part = part.split(b"\r\n\r\n", 1) + if body_part.endswith(b"\r\n"): + body_part = body_part[:-2] + + header_text = header_part.decode("utf-8", errors="replace") + for line in header_text.split("\r\n"): + if "name=" in line and "filename=" in line: + for kv in line.split(";"): + kv = kv.strip() + if kv.startswith("filename="): + filename = kv[len("filename="):].strip('"') + file_data = body_part + elif "name=" in line: + name = line.split("name=")[1].strip().strip('"') + fields[name] = body_part.decode("utf-8", errors="replace") + + return file_data, filename, fields + + def _transcribe(self) -> None: + """POST /v1/audio/transcriptions – STT (OpenAI-kompatibel).""" + content_type = self.headers.get("Content-Type", "") + if "multipart/form-data" not in content_type: + self._send_error(400, + "Content-Type muss multipart/form-data sein", + "invalid_request_error", "invalid_content_type") + return + + length = int(self.headers.get("Content-Length") or 0) + data = self.rfile.read(length) + + try: + file_data, filename, fields = self._parse_multipart( + data, content_type) + except ValueError as e: + self._send_error(400, str(e), + "invalid_request_error", "invalid_multipart") + return + + if not file_data: + self._send_error(400, "Keine Datei im Request", + "invalid_request_error", "missing_file") + return + + # Modell-Validierung + model = fields.get("model", STT_MODEL) + if model not in (STT_MODEL, "whisper"): + self._send_error(400, f"unbekanntes Modell: {model!r} " + f"(erwartet: {STT_MODEL})", + "invalid_request_error", "unknown_model") + return + + # Optionale Felder + language = fields.get("language") + prompt = fields.get("prompt") + temperature = None + if fields.get("temperature"): + try: + temperature = float(fields["temperature"]) + except ValueError: + self._send_error(400, "'temperature' muss eine Zahl sein", + "invalid_request_error", "invalid_temperature") + return + response_format = fields.get("response_format", "json") + + self.timeout = None # Transkription kann dauern + try: + result = stt_transcribe( + file_data, filename, + language=language, prompt=prompt, + temperature=temperature) + except RuntimeError as e: + self._send_error(503, str(e), "server_error", "stt_failed") + return + + # OpenAI-kompatibles Antwort-Format + if response_format == "verbose_json": + resp = { + "text": result.get("text", ""), + "language": result.get("language", "de"), + "duration": result.get("audio_duration_ms", 0) / 1000.0, + } + else: + resp = {"text": result.get("text", "")} + self._send_json(200, resp) + def _switch(self, profile: str) -> None: if profile not in PROFILES: self._send_error(400, f"unbekanntes Profil: {profile}", diff --git a/router/stt_worker.py b/router/stt_worker.py new file mode 100644 index 0000000..2cc856c --- /dev/null +++ b/router/stt_worker.py @@ -0,0 +1,390 @@ +#!/usr/bin/env python3 +""" +STT-Worker – langlebiger Whisper-Transkriptions-Service (CPU-only). + +Liest Audio-Dateien (WAV, MP3, OGG, FLAC, WebM/Opus via ffmpeg), +transkribiert sie mit whisper.cpp (whisper-cli) und liefert JSON-Text. + +Konfiguration über Umgebungsvariablen: + WHISPER_HOST Bind-Adresse (Default: 127.0.0.1) + WHISPER_PORT Port (Default: 8083) + WHISPER_CLI Pfad zu whisper-cli (Default: /opt/mike-ai/whisper.cpp/build-cpu/bin/whisper-cli) + WHISPER_MODEL Pfad zum ggml-Modell (Default: /opt/mike-ai/models/whisper/ggml-large-v3-turbo.bin) + WHISPER_THREADS Anzahl CPU-Threads (Default: 8) + WHISPER_LANGUAGE Standard-Sprache (Default: de) + FFMPEG_BIN Pfad zu ffmpeg (Default: /usr/bin/ffmpeg) + LOG_LEVEL Logging-Level (Default: INFO) + +Endpunkte: + GET /status → Health + Konfiguration + POST /transcribe → Audio-Datei transkribieren (multipart/form-data oder raw body) +""" + +import json +import logging +import os +import subprocess +import sys +import tempfile +import time +import uuid +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +# --------------------------------------------------------------------------- +# Konfiguration +# --------------------------------------------------------------------------- +HOST = os.environ.get("WHISPER_HOST", "127.0.0.1") +PORT = int(os.environ.get("WHISPER_PORT", "8083")) +WHISPER_CLI = os.environ.get( + "WHISPER_CLI", + "/opt/mike-ai/whisper.cpp/build-cpu/bin/whisper-cli", +) +WHISPER_MODEL = os.environ.get( + "WHISPER_MODEL", + "/opt/mike-ai/models/whisper/ggml-large-v3-turbo.bin", +) +WHISPER_THREADS = int(os.environ.get("WHISPER_THREADS", "8")) +WHISPER_LANGUAGE = os.environ.get("WHISPER_LANGUAGE", "de") +FFMPEG_BIN = os.environ.get("FFMPEG_BIN", "/usr/bin/ffmpeg") +LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO") + +# Audio-Formate, die whisper.cpp nativ unterstützt +NATIVE_FORMATS = {".wav", ".mp3", ".ogg", ".flac"} +# Formate, die ffmpeg-Konvertierung benötigen +CONVERT_FORMATS = {".webm", ".m4a", ".aac", ".opus", ".wma", ".amr", ".mka"} + +logging.basicConfig( + level=getattr(logging, LOG_LEVEL.upper(), logging.INFO), + format="%(asctime)s %(levelname)s %(message)s", + stream=sys.stdout, +) +log = logging.getLogger("stt-worker") + + +# --------------------------------------------------------------------------- +# Audio-Konvertierung +# --------------------------------------------------------------------------- +def _detect_format(filename: str) -> str: + """Erkennt das Dateiformat anhand der Endung.""" + ext = os.path.splitext(filename)[1].lower() + return ext + + +def _convert_to_wav(input_path: str, output_path: str) -> None: + """Konvertiert Audio per ffmpeg zu 16 kHz mono WAV (s16).""" + cmd = [ + FFMPEG_BIN, + "-y", + "-i", input_path, + "-ar", "16000", + "-ac", "1", + "-sample_fmt", "s16", + "-c:a", "pcm_s16le", + output_path, + ] + proc = subprocess.run( + cmd, capture_output=True, text=True, timeout=30, + ) + if proc.returncode != 0: + raise RuntimeError(f"ffmpeg-Fehler: {proc.stderr[-500:]}") + + +def _prepare_audio(data: bytes, filename: str) -> str: + """ + Bereitet Audio-Datei für whisper-cli vor. + Liefert Pfad zu einer WAV-Datei (16 kHz mono s16). + """ + ext = _detect_format(filename) + + if ext in NATIVE_FORMATS: + # Nativ unterstützt – direkt verwenden + tmp = tempfile.NamedTemporaryFile( + suffix=ext, prefix="stt_", delete=False + ) + tmp.write(data) + tmp.close() + return tmp.name + + if ext in CONVERT_FORMATS: + # ffmpeg-Konvertierung nötig + tmp_in = tempfile.NamedTemporaryFile( + suffix=ext, prefix="stt_in_", delete=False + ) + tmp_in.write(data) + tmp_in.close() + tmp_out = tempfile.NamedTemporaryFile( + suffix=".wav", prefix="stt_out_", delete=False + ) + tmp_out.close() + _convert_to_wav(tmp_in.name, tmp_out.name) + os.unlink(tmp_in.name) + return tmp_out.name + + # Unbekanntes Format – versuchen, es als WAV zu behandeln + tmp = tempfile.NamedTemporaryFile( + suffix=".wav", prefix="stt_", delete=False + ) + tmp.write(data) + tmp.close() + return tmp.name + + +# --------------------------------------------------------------------------- +# Transkription +# --------------------------------------------------------------------------- +def transcribe( + audio_path: str, + language: str | None = None, + prompt: str | None = None, + temperature: float | None = None, +) -> dict: + """ + Führt die Transkription mit whisper-cli aus. + Liefert dict mit 'text' und Metadaten. + """ + lang = language or WHISPER_LANGUAGE + if lang == "auto": + lang = "auto" + + out_prefix = f"/tmp/stt_{uuid.uuid4().hex[:12]}" + out_json = out_prefix + ".json" + + cmd = [ + WHISPER_CLI, + "-m", WHISPER_MODEL, + "-f", audio_path, + "-l", lang, + "-t", str(WHISPER_THREADS), + "-oj", + "-of", out_prefix, + "-np", + ] + if prompt: + cmd.extend(["--prompt", prompt]) + if temperature is not None: + cmd.extend(["-tp", str(temperature)]) + + t0 = time.monotonic() + proc = subprocess.run( + cmd, capture_output=True, text=True, timeout=300, + ) + elapsed = time.monotonic() - t0 + + if proc.returncode != 0: + raise RuntimeError( + f"whisper-cli-Fehler (rc={proc.returncode}): " + f"{proc.stderr[-500:]}" + ) + + # JSON-Output lesen + result = {"text": "", "language": lang, "duration_ms": int(elapsed * 1000)} + + if os.path.exists(out_json): + with open(out_json, "r", encoding="utf-8") as f: + jdata = json.load(f) + # whisper.cpp JSON-Format: + # {"transcription": [{"text": "...", "offsets": {"from": 0, "to": 1000}}], + # "result": {"language": "de"}, ...} + transcription = jdata.get("transcription", []) + if isinstance(transcription, list): + texts = [t.get("text", "") for t in transcription if isinstance(t, dict)] + result["text"] = " ".join(texts).strip() + # Audio-Dauer aus letztem Segment + if transcription and isinstance(transcription[-1], dict): + offsets = transcription[-1].get("offsets", {}) + if offsets: + result["audio_duration_ms"] = offsets.get("to", 0) + elif isinstance(transcription, str): + result["text"] = transcription.strip() + # Sprache aus result.language + if "result" in jdata and isinstance(jdata["result"], dict): + if "language" in jdata["result"]: + result["language"] = jdata["result"]["language"] + elif "language" in jdata: + result["language"] = jdata["language"] + os.unlink(out_json) + + # Aufräumen + for suffix in (".wav", ".mp3", ".ogg", ".flac", ".json"): + p = out_prefix + suffix + if os.path.exists(p): + os.unlink(p) + + log.info( + "Transkription: %d ms, %d Zeichen, Sprache=%s", + result["duration_ms"], len(result["text"]), result["language"], + ) + return result + + +# --------------------------------------------------------------------------- +# HTTP-Handler +# --------------------------------------------------------------------------- +class STTHandler(BaseHTTPRequestHandler): + server_version = "STTWorker/1.0" + + def log_message(self, fmt, *args): + log.info("%s %s", self.address_string(), fmt % args) + + def _send_json(self, code: int, obj: dict) -> None: + body = json.dumps(obj, ensure_ascii=False).encode("utf-8") + self.send_response(code) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(body) + + def _read_body(self) -> bytes: + length = int(self.headers.get("Content-Length", 0)) + return self.rfile.read(length) if length > 0 else b"" + + def _parse_multipart(self, data: bytes, content_type: str) -> tuple[bytes, str, dict]: + """ + Parst multipart/form-data. + Liefert (file_data, filename, form_fields). + """ + # Boundary extrahieren + boundary = None + for part in content_type.split(";"): + part = part.strip() + if part.startswith("boundary="): + boundary = part[len("boundary="):] + break + if not boundary: + raise ValueError("Kein Boundary in Content-Type") + + boundary_bytes = boundary.encode("utf-8") + file_data = b"" + filename = "" + fields = {} + + # Multipart parsen + parts = data.split(b"--" + boundary_bytes) + for part in parts: + if part in (b"", b"--", b"--\r\n", b"\r\n"): + continue + # Header und Body trennen + if b"\r\n\r\n" not in part: + continue + header_part, body_part = part.split(b"\r\n\r\n", 1) + # Trailing CRLF entfernen + if body_part.endswith(b"\r\n"): + body_part = body_part[:-2] + + header_text = header_part.decode("utf-8", errors="replace") + for line in header_text.split("\r\n"): + if "name=" in line and "filename=" in line: + # Datei-Feld + for kv in line.split(";"): + kv = kv.strip() + if kv.startswith("filename="): + filename = kv[len("filename="):].strip('"') + file_data = body_part + elif "name=" in line: + # Text-Feld + name = line.split("name=")[1].strip().strip('"') + fields[name] = body_part.decode("utf-8", errors="replace") + + return file_data, filename, fields + + def do_GET(self): + if self.path == "/status": + model_ok = os.path.isfile(WHISPER_MODEL) + cli_ok = os.path.isfile(WHISPER_CLI) + self._send_json(200, { + "ready": model_ok and cli_ok, + "model": WHISPER_MODEL, + "model_exists": model_ok, + "whisper_cli": WHISPER_CLI, + "whisper_cli_exists": cli_ok, + "threads": WHISPER_THREADS, + "language": WHISPER_LANGUAGE, + "ffmpeg": FFMPEG_BIN, + "ffmpeg_exists": os.path.isfile(FFMPEG_BIN), + }) + else: + self._send_json(404, {"error": "nicht gefunden"}) + + def do_POST(self): + if self.path != "/transcribe": + self._send_json(404, {"error": "nicht gefunden"}) + return + + content_type = self.headers.get("Content-Type", "") + + try: + if "multipart/form-data" in content_type: + data = self._read_body() + file_data, filename, fields = self._parse_multipart( + data, content_type + ) + if not file_data: + self._send_json(400, {"error": "Keine Datei im Request"}) + return + language = fields.get("language") + prompt = fields.get("prompt") + temperature = fields.get("temperature") + if temperature: + temperature = float(temperature) + else: + # Raw body (direkte Audio-Daten) + file_data = self._read_body() + filename = self.headers.get("X-Filename", "audio.wav") + language = self.headers.get("X-Language") + prompt = self.headers.get("X-Prompt") + temperature = self.headers.get("X-Temperature") + if temperature: + temperature = float(temperature) + if not file_data: + self._send_json(400, {"error": "Leerer Request-Body"}) + return + + # Audio vorbereiten + audio_path = _prepare_audio(file_data, filename) + try: + result = transcribe( + audio_path, + language=language, + prompt=prompt, + temperature=temperature, + ) + finally: + os.unlink(audio_path) + + self._send_json(200, result) + + except ValueError as e: + self._send_json(400, {"error": str(e)}) + except subprocess.TimeoutExpired: + self._send_json(504, {"error": "Transkription-Timeout"}) + except Exception as e: + log.exception("Transkriptions-Fehler") + self._send_json(500, {"error": str(e)}) + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- +def main(): + log.info( + "STT-Worker startet: host=%s port=%d model=%s threads=%d lang=%s", + HOST, PORT, WHISPER_MODEL, WHISPER_THREADS, WHISPER_LANGUAGE, + ) + if not os.path.isfile(WHISPER_MODEL): + log.warning("Modell nicht gefunden: %s", WHISPER_MODEL) + if not os.path.isfile(WHISPER_CLI): + log.warning("whisper-cli nicht gefunden: %s", WHISPER_CLI) + + server = ThreadingHTTPServer((HOST, PORT), STTHandler) + log.info("STT-Worker lauscht auf %s:%d", HOST, PORT) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() + + +if __name__ == "__main__": + main()