Audio Synthesis with Web Audio API: Building a Custom Web-Based Polyphonic Synthesizer
Master browser-native audio synthesis. Code a modular polyphonic synthesizer with custom ADSR envelopes, resonant lowpass filters, and LFO modulators in pure JS.

Master browser-native audio synthesis. Code a modular polyphonic synthesizer with custom ADSR envelopes, resonant lowpass filters, and LFO modulators in pure JS.
Audio Synthesis with Web Audio API: Building a Custom Web-Based Polyphonic Synthesizer
Before the Web Audio API, browser sound was restricted to simple pre-recorded audio file playbacks using the <audio> tag. Today, the browser has a fully modular Digital Signal Processing (DSP) routing graph engine capable of native, real-time sound synthesis, spatial 3D audio panning, and dynamic music generation.
Why download massive audio files when you can synthesize rich, responsive, interactive soundscapes programmatically on the client?
In this guide, we'll dive deep into modular sound synthesis, explore V8's audio graph nodes, and build a fully functioning, interactive Web-Based Polyphonic Synthesizer featuring custom ADSR Envelopes, a Resonant Lowpass Filter, and a Low-Frequency Oscillator (LFO) for vibrato effects.
⚡ 1. The Anatomy of a Modular Synthesizer
Modular synthesis involves routing an audio signal through distinct, self-contained functional nodes. In Web Audio, we define a corresponding AudioNode graph:
- 2.OscillatorNode (Source): Generates the fundamental raw periodic waveform (Sine, Square, Sawtooth, Triangle) at a chosen frequency (pitch).
- 4.GainNode (ADSR Envelope): Modulates the volume over time when a note is pressed (Attack, Decay, Sustain, Release) to shape the sound dynamics.
- 6.BiquadFilterNode (Filter): Shapes the tone by removing high or low frequencies (e.g., creating a warm, analog lowpass effect).
- 8.AudioDestinationNode (Speakers): The final node that outputs the compiled audio to the user's speakers.
[Oscillator 1 (Sawtooth)] ──┐
├──> [BiquadFilterNode (Lowpass)] ──> [GainNode (ADSR)] ──> [Destination (Speakers)]
[Oscillator 2 (Square)] ──┘ ▲
│
[LFO (Frequency Mod)]
🏗️ 2. Designing the Polyphonic Voice Architecture
A monophonic synthesizer can play only one note at a time. A Polyphonic synthesizer can play multiple notes concurrently (enabling chords) by dynamically spawning a distinct "Voice" instance for every active MIDI or keyboard note pressed.
Let's write our SynthVoice class that orchestrates an individual note's node graph:
javascriptclass SynthVoice { constructor(audioContext, frequency, destination) { this.ctx = audioContext; this.frequency = frequency; this.destination = destination; // 1. Initialize two parallel oscillators for a rich, detuned sound this.osc1 = this.ctx.createOscillator(); this.osc2 = this.ctx.createOscillator(); // Detune the oscillators slightly to create a wide chorus effect this.osc1.type = 'sawtooth'; this.osc1.frequency.value = frequency; this.osc1.detune.value = -8; // detune left in cents this.osc2.type = 'square'; this.osc2.frequency.value = frequency; this.osc2.detune.value = 8; // detune right in cents // 2. Initialize Resonant Lowpass Filter this.filter = this.ctx.createBiquadFilter(); this.filter.type = 'lowpass'; this.filter.frequency.value = 800; // Cutoff frequency this.filter.Q.value = 4.0; // Resonance peak // 3. Initialize Gain Node for ADSR Envelope this.envelope = this.ctx.createGain(); this.envelope.gain.setValueAtTime(0, this.ctx.currentTime); // 4. Establish Node Routing Graph this.osc1.connect(this.filter); this.osc2.connect(this.filter); this.filter.connect(this.envelope); this.envelope.connect(this.destination); } triggerAttack(adsr) { const now = this.ctx.currentTime; // Prevent immediate volume clicks using linear/exponential ramps this.envelope.gain.cancelScheduledValues(now); // Attack phase (Ramp up to full volume) this.envelope.gain.linearRampToValueAtTime(0.5, now + adsr.attack); // Decay & Sustain phase (Ramp down to sustain volume level) this.envelope.gain.setTargetAtTime(adsr.sustain * 0.5, now + adsr.attack, adsr.decay); // Start oscillators this.osc1.start(now); this.osc2.start(now); } triggerRelease(adsr) { const now = this.ctx.currentTime; this.envelope.gain.cancelScheduledValues(now); // Release phase (Fade out completely over time) this.envelope.gain.setTargetAtTime(0.0, now, adsr.release); // Stop oscillators completely once fully faded out to release thread memory this.osc1.stop(now + adsr.release * 4); this.osc2.stop(now + adsr.release * 4); } }
💻 3. Managing the Polyphonic Keyboard Controller
Now, let's write a master PolyphonicSynthesizer class that listens to keyboard events and maps keys dynamically to standard frequencies using a map dictionary.
javascriptconst NOTE_FREQS = { 'a': 261.63, // C4 'w': 277.18, // C#4 's': 293.66, // D4 'e': 311.13, // D#4 'd': 329.63, // E4 'f': 349.23, // F4 't': 369.99, // F#4 'g': 392.00, // G4 'y': 415.30, // G#4 'h': 440.00, // A4 'u': 466.16, // A#4 'j': 493.88, // B4 'k': 523.25, // C5 }; class PolyphonicSynthesizer { constructor() { this.ctx = new (window.AudioContext || window.webkitAudioContext)(); this.activeVoices = new Map(); // Master volume control this.masterGain = this.ctx.createGain(); this.masterGain.gain.value = 0.8; this.masterGain.connect(this.ctx.destination); // Default ADSR configurations this.adsr = { attack: 0.05, // seconds decay: 0.15, // seconds sustain: 0.6, // scale (0 to 1) release: 0.4 // seconds }; this.setupListeners(); } setupListeners() { window.addEventListener('keydown', (e) => { const key = e.key.toLowerCase(); if (NOTE_FREQS[key] && !this.activeVoices.has(key)) { // Start playing note const freq = NOTE_FREQS[key]; const voice = new SynthVoice(this.ctx, freq, this.masterGain); voice.triggerAttack(this.adsr); this.activeVoices.set(key, voice); console.log(`🎹 KeyDown [\${key}]: Synthesizing note at \${freq} Hz`); } }); window.addEventListener('keyup', (e) => { const key = e.key.toLowerCase(); if (this.activeVoices.has(key)) { // Trigger note release fadeout const voice = this.activeVoices.get(key); voice.triggerRelease(this.adsr); this.activeVoices.delete(key); console.log(`🎹 KeyUp [\${key}]: Releasing voice`); } }); } }
🚀 4. Modulation: Adding an LFO for Vibrato
To make synthesized audio sound warm and natural rather than flat and robotic, we add vibrato. Vibrato is a subtle, periodic modulation of the sound pitch (frequency).
In modular synthesis, we achieve this by connecting a Low-Frequency Oscillator (LFO)—an oscillator set to a very slow speed (e.g. 6Hz)—directly to the frequency parameter of our main audio oscillators.
javascript// Create LFO running at 6 cycles per second (vibrato rate) this.lfo = this.ctx.createOscillator(); this.lfo.frequency.value = 6.0; // Create LFO gain node to control vibrato depth (how wide the pitch bend is) this.lfoGain = this.ctx.createGain(); this.lfoGain.gain.value = 5.0; // bend pitch by +/- 5Hz // Connect LFO to pitch parameters directly! this.lfo.connect(this.lfoGain); this.lfoGain.connect(this.osc1.frequency); this.lfoGain.connect(this.osc2.frequency); // Start LFO this.lfo.start(now);
🏁 5. Conclusion
By building a polyphonic synthesizer Voice mapping architecture, shaping sound envelopes dynamically using ADSR values, and applying LFO vibratos directly on V8's native audio graph, you unlock a professional audio synthesis engine. It runs entirely on client silicon, bypasses download payloads completely, and offers infinite, zero-latency sonic capability.

SQLite on the Edge: Replicating Databases with LiteFS and Fly.io
A technical dive into distributed edge storage, exploring how LiteFS replicates SQLite databases across global Fly.io regions using FUSE and lease-based consensus.

Implementing Post-Quantum Cryptography in Next.js: Securing APIs against Future Decryption
Future-proof your web applications today. Learn how to secure Next.js API routes using Post-Quantum Cryptography (PQC) algorithms like ML-KEM and Kyber.