Skip to content

fix(duplex): keep late transcripts on the audio they describe - #2491

Open
rosetta-livekit-bot[bot] wants to merge 1 commit into
mainfrom
dunning-moorland-irony
Open

rosetta-livekit-bot[bot] wants to merge 1 commit into
mainfrom
dunning-moorland-irony

Conversation

@rosetta-livekit-bot

@rosetta-livekit-bot rosetta-livekit-bot Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Ports livekit/agents#7264 to keep delayed duplex transcript fragments associated with the audio they describe.

Late fragments are attached when an audio burst closes. Fragments whose timestamps place them on an already-finished burst are emitted immediately as a text-only generation, while only a subsequent speech burst can claim a pending generateReply request.

Source: livekit/agents#7264

Source diff coverage

Source diff coverage

  • livekit-agents/livekit/agents/llm/duplex_adapter.py: adapted to agents/src/llm/duplex_adapter.ts. Ported all burst bookkeeping, close-time fragment attachment, immediate text-only generations for transcript tied to already-played audio, reply-kind handling, context insertion, and reconnect reset. Adaptations are limited to TypeScript naming, Web Streams/queue primitives, and the target's millisecond-native conventions.
  • tests/test_duplex_adapter.py: adapted to agents/src/llm/duplex_adapter.test.ts. Ported all four added regression tests into the existing Vitest fake-duplex harness, preserving each source scenario and assertion intent.

No source files are omitted and no target infrastructure gap remains.

Validation

  • pnpm build: passed, 40/40 workspace builds
  • pnpm test agents: passed, 157 files and 2,635 tests; 5 skipped
  • pnpm --filter @livekit/agents lint: passed with existing warnings
  • pnpm --filter @livekit/agents typecheck: passed
  • pnpm format:check: passed
  • pnpm lint: blocked by the pre-existing unrelated @typescript-eslint/no-misused-promises error in plugins/openai/src/ws/llm.ts:127
  • cue-cli: attempted against a built, explicitly dispatched GPT-Live agent; the configured LiveKit environment rejected both worker and driver with 401 Unauthorized - invalid API key, so no runtime event assertion completed

Ported from livekit/agents#7264

Original PR description

Problem: DuplexRealtimeAdapter attaches queued transcript fragments only inside the frame handler, while the gate is open, so a fragment that arrives after the last accepted frame stays queued. The next utterance takes it as its first fragment and sets its time anchor from it, or the 3 s unclaimed timeout emits it under a new id with an error log.

Fix: _close_burst attaches due fragments before it closes the burst, so words that arrive between the last frame and the close still join it. A fragment whose span, by the last anchored burst's anchor, sits on audio that burst already played is emitted at once as a text-only generation under a fresh id, the shape the unclaimed path already produces, so it can no longer anchor the next burst.

Fixes #7227. Supersedes #7244.

Context for reviewing and coding agents

How to see it on main

With _FakeDuplexModel, push three speech frames, emit one transcript delta, then close audio_ch: the generation's text stream ends empty and the delta is still in session._fragments. Second order: let a burst with an anchored fragment close, then emit a delta whose start_ms lies inside that burst's span: on main the next speech burst's transcript begins with it.

Why a text-only generation instead of holding text_ch open

The framework builds the assistant chat item from the text stream alone and skips it when the stream closed empty (agent_activity.py:4537), and update_chat_ctx replaces the adapter's own context copy, so text recorded only in the adapter never reaches session.history or the client. Holding the stream open for a grace delays the speech handle on every turn and still loses text later than the grace. _on_generation_created schedules the text-only generation as a normal speech handle, so it plays after the burst that is speaking and never overlaps it.

Why a burst without an anchor is left alone

The span clock and the adapter's audio clock share no origin; the anchor from a burst's first fragment is the only mapping between them. A burst that never received a fragment cannot tell its own late text from the next utterance's leading text, so that case keeps the current behaviour: the next burst or the unclaimed timeout takes it.

Blast radius

_open_burst no longer assigns self._burst; the frame handler assigns it, and the unclaimed path, the late path and the function-only generation close their burst directly. _close_burst keeps its five callers: the frame handler, the idle timer, the segment task's exit, reconnect and aclose.

@rosetta-livekit-bot
rosetta-livekit-bot Bot requested a review from a team as a code owner September 14, 2026 07:10
@changeset-bot

changeset-bot Bot commented Sep 14, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3400f7b

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

@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

Comment on lines +441 to +444
if (burst.anchorMs === undefined) {
burst.anchorMs = fragment.startMs - burst.audioStartMs;
this.lastBurst = burst;
}

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.

🟡 Fully delayed transcripts join the next burst

When a burst closes before its first timestamped fragment, lastBurst remains unset. Its delayed transcript waits, then attachFragments assigns it to the next speech burst.

Learn more

lastBurst is recorded only when attachFragments establishes an anchor from a fragment. A burst that ends before any transcript arrives therefore leaves no completed burst for onTranscriptDelta to examine. If another speech burst starts within the three-second unclaimed-transcript window, its first audio frame calls attachFragments and consumes the old transcript. The resulting generation and chat message pair the old words with the new audio.

Example: A model emits burst A from 2000–2300 ms, and the gate closes it before any transcript arrives. At 2400 ms, Hello arrives with startMs: 2000; no text-only generation is created because lastBurst is undefined. If burst B starts at 3000 ms, B consumes Hello, although the text describes burst A.

Recommended fix: Preserve enough timing state for every completed speech burst, even before it receives a transcript. For stamped audio, retain the burst timeline directly; for unstamped audio, establish or reconcile the anchor when the first delayed fragment arrives. Add a regression test where an entire timestamped transcript arrives after its burst closes and before the next burst starts.

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.

0 participants