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:
+40
-8
@@ -69,6 +69,10 @@ STREAM_SEMANTIC_BACKUP_FACTOR = 2.0
|
|||||||
STREAM_VOICE_FACTOR = 2.5
|
STREAM_VOICE_FACTOR = 2.5
|
||||||
STREAM_VOICE_RMS_MIN = 0.005
|
STREAM_VOICE_RMS_MIN = 0.005
|
||||||
STREAM_VOICE_RMS_MAX = 0.020
|
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:
|
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
|
# stt_partial, um "der User redet" zu erkennen — sonst cancelt er mitten im
|
||||||
# Satz. Wir feuern EIN leeres stt_partial beim ersten Voice-Frame.
|
# Satz. Wir feuern EIN leeres stt_partial beim ersten Voice-Frame.
|
||||||
speech_signaled: bool = False
|
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-ID Gating (einmalig auf die ersten ~1.5s der Aufnahme)
|
||||||
speaker_checked: bool = False
|
speaker_checked: bool = False
|
||||||
speaker_match: Optional[bool] = None
|
speaker_match: Optional[bool] = None
|
||||||
@@ -366,14 +373,17 @@ class SessionManager:
|
|||||||
rms = self._tail_rms(sess)
|
rms = self._tail_rms(sess)
|
||||||
if rms >= self._voice_threshold(sess):
|
if rms >= self._voice_threshold(sess):
|
||||||
sess.last_voice_at = now
|
sess.last_voice_at = now
|
||||||
# Einmalig der App melden, dass Sprache begonnen hat. Ohne Live-Partials
|
sess.voiced_frames += 1
|
||||||
# wuerde ihr No-Speech-Watchdog die Aufnahme sonst am Konversations-
|
# Einmalig der App melden, dass Sprache begonnen hat — aber ERST ab genug
|
||||||
# fenster canceln, obwohl der User noch redet (Stefans Reproduktion:
|
# echter Stimme (>= STREAM_MIN_VOICED_FRAMES). Ein einzelner Geraeusch-
|
||||||
# "beendet nach ~4s, obwohl ich weiterrede — nur im Ohr-Modus"). Ein
|
# Blip darf den No-Speech-Watchdog NICHT loeschen, sonst transkribiert
|
||||||
# leeres stt_partial reicht: die App setzt streamGotPartial=true und
|
# Voxtral das Fast-Nichts und HALLUZINIERT einen Phantom-Satz. Ohne Live-
|
||||||
# loescht den Watchdog. Nach der Speaker-ID-Pruefung (oben) → fremde
|
# Partials wuerde der Watchdog die Aufnahme sonst am Konversationsfenster
|
||||||
# Stimmen signalisieren NICHT.
|
# canceln, obwohl der User redet ("beendet nach ~4s"-Repro). Leeres
|
||||||
if not sess.speech_signaled and self._ws is not None:
|
# 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
|
sess.speech_signaled = True
|
||||||
await _send(self._ws, "stt_partial", {
|
await _send(self._ws, "stt_partial", {
|
||||||
"requestId": sess.request_id,
|
"requestId": sess.request_id,
|
||||||
@@ -390,6 +400,28 @@ class SessionManager:
|
|||||||
if sess.endpoint_sent:
|
if sess.endpoint_sent:
|
||||||
return
|
return
|
||||||
sess.endpoint_sent = True
|
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))
|
audio = pcm_s16le_to_float32(bytes(sess.pcm_buffer))
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
try:
|
try:
|
||||||
|
|||||||
Reference in New Issue
Block a user