fix(stt): lange Sprachnachrichten brachen mitten im Satz ab

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) <noreply@anthropic.com>
This commit is contained in:
2026-10-03 10:18:02 +02:00
co-authored by Claude Opus 5
parent 443b36bb9d
commit cb89ebcd10
+85 -26
View File
@@ -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])