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:
@@ -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
|
||||
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 =="
|
||||
|
||||
Reference in New Issue
Block a user