UNPKG

playcanvas

Version:

PlayCanvas WebGL game engine

663 lines (660 loc) 22.5 kB
import { EventHandler } from '../../../core/event-handler.js'; import { Debug } from '../../../core/debug.js'; import { math } from '../../../core/math/math.js'; import { Vec3 } from '../../../core/math/vec3.js'; import { Asset } from '../../asset/asset.js'; import { SoundInstance } from '../../../platform/sound/instance.js'; import { SoundInstance3d } from '../../../platform/sound/instance3d.js'; /** * @import { SoundComponent } from './component.js' */ // temporary object for creating instances const instanceOptions = { volume: 0, pitch: 0, loop: false, startTime: 0, duration: 0, position: new Vec3(), maxDistance: 0, refDistance: 0, rollOffFactor: 0, distanceModel: 0, onPlay: null, onPause: null, onResume: null, onStop: null, onEnd: null }; /** * The SoundSlot controls the playback of {@link SoundInstance}s. SoundSlots are managed by * {@link SoundComponent}s. To add and remove SoundSlots on a SoundComponent, use * {@link SoundComponent#addSlot} and {@link SoundComponent#removeSlot} respectively. * * @hideconstructor * @category Sound */ class SoundSlot extends EventHandler { static{ /** * Fired when a {@link SoundInstance} starts playing on a slot. The handler is passed the sound * instance that started playing. * * @event * @example * slot.on('play', (instance) => { * console.log('Sound instance started playing'); * }); */ this.EVENT_PLAY = 'play'; } static{ /** * Fired when a {@link SoundInstance} is paused on a slot. The handler is passed the sound * instance that is paused. * * @event * @example * slot.on('pause', (instance) => { * console.log('Sound instance paused'); * }); */ this.EVENT_PAUSE = 'pause'; } static{ /** * Fired when a {@link SoundInstance} is resumed on a slot. The handler is passed the sound * instance that is resumed. * * @event * @example * slot.on('resume', (instance) => { * console.log('Sound instance resumed'); * }); */ this.EVENT_RESUME = 'resume'; } static{ /** * Fired when a {@link SoundInstance} is stopped on a slot. The handler is passed the sound * instance that is stopped. * * @event * @example * slot.on('stop', (instance) => { * console.log('Sound instance stopped'); * }); */ this.EVENT_STOP = 'stop'; } static{ /** * Fired when a sound instance stops playing because it reached its end. The handler is passed * the {@link SoundInstance} that ended. * * @event * @example * slot.on('end', (instance) => { * console.log('Sound instance playback ended'); * }); */ this.EVENT_END = 'end'; } static{ /** * Fired when the sound {@link Asset} assigned to the slot is loaded. The handler is passed the * loaded {@link Sound} resource. * * @event * @example * slot.on('load', (sound) => { * console.log('Sound resource loaded'); * }); */ this.EVENT_LOAD = 'load'; } /** * Create a new SoundSlot. * * @param {SoundComponent} component - The Component that created this slot. * @param {string} [name] - The name of the slot. Defaults to 'Untitled'. * @param {object} [options] - Settings for the slot. * @param {number} [options.volume] - The playback volume, between 0 and 1. * @param {number} [options.pitch] - The relative pitch, default of 1, plays at normal pitch. * @param {boolean} [options.loop] - If true, the sound will restart when it reaches the end. * @param {number} [options.startTime] - The start time from which the sound will start * playing. * @param {number} [options.duration] - The duration of the sound that the slot will play * starting from startTime. * @param {boolean} [options.overlap] - If true, then sounds played from slot will be played * independently of each other. Otherwise the slot will first stop the current sound before * starting the new one. * @param {boolean} [options.autoPlay] - If true, the slot will start playing as soon as its * audio asset is loaded. * @param {number} [options.asset] - The asset id of the audio asset that is going to be played * by this slot. */ constructor(component, name = 'Untitled', options = {}){ super(), /** * An array that contains all the {@link SoundInstance}s currently being played by the slot. * * @type {SoundInstance[]} */ this.instances = []; this._component = component; this._assets = component.system.app.assets; this._manager = component.system.manager; this.name = name; this._volume = options.volume !== undefined ? math.clamp(Number(options.volume) || 0, 0, 1) : 1; this._pitch = options.pitch !== undefined ? Math.max(0.01, Number(options.pitch) || 0) : 1; this._loop = !!(options.loop !== undefined ? options.loop : false); this._duration = options.duration > 0 ? options.duration : null; this._startTime = Math.max(0, Number(options.startTime) || 0); this._overlap = !!options.overlap; this._autoPlay = !!options.autoPlay; this._firstNode = null; this._lastNode = null; this._asset = options.asset; if (this._asset instanceof Asset) { this._asset = this._asset.id; } this._onInstancePlayHandler = this._onInstancePlay.bind(this); this._onInstancePauseHandler = this._onInstancePause.bind(this); this._onInstanceResumeHandler = this._onInstanceResume.bind(this); this._onInstanceStopHandler = this._onInstanceStop.bind(this); this._onInstanceEndHandler = this._onInstanceEnd.bind(this); } /** * Plays a sound. If {@link overlap} is true the new sound instance will be played * independently of any other instances already playing. Otherwise existing sound instances * will stop before playing the new sound. * * @returns {SoundInstance} The new sound instance. */ play() { // stop if overlap is false if (!this.overlap) { this.stop(); } // If not loaded and doesn't have asset - then we cannot play it. Warn and exit. if (!this.isLoaded && !this._hasAsset()) { Debug.warn(`Trying to play SoundSlot ${this.name} but it is not loaded and doesn't have an asset.`); return undefined; } const instance = this._createInstance(); this.instances.push(instance); // if not loaded then load first // and then set sound resource on the created instance if (!this.isLoaded) { const onLoad = function(sound) { const playWhenLoaded = instance._playWhenLoaded; instance.sound = sound; if (playWhenLoaded) { instance.play(); } }; this.off('load', onLoad); this.once('load', onLoad); this.load(); } else { instance.play(); } return instance; } /** * Pauses all sound instances. To continue playback call {@link resume}. * * @returns {boolean} True if the sound instances paused successfully, false otherwise. */ pause() { let paused = false; const instances = this.instances; for(let i = 0, len = instances.length; i < len; i++){ if (instances[i].pause()) { paused = true; } } return paused; } /** * Resumes playback of all paused sound instances. * * @returns {boolean} True if any instances were resumed. */ resume() { let resumed = false; const instances = this.instances; for(let i = 0, len = instances.length; i < len; i++){ if (instances[i].resume()) { resumed = true; } } return resumed; } /** * Stops playback of all sound instances. * * @returns {boolean} True if any instances were stopped. */ stop() { let stopped = false; const instances = this.instances; let i = instances.length; // do this in reverse order because as each instance // is stopped it will be removed from the instances array // by the instance stop event handler while(i--){ instances[i].stop(); stopped = true; } instances.length = 0; return stopped; } /** * Loads the asset assigned to this slot. */ load() { if (!this._hasAsset()) { return; } const asset = this._assets.get(this._asset); if (!asset) { this._assets.off(`add:${this._asset}`, this._onAssetAdd, this); this._assets.once(`add:${this._asset}`, this._onAssetAdd, this); return; } asset.off('remove', this._onAssetRemoved, this); asset.on('remove', this._onAssetRemoved, this); if (!asset.resource) { asset.off('load', this._onAssetLoad, this); asset.once('load', this._onAssetLoad, this); this._assets.load(asset); return; } this.fire('load', asset.resource); } /** * Connect external Web Audio API nodes. Any sound played by this slot will automatically * attach the specified nodes to the source that plays the sound. 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). * * @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); * slot.setExternalNodes(analyzer, filter); */ setExternalNodes(firstNode, lastNode) { if (!firstNode) { console.error('The firstNode must have a valid AudioNode'); return; } if (!lastNode) { lastNode = firstNode; } this._firstNode = firstNode; this._lastNode = lastNode; // update instances if not overlapping if (!this._overlap) { const instances = this.instances; for(let i = 0, len = instances.length; i < len; i++){ instances[i].setExternalNodes(firstNode, lastNode); } } } /** * Clears any external nodes set by {@link setExternalNodes}. */ clearExternalNodes() { this._firstNode = null; this._lastNode = null; // update instances if not overlapping if (!this._overlap) { const instances = this.instances; for(let i = 0, len = instances.length; i < len; i++){ instances[i].clearExternalNodes(); } } } /** * Gets an array that contains the two external nodes set by {@link setExternalNodes}. * * @returns {AudioNode[]} An array of 2 elements that contains the first and last nodes set by * {@link setExternalNodes}. */ getExternalNodes() { return [ this._firstNode, this._lastNode ]; } /** * Reports whether an asset is set on this slot. * * @returns {boolean} Returns true if the slot has an asset assigned. * @private */ _hasAsset() { // != intentional return this._asset != null; } /** * Creates a new {@link SoundInstance} with the properties of the slot. * * @returns {SoundInstance} The new instance. * @private */ _createInstance() { let instance = null; const component = this._component; let sound = null; // get sound resource if (this._hasAsset()) { const asset = this._assets.get(this._asset); if (asset) { sound = asset.resource; } } // initialize instance options const data = instanceOptions; data.volume = this._volume * component.volume; data.pitch = this._pitch * component.pitch; data.loop = this._loop; data.startTime = this._startTime; data.duration = this._duration; data.onPlay = this._onInstancePlayHandler; data.onPause = this._onInstancePauseHandler; data.onResume = this._onInstanceResumeHandler; data.onStop = this._onInstanceStopHandler; data.onEnd = this._onInstanceEndHandler; if (component.positional) { data.position.copy(component.entity.getPosition()); data.maxDistance = component.maxDistance; data.refDistance = component.refDistance; data.rollOffFactor = component.rollOffFactor; data.distanceModel = component.distanceModel; instance = new SoundInstance3d(this._manager, sound, data); } else { instance = new SoundInstance(this._manager, sound, data); } // hook external audio nodes if (this._firstNode) { instance.setExternalNodes(this._firstNode, this._lastNode); } return instance; } _onInstancePlay(instance) { // propagate event to slot this.fire('play', instance); // propagate event to component this._component.fire('play', this, instance); } _onInstancePause(instance) { // propagate event to slot this.fire('pause', instance); // propagate event to component this._component.fire('pause', this, instance); } _onInstanceResume(instance) { // propagate event to slot this.fire('resume', instance); // propagate event to component this._component.fire('resume', this, instance); } _onInstanceStop(instance) { // remove instance that stopped const idx = this.instances.indexOf(instance); if (idx !== -1) { this.instances.splice(idx, 1); } // propagate event to slot this.fire('stop', instance); // propagate event to component this._component.fire('stop', this, instance); } _onInstanceEnd(instance) { // remove instance that ended const idx = this.instances.indexOf(instance); if (idx !== -1) { this.instances.splice(idx, 1); } // propagate event to slot this.fire('end', instance); // propagate event to component this._component.fire('end', this, instance); } _onAssetAdd(asset) { this.load(); } _onAssetLoad(asset) { this.load(); } _onAssetRemoved(asset) { asset.off('remove', this._onAssetRemoved, this); this._assets.off(`add:${asset.id}`, this._onAssetAdd, this); this.stop(); } updatePosition(position) { const instances = this.instances; for(let i = 0, len = instances.length; i < len; i++){ instances[i].position = position; } } /** * Sets the asset id. * * @type {number|null} */ set asset(value) { const old = this._asset; if (old) { this._assets.off(`add:${old}`, this._onAssetAdd, this); const oldAsset = this._assets.get(old); if (oldAsset) { oldAsset.off('remove', this._onAssetRemoved, this); } } this._asset = value; if (this._asset instanceof Asset) { this._asset = this._asset.id; } // load asset if component and entity are enabled if (this._hasAsset() && this._component.enabled && this._component.entity.enabled) { this.load(); } } /** * Gets the asset id. * * @type {number|null} */ get asset() { return this._asset; } /** * Sets whether the slot will begin playing as soon as it is loaded. * * @type {boolean} */ set autoPlay(value) { this._autoPlay = !!value; } /** * Gets whether the slot will begin playing as soon as it is loaded. * * @type {boolean} */ get autoPlay() { return this._autoPlay; } /** * Sets the duration of the sound that the slot will play starting from startTime. * * @type {number} */ set duration(value) { this._duration = Math.max(0, Number(value) || 0) || null; // update instances if non overlapping if (!this._overlap) { const instances = this.instances; for(let i = 0, len = instances.length; i < len; i++){ instances[i].duration = this._duration; } } } /** * Gets the duration of the sound that the slot will play starting from startTime. * * @type {number} */ get duration() { let assetDuration = 0; if (this._hasAsset()) { const asset = this._assets.get(this._asset); assetDuration = asset?.resource ? asset.resource.duration : 0; } // != intentional if (this._duration != null) { return this._duration % (assetDuration || 1); } return assetDuration; } /** * Gets whether the asset of the slot is loaded. * * @type {boolean} */ get isLoaded() { if (this._hasAsset()) { const asset = this._assets.get(this._asset); if (asset) { return !!asset.resource; } } return false; } /** * Gets whether the slot is currently paused. * * @type {boolean} */ get isPaused() { const instances = this.instances; const len = instances.length; if (len === 0) { return false; } for(let i = 0; i < len; i++){ if (!instances[i].isPaused) { return false; } } return true; } /** * Gets whether the slot is currently playing. * * @type {boolean} */ get isPlaying() { const instances = this.instances; for(let i = 0, len = instances.length; i < len; i++){ if (instances[i].isPlaying) { return true; } } return false; } /** * Gets whether the slot is currently stopped. * * @type {boolean} */ get isStopped() { const instances = this.instances; for(let i = 0, len = instances.length; i < len; i++){ if (!instances[i].isStopped) { return false; } } return true; } /** * Sets whether the slot will restart when it finishes playing. * * @type {boolean} */ set loop(value) { this._loop = !!value; // update instances if non overlapping const instances = this.instances; for(let i = 0, len = instances.length; i < len; i++){ instances[i].loop = this._loop; } } /** * Gets whether the slot will restart when it finishes playing. * * @type {boolean} */ get loop() { return this._loop; } /** * Sets whether the sounds played from this slot will be played independently of each other. * Otherwise, the slot will first stop the current sound before starting the new one. * * @type {boolean} */ set overlap(value) { this._overlap = !!value; } /** * Gets whether the sounds played from this slot will be played independently of each other. * * @type {boolean} */ get overlap() { return this._overlap; } /** * Sets the pitch modifier to play the sound with. Must be larger than 0.01. * * @type {number} */ set pitch(value) { this._pitch = Math.max(Number(value) || 0, 0.01); // update instances if non overlapping if (!this._overlap) { const instances = this.instances; for(let i = 0, len = instances.length; i < len; i++){ instances[i].pitch = this.pitch * this._component.pitch; } } } /** * Gets the pitch modifier to play the sound with. * * @type {number} */ get pitch() { return this._pitch; } /** * Sets the start time from which the sound will start playing. * * @type {number} */ set startTime(value) { this._startTime = Math.max(0, Number(value) || 0); // update instances if non overlapping if (!this._overlap) { const instances = this.instances; for(let i = 0, len = instances.length; i < len; i++){ instances[i].startTime = this._startTime; } } } /** * 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(value) { this._volume = math.clamp(Number(value) || 0, 0, 1); // update instances if non overlapping if (!this._overlap) { const instances = this.instances; for(let i = 0, len = instances.length; i < len; i++){ instances[i].volume = this._volume * this._component.volume; } } } /** * Gets the volume modifier to play the sound with. * * @type {number} */ get volume() { return this._volume; } } export { SoundSlot };