Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/delegate-recorder-playout-waits.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@livekit/agents': patch
---

Prevent interrupted voice replies from deadlocking when audio recording wraps synchronized playout.
41 changes: 40 additions & 1 deletion agents/src/voice/recorder_io/recorder_io.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import path from 'node:path';
import { beforeAll, describe, expect, it } from 'vitest';
import { initializeLogger } from '../../log.js';
import { type StreamChannel, createStreamChannel } from '../../stream/stream_channel.js';
import { isWritableStreamClosedError } from '../../utils.js';
import { Future, isWritableStreamClosedError } from '../../utils.js';
import type { AgentSession } from '../agent_session.js';
import { AudioInput, AudioOutput } from '../io.js';
import { RecorderIO } from './recorder_io.js';
Expand All @@ -34,6 +34,23 @@ class FakeAudioOutput extends AudioOutput {
clearBuffer(): void {}
}

class WaitAwareAudioOutput extends FakeAudioOutput {
readonly waitStarted = new Future<void>();
private readonly continueWait = new Future<void>();

async waitForPlayout() {
const waitForCurrentSegment = super.waitForPlayout();
this.waitStarted.resolve();
await this.continueWait.await;
this.onPlaybackFinished({ playbackPosition: 0, interrupted: true });
return waitForCurrentSegment;
}

releaseWait() {
this.continueWait.resolve();
}
}

function makeFrame(durationMs: number, sampleRate = 48000, channels = 1): AudioFrame {
const samplesPerChannel = Math.floor((durationMs / 1000) * sampleRate);
return new AudioFrame(
Expand Down Expand Up @@ -121,6 +138,28 @@ describe('RecorderIO close', () => {
}, 15000);
});

describe('RecorderAudioOutput', () => {
it('snapshots its segment before delegating the playout wait', async () => {
const recorder = new RecorderIO({ agentSession: {} as AgentSession });
const downstream = new WaitAwareAudioOutput();
const output = recorder.recordOutput(downstream);

await output.captureFrame(makeFrame(20));
const waitForFirstSegment = output.waitForPlayout();
await downstream.waitStarted.await;

output.flush();
await output.captureFrame(makeFrame(20));
downstream.releaseWait();

const event = await waitForFirstSegment;

expect(event).toEqual({ playbackPosition: 0, interrupted: true });
downstream.onPlaybackFinished({ playbackPosition: 0, interrupted: true });
await recorder.close();
});
});

describe('RecorderIO writable stream error detection', () => {
it('detects ERR_INVALID_STATE stream closure errors', () => {
const err = new TypeError('Invalid state: WritableStream is closed');
Expand Down
8 changes: 8 additions & 0 deletions agents/src/voice/recorder_io/recorder_io.ts
Original file line number Diff line number Diff line change
Expand Up @@ -790,6 +790,14 @@ class RecorderAudioOutput extends AudioOutput {
}
}

async waitForPlayout(): Promise<PlaybackFinishedEvent> {
const waitForRecorder = super.waitForPlayout();
if (this.nextInChain) {
await this.nextInChain.waitForPlayout();
}
return waitForRecorder;
}

flush(): void {
super.flush();

Expand Down
Loading