Web Audio API · LFOs & Parameter Automation
Tremolo. Vibrato. Filter wobble. Autopan. Ring modulation. Five completely different effects. One idea. An oscillator so slow it doesn't make sound — it moves something else instead.
Every synthesizer has a section labelled LFO — Low Frequency Oscillator. It sounds like a specialised component, something separate from the rest of the instrument. It isn't. An LFO is just an oscillator running at a frequency too low to hear. A regular audio oscillator runs somewhere between 20Hz and 20,000Hz — the range of human hearing. An LFO runs between 0.1Hz and maybe 20Hz, below the threshold where it registers as pitch. The signal it produces isn't sound. It's a slowly cycling number between -1 and +1, and you connect it to whatever parameter you want to animate.
Connect that slow oscillator to the gain of your signal and the volume rises and falls rhythmically. That's tremolo. Connect it to the frequency of your carrier oscillator and the pitch wavers up and down. That's vibrato. Connect it to a filter's cutoff frequency and you get that classic dubstep wobble bass or the phasing, sweeping sound of a flanger. Connect it to the left-right stereo balance and you get autopan — the sound that moves across the mix. These are not four different effects processors. They are four different destinations for the exact same signal.
And then something interesting happens when you increase the LFO frequency past the 20Hz threshold. It starts to make sound of its own — not by itself, but by interacting with the carrier. At audio rates, modulation creates sidebands: entirely new frequencies that weren't in either oscillator individually. This is ring modulation, and it's also the conceptual doorway into FM synthesis. The whole thing is one continuous idea. This lesson walks it all the way from slow tremolo to metallic ring modulation — same oscillator, same math, dramatically different results.
Tremolo is the simplest possible modulation: an LFO connected to a gain node. The LFO cycles between -1 and +1, but since negative gain makes no sense, we shift it to cycle between 0 and 1 instead — that's adding a DC offset of 0.5 to its output and scaling the range accordingly. The audio signal's volume then rises and falls at the LFO rate.
At low rates (0.5–3Hz) it sounds like a human breath or a slow pulse. At rates around 5–8Hz it starts to feel mechanical and rhythmic. Above 10Hz it begins to sound more like a texture than a pulse. The depth controls how extreme the volume swing is — at full depth the signal goes completely silent at the trough; at low depth it barely wavers.
function tremolo(ctx, source, { rate = 4, depth = 0.8 } = {}) { const lfo = ctx.createOscillator(); const lfoGain = ctx.createGain(); // scales LFO depth const vca = ctx.createGain(); // the amplitude this controls lfo.type = 'sine'; lfo.frequency.value = rate; // LFO output is -1..+1. Scale to 0..depth range: lfoGain.gain.value = depth / 2; // amplitude of swing vca.gain.value = 1 - depth / 2; // centre (DC offset) lfo.connect(lfoGain); lfoGain.connect(vca.gain); // LFO modulates the gain AudioParam source.connect(vca); vca.connect(ctx.destination); lfo.start(); return vca; // connect downstream if needed }
// A square-wave LFO at a musical rate (e.g. 2Hz = half-note at 60BPM) creates a rhythmic gate effect — the volume snaps on and off in time. This is how sidechain compression is emulated in many synths without an actual compressor.
Vibrato moves the pitch of the carrier oscillator up and down at the LFO rate. Every string player, vocalist and wind player produces vibrato naturally through tiny fluctuations in breath pressure or finger position — it's a fundamental expressive technique because static pitch sounds artificial to human ears. The brain expects sound sources in the real world to wobble slightly.
The Web Audio API makes this trivially easy: connect the LFO's output to the frequency AudioParam of the carrier oscillator. The LFO value — cycling between -depth and +depth in Hz — is added directly to the oscillator's base frequency. At ±5Hz around a 440Hz carrier, the pitch wavers almost imperceptibly. At ±50Hz it becomes an obvious, exaggerated wobble. The rate controls how fast the wobble cycles — 5–7Hz is the natural range for human vibrato.
function vibrato(ctx, oscillator, { rate = 5.5, depth = 8 } = {}) { const lfo = ctx.createOscillator(); const lfoGain = ctx.createGain(); lfo.type = 'sine'; lfo.frequency.value = rate; lfoGain.gain.value = depth; // ±depth Hz deviation // Wire LFO into the oscillator's frequency param directly lfo.connect(lfoGain); lfoGain.connect(oscillator.frequency); lfo.start(); }
// The "Onset Delay" slider delays the LFO fade-in — the vibrato starts flat, then gradually deepens. This is exactly how a violinist or singer introduces vibrato: the note starts clean and the wobble is added for expression. At zero delay it sounds synthetic. At 0.3–0.5s delay it sounds human.
Connect an LFO to a lowpass filter's cutoff frequency and you get filter wobble — the defining sound of mid-2000s dubstep, the sweep of a classic phaser, the movement of a wah pedal. The filter alternately opens and closes, letting through more or less of the harmonic content of the signal on each cycle. When the filter is wide open, you hear the full bright sound; when it closes, only the low frequencies pass through.
The key numbers here are the base cutoff (the centre point of the sweep), the LFO depth in Hz (how far above and below centre the filter sweeps), and the resonance (Q). High Q values boost frequencies right at the cutoff, adding a whistling peak that rises and falls with the filter — this is what gives classic wobble bass its distinctive character. The LFO range needs to be exponential rather than linear to sweep perceptually evenly across the frequency spectrum.
function filterWobble(ctx, source, { rate=2, base=800, depth=600, q=8 } = {}) { const filter = ctx.createBiquadFilter(); const lfo = ctx.createOscillator(); const lfoGain = ctx.createGain(); filter.type = 'lowpass'; filter.frequency.value = base; filter.Q.value = q; lfo.type = 'sine'; lfo.frequency.value = rate; lfoGain.gain.value = depth; // ±depth Hz around base // LFO modulates the filter cutoff directly lfo.connect(lfoGain); lfoGain.connect(filter.frequency); source.connect(filter); filter.connect(ctx.destination); lfo.start(); }
Autopan connects an LFO to the left-right stereo balance of a signal. Implemented properly, the signal moves smoothly across the stereo field — fully left when the LFO is at +1, fully right when it's at -1, centre when the LFO is at 0. The Web Audio API has a StereoPannerNode whose pan parameter accepts values from -1 (full left) to +1 (full right), making this as clean a connection as any of the others.
The interesting design question with autopan is the LFO shape. A sine LFO produces smooth, graceful panning. A square LFO at a musical rate creates a hard rhythmic stereo bounce — the signal slams from side to side in time. A triangle LFO falls between the two: a linear sweep with sharp direction reversals at the extremes. Each produces a completely different feel despite identical rate and depth settings.
function autoPan(ctx, source, { rate = 1, depth = 1, shape = 'sine' } = {}) { const panner = ctx.createStereoPanner(); const lfo = ctx.createOscillator(); const lfoGain = ctx.createGain(); panner.pan.value = 0; // centre by default lfo.type = shape; lfo.frequency.value = rate; lfoGain.gain.value = depth; // max ±1 // LFO drives the pan param: -1 = left, +1 = right lfo.connect(lfoGain); lfoGain.connect(panner.pan); source.connect(panner); panner.connect(ctx.destination); lfo.start(); }
Everything in the previous four chapters used the LFO below 20Hz — slow enough to animate a parameter rhythmically but not fast enough to add pitch. Now increase the rate past 20Hz and something qualitatively different happens: the modulator enters the audible range. When you multiply two audio-rate signals together — which is exactly what amplitude modulation at audio rate does — the output contains the sum and difference of the two frequencies. A 440Hz carrier multiplied by a 100Hz modulator produces 540Hz and 340Hz. Neither of those was in the original signal.
This is ring modulation, and its character changes dramatically depending on the ratio between carrier and modulator. At simple integer ratios (2:1, 3:2) the sidebands land on harmonic frequencies and the result sounds musical. At complex irrational ratios the sidebands are inharmonic and the result sounds metallic, bell-like, alien. It's used throughout electronic music — Dalek voices, metal percussion, that classic Dr. Who sound — and it sits right at the conceptual boundary between modulation and synthesis. Everything that follows in FM synthesis is this same idea, just extended.
function ringMod(ctx, { carrierFreq=220, modFreq=80, depth=1 } = {}) { const carrier = ctx.createOscillator(); const modulator = ctx.createOscillator(); const modGain = ctx.createGain(); // the VCA the modulator controls const outGain = ctx.createGain(); carrier.type = 'triangle'; modulator.type = 'sine'; carrier.frequency.value = carrierFreq; modulator.frequency.value = modFreq; modGain.gain.value = 0; // start silent — modulator opens it outGain.gain.value = 0.6; // Modulator drives the gain of the carrier (multiplication = ring mod) modulator.connect(modGain.gain); carrier.connect(modGain); modGain.connect(outGain); outGain.connect(ctx.destination); carrier.start(); modulator.start(); // Sidebands: carrier ± modulator // e.g. 220Hz carrier + 80Hz mod → 300Hz + 140Hz }
// Move the modulator from 1Hz → 600Hz while holding a note and listen to the transition: at 1Hz it's tremolo, at 5Hz it's a fast pulse, at 20Hz it starts to buzz, at 80Hz you hear the sidebands clearly, at 440Hz (unison with carrier) you get a single reinforced pitch, at odd ratios like 137Hz or 333Hz the result gets increasingly metallic and strange. The whole lesson is audible in that one slider sweep.