UNPKG

playcanvas

Version:

Open-source WebGL/WebGPU 3D engine for the web

689 lines (688 loc) 19.3 kB
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 };