Skip to content

fix: report speech end timestamps across stt plugins - #7796

Open
chenghao-mou wants to merge 29 commits into
chenghao/fix/AGT-3678-stt-timestamp-anchorfrom
chenghao/fix/AGT-3678-stt-eos-timestamps
Open

chenghao-mou wants to merge 29 commits into
chenghao/fix/AGT-3678-stt-timestamp-anchorfrom
chenghao/fix/AGT-3678-stt-eos-timestamps

Conversation

@chenghao-mou

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

Copy link
Copy Markdown
Member

Streaming STT plugins and hosted inference now attach available speech-end timing to EOS events, so turn metrics use the speech boundary. Internal reconnects re-anchor provider clocks while preserving queued and replayed audio timing. Providers without verified speech-end timing remain untimed.

Stacked on #7795. Addresses AGT-3678 (Linear) and #7651 (@AndrazP's EOS diagnosis). Reconnect handling follows @longcw's review.

Initial prompt and agent context

Model: GPT-6 Astra

Can you investigate 3678? I believe this is a STT plugin reporting gap.

Can you create a PR to address the EOS speech end time reporting gap?

yes, we need to fix all of them. and verify them one by one please.

sorry, I think I was in a wrong thread. 11labs is not special from other plugins in this thread.

chenghao-mou and others added 5 commits October 13, 2026 22:22
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.
Carry provider speech-end timing through END_OF_SPEECH so STT turn
metrics retain the word timestamp instead of replacing it with event
arrival. Keep unknown timestamps unset and clear Deepgram's cached
final timing between turns and connections.

Addresses AGT-3678 and the EOS reporting case in #7651.

Co-authored-by: AndraΕΎ Pajtler <1963904+AndrazP@users.noreply.github.com>
@chenghao-mou
chenghao-mou requested a review from a team as a code owner October 13, 2026 22:11
@chenghao-mou chenghao-mou added plugins review_effort:high Needs broad context and substantial validation labels Oct 13, 2026
devin-ai-integration[bot]

This comment was marked as resolved.

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.
Apply speaker filtering and formatting only to transcripts so timed EOS
events still close turns and do not rewrite shared transcript text.
Use word timing for Inference endpoints; billed audio duration does not
identify when speech ended.
devin-ai-integration[bot]

This comment was marked as resolved.

Soniox and Speechmatics Linden gateway finals carry utterance spans without
word timestamps. Preserve those endpoints when every configured model uses
that duration contract. Leave billing and unknown durations untimed.

The timing semantics were checked against agent-gateway 69bcc23d088b.
@chenghao-mou
chenghao-mou added this pull request to stack #7798 October 13, 2026 22:52
@chenghao-mou
chenghao-mou marked this pull request as draft October 13, 2026 23:48
chenghao-mou and others added 10 commits October 14, 2026 00:48
Carry provider speech end timestamps and local VAD anchors through endpoint events. Normalize stream offsets and exclude billing duration, trailing silence, and audio from earlier pooled sessions. Add provider-specific regression coverage.

Addresses AGT-3678

Co-authored-by: AndraΕΎ Pajtler <1963904+AndrazP@users.noreply.github.com>
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 and others added 3 commits October 20, 2026 14:37
Keep the ElevenLabs endpoint and reconnect fixes in this PR. Move the remaining provider changes to a separate follow-up so they can be reviewed independently.
Carry provider speech-end timing through endpoint events and re-anchor
provider clocks after internal reconnects. Preserve queued and replayed
audio timing without assigning speech-end times to untimed providers.

Addresses AGT-3678

Co-authored-by: AndraΕΎ Pajtler <1963904+AndrazP@users.noreply.github.com>
Co-authored-by: Long Chen <longch1024@gmail.com>
@chenghao-mou chenghao-mou changed the title fix: report speech end timestamps on stt endpoint events fix: preserve elevenlabs speech end timestamps Oct 20, 2026
@chenghao-mou chenghao-mou removed the review_effort:high Needs broad context and substantial validation label Oct 20, 2026
@chenghao-mou
chenghao-mou marked this pull request as ready for review October 20, 2026 15:41
@chenghao-mou chenghao-mou changed the title fix: preserve elevenlabs speech end timestamps fix: report speech end timestamps across stt plugins Oct 20, 2026
@chenghao-mou chenghao-mou added the review_effort:high Needs broad context and substantial validation label Oct 20, 2026

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

Note

Newer findings are available below. Devin Review posted a newer report on this PR, in addition to the findings presented here.

Devin Review found 3 new potential issues.

1 flag not posted on this PR by your GitHub settings β€” view it in Devin Review. (Configure)

Devin Review

Comment on lines +1242 to +1250
alternatives=[
stt.SpeechData(
language=LanguageCode(self._opts.language),
text="",
end_time=self._final_speech_end_time,
)
]
if self._final_speech_end_time > 0
else [],

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.

🟑 Sarvam endpoints lose late final timestamps

When vad.speech_end precedes transcript.final, _emit_end_of_speech sends an untimed endpoint before the final timestamp arrives. Manual endpointing also closes its turn before receiving the final. Those speech boundaries retain arrival-time timing instead of provider timing.

Learn more

The realtime stream sends END_OF_SPEECH directly from _handle_speech_end. Its provider timestamp is stored only when a transcript.final arrives. In manual mode, _end_manual_utterance also emits the endpoint immediately upon flushing. Once the endpoint has been sent, a later final cannot update its timing.

Example: The provider sends vad.speech_end, then transcript.final with end_s=0.4. The endpoint contains no alternatives even though the provider supplied a speech boundary of 0.4 seconds.

Recommended fix: Coordinate final and endpoint emission when provider timing is required, retaining the boundary until the final arrives or a bounded fallback expires. Preserve the existing ordering and terminal behavior when the final never arrives.

Devin Review


Was this helpful? React with πŸ‘ or πŸ‘Ž to provide feedback.

Comment on lines +383 to +385
alternatives=[self._speech_data(event, segments, text)]
if segments and float(segments[-1].get("end_time", 0)) > 0
else [],

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.

🟑 Baseten empty finals discard speech timing

When Baseten sends timed segments before an empty final, segments is empty at the endpoint. The endpoint loses the preceding segment's end time and falls back to arrival timing.

Learn more

The receiver keeps speaking across non-final messages, but it only reads segments from the current message. An empty commit-triggered final can therefore close a turn after an earlier timed transcription without carrying that earlier timestamp. The new endpoint alternatives stay empty.

Example: A non-final transcription supplies a segment ending at 0.4 seconds; the subsequent final has segments=[]. END_OF_SPEECH has no timestamp, although the preceding message supplied one.

Recommended fix: Keep the last valid segment end while the turn is open and use it for an empty final. Clear it when the turn closes so a later turn cannot inherit it.

Devin Review


Was this helpful? React with πŸ‘ or πŸ‘Ž to provide feedback.

Comment thread livekit-plugins/livekit-plugins-xai/livekit/plugins/xai/stt.py Outdated

@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 1 new potential issue.

1 flag not posted on this PR by your GitHub settings β€” view it in Devin Review. (Configure)

Devin Review

Comment on lines +559 to +560
if words and words[-1].get("end", 0) > 0:
self._final_alternatives = [speech_data]

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.

🟑 New words inherit an old speech boundary

When wordless transcript.done adds words beyond the timed partial, _final_alternatives retains the partial's end time. The EOS event then places the new words before they were spoken, skewing turn timing.

Learn more

A timed partial can supply the only known word boundary for an utterance. A subsequent transcript.done with no words preserves that boundary here, even when its text adds speech beyond the partial. The EOS event passes that old time to AudioRecognition, where it becomes the turn's last speaking time. This is safe only when the wordless final repeats or summarizes speech already timed by partials.

Example: A partial reports hello ending at 0.4 seconds. A wordless transcript.done reports hello goodbye, with goodbye spoken later. EOS still reports 0.4 seconds as the end of all speech.

Recommended fix: Track the text covered by timed partials. Preserve the partial's boundary only when a wordless final contains no newly recognized speech; otherwise emit an untimed EOS or use a verified provider endpoint timestamp.

Devin Review


Was this helpful? React with πŸ‘ or πŸ‘Ž to provide feedback.

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

plugins review_effort:high Needs broad context and substantial validation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant