/**
* LittleJS Audio System
* - Play audio files (mp3, ogg, wave) and generate sounds with ZzFX
* - ZzFX sound generator integration: <a href=https://killedbyapixel.github.io/ZzFX/>ZzFX</a>
* - Sound caching for fast playback and memory efficiency
* - Volume control with attenuation and stereo panning
* - 2D spatial audio based on camera position with distance-based falloff
* - Sound instance management (pause, resume, stop)
* - Speech synthesis for text-to-speech
* - Music playback with ZzFXM support
* - Web Audio API integration with master gain control
* - Sounds and the master bus can route through effects, see the audio effects plugin
* @namespace Audio
*/
'use strict';
/** Audio context used by the engine, undefined outside a browser, where the engine runs headless
* @type {AudioContext}
* @memberof Audio */
let audioContext = typeof AudioContext == 'undefined' ? undefined : new AudioContext;
/** Master gain node for all audio to pass through, made at load so effects can connect to it any time
* @type {GainNode}
* @memberof Audio */
let audioMasterGain = audioContext?.createGain();
let audioMasterVolume; // the soundVolume the master gain was last set to
if (audioMasterGain)
{
audioMasterGain.connect(audioContext.destination);
audioMasterGain.gain.value = audioMasterVolume = soundVolume; // set starting value
audioContext.addEventListener?.('statechange', audioStateChange);
}
// soundVolume can be set directly, so the master gain follows it each frame
function audioUpdateVolume()
{
if (audioMasterGain && soundVolume !== audioMasterVolume)
audioMasterGain.gain.value = audioMasterVolume = soundVolume;
}
// the current master effect, kept so setAudioMasterEffect can undo the route it made,
// and whether its output came from an effect, which gets its default route back
let audioMasterEffectInput, audioMasterEffectOutput, audioMasterEffectOutputIsEffect;
/** Default sample rate used for sounds
* @default 44100
* @memberof Audio */
const audioDefaultSampleRate = 44100;
/** Check if the audio context is running and available for playback
* @return {boolean} - True if the audio context is running, false when there is none
* @memberof Audio */
function audioIsRunning()
{ return audioContext?.state === 'running'; }
function audioInit()
{
if (!soundEnable || headlessMode) return;
document.addEventListener('visibilitychange', audioVisibilityChange);
}
// a hidden page stops the game, so its sound stops too, and the audio clock with it so every sound picks up
// exactly where it was; only a suspend made here is undone, not one the browser holds until the first input
let audioSuspendedWhenHidden = false;
function audioVisibilityChange()
{
if (document.hidden)
{
if (!soundPauseWhenHidden || audioContext.state != 'running') return;
audioSuspendedWhenHidden = true;
audioContext.suspend();
}
else if (audioSuspendedWhenHidden)
{
audioSuspendedWhenHidden = false;
audioContext.resume();
}
}
// sound instances whose start failed only because the context was not running, like music started in gameInit
// before the first input, each with the time it tried; they start once the context runs unless paused or stopped
// first, and a one shot drops out once it would have ended anyway, so a backlog of sounds can't all play at once
const audioWaitingInstances = new Map;
function audioWaitingPrune(now=performance.now())
{
for (const [instance, startTime] of audioWaitingInstances)
{
const remaining = (instance.getDuration() - instance.pausedTime) / instance.rate;
if (!instance.loop && now - startTime > remaining * 1e3)
audioWaitingInstances.delete(instance);
}
}
function audioStateChange()
{
if (!audioIsRunning()) return;
audioWaitingPrune();
const instances = [...audioWaitingInstances.keys()];
audioWaitingInstances.clear();
for (const instance of instances)
instance.resume();
}
/** Anything with input and output audio nodes, like an effect from the audio effects plugin
* @typedef {{input: AudioNode, output: AudioNode}} AudioEffectNodes
* @memberof Audio */
/** Route all sound through an effect between the master gain and the speakers
* - Pass a node or an effect, or the first and last of a chain, each a node or an effect
* - With one argument a node is both ends, and an effect uses its own input and output
* - The output node is disconnected from everything else first, so it only feeds the speakers
* - The two ends of a chain must already be connected to each other, like effectA.connect(effectB)
* - Call with no arguments to remove the effect, an effect that was the master goes back to feeding the master gain
* - Debug video capture records the end of the master chain, but loses its tap if the effect changes mid-capture
* @param {AudioNode|AudioEffectNodes} [input] - Node or effect the master gain connects to
* @param {AudioNode|AudioEffectNodes} [output] - Node or effect that connects to the audio destination, defaults to the input's output
* @memberof Audio */
function setAudioMasterEffect(input, output)
{
// an effect stands in for its nodes, and a node is both ends when no output is passed
// (the output resolves first since its default comes from the input effect)
const outputArg = output || input;
const outputIsEffect = !!outputArg && 'input' in outputArg;
output = audioEffectNode(output, 'output') || audioEffectNode(input, 'output');
input = audioEffectNode(input, 'input');
ASSERT(!input || typeof input.connect === 'function', 'input must be an AudioNode or an effect with input and output nodes');
ASSERT(!output || typeof output.connect === 'function', 'output must be an AudioNode or an effect with input and output nodes');
if (!audioMasterGain) return; // no audio outside a browser, where the engine runs headless
// undo the current route selectively so other taps survive; an effect's output that still feeds the speakers
// goes back to the master gain, its default, so it still works for sounds, but one that connect() already moved
// on, like the head of a longer chain being set now, stays where it was sent
audioMasterGain.disconnect(audioMasterEffectInput || audioContext.destination);
if (audioMasterEffectOutput)
{
let fedSpeakers = true;
try { audioMasterEffectOutput.disconnect(audioContext.destination); }
catch { fedSpeakers = false; }
if (audioMasterEffectOutputIsEffect && fedSpeakers)
audioMasterEffectOutput.connect(audioMasterGain);
}
audioMasterEffectInput = input;
audioMasterEffectOutput = output;
audioMasterEffectOutputIsEffect = outputIsEffect;
// connect the master gain to the speakers, through the effect if there is one
if (input)
{
audioMasterGain.connect(input);
output.disconnect();
output.connect(audioContext.destination);
}
else
audioMasterGain.connect(audioContext.destination);
}
// get one of an effect's nodes, or the thing itself when it is already a node
/** @param {AudioNode|AudioEffectNodes|undefined} effectOrNode
* @param {'input'|'output'} key
* @return {AudioNode}
* @ignore */
function audioEffectNode(effectOrNode, key)
{
if (effectOrNode && 'input' in effectOrNode)
return /** @type {AudioEffectNodes} */ (effectOrNode)[key];
return /** @type {AudioNode} */ (effectOrNode);
}
// ramp an audio param to a value, cancelling anything already scheduled so stacked calls don't fight;
// returns when the ramp ends
function audioParamRamp(param, value, fadeTime=0)
{
ASSERT(fadeTime >= 0, 'fadeTime must be positive or zero');
const startTime = audioContext.currentTime;
param.cancelScheduledValues(startTime);
if (fadeTime)
{
param.setValueAtTime(param.value, startTime);
param.linearRampToValueAtTime(value, startTime + fadeTime);
}
else
param.value = value;
return startTime + fadeTime;
}
///////////////////////////////////////////////////////////////////////////////
/**
* @callback SoundLoadCallback - Function called when sound is loaded
* @param {Sound} sound
* @memberof Audio
*/
/**
* Sound Object - Stores a sound for later
* - this can be used to load and play wave, mp3, and ogg files
* - it can also create sounds using the ZzFX sound generator
* - can attenuate and apply stereo panning to sounds
* - sound instance control with pause/resume capability
*
* <a href=https://killedbyapixel.github.io/ZzFX/>Create sounds using the ZzFX Sound Designer.</a>
* @memberof Audio
* @example
* // load an audio asset file
* const sound_example = new Sound('sound.mp3');
*
* // create a zzfx sound
* const sound_example = new Sound([.5,.5]);
*
* // play a sound
* sound_example.play();
*/
class Sound
{
/** Create a sound object and cache the audio for later use
* @param {string|URL|Array} [asset] - Filename or URL of an audio file, or a zzfx array
* @param {number} [randomness] - How much to randomize frequency each time sound plays, for zzfx sounds it overrides the array's own randomness, which is used if undefined
* @param {number} [range=soundDefaultRange] - World space max range of sound
* @param {number} [taper=soundDefaultTaper] - At what percentage of range should it start tapering
* @param {SoundLoadCallback} [onloadCallback] - callback function to call when sound is loaded
*/
constructor(asset, randomness, range=soundDefaultRange, taper=soundDefaultTaper, onloadCallback)
{
if (!soundEnable || headlessMode) return;
ASSERT(!asset || isArray(asset) || isStringLike(asset), 'asset must be a file name or zzfx array');
ASSERT(randomness === undefined || isNumber(randomness), 'randomness must be a number');
ASSERT(randomness === undefined || randomness >= 0 && randomness <=1, 'randomness must be between 0 and 1');
ASSERT(isNumber(range), 'range must be a number');
ASSERT(isNumber(taper), 'taper must be a number');
/** @property {number} - World space max range of sound */
this.range = range;
/** @property {number} - At what percentage of range should it start tapering */
this.taper = taper;
/** @property {number} - How much to randomize frequency each time sound plays
* @type {number} */
this.randomness = randomness ?? 0;
/** @property {number} - Sample rate for this sound */
this.sampleRate = audioDefaultSampleRate;
/** @property {number} - How many samples per channel this sound has */
this.sampleLength = 0;
/** @property {AudioBuffer|undefined} - Decoded audio shared by every play of this sound
* @type {AudioBuffer|undefined} */
this.sampleBuffer = undefined;
// internal, the 3D plugin reads it to know the sound has loaded
/** @ignore
* @type {Array<Array<number>|Float32Array>|undefined} */
this._sampleChannels = undefined;
/** @property {number} - Percentage of this sound currently loaded, sounds
* fetched from a url stay at 0 until decoding completes */
this.loadedPercent = 0;
/** @property {SoundLoadCallback|undefined} - function to call when sound is loaded
* @type {SoundLoadCallback|undefined} */
this.onloadCallback = onloadCallback;
/** @property {AudioNode|AudioEffectNodes|undefined} - Node or effect to route every play of this sound through instead of the master gain
* - Where this sound's audio goes, unlike AudioEffect.output which is an effect's own node, effects chain with connect()
* @type {AudioNode|AudioEffectNodes|undefined} */
this.output = undefined;
if (isArray(asset))
{
// generate zzfx sound — copy so we don't mutate the caller's array
const zzfxSound = asset.slice();
// remove randomness so it can be applied on playback, a value passed in wins over the array's
const randomnessIndex = 1;
this.randomness = randomness ?? zzfxSound[randomnessIndex] ?? .05;
zzfxSound[randomnessIndex] = 0;
// generate the zzfx samples, then hand them to an audio buffer so
// the plain arrays can be released and every play shares the buffer
this.sampleChannels = [zzfxG(...zzfxSound)];
this.buildSampleBuffer();
this.loadedPercent = 1;
onloadCallback?.(this);
}
else if (asset)
{
// load the audio file, a URL object as bundlers give works like its string;
// report failures rather than leaving an unhandled rejection, the sound just stays unloaded and silent
const filename = asset + '';
engineAddLoad(this.loadSound(filename).catch(e=>
LOG('Sound load failed for', filename, '-', e.message))); // startup waits for it
}
}
/** Sample data for each channel
* Sounds keep their samples in an audio buffer, so reading this rebuilds
* the arrays from it and caches them. The copies are safe to hold onto,
* playing a sound detaches the buffer's own channel arrays.
* @return {Array<Array<number>|Float32Array>|undefined} */
get sampleChannels()
{
const buffer = this.sampleBuffer;
if (!this._sampleChannels && buffer)
{
const channels = [];
for (let i = 0; i < buffer.numberOfChannels; i++)
channels.push(buffer.getChannelData(i).slice());
this._sampleChannels = channels;
}
return this._sampleChannels;
}
/** @param {Array<Array<number>|Float32Array>} sampleChannels */
set sampleChannels(sampleChannels)
{
// new samples invalidate the buffer built from the old ones
this._sampleChannels = sampleChannels;
this.sampleBuffer = undefined;
this.sampleLength = sampleChannels?.[0]?.length || 0;
}
/** Move this sound's samples into an audio buffer that every play can share
* Does nothing if there is already a buffer or no samples to build one from */
buildSampleBuffer()
{
if (this.sampleBuffer || !this._sampleChannels || headlessMode) return;
this.sampleBuffer = createAudioBuffer(this._sampleChannels, this.sampleRate);
// the buffer owns the samples now, release the arrays we built it from
this._sampleChannels = undefined;
}
/** Play the sound
* - Browsers hold audio until the first user input, a sound played before it returns a paused instance
* that starts on its own once audio runs, unless paused or stopped first; a one shot that would have
* ended by then is dropped, and only the newest play of each sound waits, so a sound played every frame
* starts once
* @param {Vector2} [pos] - World space position to play the sound if any
* @param {number} [volume] - How much to scale volume by
* @param {number} [pitch] - How much to scale pitch by
* @param {number} [randomnessScale] - How much to scale pitch randomness
* @param {boolean} [loop] - Should the sound loop?
* @param {boolean} [paused] - Should the sound start paused
* @return {SoundInstance|undefined} - The sound instance, or undefined if sound is disabled, not loaded, out of range, or running in headless mode
*/
play(pos, volume=1, pitch=1, randomnessScale=1, loop=false, paused=false)
{
ASSERT(!pos || isVector2(pos), 'pos must be a vec2');
ASSERT(isNumber(volume), 'volume must be a number');
ASSERT(isNumber(pitch), 'pitch must be a number');
ASSERT(isNumber(randomnessScale), 'randomnessScale must be a number');
if (!soundEnable || headlessMode) return;
if (!this.sampleBuffer && !this._sampleChannels) return;
let pan;
if (pos)
{
const range = this.range;
if (range)
{
// apply range based fade
const lengthSquared = cameraPos.distanceSquared(pos);
if (lengthSquared > range*range)
return; // out of range
// attenuate volume by distance, full volume out to the taper and a fade past it,
// so a taper of 1 plays at full volume right up to the range
const distance = lengthSquared**.5, taperRange = range*this.taper;
if (distance > taperRange)
volume *= percent(distance, range, taperRange);
}
// get pan from screen space coords
pan = worldToScreen(pos).x * 2/mainCanvasSize.x - 1;
}
// Create sound instance
const rate = pitch + pitch * this.randomness*randomnessScale*rand(-1,1);
const instance = new SoundInstance(this, volume, rate, pan, loop, paused);
if (debug && debugSound && pos)
{
// visualize where positioned sounds play and their falloff range
debugCircle(pos, .5, '#0ff', .5, true, false);
if (this.range)
{
debugCircle(pos, 2*this.range, '#0ff', .5, false, false); // silent radius
debugCircle(pos, 2*this.range*this.taper, '#0ff', .5, false, false); // full volume radius
}
debugText('vol '+volume.toFixed(2)+' pitch '+rate.toFixed(2), pos, .5, '#0ff', .5, 0, 'monospace', false);
}
return instance;
}
/** Play the sound on a loop, the same as play with loop on; stop or change it through the SoundInstance returned
* @param {Vector2} [pos] - World space position to play the sound if any
* @param {number} [volume] - How much to scale volume by
* @param {number} [pitch] - How much to scale pitch by
* @param {number} [randomnessScale] - How much to scale pitch randomness
* @param {boolean} [paused] - Should the sound start paused
* @return {SoundInstance|undefined} - The sound instance, or undefined if sound is disabled, not loaded, out of range, or running in headless mode */
playLoop(pos, volume=1, pitch=1, randomnessScale=1, paused=false)
{ return this.play(pos, volume, pitch, randomnessScale, true, paused); }
/** Play a music track that loops by default
* @param {number} [volume] - Volume to play the music at
* @param {boolean} [loop] - Should the music loop?
* @param {boolean} [paused] - Should the music start paused
* @return {SoundInstance|undefined} - The sound instance, or undefined if sound is disabled, not loaded, or running in headless mode
*/
playMusic(volume=1, loop=true, paused=false)
{ return this.play(undefined, volume, 1, 0, loop, paused); }
/** Play the sound as a musical note with a semitone offset
* This can be used to play music with chromatic scales
* @param {number} [semitoneOffset] - How many semitones to offset pitch
* @param {Vector2} [pos] - World space position to play the sound if any
* @param {number} [volume=1] - How much to scale volume by
* @return {SoundInstance|undefined} - The sound instance, or undefined if sound is disabled, not loaded, out of range, or running in headless mode
*/
playNote(semitoneOffset=0, pos, volume)
{
ASSERT(isNumber(semitoneOffset), 'semitoneOffset must be a number');
const pitch = getNoteFrequency(semitoneOffset, 1);
return this.play(pos, volume, pitch, 0);
}
/** Get how long this sound is in seconds
* @return {number} - How long the sound is in seconds (0 if loading)
*/
getDuration()
{ return this.sampleLength / this.sampleRate || 0; }
/** Check if sound is loaded, for sounds fetched from a url
* @return {boolean} - True if sound is loaded and ready to play
*/
isLoaded() { return this.loadedPercent === 1; }
/** Loads a sound from a URL and decodes it into sample data.
* @param {string} filename
* @return {Promise} */
async loadSound(filename)
{
const response = await fetch(filename);
if (!response.ok)
throw new Error(`Failed to load sound from ${filename}: ${response.status} ${response.statusText}`);
const arrayBuffer = await response.arrayBuffer();
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
// keep the decoded buffer as is, it is exactly what playback needs and
// every play shares it, no channel data is read or copied
this.sampleRate = audioBuffer.sampleRate;
this.sampleLength = audioBuffer.length;
this.sampleBuffer = audioBuffer;
this._sampleChannels = undefined; // samples read from a sound loaded before are that sound's, not this one's
this.loadedPercent = 1;
this.onloadCallback?.(this);
}
}
///////////////////////////////////////////////////////////////////////////////
/**
* Sound Instance - Wraps an AudioBufferSourceNode for individual sound control
* Represents a single playing instance of a sound with pause/resume capabilities
* @memberof Audio
* @example
* // Play a sound and get an instance for control
* const jumpSound = new Sound([.5,.5,220]);
* const instance = jumpSound.play();
*
* // Control the individual instance
* instance.setVolume(.5);
* instance.pause();
* instance.resume();
* instance.stop();
*/
class SoundInstance
{
/** Create a sound instance
* @param {Sound} sound - The sound object
* @param {number} [volume] - How much to scale volume by
* @param {number} [rate] - The playback rate to use
* @param {number} [pan] - How much to apply stereo panning
* @param {boolean} [loop] - Should the sound loop?
* @param {boolean} [paused] - Should the sound start paused? */
constructor(sound, volume=1, rate=1, pan=0, loop=false, paused=false)
{
ASSERT(sound instanceof Sound, 'SoundInstance requires a valid Sound object');
ASSERT(volume >= 0, 'Sound volume must be positive or zero');
ASSERT(rate >= 0, 'Sound rate must be positive or zero');
ASSERT(isNumber(pan), 'Sound pan must be a number');
/** @property {Sound} - The sound object */
this.sound = sound;
/** @property {number} - How much to scale volume by */
this.volume = volume;
/** @property {number} - The playback rate to use */
this.rate = rate;
/** @property {number} - How much to apply stereo panning */
this.pan = pan;
/** @property {boolean} - Should the sound loop */
this.loop = loop;
/** @property {number|undefined} - Where it is in the sound while not playing, in the sound's own seconds, undefined while playing
* @type {number|undefined} */
this.pausedTime = 0;
/** @property {number} - Audio context time its place was last taken at, while playing
* @type {number|undefined} */
this.startTime = undefined;
/** @property {number} - Where it was in the sound at startTime, in the sound's own seconds */
this.startOffset = 0;
/** @property {GainNode|undefined} - Gain node for the sound, undefined once it is stopped or paused
* @type {GainNode|undefined} */
this.gainNode = undefined;
/** @property {StereoPannerNode|undefined} - Stereo panner for the sound, undefined once it is stopped or paused
* @type {StereoPannerNode|undefined} */
this.pannerNode = undefined;
/** @property {AudioBufferSourceNode|undefined} - Source node of the audio, undefined while not playing
* @type {AudioBufferSourceNode|undefined} */
this.source = undefined;
/** @property {AudioNode|AudioEffectNodes|undefined} - Node or effect to route this instance through, copied from the sound
* @type {AudioNode|AudioEffectNodes|undefined} */
this.output = sound.output;
/** @property {AudioEndedCallback|undefined} - Called when this instance plays to its end, not when it is stopped
* or paused; it is read when the sound ends, so it can be set at any time
* @type {AudioEndedCallback|undefined} */
this.onendedCallback = undefined;
/** A playback that ends on its own leaves the instance stopped, its time back at 0; the ended event of one
* stopped or replaced since is too late to change anything
* @private */
this.sourceEnded = (source)=>
{
if (source !== this.source) return;
this.source = this.gainNode = this.pannerNode = undefined;
this.startTime = undefined;
this.pausedTime = 0;
this.onendedCallback?.(source);
};
// start sound
if (!paused)
this.start();
}
/** Start playing the sound instance from a place in the sound
* @param {number} [offset] - Where to start in the sound, in its own seconds whatever the rate
*/
start(offset=0)
{
ASSERT(offset >= 0, 'Sound start offset must be positive or zero');
if (this.isPlaying())
this.stop();
this.gainNode = audioContext.createGain();
this.pannerNode = new StereoPannerNode(audioContext, {'pan':clamp(this.pan, -1, 1)});
// build the shared buffer if it was not made at load time, then play it
this.sound.buildSampleBuffer();
this.source = this.sound.sampleBuffer ?
playAudioBuffer(this.sound.sampleBuffer, this.volume, this.rate, this.pan, this.loop, this.gainNode, offset, this.sourceEnded, this.output, this.pannerNode) :
playSamples(this.sound.sampleChannels, this.volume, this.rate, this.pan, this.loop, this.sound.sampleRate, this.gainNode, offset, this.sourceEnded, this.output, this.pannerNode);
audioWaitingInstances.delete(this);
if (this.source)
{
this.startTime = audioContext.currentTime;
this.startOffset = offset;
this.pausedTime = undefined;
}
else
{
// the sound could not start, keep the place so a later resume picks it up,
// which happens on its own when it failed only because audio is not running yet
this.startTime = this.gainNode = this.pannerNode = undefined;
this.pausedTime = offset;
if (!audioIsRunning())
{
// only the newest of each sound waits, a loop a game plays again each frame is one loop
audioWaitingPrune();
for (const other of audioWaitingInstances.keys())
other.sound === this.sound && audioWaitingInstances.delete(other);
audioWaitingInstances.set(this, performance.now());
}
}
}
/** Set the volume of this sound instance, with an optional fade to it
* - A fade ducks music under dialogue or cross fades two tracks without a click
* @param {number} volume
* @param {number} [fadeTime] - Seconds to fade to the new volume over */
setVolume(volume, fadeTime=0)
{
ASSERT(volume >= 0, 'Sound volume must be positive or zero');
ASSERT(fadeTime >= 0, 'Sound fade time must be positive or zero');
this.volume = volume;
this.gainNode && audioParamRamp(this.gainNode.gain, volume, fadeTime);
}
/** Set the stereo pan of this sound instance, while it plays too
* - A looping sound can follow its source across the screen this way
* @param {number} pan - -1 is left, 0 is center, 1 is right, clamped to that range */
setPan(pan)
{
ASSERT(isNumber(pan), 'Sound pan must be a number');
this.pan = pan;
if (this.pannerNode)
this.pannerNode.pan.value = clamp(pan, -1, 1);
}
/** Set the playback rate of this sound instance, its speed and pitch, while it plays
* - A looping sound can follow something smoothly this way, like an engine with the speed
* - A rate of 0 freezes the sound in place, and it carries on from there when the rate comes back
* @param {number} rate - 1 is normal, 2 is twice as fast and an octave up */
setRate(rate)
{
ASSERT(rate >= 0, 'Sound rate must be positive or zero');
// keep the place in the sound, only the speed changes from here
if (this.isPlaying())
{
this.startOffset = this.getCurrentTime();
this.startTime = audioContext.currentTime;
}
this.rate = rate;
if (this.source)
this.source.playbackRate.value = rate;
}
/** Stop this sound instance and reset position to the start
* @param {number} [fadeTime] - Seconds to fade out over before stopping */
stop(fadeTime=0)
{
ASSERT(fadeTime >= 0, 'Sound fade time must be positive or zero');
audioWaitingInstances.delete(this); // a sound waiting for audio to run no longer starts
if (this.isPlaying())
{
if (fadeTime)
{
// ramp off gain from where it is now (not 1, or low-volume
// instances would jump back up before fading, and a volume
// fade in flight carries on down from its current point);
// the ramp and the stop share one end time
this.source.stop(audioParamRamp(this.gainNode.gain, 0, fadeTime));
}
else
this.source.stop();
}
this.pausedTime = 0;
this.source = undefined;
this.startTime = undefined;
// let go of the gain node so a later setVolume can't cancel the fade out, the ended listener disconnects
// it, and start makes a new one
this.gainNode = undefined;
this.pannerNode = undefined;
}
/** Pause this sound instance */
pause()
{
audioWaitingInstances.delete(this); // a sound waiting for audio to run no longer starts
if (this.isPaused()) return;
// save current time and stop sound
this.pausedTime = this.getCurrentTime();
this.source.stop();
this.source = undefined;
this.startTime = undefined;
this.gainNode = undefined; // resume starts with a new one at the volume set meanwhile
this.pannerNode = undefined;
}
/** Resume this sound instance */
resume()
{
if (!this.isPaused()) return;
// restart sound from paused time
this.start(this.pausedTime);
}
/** Check if this instance is currently playing
* @return {boolean} - True if playing
*/
isPlaying() { return !!this.source; }
/** Check if this instance is paused or stopped (not currently playing)
* @return {boolean} - True if not playing
*/
isPaused() { return !this.isPlaying(); }
/** Get where it is in the sound, in the sound's own seconds: at a rate of 2 it moves two seconds for each one
* that passes, and at 0 it stays put
* @return {number} - Seconds into the sound
*/
getCurrentTime()
{
if (!this.isPlaying()) return this.pausedTime;
const duration = this.getDuration();
const place = this.startOffset + (audioContext.currentTime - this.startTime) * this.rate;
return duration ? mod(place, duration) : 0; // a sound still loading has no length yet
}
/** Get the length of the sound in its own seconds, the same at any rate; divide by the rate for how long it takes to play
* @return {number} - Length in seconds (0 if loading)
*/
getDuration() { return this.sound.getDuration(); }
/** Get source of this sound instance
* @return {AudioBufferSourceNode|undefined} - The source, or undefined while not playing
*/
getSource() { return this.source; }
}
///////////////////////////////////////////////////////////////////////////////
/** Speak text with passed in settings
* @param {string} text - The text to speak
* @param {number} [volume] - How much to scale volume by
* @param {number} [rate] - How quickly to speak
* @param {number} [pitch] - How much to change the pitch by
* @param {string} [language] - The language/accent to use (examples: en, it, ru, ja, zh)
* @return {SpeechSynthesisUtterance|undefined} - The utterance that was spoken, or undefined if speech is unavailable
* @memberof Audio */
function speak(text, volume=1, rate=1, pitch=1, language='')
{
ASSERT(typeof volume !== 'string', 'speak() signature changed: language is now the last parameter, after pitch');
if (!soundEnable || headlessMode) return;
if (typeof speechSynthesis === 'undefined') return;
// common languages (not supported by all browsers)
// en - english, it - italian, fr - french, de - german, es - spanish
// ja - japanese, ru - russian, zh - chinese, hi - hindi, ko - korean
// build utterance and speak
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = language;
utterance.volume = clamp(volume*soundVolume);
utterance.rate = rate;
utterance.pitch = pitch;
speechSynthesis.speak(utterance);
return utterance;
}
/** Stop all queued speech
* @memberof Audio */
function speakStop()
{
if (typeof speechSynthesis !== 'undefined')
speechSynthesis.cancel();
}
/** Get frequency of a note on a musical scale
* @param {number} semitoneOffset - How many semitones away from the root note
* @param {number} [rootFrequency] - Frequency at semitone offset 0
* @return {number} - The frequency of the note
* @memberof Audio */
function getNoteFrequency(semitoneOffset, rootFrequency=220)
{ return rootFrequency * 2**(semitoneOffset/12); }
///////////////////////////////////////////////////////////////////////////////
/**
* @callback AudioEndedCallback - Function called when a sound ends
* @param {AudioBufferSourceNode} source
* @memberof Audio
*/
/** Play cached audio samples with given settings
* @param {Array} sampleChannels - Array of arrays of samples to play (for stereo playback)
* @param {number} [volume] - How much to scale volume by
* @param {number} [rate] - The playback rate to use
* @param {number} [pan] - How much to apply stereo panning
* @param {boolean} [loop] - True if the sound should loop when it reaches the end
* @param {number} [sampleRate=44100] - Sample rate for the sound
* @param {GainNode} [gainNode] - Optional gain node for volume control while playing (disconnected when the sound ends)
* @param {number} [offset] - Where to start in the sound, in its own seconds whatever the rate
* @param {AudioEndedCallback} [onended] - Callback for when the sound ends
* @param {AudioNode|AudioEffectNodes} [output] - Node or effect to connect the gain to instead of the master gain
* @param {StereoPannerNode} [pannerNode] - Optional stereo panner for panning while playing, its pan already set (disconnected when the sound ends)
* @return {AudioBufferSourceNode|undefined} - The source node of the sound played, undefined if play fails
* @memberof Audio */
function playSamples(sampleChannels, volume=1, rate=1, pan=0, loop=false, sampleRate=audioDefaultSampleRate, gainNode, offset=0, onended, output, pannerNode)
{
if (!soundEnable || headlessMode) return;
if (!audioIsRunning())
{
// fix stalled audio, don't build a buffer that can't be played;
// but a context suspended because the page is hidden stays suspended until it shows
if (!audioSuspendedWhenHidden)
audioContext.resume();
return;
}
const buffer = createAudioBuffer(sampleChannels, sampleRate);
return playAudioBuffer(buffer, volume, rate, pan, loop, gainNode, offset, onended, output, pannerNode);
}
/** Copy arrays of samples into a new audio buffer
* @param {Array} sampleChannels - Array of arrays of samples (for stereo playback)
* @param {number} [sampleRate=44100] - Sample rate for the sound
* @return {AudioBuffer} - The audio buffer holding the samples
* @memberof Audio */
function createAudioBuffer(sampleChannels, sampleRate=audioDefaultSampleRate)
{
const channelCount = sampleChannels.length;
const sampleLength = sampleChannels[0].length;
const buffer = audioContext.createBuffer(channelCount, sampleLength, sampleRate);
sampleChannels.forEach((c,i)=> buffer.getChannelData(i).set(c));
return buffer;
}
/** Play an audio buffer with given settings
* The buffer can be shared by any number of sounds playing at once
* @param {AudioBuffer} buffer - The audio buffer to play
* @param {number} [volume] - How much to scale volume by
* @param {number} [rate] - The playback rate to use
* @param {number} [pan] - How much to apply stereo panning
* @param {boolean} [loop] - True if the sound should loop when it reaches the end
* @param {GainNode} [gainNode] - Optional gain node for volume control while playing (disconnected when the sound ends)
* @param {number} [offset] - Where to start in the sound, in its own seconds whatever the rate
* @param {AudioEndedCallback} [onended] - Callback for when the sound ends
* @param {AudioNode|AudioEffectNodes} [output] - Node or effect to connect the gain to instead of the master gain
* @param {StereoPannerNode} [pannerNode] - Optional stereo panner for panning while playing, its pan already set (disconnected when the sound ends)
* @return {AudioBufferSourceNode|undefined} - The source node of the sound played, undefined if play fails
* @memberof Audio */
function playAudioBuffer(buffer, volume=1, rate=1, pan=0, loop=false, gainNode, offset=0, onended, output, pannerNode)
{
if (!soundEnable || headlessMode) return;
if (!audioIsRunning())
{
// fix stalled audio, this sound won't be able to play;
// but a context suspended because the page is hidden stays suspended until it shows
if (!audioSuspendedWhenHidden)
audioContext.resume();
return;
}
// setup source, many sources can share one buffer
const source = audioContext.createBufferSource();
source.buffer = buffer;
source.playbackRate.value = rate;
source.loop = loop;
// create and connect gain node
gainNode = gainNode || audioContext.createGain();
gainNode.gain.value = volume;
const outputNode = audioEffectNode(output, 'input') || audioMasterGain;
ASSERT(typeof outputNode.connect === 'function', 'output must be an AudioNode or an effect with input and output nodes');
gainNode.connect(outputNode);
// connect source to stereo panner and gain
const panner = pannerNode || new StereoPannerNode(audioContext, {'pan':clamp(pan, -1, 1)});
source.connect(panner).connect(gainNode);
// disconnect nodes when the sound ends so the audio graph doesn't grow
// unbounded across many play() calls (source.stop() also fires 'ended')
source.addEventListener('ended', ()=>
{
gainNode.disconnect();
panner.disconnect();
if (onended) onended(source);
});
// play and return sound, the offset is a place in the buffer whatever the rate
source.start(0, offset);
if (debug && debugSound)
LOG('sound', 'vol', volume.toFixed(2), 'rate', rate.toFixed(2), 'pan', pan.toFixed(2), loop ? 'loop' : '');
return source;
}
///////////////////////////////////////////////////////////////////////////////
// ZzFXMicro - Zuper Zmall Zound Zynth - v1.3.2 by Frank Force
/** Generate and play a ZzFX sound
*
* <a href=https://killedbyapixel.github.io/ZzFX/>Create sounds using the ZzFX Sound Designer.</a>
* @param {Array} zzfxSound - Array of ZzFX parameters, ex. [.5,.5]
* @return {AudioBufferSourceNode|undefined} - The audio node of the sound played, undefined if play fails
* @memberof Audio */
function zzfx(...zzfxSound) { return playSamples([zzfxG(...zzfxSound)]); }
/** Generate samples for a ZzFX sound
* @param {number} [volume] - Volume scale (percent)
* @param {number} [randomness] - How much to randomize frequency (percent Hz)
* @param {number} [frequency] - Frequency of sound (Hz)
* @param {number} [attack] - Attack time, how fast sound starts (seconds)
* @param {number} [sustain] - Sustain time, how long sound holds (seconds)
* @param {number} [release] - Release time, how fast sound fades out (seconds)
* @param {number} [shape] - Shape of the sound wave
* @param {number} [shapeCurve] - Squareness of wave (0=square, 1=normal, 2=pointy)
* @param {number} [slide] - How much to slide frequency (kHz/s)
* @param {number} [deltaSlide] - How much to change slide (kHz/s/s)
* @param {number} [pitchJump] - Frequency of pitch jump (Hz)
* @param {number} [pitchJumpTime] - Time of pitch jump (seconds)
* @param {number} [repeatTime] - Resets some parameters periodically (seconds)
* @param {number} [noise] - How much random noise to add (percent)
* @param {number} [modulation] - Frequency of modulation wave, negative flips phase (Hz)
* @param {number} [bitCrush] - Resamples at a lower frequency in (samples*100)
* @param {number} [delay] - Overlap sound with itself for reverb and flanger effects (seconds)
* @param {number} [sustainVolume] - Volume level for sustain (percent)
* @param {number} [decay] - Decay time, how long to reach sustain after attack (seconds)
* @param {number} [tremolo] - Trembling effect, rate controlled by repeat time (percent)
* @param {number} [filter] - Filter cutoff frequency, positive for HPF, negative for LPF (Hz)
* @return {Array} - Array of audio samples
* @memberof Audio */
function zzfxG
(
volume = 1,
randomness = .05,
frequency = 220,
attack = 0,
sustain = 0,
release = .1,
shape = 0,
shapeCurve = 1,
slide = 0,
deltaSlide = 0,
pitchJump = 0,
pitchJumpTime = 0,
repeatTime = 0,
noise = 0,
modulation = 0,
bitCrush = 0,
delay = 0,
sustainVolume = 1,
decay = 0,
tremolo = 0,
filter = 0
)
{
// init parameters
let sampleRate = audioDefaultSampleRate,
PI2 = PI*2,
startSlide = slide *= 500 * PI2 / sampleRate / sampleRate,
startFrequency = frequency *=
(1 + rand(randomness,-randomness)) * PI2 / sampleRate,
modOffset = 0, // modulation offset
repeat = 0, // repeat offset
crush = 0, // bit crush offset
jump = 1, // pitch jump timer
length, // sample length
b = [], // sample buffer
t = 0, // sample time
i = 0, // sample index
s = 0, // sample value
f, // wave frequency
// biquad LP/HP filter
quality = 2, w = PI2 * min(abs(filter), sampleRate/4 - 1) * 2 / sampleRate, // stable below a quarter rate
cosw = cos(w), alpha = sin(w) / 2 / quality,
a0 = 1 + alpha, a1 = -2*cosw / a0, a2 = (1 - alpha) / a0,
b0 = (1 + sign(filter) * cosw) / 2 / a0,
b1 = -(sign(filter) + cosw) / a0, b2 = b0,
x2 = 0, x1 = 0, y2 = 0, y1 = 0;
// scale by sample rate
const minAttack = 9; // prevent pop if attack is 0
attack = attack * sampleRate || minAttack;
decay *= sampleRate;
sustain *= sampleRate;
release *= sampleRate;
delay *= sampleRate;
deltaSlide *= 500 * PI2 / sampleRate**3;
modulation *= PI2 / sampleRate;
pitchJump *= PI2 / sampleRate;
pitchJumpTime *= sampleRate;
repeatTime = repeatTime * sampleRate | 0;
// generate waveform
for (length = attack + decay + sustain + release + delay | 0;
i < length; b[i++] = s * volume) // sample
{
if (!(++crush%(bitCrush*100|0))) // bit crush
{
s = shape? shape>1? shape>2? shape>3? shape>4? // wave shape
(t/PI2%1 < shapeCurve/2? 1 : -1) : // 5 square duty
sin(t**3) : // 4 noise
max(min(tan(t),1),-1): // 3 tan
1-(2*t/PI2%2+2)%2: // 2 saw
1-4*abs(round(t/PI2)-t/PI2): // 1 triangle
sin(t); // 0 sin
s = (repeatTime ?
1 - tremolo + tremolo*sin(PI2*i/repeatTime) // tremolo
: 1) *
(shape>4?s:sign(s)*abs(s)**shapeCurve) * // shape curve
(i < attack ? i/attack : // attack
i < attack + decay ? // decay
1-((i-attack)/decay)*(1-sustainVolume) : // decay falloff
i < attack + decay + sustain ? // sustain
sustainVolume : // sustain volume
i < length - delay ? // release
(length - i - delay)/release * // release falloff
sustainVolume : // release volume
0); // post release
s = delay ? s/2 + (delay > i ? 0 : // delay
(i<length-delay? 1 : (length-i)/delay) * // release delay
b[i-delay|0]/2/(volume||1)) : s; // sample delay, stored samples are 0 at volume 0
if (filter) // apply filter
s = y1 = b2*x2 + b1*(x2=x1) + b0*(x1=s) - a2*y2 - a1*(y2=y1);
}
f = (frequency += slide += deltaSlide) *// frequency
cos(modulation*modOffset++); // modulation
t += f + f*noise*sin(i**5); // noise
if (jump && ++jump > pitchJumpTime) // pitch jump
{
frequency += pitchJump; // apply pitch jump
startFrequency += pitchJump; // also apply to start
jump = 0; // stop pitch jump time
}
if (repeatTime && !(++repeat % repeatTime)) // repeat
{
frequency = startFrequency; // reset frequency
slide = startSlide; // reset slide
jump ||= 1; // reset pitch jump time
}
}
return b; // return sample buffer
}