diff --git a/packages/core/src/audio/AudioManager.ts b/packages/core/src/audio/AudioManager.ts index ad93f5ed5..2885f4222 100644 --- a/packages/core/src/audio/AudioManager.ts +++ b/packages/core/src/audio/AudioManager.ts @@ -7,15 +7,15 @@ export class AudioManager { private static _context: AudioContext; private static _gainNode: GainNode; - private static _resumePromise: Promise = null; private static _needsUserGestureResume = false; + private static _pendingSources = new Set<{ _resumePendingPlayback(): void }>(); /** * Suspend the audio context. * @returns A promise that resolves when the audio context is suspended */ static suspend(): Promise { - return AudioManager._context.suspend(); + return AudioManager._context?.suspend() ?? Promise.resolve(); } /** @@ -24,14 +24,26 @@ export class AudioManager { * @returns A promise that resolves when the audio context is resumed */ static resume(): Promise { - return (AudioManager._resumePromise ??= AudioManager._context + const context = AudioManager._context; + if (!context || context.state === "running") { + return Promise.resolve(); + } + return context .resume() .then(() => { AudioManager._needsUserGestureResume = false; - }) - .finally(() => { - AudioManager._resumePromise = null; - })); + AudioManager._resumePendingSources(); + }); + } + + /** @internal */ + static _registerPendingSource(source: { _resumePendingPlayback(): void }): void { + AudioManager._pendingSources.add(source); + } + + /** @internal */ + static _unregisterPendingSource(source: { _resumePendingPlayback(): void }): void { + AudioManager._pendingSources.delete(source); } /** @@ -41,6 +53,7 @@ export class AudioManager { let context = AudioManager._context; if (!context) { AudioManager._context = context = new window.AudioContext(); + context.onstatechange = AudioManager._onContextStateChange; document.addEventListener("visibilitychange", AudioManager._onVisibilityChange); // iOS Safari requires user gesture to resume AudioContext document.addEventListener("touchstart", AudioManager._resumeAfterInterruption, { passive: true }); @@ -70,22 +83,65 @@ export class AudioManager { return AudioManager.getContext().state === "running"; } - private static _onVisibilityChange(): void { - if (!document.hidden && AudioManager._playingCount > 0 && !AudioManager.isAudioContextRunning()) { - // iOS WKWebView WebKit bug(Triggered in LingGuang App): AudioContext may be in a "zombie" state where - // state reports "suspended" but resume() alone won't restart audio rendering. - // Calling suspend() first forces a clean internal state reset before user gesture triggers resume. - // Related: https://bugs.webkit.org/show_bug.cgi?id=263627 - AudioManager.suspend(); - AudioManager._needsUserGestureResume = true; + private static _onContextStateChange(): void { + if (AudioManager._context?.state === "running") { + AudioManager._needsUserGestureResume = false; + AudioManager._resumePendingSources(); } } + private static _resumePendingSources(): void { + if (!AudioManager._pendingSources.size || !AudioManager.isAudioContextRunning()) { + return; + } + + const pendingSources = Array.from(AudioManager._pendingSources); + AudioManager._pendingSources.clear(); + + for (let i = 0, n = pendingSources.length; i < n; i++) { + pendingSources[i]._resumePendingPlayback(); + } + } + + private static _onVisibilityChange(): void { + const context = AudioManager._context; + if ( + document.hidden || + !context || + (AudioManager._playingCount === 0 && AudioManager._pendingSources.size === 0) || + context.state === "running" + ) { + return; + } + + AudioManager.resume() + .then(() => { + if (AudioManager._context?.state !== "running") { + return AudioManager._prepareGestureResume(); + } + }) + .catch(() => { + return AudioManager._prepareGestureResume(); + }); + } + private static _resumeAfterInterruption(): void { - if (AudioManager._needsUserGestureResume) { + if (AudioManager._needsUserGestureResume || AudioManager._pendingSources.size > 0) { AudioManager.resume().catch((e) => { console.warn("Failed to resume AudioContext:", e); }); } } + + private static _prepareGestureResume(): Promise { + // 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 + return AudioManager.suspend() + .catch(() => {}) + .then(() => { + AudioManager._needsUserGestureResume = true; + }); + } } diff --git a/packages/core/src/audio/AudioSource.ts b/packages/core/src/audio/AudioSource.ts index 29a3ed8bc..3372f53e7 100644 --- a/packages/core/src/audio/AudioSource.ts +++ b/packages/core/src/audio/AudioSource.ts @@ -159,27 +159,11 @@ export class AudioSource extends Component { if (AudioManager.isAudioContextRunning()) { this._startPlayback(); } else { - // iOS Safari requires resume() to be called within the same user gesture callback that triggers playback. - // Document-level events won't work - must call resume() directly here in play(). this._pendingPlay = true; - AudioManager.resume().then( - () => { - // Check if cancelled by stop()/pause() - if (!this._pendingPlay) { - return; - } - this._pendingPlay = false; - // Check if still valid to play after async resume - if (this._destroyed || !this.enabled || !this._clip) { - return; - } - this._startPlayback(); - }, - (e) => { - this._pendingPlay = false; - console.warn("Failed to resume AudioContext:", e); - } - ); + AudioManager._registerPendingSource(this); + AudioManager.resume().catch((e) => { + console.warn("Failed to resume AudioContext:", e); + }); } } @@ -187,7 +171,7 @@ export class AudioSource extends Component { * Stops playing the clip. */ stop(): void { - this._pendingPlay = false; + this._cancelPendingPlayback(); if (this._isPlaying) { this._clearSourceNode(); @@ -203,7 +187,7 @@ export class AudioSource extends Component { * Pauses playing the clip. */ pause(): void { - this._pendingPlay = false; + this._cancelPendingPlayback(); if (this._isPlaying) { this._clearSourceNode(); @@ -250,6 +234,21 @@ export class AudioSource extends Component { this.stop(); } + /** @internal */ + _resumePendingPlayback(): void { + if (!this._pendingPlay) { + return; + } + + this._pendingPlay = false; + + if (this._destroyed || !this.enabled || !this._clip?._getAudioSource()) { + return; + } + + this._startPlayback(); + } + private _startPlayback(): void { const startTime = this._pausedTime > 0 ? this._pausedTime - this._playTime : 0; this._initSourceNode(startTime); @@ -280,4 +279,13 @@ export class AudioSource extends Component { this._sourceNode.onended = null; this._sourceNode = null; } + + private _cancelPendingPlayback(): void { + if (!this._pendingPlay) { + return; + } + + this._pendingPlay = false; + AudioManager._unregisterPendingSource(this); + } } diff --git a/tests/src/core/audio/AudioSourcePendingPlayback.test.ts b/tests/src/core/audio/AudioSourcePendingPlayback.test.ts new file mode 100644 index 000000000..a1e900332 --- /dev/null +++ b/tests/src/core/audio/AudioSourcePendingPlayback.test.ts @@ -0,0 +1,232 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AudioManager, AudioSource } from "@galacean/engine-core"; + +class MockGainNode { + gain = { + setValueAtTime: vi.fn() + }; + + connect = vi.fn(); +} + +class MockBufferSourceNode { + buffer: unknown = null; + loop = false; + onended: (() => void) | null = null; + playbackRate = { + value: 1 + }; + + connect = vi.fn(); + disconnect = vi.fn(); + start = vi.fn(); + stop = vi.fn(); +} + +class MockAudioContext { + static shouldResumeSucceed = true; + static resumeResultQueue: Array | Error> | null = null; + + currentTime = 0; + destination = {}; + onstatechange: (() => void) | null = null; + state: AudioContextState = "suspended"; + + createBufferSource(): AudioBufferSourceNode { + return new MockBufferSourceNode() as unknown as AudioBufferSourceNode; + } + + createGain(): GainNode { + return new MockGainNode() as unknown as GainNode; + } + + resume(): Promise { + const queuedResult = MockAudioContext.resumeResultQueue?.shift(); + if (queuedResult instanceof Promise) { + return queuedResult; + } + if (queuedResult instanceof Error) { + return Promise.reject(queuedResult); + } + if (!MockAudioContext.shouldResumeSucceed) { + return Promise.reject(new Error("autoplay blocked")); + } + this.state = "running"; + this.onstatechange?.(); + return Promise.resolve(); + } + + suspend(): Promise { + this.state = "suspended"; + this.onstatechange?.(); + return Promise.resolve(); + } +} + +async function flushAsync(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +function createAudioSource(): AudioSource { + const audioSource = new AudioSource({ + _isActiveInHierarchy: true, + _isActiveInScene: true, + _removeComponent() {}, + engine: {} + } as any); + + audioSource.clip = { + _addReferCount() {}, + _getAudioSource() { + return {}; + } + } as any; + + return audioSource; +} + +describe("AudioSource pending playback", () => { + beforeEach(() => { + (window as any).AudioContext = MockAudioContext; + (AudioManager as any)._context = null; + (AudioManager as any)._gainNode = null; + (AudioManager as any)._needsUserGestureResume = false; + (AudioManager as any)._pendingSources = new Set(); + MockAudioContext.shouldResumeSucceed = true; + MockAudioContext.resumeResultQueue = null; + AudioManager._playingCount = 0; + }); + + afterEach(() => { + vi.restoreAllMocks(); + document.replaceChildren(); + }); + + it("replays pending playback on the next user gesture after autoplay blocking", async () => { + const audioSource = createAudioSource(); + + vi.spyOn(console, "warn").mockImplementation(() => {}); + MockAudioContext.shouldResumeSucceed = false; + + audioSource.play(); + await flushAsync(); + + expect((audioSource as any)._pendingPlay).to.be.true; + expect((AudioManager as any)._pendingSources.size).to.equal(1); + expect((AudioManager as any)._needsUserGestureResume).to.be.false; + expect(audioSource.isPlaying).to.be.false; + + MockAudioContext.shouldResumeSucceed = true; + document.dispatchEvent(new Event("click")); + await flushAsync(); + + expect(audioSource.isPlaying).to.be.true; + expect((audioSource as any)._pendingPlay).to.be.false; + expect((AudioManager as any)._pendingSources.size).to.equal(0); + expect((AudioManager as any)._needsUserGestureResume).to.be.false; + }); + + it("cancels pending playback before the unlocking gesture arrives", async () => { + const audioSource = createAudioSource(); + + vi.spyOn(console, "warn").mockImplementation(() => {}); + MockAudioContext.shouldResumeSucceed = false; + + audioSource.play(); + await flushAsync(); + + audioSource.stop(); + expect((audioSource as any)._pendingPlay).to.be.false; + expect((AudioManager as any)._pendingSources.size).to.equal(0); + + MockAudioContext.shouldResumeSucceed = true; + document.dispatchEvent(new Event("click")); + await flushAsync(); + + expect(audioSource.isPlaying).to.be.false; + expect((audioSource as any)._pendingPlay).to.be.false; + }); + + it("keeps resume a no-op until a context already exists", async () => { + expect((AudioManager as any)._context).to.be.null; + + await AudioManager.resume(); + + expect((AudioManager as any)._context).to.be.null; + }); + + it("resumes automatically when returning to the foreground with active audio", async () => { + createAudioSource(); + const context = (AudioManager as any)._context as MockAudioContext; + + vi.spyOn(document, "hidden", "get").mockReturnValue(false); + const resumeSpy = vi.spyOn(context, "resume"); + const suspendSpy = vi.spyOn(AudioManager, "suspend"); + + context.state = "suspended"; + AudioManager._playingCount = 1; + + document.dispatchEvent(new Event("visibilitychange")); + await flushAsync(); + + expect(resumeSpy).toHaveBeenCalledTimes(1); + expect(suspendSpy).not.toHaveBeenCalled(); + expect(context.state).to.equal("running"); + expect((AudioManager as any)._needsUserGestureResume).to.be.false; + }); + + it("falls back to gesture recovery when foreground auto-resume fails", async () => { + createAudioSource(); + const context = (AudioManager as any)._context as MockAudioContext; + + vi.spyOn(document, "hidden", "get").mockReturnValue(false); + const resumeSpy = vi.spyOn(context, "resume"); + const suspendSpy = vi.spyOn(AudioManager, "suspend"); + + MockAudioContext.shouldResumeSucceed = false; + context.state = "suspended"; + AudioManager._playingCount = 1; + + document.dispatchEvent(new Event("visibilitychange")); + await flushAsync(); + + expect(resumeSpy).toHaveBeenCalledTimes(1); + expect(suspendSpy).toHaveBeenCalledTimes(1); + expect((AudioManager as any)._needsUserGestureResume).to.be.true; + + MockAudioContext.shouldResumeSucceed = true; + document.dispatchEvent(new Event("click")); + await flushAsync(); + + expect(resumeSpy).toHaveBeenCalledTimes(2); + expect(context.state).to.equal("running"); + expect((AudioManager as any)._needsUserGestureResume).to.be.false; + }); + + it("retries context.resume inside a later user gesture even if an earlier resume is still pending", async () => { + createAudioSource(); + const context = (AudioManager as any)._context as MockAudioContext; + const firstResume = new Promise(() => {}); + + MockAudioContext.resumeResultQueue = [firstResume]; + const resumeSpy = vi.spyOn(context, "resume"); + + AudioManager.resume().catch(() => {}); + await flushAsync(); + + expect(resumeSpy).toHaveBeenCalledTimes(1); + + MockAudioContext.resumeResultQueue = [Promise.resolve().then(() => { + context.state = "running"; + context.onstatechange?.(); + })]; + (AudioManager as any)._needsUserGestureResume = true; + + document.dispatchEvent(new Event("click")); + await flushAsync(); + + expect(resumeSpy).toHaveBeenCalledTimes(2); + expect(context.state).to.equal("running"); + expect((AudioManager as any)._needsUserGestureResume).to.be.false; + }); +}); diff --git a/tests/vitest.config.ts b/tests/vitest.config.ts index 91a68ffbc..e11fe737f 100644 --- a/tests/vitest.config.ts +++ b/tests/vitest.config.ts @@ -1,9 +1,18 @@ +import glsl from "../rollup-plugin-glsl"; import { defineProject } from "vitest/config"; export default defineProject({ server: { port: 51204 }, + plugins: [ + glsl({ + include: [/\.(glsl|gs)$/] + }) + ], + resolve: { + mainFields: ["debug", "module", "main"] + }, optimizeDeps: { exclude: [ "@galacean/engine",