Skip to content

Commit

Permalink
Audio resume fix (#4788)
Browse files Browse the repository at this point in the history
* simplify audio manager logic
* remove options
  • Loading branch information
slimbuck authored Oct 26, 2022
1 parent 7850feb commit 5fbdf4c
Show file tree
Hide file tree
Showing 2 changed files with 76 additions and 168 deletions.
2 changes: 1 addition & 1 deletion src/framework/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ class Application extends AppBase {
appOptions.assetPrefix = options.assetPrefix;
appOptions.scriptsOrder = options.scriptsOrder;

appOptions.soundManager = new SoundManager(options);
appOptions.soundManager = new SoundManager();
appOptions.lightmapper = Lightmapper;
appOptions.batchManager = BatchManager;
appOptions.xr = XrManager;
Expand Down
242 changes: 75 additions & 167 deletions src/platform/sound/manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,18 @@ import { EventHandler } from '../../core/event-handler.js';

import { math } from '../../core/math/math.js';

import { hasAudioContext } from '../audio/capabilities.js';
import { Channel } from '../audio/channel.js';
import { Channel3d } from '../audio/channel3d.js';

import { Listener } from './listener.js';

const CONTEXT_STATE_RUNNING = 'running';
const CONTEXT_STATE_INTERRUPTED = 'interrupted';

/**
* List of Window events to listen when AudioContext needs to be unlocked.
*/
const USER_INPUT_EVENTS = [
'click', 'contextmenu', 'auxclick', 'dblclick', 'mousedown',
'mouseup', 'pointerup', 'touchend', 'keydown', 'keyup'
'click', 'touchstart'
];

/**
Expand All @@ -31,11 +28,8 @@ class SoundManager extends EventHandler {
/**
* Create a new SoundManager instance.
*
* @param {object} [options] - Options options object.
* @param {boolean} [options.forceWebAudioApi] - Always use the Web Audio API, even if check
* indicates that it is not available.
*/
constructor(options) {
constructor() {
super();

/**
Expand All @@ -46,52 +40,18 @@ class SoundManager extends EventHandler {
*/
this._context = null;

/**
* @type {boolean}
* @private
*/
this._forceWebAudioApi = options.forceWebAudioApi;

/**
* The function callback attached to the Window events USER_INPUT_EVENTS
*
* @type {EventListenerOrEventListenerObject}
* @private
*/
this._resumeContextCallback = null;

/**
* Set to to true when suspend() was called explitly (either manually or on visibility change),
* and reset to false after resume() is called.
* This value is not directly bound to AudioContext.state.
*
* @type {boolean}
* @private
*/
this._selfSuspended = false;

/**
* If true, the AudioContext is in a special 'suspended' state where it needs to be resumed
* from a User event. In addition, some devices and browsers require that a blank sound be played.
*
* @type {boolean}
* @private
*/
this._unlocked = false;

/**
* Set after the unlock flow is triggered, but hasn't completed yet.
* Used to avoid starting multiple 'unlock' flows at the same time.
*
* @type {boolean}
* @private
*/
this._unlocking = false;
this.AudioContext = (typeof AudioContext !== 'undefined' && AudioContext) ||
(typeof webkitAudioContext !== 'undefined' && webkitAudioContext);

if (!hasAudioContext() && !this._forceWebAudioApi) {
if (!this.AudioContext) {
Debug.warn('No support for 3D audio found');
}

this._unlockHandlerFunc = this._unlockHandler.bind(this);

// user suspended audio
this._userSuspended = false;

this.listener = new Listener(this);

this._volume = 1;
Expand All @@ -113,8 +73,12 @@ class SoundManager extends EventHandler {
return this._volume;
}

get running() {
return this._context && this._context.state === CONTEXT_STATE_RUNNING;
}

get suspended() {
return !this._context || !this._unlocked || this._context.state !== CONTEXT_STATE_RUNNING;
return !this.running;
}

/**
Expand All @@ -125,87 +89,41 @@ class SoundManager extends EventHandler {
*/
get context() {
// lazy create the AudioContext
if (!this._context) {
if (hasAudioContext() || this._forceWebAudioApi) {
if (typeof AudioContext !== 'undefined') {
this._context = new AudioContext();
} else if (typeof webkitAudioContext !== 'undefined') {
this._context = new webkitAudioContext();
}

// if context was successfully created, initialize it
if (this._context) {
// AudioContext will start in a 'suspended' state if it is locked by the browser
this._unlocked = this._context.state === CONTEXT_STATE_RUNNING;
if (!this._unlocked) {
this._addContextUnlockListeners();
}

// When the browser window loses focus (i.e. switching tab, hiding the app on mobile, etc),
// the AudioContext state will be set to 'interrupted' (on iOS Safari) or 'suspended' (on other
// browsers), and 'resume' must be expliclty called.
const self = this;
this._context.onstatechange = function () {

// explicitly call .resume() when previous state was suspended or interrupted
if (self._unlocked && !self._selfSuspended && self._context.state !== CONTEXT_STATE_RUNNING) {
self._context.resume().then(() => {
// no-op
}, (e) => {
Debug.error(`Attempted to resume the AudioContext on onstatechange, but it was rejected`, e);
}).catch((e) => {
Debug.error(`Attempted to resume the AudioContext on onstatechange, but threw an exception`, e);
});
}
};
}
if (!this._context && this.AudioContext) {
this._context = new this.AudioContext();

if (this._context.state !== CONTEXT_STATE_RUNNING) {
this._registerUnlockListeners();
}
}

return this._context;
}

suspend() {
this._selfSuspended = true;

if (this.suspended) {
// already suspended
return;
if (!this._userSuspended) {
this._userSuspended = true;
if (this._context && this._context.state === CONTEXT_STATE_RUNNING) {
this._suspend();
}
}

this.fire('suspend');
}

resume() {
this._selfSuspended = false;

// cannot resume context if it wasn't created yet or if it's still locked
if (!this._context || (!this._unlocked && !this._unlocking)) {
return;
}

// @ts-ignore 'interrupted' is a valid state on iOS
if (this._context.state === CONTEXT_STATE_INTERRUPTED) {
// explictly resume() context, and only fire 'resume' event after context has resumed
this._context.resume().then(() => {
this.fire('resume');
}, (e) => {
Debug.error(`Attempted to resume the AudioContext on SoundManager.resume(), but it was rejected`, e);
}).catch((e) => {
Debug.error(`Attempted to resume the AudioContext on SoundManager.resume(), but threw an exception`, e);
});
} else {
this.fire('resume');
if (this._userSuspended) {
this._userSuspended = false;
if (this._context && this._context.state !== CONTEXT_STATE_RUNNING) {
this._resume();
}
}
}

destroy() {
this._removeUserInputListeners();

this.fire('destroy');

if (this._context && this._context.close) {
this._context.close();
if (this._context) {
this._removeUnlockListeners();
this._context?.close();
this._context = null;
}
}
Expand Down Expand Up @@ -270,68 +188,58 @@ class SoundManager extends EventHandler {
return channel;
}

/**
* Add the necessary Window EventListeners to comply with auto-play policies,
* and correctly unlock and resume the AudioContext.
* For more info, https://developers.google.com/web/updates/2018/11/web-audio-autoplay.
*
* @private
*/
_addContextUnlockListeners() {
this._unlocking = false;

// resume AudioContext on user interaction because of autoplay policy
if (!this._resumeContextCallback) {
this._resumeContextCallback = () => {
// prevent multiple unlock calls
if (!this._context || this._unlocked || this._unlocking) {
return;
}
this._unlocking = true;

// trigger the resume flow from a User-initiated event
this.resume();

// Some platforms (mostly iOS) require an additional sound to be played.
// This also performs a sanity check and verifies sounds can be played.
const buffer = this._context.createBuffer(1, 1, this._context.sampleRate);
const source = this._context.createBufferSource();
source.buffer = buffer;
source.connect(this._context.destination);
source.start(0);

// onended is only called if everything worked as expected (context is running)
source.onended = (event) => {
source.disconnect(0);

// unlocked!
this._unlocked = true;
this._unlocking = false;
this._removeUserInputListeners();
};
// resume the sound context
_resume() {
this._context.resume().then(() => {
// Some platforms (mostly iOS) require an additional sound to be played.
// This also performs a sanity check and verifies sounds can be played.
const source = this._context.createBufferSource();
source.buffer = this._context.createBuffer(1, 1, this._context.sampleRate);
source.connect(this._context.destination);
source.start(0);

// onended is only called if everything worked as expected (context is running)
source.onended = (event) => {
source.disconnect(0);
this.fire('resume');
};
}, (e) => {
Debug.error(`Attempted to resume the AudioContext on SoundManager.resume(), but it was rejected ${e}`);
}).catch((e) => {
Debug.error(`Attempted to resume the AudioContext on SoundManager.resume(), but threw an exception ${e}`);
});
}

// resume the sound context and fire suspend event if it succeeds
_suspend() {
this._context.suspend().then(() => {
this.fire('suspend');
}, (e) => {
Debug.error(`Attempted to suspend the AudioContext on SoundManager.suspend(), but it was rejected ${e}`);
}).catch((e) => {
Debug.error(`Attempted to suspend the AudioContext on SoundManager.suspend(), but threw an exception ${e}`);
});
}

_unlockHandler() {
this._removeUnlockListeners();

if (!this._userSuspended && this._context.state !== CONTEXT_STATE_RUNNING) {
this._resume();
}
}

_registerUnlockListeners() {
// attach to all user input events
USER_INPUT_EVENTS.forEach((eventName) => {
window.addEventListener(eventName, this._resumeContextCallback, false);
window.addEventListener(eventName, this._unlockHandlerFunc, false);
});
}

/**
* Remove all USER_INPUT_EVENTS unlock event listeners, if they're still attached.
*
* @private
*/
_removeUserInputListeners() {
if (!this._resumeContextCallback) {
return;
}

_removeUnlockListeners() {
USER_INPUT_EVENTS.forEach((eventName) => {
window.removeEventListener(eventName, this._resumeContextCallback, false);
window.removeEventListener(eventName, this._unlockHandlerFunc, false);
});
this._resumeContextCallback = null;
}
}

Expand Down

0 comments on commit 5fbdf4c

Please sign in to comment.