| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158 |
- 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<Ref<string | null>>
- /** Id of the message whose synthesis or play() is in flight. */
- loadingId: Readonly<Ref<string | null>>
- /** Id of the message whose last playback attempt failed. Sticky until next play(). */
- errorId: Readonly<Ref<string | null>>
- play(id: string, source: PlaySource): Promise<void>
- 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<string | null>(null)
- const loadingId = ref<string | null>(null)
- const errorId = ref<string | null>(null)
- // Closure-private state. Not reactive on purpose.
- let currentAudio: HTMLAudioElement | null = null
- let synthAbort: AbortController | null = null
- const ttsCache = new Map<string, Blob>()
- 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<void> {
- // 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,
- }
- }
|