@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
588 lines (475 loc) • 15.4 kB
JavaScript
import { BvhClient } from "../../../../core/bvh2/bvh3/BvhClient.js";
import { combine_hash } from "../../../../core/collection/array/combine_hash.js";
import List from '../../../../core/collection/list/List.js';
import { aabb3_array_compute_from_sphere } from "../../../../core/geom/3d/aabb/aabb3_array_compute_from_sphere.js";
import Vector1 from "../../../../core/geom/Vector1.js";
import {
interpolate_irradiance_linear
} from "../../../../core/math/physics/irradiance/interpolate_irradiance_linear.js";
import {
interpolate_irradiance_lograrithmic
} from "../../../../core/math/physics/irradiance/interpolate_irradiance_lograrithmic.js";
import { interpolate_irradiance_smith } from "../../../../core/math/physics/irradiance/interpolate_irradiance_smith.js";
import { objectKeyByValue } from "../../../../core/model/object/objectKeyByValue.js";
import { computeHashFloat } from "../../../../core/primitives/numbers/computeHashFloat.js";
import { number_compare_ascending } from "../../../../core/primitives/numbers/number_compare_ascending.js";
import { compareStrings } from "../../../../core/primitives/strings/compareStrings.js";
import { computeStringHash } from "../../../../core/primitives/strings/computeStringHash.js";
import { SoundAttenuationFunction } from "./SoundAttenuationFunction.js";
import { SoundEmitterFlags } from "./SoundEmitterFlags.js";
import { SoundPanningModelType } from "./SoundPanningModelType.js";
import { SoundTrack } from "./SoundTrack.js";
const DEFAULT_DISTANCE_MIN = 1;
const DEFAULT_DISTANCE_MAX = 10000;
const DEFAULT_CHANNEL = null;
/**
* @class
*/
export class SoundEmitter {
/**
* @readonly
* @type {List<SoundTrack>}
*/
tracks = new List();
/**
*
* @type {String|SoundEmitterChannels|null}
*/
channel = DEFAULT_CHANNEL;
/**
*
* @type {number}
* @private
*/
__distanceMin = 1;
/**
*
* @type {number}
* @private
*/
__distanceMax = 10000;
/**
* @deprecated
* @type {number}
* @private
*/
__distanceRolloff = 1;
/**
* TODO add to binary serialization
* @type {SoundPanningModelType}
*/
panningModel = SoundPanningModelType.HRTF;
/**
* Type of attenuation used for sound fall-off, this is only used if Attenuation flag is set
* @type {SoundAttenuationFunction|number}
*/
attenuation = SoundAttenuationFunction.Smith;
/**
*
* @type {number|SoundEmitterFlags}
*/
flags = 0;
/**
* @readonly
*/
nodes = {
/**
* @type {GainNode}
*/
volume: null,
/**
* @type {PannerNode}
*/
panner: null,
/**
* @type {GainNode}
*/
attenuation: null,
/**
* One of the other nodes, depending on the configuration
* @type {AudioNode}
*/
endpoint: null
};
/**
*
* @type {Vector1}
*/
volume = new Vector1(1);
/**
* @readonly
* @type {BvhClient}
*/
bvh = new BvhClient();
/**
*
* @constructor
*/
constructor() {
this.volume.onChanged.add((value) => {
const volume_node = this.nodes.volume;
if (volume_node !== null) {
volume_node.gain.setValueAtTime(value, 0);
}
});
}
/**
*
* @param {SoundEmitter} other
* @returns number
*/
compare(other) {
const tracks = this.tracks.compare(other.tracks);
if (tracks !== 0) {
return tracks;
}
const channel = compareStrings(this.channel, other.channel);
if (channel !== 0) {
return channel;
}
const distanceMin = number_compare_ascending(this.__distanceMin, other.__distanceMin);
if (distanceMin !== 0) {
return distanceMin;
}
const distanceMax = number_compare_ascending(this.__distanceMax, other.__distanceMax);
if (distanceMax !== 0) {
return distanceMax;
}
const panningModel = number_compare_ascending(this.panningModel, other.panningModel);
if (panningModel !== 0) {
return panningModel;
}
const attenuation = number_compare_ascending(this.attenuation, other.attenuation);
if (attenuation !== 0) {
return attenuation;
}
const flags = number_compare_ascending(this.flags, other.flags);
if (flags !== 0) {
return flags;
}
const volume = number_compare_ascending(this.volume.getValue(), other.volume.getValue());
if (volume !== 0) {
return volume;
}
return 0;
}
/**
*
* @param {number|SoundEmitterFlags} flag
* @returns {void}
*/
setFlag(flag) {
this.flags |= flag;
}
/**
*
* @param {number|SoundEmitterFlags} flag
* @returns {void}
*/
clearFlag(flag) {
this.flags &= ~flag;
}
/**
*
* @param {number|SoundEmitterFlags} flag
* @param {boolean} value
*/
writeFlag(flag, value) {
if (value) {
this.setFlag(flag);
} else {
this.clearFlag(flag);
}
}
/**
*
* @param {number|SoundEmitterFlags} flag
* @returns {boolean}
*/
getFlag(flag) {
return (this.flags & flag) === flag;
}
/**
* @deprecated
* @param {boolean} v
*/
set isPositioned(v) {
this.writeFlag(SoundEmitterFlags.Spatialization, v);
}
/**
* @deprecated
* @return {boolean}
*/
get isPositioned() {
return this.getFlag(SoundEmitterFlags.Spatialization);
}
/**
* @return {AudioNode}
*/
getTargetNode() {
return this.nodes.endpoint;
}
/**
* @returns {boolean}
* @param {SoundEmitter} other
*/
equals(other) {
return this.channel === other.channel
&& this.flags === other.flags
&& this.__distanceMin === other.__distanceMin
&& this.__distanceMax === other.__distanceMax
&& this.attenuation === other.attenuation
&& this.tracks.equals(other.tracks)
;
}
/**
*
* @return {number}
*/
hash() {
return combine_hash(
computeStringHash(this.channel),
this.flags,
computeHashFloat(this.__distanceMin),
computeHashFloat(this.__distanceMax),
this.attenuation,
this.tracks.hash()
);
}
/**
*
* @param {AudioContext} ctx
*/
buildNodes(ctx) {
const nodes = this.nodes;
nodes.volume = ctx.createGain();
if (this.getFlag(SoundEmitterFlags.Attenuation)) {
nodes.attenuation = ctx.createGain();
}
if (this.getFlag(SoundEmitterFlags.Spatialization)) {
nodes.panner = ctx.createPanner();
//
if (this.panningModel === SoundPanningModelType.HRTF) {
nodes.panner.panningModel = 'HRTF';
} else if (this.panningModel === SoundPanningModelType.EqualPower) {
nodes.panner.panningModel = 'equalpower';
} else {
console.error('Invalid value of panning model type:', this.panningModel);
nodes.panner.panningModel = 'equalpower';
}
// we set up distance model in the most efficient way, since we are ignoring it anyway and use custom distance model via a GainNode instead
nodes.panner.distanceModel = 'linear';
nodes.panner.rolloffFactor = 0;
nodes.panner.refDistance = this.distanceMin;
nodes.panner.maxDistance = this.distanceMax;
}
//do wiring
if (this.getFlag(SoundEmitterFlags.Attenuation | SoundEmitterFlags.Spatialization)) {
nodes.attenuation.connect(nodes.panner);
nodes.volume.connect(nodes.attenuation);
nodes.endpoint = nodes.panner;
} else if (this.getFlag(SoundEmitterFlags.Spatialization)) {
nodes.volume.connect(nodes.panner);
nodes.endpoint = nodes.panner;
} else if (this.getFlag(SoundEmitterFlags.Attenuation)) {
nodes.volume.connect(nodes.attenuation);
nodes.endpoint = nodes.attenuation;
} else {
nodes.endpoint = nodes.volume;
}
nodes.volume.gain.setValueAtTime(this.volume.getValue(), 0);
}
/**
* @private
* @param {number} distance
*/
writeAttenuationVolume(distance) {
let volume;
const attenuation = this.attenuation;
const distance_min = this.__distanceMin;
const distance_max = this.__distanceMax;
if (attenuation === SoundAttenuationFunction.Linear) {
volume = interpolate_irradiance_linear(distance, distance_min, distance_max);
} else if (attenuation === SoundAttenuationFunction.Logarithmic) {
volume = interpolate_irradiance_lograrithmic(distance, distance_min, distance_max);
} else if (attenuation === SoundAttenuationFunction.Smith) {
volume = interpolate_irradiance_smith(distance, distance_min, distance_max);
} else {
//unsupported function, don't attenuate
volume = 1;
}
/**
*
* @type {GainNode}
*/
const pv = this.nodes.attenuation;
if (pv !== null) {
pv.gain.setValueAtTime(volume, 0);
}
}
/**
*
* @returns {number}
*/
get distanceMin() {
return this.__distanceMin;
}
/**
* @param {number} v
*/
set distanceMin(v) {
this.__distanceMin = v;
}
/**
*
* @returns {number}
*/
get distanceMax() {
return this.__distanceMax;
}
/**
* @param {number} v
*/
set distanceMax(v) {
this.__distanceMax = v;
}
/**
* @deprecated
* @returns {number}
*/
get distanceRolloff() {
return this.__distanceRolloff;
}
/**
* @deprecated
* @param {number} v
*/
set distanceRolloff(v) {
this.__distanceRolloff = v;
const panner = this.nodes.panner;
if (panner !== null) {
panner.rolloffFactor = v;
}
}
/**
*
* @param {number} x
* @param {number} y
* @param {number} z
*/
updatePosition(x, y, z) {
if (this.getFlag(SoundEmitterFlags.Spatialization)) {
//update position of the panner node
const nodes = this.nodes;
/**
*
* @type {PannerNode}
*/
const panner = nodes.panner;
if (panner !== null) {
panner.setPosition(x, y, z);
}
}
const distanceMax = this.distanceMax;
const bounds = this.bvh.bounds;
aabb3_array_compute_from_sphere(bounds, 0, x, y, z, distanceMax);
this.bvh.write_bounds();
}
/**
*
* @param {number} [duration] fade duration in seconds
* @param startAfter
*/
fadeOutAllTracks(duration = 1, startAfter = 0) {
const tracks = this.tracks;
const n = tracks.length;
for (let i = 0; i < n; i++) {
const soundTrack = tracks.get(i);
soundTrack.setVolumeOverTime(0, duration, startAfter);
}
}
stopAllTracks() {
const tracks = this.tracks;
const n = tracks.length;
for (let i = 0; i < n; i++) {
const soundTrack = tracks.get(i);
soundTrack.playing = false;
}
}
toJSON() {
return {
isPositioned: this.getFlag(SoundEmitterFlags.Spatialization),
isAttenuated: this.getFlag(SoundEmitterFlags.Attenuation),
channel: this.channel,
volume: this.volume.toJSON(),
tracks: this.tracks.toJSON(),
distanceMin: this.distanceMin,
distanceMax: this.distanceMax,
attenuation: objectKeyByValue(SoundAttenuationFunction, this.attenuation),
panningModel: objectKeyByValue(SoundPanningModelType, this.panningModel)
};
}
fromJSON(json) {
if (json.isPositioned !== undefined) {
this.writeFlag(SoundEmitterFlags.Spatialization, json.isPositioned);
} else {
this.clearFlag(SoundEmitterFlags.Spatialization);
}
if (json.channel !== undefined) {
this.channel = json.channel;
} else {
this.channel = DEFAULT_CHANNEL;
}
if (json.volume !== undefined) {
this.volume.fromJSON(json.volume);
}
if (typeof json.distanceMin === "number") {
this.distanceMin = json.distanceMin;
} else {
this.distanceMin = DEFAULT_DISTANCE_MIN;
}
if (typeof json.distanceMax === "number") {
this.distanceMax = json.distanceMax;
} else {
this.distanceMax = DEFAULT_DISTANCE_MAX;
}
if (typeof json.isAttenuated === "boolean") {
this.writeFlag(SoundEmitterFlags.Attenuation, json.isAttenuated);
} else {
this.setFlag(SoundEmitterFlags.Attenuation);
}
if (typeof json.attenuation === "string") {
const attenuation = SoundAttenuationFunction[json.attenuation];
if (attenuation === undefined) {
throw new Error(`Unknown attenuation type '${json.attenuation}', valid types are: ${Object.keys(SoundAttenuationFunction)}`);
}
this.attenuation = attenuation;
} else {
this.attenuation = SoundAttenuationFunction.Linear;
}
//tracks
if (json.tracks !== undefined) {
this.tracks.fromJSON(json.tracks, SoundTrack);
}
if (json.panningModel === undefined) {
this.panningModel = SoundPanningModelType.HRTF;
} else {
const model = SoundPanningModelType[json.panningModel];
if (model === undefined) {
throw new Error(`Unknown panning model '${json.panningModel}', valid types are: ${Object.keys(SoundPanningModelType)}`);
}
this.panningModel = model;
}
}
/**
*
* @param json
* @returns {SoundEmitter}
*/
static fromJSON(json) {
const result = new SoundEmitter();
result.fromJSON(json);
return result;
}
}
SoundEmitter.typeName = "SoundEmitter";