Repository navigation
Conversation
BlueX888
force-pushed
the
fix/multi-speaker-start-time-offset
branch
from
October 26, 2026 10:19
54a8292 to
65f82e3
Compare
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
force-pushed
the
fix/multi-speaker-start-time-offset
branch
from
October 26, 2026 16:56
65f82e3 to
5e73a8a
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
MultiSpeakerAdapterwraps a diarizing STT and picks the primary speaker with an RMS bufferindexed by the audio the adapter has forwarded.
agent.stt_nodeseeds the stream'sstart_time_offsetwith the time already elapsed in the session, so the wrapper has to putprovider timestamps on that input timeline before the detector can find the right audio.
Two things broke the mapping:
running, so forwarded timestamps lost the elapsed offset.
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 wasever selected, and background suppression stayed off.
Review also found a third path: a wrapped stream retrying internally advanced its own
start_time_offsetwhile the adapter kept the offsets computed for the first attempt. This isreproducible on the previous PR head
65f82e34; the child-retry tests added here fail therewhile the rest of the timestamp-translation suite passes.
Why not seed
start_time_offseton the wrapped legAn earlier revision of this PR did exactly that, and it only works for plugins that fold the
field into the
SpeechDatathey emit. deepgram/assemblyai/aws add it; xAI builds timestampsstraight 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:
SpeechDatatimes (start_time/end_timeand word clocks) are shifted by wherethe leg starts on the wrapper's input timeline;
_PrimarySpeakerDetectoris told the difference between the input timeline and itsforwarded-audio clock, so the RMS lookup and stored
last_activity_timekeep indexing theaudio 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=0while the wrapper keeps the retry budget it was given and rebuilds the legafter a failure. A provider stream retrying internally would advance its own
start_time_offsetbehind the adapter's back, and since plugins fold that offset intoSpeechDatainconsistently, the adapter cannot correct for it afterwards. With the wrapperowning retries, every attempt is a fresh leg whose clock starts at zero, so both conventions
stay aligned.
timeout,retry_intervalandliveness_timeoutstill reach the providerunchanged. This matches how
FallbackAdaptercreates its recovery streams withmax_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 innerstreams, and a plugin that reconnects inside its own
_runnever raises to the wrapper. Aninner 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 retrykeeps 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 reconnectstill counts for the RMS buffer.
test_zero_offset_leaves_provider_timestamps_untouched: with no elapsed offset, theprovider clock is forwarded unchanged.
test_wrapped_stream_gets_no_retry_budget: the wrapped stream is created withmax_retry=0even when the adapter was given a retry budget, and the other connectoptions pass through.
test_child_stream_retry_keeps_the_detector_on_its_audio(aligned and xAI): a child thatwould 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 consumed0.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 livesat
[0.6, 1.1), so the loud speaker stayed suppressed. With the wrapper owning the retry, thelookup 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)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 passeduv run pytest -q --unit -- tests/test_multi_speaker_adapter.py: 21 passeduv run ruff format --checkanduv run ruff checkon the changed files: cleanuv run --group typing mypy --untyped-calls-exclude=smithy_aws_core --platform linux -p livekit.agents:Success, no issues in 234 source files
65f82e34: 3 failed, 18 passed -- the twochild-retry cases and the no-retry-budget case fail; the candidate passes all 21.
65f82e34report one leg with two attempts (retried_by=child) andno 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 --unitcollection stops attests/test_plugin_azure_realtime.pybecause the optional
azure.aipackages are not installed here, andtests/test_stt.pyneedsthe LFS audio fixtures plus provider credentials, so neither was part of this run.