Skip to content

feat(stt): opt-in stall detection in the STT FallbackAdapter - #7826

Open
rrfunde wants to merge 5 commits into
livekit:mainfrom
rrfunde:feat/7693-stt-fallback-stall-timeout
Open

rrfunde wants to merge 5 commits into
livekit:mainfrom
rrfunde:feat/7693-stt-fallback-stall-timeout

Conversation

@rrfunde

@rrfunde rrfunde commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

summary

  • add an opt-in stall_timeout (and stall_min_speech_duration, default 1.0s) to stt.FallbackAdapter to detect a streaming STT that stays connected but stops transcribing. Today the adapter only reacts to errors/timeouts, so a provider that keeps its WebSocket open but emits nothing mutes the user until it closes the session itself.
  • when set, the adapter's vad runs on the streamed audio; if the active STT emits no transcript with non-empty text (interim, preflight or final; empty finals, e.g. from the inference STT, and speech boundaries, which can come from a VAD such as stt.StreamAdapter's, don't count; stall_min_speech_duration keeps short noises out) between the start of a detected speech of at least stall_min_speech_duration and stall_timeout seconds after its end, the stream fails with a retryable APIConnectionError, so the existing fallback, availability events and recovery (which still requires a real final transcript) take over.
  • silence and short noises never arm the check; the speech start is backdated by the VAD's speech_duration so STT events arriving before the VAD's start-of-speech count; the "last transcript" clock resets on each new leg so speech from before a failover never blames the new STT; flush() is forwarded to the stall detection VAD as a segment boundary; a failure of that VAD is logged and turns off detection for that stream only; a pending check survives end_input(), so a stalled STT that stays open can't hang the caller.
  • a flush (or end_input()) during a speech ends it there: the stall VAD runs on a new stream per flush segment and the previous one's input is ended, so the check is made once the VAD processed all the pre-flush audio, never at enqueue time. Cost: one vad.stream() per flush (~9 µs silero, ~29 µs inference; pass executor= to inference.VAD to avoid a thread pool per stream).
  • a detected speech is dated by its position on the VAD's audio timeline (when its audio was pushed), not by when the VAD reports it, so a VAD lagging behind the audio never discounts transcripts the STT emitted meanwhile; the check deadline is still stall_timeout after the VAD reports the end of speech, or after the flush / end of input.
  • each detected speech keeps its own pending check; later speech never postpones an earlier unanswered speech's check.
  • disabled by default (None); when off, the stream path is unchanged (no extra task or VAD stream).
  • trade-offs, documented in the docstring:
    • each fallback stream runs its own VAD stream (extra VAD inference)
    • detection latency is the VAD's min silence + stall_timeout after the user stops talking
    • speech of the agent itself picked up by the VAD counts (the adapter has no agent state)
    • with a single STT a stall surfaces as the same "all STTs failed" error a failing STT does today
    • audio spoken during the stalled window is not replayed into the new STT (possible follow-up)

test plan

  • uv run pytest tests/test_stt_fallback.py (unit, virtual time): new tests cover a connected-but-mute primary failing over to the fallback at EOS + stall_timeout, pure silence (no failover), speech shorter than stall_min_speech_duration (no failover), a healthy primary transcribing before and after the VAD's end of speech (no failover), stall_timeout without a VAD raising ValueError, speech boundaries alone (no transcript) still failing over, flush() reaching the detector VAD, a VAD failure being logged while transcription continues, and the stall check surviving end_input(); a lagging VAD keeping a healthy STT that emitted an early interim while still failing over a mute one; each new test fails without its fix
  • verified the stall test fails with stall_timeout=None (no transcript reaches the user)
  • other STT fallback/base suites pass; ruff format/check; strict mypy -p livekit.agents

Fixes #7693

🤖 Generated with Claude Code

@rrfunde
rrfunde requested a review from a team as a code owner October 14, 2026 15:25
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-22T11:04:04.234285Z f8a8dd6 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

devin-ai-integration[bot]

This comment was marked as resolved.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b08a647f0e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +526 to +527
if ev.type != SpeechEventType.RECOGNITION_USAGE:
self._last_event_time = time.perf_counter()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Ignore wrapper VAD events when assessing STT activity

When a non-streaming primary is passed, the constructor wraps it in StreamAdapter; that wrapper emits local START_OF_SPEECH and END_OF_SPEECH events before it awaits the real recognize() call (livekit-agents/livekit/agents/stt/stream_adapter.py:127-146). This branch counts those local VAD events as provider activity, so _check_stall can never fire for that utterance if recognize() hangs or returns no transcript, and the documented wrapped-STT fallback leaves the caller muted. Track actual recognition activity for this wrapper rather than its synthetic VAD events.

Useful? React with 👍 / 👎.

Comment on lines +400 to +402
stall_monitor_task = asyncio.create_task(self._stall_monitor_task(vad_stream))
try:
await self._run_streams(vad_stream=vad_stream)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Observe failures from the stall-monitor task

If the per-stream VAD fails, VADStream.__anext__ propagates that error (livekit-agents/livekit/agents/vad.py:180-185) and _stall_monitor_task terminates, but _run waits only for _run_streams; later VAD pushes are suppressed as RuntimeErrors. With a connected mute STT, the outer stream then waits indefinitely instead of surfacing the detector failure or failing over, so opting into stall detection is silently ineffective. Wait for or observe both tasks and propagate or handle monitor termination.

Useful? React with 👍 / 👎.

Comment on lines +419 to +421
if vad_stream is not None and isinstance(data, rtc.AudioFrame):
with contextlib.suppress(RuntimeError):
vad_stream.push_frame(data)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Forward flush boundaries to the detector VAD

When callers delimit audio with public RecognizeStream.flush(), this condition ignores the _FlushSentinel even though the same sentinel is sent to the main and recovery STTs below. VADStream.flush() is a required hard segment boundary (livekit-agents/livekit/agents/vad.py:155-165), so the detector can carry speaking state across segments; two short segments can be merged past stall_min_speech_duration, and activity from one segment can mask a stall in the next. Forward or otherwise handle the flush boundary in the detector.

Useful? React with 👍 / 👎.

rrfunde added a commit to rrfunde/livekit-agents that referenced this pull request Oct 15, 2026
Only transcripts (interim, preflight, final) now count as STT progress:
speech boundaries can come from a VAD, e.g. the one of stt.StreamAdapter,
before any recognition happened, which hid a stalled recognize call.

Forward flush() to the stall detection VAD as a hard segment boundary,
like stt.StreamAdapter does, log a failure of the stall monitor (stall
detection is then disabled for that stream), and keep a pending stall
check alive after end_input() so a stalled STT that stays open doesn't
hang the caller. Rename _last_event_time to _last_transcript_time.
Addresses review feedback on livekit#7826.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rrfunde
rrfunde force-pushed the feat/7693-stt-fallback-stall-timeout branch from b08a647 to 8372619 Compare October 15, 2026 05:13

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 83726198fa

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

vad_stream.push_frame(data)
elif isinstance(data, self._FlushSentinel):
# a hard segment boundary for the VAD, as in stt.StreamAdapter
vad_stream.flush()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve active speech before flushing the detector

When a caller invokes RecognizeStream.flush() (including via end_input()) while the VAD is still in its speaking state—for example, when an audio clip ends without the detector's minimum trailing silence—this flush hard-resets both shipped VAD implementations without emitting END_OF_SPEECH (livekit-plugins/livekit-plugins-silero/livekit/plugins/silero/vad.py:358-361 and livekit-agents/livekit/agents/inference/vad.py:270-273). Since _stall_monitor_task only arms a check on END_OF_SPEECH, a connected-but-mute primary is never declared stalled in that case and the stream can wait indefinitely; preserve/evaluate the active detected segment at this boundary before resetting it.

Useful? React with 👍 / 👎.

rrfunde added a commit to rrfunde/livekit-agents that referenced this pull request Oct 15, 2026
A flush (including the one from end_input()) hard-resets the stall-detection
VAD without an END_OF_SPEECH, so an ongoing speech never scheduled a stall
check. On a flush, check the ongoing speech as if it ended there, since the
STT is flushed too. Use its VAD-reported duration so speech the VAD already
dropped can't trigger a later check. Addresses review feedback on livekit#7826.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
devin-ai-integration[bot]

This comment was marked as resolved.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5362a9fa23

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +439 to +441
if (speech_start_time := self._speech_start_time) is not None:
self._speech_start_time = None
self._schedule_stall_check(speech_start_time, self._speech_duration)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Synchronize flushes with pending VAD events

When callers enqueue buffered audio and then call flush() or end_input() without yielding, _forward_input_task can drain the unbounded input channel in one turn; vad_stream.push_frame() and vad_stream.flush() only enqueue work, so the monitor has not yet set _speech_start_time when this state is read. The VAD can subsequently emit START/INFERENCE events and then hard-reset on the queued flush without EOS, leaving a mute STT with no scheduled check and a hung stream. This enqueue-versus-monitor race in the revised flush handling is fresh evidence beyond the earlier flush finding; wait for the detector/monitor to consume the boundary before deciding whether to schedule the check.

Useful? React with 👍 / 👎.

Comment on lines +595 to +597
if self._stall_check_task is not None:
self._stall_check_task.cancel()
self._stall_check_task = asyncio.create_task(_check_stall())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the earliest unanswered-speech check

When two qualifying speech segments end less than stall_timeout apart and the active STT emits nothing, scheduling the second segment cancels the first segment's earlier deadline. Each subsequent retry utterance can therefore restart the timer, allowing a connected-but-mute primary to avoid failover indefinitely while the user keeps saying short phrases; retain the earliest pending unanswered-speech check until a transcript arrives or that deadline fires.

Useful? React with 👍 / 👎.

SpeechEventType.PREFLIGHT_TRANSCRIPT,
SpeechEventType.FINAL_TRANSCRIPT,
):
self._last_transcript_time = time.perf_counter()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require usable text before marking STT progress

When a provider emits a transcript-typed event with no alternatives or empty text, this updates _last_transcript_time and suppresses failover even though the detected speech produced no usable transcription. This is a concrete path for the built-in inference STT, whose _process_transcript filters empty interim results but still emits empty FINAL_TRANSCRIPT events (livekit-agents/livekit/agents/inference/stt.py:1417-1443); a connected primary returning such finals for long speech can therefore keep the user muted indefinitely. Only count a non-empty alternative as progress, matching the recovery check below.

Useful? React with 👍 / 👎.

rrfunde added a commit to rrfunde/livekit-agents that referenced this pull request Oct 15, 2026
- Run the stall detection VAD on a stream per flush segment: ending its
  input instead of flushing it makes the monitor see the boundary once
  the VAD processed all the audio before the flush, so a speech it only
  detects after the flush was forwarded is still checked. Speech starts
  are clamped to the segment end, as a lagging VAD dates them late.
- Keep every pending stall check: a later speech no longer cancels the
  check of an earlier unanswered one.
- Only count transcripts with a non-empty text as STT progress, as empty
  finals (e.g. of the inference STT) don't show the STT is transcribing.

Addresses review feedback on livekit#7826.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
devin-ai-integration[bot]

This comment was marked as resolved.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 840568f8db

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

if ev.type == VADEventType.START_OF_SPEECH:
# the VAD only reports speech after some of it was heard, while the STT may
# already have reacted, so date the speech back to when it began
speech_start_time = time.perf_counter() - ev.speech_duration

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Anchor speech starts to the audio timeline

When the detector processes audio behind the STT (for example, the shipped Silero path explicitly accumulates slower-than-realtime inference delay in livekit-plugins/livekit-plugins-silero/livekit/plugins/silero/vad.py:454-462), this timestamps the speech as callback time minus only its audio duration, omitting the detector backlog. A valid transcript can therefore be recorded before speech_start_time, causing the check at line 636 to mark a healthy provider stalled. The new per-segment clamp is fresh evidence beyond the earlier flush race: it applies only once a segment has an end_time, and that end can itself postdate a transcript emitted before the flush. Anchor the speech to the segment's audio timeline instead.

Useful? React with 👍 / 👎.

rrfunde added a commit to rrfunde/livekit-agents that referenced this pull request Oct 16, 2026
The stall check dated a detected speech from when its START_OF_SPEECH
event was consumed minus the speech duration, ignoring the VAD's backlog.
A VAD lagging behind the audio (e.g. Silero's slower-than-realtime
inference accumulation) dated the speech too late, so a transcript the
STT emitted for it earlier didn't count and a healthy STT was failed over.

Each VAD segment now records when each frame was pushed against the
cumulative audio duration, and a speech starts when the audio at the VAD
event's position (timestamp - speech_duration) was pushed. The kept
timeline is trimmed as events are consumed. This replaces the clamp to
the segment end. The check deadline is unchanged. Addresses review
feedback on livekit#7826.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rrfunde

rrfunde commented Oct 16, 2026

Copy link
Copy Markdown
Contributor Author

Hi! A note on scope before more review rounds. The review bots have done four rounds on this PR, and each round's fixes have added machinery: a VAD stream per flush segment, per-speech checks, and now an audio-timeline mapping so a lagging VAD dates speech correctly. It's all opt-in (stall_timeout=None keeps today's code path byte-for-byte) and covered by virtual-time tests plus a real Silero run, but the stall logic is now around 200 lines.

Before going further I'd like to check the direction with you:

  1. Is VAD-gated stall detection inside stt.FallbackAdapter the approach you'd want? The alternative is a simpler, provider-level inactivity timeout, such as a per-plugin heartbeat like fix(gladia): detect a silently dropped STT socket instead of hanging #7404 / fix(assemblyai): bound the STT read side on a half-open socket #7547, with the adapter only reacting to the resulting error.
  2. If this approach is fine, is the current scope OK, or would you rather cut it back (e.g. no flush() handling) and accept the edge cases?

Happy to simplify or close this in favour of a different design.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 new potential issues.

Devin Review

Comment on lines +54 to +58
def push_frame(self, frame: rtc.AudioFrame) -> None:
self.frame_times.append(time.perf_counter())
self.audio_duration += frame.duration
self.frame_ends.append(self.audio_duration)
self.vad_stream.push_frame(frame)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Silent streams retain unlimited frame history

When a VAD emits no events during silence, push_frame keeps every frame's timing metadata. trim runs only when a VAD event arrives, so a long-lived silent stream grows its history without bound.

Learn more

Stall detection records a wall-clock push time and an audio-timeline end for every incoming frame. A VAD is allowed to omit inference events during silence; the speech-free _FlushCountingVADStream does exactly that. The monitor only calls trim when it receives an event. On an open stream with no event or flush, the two arrays therefore grow for every frame, even though the VAD consumes its input.

Example: A speech-boundary-only VAD receives 100 audio frames each second of an hour-long silent session. No events arrive, so about 360,000 entries remain in each timing list instead of being discarded or compacted.

Recommended fix: Bound or compact the retained timeline even when the VAD emits no events. Preserve enough timing information to date a start-of-speech event that arrives late, without storing one timestamp per frame indefinitely.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +64 to +65
i = bisect.bisect_left(self.frame_ends, position)
return self.frame_times[min(i, len(self.frame_times) - 1)]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Frame-boundary speech misses stall detection

When speech starts at a frame boundary, pushed_at dates it to the preceding frame's push. A transcript between the two pushes then counts for the new speech, masking a silent STT.

Learn more

The timeline stores each frame's end position and wall-clock push time. At a frame boundary, the audio beginning at that position belongs to the next frame, but bisect_left returns the previous frame. The resulting earlier start time makes _schedule_stall_check treat a transcript from before the new speech as its answer.

Example: One frame ends at audio position 1.0s and was pushed at wall time 1.0s. The next frame starts there and is pushed at wall time 1.1s. A transcript at 1.05s precedes that speech, but the check uses 1.0s and considers it answered.

Recommended fix: Use bisect_right to select the next frame when the speech position equals a frame end, retaining the last-frame clamp for positions beyond the stored timeline.

Suggested change
i = bisect.bisect_left(self.frame_ends, position)
return self.frame_times[min(i, len(self.frame_times) - 1)]
i = bisect.bisect_right(self.frame_ends, position)
return self.frame_times[min(i, len(self.frame_times) - 1)]

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bb07e832ad

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +729 to +730
if speech_start_time is not None:
self._schedule_stall_check(speech_start_time, speech_duration)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid failing wrapped STTs for dropped flush segments

When a non-streaming primary is auto-wrapped and the caller flushes while the detector still considers a qualifying speech active, StreamAdapterWrapper handles that same boundary by calling vad_stream.flush() and continuing (livekit-agents/livekit/agents/stt/stream_adapter.py:117-121); because a VAD flush hard-resets without emitting END_OF_SPEECH, the wrapper never reaches its _wrapped_stt.recognize() call for that segment. Scheduling a check here therefore labels a healthy wrapped STT unavailable and starts fallback/recovery solely because the wrapper intentionally dropped the segment (the same applies to an explicitly supplied StreamAdapter); skip this flush-derived check for that path or make the wrapper finalize the active segment first.

Useful? React with 👍 / 👎.

rrfunde and others added 5 commits October 22, 2026 19:55
A streaming STT can keep its connection open and stop emitting events while
audio still flows. The FallbackAdapter only reacted to errors and timeouts,
so the user was effectively muted until the provider closed the session.

Add an opt-in `stall_timeout` (with `stall_min_speech_duration`) to
`stt.FallbackAdapter`. When set, the adapter's VAD runs on the streamed audio,
and if the active STT emits no event between the start of a detected speech
and `stall_timeout` seconds after its end, the stream fails with a retryable
APIConnectionError, so the existing fallback and recovery take over.
Disabled by default.

Fixes livekit#7693

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Only transcripts (interim, preflight, final) now count as STT progress:
speech boundaries can come from a VAD, e.g. the one of stt.StreamAdapter,
before any recognition happened, which hid a stalled recognize call.

Forward flush() to the stall detection VAD as a hard segment boundary,
like stt.StreamAdapter does, log a failure of the stall monitor (stall
detection is then disabled for that stream), and keep a pending stall
check alive after end_input() so a stalled STT that stays open doesn't
hang the caller. Rename _last_event_time to _last_transcript_time.
Addresses review feedback on livekit#7826.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A flush (including the one from end_input()) hard-resets the stall-detection
VAD without an END_OF_SPEECH, so an ongoing speech never scheduled a stall
check. On a flush, check the ongoing speech as if it ended there, since the
STT is flushed too. Use its VAD-reported duration so speech the VAD already
dropped can't trigger a later check. Addresses review feedback on livekit#7826.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Run the stall detection VAD on a stream per flush segment: ending its
  input instead of flushing it makes the monitor see the boundary once
  the VAD processed all the audio before the flush, so a speech it only
  detects after the flush was forwarded is still checked. Speech starts
  are clamped to the segment end, as a lagging VAD dates them late.
- Keep every pending stall check: a later speech no longer cancels the
  check of an earlier unanswered one.
- Only count transcripts with a non-empty text as STT progress, as empty
  finals (e.g. of the inference STT) don't show the STT is transcribing.

Addresses review feedback on livekit#7826.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The stall check dated a detected speech from when its START_OF_SPEECH
event was consumed minus the speech duration, ignoring the VAD's backlog.
A VAD lagging behind the audio (e.g. Silero's slower-than-realtime
inference accumulation) dated the speech too late, so a transcript the
STT emitted for it earlier didn't count and a healthy STT was failed over.

Each VAD segment now records when each frame was pushed against the
cumulative audio duration, and a speech starts when the audio at the VAD
event's position (timestamp - speech_duration) was pushed. The kept
timeline is trimmed as events are consumed. This replaces the clamp to
the segment end. The check deadline is unchanged. Addresses review
feedback on livekit#7826.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rrfunde
rrfunde force-pushed the feat/7693-stt-fallback-stall-timeout branch from bb07e83 to f8a8dd6 Compare October 22, 2026 10:56

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f8a8dd626c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +649 to +651
if forward_task.done():
forward_task.result() # propagate the stream's own failure, if any
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep pending stall checks after transcriptless completion

When end_input() follows a qualifying utterance before its stall deadline, a provider that closes cleanly without emitting text makes forward_task complete, so this branch returns success and _run cancels the VAD monitor and pending checks instead of failing over. This is reachable with shipped streams—for example, Deepgram sends its close message when input ends and treats the resulting expected socket close as a normal return (livekit-plugins/livekit-plugins-deepgram/livekit/plugins/deepgram/stt.py:646-671)—leaving the detected utterance untranscribed and the mute provider marked available; defer successful completion until the detector has concluded that there is no unanswered qualifying speech.

Useful? React with 👍 / 👎.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

STT FallbackAdapter can't detect a stream that stays connected but stops transcribing

1 participant