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:
+234
-3
@@ -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}",
|
||||
|
||||
Reference in New Issue
Block a user