Build a Synthesizer in 100 Lines of JavaScript
You don't need a DAW or a native plugin to build a real, playable synthesizer — the browser's Web Audio API gives you sample-accurate oscillators, envelopes, and filters for free. This tutorial builds a polyphonic synth you can play from your computer keyboard in about 100 lines of plain JavaScript, no libraries required.
1. The Audio Context
Every Web Audio API graph starts with a single
AudioContext. Browsers block audio from starting
without a user gesture, so create it lazily on the first click or
keypress:
let ctx = null;
function getCtx() {
if (!ctx) ctx = new (window.AudioContext || window.webkitAudioContext)();
return ctx;
}
Everything else — oscillators, gains, filters — is a
node connected into this one context's graph,
ultimately reaching ctx.destination (your speakers).
2. A Single Voice: Oscillator + Gain
The minimum instrument is one
OscillatorNode (the pitch/waveform generator) feeding
one GainNode (the volume control), which feeds the
speakers:
function midiToFreq(midi) {
return 440 * Math.pow(2, (midi - 69) / 12); // A4 = MIDI 69 = 440 Hz
}
function createVoice(freq, waveform = 'sawtooth') {
const ac = getCtx();
const osc = ac.createOscillator();
osc.type = waveform; // 'sine' | 'square' | 'sawtooth' | 'triangle'
osc.frequency.setValueAtTime(freq, ac.currentTime);
const gain = ac.createGain();
gain.gain.setValueAtTime(0, ac.currentTime); // start silent
osc.connect(gain);
return { osc, gain };
}
3. The ADSR Envelope
A raw oscillator clicks on and off instantly, which sounds harsh. Real instruments have a shape over time: Attack (rise to peak), Decay (fall to sustain level), Sustain (held level while the key is down), and Release (fade to silence after key-up) — the classic ADSR envelope.
The Web Audio API's AudioParam ramp methods make this
trivial — no manual animation loop needed, the audio thread handles
the ramps sample-accurately:
function applyADSR(gainParam, { attack = 0.01, decay = 0.15, sustain = 0.5, peak = 0.6 } = {}) {
const t = getCtx().currentTime;
gainParam.cancelScheduledValues(t);
gainParam.setValueAtTime(0, t);
gainParam.linearRampToValueAtTime(peak, t + attack); // Attack
gainParam.linearRampToValueAtTime(sustain, t + attack + decay); // Decay → Sustain
}
function releaseADSR(gainParam, release = 0.25) {
const t = getCtx().currentTime;
gainParam.cancelScheduledValues(t);
gainParam.setValueAtTime(gainParam.value, t);
gainParam.linearRampToValueAtTime(0.0001, t + release); // Release
}
exponentialRampToValueAtTime throws on a target of
exactly 0. Using a linear ramp to a tiny positive value avoids the
click a hard setValueAtTime(0, …) can cause, and keeps
the release smooth.
4. Note On / Note Off
noteOn creates a fresh voice, starts the oscillator,
and runs the attack/decay ramp. noteOff runs the
release ramp and schedules the oscillator to stop shortly after —
stopping it too early would cut the release short:
function noteOn(midi) {
const { osc, gain } = createVoice(midiToFreq(midi), currentWaveform);
applyADSR(gain.gain);
gain.connect(filterNode);
osc.start();
return { osc, gain };
}
function noteOff(voice) {
releaseADSR(voice.gain.gain);
const stopAt = getCtx().currentTime + 0.3; // after release ramp
voice.osc.stop(stopAt);
}
5. A Low-Pass Filter for Timbre
A raw sawtooth is bright and buzzy. Insert a
BiquadFilterNode between the voices and the speakers
to round it off — the classic subtractive synthesis
move, and the source of every "filter sweep" you've ever heard:
const filterNode = getCtx().createBiquadFilter();
filterNode.type = 'lowpass';
filterNode.frequency.setValueAtTime(1200, getCtx().currentTime); // cutoff in Hz
filterNode.Q.setValueAtTime(4, getCtx().currentTime); // resonance
filterNode.connect(getCtx().destination);
filterNode.frequency
from 200 Hz to 8000 Hz over a few seconds with
linearRampToValueAtTime while a note sustains — this
single line recreates the signature analogue synth "filter sweep"
heard in countless dance and film-score patches.
6. Polyphony with a Voice Map
For more than one note at a time, track active voices in a
Map keyed by MIDI note number. This also correctly
handles a key being released while a different voice for the same
pitch is still ringing out:
const activeVoices = new Map(); // midi -> voice
function keyDown(midi) {
if (activeVoices.has(midi)) return; // already sounding
activeVoices.set(midi, noteOn(midi));
}
function keyUp(midi) {
const voice = activeVoices.get(midi);
if (!voice) return;
noteOff(voice);
activeVoices.delete(midi);
}
7. A QWERTY Piano Keyboard
Map a row of computer keys to semitone offsets from a base note,
and wire real keyboard events to keyDown /
keyUp — no on-screen UI is required to have a playable
instrument:
const KEY_MAP = { // row = white + black keys, like a piano roll
'a': 0, 'w': 1, 's': 2, 'e': 3, 'd': 4,
'f': 5, 't': 6, 'g': 7, 'y': 8, 'h': 9,
'u': 10, 'j': 11, 'k': 12,
};
const BASE_MIDI = 60; // middle C
window.addEventListener('keydown', e => {
if (e.repeat || !(e.key in KEY_MAP)) return;
keyDown(BASE_MIDI + KEY_MAP[e.key]);
});
window.addEventListener('keyup', e => {
if (!(e.key in KEY_MAP)) return;
keyUp(BASE_MIDI + KEY_MAP[e.key]);
});
8. The Full ~100-Line Synth
Putting every piece together into one self-contained script — this is the complete, runnable synthesizer:
// ── A ~100-line polyphonic synth: OscillatorNode + GainNode + BiquadFilterNode ──
let ctx = null;
let filterNode = null;
let currentWaveform = 'sawtooth';
const activeVoices = new Map();
function getCtx() {
if (!ctx) {
ctx = new (window.AudioContext || window.webkitAudioContext)();
filterNode = ctx.createBiquadFilter();
filterNode.type = 'lowpass';
filterNode.frequency.setValueAtTime(1200, ctx.currentTime);
filterNode.Q.setValueAtTime(4, ctx.currentTime);
filterNode.connect(ctx.destination);
}
return ctx;
}
function midiToFreq(midi) {
return 440 * Math.pow(2, (midi - 69) / 12);
}
function createVoice(freq) {
const ac = getCtx();
const osc = ac.createOscillator();
osc.type = currentWaveform;
osc.frequency.setValueAtTime(freq, ac.currentTime);
const gain = ac.createGain();
gain.gain.setValueAtTime(0, ac.currentTime);
osc.connect(gain);
gain.connect(filterNode);
return { osc, gain };
}
function applyADSR(gainParam) {
const t = getCtx().currentTime;
const attack = 0.01, decay = 0.15, sustain = 0.5, peak = 0.6;
gainParam.cancelScheduledValues(t);
gainParam.setValueAtTime(0, t);
gainParam.linearRampToValueAtTime(peak, t + attack);
gainParam.linearRampToValueAtTime(sustain, t + attack + decay);
}
function releaseADSR(gainParam) {
const t = getCtx().currentTime;
gainParam.cancelScheduledValues(t);
gainParam.setValueAtTime(gainParam.value, t);
gainParam.linearRampToValueAtTime(0.0001, t + 0.25);
}
function noteOn(midi) {
const { osc, gain } = createVoice(midiToFreq(midi));
applyADSR(gain.gain);
osc.start();
return { osc, gain };
}
function noteOff(voice) {
releaseADSR(voice.gain.gain);
voice.osc.stop(getCtx().currentTime + 0.3);
}
function keyDown(midi) {
if (activeVoices.has(midi)) return;
activeVoices.set(midi, noteOn(midi));
}
function keyUp(midi) {
const voice = activeVoices.get(midi);
if (!voice) return;
noteOff(voice);
activeVoices.delete(midi);
}
const KEY_MAP = {
'a': 0, 'w': 1, 's': 2, 'e': 3, 'd': 4,
'f': 5, 't': 6, 'g': 7, 'y': 8, 'h': 9,
'u': 10, 'j': 11, 'k': 12,
};
const BASE_MIDI = 60;
window.addEventListener('keydown', e => {
if (e.repeat || !(e.key in KEY_MAP)) return;
keyDown(BASE_MIDI + KEY_MAP[e.key]);
});
window.addEventListener('keyup', e => {
if (!(e.key in KEY_MAP)) return;
keyUp(BASE_MIDI + KEY_MAP[e.key]);
});
currentWaveform
between 'sine', 'square',
'sawtooth', and 'triangle' to hear each
waveform's harmonic content directly. Add a second detuned
oscillator per voice for a fatter unison sound, or replace the
oscillator entirely with the FM equation from the
FM
synthesis article using
osc.frequency.setValueAtTime driven by a second,
faster oscillator's output via AudioParam
modulation.
Frequently Asked Questions
What will I learn in this tutorial?
Build a playable polyphonic synthesizer in about 100 lines of JavaScript using only the Web Audio API: OscillatorNode, GainNode, an ADSR envelope, a filter, and a computer-keyboard piano.
What topics are covered in this tutorial?
This tutorial covers: The Audio Context, A Single Voice: Oscillator + Gain, The ADSR Envelope, Note On / Note Off, A Low-Pass Filter for Timbre, Polyphony with a Voice Map, A QWERTY Piano Keyboard, The Full ~100-Line Synth.
How long does this tutorial take?
This tutorial takes approximately 20 minutes to complete.
What prerequisites do I need before starting?
This is a Intermediate-level tutorial — no special preparation beyond basic JavaScript is assumed.