Tutorial · Intermediate · ~55 min
Web Audio API · Synthesis · Algorithmic Composition

Procedural Music with Web Audio API

The Web Audio API is a full synthesis engine — oscillators, filters, envelopes, convolution reverb, and precise timing. This tutorial builds an algorithmic music generator: a tempo clock, a scale-aware sequencer, a simple synthesizer patch with ADSR, and a chord progression engine.

1Pitch and scale utilities

// MIDI note → frequency in Hz const mtof = note => 440 * Math.pow(2, (note - 69) / 12); // Common scales as semitone intervals from root const SCALES = { major: [0, 2, 4, 5, 7, 9, 11], minor: [0, 2, 3, 5, 7, 8, 10], pentatonic: [0, 2, 4, 7, 9], blues: [0, 3, 5, 6, 7, 10], dorian: [0, 2, 3, 5, 7, 9, 10], }; // Build all notes of a scale across N octaves function buildScale(root = 60, scaleName = 'minor', octaves = 3) { const intervals = SCALES[scaleName]; const notes = []; for (let oct = 0; oct < octaves; oct++) { for (const interval of intervals) { notes.push(root + oct * 12 + interval); } } return notes; } const palette = buildScale(48, 'pentatonic', 3); // → [48, 50, 52, 55, 57, 60, 62, 64, 67, 69, 72, 74, 76, 79, 81]

2ADSR synthesizer patch

const audio = new AudioContext(); // Master chain: gain → highpass filter → limiter → output const masterGain = audio.createGain(); masterGain.gain.value = 0.4; const highpass = audio.createBiquadFilter(); highpass.type = 'highpass'; highpass.frequency.value = 80; masterGain.connect(highpass); highpass.connect(audio.destination); function playNote(freq, startTime, duration, adsr = {}) { const { attack = 0.01, decay = 0.1, sustain = 0.6, release = 0.3 } = adsr; const osc = audio.createOscillator(); const gain = audio.createGain(); const filter = audio.createBiquadFilter(); osc.type = 'sawtooth'; osc.frequency.setValueAtTime(freq, startTime); // Slight detune for warmth (two oscillators 5 cents apart) const osc2 = audio.createOscillator(); osc2.type = 'sawtooth'; osc2.frequency.setValueAtTime(freq, startTime); osc2.detune.value = 5; filter.type = 'lowpass'; filter.frequency.setValueAtTime(200, startTime); filter.frequency.linearRampToValueAtTime(freq * 6, startTime + attack); filter.frequency.exponentialRampToValueAtTime(freq * 2, startTime + attack + decay); gain.gain.setValueAtTime(0, startTime); gain.gain.linearRampToValueAtTime(1, startTime + attack); gain.gain.linearRampToValueAtTime(sustain, startTime + attack + decay); gain.gain.setValueAtTime(sustain, startTime + duration - release); gain.gain.linearRampToValueAtTime(0.0001, startTime + duration); osc.connect(filter); osc2.connect(filter); filter.connect(gain); gain.connect(masterGain); osc.start(startTime); osc.stop(startTime + duration + 0.01); osc2.start(startTime); osc2.stop(startTime + duration + 0.01); }

3Precise clock with lookahead scheduling

The Web Audio API has sample-accurate timing, but setInterval is jittery. Solve this with a lookahead scheduler: fire a JS callback every 25 ms, but schedule notes 100 ms into the future:

const BPM = 120; const BEAT = 60 / BPM; // seconds per beat const LOOK = 0.1; // lookahead window (s) const TICK = 25; // scheduler interval (ms) let nextNoteTime = 0; let currentBeat = 0; let timerID = null; let playing = false; let onBeat = null; // callback: (beat, time) => void function scheduler() { while (nextNoteTime < audio.currentTime + LOOK) { if (onBeat) onBeat(currentBeat, nextNoteTime); nextNoteTime += BEAT; currentBeat = (currentBeat + 1) % 32; } timerID = setTimeout(scheduler, TICK); } function startClock() { if (playing) return; audio.resume(); nextNoteTime = audio.currentTime + 0.05; currentBeat = 0; playing = true; scheduler(); } function stopClock() { clearTimeout(timerID); playing = false; }
This pattern is from Chris Wilson's canonical Web Audio clock article. The scheduler never drifts because you are always scheduling relative to audio.currentTime, not wall time.

4Scale-aware melody sequencer

const notes = buildScale(48, 'pentatonic', 2); // Random walk: move ±1-2 steps in scale with wrap let pos = Math.floor(notes.length / 2); function nextMelodyNote() { const step = Math.random() < 0.7 ? (Math.random() < 0.5 ? 1 : -1) // small step most often : (Math.random() < 0.5 ? 2 : -2); // occasional leap pos = Math.max(0, Math.min(notes.length - 1, pos + step)); return notes[pos]; } // Probability of rests per beat const REST_PROB = 0.15; onBeat = (beat, time) => { if (Math.random() < REST_PROB) return; const midi = nextMelodyNote(); const freq = mtof(midi); const dur = BEAT * (Math.random() < 0.3 ? 2 : 1); // some notes held playNote(freq, time, dur * 0.9, { attack: 0.01, decay: 0.08, sustain: 0.5, release: 0.15 }); };

5Chord progressions

// I-VI-IV-V in C minor (MIDI root 60) const PROGRESSIONS = { 'i-VI-III-VII': [[0,3,7], [8,11,3], [3,7,10], [10,1,5]], 'i-iv-v-i': [[0,3,7], [5,8,0], [7,11,2], [0,3,7]], }; const PROG = PROGRESSIONS['i-VI-III-VII']; let chordIndex = 0; function playChord(root, intervals, time) { for (const interval of intervals) { const freq = mtof(root + interval); playNote(freq, time, BEAT * 4, { attack: 0.08, decay: 0.2, sustain: 0.4, release: 0.8 }); } } // Change chord every 4 beats onBeat = (beat, time) => { if (beat % 4 === 0) { playChord(48, PROG[chordIndex % PROG.length], time); chordIndex++; } // Melody runs on top if (Math.random() > 0.15) { playNote(mtof(nextMelodyNote()), time, BEAT * 0.85); } };

6Reverb and effects

// Convolution reverb using a generated impulse response async function createReverb(duration = 2, decay = 3) { const rate = audio.sampleRate; const length = rate * duration; const impulse = audio.createBuffer(2, length, rate); for (let ch = 0; ch < 2; ch++) { const data = impulse.getChannelData(ch); for (let i = 0; i < length; i++) { data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / length, decay); } } const reverb = audio.createConvolver(); reverb.buffer = impulse; return reverb; } // Delay effect function createDelay(time = 0.375, feedback = 0.4) { const delay = audio.createDelay(2.0); const fbGain = audio.createGain(); delay.delayTime.value = time; // 3/8 note at 120 BPM fbGain.gain.value = feedback; delay.connect(fbGain); fbGain.connect(delay); return delay; } // Chain: synth → delay → reverb → masterGain (async () => { const reverb = await createReverb(2.5, 2); const delay = createDelay(BEAT * 0.375, 0.35); masterGain.disconnect(); masterGain.connect(delay); delay.connect(reverb); reverb.connect(audio.destination); })();
The impulse-response reverb (convolverNode) created from synthetic noise sounds surprisingly natural. For real spaces, load a recorded IR file using fetch + decodeAudioData.