import { ref, onUnmounted, type Ref } from 'vue' import { synthesize } from '../services/speechService' export type PlaySource = | { kind: 'tts'; text: string } | { kind: 'blob'; blob: Blob } export interface AudioPlayer { /** Id of the message currently playing (audio element fired `playing`). */ playingId: Readonly> /** Id of the message whose synthesis or play() is in flight. */ loadingId: Readonly> /** Id of the message whose last playback attempt failed. Sticky until next play(). */ errorId: Readonly> play(id: string, source: PlaySource): Promise stop(): void } /** * Single audio playback owner for the dialogue view. * * Structural guarantees: * - At most one HTMLAudioElement / one in-flight synthesis at a time. * Every play() begins by aborting & pausing the prior session. * - Cached MP3 blobs (one per AI messageId) live in-memory; URLs are * revoked on view unmount. * - Errors collapse to a single per-id state. The view renders a retry * affordance; clicking the same play button re-enters play(). */ export function useAudioPlayer(): AudioPlayer { const playingId = ref(null) const loadingId = ref(null) const errorId = ref(null) // Closure-private state. Not reactive on purpose. let currentAudio: HTMLAudioElement | null = null let synthAbort: AbortController | null = null const ttsCache = new Map() const cachedUrls: string[] = [] function clearCurrentAudio() { if (currentAudio) { currentAudio.onplaying = null currentAudio.onended = null currentAudio.onerror = null try { currentAudio.pause() } catch { /* ignore */ } currentAudio = null } } function failPlayback(id: string, reason: unknown) { if (loadingId.value === id) loadingId.value = null if (playingId.value === id) playingId.value = null errorId.value = id console.warn('[audio-player] playback failed:', id, reason) } async function play(id: string, source: PlaySource): Promise { // A fresh attempt — drop stale error. errorId.value = null // Abort any in-flight synthesis and pause any current audio. stop() loadingId.value = id try { let blob: Blob if (source.kind === 'blob') { blob = source.blob } else { const cached = ttsCache.get(id) if (cached) { blob = cached } else { synthAbort = new AbortController() blob = await synthesize(source.text, synthAbort.signal) synthAbort = null // We may have been interrupted while awaiting (loadingId changed). if (loadingId.value !== id) return ttsCache.set(id, blob) } } const url = URL.createObjectURL(blob) cachedUrls.push(url) const audio = new Audio(url) currentAudio = audio audio.onplaying = () => { if (loadingId.value === id) loadingId.value = null playingId.value = id } audio.onended = () => { if (currentAudio === audio) currentAudio = null if (playingId.value === id) playingId.value = null } audio.onerror = () => { if (currentAudio === audio) currentAudio = null failPlayback(id, audio.error) } try { await audio.play() } catch (err) { // Most often: autoplay policy blocked the call. if (currentAudio === audio) currentAudio = null failPlayback(id, err) } } catch (err) { // Synthesis path errors. AbortError fires when stop() was called // mid-synthesis — that is a normal interrupt, not a failure. synthAbort = null if (err instanceof Error && err.name === 'AbortError') { // loadingId was already cleared synchronously by stop(); if a newer // play() has since set it again, that value belongs to that call, // not this aborted one. Do nothing here. return } failPlayback(id, err) } } function stop(): void { if (synthAbort) { synthAbort.abort() synthAbort = null } clearCurrentAudio() loadingId.value = null playingId.value = null // Note: errorId is intentionally left alone here. It is only // cleared at the start of a new play() attempt. } onUnmounted(() => { stop() for (const url of cachedUrls) { try { URL.revokeObjectURL(url) } catch { /* ignore */ } } cachedUrls.length = 0 ttsCache.clear() }) return { playingId, loadingId, errorId, play, stop, } }