playcanvas
Version:
Open-source WebGL/WebGPU 3D engine for the web
689 lines (688 loc) • 19.3 kB
JavaScript
var __defProp = Object.defineProperty;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
import { EventHandler } from "../../core/event-handler.js";
import { math } from "../../core/math/math.js";
const STATE_PLAYING = 0;
const STATE_PAUSED = 1;
const STATE_STOPPED = 2;
function capTime(time, duration) {
return time % duration || 0;
}
class SoundInstance extends EventHandler {
/**
* 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. Source is only available after calling play.
*
* @type {AudioBufferSourceNode|null}
*/
__publicField(this, "source", null);
this._manager = manager;
this._volume = options.volume !== void 0 ? math.clamp(Number(options.volume) || 0, 0, 1) : 1;
this._pitch = options.pitch !== void 0 ? Math.max(0.01, Number(options.pitch) || 0) : 1;
this._loop = !!(options.loop !== void 0 ? options.loop : false);
this._sound = sound;
this._state = STATE_STOPPED;
this._suspended = false;
this._suspendEndEvent = 0;
this._suspendInstanceEvents = false;
this._playWhenLoaded = true;
this._startTime = Math.max(0, Number(options.startTime) || 0);
this._duration = Math.max(0, Number(options.duration) || 0);
this._startOffset = null;
this._onPlayCallback = options.onPlay;
this._onPauseCallback = options.onPause;
this._onResumeCallback = options.onResume;
this._onStopCallback = options.onStop;
this._onEndCallback = options.onEnd;
this._startedAt = 0;
this._currentTime = 0;
this._currentOffset = 0;
this._inputNode = null;
this._connectorNode = null;
this._firstNode = null;
this._lastNode = null;
this._waitingContextSuspension = false;
if (!this._manager.context) {
return;
}
this._initializeNodes();
this._endedHandler = this._onEnded.bind(this);
}
/**
* 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;
this.stop();
this._startOffset = value;
this.play();
this._suspendInstanceEvents = suspend;
} else {
this._startOffset = value;
this._currentTime = value;
}
}
/**
* Gets the current time of the sound that is playing.
*
* @type {number}
*/
get currentTime() {
if (this._startOffset !== null) {
return this._startOffset;
}
if (this._state === STATE_PAUSED) {
return this._currentTime;
}
if (this._state === STATE_STOPPED || !this.source) {
return 0;
}
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);
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) {
if (this._manager.context) {
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);
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() {
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() {
this.gain = this._manager.context.createGain();
this._inputNode = this.gain;
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._manager.context) {
return false;
}
if (this._state !== STATE_STOPPED) {
this.stop();
}
this._state = STATE_PLAYING;
this._playWhenLoaded = false;
if (this._waitingContextSuspension) {
return false;
}
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;
if (this._state !== STATE_PLAYING) {
return;
}
if (!this.source) {
this._createSource();
}
let offset = capTime(this._startOffset, this.duration);
offset = capTime(this._startTime + offset, this._sound.duration);
this._startOffset = null;
if (this._duration) {
this.source.start(0, offset, this._duration);
} else {
this.source.start(0, offset);
}
this._startedAt = this._manager.context.currentTime;
this._currentTime = 0;
this._currentOffset = offset;
this.volume = this._volume;
this.loop = this._loop;
this.pitch = this._pitch;
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() {
this._playWhenLoaded = false;
if (this._state !== STATE_PLAYING) {
return false;
}
this._state = STATE_PAUSED;
if (this._waitingContextSuspension) {
return true;
}
this._updateCurrentTime();
this._suspendEndEvent++;
this.source.stop(0);
this.source = null;
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;
}
let offset = this.currentTime;
this._state = STATE_PLAYING;
if (this._waitingContextSuspension) {
return true;
}
if (!this.source) {
this._createSource();
}
if (this._startOffset !== null) {
offset = capTime(this._startOffset, this.duration);
offset = capTime(this._startTime + offset, this._sound.duration);
this._startOffset = null;
}
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;
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;
}
const wasPlaying = this._state === STATE_PLAYING;
this._state = STATE_STOPPED;
if (this._waitingContextSuspension) {
return true;
}
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._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 (!this._connectorNode) {
return;
}
if (!lastNode) {
lastNode = firstNode;
}
const speakers = this._manager.context.destination;
if (this._firstNode !== firstNode) {
if (this._firstNode) {
this._connectorNode.disconnect(this._firstNode);
} else {
this._connectorNode.disconnect(speakers);
}
this._firstNode = firstNode;
this._connectorNode.connect(firstNode);
}
if (this._lastNode !== lastNode) {
if (this._lastNode) {
this._lastNode.disconnect(speakers);
}
this._lastNode = lastNode;
this._lastNode.connect(speakers);
}
}
/**
* Clears any external nodes set by {@link setExternalNodes}.
*/
clearExternalNodes() {
if (!this._connectorNode) {
return;
}
const speakers = this._manager.context.destination;
if (this._firstNode) {
this._connectorNode.disconnect(this._firstNode);
this._firstNode = null;
}
if (this._lastNode) {
this._lastNode.disconnect(speakers);
this._lastNode = null;
}
this._connectorNode.connect(speakers);
}
/**
* Gets any external nodes set by {@link setExternalNodes}.
*
* @returns {AudioNode[]} Returns an array that contains the two nodes set by
* {@link 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 (!context) {
return null;
}
if (this._sound.buffer) {
this.source = context.createBufferSource();
this.source.buffer = this._sound.buffer;
this.source.connect(this._inputNode);
this.source.onended = this._endedHandler;
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;
}
}
}
/**
* Fired when the instance starts playing its source.
*
* @event
* @example
* instance.on('play', () => {
* console.log('Instance started playing');
* });
*/
__publicField(SoundInstance, "EVENT_PLAY", "play");
/**
* Fired when the instance is paused.
*
* @event
* @example
* instance.on('pause', () => {
* console.log('Instance paused');
* });
*/
__publicField(SoundInstance, "EVENT_PAUSE", "pause");
/**
* Fired when the instance is resumed.
*
* @event
* @example
* instance.on('resume', () => {
* console.log('Instance resumed');
* });
*/
__publicField(SoundInstance, "EVENT_RESUME", "resume");
/**
* Fired when the instance is stopped.
*
* @event
* @example
* instance.on('stop', () => {
* console.log('Instance stopped');
* });
*/
__publicField(SoundInstance, "EVENT_STOP", "stop");
/**
* Fired when the sound currently played by the instance ends.
*
* @event
* @example
* instance.on('end', () => {
* console.log('Instance ended');
* });
*/
__publicField(SoundInstance, "EVENT_END", "end");
export {
SoundInstance
};