fix(audio): harden playback lifecycle#3026
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
Walkthrough
ChangesAudio Pending Playback and Lifecycle Recovery
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/core/src/audio/AudioManager.ts`:
- Around line 39-56: AudioManager.resume() currently resumes and replays
pending/interrupted sources even when the document is hidden because it ignores
the internal _hidden flag; update resume() (and the similar branch around the
later block noted) to check AudioManager._hidden and, if true, avoid calling
_resumePendingSources() and _resumeInterruptedSources() (but still clear
foreground restore flags as appropriate), deferring actual source resumes until
_onShown() runs; reference the AudioManager.resume method, the _hidden field,
_onShown, _resumePendingSources, _resumeInterruptedSources, and
_needsUserGestureResume when making the change.
- Around line 121-129: The on-state-change handler _onContextStateChange
currently only handles transitions into "running"; update it to also detect
transitions out of "running" and move currently playing sources into the
interrupted set so they can be resumed later. Specifically, when
AudioManager._context?.state changes from "running" to a non-running state (and
not via AudioManager.suspend()), iterate over AudioManager._playingSources and
invoke each source's _suspendPlaybackForInterruption (or mark them into
AudioManager._interruptedSources), removing them from _playingSources; keep the
existing resume logic that calls _resumePendingSources and
_resumeInterruptedSources when state becomes "running" so
_resumeInterruptedPlayback can replay them. Ensure this logic lives inside
_onContextStateChange and references AudioManager._playingSources,
AudioManager._interruptedSources, AudioSource._suspendPlaybackForInterruption
and AudioSource._resumeInterruptedPlayback accordingly.
In `@packages/loader/src/AudioLoader.ts`:
- Line 12: The decorator on AudioLoader (`@resourceLoader`(AssetType.Audio,
["mp3", "ogg", "wav", "audio", "m4a", "aac", "flac"])) includes a non-standard
"audio" extension; either remove "audio" from that extensions array to avoid
incorrect mapping, or if "audio" is a deliberate alias add documentation and an
example asset demonstrating its usage and update any relevant docs/metadata
accordingly so the intent is clear. Ensure the change is made on the
`@resourceLoader` call associated with the AudioLoader class and keep the
remaining extensions unchanged.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: ea1c626e-c43c-46b2-8aa6-d3b222b79624
📒 Files selected for processing (5)
packages/core/src/audio/AudioManager.tspackages/core/src/audio/AudioSource.tspackages/loader/src/AudioLoader.tstests/src/core/audio/AudioSource.test.tstests/src/core/audio/AudioSourcePendingPlayback.test.ts
| private static _onContextStateChange(): void { | ||
| if (AudioManager._context?.state === "running") { | ||
| if (AudioManager._hidden || AudioManager._needsUserGestureResume) { | ||
| return; | ||
| } | ||
| AudioManager._needsUserGestureResume = false; | ||
| AudioManager._resumePendingSources(); | ||
| AudioManager._resumeInterruptedSources(); | ||
| } |
There was a problem hiding this comment.
Handle non-running context transitions here too.
Line 121 only reacts when the context comes back to "running". If the browser moves the shared AudioContext out of running without going through AudioManager.suspend(), _playingSources never gets converted into _interruptedSources, so Line 128 has nothing to replay when the context recovers.
Cross-file evidence: packages/core/src/audio/AudioSource.ts:242-293 provides _suspendPlaybackForInterruption() / _resumeInterruptedPlayback(), but this file only drives the suspend half from AudioManager.suspend(), not from onstatechange.
Suggested fix
private static _onContextStateChange(): void {
- if (AudioManager._context?.state === "running") {
- if (AudioManager._hidden || AudioManager._needsUserGestureResume) {
- return;
- }
- AudioManager._needsUserGestureResume = false;
- AudioManager._resumePendingSources();
- AudioManager._resumeInterruptedSources();
- }
+ const state = AudioManager._context?.state;
+ if (state === "interrupted" || state === "suspended") {
+ AudioManager._suspendActiveSourcesForInterruption();
+ return;
+ }
+
+ if (state === "running") {
+ if (AudioManager._hidden || AudioManager._needsUserGestureResume) {
+ return;
+ }
+ AudioManager._needsUserGestureResume = false;
+ AudioManager._resumePendingSources();
+ AudioManager._resumeInterruptedSources();
+ }
}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/core/src/audio/AudioManager.ts` around lines 121 - 129, The
on-state-change handler _onContextStateChange currently only handles transitions
into "running"; update it to also detect transitions out of "running" and move
currently playing sources into the interrupted set so they can be resumed later.
Specifically, when AudioManager._context?.state changes from "running" to a
non-running state (and not via AudioManager.suspend()), iterate over
AudioManager._playingSources and invoke each source's
_suspendPlaybackForInterruption (or mark them into
AudioManager._interruptedSources), removing them from _playingSources; keep the
existing resume logic that calls _resumePendingSources and
_resumeInterruptedSources when state becomes "running" so
_resumeInterruptedPlayback can replay them. Ensure this logic lives inside
_onContextStateChange and references AudioManager._playingSources,
AudioManager._interruptedSources, AudioSource._suspendPlaybackForInterruption
and AudioSource._resumeInterruptedPlayback accordingly.
…end/resume - Remove interrupted source mechanism (no source node destruction on hide) - Trust context.suspend/resume to keep nodes alive (Phaser pattern) - Add onstatechange non-running branch for external interruption recovery - Fix resume() to create context for pre-unlock use case - Reduce gesture listeners to pointerup+click, remove after unlock - Simplify iOS zombie fix to suspend→100ms→resume - Fix AudioSource.test.ts import to use @galacean/engine (CI compat)
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## dev/2.0 #3026 +/- ##
===========================================
+ Coverage 77.48% 79.22% +1.74%
===========================================
Files 914 914
Lines 101783 101799 +16
Branches 10430 11273 +843
===========================================
+ Hits 78862 80652 +1790
+ Misses 22738 20962 -1776
- Partials 183 185 +2
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
…ension - Add _hidden check in _resumePendingSources to prevent source replay while hidden - Initialize _hidden from document.hidden when context is created - Remove ".audio" from AudioLoader extensions (Editor doesn't export .audio URLs)
…n CI Mock AudioContext's suspend/resume were triggering onstatechange synchronously, causing recursive call stacks when event dispatch + state change + gesture listener interact in the same synchronous frame during CI browser tests.
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
tests/src/core/audio/AudioSourcePendingPlayback.test.ts (1)
45-49:⚠️ Potential issue | 🟠 Major | ⚡ Quick winQueued
resumeResultQueuesuccess path does not emulate real resume state transitions.On Line 47–Line 49, returning a queued
Promisedirectly bypassesstate = "running"andonstatechangedispatch, unlike the normal success path. This can make lifecycle tests observe inconsistent behavior depending on how queue entries are authored.Suggested fix
resume(): Promise<void> { const queuedResult = MockAudioContext.resumeResultQueue?.shift(); if (queuedResult instanceof Promise) { - return queuedResult; + return queuedResult.then(() => { + this.state = "running"; + const cb = this.onstatechange; + cb?.(); + }); } if (queuedResult instanceof Error) { return Promise.reject(queuedResult); } if (!MockAudioContext.shouldResumeSucceed) {🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/src/core/audio/AudioSourcePendingPlayback.test.ts` around lines 45 - 49, The resume() method in the MockAudioContext class returns a queued Promise directly without ensuring state transitions are performed. When resumeResultQueue contains a Promise entry, modify the code to chain a .then() handler to that Promise so it sets state to "running" and dispatches the onstatechange event after the Promise resolves, matching the behavior of the normal success path and ensuring consistent lifecycle state transitions regardless of how queue entries are authored.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@tests/src/core/audio/AudioSourcePendingPlayback.test.ts`:
- Around line 45-49: The resume() method in the MockAudioContext class returns a
queued Promise directly without ensuring state transitions are performed. When
resumeResultQueue contains a Promise entry, modify the code to chain a .then()
handler to that Promise so it sets state to "running" and dispatches the
onstatechange event after the Promise resolves, matching the behavior of the
normal success path and ensuring consistent lifecycle state transitions
regardless of how queue entries are authored.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: 2854a840-cfa8-4903-8578-d7e2bfd2eb41
📒 Files selected for processing (4)
notes/audio/2026-06-15-audio-context-lifecycle-ci.mdpackages/core/src/audio/AudioManager.tspackages/core/src/audio/AudioSource.tstests/src/core/audio/AudioSourcePendingPlayback.test.ts
💤 Files with no reviewable changes (1)
- packages/core/src/audio/AudioSource.ts
✅ Files skipped from review due to trivial changes (1)
- notes/audio/2026-06-15-audio-context-lifecycle-ci.md
There was a problem hiding this comment.
🧹 Nitpick comments (2)
tests/src/core/audio/AudioSourcePendingPlayback.test.ts (2)
121-127: ⚡ Quick winModel timer cancellation in the test harness.
captureScheduledTimers()records callbacks but does not trackclearTimeout, so canceled timers remain manually invokable in tests. That weakens verification of_clearForegroundResumeTimerbehavior.Suggested patch
function captureScheduledTimers(): Array<() => void> { const scheduledTimers: Array<() => void> = []; - vi.spyOn(globalThis, "setTimeout").mockImplementation((handler: TimerHandler) => { - scheduledTimers.push(handler as () => void); - return scheduledTimers.length as any; - }); + let nextId = 1; + const canceled = new Set<number>(); + + vi.spyOn(globalThis, "setTimeout").mockImplementation((handler: TimerHandler) => { + const id = nextId++; + const cb = handler as () => void; + scheduledTimers.push(() => { + if (!canceled.has(id)) cb(); + }); + return id as any; + }); + + vi.spyOn(globalThis, "clearTimeout").mockImplementation((id?: number) => { + if (typeof id === "number") canceled.add(id); + }); return scheduledTimers; }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/src/core/audio/AudioSourcePendingPlayback.test.ts` around lines 121 - 127, The `captureScheduledTimers()` function captures scheduled callbacks but does not track which timers are cancelled via `clearTimeout`, allowing cancelled timers to remain invokable in the test. Extend the function to also spy on and mock `globalThis.clearTimeout`, tracking cancelled timer IDs and removing the corresponding callbacks from the scheduledTimers array when clearTimeout is called. This ensures that cancelled timers cannot be manually invoked and properly verifies the behavior of `_clearForegroundResumeTimer`.
130-149: ⚡ Quick winMake
document.hiddenrestoration failure-safe.The helper depends on manual
restore()at each callsite. If a test throws before restoration, globaldocument.hiddencan leak into later tests and cascade failures.Suggested pattern
function mockDocumentHidden(initialHidden: boolean): { set(hidden: boolean): void; restore(): void } { const ownDescriptor = Object.getOwnPropertyDescriptor(document, "hidden"); let hidden = initialHidden; Object.defineProperty(document, "hidden", { configurable: true, get: () => hidden }); return { set(value: boolean) { hidden = value; }, restore() { if (ownDescriptor) { Object.defineProperty(document, "hidden", ownDescriptor); } else { delete (document as any).hidden; } } }; } + +async function withMockedDocumentHidden( + initialHidden: boolean, + run: (ctl: { set(hidden: boolean): void }) => Promise<void> | void +): Promise<void> { + const ctl = mockDocumentHidden(initialHidden); + try { + await run({ set: ctl.set }); + } finally { + ctl.restore(); + } +}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/src/core/audio/AudioSourcePendingPlayback.test.ts` around lines 130 - 149, The mockDocumentHidden function requires manual restoration via restore() calls, which can be skipped if a test throws an exception, causing document.hidden to leak into subsequent tests. Refactor the approach to automatically restore document.hidden after each test by leveraging Jest's afterEach hook or wrapping the mock setup in a helper that guarantees cleanup regardless of test success or failure, ensuring the original document descriptor is always restored.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@tests/src/core/audio/AudioSourcePendingPlayback.test.ts`:
- Around line 121-127: The `captureScheduledTimers()` function captures
scheduled callbacks but does not track which timers are cancelled via
`clearTimeout`, allowing cancelled timers to remain invokable in the test.
Extend the function to also spy on and mock `globalThis.clearTimeout`, tracking
cancelled timer IDs and removing the corresponding callbacks from the
scheduledTimers array when clearTimeout is called. This ensures that cancelled
timers cannot be manually invoked and properly verifies the behavior of
`_clearForegroundResumeTimer`.
- Around line 130-149: The mockDocumentHidden function requires manual
restoration via restore() calls, which can be skipped if a test throws an
exception, causing document.hidden to leak into subsequent tests. Refactor the
approach to automatically restore document.hidden after each test by leveraging
Jest's afterEach hook or wrapping the mock setup in a helper that guarantees
cleanup regardless of test success or failure, ensuring the original document
descriptor is always restored.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: 3fae02eb-37c8-470c-992a-95a83d45f534
📒 Files selected for processing (1)
tests/src/core/audio/AudioSourcePendingPlayback.test.ts
|
Addressed in
Verification:
GitHub Actions for the new head are running. |
|
Looks good from my side after removing the cross-gesture pending replay mechanism. The PR still has merge value, but the scope has changed: it is no longer a pending playback feature. It now keeps I updated the PR title and summary to reflect the current scope and removed the stale pending-replay framing. |
|
Follow-up done in
Verification:
|
|
Follow-up cleanup done in
Verification:
|
|
Addressed in I kept I also added a regression test for that exact ordering: Verified locally:
GitHub Actions on |
|
Test cleanup done in I removed the old multi-source I tried simplifying the test harness around Verified locally:
GitHub Actions on |
GuoLei1990
left a comment
There was a problem hiding this comment.
总结
复审 HEAD 54ef72e8d(自上轮 e43c51cc2 起 2 个新 commit:70b24d0ef 删除过时的 _playingCount 平衡测试、54ef72e8d 删除 notes/audio/2026-06-16-hidden-playback-gate.md)。本轮是纯清理,无任何代码逻辑改动——AudioManager.ts / AudioSource.ts 与上轮字节一致(diff 仅落在 notes/ 与测试文件)。我上轮唯一的 P2 已解决,无新问题。
已闭环(本轮新提交解决)
- [P2]
notes/audio/2026-06-16-hidden-playback-gate.mdscratchpad 又一次进了源码树 →54ef72e8d已整文件删除。已git ls-tree -r pr-head | grep notes/确认整个notes/目录从 PR 树中消失(全 PR diff 现在只剩 3 个文件,全在packages/core/tests)。关闭。 - 过时多源
_playingCount平衡测试 →70b24d0ef删除 31 行("keeps playback state consistent across play/stop/pause/ended")。该用例断言的AudioManager._playingCount++/--与s1._onPlayEnd()私有路径在f05afeb14移除_playingCount后已成死代码,删除正确。剩余 19 个用例直接锁定当前生命周期契约(autoplay drop / hidden 竞态 / caller suspend / 前台 zombie reset / 外部中断重试 / stopped-source 跨 hide/show),均走真实 lifecycle 事件派发,非戳私有制造场景。关闭。
第一性原理复核(沿用历史结论,逐条核对当前源码)
- one-shot 语义 — 已对照 base
dev/2.0的play():PR 与 base 同为「ctx 非 running 时resume()一次性尝试、resolve 后 drop」的自包含语义,无全局队列、不跨手势、不累积。相对 base 还做了两处加固:fast-path 与 post-resume 都改用_canStartPlayback()(覆盖document.hidden===true但 state 仍报 running 的 async-suspend 竞态)、post-resume 守卫从!this._clip增强为!this._clip?._getAudioSource() || !_canStartPlayback()。是对 base 的净改进,非回归。我上轮撤回_pendingSources的设计反馈已完整落地。 play()内 iOS 手势注释删除 — base 里// iOS Safari requires resume() to be called within the same user gesture callback...在本 PR 被删,但该约束并未丢失:它与所调用的AudioManager.resume()的@remarks("On iOS Safari, calling this within a user gesture... can pre-unlock audio")重复,约束随其约束的 API 一同记录。删除冗余注释合理,非问题。- 状态机收口、suspend/resume 保活已 start 节点、
_foregroundResumeTimer抑制 zombie-fix 自身 suspend 被误判为外部中断、interrupted/suspended non-running 分支等判例沿用,不再复述。
注释合规
无新增/改动注释。resume() / suspend() JSDoc 合规,_onShown zombie fix 双行 //(无句号、附 WebKit bug 链接、解释 为什么)为既有行。无违规。
简化建议
无。本轮就是删冗余(死测试 + scratchpad)。
CI 全绿(lint / build×3 / codecov+patch+project / e2e×4)。reviewDecision: REVIEW_REQUIRED,设计与实现层面我已无保留意见,唯一剩余的 notes 落点 P2 本轮已闭环——仅缺正式 approve。
基于真机验证 + 业界调研,优化问题1(iOS 切后台回前台音频挂起)修复的注释与可读性: - 注释纠正事实:删去 "Triggered in LingGuang App",改为 "Reproducible on plain iOS Safari, not only WKWebView"(真机在普通 iOS Safari 已复现僵尸态)。 - 注释厘清机制:bare resume() 报 running 但渲染管线不重启(无声、currentTime 冻结); suspend() 先清掉该状态,使后续 resume() 走完整重启路径而非被短路。 - 抽出 _zombieResumeDelay = 100 常量,并注明:100ms 为经验值(与 Phaser WebAudioSoundManager 一致),无 spec/厂商权威推荐;真机实测最稳; Promise 链 suspend().then(resume) 理论更优但偶尔失败,固定延迟未失败过。 - 手势兜底注释明确其用途(自动 resume 失败时由后续手势重试)。 仍 DO NOT MERGE — 临时拆解分支,供与作者 PR galacean#3026 对照与讨论。
精简上一版过度的注释与抽象: - 去掉 _zombieResumeDelay 常量(仅单处使用、不可配,无需抽象),内联回 100。 - 注释收敛到要点:iOS 后台 AudioContext 卡死、必须先 suspend 才能让 resume 真正重启、bug 链接;延迟说明压成 setTimeout 上方一行(100ms 经验值、无 spec)。 仍 DO NOT MERGE — 临时拆解分支,供与作者 PR galacean#3026 对照与讨论。
…em 2) 问题2:用户通过公开 API AudioManager.suspend() 主动暂停后,切后台再回前台, 问题1 的前台恢复逻辑会违背意图把它"复活"(自己又响)——因为 _onVisibilityChange 只凭 _playingCount>0 && !running 推断该恢复,而主动暂停产生的状态和系统挂起完全相同, 无法区分。真机已实锤(主动暂停→切后台→回来,音频自动恢复)。 第一性:主动暂停 vs 系统挂起的区别只存在于"谁发起的"这个意图里,无法从 AudioContext 状态反推,只能显式记录。故引入 _suspendedByCaller: - suspend() 置 true(主动暂停) - resume() 置 false(显式恢复 = 解除主动暂停意图) - 两条自动恢复路径(前台恢复 _onVisibilityChange、手势 _resumeAfterInterruption) 检查该标记,主动暂停时跳过,不自动恢复。 公开 suspend() 当前虽无引擎内部调用方,但它是 deliberately public 的用户 API (无 @internal、有 JSDoc),其"暂停后保持暂停"的契约必须成立。 仍 DO NOT MERGE — 临时拆解分支,供与作者 PR galacean#3026 对照与讨论。
…em 2) 问题2:用户通过公开 API AudioManager.suspend() 主动暂停后,切后台再回前台, 问题1 的前台恢复逻辑会违背意图把它"复活"(自己又响)——因为 _onVisibilityChange 只凭 _playingCount>0 && !running 推断该恢复,而主动暂停产生的状态和系统挂起完全相同, 无法区分。真机已实锤(主动暂停→切后台→回来,音频自动恢复)。 第一性:主动暂停 vs 系统挂起的区别只存在于"谁发起的"这个意图里,无法从 AudioContext 状态反推,只能显式记录。故引入 _suspendedByCaller: - suspend() 置 true(主动暂停) - resume() 置 false(显式恢复 = 解除主动暂停意图) - 两条自动恢复路径(前台恢复 _onVisibilityChange、手势 _resumeAfterInterruption) 检查该标记,主动暂停时跳过,不自动恢复。 公开 suspend() 当前虽无引擎内部调用方,但它是 deliberately public 的用户 API (无 @internal、有 JSDoc),其"暂停后保持暂停"的契约必须成立。 仍 DO NOT MERGE — 临时拆解分支,供与作者 PR galacean#3026 对照与讨论。
…i (problem 3) 真机对照实测推翻"问题3 需主动干预": - 裸 WebAudio 接电话挂断 → iOS 自己把 context 从 interrupted 拉回 running、自愈有声; - 引擎之前的 onstatechange 200ms 僵尸探针 + _recoverContext 在 off-gesture suspend→resume,反而戳进 iOS 的自愈过程、把它打成僵尸(state=running 但无声)。 故撤掉问题3 的主动干预: - 删 context.onstatechange = _onContextStateChange 注册; - 删 _onContextStateChange(含 200ms zombie 探针)、_recoverContext、 _reviving / _zombieProbeTimer 字段; - 来电中断交给 iOS 自愈,不主动碰 context。 问题1(切后台)与问题2(主动暂停)保留,且经裸页三模式(A 啥都不做/ B 只 resume/C suspend→resume)实测确认:切后台回前台 state 停在 interrupted, 直接 resume 抛 InvalidStateError,必须先 suspend 转 suspended 再 resume(C 有声), 所以问题1 的 suspend→100ms→resume 必需、不可简化。注释更新为该真因。 区分信号:回前台 state 卡 interrupted 不动 = 切后台(救);interrupted 自己 往 running 走 = 来电(别碰,iOS 自愈)。前者经 visibilitychange 进 _onVisibilityChange, 后者通常无 hidden 不触发,自然让 iOS 处理。 仍 DO NOT MERGE — 临时拆解分支,供与作者 PR galacean#3026 对照与讨论。
…e reason 更新 _onVisibilityChange 的注释,说明切后台回前台必须 suspend→resume 的真因: 裸页三模式真机实测(A 啥都不做 / B 只 resume / C suspend→resume):切后台回前台 时 AudioContext 停在 "interrupted",直接 resume() 抛 InvalidStateError(B 失败), 必须先 suspend() 转成 "suspended" 才能合法 resume()(C 有声)。比原注释"zombie 重置" 更准确——根因是 interrupted 状态不能直接 resume,不只是渲染管线僵尸。 仅注释改动,逻辑不变。问题1(切后台 suspend→100ms→resume)与问题2 (_suspendedByCaller 主动暂停不被自动恢复)保留。 关于问题3(来电/Siri 中断):无需引擎改动。裸页实测来电挂断后 iOS 自己把 context 从 interrupted 拉回 running、自愈有声;引擎不应主动干预(off-gesture suspend→resume 会打断 iOS 自愈)。本分支 git 历史从未包含中断探针,对来电天然不干预,符合预期。 仍 DO NOT MERGE — 临时拆解分支,供与作者 PR galacean#3026 对照与讨论。
… zombie root cause) 真机排除法定位的根因:AudioSource 构造函数(addComponent 时,通常在用户手势之前) 立即 `AudioManager.getContext().createGain()`,从而在手势前就创建了 AudioContext。 iOS 上这种"冷"(手势前)创建的 AudioContext,在小窗来电中断挂断后不会自愈, 卡成僵尸(state 报 running 但 currentTime 冻结、无声)。裸 WebAudio 在手势内 首次创建 ctx 则能自愈——唯一变量就是创建时机(真机实验 q3-bareaudio 自愈 / q3-earlyctx 僵尸 证实)。 修复:延迟创建到首次 play(手势内)。 - 构造函数不再创建 gainNode; - 新增 _ensureGainNode() 懒初始化,首次 _initSourceNode 时创建并应用 _volume; - volume setter 用 _gainNode?. 守卫(未创建时只存 _volume,懒创建时再应用); - _cloneTo 不再直接访问 target._gainNode(_volume 由字段克隆复制,懒创建时应用)。 真机验证:修复后 play 前 ctx 未创建,小窗接电话挂断自愈有声。这是从根上消除 僵尸,无需 suspend/resume 救援。 仍 DO NOT MERGE — 临时拆解分支,供与作者 PR galacean#3026 对照与讨论。
… zombie root cause) 真机排除法定位的根因:AudioSource 构造函数(addComponent 时,通常在用户手势之前) 立即 `AudioManager.getContext().createGain()`,从而在手势前就创建了 AudioContext。 iOS 上这种"冷"(手势前)创建的 AudioContext,在小窗来电中断挂断后不会自愈, 卡成僵尸(state 报 running 但 currentTime 冻结、无声)。裸 WebAudio 在手势内 首次创建 ctx 则能自愈——唯一变量就是创建时机(真机实验 q3-bareaudio 自愈 / q3-earlyctx 僵尸 证实)。 修复:延迟创建到首次 play(手势内)。 - 构造函数不再创建 gainNode; - 新增 _ensureGainNode() 懒初始化,首次 _initSourceNode 时创建并应用 _volume; - volume setter 用 _gainNode?. 守卫(未创建时只存 _volume,懒创建时再应用); - _cloneTo 不再直接访问 target._gainNode(_volume 由字段克隆复制,懒创建时应用)。 真机验证:修复后 play 前 ctx 未创建,小窗接电话挂断自愈有声。这是从根上消除 僵尸,无需 suspend/resume 救援。 仍 DO NOT MERGE — 临时拆解分支,供与作者 PR galacean#3026 对照与讨论。
…he playback context early 根因修复第二处(配合 AudioSource 延迟创建):AudioLoader 解码音频时用的是 AudioManager.getContext()(持久播放 ctx)。真实游戏在启动时(用户手势前)用 resourceManager.load 加载音频,这会提前创建播放 ctx —— iOS 上这种"冷"播放 ctx 小窗来电中断后不自愈(僵尸)。真机实测确认 loader 是 AudioSource 构造之外的第二个 提前创建点。 改用一个 decode-only 的 OfflineAudioContext 解码:它只渲染到内存、不占音频硬件、 在 iOS 中断机制之外,所以提前创建无害;解出的 AudioBuffer 与上下文无关,播放时由 AudioBufferSourceNode 自动重采样到播放 ctx 的率,音调/时长不变。播放 ctx 因此延迟到 首次 play(手势内)热创建,iOS 来电可自愈。 真机验证:真实 resourceManager.load 路径下,load 后播放 ctx 未创建,小窗接电话 挂断自愈、声音正常。 仍 DO NOT MERGE — 临时拆解分支,供与作者 PR galacean#3026 对照与讨论。
…he playback context early 根因修复第二处(配合 AudioSource 延迟创建):AudioLoader 解码音频时用的是 AudioManager.getContext()(持久播放 ctx)。真实游戏在启动时(用户手势前)用 resourceManager.load 加载音频,这会提前创建播放 ctx —— iOS 上这种"冷"播放 ctx 小窗来电中断后不自愈(僵尸)。真机实测确认 loader 是 AudioSource 构造之外的第二个 提前创建点。 改用一个 decode-only 的 OfflineAudioContext 解码:它只渲染到内存、不占音频硬件、 在 iOS 中断机制之外,所以提前创建无害;解出的 AudioBuffer 与上下文无关,播放时由 AudioBufferSourceNode 自动重采样到播放 ctx 的率,音调/时长不变。播放 ctx 因此延迟到 首次 play(手势内)热创建,iOS 来电可自愈。 真机验证:真实 resourceManager.load 路径下,load 后播放 ctx 未创建,小窗接电话 挂断自愈、声音正常。 仍 DO NOT MERGE — 临时拆解分支,供与作者 PR galacean#3026 对照与讨论。
…he playback context early 根因修复第二处(配合 AudioSource 延迟创建):AudioLoader 解码音频时用的是 AudioManager.getContext()(持久播放 ctx)。真实游戏在启动时(用户手势前)用 resourceManager.load 加载音频,这会提前创建播放 ctx —— iOS 上这种"冷"播放 ctx 小窗来电中断后不自愈(僵尸)。真机实测确认 loader 是 AudioSource 构造之外的第二个 提前创建点。 改用一个 decode-only 的 OfflineAudioContext 解码:它只渲染到内存、不占音频硬件、 在 iOS 中断机制之外,所以提前创建无害;解出的 AudioBuffer 与上下文无关,播放时由 AudioBufferSourceNode 自动重采样到播放 ctx 的率,音调/时长不变。播放 ctx 因此延迟到 首次 play(手势内)热创建,iOS 来电可自愈。 真机验证:真实 resourceManager.load 路径下,load 后播放 ctx 未创建,小窗接电话 挂断自愈、声音正常。 仍 DO NOT MERGE — 临时拆解分支,供与作者 PR galacean#3026 对照与讨论。
切后台/锁屏的瞬间,document.hidden 已同步变 true,但 ctx.suspend() 是异步的、 ctx.state 可能还报 running。这个窗口里调 play() 会真的 start() 出一声(真机实测: 切后台后漏一声),随后被冻成 isPlaying=true 但无声的僵尸 source(状态脱节,且依赖 isPlaying 的逻辑会卡住)。 论点是"后台不该启动播放"(用户不在场):此刻既不该出声(漏音),挂起延后又会在 回前台播出对不上的幽灵音。所以 play() 在 document.hidden 时直接 drop: - 入口加 document.hidden 早返回; - resume().then() 复查也加 document.hidden(防 resume 期间页面切到后台)。 真机验证:修复后切后台静音、isPlaying=false,不再漏音/留僵尸 source。 仍 DO NOT MERGE — 临时拆解分支,供与作者 PR galacean#3026 对照与讨论。
切后台/锁屏的瞬间,document.hidden 已同步变 true,但 ctx.suspend() 是异步的、 ctx.state 可能还报 running。这个窗口里调 play() 会真的 start() 出一声(真机实测: 切后台后漏一声),随后被冻成 isPlaying=true 但无声的僵尸 source(状态脱节,且依赖 isPlaying 的逻辑会卡住)。 论点是"后台不该启动播放"(用户不在场):此刻既不该出声(漏音),挂起延后又会在 回前台播出对不上的幽灵音。所以 play() 在 document.hidden 时直接 drop: - 入口加 document.hidden 早返回; - resume().then() 复查也加 document.hidden(防 resume 期间页面切到后台)。 真机验证:修复后切后台静音、isPlaying=false,不再漏音/留僵尸 source。 仍 DO NOT MERGE — 临时拆解分支,供与作者 PR galacean#3026 对照与讨论。
iOS Safari 的 back/forward cache(bfcache)恢复页面时,AudioContext 被冻结(interrupted/ suspended),但 bfcache 恢复**只派发 pageshow(persisted=true),不派发 visibilitychange**。 原来只监听 visibilitychange,bfcache 命中时无人恢复 ctx → 循环音/正在播的音频回来后没声。 真机实测确认:导航走再后退、命中 bfcache(persisted=true)时,只监听 visibilitychange 的版本回来没声;补 pageshow 后恢复有声。隔离验证(停用 visibilitychange、仅 pageshow) 命中 bfcache 仍恢复,证明 pageshow 是 bfcache 唯一可靠信号、独立有效。 补 window 'pageshow' 监听,_onPageShow 仅在 event.persisted(真 bfcache 恢复)时走与 回前台相同的 suspend→100ms→resume 恢复(普通加载 persisted=false 不处理)。 仍 DO NOT MERGE — 临时拆解分支,供与作者 PR galacean#3026 对照与讨论。
iOS Safari 的 back/forward cache(bfcache)恢复页面时,AudioContext 被冻结(interrupted/ suspended),但 bfcache 恢复**只派发 pageshow(persisted=true),不派发 visibilitychange**。 原来只监听 visibilitychange,bfcache 命中时无人恢复 ctx → 循环音/正在播的音频回来后没声。 真机实测确认:导航走再后退、命中 bfcache(persisted=true)时,只监听 visibilitychange 的版本回来没声;补 pageshow 后恢复有声。隔离验证(停用 visibilitychange、仅 pageshow) 命中 bfcache 仍恢复,证明 pageshow 是 bfcache 唯一可靠信号、独立有效。 补 window 'pageshow' 监听,_onPageShow 仅在 event.persisted(真 bfcache 恢复)时走与 回前台相同的 suspend→100ms→resume 恢复(普通加载 persisted=false 不处理)。 仍 DO NOT MERGE — 临时拆解分支,供与作者 PR galacean#3026 对照与讨论。
iOS Safari 的 back/forward cache(bfcache)恢复页面时 AudioContext 被冻结(interrupted/ suspended),但 bfcache 恢复只派发 pageshow(persisted=true)、不派发 visibilitychange。 原来只监听 visibilitychange,bfcache 命中时无人恢复 → 回来没声。真机实测确认(隔离验证 停用 visibilitychange、仅 pageshow 命中仍恢复)。 补 window 'pageshow' 监听,_onPageShow 仅在 event.persisted(真 bfcache 恢复)时走与回 前台相同的恢复。恢复逻辑提取为 _recoverPlaybackContext(原 _onVisibilityChange 改名, visibilitychange 与 pageshow 复用)。 加 _recovering 重入守卫:bfcache 恢复会同时派发 visibilitychange 和 pageshow,无守卫会 让 suspend→resume 跑两遍(浪费 + 第二个 resume 打在未落地的 suspend 上有竞态)。同时把 resume 链在 suspend().then() 上,不再用裸 timer 排在未 await 的 suspend 之后。 仍 DO NOT MERGE — 临时拆解分支,供与作者 PR galacean#3026 对照与讨论。
iOS Safari 的 back/forward cache(bfcache)恢复页面时 AudioContext 被冻结(interrupted/ suspended),但 bfcache 恢复只派发 pageshow(persisted=true)、不派发 visibilitychange。 原来只监听 visibilitychange,bfcache 命中时无人恢复 → 回来没声。真机实测确认(隔离验证 停用 visibilitychange、仅 pageshow 命中仍恢复)。 补 window 'pageshow' 监听,_onPageShow 仅在 event.persisted(真 bfcache 恢复)时走与回 前台相同的恢复。恢复逻辑提取为 _recoverPlaybackContext(原 _onVisibilityChange 改名, visibilitychange 与 pageshow 复用)。 加 _recovering 重入守卫:bfcache 恢复会同时派发 visibilitychange 和 pageshow,无守卫会 让 suspend→resume 跑两遍(浪费 + 第二个 resume 打在未落地的 suspend 上有竞态)。同时把 resume 链在 suspend().then() 上,不再用裸 timer 排在未 await 的 suspend 之后。 仍 DO NOT MERGE — 临时拆解分支,供与作者 PR galacean#3026 对照与讨论。
Summary
AudioSource.play()as a one-shot attempt: autoplay-blocked, hidden, or still-suspended resolutions are dropped instead of replayed on a later unrelated gesture.AudioSource.play()cannot start a source whiledocument.hiddenorAudioManagerhidden state is true, even if the rawAudioContext.statestill reportsrunningbefore async suspend settles.AudioManager.isAudioContextRunning()as a raw WebAudio state check; lifecycle permission stays inAudioManager._canStartPlayback()and the hidden/show handlers.AudioManagercontext lifecycle handling acrossvisibilitychange,pagehide,pageshow, external non-running state changes, caller suspend, and foreground recovery.AudioContextfromAudioManager.suspend(), and prevent caller-controlled suspend from being auto-resumed by gesture or foreground recovery.Verification
pnpm exec vitest run tests/src/core/audio/AudioSourcePendingPlayback.test.tspnpm exec cross-env HEADLESS=true vitest run tests/src/core/audio/AudioSource.test.ts tests/src/core/audio/AudioSourcePendingPlayback.test.tspnpm exec eslint tests/src/core/audio/AudioSourcePendingPlayback.test.tsgit diff --check HEAD54ef72e8d: lint, codecov, codecov patch/project, ubuntu/windows/macos build, and e2e 1/4-4/4 are all green.