Skip to content

Commit f7d11de

Browse files
mrnikettoubatbrian
andauthored
fix(tts): unblock FallbackAdapter when primary provider fails silently (#1218)
Co-authored-by: Brian Yin <brian.yin@livekit.io>
1 parent 8a06cac commit f7d11de

5 files changed

Lines changed: 285 additions & 17 deletions

File tree

.changeset/shaggy-rings-hear.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@livekit/agents": patch
3+
"@livekit/agents-plugin-elevenlabs": patch
4+
---
5+
6+
fix(tts): unblock FallbackAdapter when primary provider fails silently
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
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 'node:stream/web';
6+
import { beforeAll, describe, expect, it } from 'vitest';
7+
import { APIError } from '../_exceptions.js';
8+
import { initializeLogger } from '../log.js';
9+
import type { APIConnectOptions } from '../types.js';
10+
import { FallbackAdapter } from './fallback_adapter.js';
11+
import { ChunkedStream, SynthesizeStream, TTS } from './tts.js';
12+
13+
const SAMPLE_RATE = 24000;
14+
15+
class MockSynthesizeStream extends SynthesizeStream {
16+
label = 'mock.SynthesizeStream';
17+
18+
constructor(
19+
private mockTts: MockTTS,
20+
private shouldFail: boolean,
21+
connOptions?: APIConnectOptions,
22+
) {
23+
super(mockTts, connOptions);
24+
}
25+
26+
protected async run(): Promise<void> {
27+
if (this.shouldFail) {
28+
// Throw immediately, before any pushText has been called.
29+
// This is the scenario that previously deadlocked the FallbackAdapter:
30+
// the inner stream's mainTask finishes before forwardBufferToTTS gets
31+
// a chance to call pushText, so #monitorMetricsTask never starts and
32+
// this.output is never closed.
33+
throw new APIError('mock TTS failed immediately');
34+
}
35+
36+
// Happy path: read text from this.input and emit a single audio frame per token.
37+
for await (const data of this.input) {
38+
if (this.abortController.signal.aborted) break;
39+
if (data === SynthesizeStream.FLUSH_SENTINEL) continue;
40+
this.queue.put({
41+
requestId: 'mock-req',
42+
segmentId: 'mock-seg',
43+
frame: new AudioFrame(new Int16Array(160), this.mockTts.sampleRate, 1, 160),
44+
final: false,
45+
});
46+
}
47+
}
48+
}
49+
50+
class MockChunkedStream extends ChunkedStream {
51+
label = 'mock.ChunkedStream';
52+
constructor(
53+
private mockTts: MockTTS,
54+
text: string,
55+
private shouldFail: boolean,
56+
connOptions?: APIConnectOptions,
57+
) {
58+
super(text, mockTts, connOptions);
59+
}
60+
protected async run(): Promise<void> {
61+
if (this.shouldFail) {
62+
throw new APIError('mock TTS failed immediately');
63+
}
64+
this.queue.put({
65+
requestId: 'mock-req',
66+
segmentId: 'mock-seg',
67+
frame: new AudioFrame(new Int16Array(160), this.mockTts.sampleRate, 1, 160),
68+
final: true,
69+
});
70+
}
71+
}
72+
73+
class MockTTS extends TTS {
74+
label: string;
75+
shouldFail = false;
76+
77+
constructor(label: string, sampleRate: number = SAMPLE_RATE) {
78+
super(sampleRate, 1, { streaming: true });
79+
this.label = label;
80+
}
81+
82+
synthesize(text: string, connOptions?: APIConnectOptions): ChunkedStream {
83+
return new MockChunkedStream(this, text, this.shouldFail, connOptions);
84+
}
85+
86+
stream(options?: { connOptions?: APIConnectOptions }): SynthesizeStream {
87+
return new MockSynthesizeStream(this, this.shouldFail, options?.connOptions);
88+
}
89+
}
90+
91+
describe('TTS FallbackAdapter', () => {
92+
beforeAll(() => {
93+
initializeLogger({ pretty: false });
94+
// Suppress unhandled rejections from background tasks inside SynthesizeStream
95+
process.on('unhandledRejection', () => {});
96+
});
97+
98+
it('should fall back to the next TTS when the primary stream fails before any pushText', async () => {
99+
const primary = new MockTTS('primary');
100+
primary.shouldFail = true;
101+
const secondary = new MockTTS('secondary');
102+
const adapter = new FallbackAdapter({
103+
ttsInstances: [primary, secondary],
104+
maxRetryPerTTS: 0,
105+
recoveryDelayMs: 60_000,
106+
});
107+
108+
const stream = adapter.stream();
109+
stream.updateInputStream(
110+
new ReadableStream<string>({
111+
start(controller) {
112+
controller.enqueue('hello world');
113+
controller.close();
114+
},
115+
}),
116+
);
117+
118+
// With the deadlock bug, this loop hangs forever because the inner
119+
// primary stream's this.output is never closed. Use a hard timeout to
120+
// turn the deadlock into a test failure.
121+
const iterate = (async () => {
122+
let frameCount = 0;
123+
for await (const event of stream) {
124+
if (event === SynthesizeStream.END_OF_STREAM) break;
125+
frameCount++;
126+
}
127+
return frameCount;
128+
})();
129+
130+
const timeout = new Promise<never>((_, reject) =>
131+
setTimeout(() => reject(new Error('fallback adapter deadlocked')), 3000),
132+
);
133+
134+
const frameCount = await Promise.race([iterate, timeout]);
135+
136+
expect(frameCount).toBeGreaterThan(0);
137+
expect(adapter.status[0]!.available).toBe(false);
138+
expect(adapter.status[1]!.available).toBe(true);
139+
140+
stream.close();
141+
await adapter.close();
142+
});
143+
144+
it('should fall back when the primary has a mismatched sample rate and emits no audio', async () => {
145+
// Primary runs at 22050Hz, adapter aggregates at 24000Hz → a resampler is
146+
// created for the primary. The primary throws with no frames ever pushed,
147+
// so `resampler.push()` is never called. Regression test for a bug where
148+
// `resampler.flush()` on an unused resampler returned a phantom frame,
149+
// flipping `audioPushed` to true and making the adapter incorrectly
150+
// treat a silent failure as a success.
151+
const primary = new MockTTS('primary', 22050);
152+
primary.shouldFail = true;
153+
const secondary = new MockTTS('secondary', 24000);
154+
const adapter = new FallbackAdapter({
155+
ttsInstances: [primary, secondary],
156+
maxRetryPerTTS: 0,
157+
recoveryDelayMs: 60_000,
158+
});
159+
160+
const stream = adapter.stream();
161+
stream.updateInputStream(
162+
new ReadableStream<string>({
163+
start(controller) {
164+
controller.enqueue('hello world');
165+
controller.close();
166+
},
167+
}),
168+
);
169+
170+
const iterate = (async () => {
171+
let frameCount = 0;
172+
for await (const event of stream) {
173+
if (event === SynthesizeStream.END_OF_STREAM) break;
174+
frameCount++;
175+
}
176+
return frameCount;
177+
})();
178+
179+
const timeout = new Promise<never>((_, reject) =>
180+
setTimeout(() => reject(new Error('fallback adapter deadlocked')), 3000),
181+
);
182+
183+
const frameCount = await Promise.race([iterate, timeout]);
184+
185+
expect(frameCount).toBeGreaterThan(0);
186+
expect(adapter.status[0]!.available).toBe(false);
187+
expect(adapter.status[1]!.available).toBe(true);
188+
189+
stream.close();
190+
await adapter.close();
191+
});
192+
193+
it('should fall back in the non-streaming (synthesize) path with mismatched sample rates', async () => {
194+
// FallbackChunkedStream has the same phantom-flush vulnerability as
195+
// FallbackSynthesizeStream: when the primary's sample rate differs from
196+
// the adapter's output rate a resampler is created, and flushing an
197+
// unused resampler can return a ghost frame that masks a silent
198+
// failure. Exercise the non-streaming adapter.synthesize() path.
199+
const primary = new MockTTS('primary', 22050);
200+
primary.shouldFail = true;
201+
const secondary = new MockTTS('secondary', 24000);
202+
const adapter = new FallbackAdapter({
203+
ttsInstances: [primary, secondary],
204+
maxRetryPerTTS: 0,
205+
recoveryDelayMs: 60_000,
206+
});
207+
208+
const chunked = adapter.synthesize('hello world');
209+
210+
const iterate = (async () => {
211+
let frameCount = 0;
212+
for await (const _event of chunked) {
213+
frameCount++;
214+
}
215+
return frameCount;
216+
})();
217+
218+
const timeout = new Promise<never>((_, reject) =>
219+
setTimeout(() => reject(new Error('fallback adapter deadlocked')), 3000),
220+
);
221+
222+
const frameCount = await Promise.race([iterate, timeout]);
223+
224+
expect(frameCount).toBeGreaterThan(0);
225+
expect(adapter.status[0]!.available).toBe(false);
226+
expect(adapter.status[1]!.available).toBe(true);
227+
228+
await adapter.close();
229+
});
230+
});

agents/src/tts/fallback_adapter.ts

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -333,44 +333,48 @@ class FallbackChunkedStream extends ChunkedStream {
333333
maxRetry: this.adapter.maxRetryPerTTS,
334334
};
335335
const stream = tts.synthesize(this.inputText, connOptions, this.abortSignal);
336-
let audioReceived = false;
336+
// Tracks whether the inner stream yielded any real audio frames.
337+
// A phantom `AudioResampler.flush()` frame (observed on rtc-node
338+
// 0.13.25) could otherwise mask a silent failure as a success.
339+
let sawRawAudio = false;
337340
for await (const audio of stream) {
338341
if (this.abortController.signal.aborted) {
339342
stream.close();
340343
return;
341344
}
342345

346+
sawRawAudio = true;
347+
343348
if (resampler) {
344349
for (const frame of resampler.push(audio.frame)) {
345350
this.queue.put({
346351
...audio,
347352
frame,
348353
});
349-
audioReceived = true;
350354
}
351355
} else {
352356
this.queue.put(audio);
353-
audioReceived = true;
354357
}
355358
lastRequestId = audio.requestId;
356359
lastSegmentId = audio.segmentId;
357360
}
358361

359-
// Flush any remaining resampled frames
360-
if (resampler) {
362+
// Only flush the resampler if real audio actually went in — otherwise
363+
// flush() can return phantom frames that would mask a silent failure
364+
// from the primary provider.
365+
if (resampler && sawRawAudio) {
361366
for (const frame of resampler.flush()) {
362367
this.queue.put({
363368
requestId: lastRequestId || '',
364369
segmentId: lastSegmentId || '',
365370
frame,
366371
final: true,
367372
});
368-
audioReceived = true;
369373
}
370374
}
371375

372-
// Verify audio was actually received - silent failures should trigger fallback
373-
if (!audioReceived) {
376+
// Silent failures must trigger fallback.
377+
if (!sawRawAudio) {
374378
throw new APIConnectionError({
375379
message: 'TTS synthesis completed but no audio was received',
376380
});
@@ -480,6 +484,11 @@ class FallbackSynthesizeStream extends SynthesizeStream {
480484
}
481485
};
482486

487+
// Tracks whether the inner stream yielded any real audio frames.
488+
// `audioPushed` can be flipped on by a phantom `AudioResampler.flush()`
489+
// frame even when nothing was pushed in (rtc-node 0.13.25), so we
490+
// cannot use it to detect silent failures.
491+
let sawRawAudio = false;
483492
const processOutput = async () => {
484493
try {
485494
for await (const audio of stream) {
@@ -495,6 +504,8 @@ class FallbackSynthesizeStream extends SynthesizeStream {
495504
continue;
496505
}
497506

507+
sawRawAudio = true;
508+
498509
if (resampler) {
499510
for (const frame of resampler.push(audio.frame)) {
500511
this.queue.put({
@@ -511,8 +522,10 @@ class FallbackSynthesizeStream extends SynthesizeStream {
511522
lastSegmentId = audio.segmentId;
512523
}
513524

514-
// Flush resampler
515-
if (resampler) {
525+
// Only flush the resampler if real audio actually went in —
526+
// otherwise flush() can return phantom frames that would mask a
527+
// silent failure from the primary provider.
528+
if (resampler && sawRawAudio) {
516529
for (const frame of resampler.flush()) {
517530
this.queue.put({
518531
requestId: lastRequestId || '',
@@ -549,8 +562,9 @@ class FallbackSynthesizeStream extends SynthesizeStream {
549562
throw forwardBufferResult.reason;
550563
}
551564

552-
// Verify audio was actually received - if not, the TTS failed silently
553-
if (!this.audioPushed) {
565+
// Silent failures must trigger fallback. See `sawRawAudio` above for
566+
// why we don't check `audioPushed` here.
567+
if (!sawRawAudio) {
554568
throw new APIConnectionError({
555569
message: 'TTS stream completed but no audio was received',
556570
});

agents/src/tts/tts.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,24 @@ export abstract class SynthesizeStream
204204
// is run **after** the constructor has finished. Otherwise we get
205205
// runtime error when trying to access class variables in the
206206
// `run` method.
207-
startSoon(() => this.mainTask().finally(() => this.queue.close()));
207+
// Ensure `this.output` is closed once mainTask settles, even when
208+
// `monitorMetrics` never started (no `pushText` was ever called).
209+
// Without this, consumers iterating the stream hang forever.
210+
startSoon(async () => {
211+
try {
212+
await this.mainTask();
213+
} catch {
214+
// already surfaced via emitError; swallow to avoid unhandled rejection.
215+
} finally {
216+
this.queue.close();
217+
if (this.#monitorMetricsTask) {
218+
await this.#monitorMetricsTask.catch(() => {});
219+
}
220+
if (!this.output.closed) {
221+
this.output.close();
222+
}
223+
}
224+
});
208225
}
209226

210227
private _mainTaskImpl = async (span: Span) => {

plugins/elevenlabs/src/tts.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
//
33
// SPDX-License-Identifier: Apache-2.0
44
import {
5+
type APIConnectOptions,
56
APIConnectionError,
67
APIError,
78
APIStatusError,
@@ -787,8 +788,8 @@ export class TTS extends tts.TTS {
787788
return new ChunkedStream(this, text, { ...this.#opts });
788789
}
789790

790-
stream(): SynthesizeStream {
791-
const stream = new SynthesizeStream(this, { ...this.#opts });
791+
stream(options?: { connOptions?: APIConnectOptions }): SynthesizeStream {
792+
const stream = new SynthesizeStream(this, { ...this.#opts }, options?.connOptions);
792793
this.#streams.add(stream);
793794
return stream;
794795
}
@@ -910,8 +911,8 @@ export class SynthesizeStream extends tts.SynthesizeStream {
910911

911912
label = 'elevenlabs.SynthesizeStream';
912913

913-
constructor(tts: TTS, opts: ResolvedTTSOptions) {
914-
super(tts);
914+
constructor(tts: TTS, opts: ResolvedTTSOptions, connOptions?: APIConnectOptions) {
915+
super(tts, connOptions);
915916
this.#tts = tts;
916917
this.#opts = opts;
917918
this.#contextId = shortuuid();

0 commit comments

Comments
 (0)