diff --git a/apps/readest-app/src/__tests__/services/edge-tts-client.test.ts b/apps/readest-app/src/__tests__/services/edge-tts-client.test.ts index 9e229b03..6e5fd824 100644 --- a/apps/readest-app/src/__tests__/services/edge-tts-client.test.ts +++ b/apps/readest-app/src/__tests__/services/edge-tts-client.test.ts @@ -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 = () => Promise.resolve(undefined); +// Shared mock control for createAudioUrl() and parsed SSML marks +let createAudioUrlBehavior = vi.fn<() => Promise>(() => 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>(() => 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('', 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(); diff --git a/apps/readest-app/src/services/tts/EdgeTTSClient.ts b/apps/readest-app/src/services/tts/EdgeTTSClient.ts index 379d0a68..b64217fb 100644 --- a/apps/readest-app/src/services/tts/EdgeTTSClient.ts +++ b/apps/readest-app/src/services/tts/EdgeTTSClient.ts @@ -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 => { + 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); }