@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
476 lines (381 loc) • 10.9 kB
JavaScript
import { assert } from "../../../../core/assert.js";
import { combine_hash } from "../../../../core/collection/array/combine_hash.js";
import Signal from "../../../../core/events/signal/Signal.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 { SoundTrackFlags } from "./SoundTrackFlags.js";
import { SoundTrackNodes } from "./SoundTrackNodes.js";
const DEFAULT_FLAGS = SoundTrackFlags.StartWhenReady;
export class SoundTrack {
/**
*
* @type {AudioBuffer|null}
*/
#buffer = null;
/**
*
* @type {String|null}
*/
url = null;
/**
*
* @type {number}
*/
time = 0;
/**
* @deprecated Not used
* @type {String|null}
*/
channel = "";
/**
* @private
* @type {number}
*/
__volume = 1;
/**
* In seconds
* Obtained once clip is loaded
* @type {number}
*/
duration = -1;
/**
*
* @type {number|SoundTrackFlags}
*/
flags = DEFAULT_FLAGS;
on = {
/**
* @type {Signal<this>}
*/
ended: new Signal()
};
/**
* @private
* @type {SoundTrackNodes}
*/
#nodes = null;
/**
*
* @return {SoundTrackNodes}
*/
get nodes() {
return this.#nodes;
}
/**
*
* @param {AudioContext} context
*/
initializeNodes(context) {
this.#nodes = new SoundTrackNodes(context);
}
/**
*
* @param {AudioBuffer} v
*/
set buffer(v) {
this.#buffer = v;
this.duration = v.duration;
this.setFlag(SoundTrackFlags.Ready);
}
get buffer() {
return this.#buffer;
}
/**
*
* @returns {BaseAudioContext}
*/
get #context() {
return this.nodes.volume.context;
}
start(startTime = 0) {
//TODO: figure out a way to use AudioBuffer.playbackRate.value to control speed of playback
const nodes = this.nodes;
if (nodes.source !== null) {
/*
source is already used up, need to create a new one
NOTE: AudioBufferSourceNode can only be used once, if we want to start playback again - we have to create a new one
see https://stackoverflow.com/questions/59815825/how-to-skip-ahead-n-seconds-while-playing-track-using-web-audio-api
*/
this.suspend();
}
nodes.source = this.#context.createBufferSource();
//connect source to output
nodes.source.connect(nodes.volume);
const looping = this.getFlag(SoundTrackFlags.Loop);
nodes.source.loop = looping;
// Make the sound source use the buffer and start playing it.
nodes.source.buffer = this.#buffer;
nodes.source.onended = () => {
if (!looping) {
this.clearFlag(SoundTrackFlags.Playing);
this.on.ended.send1(this);
}
}
let when = startTime;
if (this.time < 0) {
// playback will start in the future
when = -this.time;
nodes.source.start(when, 0);
} else {
// playback will start from within the track's duration (already playing)
nodes.source.start(when, this.time);
}
this.setFlag(SoundTrackFlags.Playing);
this.clearFlag(SoundTrackFlags.Suspended);
}
suspend() {
const nodes = this.#nodes;
if (nodes.source !== null) {
nodes.source.stop();
nodes.source.disconnect();
nodes.source = null;
}
this.setFlag(SoundTrackFlags.Suspended);
}
/**
*
* @param {SoundTrack} other
* @returns number
*/
compare(other) {
const url = compareStrings(this.url, other.url);
if (url !== 0) {
return url;
}
const time = number_compare_ascending(this.time, other.time);
if (time !== 0) {
return time;
}
const channel = compareStrings(this.channel, other.channel);
if (channel !== 0) {
return channel;
}
const volume = number_compare_ascending(this.__volume, other.__volume);
if (volume !== 0) {
return volume;
}
const flags = number_compare_ascending(this.flags, other.flags);
if (flags !== 0) {
return flags;
}
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;
}
/**
* Linearly transition volume to a target value over a certain duration.
* Useful for fading sounds in and out of the mix.
*
* NOTE: volume property of the object is updated instantly, transition happens at the AudioNode level only
*
* @param {number} target target volume value
* @param {number} duration How long the transition should take, in seconds
* @param {number} [startAfter] when fading should start, see WebAudio docs on {@link AudioContext#currentTime}
*/
setVolumeOverTime(target, duration, startAfter = 0) {
// instantly update volume for consistency purposes wrt serialization
this.__volume = target;
const nodes = this.nodes;
if (nodes !== null) {
/**
*
* @type {GainNode}
*/
const volume_node = nodes.volume;
/**
*
* @type {AudioParam}
*/
const gain = volume_node.gain;
const current_value = gain.value;
let start_time = startAfter;
/**
* @type {AudioContext}
*/
const audioContext = volume_node.context;
if (audioContext !== undefined) {
start_time += audioContext.currentTime;
}
// cancel any scheduled values
gain.cancelScheduledValues(0);
gain.setValueCurveAtTime([current_value, target], start_time, duration);
}
}
/**
*
* @param {number} v
*/
set volume(v) {
this.__volume = v;
if (this.nodes !== null) {
this.nodes.volume.gain.setValueAtTime(v, 0);
}
}
/**
*
* @return {number}
*/
get volume() {
return this.__volume;
}
/**
* @deprecated
* @return {boolean}
*/
get loop() {
return this.getFlag(SoundTrackFlags.Loop);
}
/**
* @deprecated
* @param {boolean} v
*/
set loop(v) {
this.writeFlag(SoundTrackFlags.Loop, v);
}
/**
* @deprecated
* @return {boolean}
*/
get playing() {
return this.getFlag(SoundTrackFlags.Playing);
}
/**
* @deprecated
* @param {boolean} v
*/
set playing(v) {
this.writeFlag(SoundTrackFlags.Playing, v);
}
/**
* @deprecated
* @return {boolean}
*/
get startWhenReady() {
return this.getFlag(SoundTrackFlags.StartWhenReady);
}
/**
* @deprecated
* @param {boolean} v
*/
set startWhenReady(v) {
this.writeFlag(SoundTrackFlags.StartWhenReady, v);
}
/**
*
* @param {SoundTrack} other
*/
copy(other) {
this.url = other.url;
this.time = other.time;
this.volume = other.volume;
this.flags = other.flags;
}
/**
*
* @param {SoundTrack} other
* @returns {boolean}
*/
equals(other) {
return this.url === other.url
&& this.time === other.time
&& this.__volume === other.__volume
&& this.flags === other.flags
;
}
/**
*
* @return {number}
*/
hash() {
return combine_hash(
computeStringHash(this.url),
computeHashFloat(this.time),
computeHashFloat(this.__volume),
this.flags
);
}
/**
*
* @return {SoundTrack}
*/
clone() {
const r = new SoundTrack();
r.copy(this);
return r;
}
toJSON() {
return {
url: this.url,
loop: this.getFlag(SoundTrackFlags.Loop),
time: this.time,
volume: this.volume,
playing: this.getFlag(SoundTrackFlags.Playing),
startWhenReady: this.getFlag(SoundTrackFlags.StartWhenReady),
usingAssetAlias: this.getFlag(SoundTrackFlags.UsingAliasURL),
synchronized: this.getFlag(SoundTrackFlags.Synchronized)
};
}
fromJSON(
{
url,
loop = false,
time = 0,
volume = 1,
playing = false,
startWhenReady = true,
usingAssetAlias = false,
synchronized = false
}
) {
assert.isString(url, 'url');
this.url = url;
this.writeFlag(SoundTrackFlags.Loop, loop);
this.writeFlag(SoundTrackFlags.UsingAliasURL, usingAssetAlias);
this.time = time;
this.volume = volume;
this.writeFlag(SoundTrackFlags.Playing, playing);
this.writeFlag(SoundTrackFlags.StartWhenReady, startWhenReady);
this.writeFlag(SoundTrackFlags.Synchronized, synchronized);
}
static fromJSON(json) {
const track = new SoundTrack();
track.fromJSON(json);
return track;
}
}