From 5087492e25401b87ebc56f8dbe13fb1fc415c224 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 19 Apr 2026 15:49:42 +0900 Subject: [PATCH] refac --- .../chat/Messages/ResponseMessage.svelte | 65 ++++++++-------- src/lib/utils/audio.ts | 76 +++++++++++-------- 2 files changed, 75 insertions(+), 66 deletions(-) diff --git a/src/lib/components/chat/Messages/ResponseMessage.svelte b/src/lib/components/chat/Messages/ResponseMessage.svelte index ebae97d95..e97b5ac75 100644 --- a/src/lib/components/chat/Messages/ResponseMessage.svelte +++ b/src/lib/components/chat/Messages/ResponseMessage.svelte @@ -185,6 +185,7 @@ let speakingIdx: number | undefined; let loadingSpeech = false; + let speakAbort: AbortController | null = null; let showRateComment = false; @@ -202,39 +203,39 @@ }; const stopAudio = () => { + speakAbort?.abort(); + speakAbort = null; + try { speechSynthesis.cancel(); $audioQueue?.stop(); } catch {} - if (speaking) { - speaking = false; - speakingIdx = undefined; - } + speaking = false; + speakingIdx = undefined; + loadingSpeech = false; }; + // Resolve voice: model-specific > user settings > config default + const getVoiceId = () => + model?.info?.meta?.tts?.voice ?? + ($settings?.audio?.tts?.defaultVoice === $config.audio.tts.voice + ? $settings?.audio?.tts?.voice ?? $config?.audio?.tts?.voice + : $config?.audio?.tts?.voice); + const speak = async () => { if (!(message?.content ?? '').trim().length) { toast.info($i18n.t('No content to speak')); return; } + stopAudio(); + speakAbort = new AbortController(); + const { signal } = speakAbort; + speaking = true; const content = removeAllDetails(message.content); - // Get voice: model-specific > user settings > config default - const getVoiceId = () => { - // Check for model-specific TTS voice first - if (model?.info?.meta?.tts?.voice) { - return model.info.meta.tts.voice; - } - // Fall back to user settings or config default - if ($settings?.audio?.tts?.defaultVoice === $config.audio.tts.voice) { - return $settings?.audio?.tts?.voice ?? $config?.audio?.tts?.voice; - } - return $config?.audio?.tts?.voice; - }; - if ($config.audio.tts.engine === '') { let voices = []; const getVoicesLoop = setInterval(() => { @@ -242,16 +243,10 @@ if (voices.length > 0) { clearInterval(getVoicesLoop); - const voiceId = getVoiceId(); - const voice = voices?.filter((v) => v.voiceURI === voiceId)?.at(0) ?? undefined; - - console.log(voice); - + const voice = voices.find((v) => v.voiceURI === getVoiceId()); const speech = new SpeechSynthesisUtterance(content); speech.rate = $settings.audio?.tts?.playbackRate ?? 1; - console.log(speech); - speech.onend = () => { speaking = false; if ($settings.conversationMode) { @@ -281,9 +276,7 @@ ); if (!messageContentParts.length) { - console.log('No content to speak'); toast.info($i18n.t('No content to speak')); - speaking = false; loadingSpeech = false; return; @@ -303,41 +296,43 @@ await $TTSWorker.init(); } - for (const [idx, sentence] of messageContentParts.entries()) { + for (const [, sentence] of messageContentParts.entries()) { + if (signal.aborted) return; + const url = await $TTSWorker - .generate({ - text: sentence, - voice: voiceId - }) + .generate({ text: sentence, voice: voiceId }) .catch((error) => { console.error(error); toast.error(`${error}`); - speaking = false; loadingSpeech = false; }); + if (signal.aborted) return; + if (url && speaking) { $audioQueue.enqueue(url); loadingSpeech = false; } } } else { - for (const [idx, sentence] of messageContentParts.entries()) { + for (const [, sentence] of messageContentParts.entries()) { + if (signal.aborted) return; + const res = await synthesizeOpenAISpeech(localStorage.token, voiceId, sentence).catch( (error) => { console.error(error); toast.error(`${error}`); - speaking = false; loadingSpeech = false; } ); + if (signal.aborted) return; + if (res && speaking) { const blob = await res.blob(); const url = URL.createObjectURL(blob); - $audioQueue.enqueue(url); loadingSpeech = false; } diff --git a/src/lib/utils/audio.ts b/src/lib/utils/audio.ts index 6effbc413..94da18af6 100644 --- a/src/lib/utils/audio.ts +++ b/src/lib/utils/audio.ts @@ -1,32 +1,39 @@ +type AudioQueueEvent = 'stop' | 'empty-queue' | 'id-change'; + +interface AudioQueueStopDetail { + event: AudioQueueEvent; + id: string | null; +} + +export type OnStoppedCallback = (detail: AudioQueueStopDetail) => void; + export class AudioQueue { - constructor(audioElement) { + private audio: HTMLAudioElement; + private queue: string[] = []; + private current: string | null = null; + private readonly _onEnded = () => this.next(); + + id: string | null = null; + onStopped: OnStoppedCallback | null = null; + + constructor(audioElement: HTMLAudioElement) { this.audio = audioElement; - this.queue = []; - this.current = null; - this.id = null; - - this._onEnded = () => this.next(); this.audio.addEventListener('ended', this._onEnded); - - this.onStopped = null; // optional callback } - setId(newId) { - console.log('Setting audio queue ID to:', newId); - if (this.id !== newId) { - this.stop(); - this.id = newId; - if (this.onStopped) this.onStopped({ event: 'id-change', id: newId }); - } + setId(newId: string) { + if (this.id === newId) return; + + this.#halt(); + this.id = newId; + this.onStopped?.({ event: 'id-change', id: newId }); } - setPlaybackRate(rate) { - console.log('Setting audio playback rate to:', rate); + setPlaybackRate(rate: number) { this.audio.playbackRate = rate; } - enqueue(url) { - console.log('Enqueuing audio URL:', url); + enqueue(url: string) { this.queue.push(url); // Auto-play if nothing is currently playing or loaded @@ -44,30 +51,37 @@ export class AudioQueue { } next() { - this.current = this.queue.shift(); + this.current = this.queue.shift() ?? null; + if (this.current) { this.audio.src = this.current; this.audio.play(); - console.log('Playing audio URL:', this.current); } else { - this.stop(); - if (this.onStopped) this.onStopped({ event: 'empty-queue', id: this.id }); + this.#halt(); + this.onStopped?.({ event: 'empty-queue', id: this.id }); } } stop() { + this.#halt(); + this.onStopped?.({ event: 'stop', id: this.id }); + } + + destroy() { + this.audio.removeEventListener('ended', this._onEnded); + this.#halt(); + this.onStopped = null; + } + + /** + * Pause audio and clear queue without firing onStopped. + * Callers that need the callback should invoke it themselves. + */ + #halt() { this.audio.pause(); this.audio.currentTime = 0; this.audio.src = ''; this.queue = []; this.current = null; - if (this.onStopped) this.onStopped({ event: 'stop', id: this.id }); - } - - destroy() { - this.audio.removeEventListener('ended', this._onEnded); - this.stop(); - this.onStopped = null; - this.audio = null; } }