fix(stt): adaptiver VAD-Schwellwert gegen Satz-Cutoff

Feste RMS-Grenze (0.012) durch rauschboden-relativen Schwellwert ersetzt (fast-down/slow-up, geklammert). Leises/entferntes Sprechen gilt nicht mehr faelschlich als Stille und wird nicht mitten im Satz gecuttet. audio.ts: Fallback-endpointMs 1500->2400 vereinheitlicht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 01:21:43 +02:00
co-authored by Claude Opus 4.8
parent 761f4c8903
commit f5b22253b2
2 changed files with 50 additions and 3 deletions
+49 -2
View File
@@ -75,7 +75,17 @@ STREAM_SESSION_TTL_S = 120 # tote Sessions nach 2 min aufraeumen
# (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)
# --- Adaptiver Voice-Schwellwert (ersetzt die fixe 0.012-Grenze) ---
# Problem (Repro dokumentiert in audio.ts): eine FESTE RMS-Grenze schneidet
# leises/entferntes Sprechen faelschlich als „Stille" (Handy weiter weg vom Mund,
# ruhig im Auto, kurze Sprech-Pause) → Cut mitten im Satz. Loesung: die Grenze
# relativ zum gemessenen Rausch-Boden der Session fuehren. Sprache =
# noise_floor * Faktor, geklammert auf [MIN, MAX]. Bei noch ungelerntem Boden
# gilt MIN → sensibel, lieber nicht abschneiden.
STREAM_VOICE_FACTOR = 2.5 # Sprache = noise_floor * Faktor
STREAM_VOICE_RMS_MIN = 0.005 # Untergrenze (stiller Raum: nicht auf 0 kollabieren)
STREAM_VOICE_RMS_MAX = 0.020 # Obergrenze (lautes Auto: Sprache nie ganz aussperren)
STREAM_VOICE_RMS_THRESHOLD = 0.012 # Legacy-Konstante (nicht mehr im Cut-Pfad genutzt)
# 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.
@@ -323,6 +333,10 @@ class StreamSession:
last_growth_at: float = 0.0
last_transcribe_at: float = 0.0
last_voice_at: float = 0.0 # letzter Tick mit akustischer Sprach-Energie
noise_floor: float = 0.0 # adaptiver Rausch-Boden (0.0 = noch ungelernt)
voice_factor: float = STREAM_VOICE_FACTOR # per-Session konfigurierbar (Payload voiceFactor)
voice_rms_min: float = STREAM_VOICE_RMS_MIN
voice_rms_max: float = STREAM_VOICE_RMS_MAX
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
@@ -372,6 +386,10 @@ class SessionManager:
speed = float(payload.get("speed") or 1.0)
except (TypeError, ValueError):
speed = 1.0
try:
voice_factor = float(payload.get("voiceFactor") or STREAM_VOICE_FACTOR)
except (TypeError, ValueError):
voice_factor = STREAM_VOICE_FACTOR
session = StreamSession(
request_id=request_id,
audio_request_id=payload.get("audioRequestId", "") or "",
@@ -381,6 +399,7 @@ class SessionManager:
hard_cap_ms=hard_cap_ms,
voice=payload.get("voice", "") or "",
speed=speed,
voice_factor=voice_factor,
interrupted=bool(payload.get("interrupted", False)),
location=payload.get("location") or None,
sample_rate=int(payload.get("sampleRate") or 16000),
@@ -549,8 +568,16 @@ class SessionManager:
# 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:
# ADAPTIV: der Schwellwert richtet sich nach dem gemessenen Rausch-Boden
# (fast-down/slow-up), damit leises/entferntes Sprechen nicht faelschlich
# als Stille gilt und der Satz mitten drin abgeschnitten wird.
rms = self._tail_rms(sess)
if rms >= self._voice_threshold(sess):
sess.last_voice_at = now
else:
# Rausch-Boden NUR aus Nicht-Sprache lernen — waehrend Sprache
# einfrieren, sonst wandert die Grenze hoch und sperrt Sprache aus.
self._update_noise_floor(sess, rms)
# Endpoint-Entscheidung JEDEN Tick, sobald ueberhaupt Text erkannt wurde:
# (a) akustisch: seit endpoint_ms keine Sprach-Energie mehr → User ist
@@ -620,6 +647,26 @@ class SessionManager:
return 0.0
return (samples / sess.sample_rate) * 1000.0
def _voice_threshold(self, sess: StreamSession) -> float:
"""Adaptiver Voice-Schwellwert = Rausch-Boden * Faktor, geklammert auf
[min, max]. Bei noch ungelerntem Boden (0.0) → Untergrenze: sensibel,
lieber nicht abschneiden (das war der eigentliche Cutoff-Bug)."""
nf = sess.noise_floor
if nf <= 0.0:
return sess.voice_rms_min
return min(max(nf * sess.voice_factor, sess.voice_rms_min), sess.voice_rms_max)
def _update_noise_floor(self, sess: StreamSession, rms: float) -> None:
"""Rausch-Boden nachfuehren: schnell runter (neue, leisere Stille),
langsam rauf (Umgebung wird lauter). NUR mit Nicht-Sprache aufrufen."""
nf = sess.noise_floor
if nf <= 0.0:
sess.noise_floor = rms
elif rms < nf:
sess.noise_floor = 0.90 * nf + 0.10 * rms
else:
sess.noise_floor = 0.98 * nf + 0.02 * rms
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."""