fix(voice): robusteres STT-Endpointing via akustische Stille (nicht nur semantisch)
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) <noreply@anthropic.com>
This commit is contained in:
+61
-12
@@ -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_MIN_AUDIO_MS = 600 # erst transkribieren wenn min 600ms Audio da
|
||||||
STREAM_SESSION_TTL_S = 120 # tote Sessions nach 2 min aufraeumen
|
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:
|
class WhisperRunner:
|
||||||
"""Haelt das Whisper-Modell. Hot-Swap bei Konfig-Wechsel via ensure_loaded()."""
|
"""Haelt das Whisper-Modell. Hot-Swap bei Konfig-Wechsel via ensure_loaded()."""
|
||||||
@@ -310,6 +322,7 @@ class StreamSession:
|
|||||||
last_partial: str = ""
|
last_partial: str = ""
|
||||||
last_growth_at: float = 0.0
|
last_growth_at: float = 0.0
|
||||||
last_transcribe_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
|
closed: bool = False # nach stream_end gesetzt
|
||||||
endpoint_sent: bool = False # Endpoint nur einmal feuern
|
endpoint_sent: bool = False # Endpoint nur einmal feuern
|
||||||
# Speaker-ID Gating: bei aktiviertem Fingerprint pruefen wir die ersten
|
# Speaker-ID Gating: bei aktiviertem Fingerprint pruefen wir die ersten
|
||||||
@@ -533,6 +546,35 @@ class SessionManager:
|
|||||||
if audio_ms < STREAM_MIN_AUDIO_MS:
|
if audio_ms < STREAM_MIN_AUDIO_MS:
|
||||||
return
|
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
|
# Transcribe-Throttling
|
||||||
since_last = (now - sess.last_transcribe_at) * 1000.0
|
since_last = (now - sess.last_transcribe_at) * 1000.0
|
||||||
if since_last < STREAM_TRANSCRIBE_INTERVAL_MS:
|
if since_last < STREAM_TRANSCRIBE_INTERVAL_MS:
|
||||||
@@ -568,18 +610,8 @@ class SessionManager:
|
|||||||
})
|
})
|
||||||
await _debug_log(ws, "stream.partial",
|
await _debug_log(ws, "stream.partial",
|
||||||
f"id={sess.request_id[:12]} text={text[:80]!r}")
|
f"id={sess.request_id[:12]} text={text[:80]!r}")
|
||||||
else:
|
# else: kein neuer Text — die Endpoint-Entscheidung (akustisch +
|
||||||
# Stagnation pruefen — Endpoint-Bedingung
|
# semantischer Backstop) laeuft oben pro Tick, hier nichts mehr zu tun.
|
||||||
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")
|
|
||||||
|
|
||||||
def _buffer_duration_ms(self, sess: StreamSession) -> float:
|
def _buffer_duration_ms(self, sess: StreamSession) -> float:
|
||||||
# 16-bit s16le mono → 2 bytes pro Sample
|
# 16-bit s16le mono → 2 bytes pro Sample
|
||||||
@@ -588,6 +620,23 @@ class SessionManager:
|
|||||||
return 0.0
|
return 0.0
|
||||||
return (samples / sess.sample_rate) * 1000.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:
|
async def _finalize(self, sess: StreamSession, ws, reason: str) -> None:
|
||||||
"""Endgueltige Transkription auf dem vollen Buffer (beam_size=5),
|
"""Endgueltige Transkription auf dem vollen Buffer (beam_size=5),
|
||||||
feuert stt_endpoint + stt_stream_done, droppt Session."""
|
feuert stt_endpoint + stt_stream_done, droppt Session."""
|
||||||
|
|||||||
Reference in New Issue
Block a user