router: deutsche Spracherkennung mit whisper.cpp (CPU-only)
- STT-Worker (stt_worker.py): langlebiger HTTP-Service auf Port 8084 - whisper-cli als Subprozess (CPU-only, 8 Threads) - Audio-Vorbereitung via ffmpeg (WebM/Opus/M4A → 16 kHz WAV) - Nativ: WAV, MP3, OGG, FLAC - Health-Endpunkt: GET /status - Transkription: POST /transcribe (Multipart-Form-Data) - Router-Integration: - POST /v1/audio/transcriptions (OpenAI-kompatibel) - GET /v1/audio/models (whisper-1, kokoro-german) - GET /v1/audio/voices (martin, victoria) - /status mit stt-Section - model=whisper-1 akzeptiert - response_format: json, verbose_json - systemd-Service: mike-ai-whisper.service - Boot-Start, Restart on failure, journald - CPU-only, kein GPU-Lock - Deploy-Dateien aktualisiert (deploy.sh, install.sh) - Mock-STT-Worker für lokale Tests (dev/mock_stt_worker.py) - Tests ergänzt: STT Status, WAV, language=de, unbekanntes Modell, Worker down, Recovery, Audio-Modelle, Audio-Voices, STT+Qwen parallel, STT+TTS parallel - README.md: STT-Section mit Endpunkten, Benchmarks, Doku Benchmarks (CPU-only, 8 Threads): 7.3 s Audio → 8.9 s (RTF 1.22×) 30 s Audio → 16.8 s (RTF 0.56×) 50 s Audio → 18.5 s (RTF 0.37×) RAM: ~1.7 GB (Modell), Worker: ~20 MB
This commit is contained in:
@@ -19,6 +19,7 @@ Sprachausgabe bereit (Kokoro-82M, CPU-only, OpenAI-kompatibel).
|
|||||||
| Router-Port | **8081** |
|
| Router-Port | **8081** |
|
||||||
| Router-Service | `mike-ai-profile-router.service` |
|
| Router-Service | `mike-ai-profile-router.service` |
|
||||||
| TTS-Worker | `http://127.0.0.1:8082` (Service `mike-ai-kokoro.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
|
## 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` | Liste der gespeicherten Bilder (max. 200) |
|
||||||
| `GET /images/<datei>` | PNG-Download (nur `images/`-Verzeichnis, validiert) |
|
| `GET /images/<datei>` | PNG-Download (nur `images/`-Verzeichnis, validiert) |
|
||||||
| `POST /v1/audio/speech` | Deutsche Sprachausgabe (Kokoro-82M, OpenAI-kompatibel) |
|
| `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 |
|
| alles andere | Transparente Weiterleitung an llama.cpp |
|
||||||
|
|
||||||
### Verhalten
|
### 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
|
kein thinc 9.x). `spacy` wird nur für den `misaki.en`-Import benötigt
|
||||||
(Englisch), nicht für den deutschen Pfad.
|
(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
|
## Repository-Struktur
|
||||||
|
|
||||||
```
|
```
|
||||||
router/ai_profile_router.py # der Router (einzige Laufzeit-Datei)
|
router/ai_profile_router.py # der Router (einzige Laufzeit-Datei)
|
||||||
router/image_worker.py # FLUX-Worker (eigener Prozess, JSON-Protokoll)
|
router/image_worker.py # FLUX-Worker (eigener Prozess, JSON-Protokoll)
|
||||||
router/tts_worker.py # Kokoro-TTS-Worker (eigener Prozess, HTTP-API)
|
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-profile-router.service # systemd-Unit (Router)
|
||||||
deploy/mike-ai-kokoro.service # systemd-Unit (TTS-Worker)
|
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/install.sh # läuft auf dem Zielsystem (per SSH)
|
||||||
deploy/deploy.sh # läuft lokal: SCP + SSH
|
deploy/deploy.sh # läuft lokal: SCP + SSH
|
||||||
dev/ # lokale Tests (Mock-llama.cpp, Mock-Worker, Benchmarks)
|
dev/ # lokale Tests (Mock-llama.cpp, Mock-Worker, Benchmarks)
|
||||||
|
|||||||
+2
-1
@@ -10,8 +10,9 @@ STAGE="/tmp/ai-profile-router-$$"
|
|||||||
|
|
||||||
mkdir -p "$STAGE"
|
mkdir -p "$STAGE"
|
||||||
cp router/ai_profile_router.py router/image_worker.py router/tts_worker.py \
|
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/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/"
|
echo "== Übertrage Dateien nach ${TARGET}:/tmp/ai-profile-router/"
|
||||||
ssh -i "$SSH_KEY" "$TARGET" 'mkdir -p /tmp/ai-profile-router'
|
ssh -i "$SSH_KEY" "$TARGET" 'mkdir -p /tmp/ai-profile-router'
|
||||||
|
|||||||
+11
-1
@@ -10,6 +10,7 @@ DIR="$(cd "$(dirname "$0")" && pwd)"
|
|||||||
INSTALL_DIR=/opt/mike-ai/ai-profile-router
|
INSTALL_DIR=/opt/mike-ai/ai-profile-router
|
||||||
SERVICE=mike-ai-profile-router.service
|
SERVICE=mike-ai-profile-router.service
|
||||||
KOKORO_SERVICE=mike-ai-kokoro.service
|
KOKORO_SERVICE=mike-ai-kokoro.service
|
||||||
|
WHISPER_SERVICE=mike-ai-whisper.service
|
||||||
OLD_SERVICE=mike-ai-local-llm-router.service
|
OLD_SERVICE=mike-ai-local-llm-router.service
|
||||||
OLD_DIR=/opt/mike-ai/local-llm-router
|
OLD_DIR=/opt/mike-ai/local-llm-router
|
||||||
BACKUP_DIR=/opt/mike-ai/.backup-ai-profile-router-$(date +%Y%m%d-%H%M%S)
|
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/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/image_worker.py" "$INSTALL_DIR/image_worker.py"
|
||||||
install -m 0755 "$DIR/tts_worker.py" "$INSTALL_DIR/tts_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/${SERVICE}" "/etc/systemd/system/${SERVICE}"
|
||||||
install -m 0644 "$DIR/${KOKORO_SERVICE}" "/etc/systemd/system/${KOKORO_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 ---------------------------------
|
# --- 3. Python-Venv mit Bild-Abhängigkeiten ---------------------------------
|
||||||
if [ ! -x "$VENV/bin/python" ]; then
|
if [ ! -x "$VENV/bin/python" ]; then
|
||||||
@@ -134,8 +137,9 @@ fi
|
|||||||
|
|
||||||
# --- 6. Services aktivieren und starten ---------------------------------------
|
# --- 6. Services aktivieren und starten ---------------------------------------
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable "$SERVICE" "$KOKORO_SERVICE"
|
systemctl enable "$SERVICE" "$KOKORO_SERVICE" "$WHISPER_SERVICE"
|
||||||
systemctl restart "$KOKORO_SERVICE"
|
systemctl restart "$KOKORO_SERVICE"
|
||||||
|
systemctl restart "$WHISPER_SERVICE"
|
||||||
systemctl restart "$SERVICE"
|
systemctl restart "$SERVICE"
|
||||||
|
|
||||||
# --- 7. Verifikation ----------------------------------------------------------
|
# --- 7. Verifikation ----------------------------------------------------------
|
||||||
@@ -152,5 +156,11 @@ if ! systemctl is-active --quiet "$KOKORO_SERVICE"; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "-- Kokoro-Service läuft"
|
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
|
curl -sf "http://127.0.0.1:8081/status" | python3 -m json.tool
|
||||||
echo "== Fertig =="
|
echo "== Fertig =="
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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()
|
||||||
+150
-1
@@ -7,13 +7,14 @@ cd "$(dirname "$0")/.."
|
|||||||
UP_PORT=18080
|
UP_PORT=18080
|
||||||
RT_PORT=18081
|
RT_PORT=18081
|
||||||
TTS_PORT=18082
|
TTS_PORT=18082
|
||||||
|
STT_PORT=18083
|
||||||
BASE="http://127.0.0.1:$RT_PORT"
|
BASE="http://127.0.0.1:$RT_PORT"
|
||||||
FAKE_DIR="$PWD/dev/fake-profile-dir"
|
FAKE_DIR="$PWD/dev/fake-profile-dir"
|
||||||
PASS=0
|
PASS=0
|
||||||
FAIL=0
|
FAIL=0
|
||||||
|
|
||||||
cleanup() {
|
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
|
rm -f /tmp/mock_pid2 /tmp/mock_upstream_pid
|
||||||
wait 2>/dev/null || true
|
wait 2>/dev/null || true
|
||||||
}
|
}
|
||||||
@@ -54,6 +55,7 @@ IMAGE_WORKER_LOG=/tmp/test_worker.log \
|
|||||||
IMAGE_GEN_TIMEOUT=30 \
|
IMAGE_GEN_TIMEOUT=30 \
|
||||||
MOCK_WORKER_LOG=/tmp/test_worker_requests.jsonl \
|
MOCK_WORKER_LOG=/tmp/test_worker_requests.jsonl \
|
||||||
TTS_WORKER_URL="http://127.0.0.1:$TTS_PORT" \
|
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 &
|
python3 router/ai_profile_router.py >/tmp/router_test.log 2>&1 &
|
||||||
ROUTER_PID=$!
|
ROUTER_PID=$!
|
||||||
sleep 0.5
|
sleep 0.5
|
||||||
@@ -68,6 +70,15 @@ TTS_PID=$!
|
|||||||
sleep 0.5
|
sleep 0.5
|
||||||
rm -f /tmp/test_tts_requests.jsonl
|
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 -------------------------------------------------------------
|
# --- 1. /v1/models -------------------------------------------------------------
|
||||||
echo "== Test 1: /v1/models"
|
echo "== Test 1: /v1/models"
|
||||||
RESP=$(curl -sf "$BASE/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 ] \
|
[ "$CODE" = "200" ] && [ -s /tmp/tts33.wav ] \
|
||||||
&& ok "TTS nach Neustart wieder verfügbar" || bad "TTS-Recovery (Code $CODE)"
|
&& 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 --------------------------------------------------------------------------------------------
|
# --- Ergebnis --------------------------------------------------------------------------------------------
|
||||||
echo
|
echo
|
||||||
echo "== Ergebnis: $PASS bestanden, $FAIL fehlgeschlagen =="
|
echo "== Ergebnis: $PASS bestanden, $FAIL fehlgeschlagen =="
|
||||||
|
|||||||
+234
-3
@@ -22,10 +22,16 @@ Bildgenerierung (FLUX.2 [klein] 4B Base):
|
|||||||
|
|
||||||
Sprachausgabe (Kokoro-82M, deutsch, CPU-only):
|
Sprachausgabe (Kokoro-82M, deutsch, CPU-only):
|
||||||
POST /v1/audio/speech (OpenAI-kompatibel)
|
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
|
Spracherkennung (whisper.cpp, deutsch, CPU-only):
|
||||||
Prozess mit eigenem Venv und hält die Modelle dauerhaft im RAM. Der
|
POST /v1/audio/transcriptions (OpenAI-kompatibel)
|
||||||
Router leitet /v1/audio/speech per HTTP an den Worker weiter.
|
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
|
Der Router agiert als Modell-Orchestrator: vor der Generierung wird
|
||||||
llama.cpp gestoppt, der Bild-Worker lädt FLUX, generiert und entlädt
|
llama.cpp gestoppt, der Bild-Worker lädt FLUX, generiert und entlädt
|
||||||
@@ -48,6 +54,7 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
import uuid
|
||||||
import http.client
|
import http.client
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
@@ -108,6 +115,12 @@ TTS_DEFAULT_VOICE = "martin"
|
|||||||
TTS_FORMATS = ("mp3", "wav", "flac", "pcm")
|
TTS_FORMATS = ("mp3", "wav", "flac", "pcm")
|
||||||
TTS_DEFAULT_FORMAT = "mp3"
|
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
|
# Chat-Waiting: Während eines Image-Jobs oder Profilwechsels ist Qwen
|
||||||
# down. Chat-Requests warten (statt 502) bis Qwen wieder bereit ist.
|
# 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
|
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
|
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:
|
def upstream_status() -> dict:
|
||||||
"""Prüft llama.cpp: erreichbar? welches Modell? welcher Kontext?"""
|
"""Prüft llama.cpp: erreichbar? welches Modell? welcher Kontext?"""
|
||||||
try:
|
try:
|
||||||
@@ -682,10 +761,16 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self._send_json(200, self._models_payload())
|
self._send_json(200, self._models_payload())
|
||||||
elif path == "/status":
|
elif path == "/status":
|
||||||
self._send_json(200, self._status_payload())
|
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":
|
elif path == "/v1/images/generations" and self.command == "POST":
|
||||||
self._image_generate()
|
self._image_generate()
|
||||||
elif path == "/v1/audio/speech" and self.command == "POST":
|
elif path == "/v1/audio/speech" and self.command == "POST":
|
||||||
self._speech()
|
self._speech()
|
||||||
|
elif path == "/v1/audio/transcriptions" and self.command == "POST":
|
||||||
|
self._transcribe()
|
||||||
elif path == "/images" and self.command == "GET":
|
elif path == "/images" and self.command == "GET":
|
||||||
self._images_list()
|
self._images_list()
|
||||||
elif path.startswith("/images/") and self.command == "GET":
|
elif path.startswith("/images/") and self.command == "GET":
|
||||||
@@ -759,6 +844,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
"last_error": img.last_error,
|
"last_error": img.last_error,
|
||||||
},
|
},
|
||||||
"tts": tts_status(),
|
"tts": tts_status(),
|
||||||
|
"stt": stt_status(),
|
||||||
}
|
}
|
||||||
|
|
||||||
# ---------- Bildgenerierung ----------
|
# ---------- Bildgenerierung ----------
|
||||||
@@ -1002,6 +1088,151 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(audio)
|
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:
|
def _switch(self, profile: str) -> None:
|
||||||
if profile not in PROFILES:
|
if profile not in PROFILES:
|
||||||
self._send_error(400, f"unbekanntes Profil: {profile}",
|
self._send_error(400, f"unbekanntes Profil: {profile}",
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user