fix(tts): retry Edge TTS preload up to 3 times on failure, closes #4147 (#4171)

Edge TTS websocket requests fail intermittently, and a single transient
failure during preload silently dropped the cached audio chunk, which
could stall playback. Add a #createAudioUrlWithRetry helper that retries
createAudioUrl up to 3 attempts with a short backoff, bailing early when
the abort signal fires. Both the immediate and background preload paths
in speak() now use it.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-05-15 17:25:27 +08:00
committed by GitHub
parent e0cb433550
commit 4cd5d56b49
2 changed files with 100 additions and 8 deletions
@@ -3,6 +3,10 @@ import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
// Shared mock control: tests can override createBehavior to change how create() behaves
let createBehavior: () => Promise<undefined> = () => Promise.resolve(undefined);
// Shared mock control for createAudioUrl() and parsed SSML marks
let createAudioUrlBehavior = vi.fn<() => Promise<string>>(() => Promise.resolve('blob:mock-url'));
let parsedMarks: Array<{ name: string; text: string; language: string }> = [];
// --- Mocks ---
vi.mock('@/libs/edgeTTS', () => {
@@ -16,14 +20,14 @@ vi.mock('@/libs/edgeTTS', () => {
EdgeSpeechTTS: class MockEdgeSpeechTTS {
static voices = voices;
create = vi.fn().mockImplementation(() => createBehavior());
createAudioUrl = vi.fn().mockResolvedValue('blob:mock-url');
createAudioUrl = vi.fn().mockImplementation(() => createAudioUrlBehavior());
},
EDGE_TTS_PROTOCOL: 'wss',
};
});
vi.mock('@/utils/ssml', () => ({
parseSSMLMarks: vi.fn(() => ({ marks: [] })),
parseSSMLMarks: vi.fn(() => ({ marks: parsedMarks })),
}));
vi.mock('@/utils/misc', () => ({
@@ -53,6 +57,8 @@ describe('EdgeTTSClient', () => {
beforeEach(() => {
createBehavior = () => Promise.resolve(undefined);
createAudioUrlBehavior = vi.fn<() => Promise<string>>(() => Promise.resolve('blob:mock-url'));
parsedMarks = [];
client = new EdgeTTSClient();
});
@@ -362,6 +368,63 @@ describe('EdgeTTSClient', () => {
});
});
describe('speak preload retry', () => {
const consumePreload = async (c: EdgeTTSClient, signal: AbortSignal) => {
for await (const _ of c.speak('<ssml/>', signal, true)) {
void _;
}
};
test('retries createAudioUrl up to 3 times when preload fails', async () => {
await client.init();
parsedMarks = [{ name: 'mark-0', text: 'hello', language: 'en' }];
createAudioUrlBehavior = vi.fn(() => Promise.reject(new Error('network error')));
await consumePreload(client, new AbortController().signal);
expect(createAudioUrlBehavior).toHaveBeenCalledTimes(3);
});
test('does not retry when the first preload attempt succeeds', async () => {
await client.init();
parsedMarks = [{ name: 'mark-0', text: 'hello', language: 'en' }];
await consumePreload(client, new AbortController().signal);
expect(createAudioUrlBehavior).toHaveBeenCalledTimes(1);
});
test('stops retrying once an attempt succeeds', async () => {
await client.init();
parsedMarks = [{ name: 'mark-0', text: 'hello', language: 'en' }];
let calls = 0;
createAudioUrlBehavior = vi.fn(() => {
calls++;
return calls < 2
? Promise.reject(new Error('network error'))
: Promise.resolve('blob:mock-url');
});
await consumePreload(client, new AbortController().signal);
expect(createAudioUrlBehavior).toHaveBeenCalledTimes(2);
});
test('stops retrying once the signal is aborted', async () => {
await client.init();
parsedMarks = [{ name: 'mark-0', text: 'hello', language: 'en' }];
const controller = new AbortController();
createAudioUrlBehavior = vi.fn(() => {
controller.abort();
return Promise.reject(new Error('network error'));
});
await consumePreload(client, controller.signal);
expect(createAudioUrlBehavior).toHaveBeenCalledTimes(1);
});
});
describe('pause / resume / stop', () => {
test('pause returns true when no audio element exists', async () => {
const result = await client.pause();
@@ -62,6 +62,29 @@ export class EdgeTTSClient implements TTSClient {
return { lang, text, voice: voiceId, rate: 1.0, pitch: this.#pitch } as EdgeTTSPayload;
};
// Edge TTS websocket requests fail intermittently; retry the preload a few times
// before giving up so a single transient failure doesn't stall playback.
#createAudioUrlWithRetry = async (
payload: EdgeTTSPayload,
signal: AbortSignal,
maxAttempts = 3,
): Promise<string | undefined> => {
let lastError: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
if (signal.aborted) return undefined;
try {
return await this.#edgeTTS?.createAudioUrl(payload);
} catch (err) {
lastError = err;
console.warn(`Edge TTS preload attempt ${attempt}/${maxAttempts} failed`, err);
if (attempt < maxAttempts && !signal.aborted) {
await new Promise((resolve) => setTimeout(resolve, 200 * attempt));
}
}
}
throw lastError;
};
getVoiceIdFromLang = async (lang: string) => {
const preferredVoiceId = TTSUtils.getPreferredVoice(this.name, lang);
const preferredVoice = this.#voices.find((v) => v.id === preferredVoiceId);
@@ -85,11 +108,14 @@ export class EdgeTTSClient implements TTSClient {
const { language: voiceLang } = mark;
const voiceId = await this.getVoiceIdFromLang(voiceLang);
this.#currentVoiceId = voiceId;
await this.#edgeTTS
?.createAudioUrl(this.getPayload(voiceLang, mark.text, voiceId))
.catch((err) => {
console.warn('Error preloading mark', i, err);
});
try {
await this.#createAudioUrlWithRetry(
this.getPayload(voiceLang, mark.text, voiceId),
signal,
);
} catch (err) {
console.warn('Error preloading mark', i, err);
}
}
if (marks.length > maxImmediate) {
(async () => {
@@ -99,7 +125,10 @@ export class EdgeTTSClient implements TTSClient {
if (signal.aborted) break;
const { language: voiceLang } = mark;
const voiceId = await this.getVoiceIdFromLang(voiceLang);
await this.#edgeTTS?.createAudioUrl(this.getPayload(voiceLang, mark.text, voiceId));
await this.#createAudioUrlWithRetry(
this.getPayload(voiceLang, mark.text, voiceId),
signal,
);
} catch (err) {
console.warn('Error preloading mark (bg)', i, err);
}