Merge remote-tracking branch 'origin/fix/shaderlab' into fix/shaderlab

This commit is contained in:
cptbtptpbcptdtptp
2026-04-22 21:54:09 +08:00
4 changed files with 343 additions and 38 deletions

View File

@@ -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;
});
}
}

View File

@@ -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);
}
}

View 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;
});
});

View File

@@ -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",