Files
AI-Profile-Router/router/stt_worker.py
Mikei386 51ef07c874 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
2026-08-19 13:53:22 +02:00

391 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()