Skip to content

fix(audioCue): 修复快速录音时录音提示音偶尔丢失#584

Merged
appergb merged 1 commit into
betafrom
fix/audio-cue-missing
Jun 4, 2026
Merged

fix(audioCue): 修复快速录音时录音提示音偶尔丢失#584
appergb merged 1 commit into
betafrom
fix/audio-cue-missing

Conversation

@appergb
Copy link
Copy Markdown
Collaborator

@appergb appergb commented Jun 4, 2026

User description

问题

新增的「录音开始提示音」功能里,快速点一下录音时提示音有时完全不响

根因

胶囊窗口的 AudioContext 在 WebView 里常处于 suspended,播放前必须异步 resume()(耗时几十毫秒)。2784e97 引入单个 playGeneration 计数器:等 resume() 期间若录音已结束(stopAudioCue 自增计数器),挂起的播放被无条件作废。本意是避免「录音停了提示音才姗姗来迟」,但副作用是——很短的录音(resume 没跑完就已结束)整段丢音。表现即「有时没声音」,取决于当时 ctx 是否挂起、录音是否够短。

修复(A 治本 + B 兜底)

A · 预热 AudioContextprimeAudioCue
胶囊挂载时就 resume(),让录音真正开始时 ctx 已是 runningplayRecordStartCue 走同步排期,从根上绕开 suspended→resume 的异步竞态。失败静默降级。

B · 双序号 + 迟到阈值
把单计数器拆成 playSeq(新一轮播放接管)和 stopSeq(期间发生过 stop),并加 400ms 迟到阈值。抽出纯函数 shouldPlayDeferredCue 决策:

情况 是否补播
被更新一轮播放接管 ❌(让新的来,防叠音)
期间停了 + resume 真迟到(>400ms) ❌(避免姗姗来迟)
期间停了 + resume 很快(≤400ms,快速录音 ✅ 补响一声
没被打断 ✅ 正常播放

A 处理常见路径,B 是 autoplay policy 下预热可能不生效时的兜底,两层互补。

测试

  • 新增 shouldPlayDeferredCue 纯函数单测(4 个分支 + 阈值边界共 5 例),运行时执行全部通过。
  • tsc --noEmit(CI 类型门禁)通过;无残留 playGeneration 引用。
  • 设置页「试听」按钮(独立 webview + 用户手势)不受影响。

影响面

仅触及 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 shouldPlayDeferredCue covering all decision branches.


Diagram Walkthrough

flowchart LR
  A["AudioContext state"] -->|"suspended"| B["Increment playSeq, record stopSeq and time"]
  B --> C["Call ctx.resume()"]
  C --> D["Resume completed"]
  D --> E["Check shouldPlayDeferredCue"]
  E -->|"superseded (myPlay != playSeq)"| F["Do not play"]
  E -->|"not superseded, stoppedWhileWaiting and elapsed > threshold"| F
  E -->|"otherwise (quick recording or no stop)"| G["Schedule cue voices"]
  A -->|"running"| H["Schedule cue voices"]
Loading

File Walkthrough

Relevant files
Enhancement
Capsule.tsx
Add primeAudioCue call on capsule mount                                   

openless-all/app/src/components/Capsule.tsx

  • Import primeAudioCue from audioCue.
  • Add useEffect to call primeAudioCue() on mount when in Tauri.
+8/-1     
Tests
audioCue.test.ts
Add tests for shouldPlayDeferredCue                                           

openless-all/app/src/lib/audioCue.test.ts

  • Import shouldPlayDeferredCue.
  • Add 5 test cases covering superseded, not interrupted, quick
    recording, genuinely late, and boundary at threshold.
+64/-1   
Bug fix
audioCue.ts
Refactor audio cue logic with dual sequences and pre‑warming

openless-all/app/src/lib/audioCue.ts

  • Replace playGeneration with playSeq and stopSeq.
  • Add primeAudioCue to pre‑warm AudioContext.
  • Add shouldPlayDeferredCue pure function with late threshold.
  • Update playRecordStartCue to use dual sequences and threshold.
  • Add nowMs() helper using performance.now().
+66/-12 

suspended 的 AudioContext 播放前需异步 resume。原实现用单个 playGeneration:快速点一下录音时 resume 还没完成、录音已结束(stopAudioCue 自增代号),挂起的播放被作废 → 提示音「有时没有」。

A 治本:新增 primeAudioCue(),胶囊挂载时预热 resume,让常见路径同步播放、绕开竞态。
B 兜底:拆 playSeq(新播放接管)/ stopSeq(发生过 stop)双序号 + 400ms 迟到阈值,抽出纯函数 shouldPlayDeferredCue 决策——被新播放接管→不播;停了且真迟到→不播;停了但 resume 很快(快速录音)→仍补响一声。

补 shouldPlayDeferredCue 单测(含边界);tsc --noEmit 通过。
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Jun 4, 2026

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 3 🔵🔵🔵⚪⚪
🧪 PR contains tests
🔒 No security concerns identified
⚡ No major issues detected

@appergb
Copy link
Copy Markdown
Collaborator Author

appergb commented Jun 4, 2026

@claude 请审核这个 PR(目标合并到 beta)。重点:录音提示音竞态修复——shouldPlayDeferredCue 决策(被新播放接管/真迟到丢弃/快速录音补响)是否正确;playSeq/stopSeq 双序号在并发快速按键下的时序;primeAudioCue 的 useEffect 与 autoplay policy 下 resume 被拒的静默降级。能否安全合并?给出分级。

@claude
Copy link
Copy Markdown

claude Bot commented Jun 4, 2026

Claude finished @appergb's task in 2m 45s —— View job


PR 代码审核:fix(audioCue) — 快速录音提示音丢失

  • 读取任务上下文
  • 阅读变更文件(audioCue.tsaudioCue.test.tsCapsule.tsx
  • 审查 shouldPlayDeferredCue 决策逻辑
  • 审查 playSeq/stopSeq 并发时序
  • 审查 primeAudioCue 与 autoplay policy 静默降级
  • 给出综合评级与合并建议

总体评级:✅ 可安全合并(beta)

逻辑正确、边界覆盖完整、失败路径静默降级,无残留 playGeneration 引用,建议合并。


shouldPlayDeferredCue 决策逻辑审查

结论:逻辑正确。 四条决策路径均与 PR 描述的意图一致:

条件 返回值 理由
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() + 自增 stopSeqscheduleCueVoices 内部调用的是 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,不在此覆盖,符合合理取舍。


细节观察(无阻塞)

  1. nowMs() fallback 方向正确performance 不存在时返回 0,导致 elapsedMs = 0≤ 400ms → 宁可补播也不丢音,与修复初衷一致。已有注释说明。✅
  2. 无残留 playGeneration 引用:全库扫描确认已完全移除。✅
  3. 设置页「试听」不受影响:试听走用户手势触发,ctx 不会处于 suspended 状态,直接进 scheduleCueVoices,与双序号逻辑无交集。✅

建议(可选,不影响合并)

  • 可在 primeAudioCue 中加对 ctx.state === 'closed' 的早退(当前实际上也走 if (ctx.state === 'suspended') 的 false 分支,行为正确;加注释或显式检查可增加可读性)——非必要。
  • 400ms 阈值是魔法数,当前已有常量 DEFERRED_CUE_LATE_THRESHOLD_MS,命名清晰,已足够。

@appergb
Copy link
Copy Markdown
Collaborator Author

appergb commented Jun 4, 2026

✅ 第二轮复审(typescript-reviewer agent):无 CRITICAL/HIGH。shouldPlayDeferredCue 决策正确(含阈值边界)、playSeq/stopSeq 并发时序正确、primeAudioCue resume 并发幂等安全、无残留 playGeneration、设置页试听不受影响。批准合并。

@appergb appergb merged commit 13a0a03 into beta Jun 4, 2026
4 checks passed
@appergb appergb deleted the fix/audio-cue-missing branch June 4, 2026 05:08
appergb pushed a commit that referenced this pull request Jun 4, 2026
… 快速录音提示音丢失修复(#584) + 正式发版自动更新 Homebrew cask(#586)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant