فهرست منبع

refactor: remove TTS ownership from dialogue engine

Drop speakTTS / cancelTTS / ttsUtterance and all four call
sites — the engine no longer touches speechSynthesis. Add
attachStudentBlob so the WebSocket path can attach the final
recorded blob to its student message. Drop the now-unused
engine.cancelTTS() call from DialogueChatView.handleRestart.

Auto-play is restored in a follow-up commit that wires the
new view-level audio player.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
jimmylee 2 هفته پیش
والد
کامیت
523edfbf4d

+ 12 - 27
src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts

@@ -13,7 +13,6 @@ export function useDialogueEngine() {
   const api: DialogueAPI = createDialogueApi()
   const api: DialogueAPI = createDialogueApi()
   let currentAbortController: AbortController | null = null
   let currentAbortController: AbortController | null = null
   let countdownTimer: ReturnType<typeof setInterval> | null = null
   let countdownTimer: ReturnType<typeof setInterval> | null = null
-  let ttsUtterance: SpeechSynthesisUtterance | null = null
 
 
   const isProcessing = computed(() => messages.value.some(m => m.status === 'loading'))
   const isProcessing = computed(() => messages.value.some(m => m.status === 'loading'))
   const canRecord = computed(() => !isProcessing.value && !isComplete.value)
   const canRecord = computed(() => !isProcessing.value && !isComplete.value)
@@ -53,7 +52,6 @@ export function useDialogueEngine() {
       const { aiMessage } = await api.generateGreeting(sessionId.value, greetingAbortController.signal)
       const { aiMessage } = await api.generateGreeting(sessionId.value, greetingAbortController.signal)
       aiMsg.content = aiMessage
       aiMsg.content = aiMessage
       aiMsg.status = 'done'
       aiMsg.status = 'done'
-      speakTTS(aiMessage)
     } catch (err: unknown) {
     } catch (err: unknown) {
       if (err instanceof Error && err.name === 'AbortError') return  // 组件卸载:不改 UI
       if (err instanceof Error && err.name === 'AbortError') return  // 组件卸载:不改 UI
       aiMsg.status = 'error'
       aiMsg.status = 'error'
@@ -120,8 +118,6 @@ export function useDialogueEngine() {
           if (!event.isComplete) {
           if (!event.isComplete) {
             currentRound.value++
             currentRound.value++
           }
           }
-
-          speakTTS(aiMsg.content)
         }
         }
       }
       }
 
 
@@ -198,7 +194,6 @@ export function useDialogueEngine() {
           aiMsg.status = 'done'
           aiMsg.status = 'done'
           isComplete.value = event.isComplete
           isComplete.value = event.isComplete
           if (!event.isComplete) currentRound.value++
           if (!event.isComplete) currentRound.value++
-          speakTTS(aiMsg.content)
         }
         }
       }
       }
 
 
@@ -239,25 +234,6 @@ export function useDialogueEngine() {
     await api.completeSession(sessionId.value)
     await api.completeSession(sessionId.value)
   }
   }
 
 
-  // ==================== TTS ====================
-
-  function speakTTS(text: string) {
-    if (!text || typeof speechSynthesis === 'undefined') return
-
-    cancelTTS()
-    ttsUtterance = new SpeechSynthesisUtterance(text)
-    ttsUtterance.lang = 'en-US'
-    ttsUtterance.rate = 0.9
-    speechSynthesis.speak(ttsUtterance)
-  }
-
-  function cancelTTS() {
-    if (typeof speechSynthesis !== 'undefined') {
-      speechSynthesis.cancel()
-    }
-    ttsUtterance = null
-  }
-
   // ==================== Countdown ====================
   // ==================== Countdown ====================
 
 
   function startCountdown(expiresAtStr: string) {
   function startCountdown(expiresAtStr: string) {
@@ -366,7 +342,6 @@ export function useDialogueEngine() {
           aiMsg.status = 'done'
           aiMsg.status = 'done'
           isComplete.value = !!data.isComplete
           isComplete.value = !!data.isComplete
           if (!data.isComplete) currentRound.value++
           if (!data.isComplete) currentRound.value++
-          speakTTS(aiMsg.content)
           ws.close()
           ws.close()
         }
         }
         else if (data.type === 'error') {
         else if (data.type === 'error') {
@@ -421,12 +396,22 @@ export function useDialogueEngine() {
     await sendStudentMessage(audioBlob)
     await sendStudentMessage(audioBlob)
   }
   }
 
 
+  /**
+   * Attach the recorded audio blob to a student message that was
+   * pushed by `beginStudentStream` (which doesn't know the final
+   * blob until the user clicks 完成). Lets click-replay work in
+   * the WebSocket path the same way it does for HTTP.
+   */
+  function attachStudentBlob(messageId: string, blob: Blob) {
+    const msg = messages.value.find(m => m.id === messageId)
+    if (msg && msg.role === 'student') msg.audioBlob = blob
+  }
+
   // ==================== Cleanup ====================
   // ==================== Cleanup ====================
 
 
   onUnmounted(() => {
   onUnmounted(() => {
     abort()
     abort()
     greetingAbortController?.abort()
     greetingAbortController?.abort()
-    cancelTTS()
     stopCountdown()
     stopCountdown()
   })
   })
 
 
@@ -451,7 +436,7 @@ export function useDialogueEngine() {
     getReport,
     getReport,
     completeSession,
     completeSession,
     abort,
     abort,
-    cancelTTS,
+    attachStudentBlob,
   }
   }
 }
 }
 
 

+ 0 - 1
src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue

@@ -801,7 +801,6 @@ async function fetchReportSafe(): Promise<DialogueReport | null> {
 function handleRestart() {
 function handleRestart() {
   showExitConfirm.value = false
   showExitConfirm.value = false
   engine.abort()
   engine.abort()
-  engine.cancelTTS()
   emit('restart')
   emit('restart')
 }
 }