Skip to content

fix(audio-cue): 重建/唤醒退化的 AudioContext,修「用久了提示音消失」#737

Open
appergb wants to merge 1 commit into
betafrom
fix/audio-cue-context-recovery
Open

fix(audio-cue): 重建/唤醒退化的 AudioContext,修「用久了提示音消失」#737
appergb wants to merge 1 commit into
betafrom
fix/audio-cue-context-recovery

Conversation

@appergb

@appergb appergb commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator

User description

问题

用户反馈:OpenLess 使用时间长了之后会没有音频——按下录音热键时的「开始提示音」消失,去 设置 → 录音与输入 点「试听」也没声音了。需要重启才恢复。

根因

提示音不打包音频文件,而是用 Web Audio API 即时合成(src/lib/audioCue.ts)。模块持有一个 AudioContext 单例 sharedCtx创建后从不检查、也从不恢复它的运行期状态

  • interrupted(最可能的 macOS 根因):本 app 高频录音,每次录音会抢占系统音频会话,WKWebView 的 AudioContext 因此可能转入 WebKit 非标准的 interrupted 态。旧代码 playRecordStartCue 只判断 ctx.state === 'suspended'interrupted 被当作可直接排期 → 节点排到一个非 running 的 ctx 上,无声、且根本不尝试 resume → 永久静默。
  • closed:ctx 也可能被系统关闭。closed 的 ctx 无法复活(resume() 必拒、createOscillator() 必抛、异常被 catch 静默吞掉),而 getContext() 从不重建它 → 永久静默。
  • 胶囊窗口与设置窗口是两个独立 webview,各持一份 sharedCtx,都会这样退化 → 「开始提示音」和「试听」同时失效,与现象吻合。

修复(surgical,公共 API 签名不变)

  • 新增可单测的纯函数 audioContextActionForState(state)closed → recreate / running → ready / 其余(suspendedinterrupted、任何未知非运行态)→ resume。单一真相。
  • getContext():当持有的 ctx 为 closed丢弃并重建(并清空挂在旧 ctx 上的 activeVoices,避免去叠音时操作失效节点)。
  • playRecordStartCue() / primeAudioCue() 改用该分类器,把 interrupted 一并按 resume 唤醒。

任何环境(无 Web Audio / 被自动播放策略挂起 / 创建失败)仍保持原有的静默降级,绝不抛错影响录音主流程。

影响面

src/components/AudioCue.tsx(胶囊监听)与 src/pages/settings/RecordingInputSection.tsx(试听按钮)复用这几个公共函数,签名与行为向后兼容。

测试

  • npx tsc --noEmit 通过(exit 0)。
  • src/lib/audioCue.test.ts 新增 5 条状态分类断言(closed/running/suspended/interrupted/未知态),npx tsx 跑全部通过。
  • 「用久了」的退化态依赖 Web Audio 运行时,单测覆盖不到,已用纯函数把可回归的判定逻辑钉住。

PR Type

Bug fix, Tests


Description

  • Add audioContextActionForState to handle closed, running, and interrupted states.

  • Recreate closed AudioContext in getContext; resume otherwise.

  • Update primeAudioCue and playRecordStartCue to use new classification.

  • Add unit tests for all state transitions.


Diagram Walkthrough

flowchart LR
    A["AudioContext state"] --> B{"audioContextActionForState"}
    B -- "closed" --> C["recreate"]
    B -- "running" --> D["ready"]
    B -- "suspended/interrupted/other" --> E["resume"]
    C --> F["Discard old ctx, create new"]
    E --> G["ctx.resume()"]
    D --> H["Schedule audio"]
Loading

File Walkthrough

Relevant files
Tests
audioCue.test.ts
Add unit tests for AudioContext state classification         

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

  • Added 5 test assertions for audioContextActionForState covering
    'closed', 'running', 'suspended', 'interrupted', and unknown states.
+34/-0   
Bug fix
audioCue.ts
Implement AudioContext state recovery logic                           

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

  • Added AudioContextAction type and audioContextActionForState function.
  • Modified getContext to recreate closed AudioContext.
  • Updated primeAudioCue and playRecordStartCue to use the new function,
    including handling of 'interrupted' state.
+32/-4   

长时间使用后「开始录音」提示音与设置页「试听」双双静音,且重启才恢复。

根因在 audioCue.ts 的 AudioContext 单例 sharedCtx 从不检查/恢复运行期状态:
- WKWebView/WebView2 在频繁录音抢占音频会话后,ctx 可能转入 WebKit 非标准的
  `interrupted` 态。旧代码只判断 `suspended`,interrupted 被当作可直接排期 →
  排到非 running 的 ctx 上,无声且不尝试 resume → 永久静默(macOS 最可能根因)。
- ctx 也可能被系统 `closed`。closed 无法复活(resume 必拒、createOscillator 必抛、
  被 catch 静默吞掉),而 getContext 从不重建 → 永久静默。
- 胶囊窗口与设置窗口各持一份 ctx,都会这样退化 → 两处提示音同时失效。

修复:
- 新增纯函数 audioContextActionForState(state):closed→recreate / running→ready /
  其余(suspended/interrupted/未知非运行态)→resume,单一真相、可单测。
- getContext 在 ctx 为 closed 时丢弃重建(并清空挂在旧 ctx 上的 activeVoices)。
- playRecordStartCue / primeAudioCue 改用该分类器,把 interrupted 一并按 resume 唤醒。

公共 API 签名不变,仅 AudioCue.tsx / RecordingInputSection.tsx 复用,行为向后兼容。
tsc --noEmit 通过;audioCue.test.ts 新增 5 条状态分类断言,全部通过。
@github-actions

Copy link
Copy Markdown
Contributor

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

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

@appergb

appergb commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator Author

@claude 请审查这条已合并目标为 beta 的修复 PR:重建/唤醒退化的 AudioContext,修「用久了提示音消失」。重点看 src/lib/audioCue.ts 的状态分类与重建逻辑是否正确、有无回归风险,以及对 AudioCue.tsx / RecordingInputSection.tsx 两个消费方是否向后兼容。CI 已全绿、含单测。

@claude

claude Bot commented Jun 22, 2026

Copy link
Copy Markdown

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


PR #737 审查:重建/唤醒退化的 AudioContext

  • 读取 CLAUDE.md(仓库未设置,跳过)
  • 读取 audioCue.ts 核心改动
  • 读取 audioCue.test.ts 单测
  • 读取消费方 AudioCue.tsxRecordingInputSection.tsx
  • 输出完整审查意见

总体评价

逻辑正确,实现精准,可以合并。 改动做到了 PR 描述中承诺的"surgical":仅改动必须改动的部分,不引入新的异步路径,不破坏现有 API。以下是详细分析。


audioContextActionForState 状态分类

// audioCue.ts:98-102
export function audioContextActionForState(state: string): AudioContextAction {
  if (state === 'closed') return 'recreate';
  if (state === 'running') return 'ready';
  return 'resume';
}

正确。 三分支覆盖了所有运行期状态:

  • closed → 必须重建(resume() 会被拒绝,createOscillator() 会抛异常)
  • running → 可直接排期
  • 其余(suspended、WebKit 非标准 interrupted、未来未知态)→ 保守走 resume 唤醒

参数类型用 string 而非 AudioContextState 是刻意的:"interrupted" 不在 TypeScript DOM typings 中,用 string 可在不失类型安全的情况下处理运行期的非标准值。✓


getContext() 重建逻辑

// audioCue.ts:116-119
if (sharedCtx && audioContextActionForState(sharedCtx.state) === 'recreate') {
  sharedCtx = null;
  activeVoices = [];
}

正确,且细节到位:

  1. activeVoices = [] 清理防止后续 stopVoices() 操作已失效节点。
  2. 重建后新的 AudioContext 初始状态为 suspended,会被 primeAudioCue / playRecordStartCue 正确走 resume 分支处理。
  3. getContext() 返回给调用方的 ctx 状态只会是 suspendedrunning永远不会是 closed,因此 playRecordStartCueaudioContextActionForState 的返回值只有 'ready''resume',不存在悬空的 'recreate' 分支。✓

primeAudioCueplayRecordStartCue 的改动

旧代码 新代码
条件 ctx.state === 'suspended' audioContextActionForState(ctx.state) === 'resume'
覆盖 suspended suspended + interrupted + 未知非运行态

功能向后兼容(suspended 路径行为不变),新增对 interrupted 的处理是修复 macOS 抢占根因的关键。

resume 异步路径中捕获的 ctx 引用不会过期:此时 ctx 不处于 closedgetContext() 不会在并发调用中将其替换(并发第二次调用会得到同一个 suspended ctx,由 playSeq 机制去重)。✓


向后兼容性验证

AudioCue.tsx(胶囊监听):

  • 导入 playRecordStartCueprimeAudioCuestopAudioCue — 签名与语义不变 ✓

RecordingInputSection.tsx(试听按钮):

  • 仅导入 playRecordStartCue — 签名与语义不变 ✓

两个消费方无需任何改动。


单测质量

audioCue.test.ts 新增的 5 条断言覆盖了所有关键路径:

断言 意义
closed → 'recreate' bug 回归钉子,闭合了「用久了没声音」的核心路径
running → 'ready' 正常路径不退化
suspended → 'resume' 原有唤醒路径保持
interrupted → 'resume' 新修复的 macOS 路径
unknown → 'resume' 保守兜底不静默

测试风格与仓库现有约定(轻量自执行断言,无外部 runner)一致。已有的 shouldPlayDeferredCue 边界测试(含"恰好等于阈值"的 elapsedMs === 400 用例)也保留完好。✓


无回归风险项

以下场景均经过分析,无回归:

  • 连按热键(快速录音)playSeq/stopSeq 机制不受本次改动影响。
  • stopVoices() 操作失效节点activeVoices 在重建时已清空,try/catch 静默保底。
  • primeAudioCue 遇到 closed ctxgetContext() 会先重建,再由 primeAudioCue 唤醒新 ctx,路径完整。
  • 无 Web Audio 环境getContext() 返回 null 的静默降级路径不变。

结论

修复思路清晰、代码精准、测试到位,向后兼容。建议合入 beta。

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