mirror of
https://github.com/galacean/engine.git
synced 2026-06-02 08:40:12 +08:00
Merge remote-tracking branch 'origin/fix/shaderlab' into fix/shaderlab
This commit is contained in:
@@ -7,15 +7,15 @@ export class AudioManager {
|
||||
|
||||
private static _context: AudioContext;
|
||||
private static _gainNode: GainNode;
|
||||
private static _resumePromise: Promise<void> = 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
232
tests/src/core/audio/AudioSourcePendingPlayback.test.ts
Normal file
232
tests/src/core/audio/AudioSourcePendingPlayback.test.ts
Normal file
@@ -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<Promise<void> | 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<void> {
|
||||
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<void> {
|
||||
this.state = "suspended";
|
||||
this.onstatechange?.();
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
async function flushAsync(): Promise<void> {
|
||||
await new Promise<void>((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<void>(() => {});
|
||||
|
||||
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;
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user