diff --git a/android/src/services/audio.ts b/android/src/services/audio.ts index e618a1d..1bc8e69 100644 --- a/android/src/services/audio.ts +++ b/android/src/services/audio.ts @@ -1145,7 +1145,7 @@ class AudioService { speed: typeof opts.speed === 'number' ? opts.speed : 1.0, interrupted: !!opts.interrupted, location: opts.location || null, - endpointMs: typeof opts.endpointMs === 'number' ? opts.endpointMs : 1500, + endpointMs: typeof opts.endpointMs === 'number' ? opts.endpointMs : STT_ENDPOINT_DEFAULT_MS, hardCapMs: typeof opts.hardCapMs === 'number' ? opts.hardCapMs : 60000, sampleRate: 16000, projectId: opts.projectId || '', diff --git a/xtts/whisper/bridge.py b/xtts/whisper/bridge.py index 27130fb..f859e19 100644 --- a/xtts/whisper/bridge.py +++ b/xtts/whisper/bridge.py @@ -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."""