UNPKG

ez-web-audio

Version:

Making the Web Audio API super EZ since 2024.

1,674 lines 121 kB
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