2026-05-07-audio-duration-and-replay-design.md 15 KB

音频真实时长 + DetailedReport 重播 设计文档

Date: 2026-05-07 Status: Draft Touches: cococlass-english-speaking-api(后端)+ PPT(前端)

1. 问题陈述

口语对话功能里,学生 / 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。

2. 设计目标

  • 学生消息全部 surface 显示真实时长(实时泡泡 + 历史泡泡 + 报告卡片),跨刷新可恢复。
  • AI 消息不显示时长(无 audio_url 可存、无真值可拿)。
  • DetailedReport 学生卡片可重播(点 play 按钮 → 加载 audioUrl → 播放)。
  • AI 卡片不接重播(quota 不可控,文字内容已足够)。
  • 老数据 audio_duration IS NULL 时前端显示 --:--,不阻塞 UI。
  • 后端是 audioDuration 唯一来源,避免双源数据漂移。

3. 数据流总览

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,前端显示 --:--。不写回填脚本——尚在开发阶段,老用户数据无回填价值。

4. 单一数据源(消除双源风险)

学生实时录音的 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 端计算:

  • 双源(recorder totalSamples / audioContext.sampleRate + 后端 WAV header)公式相同,但若 encodeWAV 写入的 sampleRate 与 audioContext.sampleRate 不一致(比如未来加 resampling),会出现"实时显示 X 秒、刷新后显示 Y 秒"的诡异 bug。
  • 单源后端:所有 surface 同一个值,无漂移可能。

regenerate 分支:复用已存在的 student message,message.audioDuration 早已写入;regenerate 路径不重复 push student、不需要新值。无影响。

5. 命名约定

全链路统一字段名 audioDuration / audio_duration,无单位后缀——值的单位是"秒"(float)。

  • 后端列名 audio_duration(snake_case)
  • JSON 字段 audioDuration(camelCase)
  • 前端类型:
    • HistoricalDialogueMessage.audioDuration?: number | null
    • PreviewChatMessage.audioDuration?: number
    • BackendRound.audioDuration?: number | null
    • SentenceEvaluation.audioDuration 已存在(types/englishSpeaking.ts:147),保持

理由:SentenceEvaluation.audioDuration 已是这个名字;统一命名避免一份数据三种叫法。禁止用 durationSec / audioDurationSec。

6. 后端改动(cococlass-english-speaking-api)

6.1 DB Migration

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。

6.2 Model

app/models/dialogue.py:DialogueMessage 加:

audio_duration: Mapped[Optional[float]] = mapped_column(Float, nullable=True)

6.3 WAV 时长解析(防御性工具函数)

新建 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

关键约束:

  • 先验 RIFF/WAVE magic bytes(防止协议未来切到 webm/opus 时炸库)
  • 任何异常都吞掉、返回 None、记 warning 不记 error
  • 调用方写入失败不阻塞 audio_url 入库

6.4 写入点(两处)

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 输出点(三处)

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 即可。

6.6 后端 unit test

新建 tests/test_audio_utils.py,覆盖 5 种 case:

  1. 合法 WAV(mock 一段 1s 16kHz 单声道 silent WAV)→ duration ≈ 1.0
  2. 空 bytes → None
  3. 非 RIFF magic(如 b"OPUS....." 开头)→ None
  4. 截断 WAV(前 30 字节)→ None
  5. sampleRate=0 的恶意 WAV → None

7. 前端改动(PPT)

7.1 类型扩展(src/types/englishSpeaking.ts)

// HistoricalDialogueMessage 加:
audioDuration?: number | null

// PreviewChatMessage 加:
audioDuration?: number

// BackendRound (在 services/llmService.ts 内部) 加:
audioDuration?: number | null

SentenceEvaluation.audioDuration 已存在,不动。

7.2 SSE 事件类型扩展(services/llmService.ts)

SSEEvent 的 transcript 分支加 audioDuration 字段:

| { type: 'transcript'; text: string; audioDuration?: number | null }

parser 把后端 JSON 的 audioDuration 字段透传出来。

7.3 useAudioRecorder.ts —— 不动签名

stopRecording() 维持 Promise<Blob>。recorder 端不计算 audioDuration。

7.4 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,第二次浏览器自带缓存命中。

7.5 useDialogueEngine.ts

  • sendStudentMessage 签名不变。在两个 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
      ...
      }
      
      • WebSocket onmessage handler(: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,

7.6 llmService.ts:adaptReport(:127-149)

return {
  ...
  audioUrl: r.audioUrl ?? undefined,
  audioDuration: r.audioDuration ?? undefined,   // NEW
  ...
}

7.7 DialogueChatView.vue

  • handleStopRecording(:778-783):不动(recorder 接口不变)
  • 模板学生泡泡(:139-140):

    <span class="voice-duration voice-duration-student">
    {{ formatDuration(message.audioDuration) }}
    </span>
    
    • 模板 AI 泡泡(: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')}` }

7.8 DetailedReport.vue

  • 引入 useAudioPlayer 实例(组件内部独立)
  • 学生卡片 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) }},无值显示 --:--
    • AI:删除整个 <span class="card-duration">
  • formatDuration 同 7.7 的实现,加 null 兜底。

8. 风险 & 缓解

风险 缓解
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 明确)

9. 验收标准

  1. 在 dev 环境录一条学生消息,泡泡显示真实时长(不是 0:04)。
  2. 录完不刷新继续录第二条 → 两条都显示各自真实时长。
  3. 不重启会话直接刷新页面,历史消息回填后泡泡时长仍正确显示。
  4. 跑完整对话进入 DetailedReport:学生卡片显示真时长 + 点 play 能听到学生录音;AI 卡片只显示文字,无时长、无按钮。
  5. 用浏览器打开升级前创建的旧会话历史,所有消息时长显示 --:--,无报错。
  6. 后端 parse_wav_duration unit test 全过(5 种 case)。
  7. 测一次 /speak partial hit(重发同一条)→ 时长仍正确返回。
  8. WebSocket /speak-stream 路径:录一条走 WS(默认 transport 就是 WS),泡泡时长正确。

10. 不在范围内

  • AI 消息时长(无 audio_url 可存、无真值,删除显示)
  • AI 重播(quota 不可控,DetailedReport AI 卡片不接)
  • 老数据回填(D 选择,开发阶段无价值)
  • AI bubble 在 DialogueChatView 的播放体验改造(已可工作,不动)
  • 自动化 E2E 测试(手动验证)
  • useAudioPlayer.url 模式的 blob 缓存(首版不加,依赖浏览器 CDN 缓存)

11. 实施顺序

  1. 后端 migration(独立 commit)
  2. 后端 model + audio_utils + 写入点(HTTP+WS)+ 输出点(SSE/sessions-latest/report)+ unit test(一个 commit)
  3. 前端类型补字段(services + types,独立 commit)
  4. 前端 useAudioPlayer URL 模式 + useDialogueEngine 写入 audioDuration + adaptReport(一个 commit)
  5. 前端 DialogueChatView 模板(commit)
  6. 前端 DetailedReport 模板 + 接 player(commit)
  7. 手动验收 8 条