UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

476 lines (381 loc) • 10.9 kB
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; } }