From 442552c51e41680666cde8bef9079904f70d4508 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Thu, 11 Jun 2026 15:41:16 +0800 Subject: [PATCH 01/18] fix(audio): split shaderlab audio fixes --- packages/core/src/audio/AudioManager.ts | 227 +++++++++-- packages/core/src/audio/AudioSource.ts | 137 +++++-- packages/loader/src/AudioLoader.ts | 2 +- tests/src/core/audio/AudioSource.test.ts | 2 +- .../audio/AudioSourcePendingPlayback.test.ts | 358 ++++++++++++++++++ 5 files changed, 669 insertions(+), 57 deletions(-) create mode 100644 tests/src/core/audio/AudioSourcePendingPlayback.test.ts diff --git a/packages/core/src/audio/AudioManager.ts b/packages/core/src/audio/AudioManager.ts index 3aff4b19ee..b1c4c5651b 100644 --- a/packages/core/src/audio/AudioManager.ts +++ b/packages/core/src/audio/AudioManager.ts @@ -1,3 +1,9 @@ +type ResumableAudioSource = { + _resumePendingPlayback(): void; + _suspendPlaybackForInterruption(): boolean; + _resumeInterruptedPlayback(): void; +}; + /** * Audio Manager for managing global audio context and settings. */ @@ -7,15 +13,22 @@ export class AudioManager { private static _context: AudioContext; private static _gainNode: GainNode; - private static _resumePromise: Promise = null; private static _needsUserGestureResume = false; + private static _pendingSources = new Set(); + private static _playingSources = new Set(); + private static _interruptedSources = new Set(); + private static _foregroundRestoreDelay = 300; + private static _foregroundRestoreTimer: number | undefined; + private static _hidden = false; + private static _eventsBound = false; /** * Suspend the audio context. * @returns A promise that resolves when the audio context is suspended */ static suspend(): Promise { - return AudioManager.getContext().suspend(); + AudioManager._suspendActiveSourcesForInterruption(); + return AudioManager._context?.suspend() ?? Promise.resolve(); } /** @@ -24,14 +37,48 @@ export class AudioManager { * @returns A promise that resolves when the audio context is resumed */ static resume(): Promise { - return (AudioManager._resumePromise ??= AudioManager.getContext() - .resume() - .then(() => { - AudioManager._needsUserGestureResume = false; - }) - .finally(() => { - AudioManager._resumePromise = null; - })); + const context = AudioManager._context; + if (!context) { + return Promise.resolve(); + } + if (context.state === "running") { + AudioManager._clearForegroundRestore(); + AudioManager._needsUserGestureResume = false; + AudioManager._resumePendingSources(); + AudioManager._resumeInterruptedSources(); + return Promise.resolve(); + } + return context.resume().then(() => { + AudioManager._clearForegroundRestore(); + AudioManager._needsUserGestureResume = false; + AudioManager._resumePendingSources(); + AudioManager._resumeInterruptedSources(); + }); + } + + /** @internal */ + static _registerPendingSource(source: ResumableAudioSource): void { + AudioManager._pendingSources.add(source); + } + + /** @internal */ + static _unregisterPendingSource(source: ResumableAudioSource): void { + AudioManager._pendingSources.delete(source); + } + + /** @internal */ + static _registerPlayingSource(source: ResumableAudioSource): void { + AudioManager._playingSources.add(source); + } + + /** @internal */ + static _unregisterPlayingSource(source: ResumableAudioSource): void { + AudioManager._playingSources.delete(source); + } + + /** @internal */ + static _unregisterInterruptedSource(source: ResumableAudioSource): void { + AudioManager._interruptedSources.delete(source); } /** @@ -41,11 +88,12 @@ export class AudioManager { let context = AudioManager._context; if (!context) { AudioManager._context = context = new window.AudioContext(); - document.addEventListener("visibilitychange", AudioManager._onVisibilityChange); - // 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); + context.onstatechange = AudioManager._onContextStateChange; + if (!AudioManager._eventsBound) { + AudioManager._eventsBound = true; + AudioManager._bindLifecycleEvents(); + AudioManager._bindGestureEvents(); + } } return context; } @@ -70,22 +118,153 @@ export class AudioManager { return AudioManager.getContext().state === "running"; } - private static _onVisibilityChange(): void { - if (!document.hidden && AudioManager._playingCount > 0 && !AudioManager.isAudioContextRunning()) { - // iOS WKWebView WebKit bug(Triggered in LingGuang App): AudioContext may be in a "zombie" state where - // state reports "suspended" but resume() alone won't restart audio rendering. - // Calling suspend() first forces a clean internal state reset before user gesture triggers resume. - // Related: https://bugs.webkit.org/show_bug.cgi?id=263627 - AudioManager.suspend(); - AudioManager._needsUserGestureResume = true; + private static _onContextStateChange(): void { + if (AudioManager._context?.state === "running") { + if (AudioManager._hidden || AudioManager._needsUserGestureResume) { + return; + } + AudioManager._needsUserGestureResume = false; + AudioManager._resumePendingSources(); + AudioManager._resumeInterruptedSources(); + } + } + + private static _resumePendingSources(): void { + if (!AudioManager._pendingSources.size || !AudioManager.isAudioContextRunning()) { + return; + } + + const pendingSources = Array.from(AudioManager._pendingSources); + AudioManager._pendingSources.clear(); + + for (let i = 0, n = pendingSources.length; i < n; i++) { + pendingSources[i]._resumePendingPlayback(); + } + } + + private static _suspendActiveSourcesForInterruption(): void { + if (!AudioManager._playingSources.size) { + return; + } + + const playingSources = Array.from(AudioManager._playingSources); + for (let i = 0, n = playingSources.length; i < n; i++) { + const source = playingSources[i]; + if (source._suspendPlaybackForInterruption()) { + AudioManager._interruptedSources.add(source); + } + } + } + + private static _resumeInterruptedSources(): void { + if (!AudioManager._interruptedSources.size || !AudioManager.isAudioContextRunning()) { + return; + } + + const interruptedSources = Array.from(AudioManager._interruptedSources); + AudioManager._interruptedSources.clear(); + + for (let i = 0, n = interruptedSources.length; i < n; i++) { + interruptedSources[i]._resumeInterruptedPlayback(); + } + } + + private static _bindLifecycleEvents(): void { + const hiddenProp = AudioManager._getHiddenProp(); + const visibilityEvents = [ + "visibilitychange", + "mozvisibilitychange", + "msvisibilitychange", + "webkitvisibilitychange", + "qbrowserVisibilityChange" + ]; + + for (let i = 0, n = visibilityEvents.length; i < n; i++) { + document.addEventListener(visibilityEvents[i], (event) => { + const hidden = hiddenProp ? Boolean((document as any)[hiddenProp] || (event as any)?.hidden) : document.hidden; + hidden ? AudioManager._onHidden() : AudioManager._onShown(); + }); + } + + window.addEventListener("pagehide", AudioManager._onHidden); + window.addEventListener("pageshow", AudioManager._onShown); + document.addEventListener("pagehide", AudioManager._onHidden); + document.addEventListener("pageshow", AudioManager._onShown); + } + + private static _bindGestureEvents(): void { + const gestureEvents = ["pointerdown", "pointerup", "touchstart", "touchend", "mouseup", "click"]; + for (let i = 0, n = gestureEvents.length; i < n; i++) { + document.addEventListener(gestureEvents[i], AudioManager._resumeAfterInterruption, { passive: true }); + } + } + + private static _getHiddenProp(): string { + const doc = document as any; + if (typeof doc.hidden !== "undefined") return "hidden"; + if (typeof doc.mozHidden !== "undefined") return "mozHidden"; + if (typeof doc.msHidden !== "undefined") return "msHidden"; + if (typeof doc.webkitHidden !== "undefined") return "webkitHidden"; + return ""; + } + + private static _hasResumeWork(): boolean { + return ( + AudioManager._needsUserGestureResume || + AudioManager._pendingSources.size > 0 || + AudioManager._interruptedSources.size > 0 + ); + } + + private static _onHidden(): void { + if (AudioManager._hidden) { + return; + } + AudioManager._hidden = true; + AudioManager._clearForegroundRestore(); + AudioManager.suspend().catch(() => {}); + } + + private static _onShown(): void { + if (!AudioManager._hidden) { + return; + } + AudioManager._hidden = false; + + if (AudioManager._hasResumeWork()) { + AudioManager._prepareGestureResume(); + AudioManager._scheduleForegroundRestore(); } } private static _resumeAfterInterruption(): void { - if (AudioManager._needsUserGestureResume) { + if (AudioManager._hasResumeWork()) { AudioManager.resume().catch((e) => { console.warn("Failed to resume AudioContext:", e); }); } } + + private static _scheduleForegroundRestore(): void { + AudioManager._clearForegroundRestore(); + AudioManager._foregroundRestoreTimer = window.setTimeout(() => { + AudioManager._foregroundRestoreTimer = undefined; + AudioManager.resume().catch(() => AudioManager._prepareGestureResume()); + }, AudioManager._foregroundRestoreDelay); + } + + private static _clearForegroundRestore(): void { + if (AudioManager._foregroundRestoreTimer === undefined) { + return; + } + window.clearTimeout(AudioManager._foregroundRestoreTimer); + AudioManager._foregroundRestoreTimer = undefined; + } + + private static _prepareGestureResume(): Promise { + // iOS WKWebView may report a resumable state while rendering is still frozen. + // Force a clean context edge, then let a gesture or foreground retry restore sources. + AudioManager._needsUserGestureResume = true; + return AudioManager.suspend().catch(() => {}); + } } diff --git a/packages/core/src/audio/AudioSource.ts b/packages/core/src/audio/AudioSource.ts index 29a3ed8bc1..3dacf53ec3 100644 --- a/packages/core/src/audio/AudioSource.ts +++ b/packages/core/src/audio/AudioSource.ts @@ -159,27 +159,11 @@ export class AudioSource extends Component { 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; - // Check if still valid to play after async resume - if (this._destroyed || !this.enabled || !this._clip) { - return; - } - this._startPlayback(); - }, - (e) => { - this._pendingPlay = false; - console.warn("Failed to resume AudioContext:", e); - } - ); + AudioManager._registerPendingSource(this); + AudioManager.resume().catch((e) => { + console.warn("Failed to resume AudioContext:", e); + }); } } @@ -187,23 +171,27 @@ export class AudioSource extends Component { * Stops playing the clip. */ stop(): void { - this._pendingPlay = false; + this._cancelPendingPlayback(); + AudioManager._unregisterInterruptedSource(this); if (this._isPlaying) { this._clearSourceNode(); this._isPlaying = false; - this._pausedTime = -1; - this._playTime = -1; AudioManager._playingCount--; + AudioManager._unregisterPlayingSource(this); } + + this._pausedTime = -1; + this._playTime = -1; } /** * Pauses playing the clip. */ pause(): void { - this._pendingPlay = false; + this._cancelPendingPlayback(); + AudioManager._unregisterInterruptedSource(this); if (this._isPlaying) { this._clearSourceNode(); @@ -211,6 +199,7 @@ export class AudioSource extends Component { this._pausedTime = AudioManager.getContext().currentTime; this._isPlaying = false; AudioManager._playingCount--; + AudioManager._unregisterPlayingSource(this); } } @@ -250,34 +239,120 @@ export class AudioSource extends Component { this.stop(); } + /** @internal */ + _resumePendingPlayback(): void { + if (!this._pendingPlay) { + return; + } + + this._pendingPlay = false; + + if (this._destroyed || !this.enabled || !this._clip?._getAudioSource()) { + return; + } + + this._startPlayback(); + } + + /** @internal */ + _suspendPlaybackForInterruption(): boolean { + if (!this._isPlaying) { + return false; + } + + const pausedTime = AudioManager.getContext().currentTime; + this._clearSourceNode(); + + this._pausedTime = pausedTime; + this._isPlaying = false; + AudioManager._playingCount--; + AudioManager._unregisterPlayingSource(this); + + return true; + } + + /** @internal */ + _resumeInterruptedPlayback(): void { + if ( + this._destroyed || + !this.enabled || + this._isPlaying || + this._pendingPlay || + !this._clip?._getAudioSource() || + this._playTime < 0 + ) { + return; + } + + if (AudioManager.isAudioContextRunning()) { + this._startPlayback(); + } else { + this._pendingPlay = true; + AudioManager._registerPendingSource(this); + } + } + private _startPlayback(): void { const startTime = this._pausedTime > 0 ? this._pausedTime - this._playTime : 0; - this._initSourceNode(startTime); + if (!this._initSourceNode(startTime)) { + this._pausedTime = -1; + this._playTime = -1; + return; + } this._playTime = AudioManager.getContext().currentTime - startTime; this._pausedTime = -1; this._isPlaying = true; AudioManager._playingCount++; + AudioManager._registerPlayingSource(this); } - private _initSourceNode(startTime: number): void { + private _initSourceNode(startTime: number): boolean { 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 = this._clip._getAudioSource(); + sourceNode.buffer = audioBuffer; sourceNode.playbackRate.value = this._playbackRate; sourceNode.loop = this._loop; sourceNode.onended = this._onPlayEnd; this._sourceNode = sourceNode; sourceNode.connect(this._gainNode); - sourceNode.start(0, startTime); + sourceNode.start(0, offset); + return true; } private _clearSourceNode(): void { - this._sourceNode.stop(); - this._sourceNode.disconnect(); - this._sourceNode.onended = null; + const sourceNode = this._sourceNode; + if (!sourceNode) { + return; + } + + sourceNode.onended = null; + try { + sourceNode.stop(); + } catch {} + sourceNode.disconnect(); this._sourceNode = null; } + + private _cancelPendingPlayback(): void { + if (!this._pendingPlay) { + return; + } + + this._pendingPlay = false; + AudioManager._unregisterPendingSource(this); + } } diff --git a/packages/loader/src/AudioLoader.ts b/packages/loader/src/AudioLoader.ts index 46cc16e13e..29cd7d40b7 100644 --- a/packages/loader/src/AudioLoader.ts +++ b/packages/loader/src/AudioLoader.ts @@ -9,7 +9,7 @@ import { ResourceManager, resourceLoader } from "@galacean/engine-core"; -@resourceLoader(AssetType.Audio, ["mp3", "ogg", "wav", "m4a", "aac", "flac"]) +@resourceLoader(AssetType.Audio, ["mp3", "ogg", "wav", "audio", "m4a", "aac", "flac"]) class AudioLoader extends Loader { load(item: LoadItem, resourceManager: ResourceManager): AssetPromise { return new AssetPromise((resolve, reject) => { diff --git a/tests/src/core/audio/AudioSource.test.ts b/tests/src/core/audio/AudioSource.test.ts index dd8fa6c6f1..875c8035d1 100644 --- a/tests/src/core/audio/AudioSource.test.ts +++ b/tests/src/core/audio/AudioSource.test.ts @@ -1,6 +1,6 @@ import { AssetType, AudioClip, AudioManager, AudioSource, Engine } from "@galacean/engine-core"; import "@galacean/engine-loader"; -import { WebGLEngine } from "@galacean/engine"; +import { WebGLEngine } from "@galacean/engine-rhi-webgl"; import { beforeAll, describe, expect, it } from "vitest"; import { sound } from "../model/sound"; diff --git a/tests/src/core/audio/AudioSourcePendingPlayback.test.ts b/tests/src/core/audio/AudioSourcePendingPlayback.test.ts new file mode 100644 index 0000000000..5e7e4645a1 --- /dev/null +++ b/tests/src/core/audio/AudioSourcePendingPlayback.test.ts @@ -0,0 +1,358 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AudioManager, AudioSource } from "@galacean/engine-core"; + +class MockGainNode { + gain = { + setValueAtTime: vi.fn() + }; + + connect = vi.fn(); +} + +class MockBufferSourceNode { + buffer: unknown = null; + loop = false; + onended: (() => void) | null = null; + playbackRate = { + value: 1 + }; + + connect = vi.fn(); + disconnect = vi.fn(); + start = vi.fn(); + stop = vi.fn(); +} + +class MockAudioContext { + static shouldResumeSucceed = true; + static resumeResultQueue: Array | Error> | null = null; + + currentTime = 0; + destination = {}; + onstatechange: (() => void) | null = null; + state: AudioContextState = "suspended"; + + createBufferSource(): AudioBufferSourceNode { + return new MockBufferSourceNode() as unknown as AudioBufferSourceNode; + } + + createGain(): GainNode { + return new MockGainNode() as unknown as GainNode; + } + + resume(): Promise { + const queuedResult = MockAudioContext.resumeResultQueue?.shift(); + if (queuedResult instanceof Promise) { + return queuedResult; + } + if (queuedResult instanceof Error) { + return Promise.reject(queuedResult); + } + if (!MockAudioContext.shouldResumeSucceed) { + return Promise.reject(new Error("autoplay blocked")); + } + this.state = "running"; + this.onstatechange?.(); + return Promise.resolve(); + } + + suspend(): Promise { + this.state = "suspended"; + this.onstatechange?.(); + return Promise.resolve(); + } +} + +async function flushAsync(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +function createAudioSource(): AudioSource { + const audioSource = new AudioSource({ + _isActiveInHierarchy: true, + _isActiveInScene: true, + _removeComponent() {}, + engine: {} + } as any); + + audioSource.clip = { + _addReferCount() {}, + _getAudioSource() { + return {}; + } + } as any; + + return audioSource; +} + +describe("AudioSource pending playback", () => { + beforeEach(() => { + (window as any).AudioContext = MockAudioContext; + (AudioManager as any)._context = null; + (AudioManager as any)._gainNode = null; + (AudioManager as any)._needsUserGestureResume = false; + (AudioManager as any)._pendingSources = new Set(); + (AudioManager as any)._playingSources = new Set(); + (AudioManager as any)._interruptedSources = new Set(); + (AudioManager as any)._foregroundRestoreTimer = undefined; + (AudioManager as any)._hidden = false; + MockAudioContext.shouldResumeSucceed = true; + MockAudioContext.resumeResultQueue = null; + AudioManager._playingCount = 0; + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + document.replaceChildren(); + }); + + it("replays pending playback on the next user gesture after autoplay blocking", async () => { + const audioSource = createAudioSource(); + + vi.spyOn(console, "warn").mockImplementation(() => {}); + MockAudioContext.shouldResumeSucceed = false; + + audioSource.play(); + await flushAsync(); + + expect((audioSource as any)._pendingPlay).to.be.true; + expect((AudioManager as any)._pendingSources.size).to.equal(1); + expect((AudioManager as any)._needsUserGestureResume).to.be.false; + expect(audioSource.isPlaying).to.be.false; + + MockAudioContext.shouldResumeSucceed = true; + document.dispatchEvent(new Event("click")); + await flushAsync(); + + expect(audioSource.isPlaying).to.be.true; + expect((audioSource as any)._pendingPlay).to.be.false; + expect((AudioManager as any)._pendingSources.size).to.equal(0); + expect((AudioManager as any)._needsUserGestureResume).to.be.false; + }); + + it("cancels pending playback before the unlocking gesture arrives", async () => { + const audioSource = createAudioSource(); + + vi.spyOn(console, "warn").mockImplementation(() => {}); + MockAudioContext.shouldResumeSucceed = false; + + audioSource.play(); + await flushAsync(); + + audioSource.stop(); + expect((audioSource as any)._pendingPlay).to.be.false; + expect((AudioManager as any)._pendingSources.size).to.equal(0); + + MockAudioContext.shouldResumeSucceed = true; + document.dispatchEvent(new Event("click")); + await flushAsync(); + + expect(audioSource.isPlaying).to.be.false; + expect((audioSource as any)._pendingPlay).to.be.false; + }); + + it("keeps resume a no-op until a context already exists", async () => { + expect((AudioManager as any)._context).to.be.null; + + await AudioManager.resume(); + + expect((AudioManager as any)._context).to.be.null; + }); + + it("does not resume foreground audio before a hide event", async () => { + createAudioSource(); + const context = (AudioManager as any)._context as MockAudioContext; + + vi.spyOn(document, "hidden", "get").mockReturnValue(false); + const resumeSpy = vi.spyOn(context, "resume"); + const suspendSpy = vi.spyOn(AudioManager, "suspend"); + + context.state = "suspended"; + AudioManager._playingCount = 1; + + document.dispatchEvent(new Event("visibilitychange")); + await flushAsync(); + + expect(resumeSpy).not.toHaveBeenCalled(); + expect(suspendSpy).not.toHaveBeenCalled(); + expect((AudioManager as any)._needsUserGestureResume).to.be.false; + }); + + it("recreates interrupted source nodes from a foreground gesture", async () => { + const audioSource = createAudioSource(); + const context = (AudioManager as any)._context as MockAudioContext; + + context.state = "running"; + audioSource.play(); + + const firstSourceNode = (audioSource as any)._sourceNode as MockBufferSourceNode; + expect(audioSource.isPlaying).to.be.true; + expect(AudioManager._playingCount).to.equal(1); + + const hiddenSpy = vi.spyOn(document, "hidden", "get").mockReturnValue(true); + document.dispatchEvent(new Event("visibilitychange")); + await flushAsync(); + + expect(firstSourceNode.stop).toHaveBeenCalledTimes(1); + expect(audioSource.isPlaying).to.be.false; + expect(AudioManager._playingCount).to.equal(0); + expect((AudioManager as any)._interruptedSources.size).to.equal(1); + + hiddenSpy.mockReturnValue(false); + document.dispatchEvent(new Event("visibilitychange")); + await flushAsync(); + + expect(audioSource.isPlaying).to.be.false; + expect((AudioManager as any)._interruptedSources.size).to.equal(1); + expect((AudioManager as any)._needsUserGestureResume).to.be.true; + + document.dispatchEvent(new Event("touchend")); + await flushAsync(); + + expect(audioSource.isPlaying).to.be.true; + expect(AudioManager._playingCount).to.equal(1); + expect((AudioManager as any)._interruptedSources.size).to.equal(0); + expect((audioSource as any)._sourceNode).not.to.equal(firstSourceNode); + }); + + it("recovers interrupted source nodes from foreground retry after the restore delay", async () => { + vi.useFakeTimers(); + const audioSource = createAudioSource(); + const context = (AudioManager as any)._context as MockAudioContext; + + context.state = "running"; + audioSource.play(); + + const hiddenSpy = vi.spyOn(document, "hidden", "get").mockReturnValue(true); + document.dispatchEvent(new Event("visibilitychange")); + await flushAsync(); + + hiddenSpy.mockReturnValue(false); + document.dispatchEvent(new Event("visibilitychange")); + await flushAsync(); + + expect(audioSource.isPlaying).to.be.false; + + await vi.advanceTimersByTimeAsync(299); + await flushAsync(); + + expect(audioSource.isPlaying).to.be.false; + + await vi.advanceTimersByTimeAsync(1); + await flushAsync(); + + expect(audioSource.isPlaying).to.be.true; + expect((AudioManager as any)._interruptedSources.size).to.equal(0); + }); + + it("handles document pagehide/pageshow and mouseup recovery", async () => { + const audioSource = createAudioSource(); + const context = (AudioManager as any)._context as MockAudioContext; + + context.state = "running"; + audioSource.play(); + + document.dispatchEvent(new Event("pagehide")); + await flushAsync(); + + expect(audioSource.isPlaying).to.be.false; + expect((AudioManager as any)._interruptedSources.size).to.equal(1); + + document.dispatchEvent(new Event("pageshow")); + await flushAsync(); + + expect(audioSource.isPlaying).to.be.false; + expect((AudioManager as any)._needsUserGestureResume).to.be.true; + + document.dispatchEvent(new Event("mouseup")); + await flushAsync(); + + expect(audioSource.isPlaying).to.be.true; + expect((AudioManager as any)._interruptedSources.size).to.equal(0); + }); + + it("keeps gesture recovery when foreground resume fails", async () => { + vi.useFakeTimers(); + const audioSource = createAudioSource(); + const context = (AudioManager as any)._context as MockAudioContext; + + vi.spyOn(console, "warn").mockImplementation(() => {}); + const hiddenSpy = vi.spyOn(document, "hidden", "get").mockReturnValue(true); + const resumeSpy = vi.spyOn(context, "resume"); + const suspendSpy = vi.spyOn(AudioManager, "suspend"); + + context.state = "running"; + audioSource.play(); + + document.dispatchEvent(new Event("visibilitychange")); + await flushAsync(); + + MockAudioContext.shouldResumeSucceed = false; + hiddenSpy.mockReturnValue(false); + document.dispatchEvent(new Event("visibilitychange")); + await flushAsync(); + + expect(resumeSpy).not.toHaveBeenCalled(); + expect(suspendSpy).toHaveBeenCalledTimes(2); + expect((AudioManager as any)._needsUserGestureResume).to.be.true; + + await vi.advanceTimersByTimeAsync(299); + await flushAsync(); + + expect(resumeSpy).not.toHaveBeenCalled(); + expect(suspendSpy).toHaveBeenCalledTimes(2); + expect((AudioManager as any)._needsUserGestureResume).to.be.true; + + await vi.advanceTimersByTimeAsync(1); + await flushAsync(); + + expect(resumeSpy).toHaveBeenCalledTimes(1); + expect(suspendSpy).toHaveBeenCalledTimes(3); + expect((AudioManager as any)._needsUserGestureResume).to.be.true; + + document.dispatchEvent(new Event("click")); + await flushAsync(); + + expect(resumeSpy).toHaveBeenCalledTimes(2); + expect((AudioManager as any)._needsUserGestureResume).to.be.true; + + MockAudioContext.shouldResumeSucceed = true; + document.dispatchEvent(new Event("click")); + await flushAsync(); + + expect(resumeSpy).toHaveBeenCalledTimes(3); + expect(context.state).to.equal("running"); + expect((AudioManager as any)._needsUserGestureResume).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(); + + expect(resumeSpy).toHaveBeenCalledTimes(1); + + MockAudioContext.resumeResultQueue = [ + Promise.resolve().then(() => { + context.state = "running"; + context.onstatechange?.(); + }) + ]; + (AudioManager as any)._needsUserGestureResume = true; + + document.dispatchEvent(new Event("click")); + await flushAsync(); + + expect(resumeSpy).toHaveBeenCalledTimes(2); + expect(context.state).to.equal("running"); + expect((AudioManager as any)._needsUserGestureResume).to.be.false; + }); +}); From fb0f2076910799e1c135bf8e53a51f1b7267f31b Mon Sep 17 00:00:00 2001 From: luzhuang Date: Mon, 15 Jun 2026 14:43:47 +0800 Subject: [PATCH 02/18] refactor(audio): simplify lifecycle management with Phaser-style suspend/resume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- packages/core/src/audio/AudioManager.ts | 214 +++++----------- packages/core/src/audio/AudioSource.ts | 45 ---- tests/src/core/audio/AudioSource.test.ts | 2 +- .../audio/AudioSourcePendingPlayback.test.ts | 237 +++++++++--------- 4 files changed, 174 insertions(+), 324 deletions(-) diff --git a/packages/core/src/audio/AudioManager.ts b/packages/core/src/audio/AudioManager.ts index b1c4c5651b..712320f6e2 100644 --- a/packages/core/src/audio/AudioManager.ts +++ b/packages/core/src/audio/AudioManager.ts @@ -1,7 +1,5 @@ -type ResumableAudioSource = { +type PendingAudioSource = { _resumePendingPlayback(): void; - _suspendPlaybackForInterruption(): boolean; - _resumeInterruptedPlayback(): void; }; /** @@ -14,20 +12,14 @@ export class AudioManager { private static _context: AudioContext; private static _gainNode: GainNode; private static _needsUserGestureResume = false; - private static _pendingSources = new Set(); - private static _playingSources = new Set(); - private static _interruptedSources = new Set(); - private static _foregroundRestoreDelay = 300; - private static _foregroundRestoreTimer: number | undefined; + private static _pendingSources = new Set(); private static _hidden = false; - private static _eventsBound = false; /** * Suspend the audio context. * @returns A promise that resolves when the audio context is suspended */ static suspend(): Promise { - AudioManager._suspendActiveSourcesForInterruption(); return AudioManager._context?.suspend() ?? Promise.resolve(); } @@ -37,50 +29,30 @@ export class AudioManager { * @returns A promise that resolves when the audio context is resumed */ static resume(): Promise { - const context = AudioManager._context; - if (!context) { - return Promise.resolve(); - } + const context = AudioManager.getContext(); if (context.state === "running") { - AudioManager._clearForegroundRestore(); AudioManager._needsUserGestureResume = false; AudioManager._resumePendingSources(); - AudioManager._resumeInterruptedSources(); + AudioManager._removeGestureListeners(); return Promise.resolve(); } return context.resume().then(() => { - AudioManager._clearForegroundRestore(); AudioManager._needsUserGestureResume = false; AudioManager._resumePendingSources(); - AudioManager._resumeInterruptedSources(); + AudioManager._removeGestureListeners(); }); } /** @internal */ - static _registerPendingSource(source: ResumableAudioSource): void { + static _registerPendingSource(source: PendingAudioSource): void { AudioManager._pendingSources.add(source); } /** @internal */ - static _unregisterPendingSource(source: ResumableAudioSource): void { + static _unregisterPendingSource(source: PendingAudioSource): void { AudioManager._pendingSources.delete(source); } - /** @internal */ - static _registerPlayingSource(source: ResumableAudioSource): void { - AudioManager._playingSources.add(source); - } - - /** @internal */ - static _unregisterPlayingSource(source: ResumableAudioSource): void { - AudioManager._playingSources.delete(source); - } - - /** @internal */ - static _unregisterInterruptedSource(source: ResumableAudioSource): void { - AudioManager._interruptedSources.delete(source); - } - /** * @internal */ @@ -89,11 +61,10 @@ export class AudioManager { if (!context) { AudioManager._context = context = new window.AudioContext(); context.onstatechange = AudioManager._onContextStateChange; - if (!AudioManager._eventsBound) { - AudioManager._eventsBound = true; - AudioManager._bindLifecycleEvents(); - AudioManager._bindGestureEvents(); - } + document.addEventListener("visibilitychange", AudioManager._onVisibilityChange); + window.addEventListener("pagehide", AudioManager._onHidden); + window.addEventListener("pageshow", AudioManager._onShown); + AudioManager._addGestureListeners(); } return context; } @@ -119,101 +90,19 @@ export class AudioManager { } private static _onContextStateChange(): void { - if (AudioManager._context?.state === "running") { - if (AudioManager._hidden || AudioManager._needsUserGestureResume) { - return; - } + const state = AudioManager._context?.state; + if (state === "running" && !AudioManager._hidden) { AudioManager._needsUserGestureResume = false; AudioManager._resumePendingSources(); - AudioManager._resumeInterruptedSources(); - } - } - - private static _resumePendingSources(): void { - if (!AudioManager._pendingSources.size || !AudioManager.isAudioContextRunning()) { - return; - } - - const pendingSources = Array.from(AudioManager._pendingSources); - AudioManager._pendingSources.clear(); - - for (let i = 0, n = pendingSources.length; i < n; i++) { - pendingSources[i]._resumePendingPlayback(); + AudioManager._removeGestureListeners(); + } else if (state && state !== "running" && !AudioManager._hidden) { + AudioManager._needsUserGestureResume = true; + AudioManager._addGestureListeners(); } } - private static _suspendActiveSourcesForInterruption(): void { - if (!AudioManager._playingSources.size) { - return; - } - - const playingSources = Array.from(AudioManager._playingSources); - for (let i = 0, n = playingSources.length; i < n; i++) { - const source = playingSources[i]; - if (source._suspendPlaybackForInterruption()) { - AudioManager._interruptedSources.add(source); - } - } - } - - private static _resumeInterruptedSources(): void { - if (!AudioManager._interruptedSources.size || !AudioManager.isAudioContextRunning()) { - return; - } - - const interruptedSources = Array.from(AudioManager._interruptedSources); - AudioManager._interruptedSources.clear(); - - for (let i = 0, n = interruptedSources.length; i < n; i++) { - interruptedSources[i]._resumeInterruptedPlayback(); - } - } - - private static _bindLifecycleEvents(): void { - const hiddenProp = AudioManager._getHiddenProp(); - const visibilityEvents = [ - "visibilitychange", - "mozvisibilitychange", - "msvisibilitychange", - "webkitvisibilitychange", - "qbrowserVisibilityChange" - ]; - - for (let i = 0, n = visibilityEvents.length; i < n; i++) { - document.addEventListener(visibilityEvents[i], (event) => { - const hidden = hiddenProp ? Boolean((document as any)[hiddenProp] || (event as any)?.hidden) : document.hidden; - hidden ? AudioManager._onHidden() : AudioManager._onShown(); - }); - } - - window.addEventListener("pagehide", AudioManager._onHidden); - window.addEventListener("pageshow", AudioManager._onShown); - document.addEventListener("pagehide", AudioManager._onHidden); - document.addEventListener("pageshow", AudioManager._onShown); - } - - private static _bindGestureEvents(): void { - const gestureEvents = ["pointerdown", "pointerup", "touchstart", "touchend", "mouseup", "click"]; - for (let i = 0, n = gestureEvents.length; i < n; i++) { - document.addEventListener(gestureEvents[i], AudioManager._resumeAfterInterruption, { passive: true }); - } - } - - private static _getHiddenProp(): string { - const doc = document as any; - if (typeof doc.hidden !== "undefined") return "hidden"; - if (typeof doc.mozHidden !== "undefined") return "mozHidden"; - if (typeof doc.msHidden !== "undefined") return "msHidden"; - if (typeof doc.webkitHidden !== "undefined") return "webkitHidden"; - return ""; - } - - private static _hasResumeWork(): boolean { - return ( - AudioManager._needsUserGestureResume || - AudioManager._pendingSources.size > 0 || - AudioManager._interruptedSources.size > 0 - ); + private static _onVisibilityChange(): void { + document.hidden ? AudioManager._onHidden() : AudioManager._onShown(); } private static _onHidden(): void { @@ -221,8 +110,7 @@ export class AudioManager { return; } AudioManager._hidden = true; - AudioManager._clearForegroundRestore(); - AudioManager.suspend().catch(() => {}); + AudioManager._context?.suspend(); } private static _onShown(): void { @@ -231,40 +119,52 @@ export class AudioManager { } AudioManager._hidden = false; - if (AudioManager._hasResumeWork()) { - AudioManager._prepareGestureResume(); - AudioManager._scheduleForegroundRestore(); + const context = AudioManager._context; + if (!context) { + return; } + // 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. + context.suspend(); + setTimeout(() => { + context + .resume() + .then(() => { + AudioManager._needsUserGestureResume = false; + AudioManager._resumePendingSources(); + AudioManager._removeGestureListeners(); + }) + .catch(() => { + AudioManager._needsUserGestureResume = true; + AudioManager._addGestureListeners(); + }); + }, 100); } - private static _resumeAfterInterruption(): void { - if (AudioManager._hasResumeWork()) { - AudioManager.resume().catch((e) => { - console.warn("Failed to resume AudioContext:", e); - }); + private static _resumePendingSources(): void { + if (!AudioManager._pendingSources.size || !AudioManager.isAudioContextRunning()) { + return; + } + const sources = Array.from(AudioManager._pendingSources); + AudioManager._pendingSources.clear(); + for (let i = 0, n = sources.length; i < n; i++) { + sources[i]._resumePendingPlayback(); } } - private static _scheduleForegroundRestore(): void { - AudioManager._clearForegroundRestore(); - AudioManager._foregroundRestoreTimer = window.setTimeout(() => { - AudioManager._foregroundRestoreTimer = undefined; - AudioManager.resume().catch(() => AudioManager._prepareGestureResume()); - }, AudioManager._foregroundRestoreDelay); + private static _resumeAfterInterruption(): void { + if (AudioManager._needsUserGestureResume || AudioManager._pendingSources.size > 0) { + AudioManager.resume().catch(() => {}); + } } - private static _clearForegroundRestore(): void { - if (AudioManager._foregroundRestoreTimer === undefined) { - return; - } - window.clearTimeout(AudioManager._foregroundRestoreTimer); - AudioManager._foregroundRestoreTimer = undefined; + private static _addGestureListeners(): void { + document.addEventListener("pointerup", AudioManager._resumeAfterInterruption, { passive: true }); + document.addEventListener("click", AudioManager._resumeAfterInterruption, { passive: true }); } - private static _prepareGestureResume(): Promise { - // iOS WKWebView may report a resumable state while rendering is still frozen. - // Force a clean context edge, then let a gesture or foreground retry restore sources. - AudioManager._needsUserGestureResume = true; - return AudioManager.suspend().catch(() => {}); + 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 3dacf53ec3..47601c7b9e 100644 --- a/packages/core/src/audio/AudioSource.ts +++ b/packages/core/src/audio/AudioSource.ts @@ -172,14 +172,11 @@ export class AudioSource extends Component { */ stop(): void { this._cancelPendingPlayback(); - AudioManager._unregisterInterruptedSource(this); if (this._isPlaying) { this._clearSourceNode(); - this._isPlaying = false; AudioManager._playingCount--; - AudioManager._unregisterPlayingSource(this); } this._pausedTime = -1; @@ -191,15 +188,12 @@ export class AudioSource extends Component { */ pause(): void { this._cancelPendingPlayback(); - AudioManager._unregisterInterruptedSource(this); if (this._isPlaying) { this._clearSourceNode(); - this._pausedTime = AudioManager.getContext().currentTime; this._isPlaying = false; AudioManager._playingCount--; - AudioManager._unregisterPlayingSource(this); } } @@ -254,44 +248,6 @@ export class AudioSource extends Component { this._startPlayback(); } - /** @internal */ - _suspendPlaybackForInterruption(): boolean { - if (!this._isPlaying) { - return false; - } - - const pausedTime = AudioManager.getContext().currentTime; - this._clearSourceNode(); - - this._pausedTime = pausedTime; - this._isPlaying = false; - AudioManager._playingCount--; - AudioManager._unregisterPlayingSource(this); - - return true; - } - - /** @internal */ - _resumeInterruptedPlayback(): void { - if ( - this._destroyed || - !this.enabled || - this._isPlaying || - this._pendingPlay || - !this._clip?._getAudioSource() || - this._playTime < 0 - ) { - return; - } - - if (AudioManager.isAudioContextRunning()) { - this._startPlayback(); - } else { - this._pendingPlay = true; - AudioManager._registerPendingSource(this); - } - } - private _startPlayback(): void { const startTime = this._pausedTime > 0 ? this._pausedTime - this._playTime : 0; if (!this._initSourceNode(startTime)) { @@ -304,7 +260,6 @@ export class AudioSource extends Component { this._pausedTime = -1; this._isPlaying = true; AudioManager._playingCount++; - AudioManager._registerPlayingSource(this); } private _initSourceNode(startTime: number): boolean { diff --git a/tests/src/core/audio/AudioSource.test.ts b/tests/src/core/audio/AudioSource.test.ts index 875c8035d1..dd8fa6c6f1 100644 --- a/tests/src/core/audio/AudioSource.test.ts +++ b/tests/src/core/audio/AudioSource.test.ts @@ -1,6 +1,6 @@ import { AssetType, AudioClip, AudioManager, AudioSource, Engine } from "@galacean/engine-core"; import "@galacean/engine-loader"; -import { WebGLEngine } from "@galacean/engine-rhi-webgl"; +import { WebGLEngine } from "@galacean/engine"; import { beforeAll, describe, expect, it } from "vitest"; import { sound } from "../model/sound"; diff --git a/tests/src/core/audio/AudioSourcePendingPlayback.test.ts b/tests/src/core/audio/AudioSourcePendingPlayback.test.ts index 5e7e4645a1..8d80c6fdd5 100644 --- a/tests/src/core/audio/AudioSourcePendingPlayback.test.ts +++ b/tests/src/core/audio/AudioSourcePendingPlayback.test.ts @@ -79,7 +79,7 @@ function createAudioSource(): AudioSource { audioSource.clip = { _addReferCount() {}, _getAudioSource() { - return {}; + return { duration: 10 }; } } as any; @@ -93,9 +93,6 @@ describe("AudioSource pending playback", () => { (AudioManager as any)._gainNode = null; (AudioManager as any)._needsUserGestureResume = false; (AudioManager as any)._pendingSources = new Set(); - (AudioManager as any)._playingSources = new Set(); - (AudioManager as any)._interruptedSources = new Set(); - (AudioManager as any)._foregroundRestoreTimer = undefined; (AudioManager as any)._hidden = false; MockAudioContext.shouldResumeSucceed = true; MockAudioContext.resumeResultQueue = null; @@ -105,7 +102,6 @@ describe("AudioSource pending playback", () => { afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); - document.replaceChildren(); }); it("replays pending playback on the next user gesture after autoplay blocking", async () => { @@ -119,7 +115,6 @@ describe("AudioSource pending playback", () => { expect((audioSource as any)._pendingPlay).to.be.true; expect((AudioManager as any)._pendingSources.size).to.equal(1); - expect((AudioManager as any)._needsUserGestureResume).to.be.false; expect(audioSource.isPlaying).to.be.false; MockAudioContext.shouldResumeSucceed = true; @@ -129,7 +124,6 @@ describe("AudioSource pending playback", () => { expect(audioSource.isPlaying).to.be.true; expect((audioSource as any)._pendingPlay).to.be.false; expect((AudioManager as any)._pendingSources.size).to.equal(0); - expect((AudioManager as any)._needsUserGestureResume).to.be.false; }); it("cancels pending playback before the unlocking gesture arrives", async () => { @@ -150,181 +144,116 @@ describe("AudioSource pending playback", () => { await flushAsync(); expect(audioSource.isPlaying).to.be.false; - expect((audioSource as any)._pendingPlay).to.be.false; }); - it("keeps resume a no-op until a context already exists", async () => { - expect((AudioManager as any)._context).to.be.null; + it("resume() unlocks a suspended context", async () => { + createAudioSource(); + const context = (AudioManager as any)._context as MockAudioContext; + expect(context.state).to.equal("suspended"); await AudioManager.resume(); - expect((AudioManager as any)._context).to.be.null; + expect(context.state).to.equal("running"); + expect((AudioManager as any)._needsUserGestureResume).to.be.false; }); - it("does not resume foreground audio before a hide event", async () => { + it("suspends context on visibilitychange hidden", async () => { createAudioSource(); const context = (AudioManager as any)._context as MockAudioContext; + const suspendSpy = vi.spyOn(context, "suspend"); - vi.spyOn(document, "hidden", "get").mockReturnValue(false); - const resumeSpy = vi.spyOn(context, "resume"); - const suspendSpy = vi.spyOn(AudioManager, "suspend"); - - context.state = "suspended"; - AudioManager._playingCount = 1; + context.state = "running"; + vi.spyOn(document, "hidden", "get").mockReturnValue(true); document.dispatchEvent(new Event("visibilitychange")); - await flushAsync(); - expect(resumeSpy).not.toHaveBeenCalled(); - expect(suspendSpy).not.toHaveBeenCalled(); - expect((AudioManager as any)._needsUserGestureResume).to.be.false; + expect(suspendSpy).toHaveBeenCalledTimes(1); + expect((AudioManager as any)._hidden).to.be.true; }); - it("recreates interrupted source nodes from a foreground gesture", async () => { - const audioSource = createAudioSource(); + it("resumes context on visibilitychange shown via iOS zombie fix", async () => { + vi.useFakeTimers(); + createAudioSource(); const context = (AudioManager as any)._context as MockAudioContext; context.state = "running"; - audioSource.play(); - - const firstSourceNode = (audioSource as any)._sourceNode as MockBufferSourceNode; - expect(audioSource.isPlaying).to.be.true; - expect(AudioManager._playingCount).to.equal(1); - - const hiddenSpy = vi.spyOn(document, "hidden", "get").mockReturnValue(true); - document.dispatchEvent(new Event("visibilitychange")); - await flushAsync(); - - expect(firstSourceNode.stop).toHaveBeenCalledTimes(1); - expect(audioSource.isPlaying).to.be.false; - expect(AudioManager._playingCount).to.equal(0); - expect((AudioManager as any)._interruptedSources.size).to.equal(1); + const suspendSpy = vi.spyOn(context, "suspend"); + const resumeSpy = vi.spyOn(context, "resume"); - hiddenSpy.mockReturnValue(false); - document.dispatchEvent(new Event("visibilitychange")); - await flushAsync(); + (AudioManager as any)._onHidden(); + expect(suspendSpy).toHaveBeenCalledTimes(1); - expect(audioSource.isPlaying).to.be.false; - expect((AudioManager as any)._interruptedSources.size).to.equal(1); - expect((AudioManager as any)._needsUserGestureResume).to.be.true; + (AudioManager as any)._onShown(); + // _onShown calls context.suspend() synchronously then schedules resume after 100ms + expect(suspendSpy).toHaveBeenCalledTimes(2); - document.dispatchEvent(new Event("touchend")); + vi.runAllTimers(); await flushAsync(); - expect(audioSource.isPlaying).to.be.true; - expect(AudioManager._playingCount).to.equal(1); - expect((AudioManager as any)._interruptedSources.size).to.equal(0); - expect((audioSource as any)._sourceNode).not.to.equal(firstSourceNode); + expect(resumeSpy).toHaveBeenCalled(); }); - it("recovers interrupted source nodes from foreground retry after the restore delay", async () => { - vi.useFakeTimers(); - const audioSource = createAudioSource(); + it("does not act on visibilitychange shown without prior hide", async () => { + createAudioSource(); const context = (AudioManager as any)._context as MockAudioContext; - context.state = "running"; - audioSource.play(); - - const hiddenSpy = vi.spyOn(document, "hidden", "get").mockReturnValue(true); - document.dispatchEvent(new Event("visibilitychange")); - await flushAsync(); + vi.spyOn(document, "hidden", "get").mockReturnValue(false); + const suspendSpy = vi.spyOn(context, "suspend"); - hiddenSpy.mockReturnValue(false); document.dispatchEvent(new Event("visibilitychange")); await flushAsync(); - expect(audioSource.isPlaying).to.be.false; - - await vi.advanceTimersByTimeAsync(299); - await flushAsync(); - - expect(audioSource.isPlaying).to.be.false; - - await vi.advanceTimersByTimeAsync(1); - await flushAsync(); - - expect(audioSource.isPlaying).to.be.true; - expect((AudioManager as any)._interruptedSources.size).to.equal(0); + expect(suspendSpy).not.toHaveBeenCalled(); }); - it("handles document pagehide/pageshow and mouseup recovery", async () => { - const audioSource = createAudioSource(); + it("handles pagehide/pageshow lifecycle", async () => { + vi.useFakeTimers(); + createAudioSource(); const context = (AudioManager as any)._context as MockAudioContext; - context.state = "running"; - audioSource.play(); - document.dispatchEvent(new Event("pagehide")); - await flushAsync(); + (AudioManager as any)._onHidden(); + expect((AudioManager as any)._hidden).to.be.true; + expect(context.state).to.equal("suspended"); - expect(audioSource.isPlaying).to.be.false; - expect((AudioManager as any)._interruptedSources.size).to.equal(1); + (AudioManager as any)._onShown(); + expect((AudioManager as any)._hidden).to.be.false; - document.dispatchEvent(new Event("pageshow")); + // iOS zombie fix uses window.setTimeout(100ms) + vi.runAllTimers(); await flushAsync(); - - expect(audioSource.isPlaying).to.be.false; - expect((AudioManager as any)._needsUserGestureResume).to.be.true; - - document.dispatchEvent(new Event("mouseup")); await flushAsync(); - expect(audioSource.isPlaying).to.be.true; - expect((AudioManager as any)._interruptedSources.size).to.equal(0); + expect(context.state).to.equal("running"); }); - it("keeps gesture recovery when foreground resume fails", async () => { + it("sets gesture resume flag when foreground resume fails", async () => { vi.useFakeTimers(); - const audioSource = createAudioSource(); + createAudioSource(); const context = (AudioManager as any)._context as MockAudioContext; - - vi.spyOn(console, "warn").mockImplementation(() => {}); - const hiddenSpy = vi.spyOn(document, "hidden", "get").mockReturnValue(true); - const resumeSpy = vi.spyOn(context, "resume"); - const suspendSpy = vi.spyOn(AudioManager, "suspend"); - context.state = "running"; - audioSource.play(); + // Hide + vi.spyOn(document, "hidden", "get").mockReturnValue(true); document.dispatchEvent(new Event("visibilitychange")); - await flushAsync(); + // Show, but resume will fail MockAudioContext.shouldResumeSucceed = false; - hiddenSpy.mockReturnValue(false); + vi.spyOn(document, "hidden", "get").mockReturnValue(false); document.dispatchEvent(new Event("visibilitychange")); - await flushAsync(); - - expect(resumeSpy).not.toHaveBeenCalled(); - expect(suspendSpy).toHaveBeenCalledTimes(2); - expect((AudioManager as any)._needsUserGestureResume).to.be.true; - await vi.advanceTimersByTimeAsync(299); + await vi.advanceTimersByTimeAsync(100); await flushAsync(); - expect(resumeSpy).not.toHaveBeenCalled(); - expect(suspendSpy).toHaveBeenCalledTimes(2); - expect((AudioManager as any)._needsUserGestureResume).to.be.true; - - await vi.advanceTimersByTimeAsync(1); - await flushAsync(); - - expect(resumeSpy).toHaveBeenCalledTimes(1); - expect(suspendSpy).toHaveBeenCalledTimes(3); - expect((AudioManager as any)._needsUserGestureResume).to.be.true; - - document.dispatchEvent(new Event("click")); - await flushAsync(); - - expect(resumeSpy).toHaveBeenCalledTimes(2); expect((AudioManager as any)._needsUserGestureResume).to.be.true; + // Gesture succeeds MockAudioContext.shouldResumeSucceed = true; document.dispatchEvent(new Event("click")); await flushAsync(); - expect(resumeSpy).toHaveBeenCalledTimes(3); - expect(context.state).to.equal("running"); expect((AudioManager as any)._needsUserGestureResume).to.be.false; + expect(context.state).to.equal("running"); }); it("retries context.resume inside a later user gesture even if an earlier resume is still pending", async () => { @@ -355,4 +284,70 @@ describe("AudioSource pending playback", () => { expect(context.state).to.equal("running"); expect((AudioManager as any)._needsUserGestureResume).to.be.false; }); + + it("keeps _playingCount balanced across play/stop/pause/ended", async () => { + createAudioSource(); + const context = (AudioManager as any)._context as MockAudioContext; + context.state = "running"; + + const s1 = createAudioSource(); + const s2 = createAudioSource(); + + s1.play(); + s2.play(); + expect(AudioManager._playingCount).to.equal(2); + + s1.pause(); + expect(AudioManager._playingCount).to.equal(1); + + s1.play(); + expect(AudioManager._playingCount).to.equal(2); + + s2.stop(); + expect(AudioManager._playingCount).to.equal(1); + + // Simulate onended + (s1 as any)._onPlayEnd(); + expect(AudioManager._playingCount).to.equal(0); + }); + + it("does not resume a stopped source after hide/show cycle", async () => { + vi.useFakeTimers(); + const audioSource = createAudioSource(); + const context = (AudioManager as any)._context as MockAudioContext; + context.state = "running"; + + audioSource.play(); + expect(audioSource.isPlaying).to.be.true; + + audioSource.stop(); + expect(audioSource.isPlaying).to.be.false; + + // hide → show cycle + (AudioManager as any)._onHidden(); + (AudioManager as any)._onShown(); + vi.runAllTimers(); + await flushAsync(); + + // Source stays stopped — context resume does not restart stopped sources + expect(audioSource.isPlaying).to.be.false; + expect(AudioManager._playingCount).to.equal(0); + }); + + it("recovers via gesture when _needsUserGestureResume is set (external interruption path)", async () => { + createAudioSource(); + const context = (AudioManager as any)._context as MockAudioContext; + context.state = "suspended"; + + // Simulate the state that _onContextStateChange would set on external interruption + (AudioManager as any)._needsUserGestureResume = true; + + // Gesture triggers resume + MockAudioContext.shouldResumeSucceed = true; + document.dispatchEvent(new Event("pointerup")); + await flushAsync(); + + expect(context.state).to.equal("running"); + expect((AudioManager as any)._needsUserGestureResume).to.be.false; + }); }); From 965cae27743e0646b529d892d5e03f39f8de5a9c Mon Sep 17 00:00:00 2001 From: luzhuang Date: Mon, 15 Jun 2026 15:09:26 +0800 Subject: [PATCH 03/18] fix(audio): address review feedback - hidden guard, remove .audio extension - 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) --- packages/core/src/audio/AudioManager.ts | 3 ++- packages/loader/src/AudioLoader.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/core/src/audio/AudioManager.ts b/packages/core/src/audio/AudioManager.ts index 712320f6e2..3d2eb3e964 100644 --- a/packages/core/src/audio/AudioManager.ts +++ b/packages/core/src/audio/AudioManager.ts @@ -60,6 +60,7 @@ 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); @@ -142,7 +143,7 @@ export class AudioManager { } private static _resumePendingSources(): void { - if (!AudioManager._pendingSources.size || !AudioManager.isAudioContextRunning()) { + if (!AudioManager._pendingSources.size || AudioManager._hidden || !AudioManager.isAudioContextRunning()) { return; } const sources = Array.from(AudioManager._pendingSources); diff --git a/packages/loader/src/AudioLoader.ts b/packages/loader/src/AudioLoader.ts index 29cd7d40b7..46cc16e13e 100644 --- a/packages/loader/src/AudioLoader.ts +++ b/packages/loader/src/AudioLoader.ts @@ -9,7 +9,7 @@ import { ResourceManager, resourceLoader } from "@galacean/engine-core"; -@resourceLoader(AssetType.Audio, ["mp3", "ogg", "wav", "audio", "m4a", "aac", "flac"]) +@resourceLoader(AssetType.Audio, ["mp3", "ogg", "wav", "m4a", "aac", "flac"]) class AudioLoader extends Loader { load(item: LoadItem, resourceManager: ResourceManager): AssetPromise { return new AssetPromise((resolve, reject) => { From d22f900b981b2f254b9ea0f07c30cec4b4f965b1 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Mon, 15 Jun 2026 15:28:58 +0800 Subject: [PATCH 04/18] fix(audio): make mock onstatechange async to prevent stack overflow in 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. --- .../core/audio/AudioSourcePendingPlayback.test.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/src/core/audio/AudioSourcePendingPlayback.test.ts b/tests/src/core/audio/AudioSourcePendingPlayback.test.ts index 8d80c6fdd5..29aefda620 100644 --- a/tests/src/core/audio/AudioSourcePendingPlayback.test.ts +++ b/tests/src/core/audio/AudioSourcePendingPlayback.test.ts @@ -52,14 +52,18 @@ class MockAudioContext { return Promise.reject(new Error("autoplay blocked")); } this.state = "running"; - this.onstatechange?.(); - return Promise.resolve(); + const cb = this.onstatechange; + return Promise.resolve().then(() => { + cb?.(); + }); } suspend(): Promise { this.state = "suspended"; - this.onstatechange?.(); - return Promise.resolve(); + const cb = this.onstatechange; + return Promise.resolve().then(() => { + cb?.(); + }); } } From bf34eb521b64f2ba1341f3f5bf9a1839b2e1ab41 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Mon, 15 Jun 2026 16:30:46 +0800 Subject: [PATCH 05/18] fix(audio): harden context lifecycle recovery --- .../2026-06-15-audio-context-lifecycle-ci.md | 30 +++++ packages/core/src/audio/AudioManager.ts | 50 ++++++- .../audio/AudioSourcePendingPlayback.test.ts | 124 ++++++++++++++---- 3 files changed, 173 insertions(+), 31 deletions(-) create mode 100644 notes/audio/2026-06-15-audio-context-lifecycle-ci.md diff --git a/notes/audio/2026-06-15-audio-context-lifecycle-ci.md b/notes/audio/2026-06-15-audio-context-lifecycle-ci.md new file mode 100644 index 0000000000..ef9e015059 --- /dev/null +++ b/notes/audio/2026-06-15-audio-context-lifecycle-ci.md @@ -0,0 +1,30 @@ +# Audio context lifecycle CI fix + +## Context + +PR #3026 failed only in the `codecov` job. The failing step was `Test`, not the Codecov upload. The job log showed `AudioSourcePendingPlayback.test.ts` failures with `RangeError: Maximum call stack size exceeded`, while lint, build, and e2e checks were green on the same head. + +## Root cause + +`AudioManager` treated every foreground `suspended` state as an external browser interruption. That made a caller-controlled `AudioManager.suspend()` eligible for automatic click/pointer resume. + +The iOS foreground recovery path also scheduled a delayed `context.resume()` without retaining or invalidating the timer. If the page was hidden again before the delay elapsed, the stale callback could resume audio while hidden. + +The test file amplified the problem by relying on `document.hidden` getter mocks and fake timers across lifecycle tests. In full coverage order this left enough asynchronous state behind to reproduce the CI-only stack overflow. + +## Fix + +- Track caller-controlled suspension separately from browser interruptions. +- Store and clear the foreground recovery timer on hide, explicit suspend, and explicit resume. +- Guard the delayed recovery callback and its async result against hidden pages, stale contexts, and caller-controlled suspension. +- Make the audio lifecycle tests import the source audio module, reset `AudioManager` listeners/state per case, and drive scheduled callbacks explicitly instead of relying on DOM getter mocks or fake timer ordering. + +## Verification + +- `pnpm exec vitest run tests/src/core/audio/AudioSourcePendingPlayback.test.ts` +- `pnpm exec cross-env HEADLESS=true vitest run --coverage tests/src/core/audio/AudioSourcePendingPlayback.test.ts` +- `pnpm exec cross-env HEADLESS=true vitest run --coverage tests/src/core/PolyfillAudioContext.test.ts tests/src/core/audio/AudioSource.test.ts tests/src/core/audio/AudioSourcePendingPlayback.test.ts` +- `pnpm -F @galacean/engine-core run b:types` +- `pnpm exec eslint packages/core/src/audio/AudioManager.ts tests/src/core/audio/AudioSourcePendingPlayback.test.ts` + +`npm run build` was also attempted locally, but this machine failed during shader precompile before reaching the audio change. The failure was from shader compiler precompile resolving render-state enums as `undefined`; the live PR CI had already passed the build job on the same PR head. diff --git a/packages/core/src/audio/AudioManager.ts b/packages/core/src/audio/AudioManager.ts index 3d2eb3e964..4d5a7e9c0d 100644 --- a/packages/core/src/audio/AudioManager.ts +++ b/packages/core/src/audio/AudioManager.ts @@ -14,13 +14,23 @@ export class AudioManager { private static _needsUserGestureResume = false; private static _pendingSources = new Set(); private static _hidden = false; + private static _foregroundResumeTimer: ReturnType | null = null; + private static _suspendedByCaller = false; /** * Suspend the audio context. * @returns A promise that resolves when the audio context is suspended */ static suspend(): Promise { - return AudioManager._context?.suspend() ?? Promise.resolve(); + const context = AudioManager._context; + if (!context) { + return Promise.resolve(); + } + AudioManager._suspendedByCaller = true; + AudioManager._needsUserGestureResume = false; + AudioManager._clearForegroundResumeTimer(); + AudioManager._removeGestureListeners(); + return context.suspend(); } /** @@ -29,6 +39,8 @@ export class AudioManager { * @returns A promise that resolves when the audio context is resumed */ static resume(): Promise { + AudioManager._suspendedByCaller = false; + AudioManager._clearForegroundResumeTimer(); const context = AudioManager.getContext(); if (context.state === "running") { AudioManager._needsUserGestureResume = false; @@ -93,10 +105,17 @@ export class AudioManager { private static _onContextStateChange(): void { const state = AudioManager._context?.state; if (state === "running" && !AudioManager._hidden) { + AudioManager._suspendedByCaller = false; AudioManager._needsUserGestureResume = false; AudioManager._resumePendingSources(); AudioManager._removeGestureListeners(); - } else if (state && state !== "running" && !AudioManager._hidden) { + } else if ( + state && + state !== "running" && + !AudioManager._hidden && + !AudioManager._suspendedByCaller && + !AudioManager._foregroundResumeTimer + ) { AudioManager._needsUserGestureResume = true; AudioManager._addGestureListeners(); } @@ -111,6 +130,7 @@ export class AudioManager { return; } AudioManager._hidden = true; + AudioManager._clearForegroundResumeTimer(); AudioManager._context?.suspend(); } @@ -121,21 +141,31 @@ export class AudioManager { AudioManager._hidden = false; const context = AudioManager._context; - if (!context) { + if (!context || AudioManager._suspendedByCaller) { return; } // 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. context.suspend(); - setTimeout(() => { + AudioManager._foregroundResumeTimer = setTimeout(() => { + AudioManager._foregroundResumeTimer = null; + if (AudioManager._hidden || AudioManager._suspendedByCaller || AudioManager._context !== context) { + return; + } context .resume() .then(() => { + if (AudioManager._hidden || AudioManager._suspendedByCaller || AudioManager._context !== context) { + return; + } AudioManager._needsUserGestureResume = false; AudioManager._resumePendingSources(); AudioManager._removeGestureListeners(); }) .catch(() => { + if (AudioManager._hidden || AudioManager._suspendedByCaller || AudioManager._context !== context) { + return; + } AudioManager._needsUserGestureResume = true; AudioManager._addGestureListeners(); }); @@ -154,11 +184,21 @@ export class AudioManager { } private static _resumeAfterInterruption(): void { - if (AudioManager._needsUserGestureResume || AudioManager._pendingSources.size > 0) { + if ( + !AudioManager._suspendedByCaller && + (AudioManager._needsUserGestureResume || AudioManager._pendingSources.size > 0) + ) { AudioManager.resume().catch(() => {}); } } + private static _clearForegroundResumeTimer(): void { + if (AudioManager._foregroundResumeTimer !== null) { + clearTimeout(AudioManager._foregroundResumeTimer); + AudioManager._foregroundResumeTimer = null; + } + } + private static _addGestureListeners(): void { document.addEventListener("pointerup", AudioManager._resumeAfterInterruption, { passive: true }); document.addEventListener("click", AudioManager._resumeAfterInterruption, { passive: true }); diff --git a/tests/src/core/audio/AudioSourcePendingPlayback.test.ts b/tests/src/core/audio/AudioSourcePendingPlayback.test.ts index 29aefda620..3a5999e900 100644 --- a/tests/src/core/audio/AudioSourcePendingPlayback.test.ts +++ b/tests/src/core/audio/AudioSourcePendingPlayback.test.ts @@ -1,5 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { AudioManager, AudioSource } from "@galacean/engine-core"; +import { AudioManager, AudioSource } from "@galacean/engine-core/src/audio"; + +const originalAudioContext = window.AudioContext; class MockGainNode { gain = { @@ -90,22 +92,52 @@ function createAudioSource(): AudioSource { return 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("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)._needsUserGestureResume = false; + (AudioManager as any)._pendingSources = new Set(); + (AudioManager as any)._hidden = false; + (AudioManager as any)._foregroundResumeTimer = null; + (AudioManager as any)._suspendedByCaller = false; + AudioManager._playingCount = 0; +} + +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; + }); + return scheduledTimers; +} + describe("AudioSource pending playback", () => { beforeEach(() => { + resetAudioManagerState(); (window as any).AudioContext = MockAudioContext; - (AudioManager as any)._context = null; - (AudioManager as any)._gainNode = null; - (AudioManager as any)._needsUserGestureResume = false; - (AudioManager as any)._pendingSources = new Set(); - (AudioManager as any)._hidden = false; MockAudioContext.shouldResumeSucceed = true; MockAudioContext.resumeResultQueue = null; - AudioManager._playingCount = 0; }); - afterEach(() => { + afterEach(async () => { + await flushAsync(); + resetAudioManagerState(); + (window as any).AudioContext = originalAudioContext; vi.useRealTimers(); vi.restoreAllMocks(); + await flushAsync(); }); it("replays pending playback on the next user gesture after autoplay blocking", async () => { @@ -161,24 +193,24 @@ describe("AudioSource pending playback", () => { expect((AudioManager as any)._needsUserGestureResume).to.be.false; }); - it("suspends context on visibilitychange hidden", async () => { + it("suspends context when hidden", async () => { createAudioSource(); const context = (AudioManager as any)._context as MockAudioContext; const suspendSpy = vi.spyOn(context, "suspend"); context.state = "running"; - vi.spyOn(document, "hidden", "get").mockReturnValue(true); - document.dispatchEvent(new Event("visibilitychange")); + (AudioManager as any)._onHidden(); + await flushAsync(); expect(suspendSpy).toHaveBeenCalledTimes(1); expect((AudioManager as any)._hidden).to.be.true; }); it("resumes context on visibilitychange shown via iOS zombie fix", async () => { - vi.useFakeTimers(); createAudioSource(); const context = (AudioManager as any)._context as MockAudioContext; + const scheduledTimers = captureScheduledTimers(); context.state = "running"; const suspendSpy = vi.spyOn(context, "suspend"); @@ -190,30 +222,70 @@ describe("AudioSource pending playback", () => { (AudioManager as any)._onShown(); // _onShown calls context.suspend() synchronously then schedules resume after 100ms expect(suspendSpy).toHaveBeenCalledTimes(2); + expect(scheduledTimers).to.have.lengthOf(1); - vi.runAllTimers(); + scheduledTimers[0](); await flushAsync(); expect(resumeSpy).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(); + + context.state = "running"; + const resumeSpy = vi.spyOn(context, "resume"); + + (AudioManager as any)._onHidden(); + (AudioManager as any)._onShown(); + expect(scheduledTimers).to.have.lengthOf(1); + + (AudioManager as any)._onHidden(); + + scheduledTimers[0](); + await flushAsync(); + + expect(resumeSpy).not.toHaveBeenCalled(); + expect(context.state).to.equal("suspended"); + }); + + it("does not auto-resume a caller-controlled suspend on the next gesture", async () => { + createAudioSource(); + const context = (AudioManager as any)._context as MockAudioContext; + const resumeSpy = vi.spyOn(context, "resume"); + + context.state = "running"; + await AudioManager.suspend(); + (AudioManager as any)._onContextStateChange(); + await flushAsync(); + + expect((AudioManager as any)._needsUserGestureResume).to.be.false; + + document.dispatchEvent(new Event("click")); + await flushAsync(); + + expect(resumeSpy).not.toHaveBeenCalled(); + expect(context.state).to.equal("suspended"); + }); + it("does not act on visibilitychange shown without prior hide", async () => { createAudioSource(); const context = (AudioManager as any)._context as MockAudioContext; - vi.spyOn(document, "hidden", "get").mockReturnValue(false); const suspendSpy = vi.spyOn(context, "suspend"); - document.dispatchEvent(new Event("visibilitychange")); + (AudioManager as any)._onShown(); await flushAsync(); expect(suspendSpy).not.toHaveBeenCalled(); }); it("handles pagehide/pageshow lifecycle", async () => { - vi.useFakeTimers(); createAudioSource(); const context = (AudioManager as any)._context as MockAudioContext; + const scheduledTimers = captureScheduledTimers(); context.state = "running"; (AudioManager as any)._onHidden(); @@ -222,9 +294,10 @@ describe("AudioSource pending playback", () => { (AudioManager as any)._onShown(); expect((AudioManager as any)._hidden).to.be.false; + expect(scheduledTimers).to.have.lengthOf(1); // iOS zombie fix uses window.setTimeout(100ms) - vi.runAllTimers(); + scheduledTimers[0](); await flushAsync(); await flushAsync(); @@ -232,21 +305,19 @@ describe("AudioSource pending playback", () => { }); it("sets gesture resume flag when foreground resume fails", async () => { - vi.useFakeTimers(); createAudioSource(); const context = (AudioManager as any)._context as MockAudioContext; + const scheduledTimers = captureScheduledTimers(); context.state = "running"; - // Hide - vi.spyOn(document, "hidden", "get").mockReturnValue(true); - document.dispatchEvent(new Event("visibilitychange")); + (AudioManager as any)._onHidden(); // Show, but resume will fail MockAudioContext.shouldResumeSucceed = false; - vi.spyOn(document, "hidden", "get").mockReturnValue(false); - document.dispatchEvent(new Event("visibilitychange")); + (AudioManager as any)._onShown(); + expect(scheduledTimers).to.have.lengthOf(1); - await vi.advanceTimersByTimeAsync(100); + scheduledTimers[0](); await flushAsync(); expect((AudioManager as any)._needsUserGestureResume).to.be.true; @@ -316,9 +387,9 @@ describe("AudioSource pending playback", () => { }); it("does not resume a stopped source after hide/show cycle", async () => { - vi.useFakeTimers(); const audioSource = createAudioSource(); const context = (AudioManager as any)._context as MockAudioContext; + const scheduledTimers = captureScheduledTimers(); context.state = "running"; audioSource.play(); @@ -330,7 +401,8 @@ describe("AudioSource pending playback", () => { // hide → show cycle (AudioManager as any)._onHidden(); (AudioManager as any)._onShown(); - vi.runAllTimers(); + expect(scheduledTimers).to.have.lengthOf(1); + scheduledTimers[0](); await flushAsync(); // Source stays stopped — context resume does not restart stopped sources From b0aa6b80d5fb78413669afa3b1313957fca252e9 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Mon, 15 Jun 2026 17:00:10 +0800 Subject: [PATCH 06/18] fix(audio): guard resume retry state --- .../2026-06-15-audio-context-lifecycle-ci.md | 9 +++ packages/core/src/audio/AudioManager.ts | 27 ++++++-- .../audio/AudioSourcePendingPlayback.test.ts | 65 +++++++++++++++++++ 3 files changed, 96 insertions(+), 5 deletions(-) diff --git a/notes/audio/2026-06-15-audio-context-lifecycle-ci.md b/notes/audio/2026-06-15-audio-context-lifecycle-ci.md index ef9e015059..c122f17efe 100644 --- a/notes/audio/2026-06-15-audio-context-lifecycle-ci.md +++ b/notes/audio/2026-06-15-audio-context-lifecycle-ci.md @@ -19,6 +19,15 @@ The test file amplified the problem by relying on `document.hidden` getter mocks - Guard the delayed recovery callback and its async result against hidden pages, stale contexts, and caller-controlled suspension. - Make the audio lifecycle tests import the source audio module, reset `AudioManager` listeners/state per case, and drive scheduled callbacks explicitly instead of relying on DOM getter mocks or fake timer ordering. +## Review follow-up + +The first fix still left two `resume()` edges open: + +- Calling `AudioManager.resume()` while hidden could resume the shared context and make already-started source nodes continue in the background. +- Calling `AudioSource.play()` after an explicit `AudioManager.suspend()` could register pending playback, hit an autoplay rejection, and stay stuck because `suspend()` had already removed the gesture listeners. + +The follow-up fix keeps hidden `resume()` as a no-op before touching the context, and restores gesture retry state when `context.resume()` rejects while pending sources exist. Coverage now also drives `_onContextStateChange()` directly for external non-running context state. + ## Verification - `pnpm exec vitest run tests/src/core/audio/AudioSourcePendingPlayback.test.ts` diff --git a/packages/core/src/audio/AudioManager.ts b/packages/core/src/audio/AudioManager.ts index 4d5a7e9c0d..f7aae55546 100644 --- a/packages/core/src/audio/AudioManager.ts +++ b/packages/core/src/audio/AudioManager.ts @@ -41,6 +41,14 @@ export class AudioManager { static resume(): Promise { AudioManager._suspendedByCaller = false; AudioManager._clearForegroundResumeTimer(); + if (AudioManager._hidden || document.hidden) { + AudioManager._hidden = true; + if (AudioManager._pendingSources.size > 0) { + AudioManager._needsUserGestureResume = true; + } + return Promise.resolve(); + } + const context = AudioManager.getContext(); if (context.state === "running") { AudioManager._needsUserGestureResume = false; @@ -48,11 +56,20 @@ export class AudioManager { AudioManager._removeGestureListeners(); return Promise.resolve(); } - return context.resume().then(() => { - AudioManager._needsUserGestureResume = false; - AudioManager._resumePendingSources(); - AudioManager._removeGestureListeners(); - }); + return context + .resume() + .then(() => { + AudioManager._needsUserGestureResume = false; + AudioManager._resumePendingSources(); + AudioManager._removeGestureListeners(); + }) + .catch((e) => { + if (!AudioManager._hidden && AudioManager._pendingSources.size > 0) { + AudioManager._needsUserGestureResume = true; + AudioManager._addGestureListeners(); + } + throw e; + }); } /** @internal */ diff --git a/tests/src/core/audio/AudioSourcePendingPlayback.test.ts b/tests/src/core/audio/AudioSourcePendingPlayback.test.ts index 3a5999e900..6b65b928b7 100644 --- a/tests/src/core/audio/AudioSourcePendingPlayback.test.ts +++ b/tests/src/core/audio/AudioSourcePendingPlayback.test.ts @@ -251,6 +251,26 @@ describe("AudioSource pending playback", () => { expect(context.state).to.equal("suspended"); }); + it("does not resume the context while hidden", async () => { + const audioSource = createAudioSource(); + const context = (AudioManager as any)._context as MockAudioContext; + const resumeSpy = vi.spyOn(context, "resume"); + + context.state = "running"; + audioSource.play(); + expect(audioSource.isPlaying).to.be.true; + + (AudioManager as any)._onHidden(); + await flushAsync(); + + await AudioManager.resume(); + await flushAsync(); + + expect(resumeSpy).not.toHaveBeenCalled(); + expect(context.state).to.equal("suspended"); + expect(audioSource.isPlaying).to.be.true; + }); + it("does not auto-resume a caller-controlled suspend on the next gesture", async () => { createAudioSource(); const context = (AudioManager as any)._context as MockAudioContext; @@ -270,6 +290,33 @@ describe("AudioSource pending playback", () => { expect(context.state).to.equal("suspended"); }); + it("keeps pending playback retryable when play after explicit suspend is autoplay-blocked", async () => { + const audioSource = createAudioSource(); + const context = (AudioManager as any)._context as MockAudioContext; + + context.state = "running"; + await AudioManager.suspend(); + await flushAsync(); + + MockAudioContext.shouldResumeSucceed = false; + vi.spyOn(console, "warn").mockImplementation(() => {}); + + audioSource.play(); + await flushAsync(); + + expect((audioSource as any)._pendingPlay).to.be.true; + expect((AudioManager as any)._pendingSources.size).to.equal(1); + expect((AudioManager as any)._needsUserGestureResume).to.be.true; + + MockAudioContext.shouldResumeSucceed = true; + document.dispatchEvent(new Event("click")); + await flushAsync(); + + expect(audioSource.isPlaying).to.be.true; + expect((audioSource as any)._pendingPlay).to.be.false; + expect((AudioManager as any)._pendingSources.size).to.equal(0); + }); + it("does not act on visibilitychange shown without prior hide", async () => { createAudioSource(); const context = (AudioManager as any)._context as MockAudioContext; @@ -426,4 +473,22 @@ describe("AudioSource pending playback", () => { expect(context.state).to.equal("running"); expect((AudioManager as any)._needsUserGestureResume).to.be.false; }); + + it("marks external context interruption as gesture-retryable", async () => { + createAudioSource(); + const context = (AudioManager as any)._context as MockAudioContext; + + context.state = "suspended"; + context.onstatechange?.(); + await flushAsync(); + + expect((AudioManager as any)._needsUserGestureResume).to.be.true; + + MockAudioContext.shouldResumeSucceed = true; + document.dispatchEvent(new Event("pointerup")); + await flushAsync(); + + expect(context.state).to.equal("running"); + expect((AudioManager as any)._needsUserGestureResume).to.be.false; + }); }); From 2773cd3e9807f6e7bd6ab3119d2887fb6f3d02a1 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Mon, 15 Jun 2026 17:53:01 +0800 Subject: [PATCH 07/18] fix(audio): sync hidden resume state --- .../2026-06-15-audio-context-lifecycle-ci.md | 11 ++++ packages/core/src/audio/AudioManager.ts | 23 ++++++-- .../audio/AudioSourcePendingPlayback.test.ts | 56 +++++++++++++++++++ 3 files changed, 85 insertions(+), 5 deletions(-) diff --git a/notes/audio/2026-06-15-audio-context-lifecycle-ci.md b/notes/audio/2026-06-15-audio-context-lifecycle-ci.md index c122f17efe..e8d608858e 100644 --- a/notes/audio/2026-06-15-audio-context-lifecycle-ci.md +++ b/notes/audio/2026-06-15-audio-context-lifecycle-ci.md @@ -28,6 +28,17 @@ The first fix still left two `resume()` edges open: The follow-up fix keeps hidden `resume()` as a no-op before touching the context, and restores gesture retry state when `context.resume()` rejects while pending sources exist. Coverage now also drives `_onContextStateChange()` directly for external non-running context state. +## Second review follow-up + +The hidden `resume()` guard still had one ordering hole: `document.hidden` could already be `true` while `AudioManager._hidden` was still `false`. If another visibility listener called `AudioManager.resume()` in that window, the previous guard set `_hidden = true` and returned without suspending the running context. The real `_onHidden()` call then saw `_hidden === true` and skipped the required suspend. + +The fix routes the `document.hidden` branch through `_onHidden()`, and makes `_onHidden()` idempotent only when the context is already not running. `_resumePendingSources()` now also checks `document.hidden` so pending sources do not start in the same pre-handler hidden window. + +Regression coverage now includes both orders: + +- `document.hidden === true`, then `AudioManager.resume()`, before the hidden handler runs. +- `document.hidden === true`, then `_resumePendingSources()`, before the hidden handler runs. + ## Verification - `pnpm exec vitest run tests/src/core/audio/AudioSourcePendingPlayback.test.ts` diff --git a/packages/core/src/audio/AudioManager.ts b/packages/core/src/audio/AudioManager.ts index f7aae55546..00fa8f1498 100644 --- a/packages/core/src/audio/AudioManager.ts +++ b/packages/core/src/audio/AudioManager.ts @@ -41,8 +41,15 @@ export class AudioManager { static resume(): Promise { AudioManager._suspendedByCaller = false; AudioManager._clearForegroundResumeTimer(); - if (AudioManager._hidden || document.hidden) { - AudioManager._hidden = true; + if (document.hidden) { + AudioManager._onHidden(); + if (AudioManager._pendingSources.size > 0) { + AudioManager._needsUserGestureResume = true; + } + return Promise.resolve(); + } + + if (AudioManager._hidden) { if (AudioManager._pendingSources.size > 0) { AudioManager._needsUserGestureResume = true; } @@ -143,12 +150,13 @@ export class AudioManager { } private static _onHidden(): void { - if (AudioManager._hidden) { + const context = AudioManager._context; + if (AudioManager._hidden && context?.state !== "running") { return; } AudioManager._hidden = true; AudioManager._clearForegroundResumeTimer(); - AudioManager._context?.suspend(); + context?.suspend().catch(() => {}); } private static _onShown(): void { @@ -190,7 +198,12 @@ export class AudioManager { } private static _resumePendingSources(): void { - if (!AudioManager._pendingSources.size || AudioManager._hidden || !AudioManager.isAudioContextRunning()) { + if ( + !AudioManager._pendingSources.size || + AudioManager._hidden || + document.hidden || + !AudioManager.isAudioContextRunning() + ) { return; } const sources = Array.from(AudioManager._pendingSources); diff --git a/tests/src/core/audio/AudioSourcePendingPlayback.test.ts b/tests/src/core/audio/AudioSourcePendingPlayback.test.ts index 6b65b928b7..3603bb9a7c 100644 --- a/tests/src/core/audio/AudioSourcePendingPlayback.test.ts +++ b/tests/src/core/audio/AudioSourcePendingPlayback.test.ts @@ -123,6 +123,21 @@ function captureScheduledTimers(): Array<() => void> { return scheduledTimers; } +function setDocumentHiddenForTest(hidden: boolean): () => void { + const ownDescriptor = Object.getOwnPropertyDescriptor(document, "hidden"); + Object.defineProperty(document, "hidden", { + configurable: true, + get: () => hidden + }); + return () => { + if (ownDescriptor) { + Object.defineProperty(document, "hidden", ownDescriptor); + } else { + delete (document as any).hidden; + } + }; +} + describe("AudioSource pending playback", () => { beforeEach(() => { resetAudioManagerState(); @@ -271,6 +286,47 @@ describe("AudioSource pending playback", () => { 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"); + + context.state = "running"; + const restoreDocumentHidden = setDocumentHiddenForTest(true); + + await AudioManager.resume(); + restoreDocumentHidden(); + await flushAsync(); + + expect(resumeSpy).not.toHaveBeenCalled(); + expect(suspendSpy).toHaveBeenCalledTimes(1); + expect((AudioManager as any)._hidden).to.be.true; + + (AudioManager as any)._onHidden(); + await flushAsync(); + + expect(context.state).to.equal("suspended"); + }); + + it("does not resume pending sources after document becomes hidden but before hidden handler", async () => { + createAudioSource(); + const context = (AudioManager as any)._context as MockAudioContext; + const pendingSource = { + _resumePendingPlayback: vi.fn() + }; + + context.state = "running"; + const restoreDocumentHidden = setDocumentHiddenForTest(true); + (AudioManager as any)._registerPendingSource(pendingSource); + + (AudioManager as any)._resumePendingSources(); + restoreDocumentHidden(); + + expect(pendingSource._resumePendingPlayback).not.toHaveBeenCalled(); + expect((AudioManager as any)._pendingSources.size).to.equal(1); + }); + it("does not auto-resume a caller-controlled suspend on the next gesture", async () => { createAudioSource(); const context = (AudioManager as any)._context as MockAudioContext; From 238ea9c30b68e949a2bf62f6ef1d1800ed290c0b Mon Sep 17 00:00:00 2001 From: luzhuang Date: Mon, 15 Jun 2026 18:01:39 +0800 Subject: [PATCH 08/18] chore: remove audio lifecycle notes from pr --- .../2026-06-15-audio-context-lifecycle-ci.md | 50 ------------------- 1 file changed, 50 deletions(-) delete mode 100644 notes/audio/2026-06-15-audio-context-lifecycle-ci.md diff --git a/notes/audio/2026-06-15-audio-context-lifecycle-ci.md b/notes/audio/2026-06-15-audio-context-lifecycle-ci.md deleted file mode 100644 index e8d608858e..0000000000 --- a/notes/audio/2026-06-15-audio-context-lifecycle-ci.md +++ /dev/null @@ -1,50 +0,0 @@ -# Audio context lifecycle CI fix - -## Context - -PR #3026 failed only in the `codecov` job. The failing step was `Test`, not the Codecov upload. The job log showed `AudioSourcePendingPlayback.test.ts` failures with `RangeError: Maximum call stack size exceeded`, while lint, build, and e2e checks were green on the same head. - -## Root cause - -`AudioManager` treated every foreground `suspended` state as an external browser interruption. That made a caller-controlled `AudioManager.suspend()` eligible for automatic click/pointer resume. - -The iOS foreground recovery path also scheduled a delayed `context.resume()` without retaining or invalidating the timer. If the page was hidden again before the delay elapsed, the stale callback could resume audio while hidden. - -The test file amplified the problem by relying on `document.hidden` getter mocks and fake timers across lifecycle tests. In full coverage order this left enough asynchronous state behind to reproduce the CI-only stack overflow. - -## Fix - -- Track caller-controlled suspension separately from browser interruptions. -- Store and clear the foreground recovery timer on hide, explicit suspend, and explicit resume. -- Guard the delayed recovery callback and its async result against hidden pages, stale contexts, and caller-controlled suspension. -- Make the audio lifecycle tests import the source audio module, reset `AudioManager` listeners/state per case, and drive scheduled callbacks explicitly instead of relying on DOM getter mocks or fake timer ordering. - -## Review follow-up - -The first fix still left two `resume()` edges open: - -- Calling `AudioManager.resume()` while hidden could resume the shared context and make already-started source nodes continue in the background. -- Calling `AudioSource.play()` after an explicit `AudioManager.suspend()` could register pending playback, hit an autoplay rejection, and stay stuck because `suspend()` had already removed the gesture listeners. - -The follow-up fix keeps hidden `resume()` as a no-op before touching the context, and restores gesture retry state when `context.resume()` rejects while pending sources exist. Coverage now also drives `_onContextStateChange()` directly for external non-running context state. - -## Second review follow-up - -The hidden `resume()` guard still had one ordering hole: `document.hidden` could already be `true` while `AudioManager._hidden` was still `false`. If another visibility listener called `AudioManager.resume()` in that window, the previous guard set `_hidden = true` and returned without suspending the running context. The real `_onHidden()` call then saw `_hidden === true` and skipped the required suspend. - -The fix routes the `document.hidden` branch through `_onHidden()`, and makes `_onHidden()` idempotent only when the context is already not running. `_resumePendingSources()` now also checks `document.hidden` so pending sources do not start in the same pre-handler hidden window. - -Regression coverage now includes both orders: - -- `document.hidden === true`, then `AudioManager.resume()`, before the hidden handler runs. -- `document.hidden === true`, then `_resumePendingSources()`, before the hidden handler runs. - -## Verification - -- `pnpm exec vitest run tests/src/core/audio/AudioSourcePendingPlayback.test.ts` -- `pnpm exec cross-env HEADLESS=true vitest run --coverage tests/src/core/audio/AudioSourcePendingPlayback.test.ts` -- `pnpm exec cross-env HEADLESS=true vitest run --coverage tests/src/core/PolyfillAudioContext.test.ts tests/src/core/audio/AudioSource.test.ts tests/src/core/audio/AudioSourcePendingPlayback.test.ts` -- `pnpm -F @galacean/engine-core run b:types` -- `pnpm exec eslint packages/core/src/audio/AudioManager.ts tests/src/core/audio/AudioSourcePendingPlayback.test.ts` - -`npm run build` was also attempted locally, but this machine failed during shader precompile before reaching the audio change. The failure was from shader compiler precompile resolving render-state enums as `undefined`; the live PR CI had already passed the build job on the same PR head. From 560b77b4c3226ace3d480710e9970769c53ab4bc Mon Sep 17 00:00:00 2001 From: luzhuang Date: Mon, 15 Jun 2026 18:11:23 +0800 Subject: [PATCH 09/18] test(audio): use lifecycle events in playback tests --- .../audio/AudioSourcePendingPlayback.test.ts | 106 +++++++++--------- 1 file changed, 52 insertions(+), 54 deletions(-) diff --git a/tests/src/core/audio/AudioSourcePendingPlayback.test.ts b/tests/src/core/audio/AudioSourcePendingPlayback.test.ts index 3603bb9a7c..f8e0a86a6b 100644 --- a/tests/src/core/audio/AudioSourcePendingPlayback.test.ts +++ b/tests/src/core/audio/AudioSourcePendingPlayback.test.ts @@ -45,7 +45,11 @@ class MockAudioContext { resume(): Promise { 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); @@ -123,17 +127,23 @@ function captureScheduledTimers(): Array<() => void> { return scheduledTimers; } -function setDocumentHiddenForTest(hidden: boolean): () => void { +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 () => { - if (ownDescriptor) { - Object.defineProperty(document, "hidden", ownDescriptor); - } else { - delete (document as any).hidden; + return { + set(value: boolean) { + hidden = value; + }, + restore() { + if (ownDescriptor) { + Object.defineProperty(document, "hidden", ownDescriptor); + } else { + delete (document as any).hidden; + } } }; } @@ -215,7 +225,9 @@ describe("AudioSource pending playback", () => { context.state = "running"; - (AudioManager as any)._onHidden(); + const documentHidden = mockDocumentHidden(true); + document.dispatchEvent(new Event("visibilitychange")); + documentHidden.restore(); await flushAsync(); expect(suspendSpy).toHaveBeenCalledTimes(1); @@ -231,10 +243,13 @@ describe("AudioSource pending playback", () => { const suspendSpy = vi.spyOn(context, "suspend"); const resumeSpy = vi.spyOn(context, "resume"); - (AudioManager as any)._onHidden(); + const documentHidden = mockDocumentHidden(true); + document.dispatchEvent(new Event("visibilitychange")); expect(suspendSpy).toHaveBeenCalledTimes(1); - (AudioManager as any)._onShown(); + documentHidden.set(false); + document.dispatchEvent(new Event("visibilitychange")); + documentHidden.restore(); // _onShown calls context.suspend() synchronously then schedules resume after 100ms expect(suspendSpy).toHaveBeenCalledTimes(2); expect(scheduledTimers).to.have.lengthOf(1); @@ -253,11 +268,15 @@ describe("AudioSource pending playback", () => { context.state = "running"; const resumeSpy = vi.spyOn(context, "resume"); - (AudioManager as any)._onHidden(); - (AudioManager as any)._onShown(); + const documentHidden = mockDocumentHidden(true); + document.dispatchEvent(new Event("visibilitychange")); + documentHidden.set(false); + document.dispatchEvent(new Event("visibilitychange")); expect(scheduledTimers).to.have.lengthOf(1); - (AudioManager as any)._onHidden(); + documentHidden.set(true); + document.dispatchEvent(new Event("visibilitychange")); + documentHidden.restore(); scheduledTimers[0](); await flushAsync(); @@ -275,7 +294,7 @@ describe("AudioSource pending playback", () => { audioSource.play(); expect(audioSource.isPlaying).to.be.true; - (AudioManager as any)._onHidden(); + window.dispatchEvent(new Event("pagehide")); await flushAsync(); await AudioManager.resume(); @@ -293,17 +312,17 @@ describe("AudioSource pending playback", () => { const resumeSpy = vi.spyOn(context, "resume"); context.state = "running"; - const restoreDocumentHidden = setDocumentHiddenForTest(true); + const documentHidden = mockDocumentHidden(true); await AudioManager.resume(); - restoreDocumentHidden(); + documentHidden.restore(); await flushAsync(); expect(resumeSpy).not.toHaveBeenCalled(); expect(suspendSpy).toHaveBeenCalledTimes(1); expect((AudioManager as any)._hidden).to.be.true; - (AudioManager as any)._onHidden(); + window.dispatchEvent(new Event("pagehide")); await flushAsync(); expect(context.state).to.equal("suspended"); @@ -317,11 +336,11 @@ describe("AudioSource pending playback", () => { }; context.state = "running"; - const restoreDocumentHidden = setDocumentHiddenForTest(true); + const documentHidden = mockDocumentHidden(true); (AudioManager as any)._registerPendingSource(pendingSource); (AudioManager as any)._resumePendingSources(); - restoreDocumentHidden(); + documentHidden.restore(); expect(pendingSource._resumePendingPlayback).not.toHaveBeenCalled(); expect((AudioManager as any)._pendingSources.size).to.equal(1); @@ -334,7 +353,6 @@ describe("AudioSource pending playback", () => { context.state = "running"; await AudioManager.suspend(); - (AudioManager as any)._onContextStateChange(); await flushAsync(); expect((AudioManager as any)._needsUserGestureResume).to.be.false; @@ -379,7 +397,9 @@ describe("AudioSource pending playback", () => { const suspendSpy = vi.spyOn(context, "suspend"); - (AudioManager as any)._onShown(); + const documentHidden = mockDocumentHidden(false); + document.dispatchEvent(new Event("visibilitychange")); + documentHidden.restore(); await flushAsync(); expect(suspendSpy).not.toHaveBeenCalled(); @@ -391,11 +411,11 @@ describe("AudioSource pending playback", () => { const scheduledTimers = captureScheduledTimers(); context.state = "running"; - (AudioManager as any)._onHidden(); + window.dispatchEvent(new Event("pagehide")); expect((AudioManager as any)._hidden).to.be.true; expect(context.state).to.equal("suspended"); - (AudioManager as any)._onShown(); + window.dispatchEvent(new Event("pageshow")); expect((AudioManager as any)._hidden).to.be.false; expect(scheduledTimers).to.have.lengthOf(1); @@ -413,11 +433,11 @@ describe("AudioSource pending playback", () => { const scheduledTimers = captureScheduledTimers(); context.state = "running"; - (AudioManager as any)._onHidden(); + window.dispatchEvent(new Event("pagehide")); // Show, but resume will fail MockAudioContext.shouldResumeSucceed = false; - (AudioManager as any)._onShown(); + window.dispatchEvent(new Event("pageshow")); expect(scheduledTimers).to.have.lengthOf(1); scheduledTimers[0](); @@ -447,13 +467,9 @@ describe("AudioSource pending playback", () => { expect(resumeSpy).toHaveBeenCalledTimes(1); - MockAudioContext.resumeResultQueue = [ - Promise.resolve().then(() => { - context.state = "running"; - context.onstatechange?.(); - }) - ]; - (AudioManager as any)._needsUserGestureResume = true; + MockAudioContext.resumeResultQueue = [Promise.resolve()]; + context.onstatechange?.(); + await flushAsync(); document.dispatchEvent(new Event("click")); await flushAsync(); @@ -502,8 +518,8 @@ describe("AudioSource pending playback", () => { expect(audioSource.isPlaying).to.be.false; // hide → show cycle - (AudioManager as any)._onHidden(); - (AudioManager as any)._onShown(); + window.dispatchEvent(new Event("pagehide")); + window.dispatchEvent(new Event("pageshow")); expect(scheduledTimers).to.have.lengthOf(1); scheduledTimers[0](); await flushAsync(); @@ -513,38 +529,20 @@ describe("AudioSource pending playback", () => { expect(AudioManager._playingCount).to.equal(0); }); - it("recovers via gesture when _needsUserGestureResume is set (external interruption path)", async () => { - createAudioSource(); - const context = (AudioManager as any)._context as MockAudioContext; - context.state = "suspended"; - - // Simulate the state that _onContextStateChange would set on external interruption - (AudioManager as any)._needsUserGestureResume = true; - - // Gesture triggers resume - MockAudioContext.shouldResumeSucceed = true; - document.dispatchEvent(new Event("pointerup")); - await flushAsync(); - - expect(context.state).to.equal("running"); - expect((AudioManager as any)._needsUserGestureResume).to.be.false; - }); - it("marks external context interruption as gesture-retryable", async () => { createAudioSource(); const context = (AudioManager as any)._context as MockAudioContext; + const resumeSpy = vi.spyOn(context, "resume"); context.state = "suspended"; context.onstatechange?.(); await flushAsync(); - expect((AudioManager as any)._needsUserGestureResume).to.be.true; - MockAudioContext.shouldResumeSucceed = true; document.dispatchEvent(new Event("pointerup")); await flushAsync(); + expect(resumeSpy).toHaveBeenCalledTimes(1); expect(context.state).to.equal("running"); - expect((AudioManager as any)._needsUserGestureResume).to.be.false; }); }); From a3570dcd0891f51bb01cf0da986e1d6fa87d9c3f Mon Sep 17 00:00:00 2001 From: luzhuang Date: Mon, 15 Jun 2026 19:15:15 +0800 Subject: [PATCH 10/18] refactor(audio): simplify lifecycle guards --- packages/core/src/audio/AudioManager.ts | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/core/src/audio/AudioManager.ts b/packages/core/src/audio/AudioManager.ts index 00fa8f1498..3ec61960a0 100644 --- a/packages/core/src/audio/AudioManager.ts +++ b/packages/core/src/audio/AudioManager.ts @@ -43,16 +43,10 @@ export class AudioManager { AudioManager._clearForegroundResumeTimer(); if (document.hidden) { AudioManager._onHidden(); - if (AudioManager._pendingSources.size > 0) { - AudioManager._needsUserGestureResume = true; - } - return Promise.resolve(); } if (AudioManager._hidden) { - if (AudioManager._pendingSources.size > 0) { - AudioManager._needsUserGestureResume = true; - } + AudioManager._markPendingGestureResume(); return Promise.resolve(); } @@ -89,6 +83,12 @@ export class AudioManager { AudioManager._pendingSources.delete(source); } + private static _markPendingGestureResume(): void { + if (AudioManager._pendingSources.size > 0) { + AudioManager._needsUserGestureResume = true; + } + } + /** * @internal */ @@ -169,18 +169,20 @@ export class AudioManager { 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. context.suspend(); AudioManager._foregroundResumeTimer = setTimeout(() => { AudioManager._foregroundResumeTimer = null; - if (AudioManager._hidden || AudioManager._suspendedByCaller || AudioManager._context !== context) { + if (isForegroundResumeStale()) { return; } context .resume() .then(() => { - if (AudioManager._hidden || AudioManager._suspendedByCaller || AudioManager._context !== context) { + if (isForegroundResumeStale()) { return; } AudioManager._needsUserGestureResume = false; @@ -188,7 +190,7 @@ export class AudioManager { AudioManager._removeGestureListeners(); }) .catch(() => { - if (AudioManager._hidden || AudioManager._suspendedByCaller || AudioManager._context !== context) { + if (isForegroundResumeStale()) { return; } AudioManager._needsUserGestureResume = true; From 573f21d0192a6766b1ba3a0747d4e3943d0cf556 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Tue, 16 Jun 2026 14:50:11 +0800 Subject: [PATCH 11/18] fix(audio): drop cross-gesture playback replay --- packages/core/src/audio/AudioManager.ts | 64 ++--------------- packages/core/src/audio/AudioSource.ts | 45 ++++++------ .../audio/AudioSourcePendingPlayback.test.ts | 68 +++++++++---------- 3 files changed, 60 insertions(+), 117 deletions(-) diff --git a/packages/core/src/audio/AudioManager.ts b/packages/core/src/audio/AudioManager.ts index 3ec61960a0..de48e8d6e5 100644 --- a/packages/core/src/audio/AudioManager.ts +++ b/packages/core/src/audio/AudioManager.ts @@ -1,7 +1,3 @@ -type PendingAudioSource = { - _resumePendingPlayback(): void; -}; - /** * Audio Manager for managing global audio context and settings. */ @@ -12,7 +8,6 @@ export class AudioManager { private static _context: AudioContext; private static _gainNode: GainNode; private static _needsUserGestureResume = false; - private static _pendingSources = new Set(); private static _hidden = false; private static _foregroundResumeTimer: ReturnType | null = null; private static _suspendedByCaller = false; @@ -46,47 +41,19 @@ export class AudioManager { } if (AudioManager._hidden) { - AudioManager._markPendingGestureResume(); return Promise.resolve(); } const context = AudioManager.getContext(); if (context.state === "running") { AudioManager._needsUserGestureResume = false; - AudioManager._resumePendingSources(); AudioManager._removeGestureListeners(); return Promise.resolve(); } - return context - .resume() - .then(() => { - AudioManager._needsUserGestureResume = false; - AudioManager._resumePendingSources(); - AudioManager._removeGestureListeners(); - }) - .catch((e) => { - if (!AudioManager._hidden && AudioManager._pendingSources.size > 0) { - AudioManager._needsUserGestureResume = true; - AudioManager._addGestureListeners(); - } - throw e; - }); - } - - /** @internal */ - static _registerPendingSource(source: PendingAudioSource): void { - AudioManager._pendingSources.add(source); - } - - /** @internal */ - static _unregisterPendingSource(source: PendingAudioSource): void { - AudioManager._pendingSources.delete(source); - } - - private static _markPendingGestureResume(): void { - if (AudioManager._pendingSources.size > 0) { - AudioManager._needsUserGestureResume = true; - } + return context.resume().then(() => { + AudioManager._needsUserGestureResume = false; + AudioManager._removeGestureListeners(); + }); } /** @@ -131,7 +98,6 @@ export class AudioManager { if (state === "running" && !AudioManager._hidden) { AudioManager._suspendedByCaller = false; AudioManager._needsUserGestureResume = false; - AudioManager._resumePendingSources(); AudioManager._removeGestureListeners(); } else if ( state && @@ -186,7 +152,6 @@ export class AudioManager { return; } AudioManager._needsUserGestureResume = false; - AudioManager._resumePendingSources(); AudioManager._removeGestureListeners(); }) .catch(() => { @@ -199,27 +164,8 @@ export class AudioManager { }, 100); } - private static _resumePendingSources(): void { - if ( - !AudioManager._pendingSources.size || - AudioManager._hidden || - document.hidden || - !AudioManager.isAudioContextRunning() - ) { - return; - } - const sources = Array.from(AudioManager._pendingSources); - AudioManager._pendingSources.clear(); - for (let i = 0, n = sources.length; i < n; i++) { - sources[i]._resumePendingPlayback(); - } - } - private static _resumeAfterInterruption(): void { - if ( - !AudioManager._suspendedByCaller && - (AudioManager._needsUserGestureResume || AudioManager._pendingSources.size > 0) - ) { + if (!AudioManager._suspendedByCaller && AudioManager._needsUserGestureResume) { AudioManager.resume().catch(() => {}); } } diff --git a/packages/core/src/audio/AudioSource.ts b/packages/core/src/audio/AudioSource.ts index 47601c7b9e..9d80ec09b1 100644 --- a/packages/core/src/audio/AudioSource.ts +++ b/packages/core/src/audio/AudioSource.ts @@ -160,10 +160,27 @@ export class AudioSource extends Component { this._startPlayback(); } else { this._pendingPlay = true; - AudioManager._registerPendingSource(this); - AudioManager.resume().catch((e) => { - console.warn("Failed to resume AudioContext:", e); - }); + AudioManager.resume().then( + () => { + if (!this._pendingPlay) { + return; + } + this._pendingPlay = false; + if ( + this._destroyed || + !this.enabled || + !this._clip?._getAudioSource() || + !AudioManager.isAudioContextRunning() + ) { + return; + } + this._startPlayback(); + }, + (e) => { + this._pendingPlay = false; + console.warn("Failed to resume AudioContext:", e); + } + ); } } @@ -233,21 +250,6 @@ export class AudioSource extends Component { this.stop(); } - /** @internal */ - _resumePendingPlayback(): void { - if (!this._pendingPlay) { - return; - } - - this._pendingPlay = false; - - if (this._destroyed || !this.enabled || !this._clip?._getAudioSource()) { - return; - } - - this._startPlayback(); - } - private _startPlayback(): void { const startTime = this._pausedTime > 0 ? this._pausedTime - this._playTime : 0; if (!this._initSourceNode(startTime)) { @@ -303,11 +305,6 @@ export class AudioSource extends Component { } private _cancelPendingPlayback(): void { - if (!this._pendingPlay) { - return; - } - this._pendingPlay = false; - AudioManager._unregisterPendingSource(this); } } diff --git a/tests/src/core/audio/AudioSourcePendingPlayback.test.ts b/tests/src/core/audio/AudioSourcePendingPlayback.test.ts index f8e0a86a6b..9cd3297835 100644 --- a/tests/src/core/audio/AudioSourcePendingPlayback.test.ts +++ b/tests/src/core/audio/AudioSourcePendingPlayback.test.ts @@ -111,7 +111,6 @@ function resetAudioManagerState(): void { (AudioManager as any)._context = null; (AudioManager as any)._gainNode = null; (AudioManager as any)._needsUserGestureResume = false; - (AudioManager as any)._pendingSources = new Set(); (AudioManager as any)._hidden = false; (AudioManager as any)._foregroundResumeTimer = null; (AudioManager as any)._suspendedByCaller = false; @@ -148,7 +147,7 @@ function mockDocumentHidden(initialHidden: boolean): { set(hidden: boolean): voi }; } -describe("AudioSource pending playback", () => { +describe("AudioSource playback lifecycle", () => { beforeEach(() => { resetAudioManagerState(); (window as any).AudioContext = MockAudioContext; @@ -165,7 +164,7 @@ describe("AudioSource pending playback", () => { await flushAsync(); }); - it("replays pending playback on the next user gesture after autoplay blocking", async () => { + it("drops playback after autoplay-blocked resume instead of replaying on a later gesture", async () => { const audioSource = createAudioSource(); vi.spyOn(console, "warn").mockImplementation(() => {}); @@ -174,34 +173,32 @@ describe("AudioSource pending playback", () => { audioSource.play(); await flushAsync(); - expect((audioSource as any)._pendingPlay).to.be.true; - expect((AudioManager as any)._pendingSources.size).to.equal(1); + expect((audioSource as any)._pendingPlay).to.be.false; expect(audioSource.isPlaying).to.be.false; MockAudioContext.shouldResumeSucceed = true; document.dispatchEvent(new Event("click")); await flushAsync(); - expect(audioSource.isPlaying).to.be.true; - expect((audioSource as any)._pendingPlay).to.be.false; - expect((AudioManager as any)._pendingSources.size).to.equal(0); + expect(audioSource.isPlaying).to.be.false; }); - it("cancels pending playback before the unlocking gesture arrives", async () => { + it("cancels one-shot pending playback before resume resolves", async () => { const audioSource = createAudioSource(); - - vi.spyOn(console, "warn").mockImplementation(() => {}); - MockAudioContext.shouldResumeSucceed = false; + let resolveResume: () => void; + MockAudioContext.resumeResultQueue = [ + new Promise((resolve) => { + resolveResume = resolve; + }) + ]; audioSource.play(); await flushAsync(); + expect((audioSource as any)._pendingPlay).to.be.true; audioSource.stop(); expect((audioSource as any)._pendingPlay).to.be.false; - expect((AudioManager as any)._pendingSources.size).to.equal(0); - - MockAudioContext.shouldResumeSucceed = true; - document.dispatchEvent(new Event("click")); + resolveResume!(); await flushAsync(); expect(audioSource.isPlaying).to.be.false; @@ -328,22 +325,28 @@ describe("AudioSource pending playback", () => { expect(context.state).to.equal("suspended"); }); - it("does not resume pending sources after document becomes hidden but before hidden handler", async () => { - createAudioSource(); + it("drops playback requested while hidden instead of replaying after show", async () => { + const audioSource = createAudioSource(); const context = (AudioManager as any)._context as MockAudioContext; - const pendingSource = { - _resumePendingPlayback: vi.fn() - }; + const scheduledTimers = captureScheduledTimers(); context.state = "running"; - const documentHidden = mockDocumentHidden(true); - (AudioManager as any)._registerPendingSource(pendingSource); + window.dispatchEvent(new Event("pagehide")); + await flushAsync(); - (AudioManager as any)._resumePendingSources(); - documentHidden.restore(); + audioSource.play(); + await flushAsync(); + + expect((audioSource as any)._pendingPlay).to.be.false; + expect(audioSource.isPlaying).to.be.false; + + window.dispatchEvent(new Event("pageshow")); + expect(scheduledTimers).to.have.lengthOf(1); + scheduledTimers[0](); + await flushAsync(); - expect(pendingSource._resumePendingPlayback).not.toHaveBeenCalled(); - expect((AudioManager as any)._pendingSources.size).to.equal(1); + 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 () => { @@ -364,7 +367,7 @@ describe("AudioSource pending playback", () => { expect(context.state).to.equal("suspended"); }); - it("keeps pending playback retryable when play after explicit suspend is autoplay-blocked", async () => { + it("drops playback after explicit suspend when resume is autoplay-blocked", async () => { const audioSource = createAudioSource(); const context = (AudioManager as any)._context as MockAudioContext; @@ -378,17 +381,14 @@ describe("AudioSource pending playback", () => { audioSource.play(); await flushAsync(); - expect((audioSource as any)._pendingPlay).to.be.true; - expect((AudioManager as any)._pendingSources.size).to.equal(1); - expect((AudioManager as any)._needsUserGestureResume).to.be.true; + expect((audioSource as any)._pendingPlay).to.be.false; + expect((AudioManager as any)._needsUserGestureResume).to.be.false; MockAudioContext.shouldResumeSucceed = true; document.dispatchEvent(new Event("click")); await flushAsync(); - expect(audioSource.isPlaying).to.be.true; - expect((audioSource as any)._pendingPlay).to.be.false; - expect((AudioManager as any)._pendingSources.size).to.equal(0); + expect(audioSource.isPlaying).to.be.false; }); it("does not act on visibilitychange shown without prior hide", async () => { From b615f01c91264b0f3b7079a2440bc541d68186fd Mon Sep 17 00:00:00 2001 From: luzhuang Date: Tue, 16 Jun 2026 15:27:21 +0800 Subject: [PATCH 12/18] fix(audio): catch foreground suspend reset rejection --- packages/core/src/audio/AudioManager.ts | 2 +- .../audio/AudioSourcePendingPlayback.test.ts | 24 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/core/src/audio/AudioManager.ts b/packages/core/src/audio/AudioManager.ts index de48e8d6e5..b914a00a3a 100644 --- a/packages/core/src/audio/AudioManager.ts +++ b/packages/core/src/audio/AudioManager.ts @@ -139,7 +139,7 @@ export class AudioManager { 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. - context.suspend(); + context.suspend().catch(() => {}); AudioManager._foregroundResumeTimer = setTimeout(() => { AudioManager._foregroundResumeTimer = null; if (isForegroundResumeStale()) { diff --git a/tests/src/core/audio/AudioSourcePendingPlayback.test.ts b/tests/src/core/audio/AudioSourcePendingPlayback.test.ts index 9cd3297835..99b3c19212 100644 --- a/tests/src/core/audio/AudioSourcePendingPlayback.test.ts +++ b/tests/src/core/audio/AudioSourcePendingPlayback.test.ts @@ -27,6 +27,7 @@ class MockBufferSourceNode { class MockAudioContext { static shouldResumeSucceed = true; + static shouldSuspendSucceed = true; static resumeResultQueue: Array | Error> | null = null; currentTime = 0; @@ -65,6 +66,9 @@ class MockAudioContext { } suspend(): Promise { + if (!MockAudioContext.shouldSuspendSucceed) { + return Promise.reject(new Error("suspend blocked")); + } this.state = "suspended"; const cb = this.onstatechange; return Promise.resolve().then(() => { @@ -152,6 +156,7 @@ describe("AudioSource playback lifecycle", () => { resetAudioManagerState(); (window as any).AudioContext = MockAudioContext; MockAudioContext.shouldResumeSucceed = true; + MockAudioContext.shouldSuspendSucceed = true; MockAudioContext.resumeResultQueue = null; }); @@ -454,6 +459,25 @@ describe("AudioSource playback lifecycle", () => { 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"); + context.state = "running"; + + window.dispatchEvent(new Event("pagehide")); + MockAudioContext.shouldSuspendSucceed = false; + window.dispatchEvent(new Event("pageshow")); + expect(scheduledTimers).to.have.lengthOf(1); + + scheduledTimers[0](); + await flushAsync(); + + expect(resumeSpy).toHaveBeenCalledTimes(1); + expect(context.state).to.equal("running"); + }); + 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; From f05afeb143d8e54bc611c977ba14066eb5851a30 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Tue, 16 Jun 2026 15:45:48 +0800 Subject: [PATCH 13/18] refactor(audio): trim context lifecycle state --- packages/core/src/audio/AudioManager.ts | 4 ---- packages/core/src/audio/AudioSource.ts | 3 --- .../audio/AudioSourcePendingPlayback.test.ts | 19 +++++++++++-------- 3 files changed, 11 insertions(+), 15 deletions(-) diff --git a/packages/core/src/audio/AudioManager.ts b/packages/core/src/audio/AudioManager.ts index b914a00a3a..d72a0d6b6e 100644 --- a/packages/core/src/audio/AudioManager.ts +++ b/packages/core/src/audio/AudioManager.ts @@ -2,9 +2,6 @@ * 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 _needsUserGestureResume = false; @@ -68,7 +65,6 @@ export class AudioManager { document.addEventListener("visibilitychange", AudioManager._onVisibilityChange); window.addEventListener("pagehide", AudioManager._onHidden); window.addEventListener("pageshow", AudioManager._onShown); - AudioManager._addGestureListeners(); } return context; } diff --git a/packages/core/src/audio/AudioSource.ts b/packages/core/src/audio/AudioSource.ts index 9d80ec09b1..ebca34becb 100644 --- a/packages/core/src/audio/AudioSource.ts +++ b/packages/core/src/audio/AudioSource.ts @@ -193,7 +193,6 @@ export class AudioSource extends Component { if (this._isPlaying) { this._clearSourceNode(); this._isPlaying = false; - AudioManager._playingCount--; } this._pausedTime = -1; @@ -210,7 +209,6 @@ export class AudioSource extends Component { this._clearSourceNode(); this._pausedTime = AudioManager.getContext().currentTime; this._isPlaying = false; - AudioManager._playingCount--; } } @@ -261,7 +259,6 @@ export class AudioSource extends Component { this._playTime = AudioManager.getContext().currentTime - startTime; this._pausedTime = -1; this._isPlaying = true; - AudioManager._playingCount++; } private _initSourceNode(startTime: number): boolean { diff --git a/tests/src/core/audio/AudioSourcePendingPlayback.test.ts b/tests/src/core/audio/AudioSourcePendingPlayback.test.ts index 99b3c19212..38b8750886 100644 --- a/tests/src/core/audio/AudioSourcePendingPlayback.test.ts +++ b/tests/src/core/audio/AudioSourcePendingPlayback.test.ts @@ -118,7 +118,6 @@ function resetAudioManagerState(): void { (AudioManager as any)._hidden = false; (AudioManager as any)._foregroundResumeTimer = null; (AudioManager as any)._suspendedByCaller = false; - AudioManager._playingCount = 0; } function captureScheduledTimers(): Array<() => void> { @@ -503,7 +502,7 @@ describe("AudioSource playback lifecycle", () => { expect((AudioManager as any)._needsUserGestureResume).to.be.false; }); - it("keeps _playingCount balanced across play/stop/pause/ended", async () => { + it("keeps playback state consistent across play/stop/pause/ended", async () => { createAudioSource(); const context = (AudioManager as any)._context as MockAudioContext; context.state = "running"; @@ -513,20 +512,25 @@ describe("AudioSource playback lifecycle", () => { s1.play(); s2.play(); - expect(AudioManager._playingCount).to.equal(2); + expect(s1.isPlaying).to.be.true; + expect(s2.isPlaying).to.be.true; s1.pause(); - expect(AudioManager._playingCount).to.equal(1); + expect(s1.isPlaying).to.be.false; + expect(s2.isPlaying).to.be.true; s1.play(); - expect(AudioManager._playingCount).to.equal(2); + expect(s1.isPlaying).to.be.true; + expect(s2.isPlaying).to.be.true; s2.stop(); - expect(AudioManager._playingCount).to.equal(1); + expect(s1.isPlaying).to.be.true; + expect(s2.isPlaying).to.be.false; // Simulate onended (s1 as any)._onPlayEnd(); - expect(AudioManager._playingCount).to.equal(0); + expect(s1.isPlaying).to.be.false; + expect(s2.isPlaying).to.be.false; }); it("does not resume a stopped source after hide/show cycle", async () => { @@ -550,7 +554,6 @@ describe("AudioSource playback lifecycle", () => { // Source stays stopped — context resume does not restart stopped sources expect(audioSource.isPlaying).to.be.false; - expect(AudioManager._playingCount).to.equal(0); }); it("marks external context interruption as gesture-retryable", async () => { From e43c51cc2697280018b050f530569474d6af5e1c Mon Sep 17 00:00:00 2001 From: luzhuang Date: Tue, 16 Jun 2026 16:02:48 +0800 Subject: [PATCH 14/18] fix(audio): block hidden playback fast path --- .../audio/2026-06-16-hidden-playback-gate.md | 48 +++++++++++++++++++ packages/core/src/audio/AudioManager.ts | 14 ++++++ packages/core/src/audio/AudioSource.ts | 9 +--- .../audio/AudioSourcePendingPlayback.test.ts | 17 +++++++ 4 files changed, 81 insertions(+), 7 deletions(-) create mode 100644 notes/audio/2026-06-16-hidden-playback-gate.md diff --git a/notes/audio/2026-06-16-hidden-playback-gate.md b/notes/audio/2026-06-16-hidden-playback-gate.md new file mode 100644 index 0000000000..773fb0ea23 --- /dev/null +++ b/notes/audio/2026-06-16-hidden-playback-gate.md @@ -0,0 +1,48 @@ +# Hidden Playback Gate + +## Problem + +PR #3026 hardened `AudioManager.resume()` and hidden lifecycle handling, but one hidden/play ordering still allowed a source to start while the page was already hidden. + +The race was: + +- `document.hidden` becomes `true`, or `pagehide` starts. +- `AudioManager._onHidden()` either has not run yet or has called async `context.suspend()` but the context still reports `running`. +- `AudioSource.play()` sees the raw context state through `AudioManager.isAudioContextRunning()` and calls `_startPlayback()`. +- The source can then survive until foreground resume even though the playback request happened while hidden. + +## Root Cause + +`isAudioContextRunning()` is a raw WebAudio state query. Using it directly as the `AudioSource.play()` start permission mixed two different concerns: + +- whether the underlying context state is currently `running` +- whether engine lifecycle state allows starting new playback + +The raw state can lag hidden lifecycle intent because `AudioContext.suspend()` resolves asynchronously. + +## Fix + +Keep `isAudioContextRunning()` as a raw state check and add an internal playback gate: + +```ts +AudioManager._canStartPlayback() +``` + +The gate rejects playback while `document.hidden` or `_hidden` is true, synchronizes hidden state by calling `_onHidden()` when `document.hidden` is already true, and only then checks the raw context state. + +`AudioSource.play()` now uses this gate in both start points: + +- the initial fast path before `_startPlayback()` +- the post-`AudioManager.resume()` retry path + +## Verification + +Passed: + +```bash +pnpm exec vitest run tests/src/core/audio/AudioSourcePendingPlayback.test.ts +pnpm exec cross-env HEADLESS=true vitest run tests/src/core/audio/AudioSource.test.ts tests/src/core/audio/AudioSourcePendingPlayback.test.ts +pnpm -F @galacean/engine-core run b:types +pnpm exec eslint packages/core/src/audio/AudioManager.ts packages/core/src/audio/AudioSource.ts tests/src/core/audio/AudioSourcePendingPlayback.test.ts +git diff --check HEAD +``` diff --git a/packages/core/src/audio/AudioManager.ts b/packages/core/src/audio/AudioManager.ts index d72a0d6b6e..adf4894528 100644 --- a/packages/core/src/audio/AudioManager.ts +++ b/packages/core/src/audio/AudioManager.ts @@ -89,6 +89,20 @@ 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) { diff --git a/packages/core/src/audio/AudioSource.ts b/packages/core/src/audio/AudioSource.ts index ebca34becb..a4f50d019b 100644 --- a/packages/core/src/audio/AudioSource.ts +++ b/packages/core/src/audio/AudioSource.ts @@ -156,7 +156,7 @@ export class AudioSource extends Component { return; } - if (AudioManager.isAudioContextRunning()) { + if (AudioManager._canStartPlayback()) { this._startPlayback(); } else { this._pendingPlay = true; @@ -166,12 +166,7 @@ export class AudioSource extends Component { return; } this._pendingPlay = false; - if ( - this._destroyed || - !this.enabled || - !this._clip?._getAudioSource() || - !AudioManager.isAudioContextRunning() - ) { + if (this._destroyed || !this.enabled || !this._clip?._getAudioSource() || !AudioManager._canStartPlayback()) { return; } this._startPlayback(); diff --git a/tests/src/core/audio/AudioSourcePendingPlayback.test.ts b/tests/src/core/audio/AudioSourcePendingPlayback.test.ts index 38b8750886..34a79210b8 100644 --- a/tests/src/core/audio/AudioSourcePendingPlayback.test.ts +++ b/tests/src/core/audio/AudioSourcePendingPlayback.test.ts @@ -329,6 +329,23 @@ describe("AudioSource playback lifecycle", () => { expect(context.state).to.equal("suspended"); }); + it("does not start playback while document is hidden even if context is still running", async () => { + const audioSource = createAudioSource(); + const context = (AudioManager as any)._context as MockAudioContext; + const suspendSpy = vi.spyOn(context, "suspend"); + + context.state = "running"; + const documentHidden = mockDocumentHidden(true); + + audioSource.play(); + documentHidden.restore(); + await flushAsync(); + + 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 () => { const audioSource = createAudioSource(); const context = (AudioManager as any)._context as MockAudioContext; From 70b24d0efdcfa7ae5f1fb4562177af095e2f463a Mon Sep 17 00:00:00 2001 From: luzhuang Date: Tue, 16 Jun 2026 16:58:06 +0800 Subject: [PATCH 15/18] test(audio): trim obsolete playback state coverage --- .../audio/AudioSourcePendingPlayback.test.ts | 31 ------------------- 1 file changed, 31 deletions(-) diff --git a/tests/src/core/audio/AudioSourcePendingPlayback.test.ts b/tests/src/core/audio/AudioSourcePendingPlayback.test.ts index 34a79210b8..f007527b8e 100644 --- a/tests/src/core/audio/AudioSourcePendingPlayback.test.ts +++ b/tests/src/core/audio/AudioSourcePendingPlayback.test.ts @@ -519,37 +519,6 @@ describe("AudioSource playback lifecycle", () => { expect((AudioManager as any)._needsUserGestureResume).to.be.false; }); - it("keeps playback state consistent across play/stop/pause/ended", async () => { - createAudioSource(); - const context = (AudioManager as any)._context as MockAudioContext; - context.state = "running"; - - const s1 = createAudioSource(); - const s2 = createAudioSource(); - - s1.play(); - s2.play(); - expect(s1.isPlaying).to.be.true; - expect(s2.isPlaying).to.be.true; - - s1.pause(); - expect(s1.isPlaying).to.be.false; - expect(s2.isPlaying).to.be.true; - - s1.play(); - expect(s1.isPlaying).to.be.true; - expect(s2.isPlaying).to.be.true; - - s2.stop(); - expect(s1.isPlaying).to.be.true; - expect(s2.isPlaying).to.be.false; - - // Simulate onended - (s1 as any)._onPlayEnd(); - expect(s1.isPlaying).to.be.false; - expect(s2.isPlaying).to.be.false; - }); - it("does not resume a stopped source after hide/show cycle", async () => { const audioSource = createAudioSource(); const context = (AudioManager as any)._context as MockAudioContext; From 54ef72e8dbdfe72ee9b91efe6969fe0ef1c47400 Mon Sep 17 00:00:00 2001 From: luzhuang Date: Tue, 16 Jun 2026 17:04:37 +0800 Subject: [PATCH 16/18] chore: remove audio engineering note from pr --- .../audio/2026-06-16-hidden-playback-gate.md | 48 ------------------- 1 file changed, 48 deletions(-) delete mode 100644 notes/audio/2026-06-16-hidden-playback-gate.md diff --git a/notes/audio/2026-06-16-hidden-playback-gate.md b/notes/audio/2026-06-16-hidden-playback-gate.md deleted file mode 100644 index 773fb0ea23..0000000000 --- a/notes/audio/2026-06-16-hidden-playback-gate.md +++ /dev/null @@ -1,48 +0,0 @@ -# Hidden Playback Gate - -## Problem - -PR #3026 hardened `AudioManager.resume()` and hidden lifecycle handling, but one hidden/play ordering still allowed a source to start while the page was already hidden. - -The race was: - -- `document.hidden` becomes `true`, or `pagehide` starts. -- `AudioManager._onHidden()` either has not run yet or has called async `context.suspend()` but the context still reports `running`. -- `AudioSource.play()` sees the raw context state through `AudioManager.isAudioContextRunning()` and calls `_startPlayback()`. -- The source can then survive until foreground resume even though the playback request happened while hidden. - -## Root Cause - -`isAudioContextRunning()` is a raw WebAudio state query. Using it directly as the `AudioSource.play()` start permission mixed two different concerns: - -- whether the underlying context state is currently `running` -- whether engine lifecycle state allows starting new playback - -The raw state can lag hidden lifecycle intent because `AudioContext.suspend()` resolves asynchronously. - -## Fix - -Keep `isAudioContextRunning()` as a raw state check and add an internal playback gate: - -```ts -AudioManager._canStartPlayback() -``` - -The gate rejects playback while `document.hidden` or `_hidden` is true, synchronizes hidden state by calling `_onHidden()` when `document.hidden` is already true, and only then checks the raw context state. - -`AudioSource.play()` now uses this gate in both start points: - -- the initial fast path before `_startPlayback()` -- the post-`AudioManager.resume()` retry path - -## Verification - -Passed: - -```bash -pnpm exec vitest run tests/src/core/audio/AudioSourcePendingPlayback.test.ts -pnpm exec cross-env HEADLESS=true vitest run tests/src/core/audio/AudioSource.test.ts tests/src/core/audio/AudioSourcePendingPlayback.test.ts -pnpm -F @galacean/engine-core run b:types -pnpm exec eslint packages/core/src/audio/AudioManager.ts packages/core/src/audio/AudioSource.ts tests/src/core/audio/AudioSourcePendingPlayback.test.ts -git diff --check HEAD -``` From 2ba5494d5498c21ead9e6018b1a51d2d9133c13a Mon Sep 17 00:00:00 2001 From: ChenMo Date: Tue, 23 Jun 2026 15:56:47 +0800 Subject: [PATCH 17/18] fix(audio): fix iOS lifecycle at root cause (defer context creation) (#3045) fix(audio): fix iOS lifecycle at root cause (defer context creation) --- packages/core/src/Polyfill.ts | 36 + packages/core/src/audio/AudioManager.ts | 178 ++--- packages/core/src/audio/AudioSource.ts | 87 +- packages/loader/src/AudioLoader.ts | 16 +- .../audio/AudioSourcePendingPlayback.test.ts | 746 ++++++++++++------ 5 files changed, 679 insertions(+), 384 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/core/src/audio/AudioManager.ts b/packages/core/src/audio/AudioManager.ts index adf4894528..a6b5b68015 100644 --- a/packages/core/src/audio/AudioManager.ts +++ b/packages/core/src/audio/AudioManager.ts @@ -2,26 +2,28 @@ * 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 { + // 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; if (!context) { return Promise.resolve(); } AudioManager._suspendedByCaller = true; - AudioManager._needsUserGestureResume = false; - AudioManager._clearForegroundResumeTimer(); - AudioManager._removeGestureListeners(); return context.suspend(); } @@ -32,25 +34,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 +51,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); + // iOS Safari bfcache restore fires pageshow (persisted) but NOT visibilitychange, so recover here too + window.addEventListener("pageshow", AudioManager._onPageShow); + // 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); } return context; } @@ -89,111 +82,62 @@ export class AudioManager { return AudioManager.getContext().state === "running"; } - /** - * @internal - */ - static _canStartPlayback(): boolean { + private static _onVisibilityChange(): void { if (document.hidden) { - AudioManager._onHidden(); - return false; - } - if (AudioManager._hidden) { - return false; + // 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(); } - 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()) { + // 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; + if (document.hidden || AudioManager._suspendedByCaller) { return; } - context - .resume() - .then(() => { - if (isForegroundResumeStale()) { - return; - } - AudioManager._needsUserGestureResume = false; - AudioManager._removeGestureListeners(); - }) - .catch(() => { - if (isForegroundResumeStale()) { - return; - } - AudioManager._needsUserGestureResume = true; - AudioManager._addGestureListeners(); - }); + // 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); } - private static _resumeAfterInterruption(): void { - if (!AudioManager._suspendedByCaller && AudioManager._needsUserGestureResume) { - AudioManager.resume().catch(() => {}); + private static _onPageShow(event: PageTransitionEvent): void { + // iOS Safari bfcache restore (persisted) needs recovery; a normal load has no suspended context + if (event.persisted) { + AudioManager._recoverPlaybackContext(); } } - private static _clearForegroundResumeTimer(): void { - if (AudioManager._foregroundResumeTimer !== null) { - clearTimeout(AudioManager._foregroundResumeTimer); - AudioManager._foregroundResumeTimer = null; + private static _resumeAfterInterruption(): void { + // 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; } - } - - 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); + AudioManager.resume().catch((e) => { + console.warn("Failed to resume AudioContext:", e); + }); } } diff --git a/packages/core/src/audio/AudioSource.ts b/packages/core/src/audio/AudioSource.ts index a4f50d019b..b149e5609e 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,13 +192,15 @@ export class AudioSource extends Component { * Stops playing the clip. */ stop(): void { - this._cancelPendingPlayback(); + this._pendingPlay = false; if (this._isPlaying) { this._clearSourceNode(); this._isPlaying = false; + AudioManager._playingCount--; } + // stop() always resets to the start, including from a paused state (where _isPlaying is already false) this._pausedTime = -1; this._playTime = -1; } @@ -198,12 +209,14 @@ export class AudioSource extends Component { * 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 +225,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 +256,48 @@ 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; - } - } + const buffer = this._clip._getAudioSource(); - sourceNode.buffer = audioBuffer; + sourceNode.buffer = buffer; sourceNode.playbackRate.value = this._playbackRate; sourceNode.loop = this._loop; sourceNode.onended = this._onPlayEnd; this._sourceNode = sourceNode; - sourceNode.connect(this._gainNode); + sourceNode.connect(this._ensureGainNode()); + // 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); - return true; } 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..5cffc29781 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,10 @@ class AudioLoader extends Loader { }); }); } + + private static _getDecodeContext(): OfflineAudioContext { + // 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)); + } } diff --git a/tests/src/core/audio/AudioSourcePendingPlayback.test.ts b/tests/src/core/audio/AudioSourcePendingPlayback.test.ts index f007527b8e..ac05bf2fde 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 { @@ -102,22 +94,18 @@ 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); + 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,8 +156,149 @@ describe("AudioSource playback lifecycle", () => { await flushAsync(); }); + it("defers AudioContext creation until first play", () => { + const audioSource = createAudioSource(); + + // setting clip must not have created the context + expect((AudioManager as any)._context == null).to.be.true; + + const context = new MockAudioContext(); + context.state = "running"; + (AudioManager as any)._context = context; + + audioSource.play(); + + expect((AudioManager as any)._context != null).to.be.true; + }); + + it("applies a pre-play volume lazily on first play", () => { + const audioSource = createAudioSource(); + + audioSource.volume = 0.3; + + // 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); + + 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("starts immediately when the context is already running", () => { + const audioSource = createAudioSource(); + const context = AudioManager.getContext() as unknown as MockAudioContext; + context.state = "running"; + + const before = (AudioManager as any)._playingCount; + audioSource.play(); + + expect(audioSource.isPlaying).to.be.true; + expect((AudioManager as any)._playingCount).to.equal(before + 1); + }); + + 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"); + + audioSource.play(); + expect(audioSource.isPlaying).to.be.true; + const count = (AudioManager as any)._playingCount; + + audioSource.play(); + expect((AudioManager as any)._playingCount).to.equal(count); + expect(resumeSpy).not.toHaveBeenCalled(); + + // (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 ctxSuspendSpy = vi.spyOn(context, "suspend"); + const managerSuspendSpy = vi.spyOn(AudioManager, "suspend"); + + const documentHidden = mockDocumentHidden(true); + audioSource.play(); + documentHidden.restore(); + await flushAsync(); + + 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 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 documentHidden = mockDocumentHidden(true); + audioSource.play(); + expect(audioSource.isPlaying).to.be.false; + expect((audioSource as any)._pendingPlay).to.be.false; + + documentHidden.set(false); + document.dispatchEvent(new Event("visibilitychange")); + window.dispatchEvent(Object.assign(new Event("pageshow"), { persisted: true })); + vi.advanceTimersByTime(100); + documentHidden.restore(); + + expect(audioSource.isPlaying).to.be.false; + expect((audioSource as any)._pendingPlay).to.be.false; + }); + + it("replays the pending play on the resume it triggered", async () => { + const audioSource = createAudioSource(); + const documentHidden = mockDocumentHidden(false); + const context = AudioManager.getContext() as unknown as MockAudioContext; + context.state = "suspended"; + + audioSource.play(); + expect((audioSource as any)._pendingPlay).to.be.true; + + await flushAsync(); + documentHidden.restore(); + + expect((audioSource as any)._pendingPlay).to.be.false; + expect(audioSource.isPlaying).to.be.true; + }); + + // 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"; vi.spyOn(console, "warn").mockImplementation(() => {}); MockAudioContext.shouldResumeSucceed = false; @@ -187,8 +316,12 @@ describe("AudioSource playback lifecycle", () => { expect(audioSource.isPlaying).to.be.false; }); - it("cancels one-shot pending playback before resume resolves", async () => { + it("cancels a one-shot pending play before resume resolves", async () => { const audioSource = createAudioSource(); + const documentHidden = mockDocumentHidden(false); + const context = AudioManager.getContext() as unknown as MockAudioContext; + context.state = "suspended"; + let resolveResume: () => void; MockAudioContext.resumeResultQueue = [ new Promise((resolve) => { @@ -197,365 +330,536 @@ describe("AudioSource playback lifecycle", () => { ]; audioSource.play(); - await flushAsync(); expect((audioSource as any)._pendingPlay).to.be.true; audioSource.stop(); expect((audioSource as any)._pendingPlay).to.be.false; + resolveResume!(); await flushAsync(); + documentHidden.restore(); expect(audioSource.isPlaying).to.be.false; }); - it("resume() unlocks a suspended context", async () => { - createAudioSource(); - const context = (AudioManager as any)._context as MockAudioContext; - expect(context.state).to.equal("suspended"); + it("drops playback after explicit suspend when resume is autoplay-blocked", async () => { + const audioSource = createAudioSource(); + const context = AudioManager.getContext() as unknown as MockAudioContext; + context.state = "running"; - await AudioManager.resume(); + await AudioManager.suspend(); + await flushAsync(); - expect(context.state).to.equal("running"); + MockAudioContext.shouldResumeSucceed = false; + vi.spyOn(console, "warn").mockImplementation(() => {}); + + audioSource.play(); + await flushAsync(); + + expect((audioSource as any)._pendingPlay).to.be.false; expect((AudioManager as any)._needsUserGestureResume).to.be.false; + + MockAudioContext.shouldResumeSucceed = true; + document.dispatchEvent(new Event("click")); + await flushAsync(); + + expect(audioSource.isPlaying).to.be.false; }); - it("suspends context when hidden", async () => { + it("resume() unlocks a suspended context and clears the gesture flag", async () => { createAudioSource(); - const context = (AudioManager as any)._context as MockAudioContext; - const suspendSpy = vi.spyOn(context, "suspend"); - - context.state = "running"; + const context = AudioManager.getContext() as unknown as MockAudioContext; + context.state = "suspended"; + (AudioManager as any)._needsUserGestureResume = true; - const documentHidden = mockDocumentHidden(true); - document.dispatchEvent(new Event("visibilitychange")); - documentHidden.restore(); - await flushAsync(); + await AudioManager.resume(); - expect(suspendSpy).toHaveBeenCalledTimes(1); - expect((AudioManager as any)._hidden).to.be.true; + expect(context.state).to.equal("running"); + expect((AudioManager as any)._needsUserGestureResume).to.be.false; }); - it("resumes context on visibilitychange shown via iOS zombie fix", async () => { + it("coalesces overlapping resume() calls and re-issues a later resume", async () => { createAudioSource(); - const context = (AudioManager as any)._context as MockAudioContext; - const scheduledTimers = captureScheduledTimers(); + const context = AudioManager.getContext() as unknown as MockAudioContext; + context.state = "suspended"; - context.state = "running"; - const suspendSpy = vi.spyOn(context, "suspend"); + let resolveFirst: () => void; + MockAudioContext.resumeResultQueue = [ + new Promise((resolve) => { + resolveFirst = resolve; + }) + ]; const resumeSpy = vi.spyOn(context, "resume"); - const documentHidden = mockDocumentHidden(true); - document.dispatchEvent(new Event("visibilitychange")); - expect(suspendSpy).toHaveBeenCalledTimes(1); - - documentHidden.set(false); - document.dispatchEvent(new Event("visibilitychange")); - documentHidden.restore(); - // _onShown calls context.suspend() synchronously then schedules resume after 100ms - expect(suspendSpy).toHaveBeenCalledTimes(2); - expect(scheduledTimers).to.have.lengthOf(1); + AudioManager.resume().catch(() => {}); + AudioManager.resume().catch(() => {}); + expect(resumeSpy).toHaveBeenCalledTimes(1); - scheduledTimers[0](); + resolveFirst!(); await flushAsync(); - expect(resumeSpy).toHaveBeenCalled(); + await AudioManager.resume(); + expect(resumeSpy).toHaveBeenCalledTimes(2); }); - it("does not run the delayed foreground resume after hiding again", async () => { + it("does not auto-resume a caller-controlled suspend on a later gesture", async () => { createAudioSource(); - const context = (AudioManager as any)._context as MockAudioContext; - const scheduledTimers = captureScheduledTimers(); - + 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); - - documentHidden.set(true); - document.dispatchEvent(new Event("visibilitychange")); - documentHidden.restore(); + await AudioManager.suspend(); + await flushAsync(); - scheduledTimers[0](); + 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("does not resume the context while hidden", 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 resumeSpy = vi.spyOn(context, "resume"); - + const context = AudioManager.getContext() as unknown as MockAudioContext; context.state = "running"; + audioSource.play(); expect(audioSource.isPlaying).to.be.true; + const count = (AudioManager as any)._playingCount; - window.dispatchEvent(new Event("pagehide")); - await flushAsync(); - - await AudioManager.resume(); + const documentHidden = mockDocumentHidden(true); + document.dispatchEvent(new Event("visibilitychange")); + documentHidden.restore(); await flushAsync(); - expect(resumeSpy).not.toHaveBeenCalled(); - expect(context.state).to.equal("suspended"); expect(audioSource.isPlaying).to.be.true; + expect((AudioManager as any)._playingCount).to.equal(count); }); - 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; + 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(); + expect((AudioManager as any)._playingCount > 0).to.be.true; + + // 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"); - context.state = "running"; - const documentHidden = mockDocumentHidden(true); - - await AudioManager.resume(); - documentHidden.restore(); - await flushAsync(); + const documentHidden = mockDocumentHidden(false); + document.dispatchEvent(new Event("visibilitychange")); - expect(resumeSpy).not.toHaveBeenCalled(); expect(suspendSpy).toHaveBeenCalledTimes(1); - expect((AudioManager as any)._hidden).to.be.true; - window.dispatchEvent(new Event("pagehide")); + vi.advanceTimersByTime(100); await flushAsync(); + documentHidden.restore(); - expect(context.state).to.equal("suspended"); + expect(resumeSpy).toHaveBeenCalledTimes(1); + expect((AudioManager as any)._recovering).to.be.false; + expect(context.state).to.equal("running"); }); - it("does not start playback while document is hidden even if context is still running", async () => { + it("runs a single recovery cycle for back-to-back visibilitychange and pageshow", () => { const audioSource = createAudioSource(); - const context = (AudioManager as any)._context as MockAudioContext; - const suspendSpy = vi.spyOn(context, "suspend"); - + const context = AudioManager.getContext() as unknown as MockAudioContext; context.state = "running"; - const documentHidden = mockDocumentHidden(true); 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(); - await flushAsync(); + // _recovering guards the 2nd dispatch between the synchronous events expect(suspendSpy).toHaveBeenCalledTimes(1); - expect(audioSource.isPlaying).to.be.false; - expect((audioSource as any)._pendingPlay).to.be.false; + expect(scheduledTimers).to.have.lengthOf(1); }); - it("drops playback requested while hidden instead of replaying after show", async () => { + it("skips recovery when nothing is playing", () => { + vi.useFakeTimers(); 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 flushAsync(); audioSource.play(); - await flushAsync(); + audioSource.stop(); + expect((AudioManager as any)._playingCount).to.equal(0); - expect((audioSource as any)._pendingPlay).to.be.false; - expect(audioSource.isPlaying).to.be.false; + context.state = "suspended"; + const suspendSpy = vi.spyOn(context, "suspend"); + const resumeSpy = vi.spyOn(context, "resume"); - window.dispatchEvent(new Event("pageshow")); - expect(scheduledTimers).to.have.lengthOf(1); - scheduledTimers[0](); - 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(audioSource.isPlaying).to.be.false; + expect(suspendSpy).not.toHaveBeenCalled(); + expect(resumeSpy).not.toHaveBeenCalled(); }); - it("does not auto-resume a caller-controlled suspend on the next gesture", async () => { - createAudioSource(); - const context = (AudioManager as any)._context as MockAudioContext; - const resumeSpy = vi.spyOn(context, "resume"); - + 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"; + + audioSource.play(); await AudioManager.suspend(); - await flushAsync(); - expect((AudioManager as any)._needsUserGestureResume).to.be.false; + const resumeSpy = vi.spyOn(context, "resume"); - document.dispatchEvent(new Event("click")); - 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(resumeSpy).not.toHaveBeenCalled(); expect(context.state).to.equal("suspended"); }); - it("drops playback after explicit suspend when resume is autoplay-blocked", async () => { + it("falls back to a gesture when the foreground resume fails, then a click resumes", async () => { + vi.useFakeTimers(); 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(); - await flushAsync(); - MockAudioContext.shouldResumeSucceed = false; + audioSource.play(); + context.state = "suspended"; + + // the timer's auto-resume rejects, leaving the gesture fallback armed + MockAudioContext.resumeResultQueue = [new Error("autoplay blocked")]; vi.spyOn(console, "warn").mockImplementation(() => {}); - audioSource.play(); + const documentHidden = mockDocumentHidden(false); + document.dispatchEvent(new Event("visibilitychange")); + vi.advanceTimersByTime(100); await flushAsync(); - expect((audioSource as any)._pendingPlay).to.be.false; - expect((AudioManager as any)._needsUserGestureResume).to.be.false; + expect((AudioManager as any)._needsUserGestureResume).to.be.true; + expect(context.state).to.equal("suspended"); + vi.useRealTimers(); + MockAudioContext.resumeResultQueue = null; MockAudioContext.shouldResumeSucceed = true; document.dispatchEvent(new Event("click")); await flushAsync(); + documentHidden.restore(); - expect(audioSource.isPlaying).to.be.false; + expect((AudioManager as any)._needsUserGestureResume).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("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"; - const suspendSpy = vi.spyOn(context, "suspend"); + audioSource.play(); + context.state = "suspended"; + + MockAudioContext.shouldSuspendSucceed = false; + const resumeSpy = vi.spyOn(context, "resume"); const documentHidden = mockDocumentHidden(false); document.dispatchEvent(new Event("visibilitychange")); - documentHidden.restore(); + vi.advanceTimersByTime(100); await flushAsync(); + documentHidden.restore(); - expect(suspendSpy).not.toHaveBeenCalled(); + expect(resumeSpy).toHaveBeenCalledTimes(1); + expect(context.state).to.equal("running"); + expect((AudioManager as any)._recovering).to.be.false; }); - it("handles pagehide/pageshow lifecycle", async () => { - createAudioSource(); - const context = (AudioManager as any)._context as MockAudioContext; - const scheduledTimers = captureScheduledTimers(); + // 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"; - window.dispatchEvent(new Event("pagehide")); - expect((AudioManager as any)._hidden).to.be.true; - expect(context.state).to.equal("suspended"); + audioSource.play(); + context.state = "suspended"; - window.dispatchEvent(new Event("pageshow")); - expect((AudioManager as any)._hidden).to.be.false; - expect(scheduledTimers).to.have.lengthOf(1); + 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; - // iOS zombie fix uses window.setTimeout(100ms) - scheduledTimers[0](); + 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"); }); - it("sets gesture resume flag when foreground resume fails", async () => { - createAudioSource(); - const context = (AudioManager as any)._context as MockAudioContext; - const scheduledTimers = captureScheduledTimers(); + // 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"; - 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); + // hold resume unresolved to simulate the slow iOS interrupted->running transition + let releaseResume: () => void; + MockAudioContext.resumeResultQueue = [ + new Promise((resolve) => { + releaseResume = resolve; + }) + ]; - scheduledTimers[0](); - await flushAsync(); + const documentHidden = mockDocumentHidden(false); + document.dispatchEvent(new Event("visibilitychange")); + const resumeSpy = vi.spyOn(context, "resume"); - expect((AudioManager as any)._needsUserGestureResume).to.be.true; + // 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; - // Gesture succeeds - MockAudioContext.shouldResumeSucceed = true; - document.dispatchEvent(new Event("click")); + // 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); - expect((AudioManager as any)._needsUserGestureResume).to.be.false; - expect(context.state).to.equal("running"); + releaseResume!(); + await flushAsync(); + documentHidden.restore(); }); - 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("treats a non-persisted pageshow as a no-op", () => { + const audioSource = createAudioSource(); + const context = AudioManager.getContext() as unknown as MockAudioContext; context.state = "running"; - window.dispatchEvent(new Event("pagehide")); - MockAudioContext.shouldSuspendSucceed = false; - window.dispatchEvent(new Event("pageshow")); - expect(scheduledTimers).to.have.lengthOf(1); + audioSource.play(); + context.state = "suspended"; - scheduledTimers[0](); - await flushAsync(); + const scheduledTimers = captureScheduledTimers(); + const suspendSpy = vi.spyOn(context, "suspend"); - expect(resumeSpy).toHaveBeenCalledTimes(1); - expect(context.state).to.equal("running"); + window.dispatchEvent(Object.assign(new Event("pageshow"), { persisted: false })); + + expect(suspendSpy).not.toHaveBeenCalled(); + expect(scheduledTimers).to.have.lengthOf(0); }); - 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(() => {}); + it("does nothing on a spurious visibilitychange-shown with a running context", async () => { + const audioSource = createAudioSource(); + const context = AudioManager.getContext() as unknown as MockAudioContext; + context.state = "running"; + + audioSource.play(); - MockAudioContext.resumeResultQueue = [firstResume]; + const suspendSpy = vi.spyOn(context, "suspend"); const resumeSpy = vi.spyOn(context, "resume"); - AudioManager.resume().catch(() => {}); + const documentHidden = mockDocumentHidden(false); + document.dispatchEvent(new Event("visibilitychange")); + documentHidden.restore(); await flushAsync(); - expect(resumeSpy).toHaveBeenCalledTimes(1); + expect(suspendSpy).not.toHaveBeenCalled(); + expect(resumeSpy).not.toHaveBeenCalled(); + }); - MockAudioContext.resumeResultQueue = [Promise.resolve()]; - context.onstatechange?.(); - await flushAsync(); + it("keeps stop()/pause() bookkeeping consistent", () => { + const audioSource = createAudioSource(); + const context = AudioManager.getContext() as unknown as MockAudioContext; + context.state = "running"; + context.currentTime = 5; - document.dispatchEvent(new Event("click")); - await flushAsync(); + audioSource.play(); + const playingCount = (AudioManager as any)._playingCount; - expect(resumeSpy).toHaveBeenCalledTimes(2); - expect(context.state).to.equal("running"); - expect((AudioManager as any)._needsUserGestureResume).to.be.false; + 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; + + 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; }); - it("does not resume a stopped source after hide/show cycle", async () => { + // 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 as any)._context as MockAudioContext; - const scheduledTimers = captureScheduledTimers(); + const context = AudioManager.getContext() as unknown as MockAudioContext; context.state = "running"; + context.currentTime = 5; audioSource.play(); - expect(audioSource.isPlaying).to.be.true; + 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.isPlaying).to.be.false; + expect((audioSource as any)._pausedTime).to.equal(-1); + expect((audioSource as any)._playTime).to.equal(-1); - // hide → show cycle - window.dispatchEvent(new Event("pagehide")); - window.dispatchEvent(new Event("pageshow")); - expect(scheduledTimers).to.have.lengthOf(1); - scheduledTimers[0](); - await flushAsync(); + context.currentTime = 12; + audioSource.play(); + expect(audioSource.time).to.equal(0); + }); - // Source stays stopped — context resume does not restart stopped sources - expect(audioSource.isPlaying).to.be.false; + // 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); }); - it("marks external context interruption as gesture-retryable", async () => { - createAudioSource(); - const context = (AudioManager as any)._context as MockAudioContext; - const resumeSpy = vi.spyOn(context, "resume"); + // 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) + 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.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"; - context.onstatechange?.(); + const resumeSpy = vi.spyOn(context, "resume"); + const documentHidden = mockDocumentHidden(false); + document.dispatchEvent(new Event("visibilitychange")); + vi.advanceTimersByTime(100); await flushAsync(); + documentHidden.restore(); - MockAudioContext.shouldResumeSucceed = true; - document.dispatchEvent(new Event("pointerup")); + 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 + 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 33878747a84fd7bf9b8639274199628e3f5655ba Mon Sep 17 00:00:00 2001 From: "chenmo.gl" Date: Tue, 23 Jun 2026 20:05:49 +0800 Subject: [PATCH 18/18] refactor(audio): extract shared _promisifyDecodeAudioData polyfill helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _registerOfflineAudioContext 的 callback→Promise decodeAudioData 包装与 _registerAudioContext 一字不差,只 prototype 名不同。抽成共享 helper _promisifyDecodeAudioData(proto: BaseAudioContext),两处在 webkit* 别名赋值后调用, 避免将来 wrapper 改动时两份漂移。纯重构,行为不变。 --- packages/core/src/Polyfill.ts | 84 +++++++++++++---------------------- 1 file changed, 31 insertions(+), 53 deletions(-) diff --git a/packages/core/src/Polyfill.ts b/packages/core/src/Polyfill.ts index 41a03a8c9a..0806789cbb 100644 --- a/packages/core/src/Polyfill.ts +++ b/packages/core/src/Polyfill.ts @@ -43,33 +43,7 @@ export class Polyfill { if (!window.AudioContext && (window as any).webkitAudioContext) { Logger.info("Polyfill window.AudioContext"); window.AudioContext = (window as any).webkitAudioContext; - - const originalDecodeAudioData = AudioContext.prototype.decodeAudioData as ( - audioData: ArrayBuffer, - successCallback?: DecodeSuccessCallback | null, - errorCallback?: DecodeErrorCallback | null - ) => void; - - AudioContext.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); - } - ); - }); - }; + Polyfill._promisifyDecodeAudioData(AudioContext.prototype); } } @@ -78,34 +52,38 @@ export class Polyfill { if (!window.OfflineAudioContext && (window as any).webkitOfflineAudioContext) { Logger.info("Polyfill window.OfflineAudioContext"); window.OfflineAudioContext = (window as any).webkitOfflineAudioContext; + Polyfill._promisifyDecodeAudioData(OfflineAudioContext.prototype); + } + } - const originalDecodeAudioData = OfflineAudioContext.prototype.decodeAudioData as ( - audioData: ArrayBuffer, - successCallback?: DecodeSuccessCallback | null, - errorCallback?: DecodeErrorCallback | null - ) => void; + // Wrap the old callback-form decodeAudioData (on prefixed iOS contexts) into the modern Promise form + private static _promisifyDecodeAudioData(proto: BaseAudioContext): void { + const originalDecodeAudioData = proto.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); - } - ); - }); - }; - } + proto.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 {