Skip to content

fix(agents): flush STT on manual commitUserTurn with detached input audio - #2487

Open
programmarchy wants to merge 1 commit into
livekit:mainfrom
programmarchy:fix/manual-commit-flush-detached-audio
Open

fix(agents): flush STT on manual commitUserTurn with detached input audio#2487
programmarchy wants to merge 1 commit into
livekit:mainfrom
programmarchy:fix/manual-commit-flush-detached-audio

Conversation

@programmarchy

Copy link
Copy Markdown

Description

With the documented push-to-talk flow (session.input.setAudioEnabled(false) followed by session.commitUserTurn()), the committed user turn is clipped. In a real room with LiveKit Inference STT, speaking "Can you check the connection?" and releasing right after the last word committed "Can you check". The full final transcript only showed up later, during shutdown.

Two parity gaps with the Python framework cause this:

  1. AgentSession.commitUserTurn() calls activity.commitUserTurn() with no options, so audioDetached defaults to false and AudioRecognition.commitUserTurn never pushes the silence frame that flushes the STT. The provider receives no trailing audio, the final never arrives inside the 500 ms commit wait, and the interim text is committed. Python passes audio_detached=not self._session.input.audio_enabled (agent_activity.py). closeImpl already passes audioDetached: true, which is why the complete transcript appeared at shutdown.
  2. The silence flush is skipped when this.sampleRate is undefined, and it was only recorded from VAD START_OF_SPEECH events. Python records it from every pushed frame in _push_audio (audio_recognition.py). Without that, fix 1 alone does nothing under manual turn detection with vad: null, or whenever the VAD has not fired for the turn.

Changes Made

  • AgentSession.commitUserTurn() passes { audioDetached: !this.input.audioEnabled } to the activity, and documents the behavior.
  • AudioRecognition.forwardInputAudioToStt records this.sampleRate from each forwarded frame.
  • New agent_session_manual_commit_flush.test.ts: a fake streaming STT that emits an interim mid-speech and the final only after 200 ms of trailing audio. Under manual turn detection with vad: null, push 300 ms of speech, disable input, commit. On main the committed turn is "can you check"; with only the session change it is still "can you check"; with both changes it is "can you check the connection".
  • Changeset (@livekit/agents patch).

Pre-Review Checklist

  • Build passes: pnpm test agents/src/voice/agent_session agents/src/voice/audio_recognition agents/src/voice/agent_activity passes (33 files, 277 tests); prettier and eslint clean on touched files
  • AI-generated code reviewed: Removed unnecessary comments and ensured code quality
  • Changes explained: All changes are properly documented and justified above
  • Scope appropriate: All changes relate to the PR title
  • Video demo: Not applicable; covered by the automated regression test and a real-room manual-turn check in the reporting app

Testing

  • Automated tests added/updated
  • All tests pass
  • restaurant_agent.ts / realtime_agent.ts: not a major change; the default (non-manual) path is unaffected since audioDetached stays false while input audio is enabled

Additional Notes

Found while building a push-to-talk agent on @livekit/agents@1.8.1. We are carrying the one-line session change as a pnpm patch; a six-turn real-room lifecycle check (press, release, cancel, empty turn, two segments with a pause, repeat) passes with it, committing about 600 ms after release. The sample-rate change is included here because the default inference.VAD happened to report it in our app, but any configuration without a VAD speech event for the turn would still clip.

This does not change the 500 ms commit wait or add a transcript_timeout / stt_flush_duration option as in Python; those could be a follow-up.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PHUXLhxd6NUoXwLzHKvjAv

`AgentSession.commitUserTurn()` called the activity without options, so
`audioDetached` always defaulted to false and the recognizer never pushed
the silence that flushes the STT. With the documented push-to-talk release
(`input.setAudioEnabled(false)` then `commitUserTurn()`), the provider gets
no trailing audio, the final transcript never arrives inside the commit
wait, and the turn is committed from the clipped interim text. Pass
`audioDetached: !this.input.audioEnabled`, matching Python's
`AgentActivity.commit_user_turn`.

The silence flush also requires `AudioRecognition.sampleRate`, which was
only recorded from VAD start-of-speech events. Record it from every frame
forwarded to the STT, as Python's `_push_audio` does, so the flush works
with manual turn detection and no VAD.

Adds a regression test with a fake streaming STT that finalizes only after
trailing audio; it commits "can you check" before this change and
"can you check the connection" after.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PHUXLhxd6NUoXwLzHKvjAv
@programmarchy
programmarchy requested a review from a team as a code owner September 12, 2026 08:23
@changeset-bot

changeset-bot Bot commented Sep 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 35fb269

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 39 packages
Name Type
@livekit/agents Patch
@livekit/agents-plugin-anam Patch
@livekit/agents-plugin-anthropic Patch
@livekit/agents-plugin-assemblyai Patch
@livekit/agents-plugin-azure Patch
@livekit/agents-plugin-baseten Patch
@livekit/agents-plugin-bey Patch
@livekit/agents-plugin-cartesia Patch
@livekit/agents-plugin-cerebras Patch
@livekit/agents-plugin-deepgram Patch
@livekit/agents-plugin-did Patch
@livekit/agents-plugin-elevenlabs Patch
@livekit/agents-plugin-fishaudio Patch
@livekit/agents-plugin-google Patch
@livekit/agents-plugin-hume Patch
@livekit/agents-plugin-inworld Patch
@livekit/agents-plugin-krisp Patch
@livekit/agents-plugin-lemonslice Patch
@livekit/agents-plugin-liveavatar Patch
@livekit/agents-plugin-livekit Patch
@livekit/agents-plugin-meta Patch
@livekit/agents-plugin-minimax Patch
@livekit/agents-plugin-mistral Patch
@livekit/agents-plugin-mistralai Patch
@livekit/agents-plugin-neuphonic Patch
@livekit/agents-plugin-openai Patch
@livekit/agents-plugin-perplexity Patch
@livekit/agents-plugin-phonic Patch
@livekit/agents-plugin-protoface Patch
@livekit/agents-plugin-resemble Patch
@livekit/agents-plugin-rime Patch
@livekit/agents-plugin-runway Patch
@livekit/agents-plugin-sarvam Patch
@livekit/agents-plugin-silero Patch
@livekit/agents-plugin-soniox Patch
@livekit/agents-plugin-tavus Patch
@livekit/agents-plugins-test Patch
@livekit/agents-plugin-trugen Patch
@livekit/agents-plugin-xai Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@CLAassistant

CLAassistant commented Sep 12, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

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

Devin Review

}

this.activity.commitUserTurn();
this.activity.commitUserTurn({ audioDetached: !this.input.audioEnabled });

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.

🟡 Recent finals skip detached flush

When a prior segment finalized within 500 ms, commitUserTurn() skips the detached-audio flush. AudioRecognition.commitUserTurn gates both silence and waiting on that prior final's timestamp. Trailing words from the current segment can be clipped.

Learn more

A manual user turn can contain several STT segments. lastFinalTranscriptTime only records when any segment finalized; it does not prove that no audio arrived afterward. When commit follows a recent intermediate final, the timestamp gate bypasses both the new silence frame and the final-transcript wait. The latest segment therefore remains pending while EOU commits the transcript already available.

Example: The provider finalizes “Can you” after a pause. The user immediately says “check the connection” and releases push-to-talk 300 ms later. Commit sees the recent first final, sends no silence, and can commit only “Can you” instead of the complete turn.

Recommended fix: For detached audio, flush and await finalization whenever audio has arrived since the latest final. Track an input-frame generation or timestamp alongside final events, or always perform the detached flush and bounded wait.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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.

2 participants