Tutorial · Web Audio API · JavaScript · Sound
📅 July 2026 ⏱ ≈ 20 min 🎯 Intermediate

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.

ADSR gain over time: 0 ────attack────► peak ────decay────► sustain ──(held)──► ──release──► 0

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
}
Why 0.0001, not 0: 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);
Try it: Sweep 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]);
});
Next steps: Swap 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.