Repository navigation
fix(voice): map provider timestamps on the pushed-audio timeline for stt turn metrics - #7694
15054538509 wants to merge 1 commit into
Conversation
d5f4173 to
aaafe19
Compare
There was a problem hiding this comment.
Devin Review found 2 new potential issues.
1 flag not posted on this PR by your GitHub settings — view it in Devin Review. (Configure)
| pipeline = self._stt_pipeline | ||
| if pipeline is None or not ev.alternatives or ev.alternatives[0].end_time <= 0: | ||
| return None | ||
| return pipeline.wall_time(ev.alternatives[0].end_time) |
There was a problem hiding this comment.
🔴 Stream offsets double-count input gaps
After a fallback or recreated stream follows an input gap, _provider_wall_time adds that gap twice. start_time_offset includes elapsed gaps, while wall_time adds _arrival_lags; speech metrics move forward by the gap.
Learn more
Provider timestamps from the default STT node include the stream's start_time_offset. That offset is wall-clock time since the pipeline anchor, not pushed-audio duration. A stream recreated by _stt_pump receives a new offset, and a fallback leg receives an equivalent elapsed-time offset in FallbackAdapter. The pipeline's arrival lag separately records wall-clock gaps in pushed audio, so mapping the offset timestamp through that lag counts each earlier input gap twice.
Example: The pipeline starts at 0 seconds, receives 5 seconds of audio over 10 wall-clock seconds, then recreates its stream. The new stream offset is 10 seconds and its first word ends 0.5 seconds later. wall_time(10.5) adds the recorded 5-second lag and returns 15.5 seconds, although the word ended at 10.5 seconds.
Recommended fix: Keep provider timestamps and lag lookup on one coordinate system. Track each active stream's pushed-audio position separately from its wall-clock start_time_offset, then normalize event timestamps to the pipeline's cumulative pushed-audio position before calling wall_time. Cover both _stt_pump recreation and FallbackAdapter leg switching after an earlier microphone gap.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Fixed. The offset a stream is seeded with is now the pipeline's pushed-audio position (_input_duration) instead of a wall-clock read, so the gap is no longer part of it, and FallbackAdapter continues each leg at the forwarded audio duration instead of a wall-clock delta. A stream recreated after a gap now keeps the input timeline with the gap added exactly once by the clock. Covered by test_stt_stream_fallback_offset_continues_at_the_forwarded_audio and the input_gap scenarios of the report's reproduction script.
| index = bisect.bisect_right( | ||
| self._arrival_lags, audio_position, key=lambda entry: entry[0] | ||
| ) |
There was a problem hiding this comment.
🟡 Gap boundaries shift preceding speech
When a word ends exactly at a post-gap plateau boundary, bisect_right assigns the new lag. _provider_wall_time moves that pre-gap word forward by the entire gap.
Learn more
Each lag entry is keyed to the start position of the frame that established the new plateau. An event end timestamp at that exact position describes audio ending immediately before that frame, so it belongs to the preceding plateau. bisect_right instead selects the new plateau at equality.
Example: Frames cover positions 0.0–0.3, followed by a five-second gap and a new frame starting at 0.3. A word ending at 0.3 maps with the five-second lag, although all its audio preceded the gap.
Recommended fix: Use bisect_left so an exact boundary selects the previous entry. The existing max(index - 1, 0) still handles the first plateau.
| index = bisect.bisect_right( | |
| self._arrival_lags, audio_position, key=lambda entry: entry[0] | |
| ) | |
| index = bisect.bisect_left( | |
| self._arrival_lags, audio_position, key=lambda entry: entry[0] | |
| ) |
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Fixed as suggested: bisect_left, so an event ending exactly at a plateau's first position keeps the previous plateau. Covered by test_word_ending_exactly_at_a_gap_boundary_keeps_the_earlier_plateau.
Provider timestamps are positions on the pipeline's pushed-audio timeline, shifted by the `start_time_offset` the stt node seeded. Four problems made the end-of-turn metrics wrong with `turn_detection="stt"`: - an `END_OF_SPEECH` without timing overwrote the word-end anchor with its arrival, so `stopped_speaking_at` was late by the provider's endpointing delay and `transcription_delay` was always 0; - a pipeline created mid-session (any handoff with a custom `stt_node`) had no input anchor when the node seeded the offset, so the node fell back to the session start and the session time was counted twice: every timestamp landed in the future and was clamped to `now`; - audio that fell behind (a gap in the mic, or the silence `commit_user_turn` flushes into the stt) shifted every later position, pushing the word end before the turn start and dropping the metrics; - the offset itself was a wall-clock read, so it included the audio that never reached the pipeline, which the clock adds back a second time. `_STTPipeline` now anchors itself when it is created and records the lag plateaus of the audio pushed into it, and `wall_time` maps a position back to the wall clock with the plateau it falls in. The offset a stream is seeded with is the pipeline's pushed-audio position, and `FallbackAdapter` continues each leg where the forwarded audio left off instead of on a wall-clock delta. The metrics use a provider-derived `_stopped_speaking_at` that an untimestamped `END_OF_SPEECH` cannot overwrite. The endpointing anchor keeps its current behavior, and vad turn detection is unchanged.
aaafe19 to
e787f15
Compare
Fixes #7651
With
turn_detection="stt",stopped_speaking_at,transcription_delay,end_of_turn_delayand the derivede2e_latencywere off on every turn, for the reasons in the report. They sit on one missing piece: the framework mapped provider timestamps with a single constant (end_time + _input_started_at) that only holds when the stream was created at session start and never saw a gap in the mic audio.Changes
_STTPipelineanchors itself when it is created and records the lag of the audio pushed into it 鈥?one entry per plateau, a 50 ms tolerance absorbs scheduling jitter.wall_time(position)maps a position back to the wall clock with the plateau it falls in: audio captured before a gap keeps its wall time, everything after the gap is shifted by the gap, a plateau recorded later never moves audio pushed before it, an exact plateau boundary keeps the earlier plateau, and a plateau with a negative lag (the silencecommit_user_turnflushes into the stt) maps the audio after it back onto real time.FallbackAdaptercontinues each leg at the forwarded audio duration for the same reason, so a leg created after a gap keeps the input timeline without counting the gap twice._stopped_speaking_at. In stt mode the last final/preflight transcript's word end, and anEND_OF_SPEECHthat does carry timing, update it. AnEND_OF_SPEECHwithout timing does not: it still moves the endpointing anchor to its arrival (that is how the anchor delays the commit there), but the metrics keep reporting where the user actually stopped talking instead of turning the delay into 0._compute_end_of_turn_metricsprefers that value, and falls back to the endpointing anchor when it is missing or predates the turn's segment.The endpointing anchor, the commit timing and vad turn detection are unchanged; in vad mode
_stopped_speaking_atis never set.Tests
tests/test_stt_pipeline_wall_clock.py(new) covers the clock: the anchor is stamped before the first frame, the position mapping, the gap behaviour, the exact boundary, the silence flush and the plateau collapsing.tests/test_transcription_delay_anchor.pycovers the tracked value: an untimestampedEND_OF_SPEECHno longer overwrites it, preflights count, a new segment clears it, and the word end behind a mic gap keeps its capture time.tests/test_end_of_turn_metrics.pycovers the precedence and the stale-value fallback.tests/test_stt_fallback.pycovers the leg delta continuing at the forwarded audio, and the affected stubs in the existing suites gained the new attribute.Ran locally: the new and updated tests pass, together with the related audio-recognition/session suites (223 + 143 tests),
ruff check,ruff format --checkandmypyon the touched modules.Verification
The reproduction script from the issue (fake mic, fake Soniox-like STT, no API keys) against this branch, reporting
stopped_speaking_atminus the true last-word end:plainplain --eos-timestamptask(handoff)input_gap --eos-timestampinput_gapCommit timing stays as before in the first two scenarios (the anchor still moves to the arrival of an untimestamped
END_OF_SPEECH); in the handoff and gap scenarios it now waits the endpointing delay after the real word end rather than committing immediately.Two earlier revisions of this branch were corrected by Devin Review: removing the seeded offset in the mapping broke a stream restarted by the pump, and the clock's own lag double counted the gap once the offset was wall-clock based. The fix now sits in the offset seeding, the clock and the reset-aware plateau lookup.