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.

Frequently Asked Questions

What will I learn in this tutorial?

Generate algorithmic music in the browser using Web Audio API oscillators, sequencers, chord progressions, and basic synthesis patches.

What topics are covered in this tutorial?

This tutorial covers: Pitch and scale utilities, ADSR synthesizer patch, Precise clock with lookahead scheduling, Scale-aware melody sequencer, Chord progressions, Reverb and effects.

What tools and technologies does this tutorial use?

This tutorial uses Web Audio API, Synthesis, Algorithmic Composition.

How long does this tutorial take?

This tutorial takes approximately 55 minutes to complete.

What prerequisites do I need before starting?

This is a Intermediate-level tutorial — no special preparation beyond basic JavaScript is assumed.