Skip to content

Commit 8b3a0d7

Browse files
fix(room_io): drop an interrupted reply's TTS backlog instead of playing it
captureFrame only compared the segment's interrupt snapshot while the pause gate was closed, so the check was skipped once the gate reopened. cancelSpeechPause un-gates the sink to admit the next reply as soon as the handle is interrupted, but the interrupted reply's forwardAudio loop keeps running until its abort signal fires a turn later, draining the seconds of audio real TTS providers deliver ahead of realtime. Those frames reached the wire while the next reply's transcript was already streaming. The recorder's "never flushed" case now flushes before the follow-on turn: forwardAudio always flushes in its finally, and that flush is what tells the sink where the interrupted segment ends. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 82e5534 commit 8b3a0d7

4 files changed

Lines changed: 132 additions & 3 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@livekit/agents': patch
3+
---
4+
5+
Stop an interrupted reply's TTS backlog from reaching the wire
6+
7+
`captureFrame` only compared a segment's interrupt snapshot while the pause gate was closed, so
8+
the check was skipped entirely once the gate reopened. `cancelSpeechPause` un-gates the sink to
9+
admit the next reply as soon as the handle is interrupted, but the interrupted reply's
10+
`forwardAudio` loop keeps running until its abort signal fires an event loop turn later — and real
11+
TTS providers hand it several seconds of audio ahead of realtime to drain in the meantime. Those
12+
frames took the open-gate path straight to the wire, so the user kept hearing the barged-over
13+
speech resume while the next reply's transcript was already streaming.

agents/src/voice/recorder_io/recorder_io.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1032,6 +1032,10 @@ describe('RecorderAudioOutput in front of a real ParticipantAudioOutput', () =>
10321032
expect(await settleOrStall(output.waitForPlayout(), 2000)).not.toBe('did not settle');
10331033

10341034
output.resume();
1035+
// The flush `forwardAudio` runs in its `finally` is what ends the interrupted segment. Without
1036+
// it the sink cannot tell a follow-on turn apart from the interrupted turn's TTS backlog, which
1037+
// keeps arriving after the resume that admits the next reply.
1038+
output.flush();
10351039
await output.captureFrame(makeFrame(100, 24000));
10361040
output.flush();
10371041

agents/src/voice/room_io/_output.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -455,9 +455,17 @@ export class ParticipantAudioOutput extends AudioOutput {
455455
this.gatedFrames.delete(gate);
456456
}
457457
}
458-
if (this.interruptCount > this.segmentInterruptCount) {
459-
return;
460-
}
458+
}
459+
460+
// Tested on every frame, not only the ones that parked at the gate. `cancelSpeechPause`
461+
// un-gates the sink to admit the next reply as soon as the handle is interrupted, but the
462+
// interrupted reply's `forwardAudio` loop only stops an event loop turn later, when its
463+
// abort signal fires — and real TTS hands it seconds of audio ahead of realtime to drain in
464+
// the meantime. Those frames find the gate already open, so without this they reach the wire
465+
// while the next reply's transcript is streaming. `forwardAudio` always flushes in its
466+
// `finally`, which is what opens a fresh segment for the next reply.
467+
if (this.interruptCount > this.segmentInterruptCount) {
468+
return;
461469
}
462470

463471
// Count the playback segment only after the pause/interrupt gate above. super.captureFrame
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
// SPDX-FileCopyrightText: 2026 LiveKit, Inc.
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
import { AudioFrame, type Room, TrackPublishOptions, TrackSource } from '@livekit/rtc-node';
5+
import { describe, expect, it } from 'vitest';
6+
import { ParticipantAudioOutput } from './_output.js';
7+
8+
const SAMPLE_RATE = 24000;
9+
const FRAME_MS = 20;
10+
11+
/** `marker` identifies which reply produced the frame once it reaches the wire. */
12+
function frame(marker: number): AudioFrame {
13+
const samples = (SAMPLE_RATE * FRAME_MS) / 1000;
14+
const data = new Int16Array(samples);
15+
data[0] = marker;
16+
return new AudioFrame(data, SAMPLE_RATE, 1, samples);
17+
}
18+
19+
/** The real output; only track publishing is skipped (no LiveKit server in a unit test). */
20+
class TestOutput extends ParticipantAudioOutput {
21+
constructor() {
22+
super({} as Room, {
23+
sampleRate: SAMPLE_RATE,
24+
numChannels: 1,
25+
trackPublishOptions: new TrackPublishOptions({ source: TrackSource.SOURCE_MICROPHONE }),
26+
queueSizeMs: 100_000,
27+
});
28+
(this as unknown as { startedFuture: { resolve: () => void } }).startedFuture.resolve();
29+
}
30+
31+
override async start(): Promise<void> {}
32+
}
33+
34+
/** Records the marker of every frame that reaches the wire. */
35+
function wireMarkers(out: ParticipantAudioOutput): number[] {
36+
const source = (
37+
out as unknown as { audioSource: { captureFrame: (f: AudioFrame) => Promise<void> } }
38+
).audioSource;
39+
const original = source.captureFrame.bind(source);
40+
const markers: number[] = [];
41+
source.captureFrame = async (f: AudioFrame) => {
42+
markers.push(f.data[0]!);
43+
return original(f);
44+
};
45+
return markers;
46+
}
47+
48+
describe('a committed barge-in with TTS audio still in flight', () => {
49+
/**
50+
* Real TTS delivers several seconds of audio ahead of realtime, so at the moment of a barge-in
51+
* the interrupted reply's `forwardAudio` loop is still holding a backlog of already-synthesized
52+
* frames. It stays alive for the whole of the reply task's
53+
* `cancelAndWait(forwardTasks, REPLY_TASK_CANCEL_TIMEOUT)`, and only flushes in its `finally`
54+
* after that — well after `cancelSpeechPause` has un-gated the sink for the next reply.
55+
*
56+
* `captureFrame` only consults `interruptCount` while the pause gate is closed, so every one of
57+
* those backlog frames takes the open-gate path and reaches the wire while the next reply's
58+
* transcript is already streaming. That is the audio/transcript desync.
59+
*/
60+
it('does not put the interrupted reply’s backlog on the wire', async () => {
61+
const out = new TestOutput();
62+
const markers = wireMarkers(out);
63+
64+
// Reply A is playing.
65+
await out.captureFrame(frame(1));
66+
67+
// The user barges in: VAD pauses the sink, then the commit clears and un-gates it.
68+
out.pause();
69+
out.clearBuffer();
70+
out.resume();
71+
72+
// Reply A's forwarding loop has not noticed the abort yet and drains its backlog.
73+
await out.captureFrame(frame(1));
74+
await out.captureFrame(frame(1));
75+
76+
expect(markers.filter((m) => m === 1)).toHaveLength(1);
77+
78+
await out.close();
79+
});
80+
81+
/** Control: once reply A's forwarding loop unwinds and flushes, reply B must be audible. */
82+
it('plays the next reply after the interrupted one flushes', async () => {
83+
const out = new TestOutput();
84+
const markers = wireMarkers(out);
85+
86+
await out.captureFrame(frame(1));
87+
88+
out.pause();
89+
out.clearBuffer();
90+
out.resume();
91+
92+
await out.captureFrame(frame(1));
93+
94+
// `forwardAudio` flushes in a `finally`, closing the interrupted segment.
95+
out.flush();
96+
97+
await out.captureFrame(frame(2));
98+
await out.captureFrame(frame(2));
99+
100+
expect(markers.filter((m) => m === 2)).toHaveLength(2);
101+
102+
await out.close();
103+
});
104+
});

0 commit comments

Comments
 (0)