UNPKG

playcanvas

Version:

PlayCanvas WebGL game engine

1,005 lines (1,002 loc) 35 kB
import { EventHandler } from '../../core/event-handler.js'; import { math } from '../../core/math/math.js'; import { hasAudioContext } from './capabilities.js'; /** * @import { SoundManager } from './manager.js' * @import { Sound } from './sound.js' */ const STATE_PLAYING = 0; const STATE_PAUSED = 1; const STATE_STOPPED = 2; /** * Return time % duration but always return a number instead of NaN when duration is 0. * * @param {number} time - The time. * @param {number} duration - The duration. * @returns {number} The time % duration. */ function capTime(time, duration) { return time % duration || 0; } /** * A SoundInstance plays a {@link Sound}. * * @category Sound */ class SoundInstance extends EventHandler { static{ /** * Fired when the instance starts playing its source. * * @event * @example * instance.on('play', () => { * console.log('Instance started playing'); * }); */ this.EVENT_PLAY = 'play'; } static{ /** * Fired when the instance is paused. * * @event * @example * instance.on('pause', () => { * console.log('Instance paused'); * }); */ this.EVENT_PAUSE = 'pause'; } static{ /** * Fired when the instance is resumed. * * @event * @example * instance.on('resume', () => { * console.log('Instance resumed'); * }); */ this.EVENT_RESUME = 'resume'; } static{ /** * Fired when the instance is stopped. * * @event * @example * instance.on('stop', () => { * console.log('Instance stopped'); * }); */ this.EVENT_STOP = 'stop'; } static{ /** * Fired when the sound currently played by the instance ends. * * @event * @example * instance.on('end', () => { * console.log('Instance ended'); * }); */ this.EVENT_END = 'end'; } /** * Create a new SoundInstance instance. * * @param {SoundManager} manager - The sound manager. * @param {Sound} sound - The sound to play. * @param {object} options - Options for the instance. * @param {number} [options.volume] - The playback volume, between 0 and 1. Defaults to 1. * @param {number} [options.pitch] - The relative pitch. Defaults to 1 (plays at normal pitch). * @param {boolean} [options.loop] - Whether the sound should loop when it reaches the end or * not. Defaults to false. * @param {number} [options.startTime] - The time from which the playback will start in * seconds. Default is 0 to start at the beginning. Defaults to 0. * @param {number} [options.duration] - The total time after the startTime in seconds when * playback will stop or restart if loop is true. Defaults to 0. * @param {Function} [options.onPlay] - Function called when the instance starts playing. * @param {Function} [options.onPause] - Function called when the instance is paused. * @param {Function} [options.onResume] - Function called when the instance is resumed. * @param {Function} [options.onStop] - Function called when the instance is stopped. * @param {Function} [options.onEnd] - Function called when the instance ends. */ constructor(manager, sound, options){ super(), /** * Gets the source that plays the sound resource. If the Web Audio API is not supported the * type of source is [Audio](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/audio). * Source is only available after calling play. * * @type {AudioBufferSourceNode} */ this.source = null; /** * @type {SoundManager} * @private */ this._manager = manager; /** * @type {number} * @private */ this._volume = options.volume !== undefined ? math.clamp(Number(options.volume) || 0, 0, 1) : 1; /** * @type {number} * @private */ this._pitch = options.pitch !== undefined ? Math.max(0.01, Number(options.pitch) || 0) : 1; /** * @type {boolean} * @private */ this._loop = !!(options.loop !== undefined ? options.loop : false); /** * @type {Sound} * @private */ this._sound = sound; /** * Start at 'stopped'. * * @type {number} * @private */ this._state = STATE_STOPPED; /** * True if the manager was suspended. * * @type {boolean} * @private */ this._suspended = false; /** * Greater than 0 if we want to suspend the event handled to the 'onended' event. * When an 'onended' event is suspended, this counter is decremented by 1. * When a future 'onended' event is to be suspended, this counter is incremented by 1. * * @type {number} * @private */ this._suspendEndEvent = 0; /** * True if we want to suspend firing instance events. * * @type {boolean} * @private */ this._suspendInstanceEvents = false; /** * If true then the instance will start playing its source when its created. * * @type {boolean} * @private */ this._playWhenLoaded = true; /** * @type {number} * @private */ this._startTime = Math.max(0, Number(options.startTime) || 0); /** * @type {number} * @private */ this._duration = Math.max(0, Number(options.duration) || 0); /** * @type {number|null} * @private */ this._startOffset = null; // external event handlers /** @private */ this._onPlayCallback = options.onPlay; /** @private */ this._onPauseCallback = options.onPause; /** @private */ this._onResumeCallback = options.onResume; /** @private */ this._onStopCallback = options.onStop; /** @private */ this._onEndCallback = options.onEnd; if (hasAudioContext()) { /** * @type {number} * @private */ this._startedAt = 0; /** * Manually keep track of the playback position because the Web Audio API does not * provide a way to do this accurately if the playbackRate is not 1. * * @type {number} * @private */ this._currentTime = 0; /** * @type {number} * @private */ this._currentOffset = 0; /** * The input node is the one that is connected to the source. * * @type {AudioNode|null} * @private */ this._inputNode = null; /** * The connected node is the one that is connected to the destination (speakers). Any * external nodes will be connected to this node. * * @type {AudioNode|null} * @private */ this._connectorNode = null; /** * The first external node set by a user. * * @type {AudioNode|null} * @private */ this._firstNode = null; /** * The last external node set by a user. * * @type {AudioNode|null} * @private */ this._lastNode = null; /** * Set to true if a play() request was issued when the AudioContext was still suspended, * and will therefore wait until it is resumed to play the audio. * * @type {boolean} * @private */ this._waitingContextSuspension = false; this._initializeNodes(); /** @private */ this._endedHandler = this._onEnded.bind(this); } else { /** @private */ this._isReady = false; /** @private */ this._loadedMetadataHandler = this._onLoadedMetadata.bind(this); /** @private */ this._timeUpdateHandler = this._onTimeUpdate.bind(this); /** @private */ this._endedHandler = this._onEnded.bind(this); this._createSource(); } } /** * Sets the current time of the sound that is playing. If the value provided is bigger than the * duration of the instance it will wrap from the beginning. * * @type {number} */ set currentTime(value) { if (value < 0) return; if (this._state === STATE_PLAYING) { const suspend = this._suspendInstanceEvents; this._suspendInstanceEvents = true; // stop first which will set _startOffset to null this.stop(); // set _startOffset and play this._startOffset = value; this.play(); this._suspendInstanceEvents = suspend; } else { // set _startOffset which will be used when the instance will start playing this._startOffset = value; // set _currentTime this._currentTime = value; } } /** * Gets the current time of the sound that is playing. * * @type {number} */ get currentTime() { // if the user has set the currentTime and we have not used it yet // then just return that if (this._startOffset !== null) { return this._startOffset; } // if the sound is paused return the currentTime calculated when // pause() was called if (this._state === STATE_PAUSED) { return this._currentTime; } // if the sound is stopped or we don't have a source // return 0 if (this._state === STATE_STOPPED || !this.source) { return 0; } // recalculate current time this._updateCurrentTime(); return this._currentTime; } /** * Sets the duration of the sound that the instance will play starting from startTime. * * @type {number} */ set duration(value) { this._duration = Math.max(0, Number(value) || 0); // restart const isPlaying = this._state === STATE_PLAYING; this.stop(); if (isPlaying) { this.play(); } } /** * Gets the duration of the sound that the instance will play starting from startTime. * * @type {number} */ get duration() { if (!this._sound) { return 0; } if (this._duration) { return capTime(this._duration, this._sound.duration); } return this._sound.duration; } /** * Gets whether the instance is currently paused. * * @type {boolean} */ get isPaused() { return this._state === STATE_PAUSED; } /** * Gets whether the instance is currently playing. * * @type {boolean} */ get isPlaying() { return this._state === STATE_PLAYING; } /** * Gets whether the instance is currently stopped. * * @type {boolean} */ get isStopped() { return this._state === STATE_STOPPED; } /** * Gets whether the instance is currently suspended because the window is not focused. * * @type {boolean} */ get isSuspended() { return this._suspended; } /** * Sets whether the instance will restart when it finishes playing. * * @type {boolean} */ set loop(value) { this._loop = !!value; if (this.source) { this.source.loop = this._loop; } } /** * Gets whether the instance will restart when it finishes playing. * * @type {boolean} */ get loop() { return this._loop; } /** * Sets the pitch modifier to play the sound with. Must be larger than 0.01. * * @type {number} */ set pitch(pitch) { // set offset to current time so that // we calculate the rest of the time with the new pitch // from now on this._currentOffset = this.currentTime; this._startedAt = this._manager.context.currentTime; this._pitch = Math.max(Number(pitch) || 0, 0.01); if (this.source) { this.source.playbackRate.value = this._pitch; } } /** * Gets the pitch modifier to play the sound with. * * @type {number} */ get pitch() { return this._pitch; } /** * Sets the sound resource that the instance will play. * * @type {Sound} */ set sound(value) { this._sound = value; if (this._state !== STATE_STOPPED) { this.stop(); } else { this._createSource(); } } /** * Gets the sound resource that the instance will play. * * @type {Sound} */ get sound() { return this._sound; } /** * Sets the start time from which the sound will start playing. * * @type {number} */ set startTime(value) { this._startTime = Math.max(0, Number(value) || 0); // restart const isPlaying = this._state === STATE_PLAYING; this.stop(); if (isPlaying) { this.play(); } } /** * Gets the start time from which the sound will start playing. * * @type {number} */ get startTime() { return this._startTime; } /** * Sets the volume modifier to play the sound with. In range 0-1. * * @type {number} */ set volume(volume) { volume = math.clamp(volume, 0, 1); this._volume = volume; if (this.gain) { this.gain.gain.value = volume * this._manager.volume; } } /** * Gets the volume modifier to play the sound with. In range 0-1. * * @type {number} */ get volume() { return this._volume; } /** @private */ _onPlay() { this.fire('play'); if (this._onPlayCallback) { this._onPlayCallback(this); } } /** @private */ _onPause() { this.fire('pause'); if (this._onPauseCallback) { this._onPauseCallback(this); } } /** @private */ _onResume() { this.fire('resume'); if (this._onResumeCallback) { this._onResumeCallback(this); } } /** @private */ _onStop() { this.fire('stop'); if (this._onStopCallback) { this._onStopCallback(this); } } /** @private */ _onEnded() { // the callback is not fired synchronously // so only decrement _suspendEndEvent when the // callback is fired if (this._suspendEndEvent > 0) { this._suspendEndEvent--; return; } this.fire('end'); if (this._onEndCallback) { this._onEndCallback(this); } this.stop(); } /** * Handle the manager's 'volumechange' event. * * @private */ _onManagerVolumeChange() { this.volume = this._volume; } /** * Handle the manager's 'suspend' event. * * @private */ _onManagerSuspend() { if (this._state === STATE_PLAYING && !this._suspended) { this._suspended = true; this.pause(); } } /** * Handle the manager's 'resume' event. * * @private */ _onManagerResume() { if (this._suspended) { this._suspended = false; this.resume(); } } /** * Creates internal audio nodes and connects them. * * @private */ _initializeNodes() { // create gain node for volume control this.gain = this._manager.context.createGain(); this._inputNode = this.gain; // the gain node is also the connector node for 2D sound instances this._connectorNode = this.gain; this._connectorNode.connect(this._manager.context.destination); } /** * Attempt to begin playback the sound. * If the AudioContext is suspended, the audio will only start once it's resumed. * If the sound is already playing, this will restart the sound. * * @returns {boolean} True if the sound was started immediately. */ play() { if (this._state !== STATE_STOPPED) { this.stop(); } // set state to playing this._state = STATE_PLAYING; // no need for this anymore this._playWhenLoaded = false; // play() was already issued but hasn't actually started yet if (this._waitingContextSuspension) { return false; } // manager is suspended so audio cannot start now - wait for manager to resume if (this._manager.suspended) { this._manager.once('resume', this._playAudioImmediate, this); this._waitingContextSuspension = true; return false; } this._playAudioImmediate(); return true; } /** * Immediately play the sound. * This method assumes the AudioContext is ready (not suspended or locked). * * @private */ _playAudioImmediate() { this._waitingContextSuspension = false; // between play() and the manager being ready to play, a stop() or pause() call was made if (this._state !== STATE_PLAYING) { return; } if (!this.source) { this._createSource(); } // calculate start offset let offset = capTime(this._startOffset, this.duration); offset = capTime(this._startTime + offset, this._sound.duration); // reset start offset now that we started the sound this._startOffset = null; // start source with specified offset and duration if (this._duration) { this.source.start(0, offset, this._duration); } else { this.source.start(0, offset); } // reset times this._startedAt = this._manager.context.currentTime; this._currentTime = 0; this._currentOffset = offset; // Initialize volume and loop - note moved to be after start() because of Chrome bug this.volume = this._volume; this.loop = this._loop; this.pitch = this._pitch; // handle suspend events / volumechange events this._manager.on('volumechange', this._onManagerVolumeChange, this); this._manager.on('suspend', this._onManagerSuspend, this); this._manager.on('resume', this._onManagerResume, this); this._manager.on('destroy', this._onManagerDestroy, this); if (!this._suspendInstanceEvents) { this._onPlay(); } } /** * Pauses playback of sound. Call resume() to resume playback from the same position. * * @returns {boolean} Returns true if the sound was paused. */ pause() { // no need for this anymore this._playWhenLoaded = false; if (this._state !== STATE_PLAYING) { return false; } // set state to paused this._state = STATE_PAUSED; // play() was issued but hasn't actually started yet. if (this._waitingContextSuspension) { return true; } // store current time this._updateCurrentTime(); // Stop the source and re-create it because we cannot reuse the same source. // Suspend the end event as we are manually stopping the source this._suspendEndEvent++; this.source.stop(0); this.source = null; // reset user-set start offset this._startOffset = null; if (!this._suspendInstanceEvents) { this._onPause(); } return true; } /** * Resumes playback of the sound. Playback resumes at the point that the audio was paused. * * @returns {boolean} Returns true if the sound was resumed. */ resume() { if (this._state !== STATE_PAUSED) { return false; } // start at point where sound was paused let offset = this.currentTime; // set state back to playing this._state = STATE_PLAYING; // play() was issued but hasn't actually started yet if (this._waitingContextSuspension) { return true; } if (!this.source) { this._createSource(); } // if the user set the 'currentTime' property while the sound // was paused then use that as the offset instead if (this._startOffset !== null) { offset = capTime(this._startOffset, this.duration); offset = capTime(this._startTime + offset, this._sound.duration); // reset offset this._startOffset = null; } // start source if (this._duration) { this.source.start(0, offset, this._duration); } else { this.source.start(0, offset); } this._startedAt = this._manager.context.currentTime; this._currentOffset = offset; // Initialize parameters this.volume = this._volume; this.loop = this._loop; this.pitch = this._pitch; this._playWhenLoaded = false; if (!this._suspendInstanceEvents) { this._onResume(); } return true; } /** * Stops playback of sound. Calling play() again will restart playback from the beginning of * the sound. * * @returns {boolean} Returns true if the sound was stopped. */ stop() { this._playWhenLoaded = false; if (this._state === STATE_STOPPED) { return false; } // set state to stopped const wasPlaying = this._state === STATE_PLAYING; this._state = STATE_STOPPED; // play() was issued but hasn't actually started yet if (this._waitingContextSuspension) { return true; } // unsubscribe from manager events this._manager.off('volumechange', this._onManagerVolumeChange, this); this._manager.off('suspend', this._onManagerSuspend, this); this._manager.off('resume', this._onManagerResume, this); this._manager.off('destroy', this._onManagerDestroy, this); // reset stored times this._startedAt = 0; this._currentTime = 0; this._currentOffset = 0; this._startOffset = null; this._suspendEndEvent++; if (wasPlaying && this.source) { this.source.stop(0); } this.source = null; if (!this._suspendInstanceEvents) { this._onStop(); } return true; } /** * Connects external Web Audio API nodes. You need to pass the first node of the node graph * that you created externally and the last node of that graph. The first node will be * connected to the audio source and the last node will be connected to the destination of the * AudioContext (e.g. speakers). Requires Web Audio API support. * * @param {AudioNode} firstNode - The first node that will be connected to the audio source of sound instances. * @param {AudioNode} [lastNode] - The last node that will be connected to the destination of the AudioContext. * If unspecified then the firstNode will be connected to the destination instead. * @example * const context = app.systems.sound.context; * const analyzer = context.createAnalyzer(); * const distortion = context.createWaveShaper(); * const filter = context.createBiquadFilter(); * analyzer.connect(distortion); * distortion.connect(filter); * instance.setExternalNodes(analyzer, filter); */ setExternalNodes(firstNode, lastNode) { if (!firstNode) { console.error('The firstNode must be a valid Audio Node'); return; } if (!lastNode) { lastNode = firstNode; } // connections are: // source -> inputNode -> connectorNode -> [firstNode -> ... -> lastNode] -> speakers const speakers = this._manager.context.destination; if (this._firstNode !== firstNode) { if (this._firstNode) { // if firstNode already exists means the connector node // is connected to it so disconnect it this._connectorNode.disconnect(this._firstNode); } else { // if firstNode does not exist means that its connected // to the speakers so disconnect it this._connectorNode.disconnect(speakers); } // set first node and connect with connector node this._firstNode = firstNode; this._connectorNode.connect(firstNode); } if (this._lastNode !== lastNode) { if (this._lastNode) { // if last node exists means it's connected to the speakers so disconnect it this._lastNode.disconnect(speakers); } // set last node and connect with speakers this._lastNode = lastNode; this._lastNode.connect(speakers); } } /** * Clears any external nodes set by {@link SoundInstance#setExternalNodes}. */ clearExternalNodes() { const speakers = this._manager.context.destination; // break existing connections if (this._firstNode) { this._connectorNode.disconnect(this._firstNode); this._firstNode = null; } if (this._lastNode) { this._lastNode.disconnect(speakers); this._lastNode = null; } // reset connect to speakers this._connectorNode.connect(speakers); } /** * Gets any external nodes set by {@link SoundInstance#setExternalNodes}. * * @returns {AudioNode[]} Returns an array that contains the two nodes set by * {@link SoundInstance#setExternalNodes}. */ getExternalNodes() { return [ this._firstNode, this._lastNode ]; } /** * Creates the source for the instance. * * @returns {AudioBufferSourceNode|null} Returns the created source or null if the sound * instance has no {@link Sound} associated with it. * @private */ _createSource() { if (!this._sound) { return null; } const context = this._manager.context; if (this._sound.buffer) { this.source = context.createBufferSource(); this.source.buffer = this._sound.buffer; // Connect up the nodes this.source.connect(this._inputNode); // set events this.source.onended = this._endedHandler; // set loopStart and loopEnd so that the source starts and ends at the correct user-set times this.source.loopStart = capTime(this._startTime, this.source.buffer.duration); if (this._duration) { this.source.loopEnd = Math.max(this.source.loopStart, capTime(this._startTime + this._duration, this.source.buffer.duration)); } } return this.source; } /** * Sets the current time taking into account the time the instance started playing, the current * pitch and the current time offset. * * @private */ _updateCurrentTime() { this._currentTime = capTime((this._manager.context.currentTime - this._startedAt) * this._pitch + this._currentOffset, this.duration); } /** * Handle the manager's 'destroy' event. * * @private */ _onManagerDestroy() { if (this.source && this._state === STATE_PLAYING) { this.source.stop(0); this.source = null; } } } if (!hasAudioContext()) { Object.assign(SoundInstance.prototype, { play: function() { if (this._state !== STATE_STOPPED) { this.stop(); } if (!this.source) { if (!this._createSource()) { return false; } } this.volume = this._volume; this.pitch = this._pitch; this.loop = this._loop; this.source.play(); this._state = STATE_PLAYING; this._playWhenLoaded = false; this._manager.on('volumechange', this._onManagerVolumeChange, this); this._manager.on('suspend', this._onManagerSuspend, this); this._manager.on('resume', this._onManagerResume, this); this._manager.on('destroy', this._onManagerDestroy, this); // suspend immediately if manager is suspended if (this._manager.suspended) { this._onManagerSuspend(); } if (!this._suspendInstanceEvents) { this._onPlay(); } return true; }, pause: function() { if (!this.source || this._state !== STATE_PLAYING) { return false; } this._suspendEndEvent++; this.source.pause(); this._playWhenLoaded = false; this._state = STATE_PAUSED; this._startOffset = null; if (!this._suspendInstanceEvents) { this._onPause(); } return true; }, resume: function() { if (!this.source || this._state !== STATE_PAUSED) { return false; } this._state = STATE_PLAYING; this._playWhenLoaded = false; if (this.source.paused) { this.source.play(); if (!this._suspendInstanceEvents) { this._onResume(); } } return true; }, stop: function() { if (!this.source || this._state === STATE_STOPPED) { return false; } this._manager.off('volumechange', this._onManagerVolumeChange, this); this._manager.off('suspend', this._onManagerSuspend, this); this._manager.off('resume', this._onManagerResume, this); this._manager.off('destroy', this._onManagerDestroy, this); this._suspendEndEvent++; this.source.pause(); this._playWhenLoaded = false; this._state = STATE_STOPPED; this._startOffset = null; if (!this._suspendInstanceEvents) { this._onStop(); } return true; }, setExternalNodes: function() { // not supported }, clearExternalNodes: function() { // not supported }, getExternalNodes: function() { // not supported but return same type of result return [ null, null ]; }, // Sets start time after loadedmetadata is fired which is required by most browsers _onLoadedMetadata: function() { this.source.removeEventListener('loadedmetadata', this._loadedMetadataHandler); this._isReady = true; // calculate start time for source let offset = capTime(this._startOffset, this.duration); offset = capTime(this._startTime + offset, this._sound.duration); // reset currentTime this._startOffset = null; // set offset on source this.source.currentTime = offset; }, _createSource: function() { if (this._sound && this._sound.audio) { this._isReady = false; this.source = this._sound.audio.cloneNode(true); // set events this.source.addEventListener('loadedmetadata', this._loadedMetadataHandler); this.source.addEventListener('timeupdate', this._timeUpdateHandler); this.source.onended = this._endedHandler; } return this.source; }, // called every time the 'currentTime' is changed _onTimeUpdate: function() { if (!this._duration) { return; } // if the currentTime passes the end then if looping go back to the beginning // otherwise manually stop if (this.source.currentTime > capTime(this._startTime + this._duration, this.source.duration)) { if (this.loop) { this.source.currentTime = capTime(this._startTime, this.source.duration); } else { // remove listener to prevent multiple calls this.source.removeEventListener('timeupdate', this._timeUpdateHandler); this.source.pause(); // call this manually because it doesn't work in all browsers in this case this._onEnded(); } } }, _onManagerDestroy: function() { if (this.source) { this.source.pause(); } } }); Object.defineProperty(SoundInstance.prototype, 'volume', { get: function() { return this._volume; }, set: function(volume) { volume = math.clamp(volume, 0, 1); this._volume = volume; if (this.source) { this.source.volume = volume * this._manager.volume; } } }); Object.defineProperty(SoundInstance.prototype, 'pitch', { get: function() { return this._pitch; }, set: function(pitch) { this._pitch = Math.max(Number(pitch) || 0, 0.01); if (this.source) { this.source.playbackRate = this._pitch; } } }); Object.defineProperty(SoundInstance.prototype, 'sound', { get: function() { return this._sound; }, set: function(value) { this.stop(); this._sound = value; } }); Object.defineProperty(SoundInstance.prototype, 'currentTime', { get: function() { if (this._startOffset !== null) { return this._startOffset; } if (this._state === STATE_STOPPED || !this.source) { return 0; } return this.source.currentTime - this._startTime; }, set: function(value) { if (value < 0) return; this._startOffset = value; if (this.source && this._isReady) { this.source.currentTime = capTime(this._startTime + capTime(value, this.duration), this._sound.duration); this._startOffset = null; } } }); } export { SoundInstance };