The Web Audio API has evolved into a powerful platform for creating immersive audio experiences directly in the browser. In this third part of our series, we'll explore advanced concepts that elevate web audio applications from simple sound players to professional-grade audio environments.
3D Spatial Audio: Creating Immersive Soundscapes
Understanding Spatial Audio Fundamentals
Spatial audio creates the illusion that sounds exist in three-dimensional space around the listener. This technology leverages our brain's ability to localize sound based on subtle timing, level, and spectral differences between our ears.
The Web Audio API provides robust tools for creating spatial audio experiences through the PannerNode and related interfaces. Let's explore how to bring sounds to life in 3D space.
Implementing 3D Positioning with PannerNode
The PannerNode is your gateway to positioning sounds in 3D space. Here's a simple example:
const audioContext = new AudioContext();
const oscillator = audioContext.createOscillator();
oscillator.frequency.value = 440;
const panner = audioContext.createPanner();
panner.panningModel = 'HRTF';
panner.distanceModel = 'inverse';
panner.refDistance = 1;
panner.maxDistance = 10000;
panner.rolloffFactor = 1;
panner.positionX.value = 0;
panner.positionY.value = 0;
panner.positionZ.value = -5;
oscillator.connect(panner);
panner.connect(audioContext.destination);
oscillator.start();
Distance-Based Effects and Attenuation
In real-world acoustics, sounds attenuate (reduce in volume) as they move farther from the listener. The Web Audio API models this naturally through the PannerNode's distance models:
panner.distanceModel = 'inverse';
panner.refDistance = 1;
panner.maxDistance = 10000;
panner.rolloffFactor = 1;
Creating Moving Sound Sources with Doppler Effect
To create the sensation of a moving sound source, we need to animate both its position and velocity properties:
panner.positionX.value = -10;
panner.positionY.value = 0;
panner.positionZ.value = 0;
panner.velocityX.value = 10;
panner.velocityY.value = 0;
panner.velocityZ.value = 0;
function animateSound() {
const x = panner.positionX.value;
panner.positionX.value = x + 0.1;
if (x < 10) {
requestAnimationFrame(animateSound);
}
}
animateSound();
Enhanced Spatial Realism with HRTF
The Head-Related Transfer Function (HRTF) models how sounds reach our ears based on their origin in 3D space. Enabling HRTF in the Web Audio API significantly improves spatial realism:
panner.panningModel = 'HRTF';
Creating Directional Sound Sources
Sound sources can be directional, projecting sound primarily in one direction:
panner.coneInnerAngle = 40;
panner.coneOuterAngle = 180;
panner.coneOuterGain = 0.1;
Integrating with WebXR for Immersive Experiences
For truly immersive experiences, we can combine spatial audio with WebXR:
if (navigator.xr) {
navigator.xr.requestSession('immersive-vr').then(session => {
session.addEventListener('inputsourceschange', e => {
});
});
}
Building a Complete Music System
Tempo and Beat Management
A foundational element of any music system is precise timing. Let's create a tempo manager:
class TempoManager {
constructor(audioContext, bpm = 120) {
this.audioContext = audioContext;
this.bpm = bpm;
this.quarterNoteTime = 60 / this.bpm;
this.events = [];
}
scheduleAt(callback, beatPosition) {
const timeInSeconds = beatPosition * this.quarterNoteTime;
const deadline = this.audioContext.currentTime + timeInSeconds;
this.events.push({
callback,
deadline
});
return deadline;
}
setBpm(newBpm) {
this.bpm = newBpm;
this.quarterNoteTime = 60 / this.bpm;
}
}
Building a Precise Metronome
A metronome demonstrates how to achieve rock-solid timing in Web Audio:
class Metronome {
constructor(audioContext, tempo = 120) {
this.audioContext = audioContext;
this.isPlaying = false;
this.tempo = tempo;
this.lookahead = 25.0;
this.scheduleAheadTime = 0.1;
this.nextNoteTime = 0;
this.currentBeat = 0;
this.tempoManager = new TempoManager(audioContext, tempo);
this.clickBuffer = this.createClickBuffer();
}
createClickBuffer() {
const buffer = this.audioContext.createBuffer(
1,
this.audioContext.sampleRate * 0.1,
this.audioContext.sampleRate
);
const channelData = buffer.getChannelData(0);
for (let i = 0; i < buffer.length * 0.1; i++) {
channelData[i] = Math.sin(i * 0.1) * (1 - i / (buffer.length * 0.1));
}
return buffer;
}
nextNote() {
this.nextNoteTime += 60.0 / this.tempo;
this.currentBeat = (this.currentBeat + 1) % 4;
}
scheduleNote(beatNumber, time) {
const clickSource = this.audioContext.createBufferSource();
clickSource.buffer = this.clickBuffer;
const clickVolume = this.audioContext.createGain();
clickVolume.gain.value = beatNumber % 4 === 0 ? 1.0 : 0.5;
clickSource.connect(clickVolume);
clickVolume.connect(this.audioContext.destination);
clickSource.start(time);
}
scheduler() {
while (this.nextNoteTime < this.audioContext.currentTime + this.scheduleAheadTime) {
this.scheduleNote(this.currentBeat, this.nextNoteTime);
this.nextNote();
}
this.timerId = setTimeout(() => this.scheduler(), this.lookahead);
}
start() {
if (this.isPlaying) return;
this.isPlaying = true;
this.currentBeat = 0;
this.nextNoteTime = this.audioContext.currentTime;
this.scheduler();
}
stop() {
this.isPlaying = false;
clearTimeout(this.timerId);
}
setTempo(bpm) {
this.tempo = bpm;
this.tempoManager.setBpm(bpm);
}
}
Musical Theory Integration
Let's implement a simple chord and scale generator:
class MusicTheory {
static NOTES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
static SCALES = {
major: [0, 2, 4, 5, 7, 9, 11],
minor: [0, 2, 3, 5, 7, 8, 10],
pentatonicMajor: [0, 2, 4, 7, 9],
pentatonicMinor: [0, 3, 5, 7, 10],
blues: [0, 3, 5, 6, 7, 10]
};
static CHORD_PATTERNS = {
major: [0, 2, 4],
minor: [0, 2, 4],
diminished: [0, 2, 4],
augmented: [0, 2, 4],
sus2: [0, 1, 4],
sus4: [0, 3, 4],
major7: [0, 2, 4, 6],
dominant7: [0, 2, 4, 6]
};
static getScale(root, type) {
const rootIndex = this.NOTES.indexOf(root);
if (rootIndex === -1) throw new Error('Invalid root note');
const pattern = this.SCALES[type];
if (!pattern) throw new Error('Invalid scale type');
return pattern.map(step => {
const noteIndex = (rootIndex + step) % 12;
return this.NOTES[noteIndex];
});
}
static getChord(root, type) {
let scaleType;
switch(type) {
case 'major':
case 'major7':
case 'dominant7':
case 'sus2':
case 'sus4':
scaleType = 'major';
break;
case 'minor':
case 'diminished':
scaleType = 'minor';
break;
case 'augmented':
const majorScale = this.getScale(root, 'major');
const notes = [majorScale[0], majorScale[2]];
const fifthIndex = (this.NOTES.indexOf(majorScale[4]) + 1) % 12;
notes.push(this.NOTES[fifthIndex]);
return notes;
default:
throw new Error('Invalid chord type');
}
const scale = this.getScale(root, scaleType);
const pattern = this.CHORD_PATTERNS[type];
return pattern.map(degree => scale[degree]);
}
static noteToFrequency(note, octave = 4) {
const A4 = 440;
const A4_INDEX = this.NOTES.indexOf('A') + (4 * 12);
const noteIndex = this.NOTES.indexOf(note) + (octave * 12);
const semitoneDistance = noteIndex - A4_INDEX;
return A4 * Math.pow(2, semitoneDistance / 12);
}
}
Building a Sequencer
Now let's create a step sequencer for pattern-based music creation:
class StepSequencer {
constructor(audioContext, steps = 16, tracks = 4) {
this.audioContext = audioContext;
this.steps = steps;
this.tracks = tracks;
this.currentStep = 0;
this.isPlaying = false;
this.tempo = 120;
this.stepTime = 60 / this.tempo / 4;
this.nextStepTime = 0;
this.patterns = Array(tracks).fill().map(() => Array(steps).fill(false));
this.soundBuffers = [];
this.scheduleAheadTime = 0.1;
this.lookahead = 25;
}
loadSample(url, trackIndex) {
return fetch(url)
.then(response => response.arrayBuffer())
.then(arrayBuffer => this.audioContext.decodeAudioData(arrayBuffer))
.then(audioBuffer => {
this.soundBuffers[trackIndex] = audioBuffer;
});
}
toggleStep(trackIndex, stepIndex) {
this.patterns[trackIndex][stepIndex] = !this.patterns[trackIndex][stepIndex];
}
nextStep() {
this.nextStepTime += this.stepTime;
this.currentStep = (this.currentStep + 1) % this.steps;
}
playSample(trackIndex, time) {
if (!this.soundBuffers[trackIndex]) return;
const source = this.audioContext.createBufferSource();
source.buffer = this.soundBuffers[trackIndex];
source.connect(this.audioContext.destination);
source.start(time);
}
scheduler() {
while (this.nextStepTime < this.audioContext.currentTime + this.scheduleAheadTime) {
for (let track = 0; track < this.tracks; track++) {
if (this.patterns[track][this.currentStep]) {
this.playSample(track, this.nextStepTime);
}
}
this.nextStep();
}
if (this.isPlaying) {
setTimeout(() => this.scheduler(), this.lookahead);
}
}
start() {
if (this.isPlaying) return;
this.isPlaying = true;
this.currentStep = 0;
this.nextStepTime = this.audioContext.currentTime;
this.scheduler();
}
stop() {
this.isPlaying = false;
}
setTempo(bpm) {
this.tempo = bpm;
this.stepTime = 60 / this.tempo / 4;
}
}
Audio Analysis and Visualization
Building a Frequency Analyzer
The Web Audio API's AnalyserNode provides powerful tools for real-time audio analysis:
class AudioAnalyzer {
constructor(audioContext, fftSize = 2048) {
this.audioContext = audioContext;
this.analyser = audioContext.createAnalyser();
this.analyser.fftSize = fftSize;
this.analyser.smoothingTimeConstant = 0.85;
this.frequencyData = new Uint8Array(this.analyser.frequencyBinCount);
this.timeData = new Uint8Array(this.analyser.fftSize);
}
connectSource(source) {
source.connect(this.analyser);
return this;
}
getFrequencyData() {
this.analyser.getByteFrequencyData(this.frequencyData);
return this.frequencyData;
}
getTimeData() {
this.analyser.getByteTimeDomainData(this.timeData);
return this.timeData;
}
getDominantFrequency() {
this.analyser.getByteFrequencyData(this.frequencyData);
let maxIndex = 0;
let maxValue = 0;
for (let i = 0; i < this.frequencyData.length; i++) {
if (this.frequencyData[i] > maxValue) {
maxValue = this.frequencyData[i];
maxIndex = i;
}
}
return maxIndex * this.audioContext.sampleRate / this.analyser.fftSize;
}
}
Creating a Spectrum Visualizer
Now let's build a visualizer that uses Canvas to display frequency data:
class SpectrumVisualizer {
constructor(audioAnalyzer, canvasElement) {
this.analyzer = audioAnalyzer;
this.canvas = canvasElement;
this.canvasCtx = this.canvas.getContext('2d');
this.isAnimating = false;
this.resizeCanvas();
window.addEventListener('resize', () => this.resizeCanvas());
}
resizeCanvas() {
this.canvas.width = this.canvas.clientWidth;
this.canvas.height = this.canvas.clientHeight;
}
start() {
if (this.isAnimating) return;
this.isAnimating = true;
this.draw();
}
stop() {
this.isAnimating = false;
}
draw() {
if (!this.isAnimating) return;
const frequencyData = this.analyzer.getFrequencyData();
this.canvasCtx.fillStyle = 'rgba(0, 0, 0, 0.2)';
this.canvasCtx.fillRect(0, 0, this.canvas.width, this.canvas.height);
const barWidth = this.canvas.width / frequencyData.length;
let x = 0;
for (let i = 0; i < frequencyData.length; i++) {
const barHeight = frequencyData[i] / 255 * this.canvas.height;
const hue = i / frequencyData.length * 360;
this.canvasCtx.fillStyle = `hsl(${hue}, 100%, 50%)`;
this.canvasCtx.fillRect(x, this.canvas.height - barHeight, barWidth, barHeight);
x += barWidth;
}
requestAnimationFrame(() => this.draw());
}
}
Similarly, we can visualize the time-domain data:
class WaveformVisualizer {
constructor(audioAnalyzer, canvasElement) {
this.analyzer = audioAnalyzer;
this.canvas = canvasElement;
this.canvasCtx = this.canvas.getContext('2d');
this.isAnimating = false;
this.resizeCanvas();
window.addEventListener('resize', () => this.resizeCanvas());
}
resizeCanvas() {
this.canvas.width = this.canvas.clientWidth;
this.canvas.height = this.canvas.clientHeight;
}
start() {
if (this.isAnimating) return;
this.isAnimating = true;
this.draw();
}
stop() {
this.isAnimating = false;
}
draw() {
if (!this.isAnimating) return;
const timeData = this.analyzer.getTimeData();
this.canvasCtx.fillStyle = 'rgba(0, 0, 0, 0.2)';
this.canvasCtx.fillRect(0, 0, this.canvas.width, this.canvas.height);
this.canvasCtx.lineWidth = 2;
this.canvasCtx.strokeStyle = '#00FFFF';
this.canvasCtx.beginPath();
const sliceWidth = this.canvas.width / timeData.length;
let x = 0;
for (let i = 0; i < timeData.length; i++) {
const v = timeData[i] / 128.0;
const y = v * this.canvas.height / 2;
if (i === 0) {
this.canvasCtx.moveTo(x, y);
} else {
this.canvasCtx.lineTo(x, y);
}
x += sliceWidth;
}
this.canvasCtx.lineTo(this.canvas.width, this.canvas.height / 2);
this.canvasCtx.stroke();
requestAnimationFrame(() => this.draw());
}
}
Integrating with MIDI and External Hardware
Connecting to MIDI Devices
The Web MIDI API brings hardware integration to web applications:
class MidiController {
constructor() {
this.inputs = [];
this.outputs = [];
this.onNoteOn = null;
this.onNoteOff = null;
this.onControlChange = null;
}
async initialize() {
try {
const midiAccess = await navigator.requestMIDIAccess();
this.inputs = Array.from(midiAccess.inputs.values());
this.outputs = Array.from(midiAccess.outputs.values());
midiAccess.addEventListener('statechange', this.handleStateChange.bind(this));
this.inputs.forEach(input => {
input.addEventListener('midimessage', this.handleMidiMessage.bind(this));
});
return true;
} catch (error) {
console.error('MIDI access denied:', error);
return false;
}
}
handleStateChange(event) {
console.log('MIDI connection state change:', event.port.name, event.port.state);
}
handleMidiMessage(event) {
const [status, data1, data2] = event.data;
const messageType = status >> 4;
const channel = status & 0xF;
switch (messageType) {
case 0x9:
if (data2 > 0 && this.onNoteOn) {
this.onNoteOn(data1, data2, channel, event);
} else if (data2 === 0 && this.onNoteOff) {
this.onNoteOff(data1, data2, channel, event);
}
break;
case 0x8:
if (this.onNoteOff) {
this.onNoteOff(data1, data2, channel, event);
}
break;
case 0xB:
if (this.onControlChange) {
this.onControlChange(data1, data2, channel, event);
}
break;
}
}
sendNoteOn(note, velocity = 64, channel = 0) {
this.outputs.forEach(output => {
output.send([0x90 | channel, note, velocity]);
});
}
sendNoteOff(note, velocity = 0, channel = 0) {
this.outputs.forEach(output => {
output.send([0x80 | channel, note, velocity]);
});
}
sendControlChange(controller, value, channel = 0) {
this.outputs.forEach(output => {
output.send([0xB0 | channel, controller, value]);
});
}
}
Building a MIDI Synthesizer
Let's create a synthesizer controlled by MIDI:
class MidiSynthesizer {
constructor(audioContext) {
this.audioContext = audioContext;
this.activeOscillators = new Map();
this.masterGain = audioContext.createGain();
this.masterGain.gain.value = 0.5;
this.masterGain.connect(audioContext.destination);
this.waveform = 'sawtooth';
this.attackTime = 0.05;
this.releaseTime = 0.1;
this.midiController = new MidiController();
this.setupMidiHandling();
}
async initialize() {
return this.midiController.initialize();
}
setupMidiHandling() {
this.midiController.onNoteOn = (note, velocity, channel) => {
this.noteOn(note, velocity / 127);
};
this.midiController.onNoteOff = (note) => {
this.noteOff(note);
};
this.midiController.onControlChange = (controller, value) => {
if (controller === 1) {
}
};
}
noteToFrequency(note) {
return 440 * Math.pow(2, (note - 69) / 12);
}
noteOn(note, velocity = 0.7) {
if (this.activeOscillators.has(note)) {
this.noteOff(note);
}
const frequency = this.noteToFrequency(note);
const oscillator = this.audioContext.createOscillator();
oscillator.type = this.waveform;
oscillator.frequency.value = frequency;
const envelope = this.audioContext.createGain();
envelope.gain.value = 0;
oscillator.connect(envelope);
envelope.connect(this.masterGain);
envelope.gain.setValueAtTime(0, this.audioContext.currentTime);
envelope.gain.linearRampToValueAtTime(
velocity,
this.audioContext.currentTime + this.attackTime
);
oscillator.start();
this.activeOscillators.set(note, { oscillator, envelope });
}
noteOff(note) {
const activeNote = this.activeOscillators.get(note);
if (!activeNote) return;
const { oscillator, envelope } = activeNote;
const releaseEnd = this.audioContext.currentTime + this.releaseTime;
envelope.gain.setValueAtTime(envelope.gain.value, this.audioContext.currentTime);
envelope.gain.linearRampToValueAtTime(0, releaseEnd);
oscillator.stop(releaseEnd);
setTimeout(() => {
this.activeOscillators.delete(note);
}, this.releaseTime * 1000);
}
setWaveform(waveform) {
this.waveform = waveform;
}
setAttack(time) {
this.attackTime = time;
}
setRelease(time) {
this.releaseTime = time;
}
}
Building Professional Audio Applications
Creating a Multi-track Mixing Console
Let's develop a mixing console for professional audio applications:
class AudioTrack {
constructor(audioContext, name = 'Track') {
this.audioContext = audioContext;
this.name = name;
this.input = audioContext.createGain();
this.output = audioContext.createGain();
this.fader = audioContext.createGain();
this.panner = audioContext.createStereoPanner();
this.eqLow = audioContext.createBiquadFilter();
this.eqLow.type = 'lowshelf';
this.eqLow.frequency.value = 250;
this.eqLow.gain.value = 0;
this.eqMid = audioContext.createBiquadFilter();
this.eqMid.type = 'peaking';
this.eqMid.frequency.value = 1000;
this.eqMid.Q.value = 1;
this.eqMid.gain.value = 0;
this.eqHigh = audioContext.createBiquadFilter();
this.eqHigh.type = 'highshelf';
this.eqHigh.frequency.value = 4000;
this.eqHigh.gain.value = 0;
this.sends = new Map();
this.input
.connect(this.eqLow)
.connect(this.eqMid)
.connect(this.eqHigh)
.connect(this.panner)
.connect(this.fader)
.connect(this.output);
this.fader.gain.value = 0.75;
this.panner.pan.value = 0;
this.muted = false;
this.soloed = false;
}
setVolume(value) {
this.fader.gain.linearRampToValueAtTime(
value,
this.audioContext.currentTime + 0.01
);
}
setPan(value) {
this.panner.pan.linearRampToValueAtTime(
value,
this.audioContext.currentTime + 0.01
);
}
setLowEQ(gain) {
this.eqLow.gain.value = gain;
}
setMidEQ(gain) {
this.eqMid.gain.value = gain;
}
setHighEQ(gain) {
this.eqHigh.gain.value = gain;
}
setMute(mute) {
this.muted = mute;
this.fader.gain.linearRampToValueAtTime(
mute ? 0 : this.unmutedVolume || 0.75,
this.audioContext.currentTime + 0.01
);
if (mute) {
this.unmutedVolume = this.fader.gain.value;
}
}
addSend(name, destination, level = 0.5) {
const sendGain = this.audioContext.createGain();
sendGain.gain.value = level;
this.eqHigh.connect(sendGain);
sendGain.connect(destination);
this.sends.set(name, sendGain);
}
setSendLevel(name, level) {
const send = this.sends.get(name);
if (send) {
send.gain.linearRampToValueAtTime(
level,
this.audioContext.currentTime + 0.01
);
}
}
}
class MixingConsole {
constructor(audioContext) {
this.audioContext = audioContext;
this.tracks = new Map();
this.masterBus = audioContext.createGain();
this.masterBus.connect(audioContext.destination);
this.effectsBuses = new Map();
this.createReverbBus();
this.createDelayBus();
}
createTrack(name) {
const track = new AudioTrack(this.audioContext, name);
track.output.connect(this.masterBus);
track.addSend('reverb', this.effectsBuses.get('reverb'), 0);
track.addSend('delay', this.effectsBuses.get('delay'), 0);
this.tracks.set(name, track);
return track;
}
removeTrack(name) {
const track = this.tracks.get(name);
if (track) {
track.output.disconnect();
this.tracks.delete(name);
}
}
setMasterVolume(value) {
this.masterBus.gain.linearRampToValueAtTime(
value,
this.audioContext.currentTime + 0.01
);
}
createReverbBus() {
const reverbBus = this.audioContext.createGain();
const convolver = this.audioContext.createConvolver();
this.createImpulseResponse().then(buffer => {
convolver.buffer = buffer;
});
const dryGain = this.audioContext.createGain();
const wetGain = this.audioContext.createGain();
dryGain.gain.value = 0.5;
wetGain.gain.value = 0.5;
reverbBus.connect(dryGain);
reverbBus.connect(convolver);
convolver.connect(wetGain);
dryGain.connect(this.masterBus);
wetGain.connect(this.masterBus);
this.effectsBuses.set('reverb', reverbBus);
}
createDelayBus() {
const delayBus = this.audioContext.createGain();
const delayLeft = this.audioContext.createDelay(2.0);
const delayRight = this.audioContext.createDelay(2.0);
const feedback = this.audioContext.createGain();
delayLeft.delayTime.value = 0.25;
delayRight.delayTime.value = 0.5;
feedback.gain.value = 0.3;
const splitter = this.audioContext.createChannelSplitter(2);
const merger = this.audioContext.createChannelMerger(2);
delayBus.connect(splitter);
splitter.connect(delayLeft, 0);
splitter.connect(delayRight, 1);
delayLeft.connect(merger, 0, 0);
delayRight.connect(merger, 0, 1);
merger.connect(feedback);
feedback.connect(delayLeft);
feedback.connect(delayRight);
merger.connect(this.masterBus);
this.effectsBuses.set('delay', delayBus);
}
async createImpulseResponse() {
const sampleRate = this.audioContext.sampleRate;
const length = 2 * sampleRate;
const decay = 2.0;
const buffer = this.audioContext.createBuffer(2, length, sampleRate);
for (let channel = 0; channel < 2; channel++) {
const channelData = buffer.getChannelData(channel);
for (let i = 0; i < length; i++) {
const white = Math.random() * 2 - 1;
channelData[i] = white * Math.pow(1 - i / length, decay);
}
}
return buffer;
}
}
Creating Transport Controls for Audio Projects
Let's create a transport system for precise control in audio applications:
class TransportController {
constructor(audioContext) {
this.audioContext = audioContext;
this.isPlaying = false;
this.isPaused = false;
this.startTime = 0;
this.pauseTime = 0;
this.tempo = 120;
this.timeSignature = { numerator: 4, denominator: 4 };
this.loopRegion = { start: 0, end: 0, enabled: false };
this.markers = new Map();
this.onPlay = null;
this.onPause = null;
this.onStop = null;
this.onPositionChange = null;
this.scheduledEvents = [];
this.nextScheduledEventId = 0;
}
secondsToBeats(seconds) {
return seconds / 60 * this.tempo;
}
beatsToSeconds(beats) {
return beats * 60 / this.tempo;
}
secondsToMeasures(seconds) {
const beats = this.secondsToBeats(seconds);
const beatsPerMeasure = this.timeSignature.numerator;
return beats / beatsPerMeasure;
}
play() {
if (this.isPlaying) return;
if (this.isPaused) {
const elapsedTime = this.pauseTime - this.startTime;
this.startTime = this.audioContext.currentTime - elapsedTime;
this.isPaused = false;
} else {
this.startTime = this.audioContext.currentTime;
}
this.isPlaying = true;
if (this.onPlay) this.onPlay();
this.scheduleEvents();
this.updatePosition();
}
pause() {
if (!this.isPlaying || this.isPaused) return;
this.pauseTime = this.audioContext.currentTime;
this.isPaused = true;
this.isPlaying = false;
if (this.onPause) this.onPause();
}
stop() {
if (!this.isPlaying && !this.isPaused) return;
this.isPlaying = false;
this.isPaused = false;
this.startTime = 0;
this.pauseTime = 0;
this.scheduledEvents.forEach(event => {
if (event.timeoutId) {
clearTimeout(event.timeoutId);
}
});
this.scheduledEvents = [];
if (this.onStop) this.onStop();
}
getCurrentTime() {
if (this.isPaused) {
return this.pauseTime - this.startTime;
} else if (this.isPlaying) {
return this.audioContext.currentTime - this.startTime;
} else {
return 0;
}
}
seek(time) {
if (this.isPlaying) {
this.startTime = this.audioContext.currentTime - time;
} else if (this.isPaused) {
this.pauseTime = this.startTime + time;
}
if (this.isPlaying) {
this.scheduledEvents.forEach(event => {
if (event.timeoutId) {
clearTimeout(event.timeoutId);
}
});
this.scheduledEvents = [];
this.scheduleEvents();
}
if (this.onPositionChange) this.onPositionChange(time);
}
setLoopRegion(start, end) {
this.loopRegion.start = start;
this.loopRegion.end = end;
}
setLooping(enabled) {
this.loopRegion.enabled = enabled;
}
addMarker(name, time) {
this.markers.set(name, time);
}
jumpToMarker(name) {
const markerTime = this.markers.get(name);
if (markerTime !== undefined) {
this.seek(markerTime);
}
}
scheduleEvent(callback, time) {
const id = this.nextScheduledEventId++;
const event = {
id,
callback,
time,
timeoutId: null
};
if (this.isPlaying) {
const now = this.getCurrentTime();
const delay = Math.max(0, (time - now) * 1000);
event.timeoutId = setTimeout(() => {
callback();
this.scheduledEvents = this.scheduledEvents.filter(e => e.id !== id);
if (this.loopRegion.enabled && time >= this.loopRegion.end) {
this.seek(this.loopRegion.start);
}
}, delay);
}
this.scheduledEvents.push(event);
return id;
}
scheduleEvents() {
const eventsToSchedule = [...this.scheduledEvents];
eventsToSchedule.forEach(event => {
if (event.timeoutId) {
clearTimeout(event.timeoutId);
event.timeoutId = null;
}
});
const now = this.getCurrentTime();
eventsToSchedule.forEach(event => {
if (event.time >= now) {
const delay = (event.time - now) * 1000;
event.timeoutId = setTimeout(() => {
event.callback();
this.scheduledEvents = this.scheduledEvents.filter(e => e.id !== event.id);
if (this.loopRegion.enabled && this.getCurrentTime() >= this.loopRegion.end) {
this.seek(this.loopRegion.start);
}
}, delay);
}
});
}
updatePosition() {
if (!this.isPlaying) return;
const currentTime = this.getCurrentTime();
if (this.onPositionChange) {
this.onPositionChange(currentTime);
}
if (this.loopRegion.enabled && currentTime >= this.loopRegion.end) {
this.seek(this.loopRegion.start);
}
requestAnimationFrame(() => this.updatePosition());
}
}
Conclusion: The Future of Web Audio
The Web Audio API has transformed browsers into powerful audio workstations capable of professional-grade sound processing. From spatial audio and complex synthesizers to complete DAW-like applications, the capabilities continue to expand.
The integration with other web technologies like WebXR, Canvas, and Web MIDI extends the potential even further, enabling immersive audio experiences that were once only possible with native applications.
As we look to the future, technologies like AudioWorklet and WebAssembly are unlocking new performance frontiers, while creative developers continue to push the boundaries of what's possible. The Web Audio API has matured into a robust platform for audio programming that can support everything from games and virtual reality to serious music production tools.
By mastering these advanced concepts, you're well-equipped to create remarkable audio applications that run directly in the browser, accessible to users across devices and platforms without installation. The web is increasingly becoming the universal platform for audio experiences, and the tools we've explored in this article give you the power to be at the forefront of this evolution.