Skip to content

fix(stt): anchor transcript timestamps on the session input start - #7795

Open
chenghao-mou wants to merge 15 commits into
mainfrom
chenghao/fix/AGT-3678-stt-timestamp-anchor
Open

chenghao-mou wants to merge 15 commits into
mainfrom
chenghao/fix/AGT-3678-stt-timestamp-anchor

Conversation

@chenghao-mou

@chenghao-mou chenghao-mou commented Oct 13, 2026 •

Copy link
Copy Markdown
Member

The default stt_node offsets provider timestamps from the session start, but the recognizer converted them with the pipeline's first audio frame. On a pipeline created after a handoff, the mismatch pushed stopped_speaking_at into the future, and the clamp to now replaced it with the arrival time (cause 2 in #7651, AGT-3678).

Streams now absorb the delay to their first frame and re-anchor after a reconnect, so start_time_offset is exact on the caller's timeline. The default node records its base on the pipeline and the recognizer adds that same base. Custom nodes keep their current behavior.

Diagnosis and the first-frame approach come from @AndrazP's report. #7694 takes a broader approach to all three causes; END_OF_SPEECH without a timestamp and mic gaps after the first frame remain open here.

Addresses AGT-3678

Initial prompt and agent context

Model: Claude Fable 5.1

can you review my current changes?

Later messages that changed the request:

Astra can't fix this, can you help it? We need to be mindful about changes because custom node might already report session relative timestamps

maybe we add the gap between stream creation and first frame into start_time_offset?

okay create a PR for this please

@chenghao-mou
chenghao-mou requested a review from a team as a code owner October 13, 2026 21:22
@chenghao-mou chenghao-mou added the review_effort:high Needs broad context and substantial validation label Oct 13, 2026
devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

chenghao-mou and others added 15 commits October 14, 2026 13:33
The default stt_node offsets provider timestamps from the session start,
but the recognizer converted them with the pipeline's first audio frame.
On a pipeline created after a handoff the two bases differ by the
session's age, so stopped_speaking_at landed in the future and was
clamped to the arrival time.

The stream now absorbs the delay between its creation and its first
frame, and re-anchors on the next frame after a reconnect, so its offset
is exact on the caller's timeline. The default node records the base it
measured from on the pipeline, and the recognizer adds that same base.
A custom node that forwards provider timestamps untouched is anchored on
the pipeline's creation, when it opens its stream.

Addresses AGT-3678

Co-authored-by: Andraž Pajtler <1963904+AndrazP@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…irst-audio based

Two review findings. A frame queued while a connection fails is the
first audio the next connection sends, but the offset was only anchored
on push, so that frame kept the old connection's offset. The stream now
anchors on the first frame each attempt consumes, with the capture time
recorded at push.

A node that builds events itself, relative to its first frame, was
anchored on the pipeline's creation. An undeclared offset now means the
stream's timeline starts with its first audio, so such streams keep a
zero offset and the pipeline is stamped by the first frame pushed, as
before this change. Only the default node, which declares a
session-relative offset, is anchored on the session input start.
A resampler can hold audio across pushes, so its first output can carry
samples from an earlier frame. Stamping it with the push that released
it anchored the offset late by the time between the two pushes. Track
the capture time of the oldest held sample and stamp each resampled
frame from there.
A resampler can release audio from several pushes at once. Stamping the
later outputs by adding durations erased the gap between those pushes,
so a retry starting on one of them was anchored early. Track the held
input as capture spans and advance through them per output frame.
Keep the change small. The offset is anchored on the first frame pushed
to each attempt; a frame still queued when a connection fails is left
as a TODO. Resampled frames are no longer stamped individually, so
resampler buffering is a millisecond-level approximation.
The stream encoded one timeline in six fields and turned the caller's
origin into an offset and back. Move the timeline into
RecognizeStream._InputChan, which sees every push and every read.

The channel stores the origin and anchors the offset when a connection
reads its first frame. That frame was captured at the last push minus
the audio still queued. This fixes the case where a connection fails
before it reads: the retry now anchors on the oldest queued frame, not
on the next push.

A value set through the setter now means that the timeline began that
many seconds ago, also after the first frame.
The push-audio stub left input_started_at as a truthy MagicMock, so no
test reached the stamping branch. Reset it to None and assert it again.
State the pipeline anchor contract next to the field.
The anchor took the last push minus the queued audio. A pause between
queued frames is not in that sum, so the anchor moved forward by the
pause. Also keep the push time of each queued frame. A frame is pushed
after it is captured, so both values are upper bounds on its capture
start: take the earlier one. It is exact across a pause and for a burst
of buffered audio.
A leg was seeded only when it was created. If the caller set the offset
after the first leg started, that leg kept a zero offset. Seed a leg
that was not seeded when the first frame is forwarded to it. The outer
stream has read that frame, so its timeline has a start.
The module docstring said a custom node is anchored on the pipeline's
creation. It counts from its first audio, as on main.
@chenghao-mou
chenghao-mou force-pushed the chenghao/fix/AGT-3678-stt-timestamp-anchor branch from 4e59347 to 3fd2c0e Compare October 14, 2026 12:33

@longcw longcw 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.

looks good to me overall


def restart_provider_clock(self) -> None:
"""Anchor the offset again on the next frame read, for a new connection."""
self._first_frame_at = None

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.

if a plugin reconnect inside _run, e.g. for the update_options, it restarts the provider's clock but nothing calls restart_provider_clock, would this be a problem?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

good point. I am adding them in the #7796 so each plugin resets when it reconnects internally

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

review_effort:high Needs broad context and substantial validation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants