diff --git a/app/services/qwen3_websocket_asr.py b/app/services/qwen3_websocket_asr.py index 776c5e8..53f6b03 100644 --- a/app/services/qwen3_websocket_asr.py +++ b/app/services/qwen3_websocket_asr.py @@ -2021,14 +2021,23 @@ class Qwen3ASRService: self, ctx: ConnectionContext, text: str, + *, + native_stream_snapshot: bool = False, ) -> str: - """Expose only newly stabilized prefixes; the final event carries revisions.""" - candidate = self._partial_display_text(ctx, text) + """Publish a growing preview while leaving Qwen's revisable tail for finalization.""" + if native_stream_snapshot: + # Native Qwen text is a rolling snapshot. Exposing its unstable tail + # can make the next correction fail the append-only check and freeze + # the bubble at its first preview. Only publish the confirmed prefix. + candidate = ctx.stable_partial_prefix.strip() + else: + candidate = self._partial_display_text(ctx, text) + previous = ctx.partial_preview_text - if not previous or candidate.startswith(previous): + if candidate and (not previous or candidate.startswith(previous)): ctx.partial_preview_text = candidate - # Qwen may rewrite its rolling snapshot. Keep the already shown prefix - # fixed until finalization so the browser never retracts earlier words. + # If Qwen revises already published text, keep the preview fixed. The + # final segment event will replace it with the model's finalized result. return ctx.partial_preview_text def _is_suspicious_segment_text( @@ -3150,6 +3159,7 @@ class Qwen3ASRService: partial_display = self._append_only_partial_preview( ctx, visible_observed, + native_stream_snapshot=has_native_stream_snapshot, ) partial_display = self._clip_partial_text(partial_display, ctx) if ( diff --git a/test_web/index.html b/test_web/index.html index 4fb91c6..fd9982d 100644 --- a/test_web/index.html +++ b/test_web/index.html @@ -4211,24 +4211,34 @@ ${transcript} ) { const safeText = (text || "").trim(); if (!safeText) return; - if (partial && state.liveTranscript.length) { - const last = state.liveTranscript[state.liveTranscript.length - 1]; - if (last.partial && (segmentIndex === null || last.segmentIndex === segmentIndex)) { - last.label = label; - last.text = safeText; - last.speaker = speaker; - last.speakerPending = speakerPending; - last.segmentIndex = segmentIndex; - last.speakerKey = speakerKey || last.speakerKey || (segmentIndex === null ? "unknown" : `segment-${segmentIndex}`); - last.startTimeMs = startTimeMs; - last.endTimeMs = endTimeMs; + if (partial) { + const existingPartialIndex = state.liveTranscript.findIndex( + (item) => item.partial && (segmentIndex === null || item.segmentIndex === segmentIndex), + ); + if (existingPartialIndex >= 0) { + const existingPartial = state.liveTranscript[existingPartialIndex]; + existingPartial.label = label; + existingPartial.text = safeText; + existingPartial.speaker = speaker; + existingPartial.speakerPending = speakerPending; + existingPartial.segmentIndex = segmentIndex; + existingPartial.speakerKey = speakerKey || existingPartial.speakerKey || (segmentIndex === null ? "unknown" : `segment-${segmentIndex}`); + existingPartial.startTimeMs = startTimeMs; + existingPartial.endTimeMs = endTimeMs; renderLiveTranscript(); return; } } - if (!partial && state.liveTranscript.length) { - const last = state.liveTranscript[state.liveTranscript.length - 1]; - if (last.partial) state.liveTranscript.pop(); + if (!partial) { + // A late speaker update for an older segment must not erase the live preview. + const partialIndex = state.liveTranscript.findIndex( + (item) => item.partial && ( + segmentIndex === null + ? item.segmentIndex === null + : item.segmentIndex === segmentIndex + ), + ); + if (partialIndex >= 0) state.liveTranscript.splice(partialIndex, 1); } if (!partial && segmentIndex !== null) {