Tutorial · Intermediate · ~55 min
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.