Web Audio API: Building a Moog-style Modular Synthesizer in JavaScript
The Web Audio API is one of the most powerful yet underutilized features of modern browsers. It allows you to create, manipulate, and analyze audio in real-time using pure JavaScript — no plugins required.
To demonstrate the full potential of these APIs, I built a virtual modular synthesizer inspired by the Moog: a fully functional instrument featuring 3 oscillators, a resonant ladder filter, ADSR envelopes, dual LFOs, and effects — all in a single HTML file with zero dependencies.
Github Repository: https://github.com/mirchaemanuel/moog-modular/

The Legend: Robert Moog and the Birth of the Synthesizer
Before diving into code, it's worth understanding why the Moog synthesizer matters.
In 1964, Robert Moog introduced the first commercially available voltage-controlled synthesizer. Unlike previous electronic instruments, Moog's design was modular — separate components (oscillators, filters, amplifiers, envelope generators) could be connected via patch cables in any configuration, allowing musicians to sculpt entirely new sounds.
The key innovations that made the Moog revolutionary:
- Voltage-Controlled Oscillators (VCOs): Generate waveforms (sawtooth, square, triangle, sine) at frequencies determined by input voltage
- Voltage-Controlled Filter (VCF): The legendary "Moog ladder filter" — a 24dB/octave low-pass filter with resonance that defines the Moog sound
- Voltage-Controlled Amplifier (VCA): Controls volume based on input voltage
- Envelope Generators: Shape how parameters change over time using Attack-Decay-Sustain-Release (ADSR) curves
- Low-Frequency Oscillators (LFOs): Slow oscillators that modulate other parameters for vibrato, tremolo, and filter sweeps
This architecture became the template for virtually every synthesizer that followed. The Web Audio API, remarkably, provides native equivalents for all these building blocks.
Web Audio Architecture: Nodes and the Audio Graph
The Web Audio API mirrors the modular synthesizer concept. Everything is a node connected in an audio graph. Signal flows from source nodes through processing nodes to a destination (your speakers).
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
Modern browsers block autoplay audio. The AudioContext must be created or resumed after a user interaction:
function noteOn(note) {
if (!audioCtx) {
audioCtx = new AudioContext();
initAudioGraph();
}
// Create and start voice...
}
Oscillators: The Sound Source
An OscillatorNode generates periodic waveforms — the raw material of synthesis. In my Moog emulation, three VCOs work together:
const osc = audioCtx.createOscillator();
osc.type = 'sawtooth'; // 'sine', 'square', 'triangle', 'sawtooth'
osc.frequency.value = 440; // A4 = 440 Hz
// Detuning creates the classic "fat" analog sound
osc.detune.value = 7; // cents (hundredths of a semitone)
osc.connect(destination);
osc.start();
The magic of subtractive synthesis lies in combining multiple oscillators with slight detuning and different waveforms. In my implementation:
- VCO 1: Sawtooth, base pitch
- VCO 2: Square wave, +7 cents detune
- VCO 3: Triangle, one octave down
This creates harmonic richness that a single oscillator cannot achieve.
The Moog Ladder Filter
The BiquadFilterNode emulates the heart of the Moog sound — a resonant low-pass filter:
const filter = audioCtx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.value = 2000; // Cutoff frequency
filter.Q.value = 10; // Resonance (Q factor)
The Q parameter (Quality factor) creates that distinctive "squelchy" sound when cranked up. At extreme values, the filter self-oscillates — a technique used for bass drops and screaming leads.
Keyboard Tracking
Real Moog synthesizers feature keyboard tracking — the filter cutoff follows the played note so higher notes sound brighter:
const kbdTrackAmount = state.filter.kbdTrack / 100;
const freqRatio = frequency / 261.63; // C4 as reference
const kbdCutoffMod = Math.pow(freqRatio, kbdTrackAmount);
voice.filter.frequency.value = baseCutoff * kbdCutoffMod;
Envelope Generators: Shaping Sound Over Time
The GainNode implements ADSR envelopes — the contour that shapes how a sound evolves:
const vca = audioCtx.createGain();
vca.gain.value = 0;
const now = audioCtx.currentTime;
const attackTime = 0.01; // 10ms
const decayTime = 0.2; // 200ms
const sustainLevel = 0.7; // 70%
// Attack: ramp to peak
vca.gain.linearRampToValueAtTime(1.0, now + attackTime);
// Decay: fall to sustain level
vca.gain.linearRampToValueAtTime(sustainLevel, now + attackTime + decayTime);
The filter envelope works identically but modulates filter.frequency:
const filterEnvAmount = 0.6; // 60%
const targetCutoff = baseCutoff + (20000 - baseCutoff) * filterEnvAmount;
filter.frequency.linearRampToValueAtTime(targetCutoff, now + attackTime);
filter.frequency.linearRampToValueAtTime(baseCutoff, now + attackTime + decayTime);
This creates the classic "pluck" and "sweep" sounds that define analog synthesis.

LFO Modulation: Bringing Sound to Life
Low Frequency Oscillators create movement and expression. They don't produce audible sound but modulate other parameters:
const lfo = audioCtx.createOscillator();
lfo.frequency.value = 5; // 5 Hz = subtle vibrato
const lfoGain = audioCtx.createGain();
lfoGain.gain.value = 50; // Depth in cents
lfo.connect(lfoGain);
lfoGain.connect(osc.detune); // Vibrato!
lfo.start();
The key technique: route the LFO through a GainNode that controls modulation depth. This allows smooth real-time adjustment.
In my implementation, each LFO can target:
- Pitch: Vibrato effect
- Filter: Classic filter sweep ("wah" sound)
- Amplitude: Tremolo effect
- Pulse Width: PWM synthesis
Effects: Delay and Reverb
Feedback Delay
A delay with feedback creates echoes that decay over time:
const delayNode = audioCtx.createDelay(2); // Max 2 seconds
delayNode.delayTime.value = 0.35;
const feedback = audioCtx.createGain();
feedback.gain.value = 0.4; // 40% feedback
// Create feedback loop
input.connect(delayNode);
delayNode.connect(feedback);
feedback.connect(delayNode); // Signal feeds back!
delayNode.connect(output);
Convolution Reverb
The ConvolverNode applies an impulse response for realistic room acoustics. Instead of loading audio files, I generate the impulse procedurally:
const reverb = audioCtx.createConvolver();
const length = audioCtx.sampleRate * 2; // 2 seconds
const impulse = audioCtx.createBuffer(2, length, audioCtx.sampleRate);
for (let ch = 0; ch < 2; ch++) {
const data = impulse.getChannelData(ch);
for (let i = 0; i < length; i++) {
// White noise with quadratic decay
data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / length, 2);
}
}
reverb.buffer = impulse;
Real-Time Visualization: The Oscilloscope
The AnalyserNode provides real-time waveform data for visualization:
const analyser = audioCtx.createAnalyser();
analyser.fftSize = 2048;
function draw() {
requestAnimationFrame(draw);
const dataArray = new Uint8Array(analyser.frequencyBinCount);
analyser.getByteTimeDomainData(dataArray);
ctx.clearRect(0, 0, width, height);
ctx.strokeStyle = '#00ff00';
ctx.beginPath();
for (let i = 0; i < dataArray.length; i++) {
const y = (dataArray[i] / 128.0 - 1) * height / 2 + height / 2;
ctx.lineTo(i * (width / dataArray.length), y);
}
ctx.stroke();
}
draw();
Polyphonic Voice Management
To play chords, each note creates a complete signal chain:
const voices = new Map();
function noteOn(note) {
const frequency = 440 * Math.pow(2, (note - 69) / 12);
const voice = {
oscs: [createOsc(frequency), createOsc(frequency), createOsc(frequency)],
filter: createFilter(),
vca: createVCA()
};
connectVoice(voice);
triggerEnvelopes(voice);
voices.set(note, voice);
}
function noteOff(note) {
const voice = voices.get(note);
if (voice) {
triggerRelease(voice);
// Remove after release completes
setTimeout(() => voices.delete(note), releaseTime);
}
}
Smooth Parameter Updates
When adjusting parameters while notes sound, use setTargetAtTime to avoid clicks:
// Bad: causes audio clicks
filter.frequency.value = newCutoff;
// Good: smooth exponential transition
filter.frequency.setTargetAtTime(newCutoff, audioCtx.currentTime, 0.02);
The third parameter (0.02) is the time constant — how quickly to approach the target value.
Portamento (Glide)
Classic synth technique where pitch slides between notes:
if (glideTime > 0 && lastFrequency) {
osc.frequency.setValueAtTime(lastFrequency, audioCtx.currentTime);
osc.frequency.linearRampToValueAtTime(newFrequency,
audioCtx.currentTime + glideTime / 1000);
} else {
osc.frequency.value = newFrequency;
}
lastFrequency = newFrequency;
Comparing Real vs. Virtual
| Feature | Original Moog | Web Audio Version |
|---|---|---|
| Oscillators | Analog VCOs with drift | Digital OscillatorNode (stable) |
| Filter | 24dB ladder (4-pole) | 12dB BiquadFilter (2-pole) |
| Patching | Physical cables | JavaScript connections |
| Envelopes | Voltage curves | linearRampToValueAtTime |
| Noise | Analog noise source | Random buffer playback |
| Latency | ~0ms | 10-50ms (browser dependent) |
The Web Audio implementation can't perfectly replicate analog warmth and unpredictability, but it captures the architecture and behavior remarkably well.
Conclusion
The Web Audio API provides everything needed to build professional-grade instruments in the browser. The Moog Modular synthesizer demonstrates that with ~3,000 lines of vanilla JavaScript you can create:
- Three oscillators with multiple waveforms and detuning
- Resonant low-pass filter with keyboard tracking
- Dual ADSR envelopes for filter and amplitude
- Two LFOs with multiple modulation destinations
- Delay and reverb effects
- Real-time oscilloscope visualization
- Polyphonic voice management
- Preset system and demo sequences
The source code is a single HTML file — no build tools, no dependencies, just browser and creativity. Robert Moog would probably appreciate that the same modular philosophy he pioneered in 1964 lives on in today's web platform.
Try it yourself at moog-modular.a80.it — use your computer keyboard (A-L keys) or click the virtual keys to play.