Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 40 additions & 26 deletions packages/core/src/Polyfill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export class Polyfill {
static registerPolyfill(): void {
Polyfill._registerMatchAll();
Polyfill._registerAudioContext();
Polyfill._registerOfflineAudioContext();
Polyfill._registerTextMetrics();
Polyfill._registerPromiseFinally();
}
Expand Down Expand Up @@ -42,36 +43,49 @@ export class Polyfill {
if (!window.AudioContext && (window as any).webkitAudioContext) {
Logger.info("Polyfill window.AudioContext");
window.AudioContext = (window as any).webkitAudioContext;
Polyfill._promisifyDecodeAudioData(AudioContext.prototype);
}
}

const originalDecodeAudioData = AudioContext.prototype.decodeAudioData as (
audioData: ArrayBuffer,
successCallback?: DecodeSuccessCallback | null,
errorCallback?: DecodeErrorCallback | null
) => void;

AudioContext.prototype.decodeAudioData = function (
arrayBuffer: ArrayBuffer,
successCallback?: DecodeSuccessCallback | null,
errorCallback?: DecodeErrorCallback | null
): Promise<AudioBuffer> {
return new Promise<AudioBuffer>((resolve, reject) => {
originalDecodeAudioData.call(
this,
arrayBuffer,
(buffer: AudioBuffer) => {
successCallback?.(buffer);
resolve(buffer);
},
(error: DOMException) => {
errorCallback?.(error);
reject(error);
}
);
});
};
private static _registerOfflineAudioContext(): void {
Comment thread
GuoLei1990 marked this conversation as resolved.
// iOS 14.0 and earlier expose only webkitOfflineAudioContext, with callback-form decodeAudioData
if (!window.OfflineAudioContext && (window as any).webkitOfflineAudioContext) {
Logger.info("Polyfill window.OfflineAudioContext");
window.OfflineAudioContext = (window as any).webkitOfflineAudioContext;
Polyfill._promisifyDecodeAudioData(OfflineAudioContext.prototype);
}
}

// Wrap the old callback-form decodeAudioData (on prefixed iOS contexts) into the modern Promise form
private static _promisifyDecodeAudioData(proto: BaseAudioContext): void {
const originalDecodeAudioData = proto.decodeAudioData as (
audioData: ArrayBuffer,
successCallback?: DecodeSuccessCallback | null,
errorCallback?: DecodeErrorCallback | null
) => void;

proto.decodeAudioData = function (
arrayBuffer: ArrayBuffer,
successCallback?: DecodeSuccessCallback | null,
errorCallback?: DecodeErrorCallback | null
): Promise<AudioBuffer> {
return new Promise<AudioBuffer>((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.
Expand Down
78 changes: 65 additions & 13 deletions packages/core/src/audio/AudioManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,22 @@ export class AudioManager {
private static _gainNode: GainNode;
private static _resumePromise: Promise<void> = null;
private static _needsUserGestureResume = false;
private static _suspendedByCaller = false;
private static _recovering = false;

/**
* Suspend the audio context.
* @returns A promise that resolves when the audio context is suspended
*/
static suspend(): Promise<void> {
return AudioManager.getContext().suspend();
// No context means nothing is playing: suspending is a no-op and must NOT flag a caller-suspend
// (a ghost flag would later block foreground recovery), and don't create a cold context just to suspend
const context = AudioManager._context;
if (!context) {
return Promise.resolve();
}
AudioManager._suspendedByCaller = true;
return context.suspend();
}

/**
Expand All @@ -24,6 +33,7 @@ export class AudioManager {
* @returns A promise that resolves when the audio context is resumed
*/
static resume(): Promise<void> {
AudioManager._suspendedByCaller = false;
return (AudioManager._resumePromise ??= AudioManager.getContext()
.resume()
.then(() => {
Expand All @@ -42,7 +52,9 @@ export class AudioManager {
if (!context) {
AudioManager._context = context = new window.AudioContext();
document.addEventListener("visibilitychange", AudioManager._onVisibilityChange);
// iOS Safari requires user gesture to resume AudioContext
// iOS Safari bfcache restore fires pageshow (persisted) but NOT visibilitychange, so recover here too
window.addEventListener("pageshow", AudioManager._onPageShow);
// iOS Safari requires a user gesture to resume the AudioContext
document.addEventListener("touchstart", AudioManager._resumeAfterInterruption, { passive: true });
document.addEventListener("touchend", AudioManager._resumeAfterInterruption, { passive: true });
document.addEventListener("click", AudioManager._resumeAfterInterruption);
Expand Down Expand Up @@ -71,21 +83,61 @@ export class AudioManager {
}

private static _onVisibilityChange(): void {
if (!document.hidden && AudioManager._playingCount > 0 && !AudioManager.isAudioContextRunning()) {
// iOS WKWebView WebKit bug(Triggered in LingGuang App): AudioContext may be in a "zombie" state where
// state reports "suspended" but resume() alone won't restart audio rendering.
// Calling suspend() first forces a clean internal state reset before user gesture triggers resume.
// Related: https://bugs.webkit.org/show_bug.cgi?id=263627
AudioManager.suspend();
AudioManager._needsUserGestureResume = true;
if (document.hidden) {
// Desktop/Android don't auto-suspend a running WebAudio context when backgrounded (only iOS does),
// so suspend here to stop audio in the background; only if a context already exists (don't create one)
AudioManager._context?.suspend().catch(() => {});
} else {
AudioManager._recoverPlaybackContext();
}
}

private static _recoverPlaybackContext(): void {
// Returning to foreground with a non-running context (and not a deliberate pause): iOS leaves it
// "interrupted", which cannot be resumed directly; suspend() first transitions it to "suspended",
// then resume() restarts the pipeline https://bugs.webkit.org/show_bug.cgi?id=263627
// _recovering guards re-entry: a bfcache restore fires both visibilitychange and pageshow
if (
AudioManager._recovering ||
document.hidden ||
AudioManager._suspendedByCaller ||
AudioManager._playingCount <= 0 ||
AudioManager.isAudioContextRunning()
) {
return;
}
AudioManager._recovering = true;
AudioManager._needsUserGestureResume = true; // fallback if the auto-resume below is rejected
const context = AudioManager.getContext();
context.suspend().catch(() => {});
// 100ms empirical delay (resume too soon after suspend is unreliable on iOS); _recovering is cleared
// on the timer rather than off a promise because iOS may never settle suspend/resume in interrupted
setTimeout(() => {
AudioManager._recovering = false;
if (document.hidden || AudioManager._suspendedByCaller) {
return;
}
// Go through AudioManager.resume() so _resumePromise coalesces any gesture-resume racing us during
// the slow iOS interrupted->running transition; a bare context.resume() here wouldn't dedupe
AudioManager.resume().catch(() => {});
}, 100);
}

private static _onPageShow(event: PageTransitionEvent): void {
// iOS Safari bfcache restore (persisted) needs recovery; a normal load has no suspended context
if (event.persisted) {
AudioManager._recoverPlaybackContext();
}
}

private static _resumeAfterInterruption(): void {
if (AudioManager._needsUserGestureResume) {
AudioManager.resume().catch((e) => {
console.warn("Failed to resume AudioContext:", e);
});
// iOS Safari gesture fallback for when auto-resume is blocked.
// _recovering: don't bypass the 100ms delay (would resume on a still-interrupted context)
if (AudioManager._recovering || AudioManager._suspendedByCaller || !AudioManager._needsUserGestureResume) {
return;
}
AudioManager.resume().catch((e) => {
console.warn("Failed to resume AudioContext:", e);
});
}
}
46 changes: 33 additions & 13 deletions packages/core/src/audio/AudioSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand Down Expand Up @@ -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)
}

/**
Expand All @@ -155,6 +156,10 @@ export class AudioSource extends Component {
if (!this._clip?._getAudioSource() || this._isPlaying || this._pendingPlay) {
return;
}
// Hidden page: don't start (would leak a sound) and don't pend (would replay out of sync) -> drop
if (document.hidden) {
return;
}

if (AudioManager.isAudioContextRunning()) {
this._startPlayback();
Expand All @@ -169,8 +174,8 @@ export class AudioSource extends Component {
return;
}
this._pendingPlay = false;
// Check if still valid to play after async resume
if (this._destroyed || !this.enabled || !this._clip) {
// Check if still valid to play after async resume (page may have been hidden meanwhile)
if (this._destroyed || !this.enabled || !this._clip || document.hidden) {
return;
}
this._startPlayback();
Expand All @@ -191,12 +196,13 @@ export class AudioSource extends Component {

if (this._isPlaying) {
this._clearSourceNode();

this._isPlaying = false;
this._pausedTime = -1;
this._playTime = -1;
AudioManager._playingCount--;
}

// stop() always resets to the start, including from a paused state (where _isPlaying is already false)
this._pausedTime = -1;
this._playTime = -1;
}

/**
Expand All @@ -219,7 +225,7 @@ export class AudioSource extends Component {
*/
_cloneTo(target: AudioSource): void {
target._clip?._addReferCount(1);
target._gainNode.gain.setValueAtTime(target._volume, AudioManager.getContext().currentTime);
// _volume is field-cloned; its gain node is applied lazily on first play
}

/**
Expand Down Expand Up @@ -250,6 +256,16 @@ export class AudioSource extends Component {
this.stop();
}

private _ensureGainNode(): GainNode {
let gainNode = this._gainNode;
if (!gainNode) {
this._gainNode = gainNode = AudioManager.getContext().createGain();
gainNode.connect(AudioManager.getGainNode());
gainNode.gain.setValueAtTime(this._volume, AudioManager.getContext().currentTime);
}
return gainNode;
}

private _startPlayback(): void {
const startTime = this._pausedTime > 0 ? this._pausedTime - this._playTime : 0;
this._initSourceNode(startTime);
Expand All @@ -263,15 +279,19 @@ export class AudioSource extends Component {
private _initSourceNode(startTime: number): void {
const context = AudioManager.getContext();
const sourceNode = context.createBufferSource();
const buffer = this._clip._getAudioSource();

sourceNode.buffer = this._clip._getAudioSource();
sourceNode.buffer = buffer;
sourceNode.playbackRate.value = this._playbackRate;
sourceNode.loop = this._loop;
sourceNode.onended = this._onPlayEnd;
this._sourceNode = sourceNode;

sourceNode.connect(this._gainNode);
sourceNode.start(0, startTime);
sourceNode.connect(this._ensureGainNode());
// startTime is total elapsed time; for a looping clip wrap it into the buffer to keep the loop phase
// (start()'s offset clamps past the end, it does not wrap)
const offset = this._loop && buffer.duration > 0 ? startTime % buffer.duration : startTime;
sourceNode.start(0, offset);
}

private _clearSourceNode(): void {
Expand Down
16 changes: 13 additions & 3 deletions packages/loader/src/AudioLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AudioClip> {
// 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<AudioClip> {
return new AssetPromise((resolve, reject) => {
const { url } = item;
Expand All @@ -24,8 +29,7 @@ class AudioLoader extends Loader<AudioClip> {
._request<ArrayBuffer>(url, requestConfig)
.then((arrayBuffer) => {
const audioClip = new AudioClip(resourceManager.engine);
// @ts-ignore
AudioManager.getContext()
AudioLoader._getDecodeContext()
.decodeAudioData(arrayBuffer)
.then((result: AudioBuffer) => {
// @ts-ignore
Expand All @@ -47,4 +51,10 @@ class AudioLoader extends Loader<AudioClip> {
});
});
}

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