diff --git a/src/lib/components/chat/MessageInput/CallOverlay.svelte b/src/lib/components/chat/MessageInput/CallOverlay.svelte index 1340050a4a..209f63f508 100644 --- a/src/lib/components/chat/MessageInput/CallOverlay.svelte +++ b/src/lib/components/chat/MessageInput/CallOverlay.svelte @@ -456,21 +456,17 @@ }; audioElement.src = audio.src; - audioElement.muted = true; + // stopAllAudio mutes it; unmuting after play() outside a gesture makes WebKit pause it + audioElement.muted = false; audioElement.playbackRate = $settings.audio?.tts?.playbackRate ?? 1; audioElement.onended = finish; audioElement.onerror = () => finish(); audioElement.onpause = finish; - audioElement - .play() - .then(() => { - audioElement.muted = false; - }) - .catch((error) => { - console.error(error); - finish(error); - }); + audioElement.play().catch((error) => { + console.error(error); + finish(error); + }); }); } else { return Promise.resolve(); diff --git a/src/lib/components/chat/Messages/ResponseMessage.svelte b/src/lib/components/chat/Messages/ResponseMessage.svelte index 48af8d0304..fa7f7ab933 100644 --- a/src/lib/components/chat/Messages/ResponseMessage.svelte +++ b/src/lib/components/chat/Messages/ResponseMessage.svelte @@ -287,9 +287,14 @@ } else { $audioQueue.setId(`${message.id}`); $audioQueue.setPlaybackRate($settings.audio?.tts?.playbackRate ?? 1); - $audioQueue.onStopped = () => { + $audioQueue.onStopped = ({ event }) => { speaking = false; speakingIdx = undefined; + + if (event === 'error') { + speakAbort?.abort(); + toast.error($i18n.t('Audio playback failed')); + } }; loadingSpeech = true; diff --git a/src/lib/i18n/locales/en-US/translation.json b/src/lib/i18n/locales/en-US/translation.json index be708f6daf..c5aa62d0df 100644 --- a/src/lib/i18n/locales/en-US/translation.json +++ b/src/lib/i18n/locales/en-US/translation.json @@ -239,6 +239,7 @@ "Attach Notes": "", "Attach Webpage": "", "Attention to detail": "", + "Audio playback failed": "", "Audio player": "", "August": "", "Auth": "", diff --git a/src/lib/utils/audio.ts b/src/lib/utils/audio.ts index de36c9f9f2..238b61c3f2 100644 --- a/src/lib/utils/audio.ts +++ b/src/lib/utils/audio.ts @@ -1,10 +1,13 @@ -type AudioQueueEvent = 'stop' | 'empty-queue' | 'id-change'; +type AudioQueueEvent = 'stop' | 'empty-queue' | 'id-change' | 'error'; interface AudioQueueStopDetail { event: AudioQueueEvent; id: string | null; } +const SILENT_WAV = + 'data:audio/wav;base64,UklGRsQAAABXQVZFZm10IBAAAAABAAEAQB8AAIA+AAACABAAZGF0YaAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; + export type OnStoppedCallback = (detail: AudioQueueStopDetail) => void; export class AudioQueue { @@ -12,6 +15,7 @@ export class AudioQueue { private queue: string[] = []; private current: string | null = null; private readonly _onEnded = () => this.next(); + private readonly _onGesture = () => this.#unlock(); id: string | null = null; onStopped: OnStoppedCallback | null = null; @@ -19,6 +23,8 @@ export class AudioQueue { constructor(audioElement: HTMLAudioElement) { this.audio = audioElement; this.audio.addEventListener('ended', this._onEnded); + document.addEventListener('pointerdown', this._onGesture, true); + document.addEventListener('keydown', this._onGesture, true); } setId(newId: string) { @@ -50,12 +56,36 @@ export class AudioQueue { } } + /** Play silence inside the first user gesture so WebKit allows later programmatic playback. */ + #unlock() { + if (this.current || !this.audio.paused) return; + + this.audio.src = SILENT_WAV; + this.audio + .play() + .then(() => this.#removeGestureListeners()) + .catch(() => {}) + .finally(() => { + if (this.current) return; + + this.audio.pause(); + if (this.queue.length) this.next(); + }); + } + next() { this.current = this.queue.shift() ?? null; if (this.current) { - this.audio.src = this.current; - this.audio.play(); + const url = this.current; + this.audio.src = url; + this.audio.play().catch((error) => { + if (this.current !== url) return; + + console.error(error); + this.#halt(); + this.onStopped?.({ event: 'error', id: this.id }); + }); } else { this.#halt(); this.onStopped?.({ event: 'empty-queue', id: this.id }); @@ -69,10 +99,16 @@ export class AudioQueue { destroy() { this.audio.removeEventListener('ended', this._onEnded); + this.#removeGestureListeners(); this.#halt(); this.onStopped = null; } + #removeGestureListeners() { + document.removeEventListener('pointerdown', this._onGesture, true); + document.removeEventListener('keydown', this._onGesture, true); + } + /** * Pause audio and clear queue without firing onStopped. * Callers that need the callback should invoke it themselves.