diff --git a/packages/core/src/Polyfill.ts b/packages/core/src/Polyfill.ts index cae0b2f5c6..0806789cbb 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(); } @@ -42,36 +43,49 @@ export class Polyfill { if (!window.AudioContext && (window as any).webkitAudioContext) { Logger.info("Polyfill window.AudioContext"); window.AudioContext = (window as any).webkitAudioContext; + Polyfill._promisifyDecodeAudioData(AudioContext.prototype); + } + } - 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); - } - ); - }); - }; + 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; + Polyfill._promisifyDecodeAudioData(OfflineAudioContext.prototype); } } + // 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; + + 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 { // 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 3aff4b19ee..a6b5b68015 100644 --- a/packages/core/src/audio/AudioManager.ts +++ b/packages/core/src/audio/AudioManager.ts @@ -9,13 +9,22 @@ export class AudioManager { private static _gainNode: GainNode; private static _resumePromise: Promise = null; private static _needsUserGestureResume = false; + 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 { - return AudioManager.getContext().suspend(); + // 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; + return context.suspend(); } /** @@ -24,6 +33,7 @@ export class AudioManager { * @returns A promise that resolves when the audio context is resumed */ static resume(): Promise { + AudioManager._suspendedByCaller = false; return (AudioManager._resumePromise ??= AudioManager.getContext() .resume() .then(() => { @@ -42,7 +52,9 @@ export class AudioManager { if (!context) { AudioManager._context = context = new window.AudioContext(); document.addEventListener("visibilitychange", AudioManager._onVisibilityChange); - // iOS Safari requires user gesture to resume AudioContext + // 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); @@ -71,21 +83,61 @@ export class AudioManager { } 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; + if (document.hidden) { + // Desktop/Android don't auto-suspend a running WebAudio context when backgrounded (only iOS does), + // so suspend here to stop audio in the background; only if a context already exists (don't create one) + AudioManager._context?.suspend().catch(() => {}); + } else { + AudioManager._recoverPlaybackContext(); + } + } + + private static _recoverPlaybackContext(): void { + // Returning to foreground with a non-running context (and not a deliberate pause): iOS leaves it + // "interrupted", which cannot be resumed directly; suspend() first transitions it to "suspended", + // 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() + ) { + return; + } + AudioManager._recovering = true; + AudioManager._needsUserGestureResume = true; // fallback if the auto-resume below is rejected + const context = AudioManager.getContext(); + context.suspend().catch(() => {}); + // 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; + } + // 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 _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 _resumeAfterInterruption(): void { - if (AudioManager._needsUserGestureResume) { - AudioManager.resume().catch((e) => { - console.warn("Failed to resume AudioContext:", e); - }); + // 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; } + 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 29a3ed8bc1..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,6 +156,10 @@ 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.isAudioContextRunning()) { this._startPlayback(); @@ -169,8 +174,8 @@ export class AudioSource extends Component { return; } this._pendingPlay = false; - // Check if still valid to play after async resume - if (this._destroyed || !this.enabled || !this._clip) { + // 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(); @@ -191,12 +196,13 @@ export class AudioSource extends Component { if (this._isPlaying) { this._clearSourceNode(); - this._isPlaying = false; - this._pausedTime = -1; - this._playTime = -1; AudioManager._playingCount--; } + + // stop() always resets to the start, including from a paused state (where _isPlaying is already false) + this._pausedTime = -1; + this._playTime = -1; } /** @@ -219,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 } /** @@ -250,6 +256,16 @@ 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; this._initSourceNode(startTime); @@ -263,15 +279,19 @@ export class AudioSource extends Component { private _initSourceNode(startTime: number): void { const context = AudioManager.getContext(); const sourceNode = context.createBufferSource(); + const buffer = this._clip._getAudioSource(); - sourceNode.buffer = this._clip._getAudioSource(); + sourceNode.buffer = buffer; sourceNode.playbackRate.value = this._playbackRate; sourceNode.loop = this._loop; sourceNode.onended = this._onPlayEnd; this._sourceNode = sourceNode; - sourceNode.connect(this._gainNode); - sourceNode.start(0, startTime); + 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); } private _clearSourceNode(): void { 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 new file mode 100644 index 0000000000..ac05bf2fde --- /dev/null +++ b/tests/src/core/audio/AudioSourcePendingPlayback.test.ts @@ -0,0 +1,865 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AudioManager, AudioSource } from "@galacean/engine-core/src/audio"; + +const originalAudioContext = window.AudioContext; + +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 shouldSuspendSucceed = true; + static resumeResultQueue: Array | Error> | null = null; + + currentTime = 0; + destination = {}; + 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.then(() => { + this.state = "running"; + }); + } + if (queuedResult instanceof Error) { + return Promise.reject(queuedResult); + } + if (!MockAudioContext.shouldResumeSucceed) { + return Promise.reject(new Error("autoplay blocked")); + } + this.state = "running"; + return Promise.resolve(); + } + + suspend(): Promise { + if (!MockAudioContext.shouldSuspendSucceed) { + return Promise.reject(new Error("suspend blocked")); + } + this.state = "suspended"; + return Promise.resolve(); + } +} + +async function flushAsync(): Promise { + for (let i = 0; i < 4; i++) { + await Promise.resolve(); + } +} + +function createAudioSource(): AudioSource { + const audioSource = new AudioSource({ + _isActiveInHierarchy: true, + _isActiveInScene: true, + _removeComponent() {}, + engine: {} + } as any); + + audioSource.clip = { + _addReferCount() {}, + _getAudioSource() { + return { duration: 10 }; + } + } as any; + + return audioSource; +} + +function resetAudioManagerState(): void { + document.removeEventListener("visibilitychange", (AudioManager as any)._onVisibilityChange); + window.removeEventListener("pageshow", (AudioManager as any)._onPageShow); + document.removeEventListener("touchstart", (AudioManager as any)._resumeAfterInterruption); + document.removeEventListener("touchend", (AudioManager as any)._resumeAfterInterruption); + document.removeEventListener("click", (AudioManager as any)._resumeAfterInterruption); + + (AudioManager as any)._context = null; + (AudioManager as any)._gainNode = null; + (AudioManager as any)._resumePromise = null; + (AudioManager as any)._needsUserGestureResume = false; + (AudioManager as any)._suspendedByCaller = false; + (AudioManager as any)._recovering = false; + (AudioManager as any)._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; +} + +function mockDocumentHidden(initialHidden: boolean): { set(hidden: boolean): void; restore(): void } { + const ownDescriptor = Object.getOwnPropertyDescriptor(document, "hidden"); + let hidden = initialHidden; + Object.defineProperty(document, "hidden", { + configurable: true, + get: () => hidden + }); + return { + set(value: boolean) { + hidden = value; + }, + restore() { + if (ownDescriptor) { + Object.defineProperty(document, "hidden", ownDescriptor); + } else { + delete (document as any).hidden; + } + } + }; +} + +describe("AudioSource playback lifecycle", () => { + beforeEach(() => { + resetAudioManagerState(); + (window as any).AudioContext = MockAudioContext; + MockAudioContext.shouldResumeSucceed = true; + MockAudioContext.shouldSuspendSucceed = true; + MockAudioContext.resumeResultQueue = null; + }); + + afterEach(async () => { + await flushAsync(); + resetAudioManagerState(); + (window as any).AudioContext = originalAudioContext; + vi.useRealTimers(); + vi.restoreAllMocks(); + 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; + + audioSource.play(); + await flushAsync(); + + 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.false; + }); + + 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) => { + resolveResume = resolve; + }) + ]; + + audioSource.play(); + 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("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.suspend(); + await flushAsync(); + + 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("resume() unlocks a suspended context and clears the gesture flag", async () => { + createAudioSource(); + const context = AudioManager.getContext() as unknown as MockAudioContext; + context.state = "suspended"; + (AudioManager as any)._needsUserGestureResume = true; + + await AudioManager.resume(); + + expect(context.state).to.equal("running"); + expect((AudioManager as any)._needsUserGestureResume).to.be.false; + }); + + it("coalesces overlapping resume() calls and re-issues a later resume", async () => { + createAudioSource(); + const context = AudioManager.getContext() as unknown as MockAudioContext; + context.state = "suspended"; + + let resolveFirst: () => void; + MockAudioContext.resumeResultQueue = [ + new Promise((resolve) => { + resolveFirst = resolve; + }) + ]; + const resumeSpy = vi.spyOn(context, "resume"); + + AudioManager.resume().catch(() => {}); + AudioManager.resume().catch(() => {}); + expect(resumeSpy).toHaveBeenCalledTimes(1); + + resolveFirst!(); + await flushAsync(); + + await AudioManager.resume(); + expect(resumeSpy).toHaveBeenCalledTimes(2); + }); + + it("does not auto-resume a caller-controlled suspend on a later gesture", async () => { + createAudioSource(); + const context = AudioManager.getContext() as unknown as MockAudioContext; + context.state = "running"; + const resumeSpy = vi.spyOn(context, "resume"); + + await AudioManager.suspend(); + await flushAsync(); + + 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("keeps a playing source playing across a hide without tearing down the node", async () => { + const audioSource = createAudioSource(); + 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; + + const documentHidden = mockDocumentHidden(true); + document.dispatchEvent(new Event("visibilitychange")); + documentHidden.restore(); + await flushAsync(); + + expect(audioSource.isPlaying).to.be.true; + expect((AudioManager as any)._playingCount).to.equal(count); + }); + + it("performs the foreground zombie reset: suspend, 100ms, resume", async () => { + vi.useFakeTimers(); + const audioSource = createAudioSource(); + const context = AudioManager.getContext() as unknown as MockAudioContext; + context.state = "running"; + + audioSource.play(); + 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"); + + const documentHidden = mockDocumentHidden(false); + document.dispatchEvent(new Event("visibilitychange")); + + expect(suspendSpy).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(100); + await flushAsync(); + documentHidden.restore(); + + expect(resumeSpy).toHaveBeenCalledTimes(1); + expect((AudioManager as any)._recovering).to.be.false; + expect(context.state).to.equal("running"); + }); + + it("runs a single recovery cycle for back-to-back visibilitychange and pageshow", () => { + const audioSource = createAudioSource(); + const context = AudioManager.getContext() as unknown as MockAudioContext; + context.state = "running"; + + audioSource.play(); + context.state = "suspended"; + + const scheduledTimers = captureScheduledTimers(); + const suspendSpy = vi.spyOn(context, "suspend"); + + const documentHidden = mockDocumentHidden(false); + document.dispatchEvent(new Event("visibilitychange")); + window.dispatchEvent(Object.assign(new Event("pageshow"), { persisted: true })); + documentHidden.restore(); + + // _recovering guards the 2nd dispatch between the synchronous events + expect(suspendSpy).toHaveBeenCalledTimes(1); + expect(scheduledTimers).to.have.lengthOf(1); + }); + + it("skips recovery when nothing is playing", () => { + vi.useFakeTimers(); + const audioSource = createAudioSource(); + const context = AudioManager.getContext() as unknown as MockAudioContext; + context.state = "running"; + + audioSource.play(); + audioSource.stop(); + expect((AudioManager as any)._playingCount).to.equal(0); + + context.state = "suspended"; + const suspendSpy = vi.spyOn(context, "suspend"); + const resumeSpy = vi.spyOn(context, "resume"); + + const documentHidden = mockDocumentHidden(false); + document.dispatchEvent(new Event("visibilitychange")); + window.dispatchEvent(Object.assign(new Event("pageshow"), { persisted: true })); + vi.advanceTimersByTime(100); + documentHidden.restore(); + + expect(suspendSpy).not.toHaveBeenCalled(); + expect(resumeSpy).not.toHaveBeenCalled(); + }); + + 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(); + + const resumeSpy = vi.spyOn(context, "resume"); + + const documentHidden = mockDocumentHidden(false); + document.dispatchEvent(new Event("visibilitychange")); + window.dispatchEvent(Object.assign(new Event("pageshow"), { persisted: true })); + vi.advanceTimersByTime(100); + documentHidden.restore(); + + expect(resumeSpy).not.toHaveBeenCalled(); + expect(context.state).to.equal("suspended"); + }); + + it("falls back to a gesture when the foreground resume fails, then a click resumes", async () => { + vi.useFakeTimers(); + const audioSource = createAudioSource(); + const context = AudioManager.getContext() as unknown as MockAudioContext; + context.state = "running"; + + 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(() => {}); + + const documentHidden = mockDocumentHidden(false); + document.dispatchEvent(new Event("visibilitychange")); + vi.advanceTimersByTime(100); + await flushAsync(); + + expect((AudioManager as any)._needsUserGestureResume).to.be.true; + expect(context.state).to.equal("suspended"); + + vi.useRealTimers(); + MockAudioContext.resumeResultQueue = null; + MockAudioContext.shouldResumeSucceed = true; + document.dispatchEvent(new Event("click")); + await flushAsync(); + documentHidden.restore(); + + expect((AudioManager as any)._needsUserGestureResume).to.be.false; + expect(context.state).to.equal("running"); + }); + + it("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"; + + audioSource.play(); + context.state = "suspended"; + + MockAudioContext.shouldSuspendSucceed = false; + const resumeSpy = vi.spyOn(context, "resume"); + + const documentHidden = mockDocumentHidden(false); + document.dispatchEvent(new Event("visibilitychange")); + vi.advanceTimersByTime(100); + await flushAsync(); + documentHidden.restore(); + + expect(resumeSpy).toHaveBeenCalledTimes(1); + expect(context.state).to.equal("running"); + expect((AudioManager as any)._recovering).to.be.false; + }); + + // a gesture landing inside the 100ms recovery window must NOT resume: the timer still owns it, and + // a gesture resume here would both double-call context.resume() and fire before the suspend settled + it("ignores a gesture while recovery is in flight, leaving the single timer resume", async () => { + vi.useFakeTimers(); + const audioSource = createAudioSource(); + const context = AudioManager.getContext() as unknown as MockAudioContext; + context.state = "running"; + + audioSource.play(); + context.state = "suspended"; + + const documentHidden = mockDocumentHidden(false); + document.dispatchEvent(new Event("visibilitychange")); + // recovery in flight: _recovering true, gesture-fallback armed, timer not yet fired + expect((AudioManager as any)._recovering).to.be.true; + + const resumeSpy = vi.spyOn(context, "resume"); + document.dispatchEvent(new Event("click")); // gesture inside the 100ms window + await flushAsync(); + expect(resumeSpy).not.toHaveBeenCalled(); // gesture did NOT resume (recovery owns it) + + vi.advanceTimersByTime(100); + await flushAsync(); + documentHidden.restore(); + + // exactly one resume, from the timer; gesture did not double-call it + expect(resumeSpy).toHaveBeenCalledTimes(1); + expect(context.state).to.equal("running"); + }); + + // a storm of clicks AFTER the 100ms guard window but BEFORE the resume settles must coalesce via + // _resumePromise into the timer's resume (the timer goes through AudioManager.resume() now) + it("coalesces a click-storm during the slow iOS resume settle into a single context.resume()", async () => { + vi.useFakeTimers(); + const audioSource = createAudioSource(); + const context = AudioManager.getContext() as unknown as MockAudioContext; + context.state = "running"; + + audioSource.play(); + context.state = "suspended"; + + // hold resume unresolved to simulate the slow iOS interrupted->running transition + let releaseResume: () => void; + MockAudioContext.resumeResultQueue = [ + new Promise((resolve) => { + releaseResume = resolve; + }) + ]; + + const documentHidden = mockDocumentHidden(false); + document.dispatchEvent(new Event("visibilitychange")); + const resumeSpy = vi.spyOn(context, "resume"); + + // 100ms timer fires -> timer calls AudioManager.resume() which sets _resumePromise + vi.advanceTimersByTime(100); + await flushAsync(); + expect(resumeSpy).toHaveBeenCalledTimes(1); + expect((AudioManager as any)._recovering).to.be.false; + expect((AudioManager as any)._resumePromise).to.not.be.null; + + // storm of clicks while the resume is still pending -> _resumePromise coalesces them + for (let i = 0; i < 10; i++) { + document.dispatchEvent(new Event("click")); + } + await flushAsync(); + expect(resumeSpy).toHaveBeenCalledTimes(1); + + releaseResume!(); + await flushAsync(); + documentHidden.restore(); + }); + + it("treats a non-persisted pageshow as a no-op", () => { + const audioSource = createAudioSource(); + const context = AudioManager.getContext() as unknown as MockAudioContext; + context.state = "running"; + + audioSource.play(); + context.state = "suspended"; + + const scheduledTimers = captureScheduledTimers(); + const suspendSpy = vi.spyOn(context, "suspend"); + + window.dispatchEvent(Object.assign(new Event("pageshow"), { persisted: false })); + + expect(suspendSpy).not.toHaveBeenCalled(); + expect(scheduledTimers).to.have.lengthOf(0); + }); + + 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(); + + const suspendSpy = vi.spyOn(context, "suspend"); + const resumeSpy = vi.spyOn(context, "resume"); + + const documentHidden = mockDocumentHidden(false); + document.dispatchEvent(new Event("visibilitychange")); + documentHidden.restore(); + await flushAsync(); + + expect(suspendSpy).not.toHaveBeenCalled(); + expect(resumeSpy).not.toHaveBeenCalled(); + }); + + it("keeps stop()/pause() bookkeeping consistent", () => { + const audioSource = createAudioSource(); + const context = AudioManager.getContext() as unknown as MockAudioContext; + context.state = "running"; + context.currentTime = 5; + + audioSource.play(); + const playingCount = (AudioManager as any)._playingCount; + + 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; + }); + + // stop() from a PAUSED state must reset the offset so the next play() starts from 0, not the pause point + it("stop() resets the paused offset (play -> pause -> stop -> play starts from 0)", () => { + const audioSource = createAudioSource(); + const context = AudioManager.getContext() as unknown as MockAudioContext; + context.state = "running"; + context.currentTime = 5; + + audioSource.play(); + context.currentTime = 8; + audioSource.pause(); + expect((audioSource as any)._pausedTime > 0).to.be.true; + + // stop() while paused (_isPlaying already false) must still clear the offset + audioSource.stop(); + expect((audioSource as any)._pausedTime).to.equal(-1); + expect((audioSource as any)._playTime).to.equal(-1); + + context.currentTime = 12; + audioSource.play(); + expect(audioSource.time).to.equal(0); + }); + + // a looping clip resumed past one full loop must start from the loop phase, not a clamped offset + it("wraps the resume offset into the loop for a looping clip", () => { + const audioSource = createAudioSource(); + audioSource.loop = true; + const context = AudioManager.getContext() as unknown as MockAudioContext; + context.state = "running"; + + // simulate resuming at 35s elapsed on a 10s clip (duration from the clip mock) + (audioSource as any)._pausedTime = 35; + (audioSource as any)._playTime = 0; + audioSource.play(); + + const sourceNode = (audioSource as any)._sourceNode; + // start(0, offset): 35 % 10 = 5, not the clamped 35 + expect(sourceNode.start).toHaveBeenCalledWith(0, 5); + }); + + // suspend() must not create a context just to suspend it (would be the cold-ctx iOS zombie we avoid) + // suspend() with no context is a no-op: it must NOT create a context AND must NOT flag a caller-suspend + // (a ghost flag would later block foreground recovery once playback starts) + 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"; + const resumeSpy = vi.spyOn(context, "resume"); + const documentHidden = mockDocumentHidden(false); + document.dispatchEvent(new Event("visibilitychange")); + vi.advanceTimersByTime(100); + await flushAsync(); + documentHidden.restore(); + + expect(resumeSpy).toHaveBeenCalledTimes(1); + expect(context.state).to.equal("running"); + }); + + // hide-suspend: desktop/Android don't auto-suspend WebAudio when backgrounded, so we suspend on hide + 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(); + }); +});