ez-web-audio
Version:
Making the Web Audio API super EZ since 2024.
1,674 lines • 121 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);
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
class Envelope {
/**
* Creates a new Envelope with the specified ADSR parameters.
*
* @param options - ADSR configuration options
*/
constructor(options = {}) {
/** Duration in seconds to ramp from 0 to peak (1.0) */
__publicField(this, "attackTime");
/** Duration in seconds to ramp from peak to sustain level */
__publicField(this, "decayTime");
/** Amplitude level (0-1) held during sustain phase */
__publicField(this, "sustainLevel");
/** Duration in seconds for release to silence */
__publicField(this, "releaseTime");
/** Whether the envelope is currently active (between applyTo and release) */
__publicField(this, "_isActive", false);
/** The time when the current attack phase started */
__publicField(this, "_attackStartTime", 0);
/** The value the attack started from (for retriggering) */
__publicField(this, "_attackStartValue", 0);
this.attackTime = options.attackTime ?? 0.01;
this.decayTime = options.decayTime ?? 0.1;
this.sustainLevel = clamp(options.sustainLevel ?? 0.7, 0, 1);
this.releaseTime = options.releaseTime ?? 0.3;
}
/**
* Whether the envelope is currently active (between applyTo and release).
*/
get isActive() {
return this._isActive;
}
/**
* Estimates the current envelope value at a given time.
*
* Used for retriggering to determine where to pick up from.
* Returns 0 if envelope is not active.
*
* @param currentTime - The time to estimate the value at
* @returns The estimated envelope value (0-1)
*/
estimateCurrentValue(currentTime) {
if (!this._isActive) {
return 0;
}
const timeSinceStart = currentTime - this._attackStartTime;
if (timeSinceStart < 0) {
return 0;
}
const attackEndTime = this.attackTime;
const decayEndTime = attackEndTime + this.decayTime;
if (timeSinceStart < attackEndTime) {
if (this.attackTime === 0) {
return 1;
}
const attackProgress = timeSinceStart / this.attackTime;
return this._attackStartValue + (1 - this._attackStartValue) * attackProgress;
}
if (timeSinceStart < decayEndTime) {
if (this.decayTime === 0) {
return this.sustainLevel;
}
const decayProgress = (timeSinceStart - attackEndTime) / this.decayTime;
return 1 - (1 - this.sustainLevel) * decayProgress;
}
return this.sustainLevel;
}
/**
* Applies the attack-decay-sustain phases to an AudioParam.
*
* Schedules:
* 1. setValueAtTime(startValue, startTime) - Start from current value (0 for first trigger)
* 2. linearRampToValueAtTime(1, startTime + attackTime) - Attack to peak
* 3. linearRampToValueAtTime(sustainLevel, startTime + attackTime + decayTime) - Decay to sustain
*
* If retriggering (envelope already active), cancels scheduled values and
* starts the attack from the current estimated value to prevent clicks.
*
* @param gainParam - The AudioParam to schedule the envelope on (typically gainNode.gain)
* @param startTime - The audio context time to start the envelope
*/
applyTo(gainParam, startTime) {
let startValue = 0;
if (this._isActive) {
const paramWithCancelAndHold = gainParam;
if (typeof paramWithCancelAndHold.cancelAndHoldAtTime === "function") {
paramWithCancelAndHold.cancelAndHoldAtTime(startTime);
startValue = this.estimateCurrentValue(startTime);
} else {
startValue = this.estimateCurrentValue(startTime);
gainParam.cancelScheduledValues(startTime);
}
}
gainParam.setValueAtTime(startValue, startTime);
this._isActive = true;
this._attackStartTime = startTime;
this._attackStartValue = startValue;
const attackEndTime = startTime + this.attackTime;
gainParam.linearRampToValueAtTime(1, attackEndTime);
const decayEndTime = attackEndTime + this.decayTime;
gainParam.linearRampToValueAtTime(this.sustainLevel, decayEndTime);
}
/**
* Applies the release phase to an AudioParam.
*
* Uses setTargetAtTime for smooth exponential decay to zero.
* The time constant is calculated as releaseTime/5, which gives
* approximately 99% completion within releaseTime seconds.
*
* @param gainParam - The AudioParam to schedule the release on
* @param startTime - The audio context time to start the release phase
*/
release(gainParam, startTime) {
const timeConstant = this.releaseTime / 5;
gainParam.setTargetAtTime(0, startTime, timeConstant);
this._isActive = false;
}
}
class CollectionError extends Error {
constructor(message, errors, total) {
super(message);
this.errors = errors;
this.total = total;
this.name = "CollectionError";
}
}
function hasPauseMethod(item) {
return typeof item === "object" && item !== null && "pause" in item && typeof item.pause === "function";
}
async function stopAll(sounds) {
const flattened = sounds.flat(Infinity);
const total = flattened.length;
if (total === 0) {
return;
}
const results = await Promise.allSettled(
flattened.map((sound) => Promise.resolve(sound.stop()))
);
const errors = results.filter((result) => result.status === "rejected").map((result) => result.reason instanceof Error ? result.reason : new Error(String(result.reason)));
if (errors.length > 0) {
throw new CollectionError(
`Failed to stop ${errors.length} of ${total} sounds`,
errors,
total
);
}
}
async function pauseAll(tracks) {
const flattened = tracks.flat(Infinity);
const pausables = flattened.filter(hasPauseMethod);
const total = pausables.length;
if (total === 0) {
return;
}
const results = await Promise.allSettled(
pausables.map((track) => Promise.resolve(track.pause()))
);
const errors = results.filter((result) => result.status === "rejected").map((result) => result.reason instanceof Error ? result.reason : new Error(String(result.reason)));
if (errors.length > 0) {
throw new CollectionError(
`Failed to pause ${errors.length} of ${total} tracks`,
errors,
total
);
}
}
async function playAll(sounds) {
const flattened = sounds.flat(Infinity);
const total = flattened.length;
if (total === 0) {
return;
}
const results = await Promise.allSettled(
flattened.map((sound) => Promise.resolve(sound.play()))
);
const errors = results.filter((result) => result.status === "rejected").map((result) => result.reason instanceof Error ? result.reason : new Error(String(result.reason)));
if (errors.length > 0) {
throw new CollectionError(
`Failed to play ${errors.length} of ${total} sounds`,
errors,
total
);
}
}
function zeroify(input) {
const num = Math.floor(input);
if (num < 10) {
return `0${num}`;
}
return `${num}`;
}
function createTimeObject(raw, minutes, seconds) {
return {
raw,
string: `${zeroify(minutes)}:${zeroify(seconds)}`,
pojo: { minutes, seconds }
};
}
class BaseParamController {
constructor(audioSource, gainNode, pannerNode) {
__publicField(this, "startingValues", []);
__publicField(this, "valuesAtTime", []);
__publicField(this, "exponentialValues", []);
__publicField(this, "linearValues", []);
this.audioSource = audioSource;
this.gainNode = gainNode;
this.pannerNode = pannerNode;
}
// TODO: handle all gainNode and pannerNode props
get gain() {
return this.gainNode.gain.value;
}
set gain(value) {
this.gainNode.gain.value = value;
}
get pan() {
return this.pannerNode.pan.value;
}
set pan(value) {
this.pannerNode.pan.value = value;
}
updateGainNode(gainNode) {
gainNode.gain.value = this.gain;
this.gainNode = gainNode;
}
updatePannerNode(pannerNode) {
pannerNode.pan.value = this.pan;
this.pannerNode = pannerNode;
}
_update(type, value) {
switch (type) {
case "pan":
this.pan = value;
break;
case "gain":
this.gain = value;
break;
case "detune":
if (!this.audioSource.detune)
throw new Error("Audio source does not support detune");
this.audioSource.detune.value = value;
break;
default:
throw new Error(`Control type '${type}' not supported`);
}
}
// TODO: Consider changing 'from' to be something like 'using' or 'as'
update(type) {
return {
to: (value) => {
return {
from: (method) => {
switch (method) {
case "ratio":
this._update(type, value);
break;
case "inverseRatio":
this._update(type, 1 - value);
break;
case "percent":
this._update(type, value / 100);
break;
default:
throw new Error(`Control method '${method}' not supported`);
}
}
};
}
};
}
onPlaySet(type) {
return {
to: (value) => {
const paramValue = { type, value };
this.startingValues.push(paramValue);
return {
at: (time) => {
this.removeStartingValue(paramValue);
this.valuesAtTime.push({ ...paramValue, time });
},
endingAt: (time, rampType = "exponential") => {
this.removeStartingValue(paramValue);
this.addRampValue({ ...paramValue, time }, rampType);
}
};
}
};
}
onPlayRamp(type, rampType) {
return {
from: (startValue) => {
return {
to: (endValue) => {
return {
in: (endTime) => {
this.onPlaySet(type).to(startValue);
this.onPlaySet(type).to(endValue).endingAt(endTime, rampType);
}
};
}
};
}
};
}
removeStartingValue(startValue) {
this.startingValues = this.startingValues.filter((item) => item !== startValue);
}
addRampValue(valueAtTime, rampType) {
switch (rampType) {
case "exponential":
this.exponentialValues.push(valueAtTime);
break;
case "linear":
this.linearValues.push(valueAtTime);
break;
default:
throw new Error(`Unsupported ramp type: ${rampType}`);
}
}
}
class SoundController extends BaseParamController {
constructor(bufferSourceNode, gainNode, pannerNode) {
super(bufferSourceNode, gainNode, pannerNode);
this.bufferSourceNode = bufferSourceNode;
this.gainNode = gainNode;
this.pannerNode = pannerNode;
}
updateAudioSource(source) {
this.bufferSourceNode = source;
}
setValuesAtTimes() {
const { bufferSourceNode } = this;
const currentTime = bufferSourceNode.context.currentTime;
this.applyValues(this.startingValues, currentTime);
this.applyValues(this.valuesAtTime, currentTime);
this.applyRampValues(this.exponentialValues, currentTime, "exponential");
this.applyRampValues(this.linearValues, currentTime, "linear");
}
applyValues(values, currentTime) {
values.forEach((item) => {
switch (item.type) {
case "detune":
this.bufferSourceNode.detune.setValueAtTime(item.value, currentTime);
break;
case "gain":
this.gainNode.gain.setValueAtTime(item.value, currentTime);
break;
default:
throw new Error(`Unsupported control type: ${item.type}`);
}
});
}
applyRampValues(values, currentTime, rampType) {
values.forEach((item) => {
const time = currentTime + item.time;
switch (item.type) {
case "detune":
switch (rampType) {
case "exponential":
this.bufferSourceNode.detune.exponentialRampToValueAtTime(item.value, time);
break;
case "linear":
this.bufferSourceNode.detune.linearRampToValueAtTime(item.value, time);
break;
default:
throw new Error(`Unsupported ramp type: ${rampType}`);
}
break;
case "gain":
switch (rampType) {
case "exponential":
this.gainNode.gain.exponentialRampToValueAtTime(item.value, time);
break;
case "linear":
this.gainNode.gain.linearRampToValueAtTime(item.value, time);
break;
default:
throw new Error(`Unsupported ramp type: ${rampType}`);
}
break;
default:
throw new Error(`ControlType of ${item.type} not supported`);
}
});
}
}
function audioContextAwareTimeout(audioContext2) {
if (!audioContext2) {
console.warn(`ez-web-audio: AudioContext was not available when an entity was created and timing tasks will therefore use javascript native setTimeout instead of AudioContext-aware versions. Please ensure to await initAudio before instantiating any timing-sensitive entities. If your application is behaving as you'd hope, you can safely ignore this message.`);
return {
setTimeout: window.setTimeout.bind(window),
clearTimeout: window.clearTimeout.bind(window)
};
}
let tasks = [];
let nextTaskId = 1;
function now() {
return audioContext2.currentTime * 1e3;
}
function scheduler() {
const currentTime = now();
tasks.forEach((task) => {
if (task.due <= currentTime)
task.fn();
});
tasks = tasks.filter((task) => task.due > currentTime);
if (tasks.length > 0) {
window.requestAnimationFrame(scheduler);
}
}
return {
setTimeout(fn, delayMillis) {
const id = nextTaskId;
nextTaskId += 1;
tasks.push({
id,
due: now() + delayMillis,
fn
});
if (tasks.length === 1) {
window.requestAnimationFrame(scheduler);
}
return id;
},
clearTimeout(id) {
tasks = tasks.filter((t) => t.id !== id);
}
};
}
function formatDebugMessage(msg) {
const prefix = `[ez-audio:${msg.type}]`;
const time = msg.timestamp.toFixed(3);
return `${prefix} [${time}] ${msg.source}: ${msg.message}`;
}
let globalDebugEnabled = false;
let debugHandler = null;
function setGlobalDebug(enabled) {
globalDebugEnabled = enabled;
}
function isGlobalDebugEnabled() {
return globalDebugEnabled;
}
function setHandler(handler) {
debugHandler = handler;
}
function defaultHandler(msg) {
console.log(formatDebugMessage(msg), msg.details ?? "");
}
function log(msg) {
const handler = debugHandler ?? defaultHandler;
handler(msg);
}
function setDebugMode(enabled) {
setGlobalDebug(enabled);
}
function setDebugHandler(handler) {
setHandler(handler);
}
function debugLog(source, message) {
if (!isGlobalDebugEnabled() && !source.debug) return;
if (source.debug === false) return;
log({
...message,
source: message.source ?? source.name ?? "unknown"
});
}
function debugEvent(source, event, timestamp, details) {
debugLog(source, {
type: "event",
message: event,
timestamp,
details
});
}
function debugConnection(source, message, timestamp, details) {
debugLog(source, {
type: "connection",
message,
timestamp,
details
});
}
function debugWarning(source, message, timestamp, details) {
debugLog(source, {
type: "warning",
message,
timestamp,
details
});
}
class BaseSound extends EventTarget {
constructor(audioContext2, opts) {
super();
__publicField(this, "_isPlaying", false);
__publicField(this, "gainNode");
__publicField(this, "pannerNode");
__publicField(this, "setTimeout");
__publicField(this, "startedPlayingAt", 0);
/**
* @property effects
* An array of Effect instances that form the persistent effect chain.
* Effects are wired once and persist across multiple play() calls - only the source reconnects.
*
* Use addEffect() and removeEffect() to manage the effect chain.
* Chain order: source -> effectChainInput -> [effects] -> gainNode -> pannerNode -> destination
*/
__publicField(this, "effects", []);
/**
* @property effectChainInput
* The entry point for the effect chain. The audio source connects to this node,
* which then routes through any effects before reaching gain/panner/destination.
*/
__publicField(this, "effectChainInput");
/**
* @property _destination
* The final destination node for audio output. Defaults to audioContext.destination.
* Can be changed with setDestination() to route audio elsewhere (e.g., for sub-mixing).
*/
__publicField(this, "_destination");
/**
* @property _analyzer
* Optional Analyzer attached to the end of the signal chain for visualization.
* Audio flows through the analyzer (passthrough) before reaching destination.
*/
__publicField(this, "_analyzer", null);
/**
* @property connections
* An array of connections that will be placed in between the `audioSourceNode` (where the audio comes from) and the gain/panner nodes.
*
* This is useful for adding effects to a sound. For example, to add a reverb effect, you can create a `ConvolverNode` and add it to this array.
*
* The `audioSourceNode` is mandatory and always first, and the gain/panner nodes are mandatory and always last, but the nodes in between can be in any order.
*
* You can use the `addConnection` and `removeConnection` methods to add and remove connections from this array, or you can set/mutate the array directly.
*
* The `wireConnections` method is called automatically when the sound is played, and it will connect all the nodes in this array in the correct order.
*
* @deprecated Use addEffect() instead for the new persistent effect chain system.
*
* @example
* const sound = new Oscillator(audioContext, { type: 'sine', frequency: 440 })
* const convolverNode = audioContext.createConvolver()
* sound.connections = [convolverNode]
* sound.play()
*
*/
__publicField(this, "connections", []);
/**
* @property startOffset
*
* See Web Audio API documentation for this one, as it is just passed into the `start` method of the `audioSourceNode`.
*
* This is useful for starting a sound at a specific offset from the beginning of the sound. Manipulation of this value is used
* extensively in the `Track` class to allow for starting the track at specific positions.
*
* @default 0
* @see https://developer.mozilla.org/en-US/docs/Web/API/AudioScheduledSourceNode/start
*/
__publicField(this, "startOffset", 0);
/**
* @property name
*
* A name for this sound. Optional. Useful for identification of a given sound and debugging.
*/
__publicField(this, "name");
/**
* @property debug
*
* Per-sound debug override. Set to true to enable debug logging for this sound only,
* or false to disable logging even when global debug is enabled.
*
* @default undefined (follows global debug mode)
*
* @example
* sound.debug = true // enable debug for this sound
* sound.debug = false // silence this sound even when global debug is on
*/
__publicField(this, "debug");
this.audioContext = audioContext2;
const gainNode = audioContext2.createGain();
const pannerNode = audioContext2.createStereoPanner();
this.gainNode = gainNode;
this.pannerNode = pannerNode;
this.effectChainInput = audioContext2.createGain();
this._destination = audioContext2.destination;
this.wireEffectChain();
this.name = (opts == null ? void 0 : opts.name) || "";
if (opts == null ? void 0 : opts.setTimeout) {
this.setTimeout = opts.setTimeout;
} else {
this.setTimeout = audioContextAwareTimeout(audioContext2).setTimeout;
}
}
// ===== Effect Chain System =====
/**
* Wires the effect chain from effectChainInput through all non-bypassed effects
* to gainNode -> pannerNode -> [analyzer] -> destination.
*
* If an analyzer is attached, audio flows through it before reaching destination.
* The analyzer is a passthrough node that also provides visualization data.
*
* Called when effects are added/removed/reordered, destination changes, or analyzer changes.
* NOT called on every play() - the chain persists.
*
* @private
*/
wireEffectChain() {
const { effectChainInput, effects, gainNode, pannerNode, _destination, _analyzer } = this;
try {
effectChainInput.disconnect();
} catch {
}
for (const effect of effects) {
try {
effect.output.disconnect();
} catch {
}
}
try {
gainNode.disconnect();
} catch {
}
try {
pannerNode.disconnect();
} catch {
}
if (_analyzer) {
try {
_analyzer.input.disconnect();
} catch {
}
}
let currentNode = effectChainInput;
for (const effect of effects) {
if (!effect.bypass) {
currentNode.connect(effect.input);
currentNode = effect.output;
}
}
currentNode.connect(gainNode);
gainNode.connect(pannerNode);
if (_analyzer) {
pannerNode.connect(_analyzer.input);
_analyzer.input.connect(_destination);
} else {
pannerNode.connect(_destination);
}
}
/**
* Add an effect to the effect chain.
* Effects persist across multiple play() calls.
*
* @param effect - The Effect instance to add
* @param position - Optional index to insert at (defaults to end of chain)
* @returns this for chaining
*
* @example
* const filter = createFilterEffect(audioContext, 'lowpass', { frequency: 1000 })
* sound.addEffect(filter)
*/
addEffect(effect, position) {
if (position !== void 0 && position >= 0 && position <= this.effects.length) {
this.effects.splice(position, 0, effect);
} else {
this.effects.push(effect);
}
this.wireEffectChain();
debugConnection(
this,
`Effect added${position !== void 0 ? ` at position ${position}` : ""}`,
this.audioContext.currentTime,
{
effectCount: this.effects.length,
effects: this.effects.map((e, i) => `[${i}] ${e.bypass ? "(bypassed)" : "active"}`)
}
);
return this;
}
/**
* Remove an effect from the effect chain.
*
* @param effect - The Effect instance to remove
* @returns this for chaining
*
* @example
* sound.removeEffect(filter)
*/
removeEffect(effect) {
const index = this.effects.indexOf(effect);
if (index > -1) {
this.effects.splice(index, 1);
this.wireEffectChain();
debugConnection(
this,
"Effect removed",
this.audioContext.currentTime,
{
effectCount: this.effects.length,
effects: this.effects.map((e, i) => `[${i}] ${e.bypass ? "(bypassed)" : "active"}`)
}
);
}
return this;
}
/**
* Get a readonly copy of the current effects array.
*
* @returns Shallow copy of the effects array
*/
getEffects() {
return [...this.effects];
}
/**
* Set a custom destination for audio output instead of audioContext.destination.
* Useful for routing to sub-mixes, analyzers, or other processing chains.
*
* @param node - The AudioNode to route output to
* @returns this for chaining
*
* @example
* const analyzer = audioContext.createAnalyser()
* analyzer.connect(audioContext.destination)
* sound.setDestination(analyzer)
*/
setDestination(node) {
this._destination = node;
this.wireEffectChain();
return this;
}
/**
* Re-wire the effect chain. Call this after toggling effect.bypass
* to update the audio routing.
*/
rewireEffects() {
this.wireEffectChain();
}
// ===== Analyzer System =====
/**
* Attach an analyzer to this sound for visualization.
* The analyzer is inserted at the end of the signal chain (after effects and panner,
* before destination), showing the fully processed signal.
*
* The analyzer is a passthrough node - audio flows through it unchanged while
* providing frequency and waveform data for visualization.
*
* @param analyzer - The Analyzer instance to attach, or null to detach
* @returns this for chaining
*
* @example
* const analyzer = createAnalyzer(audioContext, { fftSize: 2048 })
* sound.setAnalyzer(analyzer)
*
* function draw() {
* const freqData = analyzer.getFrequencyData()
* // Draw frequency bars
* requestAnimationFrame(draw)
* }
*/
setAnalyzer(analyzer) {
this._analyzer = analyzer;
this.wireEffectChain();
return this;
}
/**
* Get the currently attached analyzer, if any.
*
* @returns The attached Analyzer instance, or null if none attached
*/
getAnalyzer() {
return this._analyzer;
}
addEventListener(type, listener, options) {
super.addEventListener(type, listener, options);
}
removeEventListener(type, listener, options) {
super.removeEventListener(type, listener, options);
}
/**
* Emit a typed event with the given detail.
* @protected
* @param type - The event type to emit
* @param detail - The event detail object
*/
emit(type, detail) {
const event = new CustomEvent(type, { detail });
this.dispatchEvent(event);
}
// ===== Convenience Methods (.on/.once/.off) =====
/**
* Subscribe to one or more events. Supports chaining.
*
* @example
* ```typescript
* sound.on('play', handlePlay).on('stop', handleStop);
* sound.on(['play', 'stop'], handleBoth);
* ```
*
* @param type - The event type(s) to subscribe to
* @param listener - The event handler function
* @returns this for chaining
*/
on(type, listener) {
if (Array.isArray(type)) {
type.forEach((t) => this.addEventListener(t, listener));
} else {
this.addEventListener(type, listener);
}
return this;
}
/**
* Subscribe to an event once. Handler is removed after first invocation.
*
* @example
* ```typescript
* sound.once('end', () => console.log('Playback finished'));
* ```
*
* @param type - The event type to subscribe to
* @param listener - The event handler function
* @returns this for chaining
*/
once(type, listener) {
this.addEventListener(type, listener, { once: true });
return this;
}
/**
* Unsubscribe from an event.
*
* Note: Due to native EventTarget limitations, you must provide the same
* listener function reference that was used when subscribing. To remove
* listeners, store the function reference when adding it.
*
* @example
* ```typescript
* const handler = (e) => console.log(e.detail);
* sound.on('play', handler);
* // later...
* sound.off('play', handler);
* ```
*
* @param type - The event type to unsubscribe from
* @param listener - The event handler function to remove
* @returns this for chaining
*/
off(type, listener) {
this.removeEventListener(type, listener);
return this;
}
addConnection(connection) {
this.connections.push(connection);
this.wireConnections();
debugConnection(
this,
`Connection added: ${connection.name ?? "unnamed"}`,
this.audioContext.currentTime,
{
connectionCount: this.connections.length,
connections: this.connections.map((c, i) => `[${i}] ${c.name ?? "unnamed"}`)
}
);
return this;
}
removeConnection(name) {
const connection = this.getConnection(name);
if (connection) {
const index = this.connections.indexOf(connection);
if (index > -1) {
this.connections.splice(index, 1);
this.wireConnections();
debugConnection(
this,
`Connection removed: ${name}`,
this.audioContext.currentTime,
{
connectionCount: this.connections.length,
connections: this.connections.map((c, i) => `[${i}] ${c.name ?? "unnamed"}`)
}
);
}
}
return this;
}
// Allows you to get any user created connection in the connections array
getConnection(name) {
return this.connections.find((c) => c.name === name);
}
// Allows you to get node from any user created connection in the connections array
getNodeFrom(connectionName) {
var _a;
return (_a = this.getConnection(connectionName)) == null ? void 0 : _a.audioNode;
}
/**
* Update an audio parameter immediately.
*
* Returns a fluent builder for setting the parameter value. Use `.to(value)`
* to set the value, then `.from(unit)` for unit interpretation.
*
* @param type - The parameter to update ('gain' or 'pan')
* @returns Fluent builder for setting the value
*
* @example
* ```typescript
* // Set gain to 50%
* sound.update('gain').to(0.5).from('ratio')
*
* // Set pan to left
* sound.update('pan').to(-1).from('ratio')
* ```
*/
update(type) {
return this.controller.update(type);
}
/**
* Set the pan position immediately.
*
* Convenience method for `update('pan').to(value).from('ratio')`.
*
* @param value - Pan position from -1 (left) to 1 (right), 0 is center
* @returns this for chaining
*
* @example
* ```typescript
* sound.changePanTo(-1) // Hard left
* sound.changePanTo(0) // Center
* sound.changePanTo(1) // Hard right
* ```
*/
changePanTo(value) {
this.controller.update("pan").to(value).from("ratio");
return this;
}
/**
* Set the gain (volume) immediately.
*
* Convenience method for `update('gain').to(value).from('ratio')`.
*
* @param value - Gain from 0 (silent) to 1 (full volume)
* @returns this for chaining
*
* @example
* ```typescript
* sound.changeGainTo(0.5) // Half volume
* sound.changeGainTo(0) // Muted
* sound.changeGainTo(1) // Full volume
* ```
*/
changeGainTo(value) {
this.controller.update("gain").to(value).from("ratio");
return this;
}
/**
* Schedule a parameter value to be set when play() is called.
*
* Use this for fade-ins, fade-outs, or precise parameter timing.
* The value is applied relative to when play() is called.
*
* @param type - The parameter to control ('gain' or 'pan')
* @returns Fluent builder for setting value and timing
*
* @example
* ```typescript
* // Fade in: start at 0, ramp to 1 over 0.5 seconds
* sound.onPlaySet('gain').to(0).at(0)
* sound.onPlaySet('gain').to(1).endingAt(0.5, 'linear')
* sound.play()
*
* // Start panned left, move to center over 2 seconds
* sound.onPlaySet('pan').to(-1).at(0)
* sound.onPlaySet('pan').to(0).endingAt(2, 'linear')
* sound.play()
* ```
*/
onPlaySet(type) {
return this.controller.onPlaySet(type);
}
/**
* Schedule a parameter ramp when play() is called.
*
* Use this for smooth transitions like vibrato, tremolo, or automation.
*
* @param type - The parameter to ramp ('gain' or 'pan')
* @param rampType - Type of ramp curve ('linear' or 'exponential')
* @returns Fluent builder for setting start value, end value, and duration
*
* @example
* ```typescript
* // Fade out over 2 seconds
* sound.onPlayRamp('gain', 'linear').from(1).to(0).in(2)
* sound.play()
*
* // Pan sweep from left to right over 4 seconds
* sound.onPlayRamp('pan', 'linear').from(-1).to(1).in(4)
* sound.play()
* ```
*/
onPlayRamp(type, rampType) {
return this.controller.onPlayRamp(type, rampType);
}
async play() {
await this.playAt(this.audioContext.currentTime);
}
playIn(when) {
this.playAt(this.audioContext.currentTime + when);
}
playFor(duration) {
this.playAt(this.audioContext.currentTime);
this.setTimeout(() => this.stop(), duration * 1e3);
}
/**
* Play after a delay, then stop after a duration.
*
* Combines playIn() and stopIn() for precise timed playback.
*
* @param playIn - Seconds from now until playback starts
* @param stopAfter - Seconds of playback before stopping (from play start)
*
* @example
* ```typescript
* // Start in 1 second, play for 3 seconds
* sound.playInAndStopAfter(1, 3)
* ```
*/
playInAndStopAfter(playIn, stopAfter) {
this.playIn(playIn);
this.stopIn(playIn + stopAfter);
}
/**
* Play the audio source at a specific time.
*
* This is the underlying method for all play variants. Time is measured in seconds
* from when the AudioContext was created (audioContext.currentTime).
*
* @param time - The AudioContext time when playback should start
*
* @example
* ```typescript
* // Play immediately
* sound.playAt(audioContext.currentTime)
*
* // Play in 2 seconds
* sound.playAt(audioContext.currentTime + 2)
*
* // Sync multiple sounds
* const startTime = audioContext.currentTime + 0.1
* sound1.playAt(startTime)
* sound2.playAt(startTime)
* ```
*/
async playAt(time) {
const { audioContext: audioContext2 } = this;
const { currentTime } = audioContext2;
const duration = this.duration.raw;
if (audioContext2.state === "suspended") {
debugWarning(this, "AudioContext suspended - call initAudio() first", currentTime);
}
await audioContext2.resume();
this.setup();
this.emit("play", {
time: currentTime,
source: this
});
debugEvent(this, "play", currentTime, { startOffset: this.startOffset });
this.audioSourceNode.start(time, this.startOffset);
this.startedPlayingAt = time;
this.audioSourceNode.onended = () => {
if (this._isPlaying) {
this._isPlaying = false;
this.emit("end", {
time: this.audioContext.currentTime,
source: this,
duration: this.duration.raw
});
debugEvent(this, "end", this.audioContext.currentTime, { duration: this.duration.raw });
}
};
if (duration && Number.isFinite(duration)) {
this.setTimeout(() => {
this._isPlaying = false;
}, this.duration.pojo.seconds * 1e3);
}
if (time <= currentTime) {
this._isPlaying = true;
} else {
this.setTimeout(() => {
this._isPlaying = true;
}, (time - currentTime) * 1e3);
}
this._onPlaybackStarted();
}
/**
* Hook method called after playback starts.
* Override in subclasses to add behavior that runs for all play variants.
* @protected
*/
_onPlaybackStarted() {
}
/**
* Stop the audio source after a delay.
*
* @param seconds - Seconds from now until playback stops
*
* @example
* ```typescript
* sound.play()
* // Stop after 5 seconds
* sound.stopIn(5)
* ```
*/
async stopIn(seconds) {
await this.stopAt(this.audioContext.currentTime + seconds);
}
/**
* Stop the audio source at a specific time.
*
* This is the underlying method for all stop variants. Time is measured in seconds
* from when the AudioContext was created (audioContext.currentTime).
*
* @param time - The AudioContext time when playback should stop
*
* @example
* ```typescript
* // Stop immediately
* sound.stopAt(audioContext.currentTime)
*
* // Stop in 5 seconds
* sound.stopAt(audioContext.currentTime + 5)
* ```
*/
async stopAt(time) {
await this.audioContext.resume();
const node = this.audioSourceNode;
const currentTime = this.audioContext.currentTime;
const stop = () => {
if (this._isPlaying) {
this._isPlaying = false;
this.emit("stop", {
time: this.audioContext.currentTime,
source: this
});
debugEvent(this, "stop", this.audioContext.currentTime);
node.stop(time);
}
};
if (time === currentTime) {
stop();
} else {
this.setTimeout(() => {
stop();
}, (time - currentTime) * 1e3);
}
}
async stop() {
await this.stopAt(this.audioContext.currentTime);
}
get isPlaying() {
return this._isPlaying;
}
get percentGain() {
return this.controller.gain * 100;
}
later(fn) {
this.setTimeout(fn, 1);
}
}
class Sound extends BaseSound {
/**
* Create a Sound instance.
*
* Note: Use {@link createSound} factory function instead of calling this directly.
*
* @param audioContext - The AudioContext to use for audio operations
* @param audioBuffer - The decoded audio data to play
* @param opts - Optional configuration (name, setTimeout override)
*/
constructor(audioContext2, audioBuffer, opts) {
super(audioContext2, opts);
/** The underlying AudioBufferSourceNode that plays the audio. */
__publicField(this, "audioSourceNode");
/** Controller for managing gain, pan, and other audio parameters. */
__publicField(this, "controller");
this.audioBuffer = audioBuffer;
const audioSourceNode = audioContext2.createBufferSource();
audioSourceNode.buffer = audioBuffer;
this.audioSourceNode = audioSourceNode;
this.audioBuffer = audioBuffer;
this.controller = new SoundController(this.audioSourceNode, this.gainNode, this.pannerNode);
}
/**
* Set up a new AudioBufferSourceNode for playback.
* Called automatically before each play() - creates fresh source nodes
* since AudioBufferSourceNode is single-use.
* @protected
*/
setup() {
if (this.audioSourceNode) {
try {
this.audioSourceNode.disconnect();
this.audioSourceNode.onended = null;
} catch {
}
}
const audioSourceNode = this.audioContext.createBufferSource();
audioSourceNode.buffer = this.audioBuffer;
this.audioSourceNode = audioSourceNode;
this.wireConnections();
this.controller.setValuesAtTimes();
audioSourceNode.onended = () => {
try {
audioSourceNode.disconnect();
audioSourceNode.onended = null;
} catch {
}
};
}
/**
* Wire audio source through connections to the effect chain.
* @protected
*/
wireConnections() {
const { connections, effectChainInput, audioSourceNode } = this;
if (connections.length === 0) {
audioSourceNode.connect(effectChainInput);
} else {
const nodes = [audioSourceNode];
for (let i = 0; i < connections.length; i++) {
nodes.push(connections[i].audioNode);
}
nodes.push(effectChainInput);
for (let i = 0; i < nodes.length - 1; i++) {
nodes[i].connect(nodes[i + 1]);
}
}
}
/**
* Get the duration of the audio buffer.
*
* Returns a TimeObject with the duration in multiple formats:
* - `raw`: Duration in seconds
* - `string`: Formatted as 'MM:SS'
* - `pojo`: Object with `minutes` and `seconds` properties
*
* @example
* ```typescript
* const sound = await createSound('song.mp3')
* console.log(sound.duration.raw) // 180.5
* console.log(sound.duration.string) // '3:00'
* console.log(sound.duration.pojo) // { minutes: 3, seconds: 0 }
* ```
*/
get duration() {
const buffer = this.audioSourceNode.buffer;
if (buffer === null)
return createTimeObject(0, 0, 0);
const { duration } = buffer;
const min = Math.floor(duration / 60);
const sec = duration % 60;
return createTimeObject(duration, min, sec);
}
}
const frequencyMap = {
C0: 16.35,
// 'C#0': 17.32,
Db0: 17.32,
D0: 18.35,
// 'D#0': 19.45,
Eb0: 19.45,
E0: 20.6,
F0: 21.83,
// 'F#0': 23.12,
Gb0: 23.12,
G0: 24.5,
// 'G#0': 25.96,
Ab0: 25.96,
A0: 27.5,
// 'A#0': 29.14,
Bb0: 29.14,
B0: 30.87,
C1: 32.7,
// 'C#1': 34.65,
Db1: 34.65,
D1: 36.71,
// 'D#1': 38.89,
Eb1: 38.89,
E1: 41.2,
F1: 43.65,
// 'F#1': 46.25,
Gb1: 46.25,
G1: 49,
// 'G#1': 51.91,
Ab1: 51.91,
A1: 55,
// 'A#1': 58.27,
Bb1: 58.27,
B1: 61.74,
C2: 65.41,
// 'C#2': 69.3,
Db2: 69.3,
D2: 73.42,
// 'D#2': 77.78,
Eb2: 77.78,
E2: 82.41,
F2: 87.31,
// 'F#2': 92.5,
Gb2: 92.5,
G2: 98,
// 'G#2': 103.83,
Ab2: 103.83,
A2: 110,
// 'A#2': 116.54,
Bb2: 116.54,
B2: 123.47,
C3: 130.81,
// 'C#3': 138.59,
Db3: 138.59,
D3: 146.83,
// 'D#3': 155.56,
Eb3: 155.56,
E3: 164.81,
F3: 174.61,
// 'F#3': 185,
Gb3: 185,
G3: 196,
// 'G#3': 207.65,
Ab3: 207.65,
A3: 220,
// 'A#3': 233.08,
Bb3: 233.08,
B3: 246.94,
C4: 261.63,
// 'C#4': 277.18,
Db4: 277.18,
D4: 293.66,
// 'D#4': 311.13,
Eb4: 311.13,
E4: 329.63,
F4: 349.23,
// 'F#4': 369.99,
Gb4: 369.99,
G4: 392,
// 'G#4': 415.3,
Ab4: 415.3,
A4: 440,
// 'A#4': 466.16,
Bb4: 466.16,
B4: 493.88,
C5: 523.25,
// 'C#5': 554.37,
Db5: 554.37,
D5: 587.33,
// 'D#5': 622.25,
Eb5: 622.25,
E5: 659.26,
F5: 698.46,
// 'F#5': 739.99,
Gb5: 739.99,
G5: 783.99,
// 'G#5': 830.61,
Ab5: 830.61,
A5: 880,
// 'A#5': 932.33,
Bb5: 932.33,
B5: 987.77,
C6: 1046.5,
// 'C#6': 1108.73,
Db6: 1108.73,
D6: 1174.66,
// 'D#6': 1244.51,
Eb6: 1244.51,
E6: 1318.51,
F6: 1396.91,
// 'F#6': 1479.98,
Gb6: 1479.98,
G6: 1567.98,
// 'G#6': 1661.22,
Ab6: 1661.22,
A6: 1760,
// 'A#6': 1864.66,
Bb6: 1864.66,
B6: 1975.53,
C7: 2093,
// 'C#7': 2217.46,
Db7: 2217.46,
D7: 2349.32,
// 'D#7': 2489.02,
Eb7: 2489.02,
E7: 2637.02,
F7: 2793.83,
// 'F#7': 2959.96,
Gb7: 2959.96,
G7: 3135.96,
// 'G#7': 3322.44,
Ab7: 3322.44,
A7: 3520,
// 'A#7': 3729.31,
Bb7: 3729.31,
B7: 3951.07,
C8: 4186.01,
// 'C#8': 4434.92,
Db8: 4434.92,
D8: 4698.64,
// 'D#8': 4978.03,
Eb8: 4978.03
};
function get(obj, path) {
return path.split(".").reduce((acc, key) => acc && acc[key], obj);
}
class AudioError extends Error {
/**
* Create a new AudioError.
*
* @param message - Human-readable error description with actionable fix
* @param code - Optional error code for programmatic handling
*/
constructor(message, code) {
var _a;
super(message);
this.code = code;
this.name = "AudioError";
if ("captureStackTrace" in Error) {
(_a = Error.captureStackTrace) == null ? void 0 : _a.call(Error, this, this.constructor);
}
}
}
class AudioContextError extends AudioError {
/**
* Create a new AudioContextError.
*
* @param message - Human-readable error description with actionable fix
* @param state - The current AudioContext state when the error occurred
*/
constructor(message, state) {
super(message, "CONTEXT_ERROR");
this.state = state;
this.name = "AudioContextError";
}
}
class AudioLoadError extends AudioError {
/**
* Create a new AudioLoadError.
*
* @param message - Human-readable error description with actionable fix
* @param url - The URL that failed to load
*/
constructor(message, url) {
super(message, "LOAD_ERROR");
this.url = url;
this.name = "AudioLoadError";
}
}
class InvalidNoteError extends AudioError {
/**
* Create a new InvalidNoteError.
*
* @param message - Human-readable error description with actionable fix
* @param identifier - The invalid note identifier that was provided
*/
constructor(message, identifier) {
super(message, "INVALID_NOTE");
this.identifier = identifier;
this.name = "InvalidNoteError";
}
}
const { warn } = console;
function MusicallyAware(Base) {
return class MusicalIdentity extends Base {
constructor(...args) {
super(...args);
/**
* The note letter (A-G). For note "Ab5", this would be "A".
*/
__publicField(this, "letter", "A");
/**
* The accidental: "" (natural), "b" (flat), or "#" (sharp).
* For note "Ab5", this would be "b".
*/
__publicField(this, "accidental", "");
/**
* The octave (0-8). For note "Ab5", this would be "5".
*/
__publicField(this, "octave", "0");
const opts = args[args.length - 1];
if (opts) {
const { identifier, frequency, letter, accidental, octave } = opts;
if (identifier && frequency || (identifier || frequency) && (letter || accidental || octave)) {
warn("ez-web-audio: upon instantiation, multiple note identifiers were provided which might be a mistake and ez-web-audio has no way to determine which should be preferred", opts, this);
}
if (identifier)
this.identifier = identifier;
if (frequency)
this.frequency = frequency;
if (letter)
this.letter = letter;
if (accidental)
this.accidental = accidental;
if (octave)
this.octave = octave;
}
}
/**
* The note name without octave (e.g., "A" or "Ab").
* Computed from letter and accidental.
*/
get name() {
const { accidental, letter } = this;
if (accidental) {
return `${letter}${accidental}`;
} else {
return letter;
}
}
/**
* The frequency of the note in hertz.
*
* Computed from the note identifier using standard piano frequencies.
* Setting this value updates all other properties to match.
*
* @example
* ```typescript
* note.frequency = 440 // Sets to A4
* console.log(note.identifier) // "A4"
* ```
*/
get frequency() {
const { identifier } = this;
if (identifier) {
return get(frequencyMap, identifier) || 0;
}
return 0;
}
set frequency(value) {
let key;
for (key in frequencyMap) {
if (value === get(frequencyMap, key)) {
this.identifier = key;
}
}
}
/**
* The full note identifier (e.g., "A4", "Bb3", "C#5").
*
* Computed from letter, accidental, and octave.
* Setting this value updates all other properties to match.
*
* @example
* ```typescript
* note.identifier = 'Bb3'
* console.log(note.letter) // "B"
* console.log(note.accidental) // "b"
* console.log(note.octave) // "3"
* console.log(note.frequency) // 233.08
* ```
*/
get identifier() {
const { accidental, letter, octave } = this;
let output = "A0";
if (accidental) {
output = `${letter}${accidental}${octave}`;
} else {
output = `${letter}${octave}`;
}
if (get(frequencyMap, output)) {
return output;
} else {
throw new InvalidNoteError(
`Invalid note: "${output}". Expected format: Letter + optional accidental + octave (e.g., A4, Bb3, C#5).`,
output
);
}
}
set identifier(value) {
const [letter] = value;
const octave = value[2] || value[1];
let accidental;
if (value[2]) {
accidental = value[1];
} else {
accidental = "";
}
this.letter = letter;
this.accidental = accidental;
this.octave = octave;
}
};
}
class SampledNote extends MusicallyAware(Sound) {
}
class Font {
/**
* Array of all SampledNote instances in this font.
*/
constructor(notes) {
this.notes = notes;
}
/**
* Get a note by its identifier.
*
* @param identifier - Note identifier like "A4", "Bb3", "C#5"
* @returns The SampledNote instance, or undefined if not found
*
* @example
* ```typescript
* const note = font.getNote('A4')
* if (note) {
* console.log(note.frequency) // 440
* note.play()
* }
* ```
*/
getNote(identifier) {
return this.notes.find((note) => note.identifier === identifier);
}
/**
* Play a note by its identifier.
*
* @param identifier - Note identifier like "A4", "Bb3", "C#5"
* @throws Error if note identifier not found in font
*
* @example
* ```typescript
* // Play a chord
* font.play('C4')
* font.play('E4')
* font.play('G4')
* ```
*/
play(identifier) {
const note = this.getNote(identifier);
if (!note) {
throw new Error(`EZ Web Audio: No note with identifier ${identifier} found.`);
}
note.play();
}
}
function base64ToUint8(base64String) {
return new Uint8Array(
atob(base64String).split("").map((char) => char.charCodeAt(0))
);
}
function mungeSoundFont(soundfont) {
const begin = soundfont.indexOf("=", soundfont.indexOf("MIDI.Soundfont.")) + 2;
const end = soundfont.lastIndexOf('"') + 1;
const string = `${soundfont.slice(begin, end)}}`.replace(/data:audio\/mp3;base64,/g, "").replace(/data:audio\/mpeg;base64,/g, "").replace(/data:audio\/ogg;base64,/g, "");
return JSON.parse(string);
}
function arraySwap(arr, index) {
const endOfArr = arr.slice(0, index);
const beginOfArr = arr.slice(index, arr.length);
beginOfArr.push(...endOfArr);
return beginOfArr;
}
function unique(arr) {
return [...new Set(arr)];
}
function sortNotes(notes) {
let sortedNotes = extractOctaves(notes);
sortedNotes = stripDuplicateOctaves(sortedNotes);
let octavesWithNotes = createOctavesWithNotes(sortedNotes);
octavesWithNotes = octaveSort(octavesWithNotes);
octavesWithNotes = octaveShift(octavesWithNotes);
return octavesWithNotes.flat();
}
function octaveShift(octaves) {
const firstOctave = octaves.shift() || [];
const secondOctaveNames = octaves[0].map((note) => note.name);
const lastNote = firstOctave[firstOctave.length - 1].name;
const indexToShiftAt = secondOctaveNames.lastIndexOf(lastNote) + 1;
console.log(secondOctaveNames);
const result = octaves.map((octave