From 75dc04d5983c77ffcd861b8058f63162c09fe50f Mon Sep 17 00:00:00 2001 From: shaqtiktok Date: Fri, 22 Aug 2025 16:22:34 +0100 Subject: [PATCH] fix(tts): reuse audio object in the same paragraph for better performance, closes #1777 (#1853) * Remove stopInternal which causes audio delaying * Change audio obj to not be recreated every sentence * refactor abort signal handler --------- Co-authored-by: Huang Xin --- .../src/services/tts/EdgeTTSClient.ts | 70 +++++++++---------- 1 file changed, 33 insertions(+), 37 deletions(-) diff --git a/apps/readest-app/src/services/tts/EdgeTTSClient.ts b/apps/readest-app/src/services/tts/EdgeTTSClient.ts index f4e5e725..defb34d9 100644 --- a/apps/readest-app/src/services/tts/EdgeTTSClient.ts +++ b/apps/readest-app/src/services/tts/EdgeTTSClient.ts @@ -97,18 +97,17 @@ export class EdgeTTSClient implements TTSClient { } as TTSMessageEvent; return; - } else { - await this.stopInternal(); } + await this.stopInternal(); + // Reuse the same Audio element inside the ssml session + this.#audioElement = new Audio(); + const audio = this.#audioElement; + audio.setAttribute('x-webkit-airplay', 'deny'); + audio.preload = 'auto'; + for (const mark of marks) { - if (signal.aborted) { - yield { - code: 'error', - message: 'Aborted', - } as TTSMessageEvent; - break; - } + let abortHandler: null | (() => void) = null; try { const { language: voiceLang } = mark; const voiceId = await this.getVoiceIdFromLang(voiceLang); @@ -116,11 +115,7 @@ export class EdgeTTSClient implements TTSClient { const blob = await this.#edgeTTS.createAudio( this.getPayload(voiceLang, mark.text, voiceId), ); - const url = URL.createObjectURL(blob); - this.#audioElement = new Audio(url); - const audio = this.#audioElement; - audio.setAttribute('x-webkit-airplay', 'deny'); - audio.preload = 'auto'; + audio.src = URL.createObjectURL(blob); this.controller?.dispatchSpeakMark(mark); @@ -135,27 +130,30 @@ export class EdgeTTSClient implements TTSClient { audio.onended = null; audio.onerror = null; audio.pause(); - audio.src = ''; - URL.revokeObjectURL(url); + if (audio.src) { + URL.revokeObjectURL(audio.src); + audio.removeAttribute('src'); + } }; + abortHandler = () => { + cleanUp(); + resolve({ code: 'error', message: 'Aborted' }); + }; + if (signal.aborted) { + abortHandler(); + return; + } else { + signal.addEventListener('abort', abortHandler); + } audio.onended = () => { cleanUp(); - if (signal.aborted) { - resolve({ code: 'error', message: 'Aborted' }); - } else { - resolve({ code: 'end', message: `Chunk finished: ${mark.name}` }); - } + resolve({ code: 'end', message: `Chunk finished: ${mark.name}` }); }; audio.onerror = (e) => { cleanUp(); console.warn('Audio playback error:', e); resolve({ code: 'error', message: 'Audio playback error' }); }; - if (signal.aborted) { - cleanUp(); - resolve({ code: 'error', message: 'Aborted' }); - return; - } this.#isPlaying = true; audio.play().catch((err) => { cleanUp(); @@ -167,22 +165,20 @@ export class EdgeTTSClient implements TTSClient { } catch (error) { if (error instanceof Error && error.message === 'No audio data received.') { console.warn('No audio data received for:', mark.text); - yield { - code: 'end', - message: `Chunk finished: ${mark.name}`, - } as TTSMessageEvent; + yield { code: 'end', message: `Chunk finished: ${mark.name}` } as TTSMessageEvent; continue; } - console.log('Error:', error); - yield { - code: 'error', - message: error instanceof Error ? error.message : String(error), - } as TTSMessageEvent; + const message = error instanceof Error ? error.message : String(error); + console.warn('TTS error for mark:', mark.text, message); + yield { code: 'error', message } as TTSMessageEvent; break; + } finally { + if (abortHandler) { + signal.removeEventListener('abort', abortHandler); + } } - - await this.stopInternal(); } + await this.stopInternal(); } async pause() {