#!/usr/bin/env python3 """ ARIA Voxtral Bridge — Streaming-STT via Voxtral-Mini-4B-Realtime-2602 (vLLM). Zwilling der whisper-Bridge, aber das Transkribieren macht NICHT faster-whisper im selben Prozess, sondern der separate vLLM-Realtime-Server (Container `voxtral-vllm`) ueber dessen WebSocket-API `/v1/realtime`. Diese Bridge ist reine Glue: App ──(RVS: stt_stream_start / stt_audio_chunk / stt_stream_end)──▶ diese Bridge diese Bridge ──(WS /v1/realtime: PCM16-b64 append)──▶ voxtral-vllm voxtral-vllm ──(transcription.delta / transcription.done)──▶ diese Bridge diese Bridge ──(RVS: stt_partial / stt_endpoint / stt_stream_done)──▶ App/aria-bridge Das RVS-Wire-Protokoll ist IDENTISCH zur whisper-Bridge (drop-in). Das Endpointing (wann hat der User aufgehoert zu sprechen) macht diese Bridge selbst — mit demselben ADAPTIVEN Rausch-Boden-Endpointer wie whisper (Voxtral Realtime liefert laut vLLM-Doku keine eigene VAD/„speaker done"-Semantik, nur transcription.delta/.done). Die akustische Energie messen wir auf unserer eigenen PCM-Kopie, die semantische Stagnation am Delta-Textwachstum. ⚠️ HARDWARE: Voxtral-Mini-4B-Realtime-2602 braucht >=16 GB VRAM (BF16). Auf der RTX 3060 (12 GB) laeuft es NICHT — erst auf der 24-GB-Karte. Bis dahin bleibt die whisper-Bridge aktiv (Profil-gesteuert im docker-compose). ⚠️ VERIFY-ON-FIRST-RUN: Die exakten vLLM-Realtime-FRAME-Namen (Audio-Append, Delta/Done-Event-Typen) sind unten als Konstanten gebuendelt und nach dem OpenAI-Realtime-Schema modelliert. Gegen das offizielle vLLM-Realtime- Client-Beispiel pruefen und ggf. anpassen — sie stehen bewusst an EINER Stelle. Response-Handling ist defensiv (mehrere moegliche Feldnamen). Env: RVS_HOST, RVS_PORT, RVS_TLS, RVS_TLS_FALLBACK, RVS_TOKEN VOXTRAL_VLLM_URL Default: ws://voxtral-vllm:8000/v1/realtime VOXTRAL_MODEL Default: mistralai/Voxtral-Mini-4B-Realtime-2602 VOXTRAL_LANGUAGE Default: de """ import asyncio import base64 import json import logging import os import time from dataclasses import dataclass, field from typing import Optional import numpy as np import websockets logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", datefmt="%H:%M:%S", ) logger = logging.getLogger("voxtral-bridge") RVS_HOST = os.getenv("RVS_HOST", "").strip() RVS_PORT = int(os.getenv("RVS_PORT", "443")) RVS_TLS = os.getenv("RVS_TLS", "true").lower() == "true" RVS_TLS_FALLBACK = os.getenv("RVS_TLS_FALLBACK", "true").lower() == "true" RVS_TOKEN = os.getenv("RVS_TOKEN", "").strip() VOXTRAL_VLLM_URL = os.getenv("VOXTRAL_VLLM_URL", "ws://voxtral-vllm:8000/v1/realtime") VOXTRAL_MODEL = os.getenv("VOXTRAL_MODEL", "mistralai/Voxtral-Mini-4B-Realtime-2602") VOXTRAL_LANGUAGE = os.getenv("VOXTRAL_LANGUAGE", "de") # ── vLLM-Realtime-Frames — HIER anpassen falls das Client-Beispiel abweicht ── # Senderichtung (wir → vLLM): PCM16-16kHz-mono base64 anhaengen + committen. VLLM_SEND_APPEND = "input_audio_buffer.append" # {"type":..., "audio": ""} VLLM_SEND_COMMIT = "input_audio_buffer.commit" # Buffer abschliessen VLLM_AUDIO_FIELD = "audio" # Empfangsrichtung (vLLM → wir): inkrementeller Text + final. Defensiv geprueft. VLLM_DELTA_SUFFIXES = ("transcription.delta",) # msg["type"] endet hierauf VLLM_DONE_SUFFIXES = ("transcription.done", "transcription.completed") VLLM_DELTA_FIELDS = ("delta", "text", "transcription") # eins davon traegt den Text # ── Streaming-/Endpointing-Parameter (analog whisper-Bridge) ── STREAM_DEFAULT_ENDPOINT_MS = 2400 STREAM_DEFAULT_HARD_CAP_MS = 60000 STREAM_MIN_AUDIO_MS = 600 STREAM_SESSION_TTL_S = 120 STREAM_ENERGY_WINDOW_MS = 300 STREAM_SEMANTIC_BACKUP_FACTOR = 2.0 # Adaptiver Voice-Schwellwert (siehe whisper-Bridge M0.1): Grenze relativ zum # gemessenen Rausch-Boden statt fix — schneidet leises Sprechen nicht ab. STREAM_VOICE_FACTOR = 2.5 STREAM_VOICE_RMS_MIN = 0.005 STREAM_VOICE_RMS_MAX = 0.020 def pcm_s16le_to_float32(data: bytes) -> np.ndarray: if not data: return np.zeros(0, dtype=np.float32) arr = np.frombuffer(data, dtype=np.int16).astype(np.float32) / 32768.0 return arr async def _send(ws, mtype: str, payload: dict) -> None: try: await ws.send(json.dumps({ "type": mtype, "payload": payload, "timestamp": int(time.time() * 1000), })) except Exception as e: logger.warning("RVS-Send fehlgeschlagen (%s): %s", mtype, e) @dataclass class StreamSession: request_id: str audio_request_id: str language: str endpoint_ms: int hard_cap_ms: int voice: str = "" speed: float = 1.0 interrupted: bool = False location: Optional[dict] = None sample_rate: int = 16000 voice_factor: float = STREAM_VOICE_FACTOR voice_rms_min: float = STREAM_VOICE_RMS_MIN voice_rms_max: float = STREAM_VOICE_RMS_MAX pcm_buffer: bytearray = field(default_factory=bytearray) started_at: float = field(default_factory=time.time) last_chunk_at: float = field(default_factory=time.time) last_partial: str = "" last_growth_at: float = 0.0 last_voice_at: float = 0.0 noise_floor: float = 0.0 closed: bool = False endpoint_sent: bool = False # vLLM-Realtime-Session vllm_ws: object = None vllm_reader: object = None class SessionManager: def __init__(self) -> None: self._sessions: dict[str, StreamSession] = {} self._ws = None # RVS def attach_ws(self, ws) -> None: self._ws = ws async def start_session(self, payload: dict) -> Optional[StreamSession]: request_id = (payload.get("requestId") or "").strip() if not request_id: logger.warning("stt_stream_start ohne requestId — ignoriert") return None try: endpoint_ms = int(payload.get("endpointMs") or STREAM_DEFAULT_ENDPOINT_MS) except (TypeError, ValueError): endpoint_ms = STREAM_DEFAULT_ENDPOINT_MS try: hard_cap_ms = int(payload.get("hardCapMs") or STREAM_DEFAULT_HARD_CAP_MS) except (TypeError, ValueError): hard_cap_ms = STREAM_DEFAULT_HARD_CAP_MS try: voice_factor = float(payload.get("voiceFactor") or STREAM_VOICE_FACTOR) except (TypeError, ValueError): voice_factor = STREAM_VOICE_FACTOR sess = StreamSession( request_id=request_id, audio_request_id=payload.get("audioRequestId", "") or "", language=payload.get("language") or VOXTRAL_LANGUAGE, endpoint_ms=endpoint_ms, hard_cap_ms=hard_cap_ms, voice=payload.get("voice", "") or "", speed=float(payload.get("speed") or 1.0), voice_factor=voice_factor, interrupted=bool(payload.get("interrupted", False)), location=payload.get("location") or None, sample_rate=int(payload.get("sampleRate") or 16000), ) # vLLM-Realtime-Session oeffnen + Reader starten. try: sess.vllm_ws = await websockets.connect(VOXTRAL_VLLM_URL, max_size=8 * 1024 * 1024) await self._vllm_configure(sess) sess.vllm_reader = asyncio.create_task(self._vllm_read_loop(sess)) except Exception as e: logger.exception("Stream %s: vLLM-Realtime-Connect fehlgeschlagen: %s", request_id[:8], e) # ohne Backend keine Transkription → sofort leeres Endpoint melden self._sessions[request_id] = sess await self._finalize(sess, reason="vllm_unavailable") return None self._sessions[request_id] = sess logger.info("Voxtral-Session offen: id=%s lang=%s endpointMs=%d", request_id[:8], sess.language, sess.endpoint_ms) return sess async def _vllm_configure(self, sess: StreamSession) -> None: """Optionale Session-Konfig an vLLM (Modell/Sprache/temperature=0). VERIFY: exaktes session.update-Schema gegen vLLM-Realtime-Beispiel. Best-effort — Fehler hier sind nicht fatal.""" try: await sess.vllm_ws.send(json.dumps({ "type": "session.update", "session": { "model": VOXTRAL_MODEL, "language": sess.language, "temperature": 0.0, "input_audio_format": "pcm16", }, })) except Exception: pass async def _vllm_read_loop(self, sess: StreamSession) -> None: """Liest transcription.delta/.done vom vLLM-Realtime-Server.""" ws = sess.vllm_ws try: async for raw in ws: try: msg = json.loads(raw) except Exception: continue mtype = str(msg.get("type", "")) if any(mtype.endswith(s) for s in VLLM_DELTA_SUFFIXES): text = self._extract_text(msg) if text: await self._on_delta(sess, text) elif any(mtype.endswith(s) for s in VLLM_DONE_SUFFIXES): text = self._extract_text(msg) if text: await self._on_delta(sess, text, final=True) except Exception: logger.debug("Stream %s: vLLM-Reader beendet", sess.request_id[:8]) @staticmethod def _extract_text(msg: dict) -> str: for f in VLLM_DELTA_FIELDS: v = msg.get(f) if isinstance(v, str) and v: return v return "" async def _on_delta(self, sess: StreamSession, text: str, final: bool = False) -> None: """Neuer/finaler Transkript-Text vom vLLM. delta = inkrementell; wir haengen an, wenn er den bisherigen Partial verlaengert, sonst ersetzen wir (Voxtral kann korrigieren).""" if final or text.startswith(sess.last_partial): new_full = text if final else text else: new_full = (sess.last_partial + text).strip() new_full = new_full.strip() if new_full and new_full != sess.last_partial: sess.last_partial = new_full sess.last_growth_at = time.time() if self._ws is not None: await _send(self._ws, "stt_partial", { "requestId": sess.request_id, "audioRequestId": sess.audio_request_id, "text": new_full, }) def feed_chunk(self, payload: dict) -> bool: request_id = payload.get("requestId", "") sess = self._sessions.get(request_id) if sess is None or sess.closed: return False pcm_b64 = payload.get("pcm", "") if not pcm_b64: return True try: pcm = base64.b64decode(pcm_b64) except Exception: return True sess.pcm_buffer.extend(pcm) sess.last_chunk_at = time.time() # An vLLM weiterreichen (fire-and-forget). if sess.vllm_ws is not None: asyncio.create_task(self._vllm_append(sess, pcm_b64)) return True async def _vllm_append(self, sess: StreamSession, pcm_b64: str) -> None: try: await sess.vllm_ws.send(json.dumps({ "type": VLLM_SEND_APPEND, VLLM_AUDIO_FIELD: pcm_b64, })) except Exception: pass def end_session(self, request_id: str) -> None: sess = self._sessions.get(request_id) if sess is not None: sess.closed = True def drop(self, request_id: str) -> None: sess = self._sessions.pop(request_id, None) if sess is not None: self._teardown_vllm(sess) def _teardown_vllm(self, sess: StreamSession) -> None: try: if sess.vllm_reader is not None: sess.vllm_reader.cancel() except Exception: pass if sess.vllm_ws is not None: asyncio.create_task(self._close_ws(sess.vllm_ws)) sess.vllm_ws = None @staticmethod async def _close_ws(ws) -> None: try: await ws.close() except Exception: pass # ── Endpointer (adaptiv, wie whisper-Bridge M0.1) ── def _buffer_duration_ms(self, sess: StreamSession) -> float: samples = len(sess.pcm_buffer) // 2 return (samples / sess.sample_rate) * 1000.0 if samples else 0.0 def _tail_rms(self, sess: StreamSession) -> float: win_bytes = int(sess.sample_rate * STREAM_ENERGY_WINDOW_MS / 1000) * 2 if win_bytes <= 0: return 0.0 tail = sess.pcm_buffer[-win_bytes:] if len(tail) < 2: return 0.0 arr = pcm_s16le_to_float32(bytes(tail)) if arr.size == 0: return 0.0 return float(np.sqrt(np.mean(arr * arr))) def _voice_threshold(self, sess: StreamSession) -> float: nf = sess.noise_floor if nf <= 0.0: return sess.voice_rms_min return min(max(nf * sess.voice_factor, sess.voice_rms_min), sess.voice_rms_max) def _update_noise_floor(self, sess: StreamSession, rms: float) -> None: nf = sess.noise_floor if nf <= 0.0: sess.noise_floor = rms elif rms < nf: sess.noise_floor = 0.90 * nf + 0.10 * rms else: sess.noise_floor = 0.98 * nf + 0.02 * rms async def run_endpointer(self) -> None: logger.info("Voxtral-Endpointer gestartet (adaptiver VAD)") while True: await asyncio.sleep(0.2) now = time.time() for sid, sess in list(self._sessions.items()): try: await self._tick(sess, now) except Exception: logger.exception("Endpointer-Tick crashed (session=%s)", sid[:8]) for sid, sess in list(self._sessions.items()): if now - sess.last_chunk_at > STREAM_SESSION_TTL_S: logger.info("Stream %s: TTL — drop", sid[:8]) self.drop(sid) async def _tick(self, sess: StreamSession, now: float) -> None: if sess.endpoint_sent: return elapsed_ms = (now - sess.started_at) * 1000.0 if elapsed_ms > sess.hard_cap_ms and not sess.closed: await self._finalize(sess, reason="hardcap") return if sess.closed: await self._finalize(sess, reason="stream_end") return if self._buffer_duration_ms(sess) < STREAM_MIN_AUDIO_MS: return # adaptive akustische Sprach-Aktivitaet rms = self._tail_rms(sess) if rms >= self._voice_threshold(sess): sess.last_voice_at = now else: self._update_noise_floor(sess, rms) # Endpoint: akustisch (Primaer) oder semantisch (Backstop), sobald Text da if sess.last_growth_at > 0.0: acoustic_silence_ms = (now - sess.last_voice_at) * 1000.0 if sess.last_voice_at > 0 else 0.0 semantic_silence_ms = (now - sess.last_growth_at) * 1000.0 acoustic_done = sess.last_voice_at > 0 and acoustic_silence_ms >= sess.endpoint_ms semantic_done = semantic_silence_ms >= sess.endpoint_ms * STREAM_SEMANTIC_BACKUP_FACTOR if acoustic_done or semantic_done: await self._finalize(sess, reason="endpoint" if acoustic_done else "endpoint_semantic") async def _finalize(self, sess: StreamSession, reason: str) -> None: if sess.endpoint_sent: return sess.endpoint_sent = True # vLLM ggf. committen, damit ein letztes transcription.done kommt. if sess.vllm_ws is not None: try: await sess.vllm_ws.send(json.dumps({"type": VLLM_SEND_COMMIT})) await asyncio.sleep(0.15) # kurz auf finalen Delta warten except Exception: pass final_text = sess.last_partial.strip() duration_s = self._buffer_duration_ms(sess) / 1000.0 logger.info("Stream %s: FINAL (reason=%s, %.1fs): %r", sess.request_id[:8], reason, duration_s, final_text[:120]) if self._ws is not None: endpoint_payload = { "requestId": sess.request_id, "audioRequestId": sess.audio_request_id, "text": final_text, "reason": reason, "durationS": duration_s, "sttMs": 0, "voice": sess.voice, "speed": sess.speed, "interrupted": sess.interrupted, } if sess.location: endpoint_payload["location"] = sess.location await _send(self._ws, "stt_endpoint", endpoint_payload) await _send(self._ws, "stt_stream_done", { "requestId": sess.request_id, "audioRequestId": sess.audio_request_id, "text": final_text, "reason": reason, }) self.drop(sess.request_id) async def _broadcast_status(ws, state: str, **extra) -> None: payload = {"service": "voxtral", "state": state} payload.update(extra) await _send(ws, "service_status", payload) async def run_loop(sessions: SessionManager) -> None: use_tls = RVS_TLS retry_s = 2 tls_fallback_tried = False while True: scheme = "wss" if use_tls else "ws" url = f"{scheme}://{RVS_HOST}:{RVS_PORT}/ws?token={RVS_TOKEN}" masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url try: logger.info("Verbinde zu RVS: %s", masked) async with websockets.connect(url, ping_interval=20, ping_timeout=10, max_size=50 * 1024 * 1024) as ws: logger.info("RVS verbunden") retry_s = 2 tls_fallback_tried = False sessions.attach_ws(ws) await _broadcast_status(ws, "ready", model=VOXTRAL_MODEL) await _send(ws, "config_request", {"service": "voxtral"}) async for raw in ws: try: msg = json.loads(raw) except Exception: continue mtype = msg.get("type", "") payload = msg.get("payload", {}) or {} if mtype == "stt_stream_start": asyncio.create_task(sessions.start_session(payload)) elif mtype == "stt_audio_chunk": sessions.feed_chunk(payload) elif mtype == "stt_stream_end": sessions.end_session(payload.get("requestId", "")) # stt_request (Legacy One-Shot) macht Voxtral hier NICHT — # dafuer bleibt die whisper-Bridge (Fallback). except Exception as e: logger.warning("RVS-Verbindung verloren: %s — retry in %ds", e, retry_s) if use_tls and RVS_TLS_FALLBACK and not tls_fallback_tried: use_tls = False tls_fallback_tried = True logger.info("TLS-Fallback: versuche ws:// (kein TLS)") continue await asyncio.sleep(retry_s) retry_s = min(retry_s * 2, 30) use_tls = RVS_TLS # fuer den naechsten Zyklus zuruecksetzen async def main() -> None: if not RVS_HOST or not RVS_TOKEN: logger.error("RVS_HOST/RVS_TOKEN fehlen — .env pruefen. Abbruch.") return sessions = SessionManager() logger.info("Voxtral-Bridge startet — vLLM=%s Modell=%s", VOXTRAL_VLLM_URL, VOXTRAL_MODEL) await asyncio.gather( run_loop(sessions), sessions.run_endpointer(), ) if __name__ == "__main__": try: asyncio.run(main()) except KeyboardInterrupt: pass