Skip to content

Commit f0ac742

Browse files
authored
fix: add idle timeouts to TTS stream reads to prevent agent stuck in speaking state (#1174)
1 parent a183e42 commit f0ac742

5 files changed

Lines changed: 205 additions & 11 deletions

File tree

.changeset/gorgeous-seas-grin.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@livekit/agents": patch
3+
---
4+
5+
fix: add idle timeouts to TTS stream reads to prevent agent stuck in speaking state

agents/src/inference/tts.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
// SPDX-License-Identifier: Apache-2.0
44
import type { AudioFrame } from '@livekit/rtc-node';
55
import { WebSocket } from 'ws';
6-
import { APIError, APIStatusError } from '../_exceptions.js';
6+
import { APIError, APIStatusError, APITimeoutError } from '../_exceptions.js';
77
import { AudioByteStream } from '../audio.js';
88
import { ConnectionPool } from '../connection_pool.js';
99
import { type LanguageCode, normalizeLanguage } from '../language.js';
@@ -13,7 +13,15 @@ import { basic as tokenizeBasic } from '../tokenize/index.js';
1313
import type { ChunkedStream } from '../tts/index.js';
1414
import { SynthesizeStream as BaseSynthesizeStream, TTS as BaseTTS } from '../tts/index.js';
1515
import { type APIConnectOptions, DEFAULT_API_CONNECT_OPTIONS } from '../types.js';
16-
import { Event, Future, Task, cancelAndWait, combineSignals, shortuuid } from '../utils.js';
16+
import {
17+
Event,
18+
Future,
19+
Task,
20+
cancelAndWait,
21+
combineSignals,
22+
shortuuid,
23+
waitUntilTimeout,
24+
} from '../utils.js';
1725
import {
1826
type TtsClientEvent,
1927
type TtsServerEvent,
@@ -578,6 +586,7 @@ export class SynthesizeStream<TModel extends TTSModels> extends BaseSynthesizeSt
578586

579587
const createRecvTask = async (signal: AbortSignal) => {
580588
let currentSessionId: string | null = null;
589+
const recvTimeoutMs = this.connOptions.timeoutMs;
581590

582591
const bstream = new AudioByteStream(this.opts.sampleRate, NUM_CHANNELS);
583592
const serverEventStream = eventChannel.stream();
@@ -587,7 +596,12 @@ export class SynthesizeStream<TModel extends TTSModels> extends BaseSynthesizeSt
587596
await inputSentEvent.wait();
588597

589598
while (!this.closed && !signal.aborted) {
590-
const result = await reader.read();
599+
const result = await waitUntilTimeout(
600+
reader.read(),
601+
recvTimeoutMs,
602+
() => new APITimeoutError({ message: 'TTS recv idle timeout' }),
603+
);
604+
591605
if (signal.aborted) return;
592606
if (result.done) return;
593607

@@ -632,6 +646,14 @@ export class SynthesizeStream<TModel extends TTSModels> extends BaseSynthesizeSt
632646
break;
633647
}
634648
}
649+
} catch (e) {
650+
if (e instanceof APITimeoutError) {
651+
this.#logger.warn('TTS recv task timed out waiting for server message');
652+
await resourceCleanup();
653+
completionFuture.reject(e);
654+
return;
655+
}
656+
throw e;
635657
} finally {
636658
reader.releaseLock();
637659
try {

agents/src/utils.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type {
99
TrackKind,
1010
} from '@livekit/rtc-node';
1111
import { AudioFrame, AudioResampler, RoomEvent } from '@livekit/rtc-node';
12+
import type { Throws } from '@livekit/throws-transformer/throws';
1213
import { AsyncLocalStorage } from 'node:async_hooks';
1314
import { EventEmitter, once } from 'node:events';
1415
import type { ReadableStream } from 'node:stream/web';
@@ -804,6 +805,33 @@ export function delay(ms: number, options: DelayOptions = {}): Promise<void> {
804805
});
805806
}
806807

808+
export class IdleTimeoutError extends Error {
809+
constructor(message = 'idle timeout') {
810+
super(message);
811+
this.name = 'IdleTimeoutError';
812+
}
813+
}
814+
815+
/**
816+
* Race a promise against an idle timeout. If the promise does not settle within
817+
* `timeoutMs` milliseconds, the returned promise rejects with {@link IdleTimeoutError}
818+
* (or the error returned by `throwError` when provided).
819+
* The timer is properly cleaned up on settlement to avoid leaking handles.
820+
*/
821+
export function waitUntilTimeout<T, E extends Error = IdleTimeoutError>(
822+
promise: Promise<T>,
823+
timeoutMs: number,
824+
throwError?: () => E,
825+
): Promise<Throws<T, E>> {
826+
let timer: ReturnType<typeof setTimeout> | undefined;
827+
return Promise.race([
828+
promise,
829+
new Promise<never>((_, reject) => {
830+
timer = setTimeout(() => reject(throwError?.() ?? new IdleTimeoutError()), timeoutMs);
831+
}),
832+
]).finally(() => clearTimeout(timer)) as Promise<Throws<T, E>>;
833+
}
834+
807835
/**
808836
* Returns a participant that matches the given identity. If identity is None, the first
809837
* participant that joins the room will be returned.

agents/src/voice/generation.ts

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,15 @@ import { log } from '../log.js';
2525
import { IdentityTransform } from '../stream/identity_transform.js';
2626
import { traceTypes, tracer } from '../telemetry/index.js';
2727
import { USERDATA_TIMED_TRANSCRIPT } from '../types.js';
28-
import { Future, Task, shortuuid, toError, waitForAbort } from '../utils.js';
28+
import {
29+
Future,
30+
IdleTimeoutError,
31+
Task,
32+
shortuuid,
33+
toError,
34+
waitForAbort,
35+
waitUntilTimeout,
36+
} from '../utils.js';
2937
import {
3038
type Agent,
3139
type ModelSettings,
@@ -46,6 +54,8 @@ import {
4654
import { RunContext } from './run_context.js';
4755
import type { SpeechHandle } from './speech_handle.js';
4856

57+
const TTS_READ_IDLE_TIMEOUT_MS = 10_000;
58+
4959
/** @internal */
5060
export class _LLMGenerationData {
5161
generatedText: string = '';
@@ -550,6 +560,7 @@ export function performTTSInference(
550560
model?: string,
551561
provider?: string,
552562
): [Task<void>, _TTSGenerationData] {
563+
const logger = log();
553564
const audioStream = new IdentityTransform<AudioFrame>();
554565
const outputWriter = audioStream.writable.getWriter();
555566
const audioOutputStream = audioStream.readable;
@@ -624,12 +635,15 @@ export function performTTSInference(
624635
// JS currently only does single inference, so initialPushedDuration is always 0.
625636
// TODO: Add FlushSentinel + multi-segment loop
626637
const initialPushedDuration = pushedDuration;
627-
628638
while (true) {
629639
if (signal.aborted) {
630640
break;
631641
}
632-
const { done, value: frame } = await ttsStreamReader.read();
642+
643+
const { done, value: frame } = await waitUntilTimeout(
644+
ttsStreamReader.read(),
645+
TTS_READ_IDLE_TIMEOUT_MS,
646+
);
633647
if (done) {
634648
break;
635649
}
@@ -671,14 +685,15 @@ export function performTTSInference(
671685
pushedDuration += frameDuration;
672686
}
673687
} catch (error) {
674-
if (error instanceof DOMException && error.name === 'AbortError') {
675-
// Abort signal was triggered, handle gracefully
688+
if (error instanceof IdleTimeoutError) {
689+
logger.warn('TTS stream stalled after producing audio, forcing close');
690+
} else if (error instanceof DOMException && error.name === 'AbortError') {
676691
return;
692+
} else {
693+
throw error;
677694
}
678-
throw error;
679695
} finally {
680696
if (!timedTextsFut.done) {
681-
// Ensure downstream consumers don't hang on errors.
682697
timedTextsFut.resolve(null);
683698
}
684699
ttsStreamReader?.releaseLock();
@@ -773,9 +788,12 @@ async function forwardAudio(
773788
out: _AudioOut,
774789
signal?: AbortSignal,
775790
): Promise<void> {
791+
const logger = log();
776792
const reader = ttsStream.getReader();
777793
let resampler: AudioResampler | null = null;
778794

795+
const FORWARD_AUDIO_IDLE_TIMEOUT_MS = 10_000;
796+
779797
const onPlaybackStarted = (ev: { createdAt: number }) => {
780798
if (!out.firstFrameFut.done) {
781799
out.firstFrameFut.resolve(ev.createdAt);
@@ -791,7 +809,10 @@ async function forwardAudio(
791809
break;
792810
}
793811

794-
const { done, value: frame } = await reader.read();
812+
const { done, value: frame } = await waitUntilTimeout(
813+
reader.read(),
814+
FORWARD_AUDIO_IDLE_TIMEOUT_MS,
815+
);
795816
if (done) break;
796817

797818
out.audio.push(frame);
@@ -819,6 +840,12 @@ async function forwardAudio(
819840
await audioOutput.captureFrame(f);
820841
}
821842
}
843+
} catch (e) {
844+
if (e instanceof IdleTimeoutError) {
845+
logger.warn('audio forwarding stalled waiting for TTS frames, forcing close');
846+
} else {
847+
throw e;
848+
}
822849
} finally {
823850
audioOutput.off(AudioOutput.EVENT_PLAYBACK_STARTED, onPlaybackStarted);
824851

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// SPDX-FileCopyrightText: 2026 LiveKit, Inc.
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
import { AudioFrame } from '@livekit/rtc-node';
5+
import { ReadableStream } from 'stream/web';
6+
import { describe, expect, it, vi } from 'vitest';
7+
import { initializeLogger } from '../log.js';
8+
import { performAudioForwarding, performTTSInference } from './generation.js';
9+
import { AudioOutput } from './io.js';
10+
11+
function createSilentFrame(sampleRate = 24000, channels = 1, durationMs = 20): AudioFrame {
12+
const samplesPerChannel = Math.floor((sampleRate * durationMs) / 1000);
13+
const data = new Int16Array(samplesPerChannel * channels);
14+
return new AudioFrame(data, sampleRate, channels, samplesPerChannel);
15+
}
16+
17+
class MockAudioOutput extends AudioOutput {
18+
capturedFrames: AudioFrame[] = [];
19+
20+
constructor() {
21+
super(24000);
22+
}
23+
24+
async captureFrame(frame: AudioFrame): Promise<void> {
25+
await super.captureFrame(frame);
26+
this.capturedFrames.push(frame);
27+
this.onPlaybackStarted(Date.now());
28+
}
29+
30+
clearBuffer(): void {
31+
// no-op for mock
32+
}
33+
}
34+
35+
describe('TTS stream idle timeout', () => {
36+
initializeLogger({ pretty: false, level: 'silent' });
37+
38+
it('forwardAudio completes when TTS stream stalls after producing frames', async () => {
39+
const stalledStream = new ReadableStream<AudioFrame>({
40+
start(controller) {
41+
controller.enqueue(createSilentFrame());
42+
controller.enqueue(createSilentFrame());
43+
},
44+
});
45+
46+
const audioOutput = new MockAudioOutput();
47+
const controller = new AbortController();
48+
49+
const [task, audioOut] = performAudioForwarding(stalledStream, audioOutput, controller);
50+
51+
vi.useFakeTimers();
52+
53+
const taskPromise = task.result;
54+
await vi.advanceTimersByTimeAsync(11_000);
55+
await taskPromise;
56+
57+
vi.useRealTimers();
58+
59+
expect(audioOutput.capturedFrames.length).toBe(2);
60+
expect(audioOut.firstFrameFut.done).toBe(true);
61+
}, 10_000);
62+
63+
it('forwardAudio completes normally when TTS stream closes properly', async () => {
64+
const normalStream = new ReadableStream<AudioFrame>({
65+
start(controller) {
66+
controller.enqueue(createSilentFrame());
67+
controller.enqueue(createSilentFrame());
68+
controller.enqueue(createSilentFrame());
69+
controller.close();
70+
},
71+
});
72+
73+
const audioOutput = new MockAudioOutput();
74+
const controller = new AbortController();
75+
76+
const [task, audioOut] = performAudioForwarding(normalStream, audioOutput, controller);
77+
78+
await task.result;
79+
80+
expect(audioOutput.capturedFrames.length).toBe(3);
81+
expect(audioOut.firstFrameFut.done).toBe(true);
82+
});
83+
84+
it('performTTSInference completes when TTS node returns stalled stream', async () => {
85+
const stalledTtsStream = new ReadableStream<AudioFrame>({
86+
start(controller) {
87+
controller.enqueue(createSilentFrame());
88+
},
89+
});
90+
91+
const ttsNode = async () => stalledTtsStream;
92+
const textInput = new ReadableStream<string>({
93+
start(controller) {
94+
controller.enqueue('Hello world');
95+
controller.close();
96+
},
97+
});
98+
99+
const controller = new AbortController();
100+
const [task, genData] = performTTSInference(ttsNode, textInput, {}, controller);
101+
102+
vi.useFakeTimers();
103+
104+
const taskPromise = task.result;
105+
await vi.advanceTimersByTimeAsync(11_000);
106+
await taskPromise;
107+
108+
vi.useRealTimers();
109+
110+
expect(genData.ttfb).toBeDefined();
111+
}, 10_000);
112+
});

0 commit comments

Comments
 (0)