diff --git a/xtts/voxtral/bridge.py b/xtts/voxtral/bridge.py index ef75f1e..9937835 100644 --- a/xtts/voxtral/bridge.py +++ b/xtts/voxtral/bridge.py @@ -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: