|
|
@@ -0,0 +1,156 @@
|
|
|
+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') {
|
|
|
+ if (loadingId.value === id) loadingId.value = null
|
|
|
+ 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,
|
|
|
+ }
|
|
|
+}
|