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:
+85
-26
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user