Bläddra i källkod

feat: add useAudioPlayer composable

Sole audio playback owner with structural single-channel
guarantee, per-id error surface, and synthesis result cache.
Calls speechService.synthesize for kind:'tts' sources and
plays kind:'blob' sources directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
jimmylee 1 månad sedan
förälder
incheckning
cd1c0b8065
1 ändrade filer med 156 tillägg och 0 borttagningar
  1. 156 0
      src/views/Editor/EnglishSpeaking/composables/useAudioPlayer.ts

+ 156 - 0
src/views/Editor/EnglishSpeaking/composables/useAudioPlayer.ts

@@ -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,
+  }
+}