|
|
@@ -49,7 +49,7 @@
|
|
|
<!-- 音频条 -->
|
|
|
<div v-if="message.content || message.status === 'done'" class="voice-bar voice-ai">
|
|
|
<button class="play-btn play-ai" @click="togglePlay(message.id)">
|
|
|
- <svg v-if="playingMessageId !== message.id" width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
|
|
+ <svg v-if="player.playingId.value !== message.id" width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
|
|
<polygon points="5 3 19 12 5 21 5 3" />
|
|
|
</svg>
|
|
|
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
|
|
@@ -105,7 +105,7 @@
|
|
|
/>
|
|
|
</div>
|
|
|
<button class="play-btn play-student" @click="togglePlay(message.id)">
|
|
|
- <svg v-if="playingMessageId !== message.id" width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
|
|
+ <svg v-if="player.playingId.value !== message.id" width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
|
|
<polygon points="5 3 19 12 5 21 5 3" />
|
|
|
</svg>
|
|
|
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
|
|
@@ -427,6 +427,7 @@ import type { PropType } from 'vue'
|
|
|
import type { PreviewChatMessage, BadgeAchievement, DialogueReport, SessionStartInfo, TaskHint } from '@/types/englishSpeaking'
|
|
|
import { useDialogueEngine } from '../composables/useDialogueEngine'
|
|
|
import { useAudioRecorder } from '../composables/useAudioRecorder'
|
|
|
+import { useAudioPlayer } from '../composables/useAudioPlayer'
|
|
|
import TaskHintModal from './TaskHintModal.vue'
|
|
|
import { createDialogueApi } from '../services/llmService'
|
|
|
|
|
|
@@ -485,6 +486,7 @@ const BADGE_CONFIG: Record<string, BadgeAchievement> = {
|
|
|
|
|
|
const engine = useDialogueEngine()
|
|
|
const recorder = useAudioRecorder()
|
|
|
+const player = useAudioPlayer()
|
|
|
|
|
|
// ─────────────────────────────────────────────
|
|
|
// Local UI State
|
|
|
@@ -492,7 +494,6 @@ const recorder = useAudioRecorder()
|
|
|
|
|
|
const chatContainerRef = ref<HTMLDivElement>()
|
|
|
const expandedMessageId = ref<string | null>(null)
|
|
|
-const playingMessageId = ref<string | null>(null)
|
|
|
const showHintModal = ref(false)
|
|
|
const taskHint = ref<TaskHint | null>(null)
|
|
|
const taskHintLoading = ref(false)
|
|
|
@@ -509,9 +510,6 @@ const phonemeDetail = ref<{
|
|
|
const silenceHintText = ref('')
|
|
|
const showIdleHint = ref(false)
|
|
|
let idleHintTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
-let currentAudio: HTMLAudioElement | null = null
|
|
|
-let currentAudioUrl: string | null = null
|
|
|
-
|
|
|
|
|
|
// 徽章
|
|
|
const showBadge = ref<BadgeAchievement | null>(null)
|
|
|
@@ -607,6 +605,7 @@ let streamCtl: ReturnType<typeof engine.beginStudentStream> | null = null
|
|
|
|
|
|
async function handleStartRecording() {
|
|
|
if (!engine.canRecord.value || recorder.isRecording.value) return
|
|
|
+ player.stop()
|
|
|
try {
|
|
|
await recorder.startRecording()
|
|
|
// 启动流式会话(立即 push UI 占位消息 + 打开 WebSocket)
|
|
|
@@ -644,7 +643,7 @@ async function handleFinishRecording() {
|
|
|
const blob = await recorder.stopRecording()
|
|
|
recorder.onChunk.value = null
|
|
|
if (ctl) {
|
|
|
- // 走 WebSocket 流式路径。WS 失败时,不自动降级 HTTP —— 让错误泡泡的"重试"按钮走人工路径。
|
|
|
+ engine.attachStudentBlob(ctl.studentMsgId, blob)
|
|
|
ctl.finish()
|
|
|
} else {
|
|
|
// 没启动流式(异常情况)→ 直接走旧 HTTP 路径
|
|
|
@@ -713,59 +712,21 @@ async function loadTaskHint() {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-function stopCurrentPlayback() {
|
|
|
- if (currentAudio) {
|
|
|
- currentAudio.pause()
|
|
|
- currentAudio = null
|
|
|
- }
|
|
|
- if (currentAudioUrl) {
|
|
|
- URL.revokeObjectURL(currentAudioUrl)
|
|
|
- currentAudioUrl = null
|
|
|
- }
|
|
|
- if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel()
|
|
|
- playingMessageId.value = null
|
|
|
-}
|
|
|
-
|
|
|
function togglePlay(id: string) {
|
|
|
- // 已在播放 → 停止
|
|
|
- if (playingMessageId.value === id) {
|
|
|
- stopCurrentPlayback()
|
|
|
+ // Same id is currently playing or loading → stop.
|
|
|
+ if (player.playingId.value === id || player.loadingId.value === id) {
|
|
|
+ player.stop()
|
|
|
return
|
|
|
}
|
|
|
- stopCurrentPlayback()
|
|
|
|
|
|
const msg = engine.messages.value.find(m => m.id === id)
|
|
|
if (!msg) return
|
|
|
|
|
|
- playingMessageId.value = id
|
|
|
-
|
|
|
if (msg.role === 'student' && msg.audioBlob) {
|
|
|
- // 学生消息:播放录音 Blob
|
|
|
- currentAudioUrl = URL.createObjectURL(msg.audioBlob)
|
|
|
- currentAudio = new Audio(currentAudioUrl)
|
|
|
- const clearIfSame = () => {
|
|
|
- if (playingMessageId.value === id) stopCurrentPlayback()
|
|
|
- }
|
|
|
- currentAudio.onended = clearIfSame
|
|
|
- currentAudio.onerror = clearIfSame
|
|
|
- currentAudio.play().catch((err) => {
|
|
|
- console.error('[audio] play failed:', err)
|
|
|
- clearIfSame()
|
|
|
- })
|
|
|
- } else if (msg.role === 'ai' && msg.content && typeof speechSynthesis !== 'undefined') {
|
|
|
- // AI 消息:用浏览器 TTS 重读
|
|
|
- const utter = new SpeechSynthesisUtterance(msg.content)
|
|
|
- utter.lang = 'en-US'
|
|
|
- utter.rate = 0.9
|
|
|
- const clearIfSame = () => {
|
|
|
- if (playingMessageId.value === id) playingMessageId.value = null
|
|
|
- }
|
|
|
- utter.onend = clearIfSame
|
|
|
- utter.onerror = clearIfSame
|
|
|
- speechSynthesis.speak(utter)
|
|
|
- } else {
|
|
|
- // 无可播内容,直接复位
|
|
|
- playingMessageId.value = null
|
|
|
+ player.play(id, { kind: 'blob', blob: msg.audioBlob })
|
|
|
+ }
|
|
|
+ else if (msg.role === 'ai' && msg.content) {
|
|
|
+ player.play(id, { kind: 'tts', text: msg.content })
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -801,6 +762,7 @@ async function fetchReportSafe(): Promise<DialogueReport | null> {
|
|
|
function handleRestart() {
|
|
|
showExitConfirm.value = false
|
|
|
engine.abort()
|
|
|
+ player.stop()
|
|
|
emit('restart')
|
|
|
}
|
|
|
|
|
|
@@ -926,7 +888,6 @@ onMounted(() => {
|
|
|
onUnmounted(() => {
|
|
|
if (idleHintTimer) clearTimeout(idleHintTimer)
|
|
|
if (badgeTimer) clearTimeout(badgeTimer)
|
|
|
- stopCurrentPlayback()
|
|
|
})
|
|
|
</script>
|
|
|
|