|
@@ -569,7 +569,8 @@ const phonemeDetail = ref<{
|
|
|
const silenceHintText = ref('')
|
|
const silenceHintText = ref('')
|
|
|
const showIdleHint = ref(false)
|
|
const showIdleHint = ref(false)
|
|
|
let idleHintTimer: ReturnType<typeof setTimeout> | null = null
|
|
let idleHintTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
-let playTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
|
|
|
+let currentAudio: HTMLAudioElement | null = null
|
|
|
|
|
+let currentAudioUrl: string | null = null
|
|
|
|
|
|
|
|
// 总用时计数(独立 UI 计时,与 engine.countdownSeconds 互斥显示)
|
|
// 总用时计数(独立 UI 计时,与 engine.countdownSeconds 互斥显示)
|
|
|
const totalSeconds = ref(0)
|
|
const totalSeconds = ref(0)
|
|
@@ -664,17 +665,34 @@ function stateStyle(target: string) {
|
|
|
// Actions
|
|
// Actions
|
|
|
// ─────────────────────────────────────────────
|
|
// ─────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
+// 当前流式会话控制器(开始录音时打开 WebSocket,结束时收尾)
|
|
|
|
|
+let streamCtl: ReturnType<typeof engine.beginStudentStream> | null = null
|
|
|
|
|
+
|
|
|
async function handleStartRecording() {
|
|
async function handleStartRecording() {
|
|
|
if (!engine.canRecord.value || recorder.isRecording.value) return
|
|
if (!engine.canRecord.value || recorder.isRecording.value) return
|
|
|
try {
|
|
try {
|
|
|
await recorder.startRecording()
|
|
await recorder.startRecording()
|
|
|
|
|
+ // 启动流式会话(立即 push UI 占位消息 + 打开 WebSocket)
|
|
|
|
|
+ streamCtl = engine.beginStudentStream({
|
|
|
|
|
+ sampleRate: recorder.sampleRate.value,
|
|
|
|
|
+ bits: 16,
|
|
|
|
|
+ channels: 1,
|
|
|
|
|
+ })
|
|
|
|
|
+ if (streamCtl) {
|
|
|
|
|
+ recorder.onChunk.value = streamCtl.pushChunk
|
|
|
|
|
+ }
|
|
|
} catch (err) {
|
|
} catch (err) {
|
|
|
console.error('Failed to start recording:', err)
|
|
console.error('Failed to start recording:', err)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function handleCancelRecording() {
|
|
function handleCancelRecording() {
|
|
|
- // 直接停止,不发送(丢弃本次录音)
|
|
|
|
|
|
|
+ // 停止录音 + 中止流式会话(丢弃本次录音)
|
|
|
|
|
+ recorder.onChunk.value = null
|
|
|
|
|
+ if (streamCtl) {
|
|
|
|
|
+ streamCtl.abortStream()
|
|
|
|
|
+ streamCtl = null
|
|
|
|
|
+ }
|
|
|
if (recorder.isRecording.value) {
|
|
if (recorder.isRecording.value) {
|
|
|
recorder.stopRecording().catch(() => {})
|
|
recorder.stopRecording().catch(() => {})
|
|
|
}
|
|
}
|
|
@@ -683,9 +701,29 @@ function handleCancelRecording() {
|
|
|
|
|
|
|
|
async function handleFinishRecording() {
|
|
async function handleFinishRecording() {
|
|
|
if (!recorder.isRecording.value) return
|
|
if (!recorder.isRecording.value) return
|
|
|
|
|
+ const ctl = streamCtl
|
|
|
|
|
+ streamCtl = null
|
|
|
try {
|
|
try {
|
|
|
const blob = await recorder.stopRecording()
|
|
const blob = await recorder.stopRecording()
|
|
|
- await engine.sendStudentMessage(blob)
|
|
|
|
|
|
|
+ recorder.onChunk.value = null
|
|
|
|
|
+ if (ctl) {
|
|
|
|
|
+ // 走 WebSocket 流式路径
|
|
|
|
|
+ ctl.finish()
|
|
|
|
|
+ // HTTP fallback:如果 WS 出错,engine.beginStudentStream 内部会把占位消息标为 error
|
|
|
|
|
+ // 这里监听 2 秒看状态,若被标为 error 则降级到 /speak
|
|
|
|
|
+ setTimeout(() => {
|
|
|
|
|
+ const sMsg = engine.messages.value.find(m => m.id === ctl.studentMsgId)
|
|
|
|
|
+ if (sMsg && sMsg.status === 'error') {
|
|
|
|
|
+ console.warn('[speak] WS failed, falling back to HTTP /speak')
|
|
|
|
|
+ engine.streamFallback(blob, ctl.studentMsgId, ctl.aiMsgId).catch(err => {
|
|
|
|
|
+ console.error('HTTP fallback also failed:', err)
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ }, 2000)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 没启动流式(异常情况)→ 直接走旧 HTTP 路径
|
|
|
|
|
+ await engine.sendStudentMessage(blob)
|
|
|
|
|
+ }
|
|
|
} catch (err) {
|
|
} catch (err) {
|
|
|
console.error('Recording/send failed:', err)
|
|
console.error('Recording/send failed:', err)
|
|
|
}
|
|
}
|
|
@@ -703,17 +741,60 @@ function toggleExpand(id: string) {
|
|
|
expandedMessageId.value = expandedMessageId.value === id ? null : id
|
|
expandedMessageId.value = expandedMessageId.value === id ? null : id
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+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) {
|
|
function togglePlay(id: string) {
|
|
|
|
|
+ // 已在播放 → 停止
|
|
|
if (playingMessageId.value === id) {
|
|
if (playingMessageId.value === id) {
|
|
|
- playingMessageId.value = null
|
|
|
|
|
- if (playTimer) clearTimeout(playTimer)
|
|
|
|
|
|
|
+ stopCurrentPlayback()
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
|
|
+ stopCurrentPlayback()
|
|
|
|
|
+
|
|
|
|
|
+ const msg = engine.messages.value.find(m => m.id === id)
|
|
|
|
|
+ if (!msg) return
|
|
|
|
|
+
|
|
|
playingMessageId.value = id
|
|
playingMessageId.value = id
|
|
|
- if (playTimer) clearTimeout(playTimer)
|
|
|
|
|
- playTimer = setTimeout(() => {
|
|
|
|
|
- if (playingMessageId.value === id) playingMessageId.value = null
|
|
|
|
|
- }, 3000)
|
|
|
|
|
|
|
+
|
|
|
|
|
+ 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
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function openPhonemeDetail(wa: NonNullable<NonNullable<PreviewChatMessage['evaluation']>['wordAnalysis']>[0]) {
|
|
function openPhonemeDetail(wa: NonNullable<NonNullable<PreviewChatMessage['evaluation']>['wordAnalysis']>[0]) {
|
|
@@ -885,8 +966,8 @@ onMounted(() => {
|
|
|
onUnmounted(() => {
|
|
onUnmounted(() => {
|
|
|
if (totalTimer) clearInterval(totalTimer)
|
|
if (totalTimer) clearInterval(totalTimer)
|
|
|
if (idleHintTimer) clearTimeout(idleHintTimer)
|
|
if (idleHintTimer) clearTimeout(idleHintTimer)
|
|
|
- if (playTimer) clearTimeout(playTimer)
|
|
|
|
|
if (badgeTimer) clearTimeout(badgeTimer)
|
|
if (badgeTimer) clearTimeout(badgeTimer)
|
|
|
|
|
+ stopCurrentPlayback()
|
|
|
})
|
|
})
|
|
|
</script>
|
|
</script>
|
|
|
|
|
|