Skip to content

fix(cli): prebuffer console playback so realtime-paced audio doesn't underrun - #7238

Merged
u9g merged 2 commits into
mainfrom
fix/console-speaker-prebuffer
Sep 14, 2026
Merged

u9g merged 2 commits into
mainfrom
fix/console-speaker-prebuffer

Conversation

@u9g

@u9g u9g commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Problem

Running a duplex model (e.g. GPTLiveModel) under python agent.py console produces audible clicks and stutters mid-word.

A duplex model streams its voice at real-time pace: one 100 ms frame every ~100 ms. The console speaker callback also pulls 100 ms per tick, so the buffer runs with a single block of margin. Whenever the gap between two arriving frames exceeds ~100 ms, the callback finds the buffer empty and zero-fills the block. That inserted silence is the click.

TTS pipelines never hit this because TTS delivers audio much faster than real time and the buffer stays deep.

Measured

Instrumented _sd_output_callback and let the agent speak its greeting with no mic input. Frames arrived every ~100 ms with occasional 150–180 ms gaps; each gap produced a buffer depth of zero mid-utterance.

Before (samples queued per 100 ms callback, one utterance):

2400 2400 2400 2400 2400 2400 2400 2400 2400 2400 2400 2400 2400 2400 2400 0 4800 4800 ... 4800 2400 0

After:

2400 4800 7200 7200 7200 7200 ... 7200 7200 4800 2400 0

Fix

ConsoleAudioOutput holds the head of each segment until 300 ms is queued, or until the segment is flushed (so short utterances are not delayed past their end). A mid-segment underrun does not re-prime, so the failure mode stays a single short gap rather than a longer one.

Commits:

  1. Pure move: the drain logic leaves AgentsConsole._sd_output_callback and becomes ConsoleAudioOutput.read_into, so it can be unit tested.
  2. The prebuffer, with tests in tests/test_cli_console_output.py.

Follow-up

lk agent console (Go, pkg/console/pipeline.go) pads its playback ring with silence the same way and likely needs the same margin. Not measured yet.

@u9g
u9g requested a review from a team as a code owner September 11, 2026 19:12

@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 +190 to +192
if not self._pushed_duration:
self._capture_start = time.monotonic()
self._priming = True

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.

🟡 Primed interruption invents played audio

An interruption during _priming reports elapsed buffering time as played audio, although every callback emitted silence. forward_generation then truncates realtime history at that false position.

Learn more

_capture_start begins when the first frame arrives, but the new priming period deliberately outputs only silence. If clear_buffer() interrupts the segment before priming completes, _wait_for_playout() subtracts that capture timestamp from the interruption time and reports up to 300 ms as played. The generation path accepts any positive playback position as proof that audio reached the speakers and uses it when truncating realtime-model history in forward_generation. The remote model can therefore retain assistant audio that the user never heard.

Example: A 100 ms frame arrives, then the user interrupts 200 ms later before the 300 ms threshold. Every speaker callback produced zeros, but the completion event reports about 200 ms played and realtime history is truncated there instead of removing the unheard response.

Recommended fix: Track actual samples removed from _output_buf, or start interruption timing when _maybe_mark_playback_started() first drains audio. Report zero when interruption occurs before playback starts, and add a test that interrupts during priming and asserts both no playback-start event and a zero playback position.

Devin Review

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

@u9g

u9g commented Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

Before/after recording of the same scripted call in python agent.py console with GPTLiveModel. The caller's lines were synthesized with cue-cli's inference TTS client and fed into the console's mic path on a fixed script; the speaker callback output was captured to WAV, so what you hear is exactly what the sounddevice callback played.

Before (livekit-agents 1.8.1). Zero-filled blocks land inside agent speech at 0:18.0 and 0:38.3, the clicks you hear mid-word:

console_crackle_before.mp4

After (this PR). Same script, no zero-filled blocks inside speech; agent speech starts 300 ms later:

console_crackle_after.mp4

Counted offline on the captured agent track: a 100 ms block of exact digital zeros bracketed by speech within 300 ms on both sides. GPT-Live's own pauses carry low-level noise, so exact zeros only come from the callback's zero fill.

…underrun

A duplex model such as GPT-Live streams its voice at real-time pace, one 100ms
frame every ~100ms, so the console speaker ran with a single callback of margin.
Any network jitter over ~100ms drained the buffer and the callback zero-filled
the block, which played as a click mid-word. Hold the head of each segment until
300ms is queued (or the segment is flushed) so the margin survives jitter.
@u9g
u9g force-pushed the fix/console-speaker-prebuffer branch from c37d5d5 to f8e2d96 Compare September 11, 2026 19:33

@davidzhao davidzhao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

lg, though worth mentioning this will be deprecated eventually

@u9g
u9g merged commit f69c55c into main Sep 14, 2026
24 checks passed
@u9g
u9g deleted the fix/console-speaker-prebuffer branch September 14, 2026 12:44
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