From cb89ebcd109008db5fd8d41255ea487cf0fed278 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Sat, 3 Oct 2026 10:18:02 +0200 Subject: [PATCH] fix(stt): lange Sprachnachrichten brachen mitten im Satz ab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Endpointer hat seine EIGENE Rechenzeit als Stille gezaehlt. Der Tick-Loop laeuft alle 200ms und ist die einzige Stelle, die akustische Sprach-Energie misst (last_voice_at). Er hat aber auf die Zwischen- Transkription gewartet — und die laeuft jedes Mal ueber den KOMPLETTEN Buffer. Bei 10s Audio harmlos, bei zwei Minuten dauert sie mehrere Sekunden. In dieser Zeit tickte nichts, danach war `now - last_voice_at` um die ganze Rechenzeit groesser als endpoint_ms (5s) — Endpoint mitten im Satz, obwohl durchgehend gesprochen wurde. Deshalb traf es genau die LANGEN Nachrichten, und zwar immer spaeter werdend je laenger die Aufnahme. Nachgewiesen: die Nachricht vom 02.10. 21:28 (1231 Zeichen) war die einzige mit reason=endpoint statt stream_end und endet mitten im Satz ("...weil ich weiss..."). Alle vollstaendigen Nachrichten im Thread haben reason=stream_end, also den Stop-Knopf. Fix: - Zwischen-Transkription laeuft als eigener Task. Der Tick bleibt frei und misst weiter alle 200ms — die Rechenzeit kann nicht mehr als Stille gelten. Laeuft noch eine, wird keine zweite gestartet. - Zwischen-Transkriptionen sehen nur noch die letzten 30s an. Damit bleibt ihre Kosten konstant statt mit der Aufnahmelaenge zu wachsen. Der FINALE Text entsteht unveraendert aus dem gesamten Buffer mit beam_size=5 — es geht kein Wort verloren. stt_partial bekommt dafuer ein Feld 'windowed'. - _finalize und drop brechen eine laufende Zwischen-Transkription ab, damit der finale Text nicht hinter ihr auf den GPU-Lock warten muss. Co-Authored-By: Claude Opus 5 (1M context) --- xtts/whisper/bridge.py | 111 +++++++++++++++++++++++++++++++---------- 1 file changed, 85 insertions(+), 26 deletions(-) diff --git a/xtts/whisper/bridge.py b/xtts/whisper/bridge.py index e1c9d9d..5b944a6 100644 --- a/xtts/whisper/bridge.py +++ b/xtts/whisper/bridge.py @@ -99,6 +99,12 @@ STREAM_SPEAKER_CHECK_MS = 1500 # Mindest-Audio fuer Speaker-ID-Pruefung STREAM_DEFAULT_ENDPOINT_MS = 1500 # nach 1.5s ohne neuen Text → Endpoint 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 +# Zwischen-Transkriptionen sehen nur die letzten N Sekunden an. Sonst waechst +# ihre Rechenzeit mit der Aufnahmelaenge (jedes Mal der ganze Buffer): bei 2min +# Sprechen dauerte eine Zwischen-Transkription mehrere Sekunden, blockierte die +# GPU und machte die Live-Anzeige traege. Der FINALE Text entsteht weiterhin aus +# dem gesamten Buffer (beam_size=5) — es geht also kein Wort verloren. +STREAM_PARTIAL_WINDOW_MS = 30000 STREAM_SESSION_TTL_S = 120 # tote Sessions nach 2 min aufraeumen # Akustisches Endpointing (ergaenzt die rein-semantische Stagnation). @@ -387,6 +393,10 @@ class StreamSession: speaker_checked: bool = False speaker_match: Optional[bool] = None speaker_similarity: float = 0.0 + # Laufende Zwischen-Transkription. Die laeuft NEBENHER, nicht im Tick — + # sonst stand der Endpointer waehrend des Transkribierens, und die + # Rechenzeit wurde als Stille gezaehlt (siehe _tick_session). + partial_task: Optional[asyncio.Task] = None class SessionManager: @@ -479,7 +489,11 @@ class SessionManager: return session def drop(self, request_id: str) -> None: - self._sessions.pop(request_id, None) + sess = self._sessions.pop(request_id, None) + # Nicht als Waise weiterlaufen lassen — sie haelt sonst den GPU-Lock + # fuer eine Sitzung, die es nicht mehr gibt. + if sess is not None and sess.partial_task is not None and not sess.partial_task.done(): + sess.partial_task.cancel() async def run_endpointer(self) -> None: """Background-Loop: alle ~200ms ueber alle Sessions iterieren.""" @@ -660,38 +674,78 @@ class SessionManager: if since_last < STREAM_TRANSCRIBE_INTERVAL_MS: return + # Zwischen-Transkription NEBENHER starten, nicht hier abwarten. + # + # WARUM: dieser Tick laeuft alle 200ms und ist die einzige Stelle, die + # akustische Sprach-Energie misst (last_voice_at). Wurde hier auf das + # Transkribieren gewartet, stand der Endpointer solange — und beim + # naechsten Tick war `now - last_voice_at` um die gesamte Rechenzeit + # groesser. Die Rechenzeit zaehlte also als STILLE. + # + # Das skaliert mit der Aufnahmelaenge, weil jede Zwischen-Transkription + # den KOMPLETTEN Buffer neu durchlaeuft: bei 10s Audio harmlos, bei 2min + # dauert sie mehrere Sekunden und reisst damit die endpoint_ms-Grenze — + # mitten im Satz. Genau das war "lange Texte hoeren irgendwann auf, + # obwohl ich weiterrede". + if sess.partial_task is not None and not sess.partial_task.done(): + return # vorige laeuft noch — Tick bleibt frei fuers Messen sess.last_transcribe_at = now - try: - audio = pcm_s16le_to_float32(bytes(sess.pcm_buffer)) - except Exception: - logger.exception("Stream %s: PCM-Decode fehlgeschlagen", sess.request_id[:8]) - return + sess.partial_task = asyncio.create_task(self._partial_transcribe(sess, ws)) + async def _partial_transcribe(self, sess: StreamSession, ws) -> None: + """Zwischen-Transkription fuer Live-Text + Wachstums-Erkennung. + + Laeuft als eigener Task (siehe _tick_session). Der finale Text entsteht + in _finalize aus dem GESAMTEN Buffer mit beam_size=5 — hier darf also + gekuerzt und ungenau gearbeitet werden, ohne dass Text verloren geht. + """ try: + # Nur das Ende des Buffers ansehen. Sonst waechst die Rechenzeit mit + # der Aufnahmelaenge, bindet die GPU und die Live-Anzeige wird immer + # traeger. Fuer "waechst der Text noch?" reicht der Schluss. + tail_bytes = int(STREAM_PARTIAL_WINDOW_MS / 1000.0 * sess.sample_rate) * 2 + buf = bytes(sess.pcm_buffer) + windowed = len(buf) > tail_bytes + try: + audio = pcm_s16le_to_float32(buf[-tail_bytes:] if windowed else buf) + except Exception: + logger.exception("Stream %s: PCM-Decode fehlgeschlagen", sess.request_id[:8]) + return + # Kleinere beam_size fuer Streaming-Partials — wir wollen Latenz, # nicht maximale Genauigkeit. Final-Transcribe (in _finalize) faehrt # dann mit beam_size=5. - text, _dur = await self.runner.transcribe(audio, sess.language, beam_size=1, vad_filter=True) - except Exception: - logger.exception("Stream %s: Partial-Transcribe crashed", sess.request_id[:8]) - return + try: + text, _dur = await self.runner.transcribe( + audio, sess.language, beam_size=1, vad_filter=True) + except asyncio.CancelledError: + raise + except Exception: + logger.exception("Stream %s: Partial-Transcribe crashed", sess.request_id[:8]) + return - text = text.strip() - grew = bool(text) and text != sess.last_partial - if grew: - sess.last_partial = text - sess.last_growth_at = now - # Optional: stt_partial broadcasten fuer UI-Feedback. Wir schicken's - # mit damit Diagnostic / ChatScreen Live-Text zeigen kann. - await _send(ws, "stt_partial", { - "requestId": sess.request_id, - "audioRequestId": sess.audio_request_id, - "text": text, - }) - await _debug_log(ws, "stream.partial", - f"id={sess.request_id[:12]} text={text[:80]!r}") - # else: kein neuer Text — die Endpoint-Entscheidung (akustisch + - # semantischer Backstop) laeuft oben pro Tick, hier nichts mehr zu tun. + if sess.endpoint_sent or sess.closed: + return # Session ist durch — Partial nicht mehr nachschicken + text = text.strip() + grew = bool(text) and text != sess.last_partial + if grew: + sess.last_partial = text + sess.last_growth_at = time.time() + # Optional: stt_partial broadcasten fuer UI-Feedback. Wir schicken's + # mit damit Diagnostic / ChatScreen Live-Text zeigen kann. + await _send(ws, "stt_partial", { + "requestId": sess.request_id, + "audioRequestId": sess.audio_request_id, + "text": text, + # Nur der Schluss der Aufnahme — der volle Text kommt final. + "windowed": windowed, + }) + await _debug_log(ws, "stream.partial", + f"id={sess.request_id[:12]}{' [tail]' if windowed else ''} text={text[:80]!r}") + # else: kein neuer Text — die Endpoint-Entscheidung (akustisch + + # semantischer Backstop) laeuft im Tick, hier nichts mehr zu tun. + except asyncio.CancelledError: + pass def _buffer_duration_ms(self, sess: StreamSession) -> float: # 16-bit s16le mono → 2 bytes pro Sample @@ -743,6 +797,11 @@ class SessionManager: if sess.endpoint_sent: return sess.endpoint_sent = True + # Laufende Zwischen-Transkription abbrechen: sie haelt den GPU-Lock und + # der finale Text muesste sonst hinter ihr warten — bei langen Aufnahmen + # mehrere Sekunden zusaetzliche Verzoegerung fuer nichts. + if sess.partial_task is not None and not sess.partial_task.done(): + sess.partial_task.cancel() audio = pcm_s16le_to_float32(bytes(sess.pcm_buffer)) if audio.size == 0: logger.info("Stream %s: leere Audio-Daten — final text leer", sess.request_id[:8])