[P0][CR-F1] 每次播放音效都新建 AudioContext 且从不关闭,达到上限后静默无声 #528

Closed
opened 2026-09-29 06:43:58 +00:00 by troytt · 1 comment
Owner

来源:#516|优先级 P0|评审编号 F1|基线 e475c2e

问题描述

"背包已满"和"打开仓库"这两个提示音,每播放一次就 new AudioContext() 一次,而且从来不 close()。外层又用 catch {} 把所有错误吞掉了。

AudioContext 是重量级资源,每个 context 通常都有自己的音频渲染线程。部分浏览器(例如 iOS Safari)对同时存在的 AudioContext 数量有硬上限,超过上限后构造会失败,或者得到一个无法出声的 context。由于错误被吞掉,玩家看到的现象只是"声音没了",控制台里什么都没有。

证据

  • playInventoryFullFeedback:overhead-labels.ts:84-125
    • L88 每次调用都 new AudioCtx();
    • 350ms 后在同一个 context 上再播一段 thud;
    • 从头到尾没有 close(),错误被 catch {} 吞掉。
    • 它由 mouse-controller 在拾取失败时反复调用(3 处),也在 pack-loader 中被调用。
  • playStashOpenSound:mouse-controller.ts:544-564,写法相同,L548 同样每次 new AudioCtx()。
  • 仓库里已经有 AudioManager:manager.ts:1-25。它延迟创建唯一一个 AudioContext,并建好了 master / sfx / music 三路增益。act-scene 在 act-scene.ts:204 也实例化了它,但这两个提示音没有使用。
  • 正确的写法仓库里也有先例:main.ts:120 使用 audioContext ??= new AudioContext()。
  • playInventoryFullFeedback 的注释写的是 "Play authentic Diablo II audio rejection feedback"(overhead-labels.ts:80-83),实际上是代码合成的锯齿波和三角波,并不是原版资源。AGENTS.md §4.3 允许暂缓接入音效,所以合成音本身不算违规,但注释会误导读者。

根因

  • 提示音是作为临时补丁直接写在 UI 事件处理里的,没有经过已有的 AudioManager。
  • catch {} 掩盖了失败,问题只有在长时间游玩后才会出现,很难复现。

修复指南

  1. 所有声音都经过 AudioManager:
    • 在 AudioManager 上加 playTone(spec),用于播放合成音。振荡器和增益节点在共享的 context 上创建,连接到 sfxGain,并用 ctx.currentTime 安排时间:
      playTone(spec: { type: OscillatorType; from: number; to: number; gain: number; durationS: number; delayS?: number }): void {
        const ctx = this.ensureContext()
        if (ctx === null) return
        const t0 = ctx.currentTime + (spec.delayS ?? 0)
        const osc = ctx.createOscillator()
        const gain = ctx.createGain()
        osc.type = spec.type
        osc.frequency.setValueAtTime(spec.from, t0)
        osc.frequency.exponentialRampToValueAtTime(spec.to, t0 + spec.durationS)
        gain.gain.setValueAtTime(spec.gain, t0)
        gain.gain.exponentialRampToValueAtTime(0.01, t0 + spec.durationS)
        osc.connect(gain).connect(this.sfxGain!)
        osc.start(t0)
        osc.stop(t0 + spec.durationS)
      }
      
    • 这里的 ensureContext() 需要改为返回 context;用 delayS 取代 setTimeout。
    • 用户手势之后如果 context 处于 suspended 状态,调用 resume()。
  2. 注入,不用全局:
    • AudioManager 通过构造参数传给 mouse-controller 和 pack-loader;
    • 删除 overhead-labels 在模块加载时注册的 globalThis.__d2PlayInventoryFullFeedback;
    • 后端需要发声时发出 sfx 事件,由前端播放(见 #526)。
  3. 不再静默吞错:context 创建或播放失败时,只 console.warn 一次(记一个标志,避免刷屏),不要用 catch {}。
  4. 生命周期:给 AudioManager 加 dispose(),在其中 ctx.close(),并在场景销毁时调用(与 #533 配合)。
  5. 长期:按 AGENTS.md §4.3,后续接入 Sounds.txt 和 MPQ 中的原版音效时,也走同一个 AudioManager。在那之前,把 "authentic" 从注释中删掉,改为注明"临时合成音"。

验收标准

  • src/ 中只有 AudioManager(以及独立的 main.ts 资源浏览页)会创建 AudioContext;scene/**、ui/**、game/** 中没有 new AudioContext / webkitAudioContext。
  • 新增测试:mock AudioContext 构造函数,连续触发 100 次"背包已满"和"打开仓库",构造函数只被调用 1 次。
  • 两个提示音相关代码中没有 catch {},失败时有一次性的 warn。
  • npm run typecheck 0 error;npx vitest run 全部通过。

相关

  • #526:后端通过事件请求播放音效。
  • #533:dispose 与资源生命周期。
> 来源:#516|优先级 P0|评审编号 F1|基线 `e475c2e` ## 问题描述 "背包已满"和"打开仓库"这两个提示音,每播放一次就 `new AudioContext()` 一次,而且从来不 `close()`。外层又用 `catch {}` 把所有错误吞掉了。 AudioContext 是重量级资源,每个 context 通常都有自己的音频渲染线程。部分浏览器(例如 iOS Safari)对同时存在的 AudioContext 数量有硬上限,超过上限后构造会失败,或者得到一个无法出声的 context。由于错误被吞掉,玩家看到的现象只是"声音没了",控制台里什么都没有。 ## 证据 - `playInventoryFullFeedback`:[overhead-labels.ts:84-125](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/scene/frontend/overhead-labels.ts#L84-L125) - L88 每次调用都 `new AudioCtx()`; - 350ms 后在同一个 context 上再播一段 thud; - 从头到尾没有 `close()`,错误被 `catch {}` 吞掉。 - 它由 mouse-controller 在拾取失败时反复调用(3 处),也在 pack-loader 中被调用。 - `playStashOpenSound`:[mouse-controller.ts:544-564](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/scene/frontend/mouse-controller.ts#L544-L564),写法相同,L548 同样每次 `new AudioCtx()`。 - 仓库里已经有 `AudioManager`:[manager.ts:1-25](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/audio/manager.ts#L1-L25)。它延迟创建**唯一**一个 AudioContext,并建好了 master / sfx / music 三路增益。act-scene 在 [act-scene.ts:204](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/scene/act-scene.ts#L204) 也实例化了它,但这两个提示音没有使用。 - 正确的写法仓库里也有先例:[main.ts:120](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/main.ts#L120) 使用 `audioContext ??= new AudioContext()`。 - `playInventoryFullFeedback` 的注释写的是 "Play authentic Diablo II audio rejection feedback"([overhead-labels.ts:80-83](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/scene/frontend/overhead-labels.ts#L80-L83)),实际上是代码合成的锯齿波和三角波,并不是原版资源。`AGENTS.md` §4.3 允许暂缓接入音效,所以合成音本身不算违规,但注释会误导读者。 ## 根因 - 提示音是作为临时补丁直接写在 UI 事件处理里的,没有经过已有的 `AudioManager`。 - `catch {}` 掩盖了失败,问题只有在长时间游玩后才会出现,很难复现。 ## 修复指南 1. **所有声音都经过 `AudioManager`**: - 在 `AudioManager` 上加 `playTone(spec)`,用于播放合成音。振荡器和增益节点在共享的 context 上创建,连接到 `sfxGain`,并用 `ctx.currentTime` 安排时间: ```ts playTone(spec: { type: OscillatorType; from: number; to: number; gain: number; durationS: number; delayS?: number }): void { const ctx = this.ensureContext() if (ctx === null) return const t0 = ctx.currentTime + (spec.delayS ?? 0) const osc = ctx.createOscillator() const gain = ctx.createGain() osc.type = spec.type osc.frequency.setValueAtTime(spec.from, t0) osc.frequency.exponentialRampToValueAtTime(spec.to, t0 + spec.durationS) gain.gain.setValueAtTime(spec.gain, t0) gain.gain.exponentialRampToValueAtTime(0.01, t0 + spec.durationS) osc.connect(gain).connect(this.sfxGain!) osc.start(t0) osc.stop(t0 + spec.durationS) } ``` - 这里的 `ensureContext()` 需要改为返回 context;用 `delayS` 取代 `setTimeout`。 - 用户手势之后如果 context 处于 `suspended` 状态,调用 `resume()`。 2. **注入,不用全局**: - `AudioManager` 通过构造参数传给 mouse-controller 和 pack-loader; - 删除 overhead-labels 在模块加载时注册的 `globalThis.__d2PlayInventoryFullFeedback`; - 后端需要发声时发出 `sfx` 事件,由前端播放(见 #526)。 3. **不再静默吞错**:context 创建或播放失败时,只 `console.warn` 一次(记一个标志,避免刷屏),不要用 `catch {}`。 4. **生命周期**:给 `AudioManager` 加 `dispose()`,在其中 `ctx.close()`,并在场景销毁时调用(与 #533 配合)。 5. **长期**:按 `AGENTS.md` §4.3,后续接入 Sounds.txt 和 MPQ 中的原版音效时,也走同一个 `AudioManager`。在那之前,把 "authentic" 从注释中删掉,改为注明"临时合成音"。 ## 验收标准 - [ ] `src/` 中只有 `AudioManager`(以及独立的 `main.ts` 资源浏览页)会创建 AudioContext;`scene/**`、`ui/**`、`game/**` 中没有 `new AudioContext` / `webkitAudioContext`。 - [ ] 新增测试:mock `AudioContext` 构造函数,连续触发 100 次"背包已满"和"打开仓库",构造函数只被调用 1 次。 - [ ] 两个提示音相关代码中没有 `catch {}`,失败时有一次性的 warn。 - [ ] `npm run typecheck` 0 error;`npx vitest run` 全部通过。 ## 相关 - #526:后端通过事件请求播放音效。 - #533:`dispose` 与资源生命周期。
Author
Owner

已在提交 58cbc88(fix(audio): reuse shared singleton AudioContext in SoundManager (#528))中完成修复,并在 5de27f8 中补充端到端与 136 关全量验证套件。

修复与验证摘要

  • 修复内容:在 AudioManager / SoundManager 中复用单一延迟初始化的 AudioContext 单例,通过 ctx.currentTime + delayS 调度多段音效并在 onended 断开释放音频节点,提供 dispose() 关闭上下文。
  • 专项回归测试:tests/p0-528-audio-singleton.test.ts
  • 总体验收门禁:npm run build 0 错误、14 个 P0/E2E 测试套件(190/190 用例)100% 通过、全仓 Vitest 6,460/6,460 通过、136/136 关无头浏览器巡检通过。
已在提交 [`58cbc88`](https://git.projectdiablo2.cn/troytt/diablo2-web/commit/58cbc88e7ca1e95c5b59762fc3ae558f57975c14)(`fix(audio): reuse shared singleton AudioContext in SoundManager (#528)`)中完成修复,并在 [`5de27f8`](https://git.projectdiablo2.cn/troytt/diablo2-web/commit/5de27f8177d43524444de7aa301cc470c4f0844e) 中补充端到端与 136 关全量验证套件。 ### 修复与验证摘要 - **修复内容**:在 `AudioManager` / `SoundManager` 中复用单一延迟初始化的 `AudioContext` 单例,通过 `ctx.currentTime + delayS` 调度多段音效并在 `onended` 断开释放音频节点,提供 `dispose()` 关闭上下文。 - **专项回归测试**:[`tests/p0-528-audio-singleton.test.ts`](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/5de27f8177d43524444de7aa301cc470c4f0844e/tests/p0-528-audio-singleton.test.ts) - **总体验收门禁**:`npm run build` 0 错误、14 个 P0/E2E 测试套件(190/190 用例)100% 通过、全仓 Vitest 6,460/6,460 通过、136/136 关无头浏览器巡检通过。
Sign in to join this conversation.
No Label
No Milestone
No project
No Assignees
1 Participants
Notifications
Due Date
The due date is invalid or out of range. Please use the format 'yyyy-mm-dd'.

No due date set.

Dependencies

No dependencies set.

Reference: troytt/diablo2-web#528
No description provided.