Skip to content

fix(stt): translate multi-speaker timestamps and own retries in the adapter - #7928

Open
BlueX888 wants to merge 3 commits into
livekit:mainfrom
BlueX888:fix/multi-speaker-start-time-offset
Open

BlueX888 wants to merge 3 commits into
livekit:mainfrom
BlueX888:fix/multi-speaker-start-time-offset

Conversation

@BlueX888

@BlueX888 BlueX888 commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Problem

MultiSpeakerAdapter wraps a diarizing STT and picks the primary speaker with an RMS buffer
indexed by the audio the adapter has forwarded. agent.stt_node seeds the stream's
start_time_offset with the time already elapsed in the session, so the wrapper has to put
provider timestamps on that input timeline before the detector can find the right audio.

Two things broke the mapping:

  • A leg built after a retry started its provider clock at zero while the pipeline anchor kept
    running, so forwarded timestamps lost the elapsed offset.
  • The detector compared provider timestamps directly with its forwarded-audio clock, which is
    shorter than the input timeline by the elapsed offset and by any reconnect gap. The RMS
    lookup (_get_rms_for_timerange) missed for every final transcript, no primary speaker was
    ever selected, and background suppression stayed off.

Review also found a third path: a wrapped stream retrying internally advanced its own
start_time_offset while the adapter kept the offsets computed for the first attempt. This is
reproducible on the previous PR head 65f82e34; the child-retry tests added here fail there
while the rest of the timestamp-translation suite passes.

Why not seed start_time_offset on the wrapped leg

An earlier revision of this PR did exactly that, and it only works for plugins that fold the
field into the SpeechData they emit. deepgram/assemblyai/aws add it; xAI builds timestamps
straight from the provider's word clocks and ignores it. Seeding therefore put different
providers on different timelines.

Approach

Provider-agnostic timestamp translation. Every wrapped leg keeps its own provider clock, and
the wrapper translates its events once:

  • forwarded SpeechData times (start_time/end_time and word clocks) are shifted by where
    the leg starts on the wrapper's input timeline;
  • _PrimarySpeakerDetector is told the difference between the input timeline and its
    forwarded-audio clock, so the RMS lookup and stored last_activity_time keep indexing the
    audio it actually saw. That difference also covers the reconnect gap.

The wrapper also owns provider retries now: the wrapped stream is created with
max_retry=0 while the wrapper keeps the retry budget it was given and rebuilds the leg
after a failure. A provider stream retrying internally would advance its own
start_time_offset behind the adapter's back, and since plugins fold that offset into
SpeechData inconsistently, the adapter cannot correct for it afterwards. With the wrapper
owning retries, every attempt is a fresh leg whose clock starts at zero, so both conventions
stay aligned. timeout, retry_interval and liveness_timeout still reach the provider
unchanged. This matches how FallbackAdapter creates its recovery streams with
max_retry=0.

Scope: this covers the leg the adapter creates itself. If the wrapped STT is a nested
adapter such as FallbackAdapter, that adapter re-expands the retry budget for its inner
streams, and a plugin that reconnects inside its own _run never raises to the wrapper. An
inner stream in either shape can still restart its own clock, which remains out of scope for
this PR.

Tests

  • test_nonzero_offset_keeps_primary_and_suppresses_background (aligned and real xAI shape):
    a segment reported at wrapper offset 30 s is found in the detector's buffer, becomes
    primary, and background speech is suppressed.
  • test_wrapper_retry_keeps_forwarded_timestamps_on_input_timeline: a wrapper-level retry
    keeps forwarded times on the input timeline and the wrapped leg's offset at zero.
  • test_wrapper_retry_maps_audio_consumed_before_the_gap: audio consumed before a reconnect
    still counts for the RMS buffer.
  • test_zero_offset_leaves_provider_timestamps_untouched: with no elapsed offset, the
    provider clock is forwarded unchanged.
  • test_wrapped_stream_gets_no_retry_budget: the wrapped stream is created with
    max_retry=0 even when the adapter was given a retry budget, and the other connect
    options pass through.
  • test_child_stream_retry_keeps_the_detector_on_its_audio (aligned and xAI): a child that
    would retry internally fails once, the wrapper retries, and the louder speaker still takes
    over -- the RMS window matches the audio actually forwarded.

For the child-retry regression on the previous PR head 65f82e34, the first leg consumed
0.6 s and then retried itself: the provider clock restarted at zero, and the detector looked
up about [0.1, 0.6] (aligned) and about [0.0, 0.5] (xAI) for audio that actually lives
at [0.6, 1.1), so the loud speaker stayed suppressed. With the wrapper owning the retry, the
lookup matches the forwarded audio for both conventions and the loud speaker takes over.

Verification

  • uv run pytest tests/test_multi_speaker_adapter.py -q: 21 passed (10 consecutive runs)
  • related STT/adapter suites (multi-speaker primary speaker, STT base/fallback/event
    traces/prewarm, agent STT node, audio recognition push/handoff/aclose, STT context):
    128 passed
  • uv run pytest tests/test_multi_speaker_primary_speaker.py tests/test_stt_fallback.py -q:
    18 passed
  • uv run pytest tests/test_plugin_deepgram_stt.py -q: 44 passed
  • uv run pytest -q --unit -- tests/test_multi_speaker_adapter.py: 21 passed
  • uv run ruff format --check and uv run ruff check on the changed files: clean
  • uv run --group typing mypy --untyped-calls-exclude=smithy_aws_core --platform linux -p livekit.agents:
    Success, no issues in 234 source files
  • the same test file on the previous PR head 65f82e34: 3 failed, 18 passed -- the two
    child-retry cases and the no-retry-budget case fail; the candidate passes all 21.
  • standalone probes on 65f82e34 report one leg with two attempts (retried_by=child) and
    no speaker takeover for either convention; on the candidate, two legs with one attempt
    each (retried_by=wrapper) and the louder speaker takes over.

Environment note: a full pytest --unit collection stops at tests/test_plugin_azure_realtime.py
because the optional azure.ai packages are not installed here, and tests/test_stt.py needs
the LFS audio fixtures plus provider credentials, so neither was part of this run.

@BlueX888
BlueX888 requested a review from a team as a code owner October 18, 2026 15:52
devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

BlueX888 and others added 3 commits October 26, 2026 23:46
MultiSpeakerAdapterWrapper._run builds a fresh wrapped stream on every
attempt but never set its start_time_offset. A leg created after a
wrapper-level retry therefore restarted provider timestamps at zero
while the pipeline anchor and the detector's _pushed_duration kept
running, misplacing word-aligned timing and making the primary-speaker
RMS lookup read pre-reconnect audio.

Seed the new leg from the wrapper's accumulated offset, mirroring how
FallbackRecognizeStream seeds each fallback leg. Add a regression test
that forces a wrapper-level retry and asserts both legs inherit the
offset and that forwarded end_time stays on the input timeline.
…retries

MultiSpeakerAdapterWrapper._run creates a fresh wrapped leg on every attempt
but never seeded its start_time_offset, so after a wrapper-level retry provider
timestamps restarted at zero while the pipeline anchor kept running. Seeding
the leg only works for plugins that apply start_time_offset when building
SpeechData: deepgram/assemblyai/aws do, xAI builds its timestamps straight from
the provider's word clocks and ignores the field, so a seeded offset leaves
xAI on a different timeline and primary-speaker detection never selects a
speaker, which also disables background suppression.

Keep every leg on its own provider clock instead and translate its events once
in the adapter: shift them by the leg's start position on the wrapper's input
timeline for forwarding, and let the detector subtract the difference between
that position and the audio it has already forwarded (the reconnect gap) so the
RMS lookup keeps indexing the frames it actually saw. Frames still in flight
when a leg closes are counted by the detector but no longer turn the retryable
provider failure into a fatal RuntimeError.

Tests cover both provider conventions (aligned plugins and the real xAI
_words_to_speech_data shape) with a nonzero wrapper offset, a wrapper-level
retry, audio consumed before the retry, and the zero-offset path.
MultiSpeakerAdapterWrapper passed its own conn options to the wrapped STT,
so a retryable provider failure could be retried inside the wrapped
stream. That internal retry advances the leg's own start_time_offset
while the adapter keeps the offsets it computed when the leg was created,
and diarization plugins disagree about whether the offset is already
folded into the SpeechData they emit (deepgram/assemblyai/aws add it, xAI
builds its timestamps from the provider's word clocks and ignores it), so
the adapter cannot correct for the shift afterwards. The detector then
looks up the wrong audio window, which can leave the true primary speaker
suppressed.

Create the wrapped stream with max_retry=0 and keep the retry budget on
the wrapper: the failure now propagates to _main_task, which rebuilds the
leg and recomputes the input offset and the detector offset for the new
one. timeout, retry_interval and liveness_timeout are still forwarded
unchanged. FallbackAdapter already layers retries this way for its
recovery streams.

Tests pin the no-retry-budget contract and force a child stream that
would retry internally to fail once, for both provider conventions; both
fail on the unpatched tree and pass here.
@BlueX888
BlueX888 force-pushed the fix/multi-speaker-start-time-offset branch from 65f82e3 to 5e73a8a Compare October 26, 2026 16:56
@BlueX888 BlueX888 changed the title fix(stt): seed the wrapped stream's start_time_offset on MultiSpeakerAdapter retries fix(stt): translate multi-speaker timestamps and own retries in the adapter Oct 26, 2026

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.

1 participant