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(); + }); });