Date: 2026-05-07
Status: Draft
Touches: cococlass-english-speaking-api(后端)+ PPT(前端)
口语对话功能里,学生 / AI 语音泡泡的时长在多处呈现,但都是写死或假兜底:
| 表面 | 当前实现 | 现状 |
|---|---|---|
DialogueChatView 学生泡泡 |
模板写死 0:04 |
假数据 |
DialogueChatView AI 泡泡 |
模板写死 0:04 |
假数据 |
DetailedReport 学生卡片 |
audioDuration \|\| 3 兜底 |
字段类型存在但后端不下发,永远是 0:03 |
DetailedReport AI 卡片 |
同上 | 同上 |
DetailedReport 学生卡片 重播 |
<button @click.stop> 无 handler |
装饰,无功能 |
DetailedReport AI 卡片 重播 |
同上 | 装饰,无功能 |
根因:后端 dialogue_message 表只存 audio_url,从不计算/存储音频时长;前端 DetailedReport 也没接 useAudioPlayer。
audio_duration IS NULL 时前端显示 --:--,不阻塞 UI。| Surface | 时长来源 | 备注 |
|---|---|---|
DialogueChatView 学生泡泡(实时刚录) |
后端 /speak 的 SSE transcript 事件回传 audioDuration |
后端 transcript+duration 一起到,voice-bar 出现时即有值,无 UX 延迟(见 §4) |
DialogueChatView 学生泡泡(刷新后历史) |
后端 /sessions/latest 下发的 audioDuration |
跨刷新可用 |
DialogueChatView AI 泡泡 |
不显示(删 span) | 无 audio_url、TTS 即时合成 |
DetailedReport 学生卡片 |
后端 /report 下发的 audioDuration |
报告页只在会话结束后看 |
DetailedReport AI 卡片 |
不显示(删 span) | 同上 |
真值产生(后端):/speak(HTTP)和 /speak-stream(WebSocket)接到学生 WAV 时,从 RIFF header 解析出秒数,跟 audio_url 一并写入 dialogue_message.audio_duration。
老数据:忽略(D)。已存在的会话 audio_duration 保持 NULL,前端显示 --:--。不写回填脚本——尚在开发阶段,老用户数据无回填价值。
学生实时录音的 audioDuration 不在前端 recorder 计算,由后端 /speak 的 transcript 事件回传。
为什么这条路 UX 无代价:
DialogueChatView.vue:132 学生 voice-bar 模板带条件 v-if="message.content || message.status !== 'loading'"——录完点 stop 后到 transcript 事件到达之间,voice-bar 本来就不渲染(学生 typing-bubble 占位)。transcript 事件到达 → message.content 填入 → voice-bar 第一次渲染。把 audioDuration 一起塞进 transcript 事件,时长在 voice-bar 出现的同一帧就有值,视觉上等同"立即显示"。
为什么不用 recorder 端计算:
totalSamples / audioContext.sampleRate + 后端 WAV header)公式相同,但若 encodeWAV 写入的 sampleRate 与 audioContext.sampleRate 不一致(比如未来加 resampling),会出现"实时显示 X 秒、刷新后显示 Y 秒"的诡异 bug。regenerate 分支:复用已存在的 student message,message.audioDuration 早已写入;regenerate 路径不重复 push student、不需要新值。无影响。
全链路统一字段名 audioDuration / audio_duration,无单位后缀——值的单位是"秒"(float)。
audio_duration(snake_case)audioDuration(camelCase)HistoricalDialogueMessage.audioDuration?: number | nullPreviewChatMessage.audioDuration?: numberBackendRound.audioDuration?: number | nullSentenceEvaluation.audioDuration 已存在(types/englishSpeaking.ts:147),保持理由:SentenceEvaluation.audioDuration 已是这个名字;统一命名避免一份数据三种叫法。禁止用 durationSec / audioDurationSec。
cococlass-english-speaking-api)migrations/2026-05-07-add-dialogue-message-audio-duration.sql:
USE speaking;
ALTER TABLE dialogue_message
ADD COLUMN audio_duration FLOAT NULL AFTER audio_url;
Nullable、无默认值——老数据保持 NULL。
app/models/dialogue.py:DialogueMessage 加:
audio_duration: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
新建 app/service/speaking/audio_utils.py:
import io
import logging
import struct
import wave
logger = logging.getLogger(__name__)
def parse_wav_duration(audio_bytes: bytes) -> float | None:
"""
Parse a WAV bytes blob and return its duration in seconds.
Returns None on any of:
- empty / too-short bytes
- non-RIFF / non-WAVE magic
- corrupt header / parse exception
- sampleRate <= 0
Defensive: NEVER raises. Duration is a non-essential UX field; the
callsite must not let a parse failure block audio_url persistence.
"""
if len(audio_bytes) < 44:
return None
if audio_bytes[:4] != b"RIFF" or audio_bytes[8:12] != b"WAVE":
return None
try:
with wave.open(io.BytesIO(audio_bytes), "rb") as wf:
frames = wf.getnframes()
rate = wf.getframerate()
if rate <= 0 or frames < 0:
return None
return frames / rate
except (wave.Error, EOFError, struct.error, ValueError) as e:
logger.warning(f"parse_wav_duration failed: {e}")
return None
关键约束:
HTTP /speak(dialogue_service.py:527-535 附近):
audio_duration = parse_wav_duration(audio_bytes)
student_msg = DialogueMessage(
...
audio_url=audio_url,
audio_duration=audio_duration,
...
)
WebSocket /speak-stream(api/dialogue.py:545 附近):累积完整段 WAV bytes 后同样调 parse_wav_duration 写入。具体行号待 PLAN 阶段定位。
6.5.1 /speak SSE transcript 事件(dialogue_service.py:525 / :499)
当前 yield:
yield ("transcript", {"text": transcript, "round": current_round})
改为:
yield ("transcript", {
"text": transcript,
"round": current_round,
"audioDuration": audio_duration, # 可能为 None
})
两个 yield 点(partial hit :499 和 full miss :525)都要加;partial hit 时 audio_duration 从 student_row.audio_duration 取(已入库)。
6.5.2 /sessions/latest(dialogue_service.py:689 附近):
{
...
"audioUrl": msg.audio_url,
"audioDuration": msg.audio_duration, # NEW
}
6.5.3 /report(dialogue_service.py:927 附近):同样加 "audioDuration": msg.audio_duration。
6.5.4 WebSocket /speak-stream transcript 事件:与 HTTP §6.5.1 协议对齐——WS 的 transcript 事件 payload 也加 "audioDuration": audio_duration,前端只写一个 transcript handler 即可。
新建 tests/test_audio_utils.py,覆盖 5 种 case:
b"OPUS....." 开头)→ NonePPT)src/types/englishSpeaking.ts)// HistoricalDialogueMessage 加:
audioDuration?: number | null
// PreviewChatMessage 加:
audioDuration?: number
// BackendRound (在 services/llmService.ts 内部) 加:
audioDuration?: number | null
SentenceEvaluation.audioDuration 已存在,不动。
services/llmService.ts)SSEEvent 的 transcript 分支加 audioDuration 字段:
| { type: 'transcript'; text: string; audioDuration?: number | null }
parser 把后端 JSON 的 audioDuration 字段透传出来。
useAudioRecorder.ts —— 不动签名stopRecording() 维持 Promise<Blob>。recorder 端不计算 audioDuration。
useAudioPlayer.ts 扩 URL 模式export type PlaySource =
| { kind: 'tts'; text: string }
| { kind: 'blob'; blob: Blob }
| { kind: 'url'; url: string } // NEW
// play() 内部,url 分支:
if (source.kind === 'url') {
const res = await fetch(source.url, { signal: ... })
if (!res.ok) throw new Error(`fetch audio failed: ${res.status}`)
blob = await res.blob()
}
共用现有 currentAudio / 错误处理 / stop 逻辑。可加可选缓存(urlCache: Map<id, Blob>)避免重复 fetch,但首版不加——audioUrl 通常是 CDN,第二次浏览器自带缓存命中。
useDialogueEngine.tssendStudentMessage 签名不变。在两个 push student 的位置(HTTP :118-126 和 WebSocket :354-363)后续的 transcript 事件 handler 各加一行写入:
HTTP SSE handler(:144 附近的 if (event.type === 'transcript')):
if (event.type === 'transcript') {
studentMsg.content = event.text
studentMsg.status = 'done'
if (event.audioDuration != null) studentMsg.audioDuration = event.audioDuration
...
}
:425 附近的 if (data.type === 'transcript')):同样加 if (data.audioDuration != null) studentMsg.audioDuration = data.audioDuration。regenerate 路径不需要改——它只 push AI,不重新 push student;prevStudent.audioDuration 早已写入并保留。toPreviewMessage()(:33-43)把 message.audioDuration 映射进 PreviewChatMessage.audioDuration(覆盖刷新后历史消息):
ts
audioDuration: message.audioDuration ?? undefined,
llmService.ts:adaptReport(:127-149)return {
...
audioUrl: r.audioUrl ?? undefined,
audioDuration: r.audioDuration ?? undefined, // NEW
...
}
DialogueChatView.vuehandleStopRecording(:778-783):不动(recorder 接口不变)模板学生泡泡(:139-140):
<span class="voice-duration voice-duration-student">
{{ formatDuration(message.audioDuration) }}
</span>
:98-101):整段删掉 <span class="voice-duration voice-duration-ai">。formatDuration(seconds: number | null | undefined): string(在 <script setup> 内部新增):
ts
function formatDuration(seconds: number | null | undefined): string {
if (seconds == null || !Number.isFinite(seconds)) return '--:--'
const total = Math.round(seconds)
const mins = Math.floor(total / 60)
const secs = total % 60
return `${mins}:${String(secs).padStart(2, '0')}`
}
DetailedReport.vueuseAudioPlayer 实例(组件内部独立)学生卡片 card-play-btn(:33):
<button
class="card-play-btn play-student"
:disabled="!sentence.audioUrl"
@click.stop="handleSentencePlay(sentence)"
>
其中:
function handleSentencePlay(s: SentenceEvaluation) {
if (!s.audioUrl) return
player.play(s.id, { kind: 'url', url: s.audioUrl })
}
AI 卡片:整个 <button class="card-play-btn"> 删掉,只保留波形 + 文本。
时长 span(:48-50):
{{ formatDuration(sentence.audioDuration) }},无值显示 --:--<span class="card-duration">formatDuration 同 7.7 的实现,加 null 兜底。
| 风险 | 缓解 |
|---|---|
| WAV 解析失败 | RIFF magic + try/except 全包;返回 None 不阻塞入库;warning log |
| 协议未来切 webm/opus | RIFF 校验首关命中失败即 None,不会异常 |
前端老数据 audioDuration 是 null |
formatDuration null 兜底返回 --:--;模板 ?. 链不崩 |
| Migration 顺序 | 后端必须先部署(标准 backward-compatible nullable add) |
| TTS quota(AI 重播) | 范围内完全不接 AI 重播,DetailedReport AI 卡片纯文字 |
WS /speak-stream 路径漏改 |
设计明确两个写入点 + done 事件输出点都要改;执行时按 PLAN 任务清单逐个跟进 |
/speak partial hit 路径漏 audioDuration |
partial hit 时从已入库的 student_row.audio_duration 取(spec §6.5.1 明确) |
--:--,无报错。parse_wav_duration unit test 全过(5 种 case)。/speak partial hit(重发同一条)→ 时长仍正确返回。/speak-stream 路径:录一条走 WS(默认 transport 就是 WS),泡泡时长正确。useAudioPlayer.url 模式的 blob 缓存(首版不加,依赖浏览器 CDN 缓存)