fix(audioCue): 修复快速录音时录音提示音偶尔丢失#584
Conversation
suspended 的 AudioContext 播放前需异步 resume。原实现用单个 playGeneration:快速点一下录音时 resume 还没完成、录音已结束(stopAudioCue 自增代号),挂起的播放被作废 → 提示音「有时没有」。 A 治本:新增 primeAudioCue(),胶囊挂载时预热 resume,让常见路径同步播放、绕开竞态。 B 兜底:拆 playSeq(新播放接管)/ stopSeq(发生过 stop)双序号 + 400ms 迟到阈值,抽出纯函数 shouldPlayDeferredCue 决策——被新播放接管→不播;停了且真迟到→不播;停了但 resume 很快(快速录音)→仍补响一声。 补 shouldPlayDeferredCue 单测(含边界);tsc --noEmit 通过。
PR Reviewer Guide 🔍Here are some key observations to aid the review process:
|
|
@claude 请审核这个 PR(目标合并到 beta)。重点:录音提示音竞态修复—— |
|
Claude finished @appergb's task in 2m 45s —— View job PR 代码审核:fix(audioCue) — 快速录音提示音丢失
总体评级:✅ 可安全合并(beta)逻辑正确、边界覆盖完整、失败路径静默降级,无残留
|
| 条件 | 返回值 | 理由 |
|---|---|---|
superseded: true(被更新播放接管) |
false |
优先级最高,无条件让位——正确 |
stoppedWhileWaiting && elapsed > 400ms |
false |
真迟到丢弃——正确 |
stoppedWhileWaiting && elapsed ≤ 400ms(快速录音) |
true |
核心修复点——正确 |
| 未被打断 | true |
正常播放——正确 |
边界值:阈值判断用 > 而非 >=,elapsed === 400 时仍播放,测试第 5 例已覆盖并命名清晰(cue at exactly the threshold still plays)。✅
设计选择合理:superseded 检查放在前,不受 elapsedMs 影响——即使新播放请求的 resume 也很快,旧的也不该抢先发声。
playSeq/stopSeq 并发时序审查
结论:并发语义正确。 JS 单线程事件循环消除了真正的竞态,以下两个典型场景均正确处理:
场景 A:快速连按两次(A→B 均在 suspended 分支)
Press A → playSeq=1, myPlayA=1, stopAtRequestA=0
Stop A → stopSeq=1
Press B → playSeq=2, myPlayB=2, stopAtRequestB=1
A.resume resolves → superseded: 1≠2 → 丢弃 ✅
B.resume resolves → superseded: 2=2(false), stoppedWhileWaiting: stopSeq≠1(=若B已停) → 按阈值决定 ✅
场景 B:单次快速录音(resume 还没完就已结束)
Press → playSeq=1, myPlay=1, stopAtRequest=0, requestedAt=0ms
Stop → stopSeq=1 (20ms后)
Resume resolves (80ms后) → superseded:false, stoppedWhileWaiting:true, elapsed=80ms ≤ 400ms → 补播 ✅
一个细节值得注意:stopAudioCue 调用 stopVoices() + 自增 stopSeq;scheduleCueVoices 内部调用的是 stopVoices()(不增 stopSeq)。注释已说明这是故意的设计——避免在同一轮播放内把自己的序号作废。逻辑正确。
stopSeq 无上界:作为计数器而非 Generation 不会溢出(JS Number 安全整数范围),可接受。
primeAudioCue + autoplay policy 静默降级审查
export function primeAudioCue(): void {
const ctx = getContext();
if (!ctx) return;
if (ctx.state === 'suspended') {
ctx.resume().catch(() => undefined); // 拒绝时静默
}
}useEffect(() => {
if (!isTauri) return;
primeAudioCue();
}, []);分析:
resume()被 autoplay policy 拒绝时:.catch(() => undefined)完全静默,不抛出,不影响录音流程。✅- 预热不生效(ctx 仍为 suspended)时:
playRecordStartCue进入if (ctx.state === 'suspended')分支,走playSeq/stopSeq兜底路径,两层机制互补。✅ primeAudioCue不捕获序号,不调度声音,纯粹是resume()热身——与playRecordStartCue的计数逻辑完全解耦。✅- 如果
primeAudioCue的 resume 尚未完成时playRecordStartCue也被调用:会触发第二次ctx.resume(),浏览器处理为幂等(合并或立即返回),primeAudioCue的.catch不会干扰playRecordStartCue的.then调度。✅ useEffect无需清理函数(primeAudioCue不持有订阅),返回undefined正确。✅
测试覆盖审查
5 个测试用例覆盖了纯函数的所有决策分支和边界:
| 用例名 | 覆盖场景 | 评价 |
|---|---|---|
superseded cue does not play |
被接管 | ✅ |
cue plays when nothing interrupted it |
正常播放(elapsed 远超阈值但无 stop) | ✅ |
quick recording still gets a slightly-late cue |
核心修复:快速录音补响 | ✅ |
genuinely late cue is dropped |
真迟到丢弃 | ✅ |
cue at exactly the threshold still plays |
边界值(> 而非 >=) |
✅ |
测试风格与仓库现有 .test.ts 一致(轻量自执行断言,tsx 可直接运行)。运行时播放行为依赖 Web Audio API,不在此覆盖,符合合理取舍。
细节观察(无阻塞)
nowMs()fallback 方向正确:performance不存在时返回 0,导致elapsedMs = 0,≤ 400ms→ 宁可补播也不丢音,与修复初衷一致。已有注释说明。✅- 无残留
playGeneration引用:全库扫描确认已完全移除。✅ - 设置页「试听」不受影响:试听走用户手势触发,ctx 不会处于 suspended 状态,直接进
scheduleCueVoices,与双序号逻辑无交集。✅
建议(可选,不影响合并)
- 可在
primeAudioCue中加对ctx.state === 'closed'的早退(当前实际上也走if (ctx.state === 'suspended')的 false 分支,行为正确;加注释或显式检查可增加可读性)——非必要。 - 400ms 阈值是魔法数,当前已有常量
DEFERRED_CUE_LATE_THRESHOLD_MS,命名清晰,已足够。
|
✅ 第二轮复审(typescript-reviewer agent):无 CRITICAL/HIGH。shouldPlayDeferredCue 决策正确(含阈值边界)、playSeq/stopSeq 并发时序正确、primeAudioCue resume 并发幂等安全、无残留 playGeneration、设置页试听不受影响。批准合并。 |
User description
问题
新增的「录音开始提示音」功能里,快速点一下录音时提示音有时完全不响。
根因
胶囊窗口的
AudioContext在 WebView 里常处于suspended,播放前必须异步resume()(耗时几十毫秒)。2784e97引入单个playGeneration计数器:等resume()期间若录音已结束(stopAudioCue自增计数器),挂起的播放被无条件作废。本意是避免「录音停了提示音才姗姗来迟」,但副作用是——很短的录音(resume 没跑完就已结束)整段丢音。表现即「有时没声音」,取决于当时 ctx 是否挂起、录音是否够短。修复(A 治本 + B 兜底)
A · 预热
AudioContext(primeAudioCue)胶囊挂载时就
resume(),让录音真正开始时 ctx 已是running,playRecordStartCue走同步排期,从根上绕开 suspended→resume 的异步竞态。失败静默降级。B · 双序号 + 迟到阈值
把单计数器拆成
playSeq(新一轮播放接管)和stopSeq(期间发生过 stop),并加 400ms 迟到阈值。抽出纯函数shouldPlayDeferredCue决策:A 处理常见路径,B 是 autoplay policy 下预热可能不生效时的兜底,两层互补。
测试
shouldPlayDeferredCue纯函数单测(4 个分支 + 阈值边界共 5 例),运行时执行全部通过。tsc --noEmit(CI 类型门禁)通过;无残留playGeneration引用。影响面
仅触及
audioCue.ts(播放/停止/预热)与Capsule.tsx(一个挂载预热 effect)。不碰录音主流程,全程静默降级、绝不抛错。PR Type
Bug fix, Tests
Description
Pre-warm AudioContext on capsule mount to avoid suspended→resume race condition.
Replace single playGeneration counter with dual playSeq/stopSeq and 400ms late threshold.
Add unit tests for
shouldPlayDeferredCuecovering all decision branches.Diagram Walkthrough
File Walkthrough
Capsule.tsx
Add primeAudioCue call on capsule mountopenless-all/app/src/components/Capsule.tsx
primeAudioCuefrom audioCue.useEffectto callprimeAudioCue()on mount when in Tauri.audioCue.test.ts
Add tests for shouldPlayDeferredCueopenless-all/app/src/lib/audioCue.test.ts
shouldPlayDeferredCue.recording, genuinely late, and boundary at threshold.
audioCue.ts
Refactor audio cue logic with dual sequences and pre‑warmingopenless-all/app/src/lib/audioCue.ts
playGenerationwithplaySeqandstopSeq.primeAudioCueto pre‑warmAudioContext.shouldPlayDeferredCuepure function with late threshold.playRecordStartCueto use dual sequences and threshold.nowMs()helper usingperformance.now().