Skip to content

Commit 97e17e3

Browse files
fix(voice): scope forwardAudio playback-started listener to its own segment (#1786)
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 1a3ef4c commit 97e17e3

3 files changed

Lines changed: 80 additions & 1 deletion

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@livekit/agents': patch
3+
---
4+
5+
fix(voice): scope forwardAudio's playback-started listener to its own segment
6+
7+
When a speech is interrupted, the scheduling loop immediately authorizes the next
8+
speech, so the new segment's `forwardAudio` registers its `playback_started`
9+
listener on the shared audio output while the interrupted segment is still
10+
emitting events during teardown. The stray event resolved the new segment's
11+
`firstFrameFut` before its first frame was captured, which skipped resampler
12+
creation and pushed an unresampled frame straight to the `AudioSource`
13+
(`RtcError: sample_rate and num_channels don't match`) and corrupted playback
14+
bookkeeping. The listener now only resolves `firstFrameFut` after the segment has
15+
captured its own first frame.

agents/src/voice/generation.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -836,8 +836,14 @@ async function forwardAudio(
836836
const reader = ttsStream.getReader();
837837
let resampler: AudioResampler | null = null;
838838

839+
// The audio output is shared across overlapping segments, so ignore a
840+
// PLAYBACK_STARTED from another segment until we capture our own first frame.
841+
// Resolving `firstFrameFut` early skips resampler creation and pushes an
842+
// unresampled frame (`RtcError: sample_rate and num_channels don't match`).
843+
let hasCapturedOwnFrame = false;
844+
839845
const onPlaybackStarted = (ev: { createdAt: number }) => {
840-
if (!out.firstFrameFut.done) {
846+
if (hasCapturedOwnFrame && !out.firstFrameFut.done) {
841847
out.firstFrameFut.resolve(ev.createdAt);
842848
}
843849
};
@@ -868,6 +874,10 @@ async function forwardAudio(
868874
resampler = new AudioResampler(frame.sampleRate, audioOutput.sampleRate, 1);
869875
}
870876

877+
// Mark before capturing so the PLAYBACK_STARTED emitted synchronously inside
878+
// the first captureFrame is attributed to this segment.
879+
hasCapturedOwnFrame = true;
880+
871881
if (resampler) {
872882
for (const f of resampler.push(frame)) {
873883
await audioOutput.captureFrame(f);

agents/src/voice/generation_tts_timeout.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,60 @@ describe('TTS stream idle timeout', () => {
105105
expect(audioOut.firstFrameFut.done).toBe(true);
106106
});
107107

108+
it('ignores PLAYBACK_STARTED from another segment before its own first frame', async () => {
109+
// Stalled stream so the forwarder is still waiting on its first read when a
110+
// stray event (from an interrupted overlapping segment) arrives; the idle
111+
// timeout then ends the loop without this segment ever capturing a frame.
112+
const stalledStream = new ReadableStream<AudioFrame>({ start() {} });
113+
114+
const audioOutput = new MockAudioOutput();
115+
const controller = new AbortController();
116+
const [task, audioOut] = performAudioForwarding(stalledStream, audioOutput, controller, 500);
117+
118+
// Reject path is expected (no first frame ever captured).
119+
audioOut.firstFrameFut.await.catch(() => {});
120+
121+
vi.useFakeTimers();
122+
123+
audioOutput.onPlaybackStarted(Date.now());
124+
expect(audioOut.firstFrameFut.done).toBe(false);
125+
126+
const taskPromise = task.result;
127+
await vi.advanceTimersByTimeAsync(600);
128+
await taskPromise;
129+
130+
vi.useRealTimers();
131+
132+
expect(audioOutput.capturedFrames.length).toBe(0);
133+
expect(audioOut.firstFrameFut.rejected).toBe(true);
134+
});
135+
136+
it('resamples a rate-mismatched frame even after a stray PLAYBACK_STARTED', async () => {
137+
// Output is 24kHz; frames are 16kHz and must be resampled regardless of any
138+
// stray PLAYBACK_STARTED resolving firstFrameFut early.
139+
const stream = new ReadableStream<AudioFrame>({
140+
start(controller) {
141+
controller.enqueue(createSilentFrame(16000));
142+
controller.enqueue(createSilentFrame(16000));
143+
controller.close();
144+
},
145+
});
146+
147+
const audioOutput = new MockAudioOutput();
148+
const controller = new AbortController();
149+
const [task, audioOut] = performAudioForwarding(stream, audioOutput, controller);
150+
151+
audioOutput.onPlaybackStarted(Date.now());
152+
153+
await task.result;
154+
155+
expect(audioOut.firstFrameFut.done).toBe(true);
156+
expect(audioOutput.capturedFrames.length).toBeGreaterThan(0);
157+
for (const f of audioOutput.capturedFrames) {
158+
expect(f.sampleRate).toBe(24000);
159+
}
160+
});
161+
108162
it('performTTSInference completes when TTS node returns stalled stream', async () => {
109163
const stalledTtsStream = new ReadableStream<AudioFrame>({
110164
start(controller) {

0 commit comments

Comments
 (0)