UNPKG

playcanvas

Version:

PlayCanvas WebGL game engine

589 lines (586 loc) 21.1 kB
import { Debug } from '../../../core/debug.js'; import { DISTANCE_LINEAR } from '../../../platform/sound/constants.js'; import { Component } from '../component.js'; import { SoundSlot } from './slot.js'; /** * @import { Entity } from '../../entity.js' * @import { SoundInstance } from '../../../platform/sound/instance.js' */ /** * The SoundComponent enables an {@link Entity} to play audio. The SoundComponent can manage * multiple {@link SoundSlot}s, each of which can play a different audio asset with its own set * of properties such as volume, pitch, and looping behavior. * * The SoundComponent supports positional audio, meaning that the sound can be played relative * to the Entity's position in 3D space. This is useful for creating immersive audio experiences * where the sound's volume and panning are affected by the listener's position and orientation. * Positional audio requires that an Entity with an {@link AudioListenerComponent} be added to the * scene. * * You should never need to use the SoundComponent constructor directly. To add a SoundComponent * to an Entity, use {@link Entity#addComponent}: * * ```javascript * const entity = new pc.Entity(); * entity.addComponent('sound', { * volume: 0.8, * positional: true * }); * ``` * * Once the SoundComponent is added to the entity, you can access it via the {@link Entity#sound} * property: * * ```javascript * entity.sound.volume = 0.9; // Set the volume for all sounds * * console.log(entity.sound.volume); // Get the volume and print it * ``` * * Add individual sounds by creating sound slots on the component: * * ```javascript * entity.sound.addSlot('beep', { * asset: asset * }); * ``` * * Relevant Engine API examples: * * - [Positional Sound](https://playcanvas.github.io/#/sound/positional) * * @hideconstructor * @category Sound */ class SoundComponent extends Component { static{ /** * Fired when a sound instance starts playing. The handler is passed the {@link SoundSlot} and * the {@link SoundInstance} that started playing. * * @event * @example * entity.sound.on('play', (slot, instance) => { * console.log(`Sound ${slot.name} started playing`); * }); */ this.EVENT_PLAY = 'play'; } static{ /** * Fired when a sound instance is paused. The handler is passed the {@link SoundSlot} and the * {@link SoundInstance} that was paused. * * @event * @example * entity.sound.on('pause', (slot, instance) => { * console.log(`Sound ${slot.name} paused`); * }); */ this.EVENT_PAUSE = 'pause'; } static{ /** * Fired when a sound instance is resumed. The handler is passed the {@link SoundSlot} and the * {@link SoundInstance} that was resumed. * * @event * @example * entity.sound.on('resume', (slot, instance) => { * console.log(`Sound ${slot.name} resumed`); * }); */ this.EVENT_RESUME = 'resume'; } static{ /** * Fired when a sound instance is stopped. The handler is passed the {@link SoundSlot} and the * {@link SoundInstance} that was stopped. * * @event * @example * entity.sound.on('stop', (slot, instance) => { * console.log(`Sound ${slot.name} stopped`); * }); */ this.EVENT_STOP = 'stop'; } static{ /** * Fired when a sound instance stops playing because it reached its end. The handler is passed * the {@link SoundSlot} and the {@link SoundInstance} that ended. * * @event * @example * entity.sound.on('end', (slot, instance) => { * console.log(`Sound ${slot.name} ended`); * }); */ this.EVENT_END = 'end'; } /** * Update the specified property on all sound instances. * * @param {string} property - The name of the SoundInstance property to update. * @param {string|number} value - The value to set the property to. * @param {boolean} isFactor - True if the value is a factor of the slot property or false * if it is an absolute value. * @private */ _updateSoundInstances(property, value, isFactor) { const slots = this._slots; for(const key in slots){ const slot = slots[key]; // only change value of non-overlapping instances if (!slot.overlap) { const instances = slot.instances; for(let i = 0, len = instances.length; i < len; i++){ instances[i][property] = isFactor ? slot[property] * value : value; } } } } /** * Sets which algorithm to use to reduce the volume of the sound as it moves away from the * listener. Can be: * * - {@link DISTANCE_LINEAR} * - {@link DISTANCE_INVERSE} * - {@link DISTANCE_EXPONENTIAL} * * Defaults to {@link DISTANCE_LINEAR}. * * @type {string} */ set distanceModel(value) { this._distanceModel = value; this._updateSoundInstances('distanceModel', value, false); } /** * Gets which algorithm to use to reduce the volume of the sound as it moves away from the * listener. * * @type {string} */ get distanceModel() { return this._distanceModel; } /** * Sets the maximum distance from the listener at which audio falloff stops. Note that the * volume of the audio is not 0 after this distance, but just doesn't fall off anymore. * Defaults to 10000. * * @type {number} */ set maxDistance(value) { this._maxDistance = value; this._updateSoundInstances('maxDistance', value, false); } /** * Gets the maximum distance from the listener at which audio falloff stops. * * @type {number} */ get maxDistance() { return this._maxDistance; } /** * Sets the reference distance for reducing volume as the sound source moves further from the * listener. Defaults to 1. * * @type {number} */ set refDistance(value) { this._refDistance = value; this._updateSoundInstances('refDistance', value, false); } /** * Gets the reference distance for reducing volume as the sound source moves further from the * listener. * * @type {number} */ get refDistance() { return this._refDistance; } /** * Sets the factor used in the falloff equation. Defaults to 1. * * @type {number} */ set rollOffFactor(value) { this._rollOffFactor = value; this._updateSoundInstances('rollOffFactor', value, false); } /** * Gets the factor used in the falloff equation. * * @type {number} */ get rollOffFactor() { return this._rollOffFactor; } /** * Sets the pitch modifier to play the audio with. Must be larger than 0.01. Defaults to 1. * * @type {number} */ set pitch(value) { this._pitch = value; this._updateSoundInstances('pitch', value, true); } /** * Gets the pitch modifier to play the audio with. * * @type {number} */ get pitch() { return this._pitch; } /** * Sets the volume modifier to play the audio with. In range 0-1. Defaults to 1. * * @type {number} */ set volume(value) { this._volume = value; this._updateSoundInstances('volume', value, true); } /** * Gets the volume modifier to play the audio with. * * @type {number} */ get volume() { return this._volume; } /** * Sets whether the component plays positional sound. If true, the audio will play back at the * location of the Entity in space, so the audio will be affected by the position of the * {@link AudioListenerComponent}. Defaults to true. * * @type {boolean} */ set positional(newValue) { this._positional = newValue; const slots = this._slots; for(const key in slots){ const slot = slots[key]; // recreate non overlapping sounds if (!slot.overlap) { const instances = slot.instances; const oldLength = instances.length; // When the instance is stopped, it gets removed from the slot.instances array // so we are going backwards to compensate for that for(let i = oldLength - 1; i >= 0; i--){ const isPlaying = instances[i].isPlaying || instances[i].isSuspended; const currentTime = instances[i].currentTime; if (isPlaying) { instances[i].stop(); } const instance = slot._createInstance(); if (isPlaying) { instance.play(); instance.currentTime = currentTime; } instances.push(instance); } } } } /** * Gets whether the component plays positional sound. * * @type {boolean} */ get positional() { return this._positional; } /** * Sets a dictionary that contains the {@link SoundSlot}s managed by this SoundComponent. * * @type {Object<string, SoundSlot>} */ set slots(newValue) { const oldValue = this._slots; // stop previous slots if (oldValue) { for(const key in oldValue){ oldValue[key].stop(); } } const slots = {}; // convert data to slots for(const key in newValue){ if (!(newValue[key] instanceof SoundSlot)) { if (newValue[key].name) { slots[newValue[key].name] = new SoundSlot(this, newValue[key].name, newValue[key]); } } else { slots[newValue[key].name] = newValue[key]; } } this._slots = slots; // call onEnable in order to start autoPlay slots if (this.enabled && this.entity.enabled) { this.onEnable(); } } /** * Gets a dictionary that contains the {@link SoundSlot}s managed by this SoundComponent. * * @type {Object<string, SoundSlot>} */ get slots() { return this._slots; } onEnable() { // do not run if running in Editor if (this.system._inTools) { return; } const slots = this._slots; const playingBeforeDisable = this._playingBeforeDisable; for(const key in slots){ const slot = slots[key]; // play if autoPlay is true or // if the slot was paused when the component // got disabled if (slot.autoPlay && slot.isStopped) { slot.play(); } else if (playingBeforeDisable[key]) { slot.resume(); } else if (!slot.isLoaded) { // start loading slots slot.load(); } } } onDisable() { const slots = this._slots; const playingBeforeDisable = {}; for(const key in slots){ // pause non-overlapping sounds if (!slots[key].overlap) { if (slots[key].isPlaying) { slots[key].pause(); // remember sounds playing when we disable // so we can resume them on enable playingBeforeDisable[key] = true; } } } this._playingBeforeDisable = playingBeforeDisable; } onRemove() { this.off(); } /** * Creates a new {@link SoundSlot} with the specified name. * * @param {string} name - The name of the slot. * @param {object} [options] - Settings for the slot. * @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] - If true, the sound will restart when it reaches the end. * Defaults to false. * @param {number} [options.startTime] - The start time from which the sound will start playing. * Defaults to 0 to start at the beginning. * @param {number} [options.duration] - The duration of the sound that the slot will play * starting from startTime. Defaults to `null` which means play to end of the sound. * @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. Defaults to false. * @param {boolean} [options.autoPlay] - If true, the slot will start playing as soon as its * audio asset is loaded. Defaults to false. * @param {number} [options.asset] - The asset id of the audio asset that is going to be played * by this slot. * @returns {SoundSlot|null} The new slot or null if the slot already exists. * @example * // get an asset by id * const asset = app.assets.get(10); * // add a slot * this.entity.sound.addSlot('beep', { * asset: asset * }); * // play * this.entity.sound.play('beep'); */ addSlot(name, options) { const slots = this._slots; if (slots[name]) { Debug.warn(`A sound slot with name ${name} already exists on Entity ${this.entity.path}`); return null; } const slot = new SoundSlot(this, name, options); slots[name] = slot; if (slot.autoPlay && this.enabled && this.entity.enabled) { slot.play(); } return slot; } /** * Removes the {@link SoundSlot} with the specified name. * * @param {string} name - The name of the slot. * @example * // remove a slot called 'beep' * this.entity.sound.removeSlot('beep'); */ removeSlot(name) { const slots = this._slots; if (slots[name]) { slots[name].stop(); delete slots[name]; } } /** * Returns the slot with the specified name. * * @param {string} name - The name of the slot. * @returns {SoundSlot|undefined} The slot. * @example * // get a slot and set its volume * this.entity.sound.slot('beep').volume = 0.5; * */ slot(name) { return this._slots[name]; } /** * Return a property from the slot with the specified name. * * @param {string} name - The name of the {@link SoundSlot} to look for. * @param {string} property - The name of the property to look for. * @returns {*} The value from the looked property inside the slot with specified name. May be * undefined if slot does not exist. * @private */ _getSlotProperty(name, property) { if (!this.enabled || !this.entity.enabled) { return undefined; } const slot = this._slots[name]; if (!slot) { Debug.warn(`Trying to get ${property} from sound slot with name ${name} which does not exist`); return undefined; } return slot[property]; } /** * Returns true if the slot with the specified name is currently playing. * * @param {string} name - The name of the {@link SoundSlot} to look for. * @returns {boolean} True if the slot with the specified name exists and is currently playing. */ isPlaying(name) { return this._getSlotProperty(name, 'isPlaying') || false; } /** * Returns true if the asset of the slot with the specified name is loaded.. * * @param {string} name - The name of the {@link SoundSlot} to look for. * @returns {boolean} True if the slot with the specified name exists and its asset is loaded. */ isLoaded(name) { return this._getSlotProperty(name, 'isLoaded') || false; } /** * Returns true if the slot with the specified name is currently paused. * * @param {string} name - The name of the {@link SoundSlot} to look for. * @returns {boolean} True if the slot with the specified name exists and is currently paused. */ isPaused(name) { return this._getSlotProperty(name, 'isPaused') || false; } /** * Returns true if the slot with the specified name is currently stopped. * * @param {string} name - The name of the {@link SoundSlot} to look for. * @returns {boolean} True if the slot with the specified name exists and is currently stopped. */ isStopped(name) { return this._getSlotProperty(name, 'isStopped') || false; } /** * Begins playing the sound slot with the specified name. The slot will restart playing if it * is already playing unless the overlap field is true in which case a new sound will be * created and played. * * @param {string} name - The name of the {@link SoundSlot} to play. * @returns {SoundInstance|null} The sound instance that will be played. Returns null if the * component or its parent entity is disabled or if the SoundComponent has no slot with the * specified name. * @example * // get asset by id * const asset = app.assets.get(10); * // create a slot and play it * this.entity.sound.addSlot('beep', { * asset: asset * }); * this.entity.sound.play('beep'); */ play(name) { if (!this.enabled || !this.entity.enabled) { return null; } const slot = this._slots[name]; if (!slot) { Debug.warn(`Trying to play sound slot with name ${name} which does not exist`); return null; } return slot.play(); } /** * Pauses playback of the slot with the specified name. If the name is undefined then all slots * currently played will be paused. The slots can be resumed by calling {@link SoundComponent#resume}. * * @param {string} [name] - The name of the slot to pause. Leave undefined to pause everything. * @example * // pause all sounds * this.entity.sound.pause(); * // pause a specific sound * this.entity.sound.pause('beep'); */ pause(name) { const slots = this._slots; if (name) { const slot = slots[name]; if (!slot) { Debug.warn(`Trying to pause sound slot with name ${name} which does not exist`); return; } slot.pause(); } else { for(const key in slots){ slots[key].pause(); } } } /** * Resumes playback of the sound slot with the specified name if it's paused. If no name is * specified all slots will be resumed. * * @param {string} [name] - The name of the slot to resume. Leave undefined to resume everything. * @example * // resume all sounds * this.entity.sound.resume(); * // resume a specific sound * this.entity.sound.resume('beep'); */ resume(name) { const slots = this._slots; if (name) { const slot = slots[name]; if (!slot) { Debug.warn(`Trying to resume sound slot with name ${name} which does not exist`); return; } if (slot.isPaused) { slot.resume(); } } else { for(const key in slots){ slots[key].resume(); } } } /** * Stops playback of the sound slot with the specified name if it's paused. If no name is * specified all slots will be stopped. * * @param {string} [name] - The name of the slot to stop. Leave undefined to stop everything. * @example * // stop all sounds * this.entity.sound.stop(); * // stop a specific sound * this.entity.sound.stop('beep'); */ stop(name) { const slots = this._slots; if (name) { const slot = slots[name]; if (!slot) { Debug.warn(`Trying to stop sound slot with name ${name} which does not exist`); return; } slot.stop(); } else { for(const key in slots){ slots[key].stop(); } } } constructor(...args){ super(...args), /** @private */ this._volume = 1, /** @private */ this._pitch = 1, /** @private */ this._positional = true, /** @private */ this._refDistance = 1, /** @private */ this._maxDistance = 10000, /** @private */ this._rollOffFactor = 1, /** @private */ this._distanceModel = DISTANCE_LINEAR, /** * @type {Object<string, SoundSlot>} * @private */ this._slots = {}, /** @private */ this._playingBeforeDisable = {}; } } export { SoundComponent };