fix(voxtral): Halluzinations-Guard — kein Phantom-Text aus Stille/Blip

Stefans Repro: nach dem Senden ging sofort ein Passiv-Fenster auf (waehrend ARIA
noch arbeitete), und obwohl er nichts sagte, kam eine "Nachricht von ihm" — Voxtral
halluzinierte aus Fast-Nichts einen Fuellsatz. Der ging als Phantom-Turn ans Brain
und liess das Gespraech entgleisen (Antwort drauf, dann Konversation-Ende).

Ursache war eine Nebenwirkung des "Sprache erkannt"-Pings (9e78d75): der feuerte
beim ERSTEN Voice-Frame — also auch bei einem kurzen Geraeusch-Blip. Damit war der
App-No-Speech-Watchdog geloescht, der akustische Endpoint feuerte, und Voxtral
transkribierte den Blip → Halluzination. (Vorher haette der Watchdog den Blip
verworfen.)

Fix: voiced_frames-Zaehler (Endpointer-Frames mit echter Stimme). Erst ab
STREAM_MIN_VOICED_FRAMES (=2, ~400ms, env-tunebar) gilt es als Sprache:
- Der Speech-Ping feuert erst ab dieser Schwelle (ein Blip loescht den Watchdog
  also nicht mehr).
- _finalize transkribiert unter der Schwelle NICHT, sondern schickt ein leeres
  Endpoint (no-speech) → App re-armt still, kein Phantom-Turn. Manueller Stop
  (stream_end) ist ausgenommen (bewusst gesprochen, kurze Woerter ok).

Deploy: docker compose up -d --build voxtral-bridge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 14:35:21 +02:00
co-authored by Claude Opus 4.8
parent 9bdfb7193e
commit 6addb2f8fe
+40 -8
View File
@@ -69,6 +69,10 @@ STREAM_SEMANTIC_BACKUP_FACTOR = 2.0
STREAM_VOICE_FACTOR = 2.5
STREAM_VOICE_RMS_MIN = 0.005
STREAM_VOICE_RMS_MAX = 0.020
# Mindest-Stimme (in ~200ms-Endpointer-Frames), ab der eine Aufnahme ueberhaupt
# als Sprache gilt. Darunter = Stille / kurzer Geraeusch-Blip → KEIN Transkript
# (Voxtral halluziniert aus Fast-Nichts sonst einen Fuellsatz). 2 ≈ 400ms.
STREAM_MIN_VOICED_FRAMES = int(os.getenv("STREAM_MIN_VOICED_FRAMES", "2"))
def pcm_s16le_to_float32(data: bytes) -> np.ndarray:
@@ -173,6 +177,9 @@ class StreamSession:
# stt_partial, um "der User redet" zu erkennen — sonst cancelt er mitten im
# Satz. Wir feuern EIN leeres stt_partial beim ersten Voice-Frame.
speech_signaled: bool = False
# Anzahl Endpointer-Frames (~200ms) mit echter Stimme. Gate gegen Halluzination
# aus Stille/Blips: unter STREAM_MIN_VOICED_FRAMES wird nicht transkribiert.
voiced_frames: int = 0
# Speaker-ID Gating (einmalig auf die ersten ~1.5s der Aufnahme)
speaker_checked: bool = False
speaker_match: Optional[bool] = None
@@ -366,14 +373,17 @@ class SessionManager:
rms = self._tail_rms(sess)
if rms >= self._voice_threshold(sess):
sess.last_voice_at = now
# Einmalig der App melden, dass Sprache begonnen hat. Ohne Live-Partials
# wuerde ihr No-Speech-Watchdog die Aufnahme sonst am Konversations-
# fenster canceln, obwohl der User noch redet (Stefans Reproduktion:
# "beendet nach ~4s, obwohl ich weiterrede — nur im Ohr-Modus"). Ein
# leeres stt_partial reicht: die App setzt streamGotPartial=true und
# loescht den Watchdog. Nach der Speaker-ID-Pruefung (oben) → fremde
# Stimmen signalisieren NICHT.
if not sess.speech_signaled and self._ws is not None:
sess.voiced_frames += 1
# Einmalig der App melden, dass Sprache begonnen hat — aber ERST ab genug
# echter Stimme (>= STREAM_MIN_VOICED_FRAMES). Ein einzelner Geraeusch-
# Blip darf den No-Speech-Watchdog NICHT loeschen, sonst transkribiert
# Voxtral das Fast-Nichts und HALLUZINIERT einen Phantom-Satz. Ohne Live-
# Partials wuerde der Watchdog die Aufnahme sonst am Konversationsfenster
# canceln, obwohl der User redet ("beendet nach ~4s"-Repro). Leeres
# stt_partial: App setzt streamGotPartial=true + loescht den Watchdog.
# Nach der Speaker-ID-Pruefung (oben) → fremde Stimmen signalisieren NICHT.
if (not sess.speech_signaled and self._ws is not None
and sess.voiced_frames >= STREAM_MIN_VOICED_FRAMES):
sess.speech_signaled = True
await _send(self._ws, "stt_partial", {
"requestId": sess.request_id,
@@ -390,6 +400,28 @@ class SessionManager:
if sess.endpoint_sent:
return
sess.endpoint_sent = True
# Halluzinations-Guard: zu wenig echte Stimme (Stille / kurzer Blip im
# Passiv-/Wake-Fenster) → NICHT transkribieren. Voxtral (wie Whisper) baut
# aus Fast-Nichts gern einen Fuellsatz ("keine Ahnung" o.ae.), der dann als
# PHANTOM-Nachricht ans Brain geht und das Gespraech entgleisen laesst
# (Stefans Repro: "kam Nachricht von mir, obwohl ich nichts sagte"). Leeres
# Endpoint = no-speech → App re-armt still. Der manuelle Stop (stream_end)
# ist ausgenommen: dort hat der User bewusst gesprochen (kurze Woerter ok).
if reason != "stream_end" and sess.voiced_frames < STREAM_MIN_VOICED_FRAMES:
logger.info("Stream %s: no-speech (voiced_frames=%d<%d, reason=%s) — leeres Endpoint",
sess.request_id[:8], sess.voiced_frames, STREAM_MIN_VOICED_FRAMES, reason)
if self._ws is not None:
nospeech = {"requestId": sess.request_id,
"audioRequestId": sess.audio_request_id,
"text": "", "reason": f"no_speech:{reason}",
"durationS": 0.0, "sttMs": 0}
await _send(self._ws, "stt_endpoint", nospeech)
await _send(self._ws, "stt_stream_done", {
"requestId": sess.request_id,
"audioRequestId": sess.audio_request_id,
"text": "", "reason": f"no_speech:{reason}"})
self.drop(sess.request_id)
return
audio = pcm_s16le_to_float32(bytes(sess.pcm_buffer))
t0 = time.time()
try: