From 648698d2027b5e4fa921edbd9fd11371687a9012 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Mon, 6 Jul 2026 00:31:48 +0200 Subject: [PATCH] fix(voice): robusteres STT-Endpointing via akustische Stille (nicht nur semantisch) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug: die Spracherkennung bricht mal zu frueh ab (nach zwei Worten obwohl noch gesprochen wird) und merkt mal gar nicht dass man aufgehoert hat. Ursache: der Endpointer feuerte rein auf semantischer Stagnation (Whisper- Transkript waechst endpoint_ms lang nicht). Das ist fragil — kurze Sprech- Pausen oder beam_size=1-Instabilitaet → vorzeitiger Cut; Oszillation/ Halluzination → nie ein Endpoint. Fix: akustische Stille als robustes Primaersignal. Jeder Tick (~200ms) misst die RMS-Energie der letzten 300ms: - Solange Sprach-Energie da ist, bleibt die Session am Leben (kein Cut mitten im Reden). - Endpoint feuert wenn seit endpoint_ms keine Sprach-Energie mehr da ist. - Semantischer Backstop (2x endpoint_ms) fuer laute Umgebungen (Auto), wo die Energie nie faellt — dort degradiert es sauber aufs bisherige Verhalten. RMS-Threshold (0.012) gegen Stille/Sprache/Fahrgeraeusch verifiziert. Konstanten oben in der Datei tunebar. Co-Authored-By: Claude Opus 4.8 (1M context) --- xtts/whisper/bridge.py | 73 +++++++++++++++++++++++++++++++++++------- 1 file changed, 61 insertions(+), 12 deletions(-) diff --git a/xtts/whisper/bridge.py b/xtts/whisper/bridge.py index c96843e..27130fb 100644 --- a/xtts/whisper/bridge.py +++ b/xtts/whisper/bridge.py @@ -69,6 +69,18 @@ STREAM_DEFAULT_HARD_CAP_MS = 60000 # nach 60s Audio: harter Cut egal was STREAM_MIN_AUDIO_MS = 600 # erst transkribieren wenn min 600ms Audio da STREAM_SESSION_TTL_S = 120 # tote Sessions nach 2 min aufraeumen +# Akustisches Endpointing (ergaenzt die rein-semantische Stagnation). +# Motivation: der reine „Transkript waechst nicht mehr"-Endpoint feuert zu +# frueh (kurze Sprech-Pausen, beam_size=1-Instabilitaet) oder gar nicht +# (Whisper oszilliert/halluziniert). Echte akustische Stille ist das robuste +# „User hat aufgehoert"-Signal. +STREAM_ENERGY_WINDOW_MS = 300 # RMS ueber die letzten 300ms Audio messen +STREAM_VOICE_RMS_THRESHOLD = 0.012 # RMS darueber = Sprache (haelt Session am Leben) +# Rein-semantischer Backstop: wenn die Energie NIE faellt (laute Umgebung, +# z.B. Auto), endpointen wir trotzdem — aber erst nach diesem Faktor x +# endpoint_ms, damit normales Sprechen mit Pausen nicht abgeschnitten wird. +STREAM_SEMANTIC_BACKUP_FACTOR = 2.0 + class WhisperRunner: """Haelt das Whisper-Modell. Hot-Swap bei Konfig-Wechsel via ensure_loaded().""" @@ -310,6 +322,7 @@ class StreamSession: last_partial: str = "" last_growth_at: float = 0.0 last_transcribe_at: float = 0.0 + last_voice_at: float = 0.0 # letzter Tick mit akustischer Sprach-Energie closed: bool = False # nach stream_end gesetzt endpoint_sent: bool = False # Endpoint nur einmal feuern # Speaker-ID Gating: bei aktiviertem Fingerprint pruefen wir die ersten @@ -533,6 +546,35 @@ class SessionManager: if audio_ms < STREAM_MIN_AUDIO_MS: return + # Akustische Sprach-Aktivitaet JEDEN Tick (~200ms) messen — unabhaengig + # vom Transcribe-Throttle. Solange wirklich gesprochen wird, bleibt die + # Session am Leben, auch wenn Whisper gerade keinen neuen Text liefert. + if self._tail_rms(sess) >= STREAM_VOICE_RMS_THRESHOLD: + sess.last_voice_at = now + + # Endpoint-Entscheidung JEDEN Tick, sobald ueberhaupt Text erkannt wurde: + # (a) akustisch: seit endpoint_ms keine Sprach-Energie mehr → User ist + # fertig. Das ist der robuste Primaerpfad gegen „hoert nach zwei + # Worten auf" (waehrend echten Sprechens ist Energie da → kein Cut). + # (b) semantisch (Backstop): Transkript stagniert deutlich laenger als + # endpoint_ms — fuer laute Umgebungen wo die Energie nie faellt. + if sess.last_growth_at > 0.0 and not sess.endpoint_sent: + 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: + logger.info( + "Stream %s: Endpoint (%s) — akustisch %dms / semantisch %dms — Text=%r", + sess.request_id[:8], + "akustisch" if acoustic_done else "semantisch", + int(acoustic_silence_ms), int(semantic_silence_ms), + sess.last_partial[:80], + ) + await self._finalize(sess, ws, + reason="endpoint" if acoustic_done else "endpoint_semantic") + return + # Transcribe-Throttling since_last = (now - sess.last_transcribe_at) * 1000.0 if since_last < STREAM_TRANSCRIBE_INTERVAL_MS: @@ -568,18 +610,8 @@ class SessionManager: }) await _debug_log(ws, "stream.partial", f"id={sess.request_id[:12]} text={text[:80]!r}") - else: - # Stagnation pruefen — Endpoint-Bedingung - if sess.last_growth_at == 0.0: - # Noch gar kein Text erkannt. Wenn der User gar nichts sagt - # springt Brain irgendwann aus eigenem Conversation-Window- - # Timeout in der App raus; wir machen hier nix. - return - silence_ms = (now - sess.last_growth_at) * 1000.0 - if silence_ms >= sess.endpoint_ms and not sess.endpoint_sent: - logger.info("Stream %s: Endpoint nach %dms ohne neuen Text — Text=%r", - sess.request_id[:8], int(silence_ms), sess.last_partial[:80]) - await self._finalize(sess, ws, reason="endpoint") + # else: kein neuer Text — die Endpoint-Entscheidung (akustisch + + # semantischer Backstop) laeuft oben pro Tick, hier nichts mehr zu tun. def _buffer_duration_ms(self, sess: StreamSession) -> float: # 16-bit s16le mono → 2 bytes pro Sample @@ -588,6 +620,23 @@ class SessionManager: return 0.0 return (samples / sess.sample_rate) * 1000.0 + def _tail_rms(self, sess: StreamSession) -> float: + """RMS-Energie der letzten STREAM_ENERGY_WINDOW_MS des Audio-Buffers. + Dient als akustisches „redet noch / ist still"-Signal.""" + 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 + try: + arr = pcm_s16le_to_float32(bytes(tail)) + except Exception: + return 0.0 + if arr.size == 0: + return 0.0 + return float(np.sqrt(np.mean(arr * arr))) + async def _finalize(self, sess: StreamSession, ws, reason: str) -> None: """Endgueltige Transkription auf dem vollen Buffer (beam_size=5), feuert stt_endpoint + stt_stream_done, droppt Session."""