From d234a13f598d2d7522b653cce0c4d6b15a882198 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Sun, 21 Jun 2026 00:58:52 +0800 Subject: [PATCH 1/9] fix(audio): fix iOS lifecycle at root cause (defer context creation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把 audio 播放生命周期修复改为根因治本,替换本分支原先基于 onstatechange/ 显式 hide-suspend 的方案。所有问题均在真机 iOS Safari 实测复现并验证修复。 根因(本分支原方案未触及):AudioContext 在任何用户手势之前就被创建—— AudioSource 构造时建 gainNode、AudioLoader 用播放 ctx 解码——这种"冷"上下文 在 iOS 来电中断后无法自愈(state 报 running 但 currentTime 冻结的僵尸态)。 改动: - AudioSource: 不在构造期建 gainNode,改为首次 play 时 _ensureGainNode 懒建; ctx 因此推迟到首次 play(用户手势内)才创建,变"热"上下文,来电后 iOS 自愈 - AudioLoader: 用专用 OfflineAudioContext(1,1,44100)解码,不再用播放 ctx 解码(这是第二个过早创建点);要求 iOS >= 14.5(无前缀 OfflineAudioContext) - AudioManager: 切后台/锁屏回前台时 _recoverPlaybackContext 做 suspend→100ms →resume 唤醒僵尸 ctx(对齐 webkit #263627);bfcache 恢复只派发 pageshow (persisted)不派发 visibilitychange,故 _onPageShow 同走恢复;_recovering 重入守卫防 bfcache 双事件(visibilitychange+pageshow)跑两遍;_suspendedByCaller 让显式 suspend() 不被手势/可见性自动唤醒;play() 在 document.hidden 时直接丢弃 (不启动也不挂起,避免回前台错位重放或后台漏音) 移除原方案的 onstatechange:实测"来电之外的外部中断"在 iOS 上无可触发场景 (放音乐/视频是叠加混播、Siri 不打断),唯一独占中断是来电、已由根因覆盖; 而 onstatechange 驱动的恢复正是冷 ctx 僵尸的来源之一。 测试:重写为 AudioSourcePendingPlayback.test.ts,覆盖延迟创建、懒 gainNode、 hidden 丢弃、pending 重放/取消、autoplay-blocked 丢弃、resume 合并、显式 suspend 不自动恢复、僵尸态恢复(suspend→100ms→resume)、_recovering 重入守卫、 bfcache pageshow(persisted)恢复、suspend reject 不阻断 resume 等 23 个用例。 --- packages/core/src/audio/AudioManager.ts | 175 ++---- packages/core/src/audio/AudioSource.ts | 90 ++- packages/loader/src/AudioLoader.ts | 17 +- .../audio/AudioSourcePendingPlayback.test.ts | 578 ++++++++++-------- 4 files changed, 441 insertions(+), 419 deletions(-) diff --git a/packages/core/src/audio/AudioManager.ts b/packages/core/src/audio/AudioManager.ts index adf4894528..475fb5a4d6 100644 --- a/packages/core/src/audio/AudioManager.ts +++ b/packages/core/src/audio/AudioManager.ts @@ -2,27 +2,23 @@ * Audio Manager for managing global audio context and settings. */ export class AudioManager { + /** @internal */ + static _playingCount = 0; + private static _context: AudioContext; private static _gainNode: GainNode; + private static _resumePromise: Promise = null; private static _needsUserGestureResume = false; - private static _hidden = false; - private static _foregroundResumeTimer: ReturnType | null = null; private static _suspendedByCaller = false; + private static _recovering = false; /** * Suspend the audio context. * @returns A promise that resolves when the audio context is suspended */ static suspend(): Promise { - const context = AudioManager._context; - if (!context) { - return Promise.resolve(); - } AudioManager._suspendedByCaller = true; - AudioManager._needsUserGestureResume = false; - AudioManager._clearForegroundResumeTimer(); - AudioManager._removeGestureListeners(); - return context.suspend(); + return AudioManager.getContext().suspend(); } /** @@ -32,25 +28,14 @@ export class AudioManager { */ static resume(): Promise { AudioManager._suspendedByCaller = false; - AudioManager._clearForegroundResumeTimer(); - if (document.hidden) { - AudioManager._onHidden(); - } - - if (AudioManager._hidden) { - return Promise.resolve(); - } - - const context = AudioManager.getContext(); - if (context.state === "running") { - AudioManager._needsUserGestureResume = false; - AudioManager._removeGestureListeners(); - return Promise.resolve(); - } - return context.resume().then(() => { - AudioManager._needsUserGestureResume = false; - AudioManager._removeGestureListeners(); - }); + return (AudioManager._resumePromise ??= AudioManager.getContext() + .resume() + .then(() => { + AudioManager._needsUserGestureResume = false; + }) + .finally(() => { + AudioManager._resumePromise = null; + })); } /** @@ -60,11 +45,13 @@ export class AudioManager { let context = AudioManager._context; if (!context) { AudioManager._context = context = new window.AudioContext(); - AudioManager._hidden = document.hidden; - context.onstatechange = AudioManager._onContextStateChange; - document.addEventListener("visibilitychange", AudioManager._onVisibilityChange); - window.addEventListener("pagehide", AudioManager._onHidden); - window.addEventListener("pageshow", AudioManager._onShown); + document.addEventListener("visibilitychange", AudioManager._recoverPlaybackContext); + // bfcache restore fires pageshow (persisted) but NOT visibilitychange, so recover here too + window.addEventListener("pageshow", AudioManager._onPageShow); + // iOS Safari requires user gesture to resume AudioContext + document.addEventListener("touchstart", AudioManager._resumeAfterInterruption, { passive: true }); + document.addEventListener("touchend", AudioManager._resumeAfterInterruption, { passive: true }); + document.addEventListener("click", AudioManager._resumeAfterInterruption); } return context; } @@ -89,111 +76,51 @@ export class AudioManager { return AudioManager.getContext().state === "running"; } - /** - * @internal - */ - static _canStartPlayback(): boolean { - if (document.hidden) { - AudioManager._onHidden(); - return false; - } - if (AudioManager._hidden) { - return false; - } - return AudioManager.getContext().state === "running"; - } - - private static _onContextStateChange(): void { - const state = AudioManager._context?.state; - if (state === "running" && !AudioManager._hidden) { - AudioManager._suspendedByCaller = false; - AudioManager._needsUserGestureResume = false; - AudioManager._removeGestureListeners(); - } else if ( - state && - state !== "running" && - !AudioManager._hidden && - !AudioManager._suspendedByCaller && - !AudioManager._foregroundResumeTimer + private static _recoverPlaybackContext(): void { + // Returning to foreground with a non-running context (and not a deliberate pause): iOS leaves it + // "interrupted", which cannot be resumed directly; suspend() first transitions it to "suspended", + // then resume() restarts the pipeline https://bugs.webkit.org/show_bug.cgi?id=263627 + // _recovering guards re-entry: a bfcache restore fires both visibilitychange and pageshow + if ( + AudioManager._recovering || + document.hidden || + AudioManager._suspendedByCaller || + AudioManager._playingCount <= 0 || + AudioManager.isAudioContextRunning() ) { - AudioManager._needsUserGestureResume = true; - AudioManager._addGestureListeners(); - } - } - - private static _onVisibilityChange(): void { - document.hidden ? AudioManager._onHidden() : AudioManager._onShown(); - } - - private static _onHidden(): void { - const context = AudioManager._context; - if (AudioManager._hidden && context?.state !== "running") { - return; - } - AudioManager._hidden = true; - AudioManager._clearForegroundResumeTimer(); - context?.suspend().catch(() => {}); - } - - private static _onShown(): void { - if (!AudioManager._hidden) { - return; - } - AudioManager._hidden = false; - - const context = AudioManager._context; - if (!context || AudioManager._suspendedByCaller) { return; } - const isForegroundResumeStale = (): boolean => - AudioManager._hidden || AudioManager._suspendedByCaller || AudioManager._context !== context; - // iOS WKWebView zombie fix (https://bugs.webkit.org/show_bug.cgi?id=263627): - // force suspend then resume after a short delay to reset the audio rendering pipeline. + AudioManager._recovering = true; + AudioManager._needsUserGestureResume = true; // fallback if the auto-resume below is rejected + const context = AudioManager.getContext(); context.suspend().catch(() => {}); - AudioManager._foregroundResumeTimer = setTimeout(() => { - AudioManager._foregroundResumeTimer = null; - if (isForegroundResumeStale()) { - return; - } + // Clear _recovering on the timer itself, NOT off a promise: suspending/resuming an "interrupted" + // context on iOS may never settle, which would leave _recovering stuck true and block all later + // recovery. The timer always fires, and 100ms already covers the bfcache double-dispatch window. + // 100ms is an empirical delay; resuming too soon after suspend is unreliable. + setTimeout(() => { + AudioManager._recovering = false; context .resume() .then(() => { - if (isForegroundResumeStale()) { - return; - } AudioManager._needsUserGestureResume = false; - AudioManager._removeGestureListeners(); }) - .catch(() => { - if (isForegroundResumeStale()) { - return; - } - AudioManager._needsUserGestureResume = true; - AudioManager._addGestureListeners(); - }); + .catch(() => {}); }, 100); } - private static _resumeAfterInterruption(): void { - if (!AudioManager._suspendedByCaller && AudioManager._needsUserGestureResume) { - AudioManager.resume().catch(() => {}); + private static _onPageShow(event: PageTransitionEvent): void { + // Only a bfcache restore needs handling here; a normal load has no suspended context to recover + if (event.persisted) { + AudioManager._recoverPlaybackContext(); } } - private static _clearForegroundResumeTimer(): void { - if (AudioManager._foregroundResumeTimer !== null) { - clearTimeout(AudioManager._foregroundResumeTimer); - AudioManager._foregroundResumeTimer = null; + private static _resumeAfterInterruption(): void { + if (!AudioManager._suspendedByCaller && AudioManager._needsUserGestureResume) { + AudioManager.resume().catch((e) => { + console.warn("Failed to resume AudioContext:", e); + }); } } - - private static _addGestureListeners(): void { - document.addEventListener("pointerup", AudioManager._resumeAfterInterruption, { passive: true }); - document.addEventListener("click", AudioManager._resumeAfterInterruption, { passive: true }); - } - - private static _removeGestureListeners(): void { - document.removeEventListener("pointerup", AudioManager._resumeAfterInterruption); - document.removeEventListener("click", AudioManager._resumeAfterInterruption); - } } diff --git a/packages/core/src/audio/AudioSource.ts b/packages/core/src/audio/AudioSource.ts index a4f50d019b..c784541d23 100644 --- a/packages/core/src/audio/AudioSource.ts +++ b/packages/core/src/audio/AudioSource.ts @@ -72,7 +72,8 @@ export class AudioSource extends Component { set volume(value: number) { value = Math.min(Math.max(0, value), 1.0); this._volume = value; - this._gainNode.gain.setValueAtTime(value, AudioManager.getContext().currentTime); + // No node yet -> _ensureGainNode() applies _volume on first play + this._gainNode?.gain.setValueAtTime(value, AudioManager.getContext().currentTime); } /** @@ -143,9 +144,9 @@ export class AudioSource extends Component { constructor(entity: Entity) { super(entity); this._onPlayEnd = this._onPlayEnd.bind(this); - - this._gainNode = AudioManager.getContext().createGain(); - this._gainNode.connect(AudioManager.getGainNode()); + // Gain node is created lazily on first play, not here: creating it would spin up the AudioContext + // before any user gesture, and on iOS such a pre-gesture context never recovers from a phone-call + // interruption (stays a silent zombie) } /** @@ -155,18 +156,26 @@ export class AudioSource extends Component { if (!this._clip?._getAudioSource() || this._isPlaying || this._pendingPlay) { return; } + // Hidden page: don't start (would leak a sound) and don't pend (would replay out of sync) -> drop + if (document.hidden) { + return; + } - if (AudioManager._canStartPlayback()) { + if (AudioManager.isAudioContextRunning()) { this._startPlayback(); } else { + // iOS Safari requires resume() to be called within the same user gesture callback that triggers playback. + // Document-level events won't work - must call resume() directly here in play(). this._pendingPlay = true; AudioManager.resume().then( () => { + // Check if cancelled by stop()/pause() if (!this._pendingPlay) { return; } this._pendingPlay = false; - if (this._destroyed || !this.enabled || !this._clip?._getAudioSource() || !AudioManager._canStartPlayback()) { + // Check if still valid to play after async resume (page may have been hidden meanwhile) + if (this._destroyed || !this.enabled || !this._clip || document.hidden) { return; } this._startPlayback(); @@ -183,27 +192,30 @@ export class AudioSource extends Component { * Stops playing the clip. */ stop(): void { - this._cancelPendingPlayback(); + this._pendingPlay = false; if (this._isPlaying) { this._clearSourceNode(); + this._isPlaying = false; + this._pausedTime = -1; + this._playTime = -1; + AudioManager._playingCount--; } - - this._pausedTime = -1; - this._playTime = -1; } /** * Pauses playing the clip. */ pause(): void { - this._cancelPendingPlayback(); + this._pendingPlay = false; if (this._isPlaying) { this._clearSourceNode(); + this._pausedTime = AudioManager.getContext().currentTime; this._isPlaying = false; + AudioManager._playingCount--; } } @@ -212,7 +224,7 @@ export class AudioSource extends Component { */ _cloneTo(target: AudioSource): void { target._clip?._addReferCount(1); - target._gainNode.gain.setValueAtTime(target._volume, AudioManager.getContext().currentTime); + // _volume is field-cloned; its gain node is applied lazily on first play } /** @@ -243,60 +255,44 @@ export class AudioSource extends Component { this.stop(); } + private _ensureGainNode(): GainNode { + let gainNode = this._gainNode; + if (!gainNode) { + this._gainNode = gainNode = AudioManager.getContext().createGain(); + gainNode.connect(AudioManager.getGainNode()); + gainNode.gain.setValueAtTime(this._volume, AudioManager.getContext().currentTime); + } + return gainNode; + } + private _startPlayback(): void { const startTime = this._pausedTime > 0 ? this._pausedTime - this._playTime : 0; - if (!this._initSourceNode(startTime)) { - this._pausedTime = -1; - this._playTime = -1; - return; - } + this._initSourceNode(startTime); this._playTime = AudioManager.getContext().currentTime - startTime; this._pausedTime = -1; this._isPlaying = true; + AudioManager._playingCount++; } - private _initSourceNode(startTime: number): boolean { + private _initSourceNode(startTime: number): void { const context = AudioManager.getContext(); const sourceNode = context.createBufferSource(); - const audioBuffer = this._clip._getAudioSource(); - const duration = audioBuffer.duration; - let offset = Math.max(0, startTime); - - if (duration > 0) { - if (this._loop) { - offset %= duration; - } else if (offset >= duration) { - return false; - } - } - sourceNode.buffer = audioBuffer; + sourceNode.buffer = this._clip._getAudioSource(); sourceNode.playbackRate.value = this._playbackRate; sourceNode.loop = this._loop; sourceNode.onended = this._onPlayEnd; this._sourceNode = sourceNode; - sourceNode.connect(this._gainNode); - sourceNode.start(0, offset); - return true; + sourceNode.connect(this._ensureGainNode()); + sourceNode.start(0, startTime); } private _clearSourceNode(): void { - const sourceNode = this._sourceNode; - if (!sourceNode) { - return; - } - - sourceNode.onended = null; - try { - sourceNode.stop(); - } catch {} - sourceNode.disconnect(); + this._sourceNode.stop(); + this._sourceNode.disconnect(); + this._sourceNode.onended = null; this._sourceNode = null; } - - private _cancelPendingPlayback(): void { - this._pendingPlay = false; - } } diff --git a/packages/loader/src/AudioLoader.ts b/packages/loader/src/AudioLoader.ts index 46cc16e13e..7577851106 100644 --- a/packages/loader/src/AudioLoader.ts +++ b/packages/loader/src/AudioLoader.ts @@ -2,15 +2,20 @@ import { AssetPromise, AssetType, AudioClip, - AudioManager, LoadItem, Loader, RequestConfig, ResourceManager, resourceLoader } from "@galacean/engine-core"; + @resourceLoader(AssetType.Audio, ["mp3", "ogg", "wav", "m4a", "aac", "flac"]) class AudioLoader extends Loader { + // Decode here instead of the playback AudioContext: decoding happens at load time (before any user + // gesture), and creating the playback context that early breaks iOS phone-call recovery; the offline + // context decodes without touching the playback context + private static _decodeContext: OfflineAudioContext; + load(item: LoadItem, resourceManager: ResourceManager): AssetPromise { return new AssetPromise((resolve, reject) => { const { url } = item; @@ -24,8 +29,7 @@ class AudioLoader extends Loader { ._request(url, requestConfig) .then((arrayBuffer) => { const audioClip = new AudioClip(resourceManager.engine); - // @ts-ignore - AudioManager.getContext() + AudioLoader._getDecodeContext() .decodeAudioData(arrayBuffer) .then((result: AudioBuffer) => { // @ts-ignore @@ -47,4 +51,11 @@ class AudioLoader extends Loader { }); }); } + + private static _getDecodeContext(): OfflineAudioContext { + // length/channels unused (decode only); decodeAudioData resamples to this rate, then the buffer is + // resampled again to the playback rate at play time, so pitch/duration are unaffected; 44100 is the + // safest rate across browsers; unprefixed OfflineAudioContext requires iOS >= 14.5 + return (AudioLoader._decodeContext ||= new OfflineAudioContext(1, 1, 44100)); + } } diff --git a/tests/src/core/audio/AudioSourcePendingPlayback.test.ts b/tests/src/core/audio/AudioSourcePendingPlayback.test.ts index f007527b8e..59333a1b10 100644 --- a/tests/src/core/audio/AudioSourcePendingPlayback.test.ts +++ b/tests/src/core/audio/AudioSourcePendingPlayback.test.ts @@ -32,7 +32,6 @@ class MockAudioContext { currentTime = 0; destination = {}; - onstatechange: (() => void) | null = null; state: AudioContextState = "suspended"; createBufferSource(): AudioBufferSourceNode { @@ -48,8 +47,6 @@ class MockAudioContext { if (queuedResult instanceof Promise) { return queuedResult.then(() => { this.state = "running"; - const cb = this.onstatechange; - cb?.(); }); } if (queuedResult instanceof Error) { @@ -59,10 +56,7 @@ class MockAudioContext { return Promise.reject(new Error("autoplay blocked")); } this.state = "running"; - const cb = this.onstatechange; - return Promise.resolve().then(() => { - cb?.(); - }); + return Promise.resolve(); } suspend(): Promise { @@ -70,16 +64,14 @@ class MockAudioContext { return Promise.reject(new Error("suspend blocked")); } this.state = "suspended"; - const cb = this.onstatechange; - return Promise.resolve().then(() => { - cb?.(); - }); + return Promise.resolve(); } } async function flushAsync(): Promise { - await Promise.resolve(); - await Promise.resolve(); + for (let i = 0; i < 4; i++) { + await Promise.resolve(); + } } function createAudioSource(): AudioSource { @@ -101,23 +93,19 @@ function createAudioSource(): AudioSource { } function resetAudioManagerState(): void { - document.removeEventListener("visibilitychange", (AudioManager as any)._onVisibilityChange); - window.removeEventListener("pagehide", (AudioManager as any)._onHidden); - window.removeEventListener("pageshow", (AudioManager as any)._onShown); - document.removeEventListener("pointerup", (AudioManager as any)._resumeAfterInterruption); + document.removeEventListener("visibilitychange", (AudioManager as any)._recoverPlaybackContext); + window.removeEventListener("pageshow", (AudioManager as any)._onPageShow); + document.removeEventListener("touchstart", (AudioManager as any)._resumeAfterInterruption); + document.removeEventListener("touchend", (AudioManager as any)._resumeAfterInterruption); document.removeEventListener("click", (AudioManager as any)._resumeAfterInterruption); - const foregroundResumeTimer = (AudioManager as any)._foregroundResumeTimer; - if (foregroundResumeTimer !== null && foregroundResumeTimer !== undefined) { - clearTimeout(foregroundResumeTimer); - } - (AudioManager as any)._context = null; (AudioManager as any)._gainNode = null; + (AudioManager as any)._resumePromise = null; (AudioManager as any)._needsUserGestureResume = false; - (AudioManager as any)._hidden = false; - (AudioManager as any)._foregroundResumeTimer = null; (AudioManager as any)._suspendedByCaller = false; + (AudioManager as any)._recovering = false; + (AudioManager as any)._playingCount = 0; } function captureScheduledTimers(): Array<() => void> { @@ -168,394 +156,494 @@ describe("AudioSource playback lifecycle", () => { await flushAsync(); }); - it("drops playback after autoplay-blocked resume instead of replaying on a later gesture", async () => { + it("defers AudioContext creation until first play", () => { const audioSource = createAudioSource(); - vi.spyOn(console, "warn").mockImplementation(() => {}); - MockAudioContext.shouldResumeSucceed = false; + // setting clip must not have created the context + expect((AudioManager as any)._context == null).to.be.true; - audioSource.play(); - await flushAsync(); - - expect((audioSource as any)._pendingPlay).to.be.false; - expect(audioSource.isPlaying).to.be.false; + const context = new MockAudioContext(); + context.state = "running"; + (AudioManager as any)._context = context; - MockAudioContext.shouldResumeSucceed = true; - document.dispatchEvent(new Event("click")); - await flushAsync(); + audioSource.play(); - expect(audioSource.isPlaying).to.be.false; + expect((AudioManager as any)._context != null).to.be.true; }); - it("cancels one-shot pending playback before resume resolves", async () => { + it("applies a pre-play volume lazily on first play", () => { const audioSource = createAudioSource(); - let resolveResume: () => void; - MockAudioContext.resumeResultQueue = [ - new Promise((resolve) => { - resolveResume = resolve; - }) - ]; - audioSource.play(); - await flushAsync(); - expect((audioSource as any)._pendingPlay).to.be.true; + audioSource.volume = 0.3; - audioSource.stop(); - expect((audioSource as any)._pendingPlay).to.be.false; - resolveResume!(); - await flushAsync(); + // no node and no context created by the volume setter alone + expect((audioSource as any)._gainNode == null).to.be.true; + expect((AudioManager as any)._context == null).to.be.true; + expect(audioSource.volume).to.equal(0.3); - expect(audioSource.isPlaying).to.be.false; + const context = new MockAudioContext(); + context.state = "running"; + (AudioManager as any)._context = context; + + audioSource.play(); + + const gainNode = (audioSource as any)._gainNode as MockGainNode; + expect(gainNode != null).to.be.true; + expect(gainNode.gain.setValueAtTime).toHaveBeenCalledWith(0.3, context.currentTime); }); - it("resume() unlocks a suspended context", async () => { - createAudioSource(); - const context = (AudioManager as any)._context as MockAudioContext; - expect(context.state).to.equal("suspended"); + it("starts immediately when the context is already running", () => { + const audioSource = createAudioSource(); + const context = AudioManager.getContext() as unknown as MockAudioContext; + context.state = "running"; - await AudioManager.resume(); + const before = (AudioManager as any)._playingCount; + audioSource.play(); - expect(context.state).to.equal("running"); - expect((AudioManager as any)._needsUserGestureResume).to.be.false; + expect(audioSource.isPlaying).to.be.true; + expect((AudioManager as any)._playingCount).to.equal(before + 1); }); - it("suspends context when hidden", async () => { - createAudioSource(); - const context = (AudioManager as any)._context as MockAudioContext; - const suspendSpy = vi.spyOn(context, "suspend"); - + it("guards play re-entrancy", () => { + // (a) no clip -> noop + const noClip = new AudioSource({ + _isActiveInHierarchy: true, + _isActiveInScene: true, + _removeComponent() {}, + engine: {} + } as any); + noClip.play(); + expect(noClip.isPlaying).to.be.false; + expect((AudioManager as any)._context == null).to.be.true; + + // (b) already playing -> second play is a noop + const audioSource = createAudioSource(); + const context = AudioManager.getContext() as unknown as MockAudioContext; context.state = "running"; + const resumeSpy = vi.spyOn(context, "resume"); - const documentHidden = mockDocumentHidden(true); - document.dispatchEvent(new Event("visibilitychange")); - documentHidden.restore(); - await flushAsync(); + audioSource.play(); + expect(audioSource.isPlaying).to.be.true; + const count = (AudioManager as any)._playingCount; - expect(suspendSpy).toHaveBeenCalledTimes(1); - expect((AudioManager as any)._hidden).to.be.true; - }); + audioSource.play(); + expect((AudioManager as any)._playingCount).to.equal(count); + expect(resumeSpy).not.toHaveBeenCalled(); - it("resumes context on visibilitychange shown via iOS zombie fix", async () => { - createAudioSource(); - const context = (AudioManager as any)._context as MockAudioContext; - const scheduledTimers = captureScheduledTimers(); + // (c) pending play -> noop + audioSource.stop(); + context.state = "suspended"; + (audioSource as any)._pendingPlay = true; + audioSource.play(); + expect(audioSource.isPlaying).to.be.false; + }); + // KEY divergence: hidden play is dropped, never suspends + it("drops a play requested while hidden without pending or suspending", async () => { + const audioSource = createAudioSource(); + const context = AudioManager.getContext() as unknown as MockAudioContext; context.state = "running"; - const suspendSpy = vi.spyOn(context, "suspend"); - const resumeSpy = vi.spyOn(context, "resume"); + const ctxSuspendSpy = vi.spyOn(context, "suspend"); + const managerSuspendSpy = vi.spyOn(AudioManager, "suspend"); const documentHidden = mockDocumentHidden(true); - document.dispatchEvent(new Event("visibilitychange")); - expect(suspendSpy).toHaveBeenCalledTimes(1); - - documentHidden.set(false); - document.dispatchEvent(new Event("visibilitychange")); + audioSource.play(); documentHidden.restore(); - // _onShown calls context.suspend() synchronously then schedules resume after 100ms - expect(suspendSpy).toHaveBeenCalledTimes(2); - expect(scheduledTimers).to.have.lengthOf(1); - - scheduledTimers[0](); await flushAsync(); - expect(resumeSpy).toHaveBeenCalled(); + expect(audioSource.isPlaying).to.be.false; + expect((audioSource as any)._pendingPlay).to.be.false; + expect(ctxSuspendSpy).not.toHaveBeenCalled(); + expect(managerSuspendSpy).not.toHaveBeenCalled(); }); - it("does not run the delayed foreground resume after hiding again", async () => { - createAudioSource(); - const context = (AudioManager as any)._context as MockAudioContext; - const scheduledTimers = captureScheduledTimers(); - + it("does not replay a hidden-dropped play after returning to foreground", () => { + vi.useFakeTimers(); + const audioSource = createAudioSource(); + const context = AudioManager.getContext() as unknown as MockAudioContext; context.state = "running"; - const resumeSpy = vi.spyOn(context, "resume"); const documentHidden = mockDocumentHidden(true); - document.dispatchEvent(new Event("visibilitychange")); - documentHidden.set(false); - document.dispatchEvent(new Event("visibilitychange")); - expect(scheduledTimers).to.have.lengthOf(1); + audioSource.play(); + expect(audioSource.isPlaying).to.be.false; + expect((audioSource as any)._pendingPlay).to.be.false; - documentHidden.set(true); + documentHidden.set(false); document.dispatchEvent(new Event("visibilitychange")); + window.dispatchEvent(Object.assign(new Event("pageshow"), { persisted: true })); + vi.advanceTimersByTime(100); documentHidden.restore(); - scheduledTimers[0](); - await flushAsync(); - - expect(resumeSpy).not.toHaveBeenCalled(); - expect(context.state).to.equal("suspended"); + expect(audioSource.isPlaying).to.be.false; + expect((audioSource as any)._pendingPlay).to.be.false; }); - it("does not resume the context while hidden", async () => { + it("replays the pending play on the resume it triggered", async () => { const audioSource = createAudioSource(); - const context = (AudioManager as any)._context as MockAudioContext; - const resumeSpy = vi.spyOn(context, "resume"); + const documentHidden = mockDocumentHidden(false); + const context = AudioManager.getContext() as unknown as MockAudioContext; + context.state = "suspended"; - context.state = "running"; audioSource.play(); - expect(audioSource.isPlaying).to.be.true; - - window.dispatchEvent(new Event("pagehide")); - await flushAsync(); + expect((audioSource as any)._pendingPlay).to.be.true; - await AudioManager.resume(); await flushAsync(); + documentHidden.restore(); - expect(resumeSpy).not.toHaveBeenCalled(); - expect(context.state).to.equal("suspended"); + expect((audioSource as any)._pendingPlay).to.be.false; expect(audioSource.isPlaying).to.be.true; }); - it("suspends the context when resume runs after document becomes hidden but before hidden handler", async () => { - createAudioSource(); - const context = (AudioManager as any)._context as MockAudioContext; - const suspendSpy = vi.spyOn(context, "suspend"); - const resumeSpy = vi.spyOn(context, "resume"); + // HEADLINE + it("drops playback after autoplay-blocked resume instead of replaying on a later gesture", async () => { + const audioSource = createAudioSource(); + const context = AudioManager.getContext() as unknown as MockAudioContext; + context.state = "suspended"; - context.state = "running"; - const documentHidden = mockDocumentHidden(true); + vi.spyOn(console, "warn").mockImplementation(() => {}); + MockAudioContext.shouldResumeSucceed = false; - await AudioManager.resume(); - documentHidden.restore(); + audioSource.play(); await flushAsync(); - expect(resumeSpy).not.toHaveBeenCalled(); - expect(suspendSpy).toHaveBeenCalledTimes(1); - expect((AudioManager as any)._hidden).to.be.true; + expect((audioSource as any)._pendingPlay).to.be.false; + expect(audioSource.isPlaying).to.be.false; - window.dispatchEvent(new Event("pagehide")); + MockAudioContext.shouldResumeSucceed = true; + document.dispatchEvent(new Event("click")); await flushAsync(); - expect(context.state).to.equal("suspended"); + expect(audioSource.isPlaying).to.be.false; }); - it("does not start playback while document is hidden even if context is still running", async () => { + it("cancels a one-shot pending play before resume resolves", async () => { const audioSource = createAudioSource(); - const context = (AudioManager as any)._context as MockAudioContext; - const suspendSpy = vi.spyOn(context, "suspend"); + const documentHidden = mockDocumentHidden(false); + const context = AudioManager.getContext() as unknown as MockAudioContext; + context.state = "suspended"; - context.state = "running"; - const documentHidden = mockDocumentHidden(true); + let resolveResume: () => void; + MockAudioContext.resumeResultQueue = [ + new Promise((resolve) => { + resolveResume = resolve; + }) + ]; audioSource.play(); - documentHidden.restore(); + expect((audioSource as any)._pendingPlay).to.be.true; + + audioSource.stop(); + expect((audioSource as any)._pendingPlay).to.be.false; + + resolveResume!(); await flushAsync(); + documentHidden.restore(); - expect(suspendSpy).toHaveBeenCalledTimes(1); expect(audioSource.isPlaying).to.be.false; - expect((audioSource as any)._pendingPlay).to.be.false; }); - it("drops playback requested while hidden instead of replaying after show", async () => { + it("drops playback after explicit suspend when resume is autoplay-blocked", async () => { const audioSource = createAudioSource(); - const context = (AudioManager as any)._context as MockAudioContext; - const scheduledTimers = captureScheduledTimers(); - + const context = AudioManager.getContext() as unknown as MockAudioContext; context.state = "running"; - window.dispatchEvent(new Event("pagehide")); + + await AudioManager.suspend(); await flushAsync(); + MockAudioContext.shouldResumeSucceed = false; + vi.spyOn(console, "warn").mockImplementation(() => {}); + audioSource.play(); await flushAsync(); expect((audioSource as any)._pendingPlay).to.be.false; - expect(audioSource.isPlaying).to.be.false; + expect((AudioManager as any)._needsUserGestureResume).to.be.false; - window.dispatchEvent(new Event("pageshow")); - expect(scheduledTimers).to.have.lengthOf(1); - scheduledTimers[0](); + MockAudioContext.shouldResumeSucceed = true; + document.dispatchEvent(new Event("click")); await flushAsync(); - expect(context.state).to.equal("running"); expect(audioSource.isPlaying).to.be.false; }); - it("does not auto-resume a caller-controlled suspend on the next gesture", async () => { + it("resume() unlocks a suspended context and clears the gesture flag", async () => { + createAudioSource(); + const context = AudioManager.getContext() as unknown as MockAudioContext; + context.state = "suspended"; + (AudioManager as any)._needsUserGestureResume = true; + + await AudioManager.resume(); + + expect(context.state).to.equal("running"); + expect((AudioManager as any)._needsUserGestureResume).to.be.false; + }); + + it("coalesces overlapping resume() calls and re-issues a later resume", async () => { createAudioSource(); - const context = (AudioManager as any)._context as MockAudioContext; + const context = AudioManager.getContext() as unknown as MockAudioContext; + context.state = "suspended"; + + let resolveFirst: () => void; + MockAudioContext.resumeResultQueue = [ + new Promise((resolve) => { + resolveFirst = resolve; + }) + ]; const resumeSpy = vi.spyOn(context, "resume"); + AudioManager.resume().catch(() => {}); + AudioManager.resume().catch(() => {}); + expect(resumeSpy).toHaveBeenCalledTimes(1); + + resolveFirst!(); + await flushAsync(); + + await AudioManager.resume(); + expect(resumeSpy).toHaveBeenCalledTimes(2); + }); + + it("does not auto-resume a caller-controlled suspend on a later gesture", async () => { + createAudioSource(); + const context = AudioManager.getContext() as unknown as MockAudioContext; context.state = "running"; + const resumeSpy = vi.spyOn(context, "resume"); + await AudioManager.suspend(); await flushAsync(); - expect((AudioManager as any)._needsUserGestureResume).to.be.false; - document.dispatchEvent(new Event("click")); + document.dispatchEvent(new Event("touchend")); await flushAsync(); expect(resumeSpy).not.toHaveBeenCalled(); expect(context.state).to.equal("suspended"); + expect((AudioManager as any)._needsUserGestureResume).to.be.false; }); - it("drops playback after explicit suspend when resume is autoplay-blocked", async () => { + it("keeps a playing source playing across a hide without tearing down the node", async () => { const audioSource = createAudioSource(); - const context = (AudioManager as any)._context as MockAudioContext; - + const context = AudioManager.getContext() as unknown as MockAudioContext; context.state = "running"; - await AudioManager.suspend(); + + audioSource.play(); + expect(audioSource.isPlaying).to.be.true; + const count = (AudioManager as any)._playingCount; + + const documentHidden = mockDocumentHidden(true); + document.dispatchEvent(new Event("visibilitychange")); + documentHidden.restore(); await flushAsync(); - MockAudioContext.shouldResumeSucceed = false; - vi.spyOn(console, "warn").mockImplementation(() => {}); + expect(audioSource.isPlaying).to.be.true; + expect((AudioManager as any)._playingCount).to.equal(count); + }); + + it("performs the foreground zombie reset: suspend, 100ms, resume", async () => { + vi.useFakeTimers(); + const audioSource = createAudioSource(); + const context = AudioManager.getContext() as unknown as MockAudioContext; + context.state = "running"; audioSource.play(); - await flushAsync(); + expect((AudioManager as any)._playingCount > 0).to.be.true; - expect((audioSource as any)._pendingPlay).to.be.false; - expect((AudioManager as any)._needsUserGestureResume).to.be.false; + // simulate iOS leaving the context non-running after the interruption + context.state = "suspended"; + const suspendSpy = vi.spyOn(context, "suspend"); + const resumeSpy = vi.spyOn(context, "resume"); - MockAudioContext.shouldResumeSucceed = true; - document.dispatchEvent(new Event("click")); + const documentHidden = mockDocumentHidden(false); + document.dispatchEvent(new Event("visibilitychange")); + + expect(suspendSpy).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(100); await flushAsync(); + documentHidden.restore(); - expect(audioSource.isPlaying).to.be.false; + expect(resumeSpy).toHaveBeenCalledTimes(1); + expect((AudioManager as any)._recovering).to.be.false; + expect(context.state).to.equal("running"); }); - it("does not act on visibilitychange shown without prior hide", async () => { - createAudioSource(); - const context = (AudioManager as any)._context as MockAudioContext; + it("runs a single recovery cycle for back-to-back visibilitychange and pageshow", () => { + const audioSource = createAudioSource(); + const context = AudioManager.getContext() as unknown as MockAudioContext; + context.state = "running"; + audioSource.play(); + context.state = "suspended"; + + const scheduledTimers = captureScheduledTimers(); const suspendSpy = vi.spyOn(context, "suspend"); const documentHidden = mockDocumentHidden(false); document.dispatchEvent(new Event("visibilitychange")); + window.dispatchEvent(Object.assign(new Event("pageshow"), { persisted: true })); + documentHidden.restore(); + + // _recovering guards the 2nd dispatch between the synchronous events + expect(suspendSpy).toHaveBeenCalledTimes(1); + expect(scheduledTimers).to.have.lengthOf(1); + }); + + it("skips recovery when nothing is playing", () => { + vi.useFakeTimers(); + const audioSource = createAudioSource(); + const context = AudioManager.getContext() as unknown as MockAudioContext; + context.state = "running"; + + audioSource.play(); + audioSource.stop(); + expect((AudioManager as any)._playingCount).to.equal(0); + + context.state = "suspended"; + const suspendSpy = vi.spyOn(context, "suspend"); + const resumeSpy = vi.spyOn(context, "resume"); + + const documentHidden = mockDocumentHidden(false); + document.dispatchEvent(new Event("visibilitychange")); + window.dispatchEvent(Object.assign(new Event("pageshow"), { persisted: true })); + vi.advanceTimersByTime(100); documentHidden.restore(); - await flushAsync(); expect(suspendSpy).not.toHaveBeenCalled(); + expect(resumeSpy).not.toHaveBeenCalled(); }); - it("handles pagehide/pageshow lifecycle", async () => { - createAudioSource(); - const context = (AudioManager as any)._context as MockAudioContext; - const scheduledTimers = captureScheduledTimers(); + it("skips recovery after a caller suspend across a hide/show", async () => { + vi.useFakeTimers(); + const audioSource = createAudioSource(); + const context = AudioManager.getContext() as unknown as MockAudioContext; context.state = "running"; - window.dispatchEvent(new Event("pagehide")); - expect((AudioManager as any)._hidden).to.be.true; - expect(context.state).to.equal("suspended"); + audioSource.play(); + await AudioManager.suspend(); - window.dispatchEvent(new Event("pageshow")); - expect((AudioManager as any)._hidden).to.be.false; - expect(scheduledTimers).to.have.lengthOf(1); + const resumeSpy = vi.spyOn(context, "resume"); - // iOS zombie fix uses window.setTimeout(100ms) - scheduledTimers[0](); - await flushAsync(); - await flushAsync(); + const documentHidden = mockDocumentHidden(false); + document.dispatchEvent(new Event("visibilitychange")); + window.dispatchEvent(Object.assign(new Event("pageshow"), { persisted: true })); + vi.advanceTimersByTime(100); + documentHidden.restore(); - expect(context.state).to.equal("running"); + expect(resumeSpy).not.toHaveBeenCalled(); + expect(context.state).to.equal("suspended"); }); - it("sets gesture resume flag when foreground resume fails", async () => { - createAudioSource(); - const context = (AudioManager as any)._context as MockAudioContext; - const scheduledTimers = captureScheduledTimers(); + it("falls back to a gesture when the foreground resume fails, then a click resumes", async () => { + vi.useFakeTimers(); + const audioSource = createAudioSource(); + const context = AudioManager.getContext() as unknown as MockAudioContext; context.state = "running"; - window.dispatchEvent(new Event("pagehide")); + audioSource.play(); + context.state = "suspended"; - // Show, but resume will fail - MockAudioContext.shouldResumeSucceed = false; - window.dispatchEvent(new Event("pageshow")); - expect(scheduledTimers).to.have.lengthOf(1); + // the timer's auto-resume rejects, leaving the gesture fallback armed + MockAudioContext.resumeResultQueue = [new Error("autoplay blocked")]; + vi.spyOn(console, "warn").mockImplementation(() => {}); - scheduledTimers[0](); + const documentHidden = mockDocumentHidden(false); + document.dispatchEvent(new Event("visibilitychange")); + vi.advanceTimersByTime(100); await flushAsync(); expect((AudioManager as any)._needsUserGestureResume).to.be.true; + expect(context.state).to.equal("suspended"); - // Gesture succeeds + vi.useRealTimers(); + MockAudioContext.resumeResultQueue = null; MockAudioContext.shouldResumeSucceed = true; document.dispatchEvent(new Event("click")); await flushAsync(); + documentHidden.restore(); expect((AudioManager as any)._needsUserGestureResume).to.be.false; expect(context.state).to.equal("running"); }); - it("continues foreground resume when the zombie-reset suspend rejects", async () => { - createAudioSource(); - const context = (AudioManager as any)._context as MockAudioContext; - const scheduledTimers = captureScheduledTimers(); - const resumeSpy = vi.spyOn(context, "resume"); + it("still resumes when the zombie-reset suspend rejects", async () => { + vi.useFakeTimers(); + const audioSource = createAudioSource(); + const context = AudioManager.getContext() as unknown as MockAudioContext; context.state = "running"; - window.dispatchEvent(new Event("pagehide")); + audioSource.play(); + context.state = "suspended"; + MockAudioContext.shouldSuspendSucceed = false; - window.dispatchEvent(new Event("pageshow")); - expect(scheduledTimers).to.have.lengthOf(1); + const resumeSpy = vi.spyOn(context, "resume"); - scheduledTimers[0](); + const documentHidden = mockDocumentHidden(false); + document.dispatchEvent(new Event("visibilitychange")); + vi.advanceTimersByTime(100); await flushAsync(); + documentHidden.restore(); expect(resumeSpy).toHaveBeenCalledTimes(1); expect(context.state).to.equal("running"); + expect((AudioManager as any)._recovering).to.be.false; }); - it("retries context.resume inside a later user gesture even if an earlier resume is still pending", async () => { - createAudioSource(); - const context = (AudioManager as any)._context as MockAudioContext; - const firstResume = new Promise(() => {}); - - MockAudioContext.resumeResultQueue = [firstResume]; - const resumeSpy = vi.spyOn(context, "resume"); - - AudioManager.resume().catch(() => {}); - await flushAsync(); + it("treats a non-persisted pageshow as a no-op", () => { + const audioSource = createAudioSource(); + const context = AudioManager.getContext() as unknown as MockAudioContext; + context.state = "running"; - expect(resumeSpy).toHaveBeenCalledTimes(1); + audioSource.play(); + context.state = "suspended"; - MockAudioContext.resumeResultQueue = [Promise.resolve()]; - context.onstatechange?.(); - await flushAsync(); + const scheduledTimers = captureScheduledTimers(); + const suspendSpy = vi.spyOn(context, "suspend"); - document.dispatchEvent(new Event("click")); - await flushAsync(); + window.dispatchEvent(Object.assign(new Event("pageshow"), { persisted: false })); - expect(resumeSpy).toHaveBeenCalledTimes(2); - expect(context.state).to.equal("running"); - expect((AudioManager as any)._needsUserGestureResume).to.be.false; + expect(suspendSpy).not.toHaveBeenCalled(); + expect(scheduledTimers).to.have.lengthOf(0); }); - it("does not resume a stopped source after hide/show cycle", async () => { + it("does nothing on a spurious visibilitychange-shown with a running context", async () => { const audioSource = createAudioSource(); - const context = (AudioManager as any)._context as MockAudioContext; - const scheduledTimers = captureScheduledTimers(); + const context = AudioManager.getContext() as unknown as MockAudioContext; context.state = "running"; audioSource.play(); - expect(audioSource.isPlaying).to.be.true; - audioSource.stop(); - expect(audioSource.isPlaying).to.be.false; + const suspendSpy = vi.spyOn(context, "suspend"); + const resumeSpy = vi.spyOn(context, "resume"); - // hide → show cycle - window.dispatchEvent(new Event("pagehide")); - window.dispatchEvent(new Event("pageshow")); - expect(scheduledTimers).to.have.lengthOf(1); - scheduledTimers[0](); + const documentHidden = mockDocumentHidden(false); + document.dispatchEvent(new Event("visibilitychange")); + documentHidden.restore(); await flushAsync(); - // Source stays stopped — context resume does not restart stopped sources - expect(audioSource.isPlaying).to.be.false; + expect(suspendSpy).not.toHaveBeenCalled(); + expect(resumeSpy).not.toHaveBeenCalled(); }); - it("marks external context interruption as gesture-retryable", async () => { - createAudioSource(); - const context = (AudioManager as any)._context as MockAudioContext; - const resumeSpy = vi.spyOn(context, "resume"); + it("keeps stop()/pause() bookkeeping consistent", () => { + const audioSource = createAudioSource(); + const context = AudioManager.getContext() as unknown as MockAudioContext; + context.state = "running"; + context.currentTime = 5; - context.state = "suspended"; - context.onstatechange?.(); - await flushAsync(); + audioSource.play(); + const playingCount = (AudioManager as any)._playingCount; - MockAudioContext.shouldResumeSucceed = true; - document.dispatchEvent(new Event("pointerup")); - await flushAsync(); + audioSource.pause(); + expect((AudioManager as any)._playingCount).to.equal(playingCount - 1); + expect(audioSource.isPlaying).to.be.false; + expect((audioSource as any)._pausedTime > 0).to.be.true; - expect(resumeSpy).toHaveBeenCalledTimes(1); - expect(context.state).to.equal("running"); + audioSource.play(); + const playingCount2 = (AudioManager as any)._playingCount; + + audioSource.stop(); + expect((audioSource as any)._pausedTime).to.equal(-1); + expect((audioSource as any)._playTime).to.equal(-1); + expect((AudioManager as any)._playingCount).to.equal(playingCount2 - 1); + expect((audioSource as any)._pendingPlay).to.be.false; }); }); From a8eda78a12009a3360ad537fdc98477a587bdc2b Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Mon, 22 Jun 2026 16:57:57 +0800 Subject: [PATCH 2/9] fix(audio): suspend on hide so backgrounded audio stops on all platforms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 回应 review:覆盖版连带删了全部 hide→suspend,导致桌面 Chrome/Firefox、Android Chrome 切 tab/最小化时音频继续在后台出声——这些平台不会自动 suspend 正在运行的 WebAudio ctx(只有 iOS Safari 会)。纯 WebAudio 真机实测确认后台一直响。 “切后台静音”是引擎行业默认(Unity runInBackground=false、Unreal UnfocusedVolumeMultiplier=0.0、Phaser pauseOnBlur=true、Cocos visibilitychange 硬挂起)。 不加 opt-out 开关:Web 上 iOS Safari 系统强制后台 suspend、开发者无法绕过,给“后台 继续放”的开关在 iOS 上必然失效,故硬编码默认 hide→suspend、全平台对齐 iOS 约束。 - visibilitychange 改挂分派器 _onVisibilityChange:hidden → _context?.suspend() (仅在 ctx 已存在时,不为 suspend 去创建 ctx,不污染 _suspendedByCaller,故回前台 仍正常恢复);visible → _recoverPlaybackContext - 恢复定时器 resume 前加 document.hidden / _suspendedByCaller staleness 检查:回前台 触发 suspend→100ms→resume 的窗口内若又切回后台,不再在隐藏页 resume(对齐 play() 对 document.hidden 的 drop) hide→suspend 与延迟创建 ctx(iOS 来电根因)是正交两件事,分开处理。补 4 个测试。 --- packages/core/src/audio/AudioManager.ts | 16 +++- .../audio/AudioSourcePendingPlayback.test.ts | 74 ++++++++++++++++++- 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/packages/core/src/audio/AudioManager.ts b/packages/core/src/audio/AudioManager.ts index 475fb5a4d6..6d80b67750 100644 --- a/packages/core/src/audio/AudioManager.ts +++ b/packages/core/src/audio/AudioManager.ts @@ -45,7 +45,7 @@ export class AudioManager { let context = AudioManager._context; if (!context) { AudioManager._context = context = new window.AudioContext(); - document.addEventListener("visibilitychange", AudioManager._recoverPlaybackContext); + document.addEventListener("visibilitychange", AudioManager._onVisibilityChange); // bfcache restore fires pageshow (persisted) but NOT visibilitychange, so recover here too window.addEventListener("pageshow", AudioManager._onPageShow); // iOS Safari requires user gesture to resume AudioContext @@ -76,6 +76,16 @@ export class AudioManager { return AudioManager.getContext().state === "running"; } + private static _onVisibilityChange(): void { + if (document.hidden) { + // Desktop/Android don't auto-suspend a running WebAudio context when backgrounded (only iOS does), + // so suspend here to stop audio in the background; only if a context already exists (don't create one) + AudioManager._context?.suspend().catch(() => {}); + } else { + AudioManager._recoverPlaybackContext(); + } + } + private static _recoverPlaybackContext(): void { // Returning to foreground with a non-running context (and not a deliberate pause): iOS leaves it // "interrupted", which cannot be resumed directly; suspend() first transitions it to "suspended", @@ -100,6 +110,10 @@ export class AudioManager { // 100ms is an empirical delay; resuming too soon after suspend is unreliable. setTimeout(() => { AudioManager._recovering = false; + // Hidden again during the delay, or a deliberate pause landed: don't resume on a backgrounded page + if (document.hidden || AudioManager._suspendedByCaller) { + return; + } context .resume() .then(() => { diff --git a/tests/src/core/audio/AudioSourcePendingPlayback.test.ts b/tests/src/core/audio/AudioSourcePendingPlayback.test.ts index 59333a1b10..c9c46de912 100644 --- a/tests/src/core/audio/AudioSourcePendingPlayback.test.ts +++ b/tests/src/core/audio/AudioSourcePendingPlayback.test.ts @@ -93,7 +93,7 @@ function createAudioSource(): AudioSource { } function resetAudioManagerState(): void { - document.removeEventListener("visibilitychange", (AudioManager as any)._recoverPlaybackContext); + document.removeEventListener("visibilitychange", (AudioManager as any)._onVisibilityChange); window.removeEventListener("pageshow", (AudioManager as any)._onPageShow); document.removeEventListener("touchstart", (AudioManager as any)._resumeAfterInterruption); document.removeEventListener("touchend", (AudioManager as any)._resumeAfterInterruption); @@ -646,4 +646,76 @@ describe("AudioSource playback lifecycle", () => { expect((AudioManager as any)._playingCount).to.equal(playingCount2 - 1); expect((audioSource as any)._pendingPlay).to.be.false; }); + + // hide-suspend: desktop/Android don't auto-suspend WebAudio when backgrounded, so we suspend on hide + it("suspends the context when the page is hidden", () => { + const audioSource = createAudioSource(); + const context = AudioManager.getContext() as unknown as MockAudioContext; + context.state = "running"; + audioSource.play(); + const suspendSpy = vi.spyOn(context, "suspend"); + + const documentHidden = mockDocumentHidden(true); + document.dispatchEvent(new Event("visibilitychange")); + documentHidden.restore(); + + expect(suspendSpy).toHaveBeenCalledTimes(1); + }); + + // hide-suspend must not create a context (would break the deferred-creation root-cause fix) + it("does not create a context on hide when none exists", () => { + document.removeEventListener("visibilitychange", (AudioManager as any)._onVisibilityChange); + document.addEventListener("visibilitychange", (AudioManager as any)._onVisibilityChange); + + const documentHidden = mockDocumentHidden(true); + document.dispatchEvent(new Event("visibilitychange")); + documentHidden.restore(); + + expect((AudioManager as any)._context == null).to.be.true; + }); + + // hide-suspend uses the bare context.suspend(), so a return to foreground still recovers + it("recovers after a hide-suspend (hide does not flag _suspendedByCaller)", async () => { + vi.useFakeTimers(); + const audioSource = createAudioSource(); + const context = AudioManager.getContext() as unknown as MockAudioContext; + context.state = "running"; + audioSource.play(); + + const documentHidden = mockDocumentHidden(true); + document.dispatchEvent(new Event("visibilitychange")); + expect((AudioManager as any)._suspendedByCaller).to.be.false; + + const resumeSpy = vi.spyOn(context, "resume"); + documentHidden.set(false); + document.dispatchEvent(new Event("visibilitychange")); + vi.advanceTimersByTime(100); + await flushAsync(); + documentHidden.restore(); + + expect(resumeSpy).toHaveBeenCalledTimes(1); + expect(context.state).to.equal("running"); + }); + + // staleness guard: hidden again during the 100ms recovery delay must not resume on a backgrounded page + it("does not resume if hidden again during the recovery delay", async () => { + vi.useFakeTimers(); + const audioSource = createAudioSource(); + const context = AudioManager.getContext() as unknown as MockAudioContext; + context.state = "running"; + audioSource.play(); + context.state = "suspended"; + + const documentHidden = mockDocumentHidden(false); + document.dispatchEvent(new Event("visibilitychange")); + const resumeSpy = vi.spyOn(context, "resume"); + + // hidden again before the 100ms timer fires + documentHidden.set(true); + vi.advanceTimersByTime(100); + await flushAsync(); + documentHidden.restore(); + + expect(resumeSpy).not.toHaveBeenCalled(); + }); }); From fd15f7c8c1424ff8290e075cad2f1dbacfee4528 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Mon, 22 Jun 2026 17:10:18 +0800 Subject: [PATCH 3/9] fix(audio): don't create a context just to suspend it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AudioManager.suspend() 是公开 API,原先无条件 getContext().suspend()——用户在任何 播放前调一次就会凭空创建一个手势前的“冷”上下文,正是本 PR 根因要规避的 iOS zombie 来源。加 !_context 守卫:无上下文时直接 resolve(没东西在播,无需 suspend)。 --- packages/core/src/audio/AudioManager.ts | 4 +++- tests/src/core/audio/AudioSourcePendingPlayback.test.ts | 8 ++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/core/src/audio/AudioManager.ts b/packages/core/src/audio/AudioManager.ts index 6d80b67750..aa15feced7 100644 --- a/packages/core/src/audio/AudioManager.ts +++ b/packages/core/src/audio/AudioManager.ts @@ -18,7 +18,9 @@ export class AudioManager { */ static suspend(): Promise { AudioManager._suspendedByCaller = true; - return AudioManager.getContext().suspend(); + // No context yet means nothing is playing; don't create a (cold) one just to suspend it + const context = AudioManager._context; + return context ? context.suspend() : Promise.resolve(); } /** diff --git a/tests/src/core/audio/AudioSourcePendingPlayback.test.ts b/tests/src/core/audio/AudioSourcePendingPlayback.test.ts index c9c46de912..713e55f32a 100644 --- a/tests/src/core/audio/AudioSourcePendingPlayback.test.ts +++ b/tests/src/core/audio/AudioSourcePendingPlayback.test.ts @@ -647,6 +647,14 @@ describe("AudioSource playback lifecycle", () => { expect((audioSource as any)._pendingPlay).to.be.false; }); + // suspend() must not create a context just to suspend it (would be the cold-ctx iOS zombie we avoid) + it("does not create a context when suspend() is called before any playback", async () => { + await AudioManager.suspend(); + + expect((AudioManager as any)._context == null).to.be.true; + expect((AudioManager as any)._suspendedByCaller).to.be.true; + }); + // hide-suspend: desktop/Android don't auto-suspend WebAudio when backgrounded, so we suspend on hide it("suspends the context when the page is hidden", () => { const audioSource = createAudioSource(); From b1d9e62ab4fd16662735f28ddf151d54402317fd Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Mon, 22 Jun 2026 17:13:20 +0800 Subject: [PATCH 4/9] docs(audio): attribute platform-specific handling in comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 音频生命周期里多处是平台专属处理(bfcache pageshow、手势 resume、手势兜底都只为 iOS Safari),原注释没点明平台,容易被误当通用逻辑误删。补上平台归属:bfcache / pageshow / _resumeAfterInterruption 三处注释明确标注 iOS Safari。 仅注释。 --- packages/core/src/audio/AudioManager.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/core/src/audio/AudioManager.ts b/packages/core/src/audio/AudioManager.ts index aa15feced7..ecf68df576 100644 --- a/packages/core/src/audio/AudioManager.ts +++ b/packages/core/src/audio/AudioManager.ts @@ -48,9 +48,9 @@ export class AudioManager { if (!context) { AudioManager._context = context = new window.AudioContext(); document.addEventListener("visibilitychange", AudioManager._onVisibilityChange); - // bfcache restore fires pageshow (persisted) but NOT visibilitychange, so recover here too + // iOS Safari bfcache restore fires pageshow (persisted) but NOT visibilitychange, so recover here too window.addEventListener("pageshow", AudioManager._onPageShow); - // iOS Safari requires user gesture to resume AudioContext + // iOS Safari requires a user gesture to resume the AudioContext document.addEventListener("touchstart", AudioManager._resumeAfterInterruption, { passive: true }); document.addEventListener("touchend", AudioManager._resumeAfterInterruption, { passive: true }); document.addEventListener("click", AudioManager._resumeAfterInterruption); @@ -126,13 +126,14 @@ export class AudioManager { } private static _onPageShow(event: PageTransitionEvent): void { - // Only a bfcache restore needs handling here; a normal load has no suspended context to recover + // iOS Safari bfcache restore (persisted) needs recovery; a normal load has no suspended context if (event.persisted) { AudioManager._recoverPlaybackContext(); } } private static _resumeAfterInterruption(): void { + // iOS Safari fallback: when auto-resume is blocked, resume on the next user gesture if (!AudioManager._suspendedByCaller && AudioManager._needsUserGestureResume) { AudioManager.resume().catch((e) => { console.warn("Failed to resume AudioContext:", e); From b52f998bec3509c90bc6289c80faefb132cda6cf Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Mon, 22 Jun 2026 18:14:50 +0800 Subject: [PATCH 5/9] fix(audio): don't flag a caller-suspend when there is no context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit suspend() 在无 context 时本应是 no-op(没有正在播放的音频可暂停),却仍置了 _suspendedByCaller=true,留下一个“幽灵”标志。之后首次 play()(在用户手势内创建 context、开始播放)后,这个标志会让切后台再回前台的 _recoverPlaybackContext 守卫 短路,导致音频无法恢复。 真机复现:序列「先 suspend()(无 ctx)→ play → 切后台 → 回前台」在 Android Chrome 不恢复(无声),iOS 正常。根因平台分歧:iOS 新建 context 默认 suspended → play 走 resume 分支(resume 清标志);Android 在手势内新建 context 立即 running → play 走 _startPlayback 直接播、跳过 resume → 标志残留 true。 修复落在源头:无 context 时直接 resolve 且不置标志(suspend 一个不存在的播放不该 留状态)。补回归测试覆盖该序列。Android 真机验证回前台恢复出声。 --- packages/core/src/audio/AudioManager.ts | 10 +++++-- .../audio/AudioSourcePendingPlayback.test.ts | 29 +++++++++++++++++-- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/packages/core/src/audio/AudioManager.ts b/packages/core/src/audio/AudioManager.ts index ecf68df576..fb68da8f66 100644 --- a/packages/core/src/audio/AudioManager.ts +++ b/packages/core/src/audio/AudioManager.ts @@ -17,10 +17,14 @@ export class AudioManager { * @returns A promise that resolves when the audio context is suspended */ static suspend(): Promise { - AudioManager._suspendedByCaller = true; - // No context yet means nothing is playing; don't create a (cold) one just to suspend it + // No context means nothing is playing: suspending is a no-op and must NOT flag a caller-suspend + // (a ghost flag would later block foreground recovery), and don't create a cold context just to suspend const context = AudioManager._context; - return context ? context.suspend() : Promise.resolve(); + if (!context) { + return Promise.resolve(); + } + AudioManager._suspendedByCaller = true; + return context.suspend(); } /** diff --git a/tests/src/core/audio/AudioSourcePendingPlayback.test.ts b/tests/src/core/audio/AudioSourcePendingPlayback.test.ts index 713e55f32a..f1258a0eb3 100644 --- a/tests/src/core/audio/AudioSourcePendingPlayback.test.ts +++ b/tests/src/core/audio/AudioSourcePendingPlayback.test.ts @@ -648,11 +648,36 @@ describe("AudioSource playback lifecycle", () => { }); // suspend() must not create a context just to suspend it (would be the cold-ctx iOS zombie we avoid) - it("does not create a context when suspend() is called before any playback", async () => { + // suspend() with no context is a no-op: it must NOT create a context AND must NOT flag a caller-suspend + // (a ghost flag would later block foreground recovery once playback starts) + it("does not create a context or flag a caller-suspend when suspend() runs before any playback", async () => { await AudioManager.suspend(); expect((AudioManager as any)._context == null).to.be.true; - expect((AudioManager as any)._suspendedByCaller).to.be.true; + expect((AudioManager as any)._suspendedByCaller).to.be.false; + }); + + // root cause regression: suspend() before first play (no ctx) must not leave a ghost flag that blocks + // foreground recovery after the page is later backgrounded and restored + it("recovers after suspend()-before-first-play then a hide/show cycle", async () => { + vi.useFakeTimers(); + AudioManager.suspend(); + const audioSource = createAudioSource(); + const context = AudioManager.getContext() as unknown as MockAudioContext; + context.state = "running"; + audioSource.play(); + expect((AudioManager as any)._suspendedByCaller).to.be.false; + + context.state = "suspended"; + const resumeSpy = vi.spyOn(context, "resume"); + const documentHidden = mockDocumentHidden(false); + document.dispatchEvent(new Event("visibilitychange")); + vi.advanceTimersByTime(100); + await flushAsync(); + documentHidden.restore(); + + expect(resumeSpy).toHaveBeenCalledTimes(1); + expect(context.state).to.equal("running"); }); // hide-suspend: desktop/Android don't auto-suspend WebAudio when backgrounded, so we suspend on hide From 8f96b2627f2b46fc2e3d19f5cd1f3d9d15af012e Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Tue, 23 Jun 2026 14:03:11 +0800 Subject: [PATCH 6/9] fix(audio): reset offset on stop and wrap loop resume into the buffer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 回应 review 两条 [P1]: 1. stop() 不再重置已暂停音源的播放偏移。`_pausedTime`/`_playTime` 的重置原先放在 `if (this._isPlaying)` 分支内,而 `play() → pause() → stop()` 后 `_isPlaying` 已为 false,导致重置被跳过;再 `play()` 会从旧暂停点续播而非从 0 开始,违反 stop() 的归零契约。把重置移出分支:stop() 无条件归零(拆 source node 仍只在 有 node 时做)。 2. 循环音频恢复用累计播放时长当 offset,丢失循环相位。`_startPlayback` 的 `startTime` 是累计已播时长,循环播放会无限增长越过 buffer 时长;直接传给 `sourceNode.start(0, offset)` 时——按 spec,越界 offset 被 clamp(不 wrap)—— 会丢相位(10s 循环播 35s 应恢复到 5s,却传 35s)。loop 时对 buffer.duration 取模,把累计时长折回 buffer 内的循环相位。non-loop 不取模(暂停点天然 < 时长)。 补回归测试:play→pause→stop→play 从 0 开始;loop 累计 35s 恢复 start(0, 5)。 --- packages/core/src/audio/AudioSource.ts | 15 ++++--- .../audio/AudioSourcePendingPlayback.test.ts | 39 +++++++++++++++++++ 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/packages/core/src/audio/AudioSource.ts b/packages/core/src/audio/AudioSource.ts index c784541d23..b149e5609e 100644 --- a/packages/core/src/audio/AudioSource.ts +++ b/packages/core/src/audio/AudioSource.ts @@ -196,12 +196,13 @@ export class AudioSource extends Component { if (this._isPlaying) { this._clearSourceNode(); - this._isPlaying = false; - this._pausedTime = -1; - this._playTime = -1; AudioManager._playingCount--; } + + // stop() always resets to the start, including from a paused state (where _isPlaying is already false) + this._pausedTime = -1; + this._playTime = -1; } /** @@ -278,15 +279,19 @@ export class AudioSource extends Component { private _initSourceNode(startTime: number): void { const context = AudioManager.getContext(); const sourceNode = context.createBufferSource(); + const buffer = this._clip._getAudioSource(); - sourceNode.buffer = this._clip._getAudioSource(); + sourceNode.buffer = buffer; sourceNode.playbackRate.value = this._playbackRate; sourceNode.loop = this._loop; sourceNode.onended = this._onPlayEnd; this._sourceNode = sourceNode; sourceNode.connect(this._ensureGainNode()); - sourceNode.start(0, startTime); + // startTime is total elapsed time; for a looping clip wrap it into the buffer to keep the loop phase + // (start()'s offset clamps past the end, it does not wrap) + const offset = this._loop && buffer.duration > 0 ? startTime % buffer.duration : startTime; + sourceNode.start(0, offset); } private _clearSourceNode(): void { diff --git a/tests/src/core/audio/AudioSourcePendingPlayback.test.ts b/tests/src/core/audio/AudioSourcePendingPlayback.test.ts index f1258a0eb3..0d7c06918f 100644 --- a/tests/src/core/audio/AudioSourcePendingPlayback.test.ts +++ b/tests/src/core/audio/AudioSourcePendingPlayback.test.ts @@ -647,6 +647,45 @@ describe("AudioSource playback lifecycle", () => { expect((audioSource as any)._pendingPlay).to.be.false; }); + // stop() from a PAUSED state must reset the offset so the next play() starts from 0, not the pause point + it("stop() resets the paused offset (play -> pause -> stop -> play starts from 0)", () => { + const audioSource = createAudioSource(); + const context = AudioManager.getContext() as unknown as MockAudioContext; + context.state = "running"; + context.currentTime = 5; + + audioSource.play(); + context.currentTime = 8; + audioSource.pause(); + expect((audioSource as any)._pausedTime > 0).to.be.true; + + // stop() while paused (_isPlaying already false) must still clear the offset + audioSource.stop(); + expect((audioSource as any)._pausedTime).to.equal(-1); + expect((audioSource as any)._playTime).to.equal(-1); + + context.currentTime = 12; + audioSource.play(); + expect(audioSource.time).to.equal(0); + }); + + // a looping clip resumed past one full loop must start from the loop phase, not a clamped offset + it("wraps the resume offset into the loop for a looping clip", () => { + const audioSource = createAudioSource(); + audioSource.loop = true; + const context = AudioManager.getContext() as unknown as MockAudioContext; + context.state = "running"; + + // simulate resuming at 35s elapsed on a 10s clip (duration from the clip mock) + (audioSource as any)._pausedTime = 35; + (audioSource as any)._playTime = 0; + audioSource.play(); + + const sourceNode = (audioSource as any)._sourceNode; + // start(0, offset): 35 % 10 = 5, not the clamped 35 + expect(sourceNode.start).toHaveBeenCalledWith(0, 5); + }); + // suspend() must not create a context just to suspend it (would be the cold-ctx iOS zombie we avoid) // suspend() with no context is a no-op: it must NOT create a context AND must NOT flag a caller-suspend // (a ghost flag would later block foreground recovery once playback starts) From 597a89f9e95566ac8ab5b927d9e013dac5afb725 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Tue, 23 Jun 2026 15:27:51 +0800 Subject: [PATCH 7/9] fix(audio): don't repeat context.resume() during the recovery window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 回应 review:_recoverPlaybackContext 的 100ms 定时器原先裸调 context.resume(),不走 AudioManager.resume()=不占用 _resumePromise;恢复窗口内手势穿过守卫后再走 AudioManager.resume() 时,promise 仍是 null,于是又一次 context.resume()。真机 iOS 恢复过程中狂点屏幕可复现 2 次 context.resume()(节奏取决于 iOS interrupted→running 落地耗时,经验 300~400ms)。 修复: - 定时器改走 AudioManager.resume(),让 _resumePromise 在恢复期占住合并锁;后续手势 再调 AudioManager.resume() 时 ??= 短路、复用同一 promise,整窗口只一次 context.resume()。 - _resumeAfterInterruption 加 _recovering 守卫:suspend→100ms→resume 的前 100ms 窗口里手势直接 return,不在 suspend 未落地时抢跑(守注释强调的 empirical delay)。 补回归测试: - 恢复中(_recovering=true)手势不触发 resume - 恢复后(_recovering=false)resume 未 settle 的 click 风暴被 _resumePromise 合并为 单次 context.resume() --- packages/core/src/audio/AudioManager.ts | 23 +++--- .../audio/AudioSourcePendingPlayback.test.ts | 72 +++++++++++++++++++ 2 files changed, 84 insertions(+), 11 deletions(-) diff --git a/packages/core/src/audio/AudioManager.ts b/packages/core/src/audio/AudioManager.ts index fb68da8f66..dba518cdaa 100644 --- a/packages/core/src/audio/AudioManager.ts +++ b/packages/core/src/audio/AudioManager.ts @@ -120,12 +120,10 @@ export class AudioManager { if (document.hidden || AudioManager._suspendedByCaller) { return; } - context - .resume() - .then(() => { - AudioManager._needsUserGestureResume = false; - }) - .catch(() => {}); + // Go through AudioManager.resume() so its _resumePromise coalesces any user-gesture resume that + // races us during the slow iOS interrupted->running transition (~300-400ms); otherwise a bare + // context.resume() here would not occupy the promise and a gesture would issue a second call + AudioManager.resume().catch(() => {}); }, 100); } @@ -137,11 +135,14 @@ export class AudioManager { } private static _resumeAfterInterruption(): void { - // iOS Safari fallback: when auto-resume is blocked, resume on the next user gesture - if (!AudioManager._suspendedByCaller && AudioManager._needsUserGestureResume) { - AudioManager.resume().catch((e) => { - console.warn("Failed to resume AudioContext:", e); - }); + // iOS Safari fallback: when auto-resume is blocked, resume on the next user gesture. + // Skip while _recovering: the recovery timer still owns the resume, and resuming now would both + // double-call context.resume() and fire before the suspend has settled (the empirical delay) + if (AudioManager._recovering || AudioManager._suspendedByCaller || !AudioManager._needsUserGestureResume) { + return; } + AudioManager.resume().catch((e) => { + console.warn("Failed to resume AudioContext:", e); + }); } } diff --git a/tests/src/core/audio/AudioSourcePendingPlayback.test.ts b/tests/src/core/audio/AudioSourcePendingPlayback.test.ts index 0d7c06918f..ac05bf2fde 100644 --- a/tests/src/core/audio/AudioSourcePendingPlayback.test.ts +++ b/tests/src/core/audio/AudioSourcePendingPlayback.test.ts @@ -587,6 +587,78 @@ describe("AudioSource playback lifecycle", () => { expect((AudioManager as any)._recovering).to.be.false; }); + // a gesture landing inside the 100ms recovery window must NOT resume: the timer still owns it, and + // a gesture resume here would both double-call context.resume() and fire before the suspend settled + it("ignores a gesture while recovery is in flight, leaving the single timer resume", async () => { + vi.useFakeTimers(); + const audioSource = createAudioSource(); + const context = AudioManager.getContext() as unknown as MockAudioContext; + context.state = "running"; + + audioSource.play(); + context.state = "suspended"; + + const documentHidden = mockDocumentHidden(false); + document.dispatchEvent(new Event("visibilitychange")); + // recovery in flight: _recovering true, gesture-fallback armed, timer not yet fired + expect((AudioManager as any)._recovering).to.be.true; + + const resumeSpy = vi.spyOn(context, "resume"); + document.dispatchEvent(new Event("click")); // gesture inside the 100ms window + await flushAsync(); + expect(resumeSpy).not.toHaveBeenCalled(); // gesture did NOT resume (recovery owns it) + + vi.advanceTimersByTime(100); + await flushAsync(); + documentHidden.restore(); + + // exactly one resume, from the timer; gesture did not double-call it + expect(resumeSpy).toHaveBeenCalledTimes(1); + expect(context.state).to.equal("running"); + }); + + // a storm of clicks AFTER the 100ms guard window but BEFORE the resume settles must coalesce via + // _resumePromise into the timer's resume (the timer goes through AudioManager.resume() now) + it("coalesces a click-storm during the slow iOS resume settle into a single context.resume()", async () => { + vi.useFakeTimers(); + const audioSource = createAudioSource(); + const context = AudioManager.getContext() as unknown as MockAudioContext; + context.state = "running"; + + audioSource.play(); + context.state = "suspended"; + + // hold resume unresolved to simulate the slow iOS interrupted->running transition + let releaseResume: () => void; + MockAudioContext.resumeResultQueue = [ + new Promise((resolve) => { + releaseResume = resolve; + }) + ]; + + const documentHidden = mockDocumentHidden(false); + document.dispatchEvent(new Event("visibilitychange")); + const resumeSpy = vi.spyOn(context, "resume"); + + // 100ms timer fires -> timer calls AudioManager.resume() which sets _resumePromise + vi.advanceTimersByTime(100); + await flushAsync(); + expect(resumeSpy).toHaveBeenCalledTimes(1); + expect((AudioManager as any)._recovering).to.be.false; + expect((AudioManager as any)._resumePromise).to.not.be.null; + + // storm of clicks while the resume is still pending -> _resumePromise coalesces them + for (let i = 0; i < 10; i++) { + document.dispatchEvent(new Event("click")); + } + await flushAsync(); + expect(resumeSpy).toHaveBeenCalledTimes(1); + + releaseResume!(); + await flushAsync(); + documentHidden.restore(); + }); + it("treats a non-persisted pageshow as a no-op", () => { const audioSource = createAudioSource(); const context = AudioManager.getContext() as unknown as MockAudioContext; From d5d475a94b7f571fe92d8b8217c14de58b129ef2 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Tue, 23 Jun 2026 15:38:15 +0800 Subject: [PATCH 8/9] docs(audio): tighten recovery comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 合并/精简 _recoverPlaybackContext 与 _resumeAfterInterruption 周边的注释:把"100ms 经验值 + _recovering 为何挂 timer 而非 promise"两条合并到一处;把"走 AudioManager. resume() 让 _resumePromise 合并"压成两行;删除单纯描述 WHAT 的 hidden/suspendedByCaller 分支注释(代码自身已直白)。核心 WHY 保留。仅注释。 --- packages/core/src/audio/AudioManager.ts | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/packages/core/src/audio/AudioManager.ts b/packages/core/src/audio/AudioManager.ts index dba518cdaa..a6b5b68015 100644 --- a/packages/core/src/audio/AudioManager.ts +++ b/packages/core/src/audio/AudioManager.ts @@ -110,19 +110,15 @@ export class AudioManager { AudioManager._needsUserGestureResume = true; // fallback if the auto-resume below is rejected const context = AudioManager.getContext(); context.suspend().catch(() => {}); - // Clear _recovering on the timer itself, NOT off a promise: suspending/resuming an "interrupted" - // context on iOS may never settle, which would leave _recovering stuck true and block all later - // recovery. The timer always fires, and 100ms already covers the bfcache double-dispatch window. - // 100ms is an empirical delay; resuming too soon after suspend is unreliable. + // 100ms empirical delay (resume too soon after suspend is unreliable on iOS); _recovering is cleared + // on the timer rather than off a promise because iOS may never settle suspend/resume in interrupted setTimeout(() => { AudioManager._recovering = false; - // Hidden again during the delay, or a deliberate pause landed: don't resume on a backgrounded page if (document.hidden || AudioManager._suspendedByCaller) { return; } - // Go through AudioManager.resume() so its _resumePromise coalesces any user-gesture resume that - // races us during the slow iOS interrupted->running transition (~300-400ms); otherwise a bare - // context.resume() here would not occupy the promise and a gesture would issue a second call + // Go through AudioManager.resume() so _resumePromise coalesces any gesture-resume racing us during + // the slow iOS interrupted->running transition; a bare context.resume() here wouldn't dedupe AudioManager.resume().catch(() => {}); }, 100); } @@ -135,9 +131,8 @@ export class AudioManager { } private static _resumeAfterInterruption(): void { - // iOS Safari fallback: when auto-resume is blocked, resume on the next user gesture. - // Skip while _recovering: the recovery timer still owns the resume, and resuming now would both - // double-call context.resume() and fire before the suspend has settled (the empirical delay) + // iOS Safari gesture fallback for when auto-resume is blocked. + // _recovering: don't bypass the 100ms delay (would resume on a still-interrupted context) if (AudioManager._recovering || AudioManager._suspendedByCaller || !AudioManager._needsUserGestureResume) { return; } From 6119703e9cfd95e7e096cfc08c93f6e9029dfd20 Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Tue, 23 Jun 2026 15:55:23 +0800 Subject: [PATCH 9/9] fix(audio): polyfill webkitOfflineAudioContext to support iOS 9-14.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 无前缀 OfflineAudioContext 自 iOS 14.1 才有,老 iOS 上 AudioLoader. _getDecodeContext() 会直接 ReferenceError、所有音频加载即崩。给 Polyfill 加 _registerOfflineAudioContext():在无 OfflineAudioContext 但有 webkitOfflineAudioContext 时做别名,并把其 callback 形式的 decodeAudioData 包成 Promise(对齐既有 _registerAudioContext 的处理)。 按 MDN browser-compat-data:webkitOfflineAudioContext 自 iOS 7。但 PR 链路还依赖 AudioContext.suspend()/resume() 返回 Promise(iOS 9+),故功能可用下限从 iOS 14.1 拓到 iOS 9。 --- packages/core/src/Polyfill.ts | 36 ++++++++++++++++++++++++++++++ packages/loader/src/AudioLoader.ts | 5 ++--- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/packages/core/src/Polyfill.ts b/packages/core/src/Polyfill.ts index cae0b2f5c6..41a03a8c9a 100644 --- a/packages/core/src/Polyfill.ts +++ b/packages/core/src/Polyfill.ts @@ -7,6 +7,7 @@ export class Polyfill { static registerPolyfill(): void { Polyfill._registerMatchAll(); Polyfill._registerAudioContext(); + Polyfill._registerOfflineAudioContext(); Polyfill._registerTextMetrics(); Polyfill._registerPromiseFinally(); } @@ -72,6 +73,41 @@ export class Polyfill { } } + private static _registerOfflineAudioContext(): void { + // iOS 14.0 and earlier expose only webkitOfflineAudioContext, with callback-form decodeAudioData + if (!window.OfflineAudioContext && (window as any).webkitOfflineAudioContext) { + Logger.info("Polyfill window.OfflineAudioContext"); + window.OfflineAudioContext = (window as any).webkitOfflineAudioContext; + + const originalDecodeAudioData = OfflineAudioContext.prototype.decodeAudioData as ( + audioData: ArrayBuffer, + successCallback?: DecodeSuccessCallback | null, + errorCallback?: DecodeErrorCallback | null + ) => void; + + OfflineAudioContext.prototype.decodeAudioData = function ( + arrayBuffer: ArrayBuffer, + successCallback?: DecodeSuccessCallback | null, + errorCallback?: DecodeErrorCallback | null + ): Promise { + return new Promise((resolve, reject) => { + originalDecodeAudioData.call( + this, + arrayBuffer, + (buffer: AudioBuffer) => { + successCallback?.(buffer); + resolve(buffer); + }, + (error: DOMException) => { + errorCallback?.(error); + reject(error); + } + ); + }); + }; + } + } + private static _registerTextMetrics(): void { // Based on the specific version of the engine implementation, when actualBoundingBoxLeft is not supported, width is used to represent the rendering width, and `textAlign` uses the default value `start` and direction is left to right. // Some devices do not support actualBoundingBoxLeft and actualBoundingBoxRight in TextMetrics. diff --git a/packages/loader/src/AudioLoader.ts b/packages/loader/src/AudioLoader.ts index 7577851106..5cffc29781 100644 --- a/packages/loader/src/AudioLoader.ts +++ b/packages/loader/src/AudioLoader.ts @@ -53,9 +53,8 @@ class AudioLoader extends Loader { } private static _getDecodeContext(): OfflineAudioContext { - // length/channels unused (decode only); decodeAudioData resamples to this rate, then the buffer is - // resampled again to the playback rate at play time, so pitch/duration are unaffected; 44100 is the - // safest rate across browsers; unprefixed OfflineAudioContext requires iOS >= 14.5 + // length/channels are decode-only placeholders; 44100 is the safest cross-browser rate. + // decodeAudioData resamples once to this rate then again to the playback rate, so pitch/duration are unaffected return (AudioLoader._decodeContext ||= new OfflineAudioContext(1, 1, 44100)); } }