diff --git a/apps/readest-app/src/services/tts/EdgeTTSClient.ts b/apps/readest-app/src/services/tts/EdgeTTSClient.ts index 1c015165..9e47f9aa 100644 --- a/apps/readest-app/src/services/tts/EdgeTTSClient.ts +++ b/apps/readest-app/src/services/tts/EdgeTTSClient.ts @@ -44,7 +44,11 @@ export class EdgeTTSClient implements TTSClient { return { lang, text, voice: voiceId, rate: this.#rate, pitch: this.#pitch } as EdgeTTSPayload; }; - async *speak(ssml: string, signal: AbortSignal): AsyncGenerator { + async *speak( + ssml: string, + signal: AbortSignal, + preload = false, + ): AsyncGenerator { const { marks } = parseSSMLMarks(ssml); const lang = parseSSMLLang(ssml) || 'en'; @@ -57,15 +61,19 @@ export class EdgeTTSClient implements TTSClient { voiceId = this.#voice.id; } - await this.stopInternal(); - - // Preloading for longer ssml - if (marks.length > 1) { - for (const mark of marks.slice(1)) { - this.#edgeTTS.createAudio(this.getPayload(lang, mark.text, voiceId)).catch((error) => { - console.warn('Error preloading mark:', mark, error); + if (preload) { + for (const mark of marks) { + await this.#edgeTTS.createAudio(this.getPayload(lang, mark.text, voiceId)).catch((err) => { + console.warn('Error preloading:', err); }); } + yield { + code: 'end', + message: 'Preload finished', + }; + return; + } else { + await this.stopInternal(); } for (const mark of marks) { @@ -102,6 +110,13 @@ export class EdgeTTSClient implements TTSClient { message: `Chunk finished: ${mark.name}`, }); }; + if (signal.aborted) { + resolve({ + code: 'error', + message: 'Aborted', + }); + return; + } this.#sourceNode.start(0); this.#isPlaying = true; this.#startedAt = this.#audioContext.currentTime; diff --git a/apps/readest-app/src/services/tts/TTSClient.ts b/apps/readest-app/src/services/tts/TTSClient.ts index 3e454355..92906c36 100644 --- a/apps/readest-app/src/services/tts/TTSClient.ts +++ b/apps/readest-app/src/services/tts/TTSClient.ts @@ -17,7 +17,7 @@ export interface TTSVoice { export interface TTSClient { init(): Promise; - speak(ssml: string, signal: AbortSignal): AsyncIterable; + speak(ssml: string, signal: AbortSignal, preload?: boolean): AsyncIterable; pause(): Promise; resume(): Promise; stop(): Promise; diff --git a/apps/readest-app/src/services/tts/TTSController.ts b/apps/readest-app/src/services/tts/TTSController.ts index 9305270e..b2978c01 100644 --- a/apps/readest-app/src/services/tts/TTSController.ts +++ b/apps/readest-app/src/services/tts/TTSController.ts @@ -54,6 +54,27 @@ export class TTSController extends EventTarget { await this.view.initTTS(granularity); } + async preloadSSML(ssml: string | undefined) { + if (!ssml) return; + const iter = await this.ttsClient.speak(ssml, new AbortController().signal, true); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _ of iter); + } + + async preloadNextSSML(count: number) { + const tts = this.view.tts; + if (!tts) return; + let preloaded = 0; + for (let i = 0; i < count; i++) { + const ssml = tts.next(); + this.preloadSSML(ssml); + if (ssml) preloaded++; + } + for (let i = 0; i < preloaded; i++) { + tts.prev(); + } + } + async #speak(ssml: string | undefined | Promise) { await this.stop(); this.#currentSpeakAbortController = new AbortController(); @@ -63,6 +84,7 @@ export class TTSController extends EventTarget { console.log('TTS speak'); this.state = 'playing'; ssml = await ssml; + await this.preloadSSML(ssml); if (!ssml) { this.#nossmlCnt++; // FIXME: in case we are at the end of the book, need a better way to handle this @@ -95,6 +117,7 @@ export class TTSController extends EventTarget { async speak(ssml: string | Promise) { await this.initViewTTS(); this.#speak(ssml).catch((e) => this.error(e)); + this.preloadNextSSML(2); } play() { @@ -107,10 +130,9 @@ export class TTSController extends EventTarget { async start() { await this.initViewTTS(); - const resumeOrStart = this.state.includes('paused') - ? this.view.tts?.resume() - : this.view.tts?.start(); - return this.#speak(resumeOrStart); + const ssml = this.state.includes('paused') ? this.view.tts?.resume() : this.view.tts?.start(); + this.#speak(ssml); + this.preloadNextSSML(2); } async pause() { @@ -149,6 +171,7 @@ export class TTSController extends EventTarget { if (this.state === 'playing') { await this.stop(); this.#speak(this.view.tts?.next()); + this.preloadNextSSML(2); } else { this.state = 'forward-paused'; this.view.tts?.next(true); diff --git a/apps/readest-app/src/services/tts/WebSpeechClient.ts b/apps/readest-app/src/services/tts/WebSpeechClient.ts index fdabb475..c55ccc52 100644 --- a/apps/readest-app/src/services/tts/WebSpeechClient.ts +++ b/apps/readest-app/src/services/tts/WebSpeechClient.ts @@ -205,7 +205,14 @@ export class WebSpeechClient implements TTSClient { return this.available; } - async *speak(ssml: string, signal: AbortSignal): AsyncGenerator { + async *speak( + ssml: string, + signal: AbortSignal, + preload = false, + ): AsyncGenerator { + // no need to preload for web speech + if (preload) return; + const lang = parseSSMLLang(ssml) || 'en'; if (!this.#voice) { const voices = await this.getVoices(lang);