forked from akai/readest
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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user