playcanvas
Version:
Open-source WebGL/WebGPU 3D engine for the web
654 lines (653 loc) • 19.1 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 { 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";
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
};
class SoundSlot extends EventHandler {
/**
* 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();
/**
* The name of the slot.
*
* @type {string}
*/
__publicField(this, "name");
/**
* An array that contains all the {@link SoundInstance}s currently being played by the slot.
*
* @type {SoundInstance[]}
*/
__publicField(this, "instances", []);
this._component = component;
this._assets = component.system.app.assets;
this._manager = component.system.manager;
this.name = name;
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._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() {
if (!this.overlap) {
this.stop();
}
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 void 0;
}
const instance = this._createInstance();
this.instances.push(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;
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;
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;
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() {
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;
if (this._hasAsset()) {
const asset = this._assets.get(this._asset);
if (asset) {
sound = asset.resource;
}
}
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);
}
if (this._firstNode) {
instance.setExternalNodes(this._firstNode, this._lastNode);
}
return instance;
}
_onInstancePlay(instance) {
this.fire("play", instance);
this._component.fire("play", this, instance);
}
_onInstancePause(instance) {
this.fire("pause", instance);
this._component.fire("pause", this, instance);
}
_onInstanceResume(instance) {
this.fire("resume", instance);
this._component.fire("resume", this, instance);
}
_onInstanceStop(instance) {
const idx = this.instances.indexOf(instance);
if (idx !== -1) {
this.instances.splice(idx, 1);
}
this.fire("stop", instance);
this._component.fire("stop", this, instance);
}
_onInstanceEnd(instance) {
const idx = this.instances.indexOf(instance);
if (idx !== -1) {
this.instances.splice(idx, 1);
}
this.fire("end", instance);
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;
}
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;
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;
}
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;
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);
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);
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);
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;
}
}
/**
* 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');
* });
*/
__publicField(SoundSlot, "EVENT_PLAY", "play");
/**
* 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');
* });
*/
__publicField(SoundSlot, "EVENT_PAUSE", "pause");
/**
* 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');
* });
*/
__publicField(SoundSlot, "EVENT_RESUME", "resume");
/**
* 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');
* });
*/
__publicField(SoundSlot, "EVENT_STOP", "stop");
/**
* 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');
* });
*/
__publicField(SoundSlot, "EVENT_END", "end");
/**
* 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');
* });
*/
__publicField(SoundSlot, "EVENT_LOAD", "load");
export {
SoundSlot
};