Web Audio API · Distortion & Saturation
You've used saturation, distortion and overdrive your entire career. This is what they actually are — a mathematical function applied to every sample, bending the shape of the wave itself.
A digital audio signal is a sequence of numbers. Each number — each sample — represents air pressure at a moment in time. A sample with value 0.0 is silence. A sample with value 1.0 is the loudest thing the system can represent. A sample with value -1.0 is the loudest thing in the other direction. That's the entire model. Audio is just a list of floating point numbers between -1 and 1, playing back at 44,100 of them per second.
Here's the insight that unlocks distortion entirely: if you apply any mathematical function to those numbers, you change the sound. Multiply every sample by 2 — that's volume. Multiply by 0.5 — quieter. But what if you apply a non-linear function? Something that treats large values differently from small ones? Something that squashes or clips or folds the signal? You change not just the loudness but the shape of the wave — and changing the shape means changing the harmonic content. You're adding frequencies that weren't in the original signal. That's distortion. That's saturation. That's every overdrive pedal, tape machine, tube amplifier and lo-fi plugin you've ever used — reduced to a single function applied to a sequence of numbers.
The Web Audio API gives us the WaveShaperNode for exactly this. You provide a transfer curve — an array that maps every possible input value to an output value — and the node applies it to every sample in real time. The entire character of your distortion unit lives in that one array. This blog builds four of them from scratch, then chains them together.
Soft clipping is the sound of tape machines, tube amplifiers and analog gear. When you push an analog circuit past its comfortable operating range, it doesn't suddenly hit a hard wall — it starts to gently compress and round the peaks of the wave. The loudest parts of the signal get squashed smoothly, generating new harmonics gradually rather than all at once.
The mathematical function for this is tanh(x) — the hyperbolic tangent. Feed it a small input and it returns almost the same value unchanged. Feed it a large input and it asymptotes toward ±1, smoothly. Drive the signal harder by multiplying the input before passing it through tanh, and you get progressively warmer, richer saturation without any sudden harsh edges. This is literally what every "tape saturation" and "tube warmth" plugin is doing.
function softClipCurve(drive = 2, samples = 256) { const curve = new Float32Array(samples); for (let i = 0; i < samples; i++) { const x = (i * 2) / samples - 1; // input range: -1 to +1 curve[i] = Math.tanh(x * drive); // drive amplifies before squash } return curve; } const shaper = ctx.createWaveShaper(); shaper.curve = softClipCurve(4); shaper.oversample = '4x'; // reduce aliasing source.connect(shaper); shaper.connect(ctx.destination);
// The transfer curve canvas is the whole story. When the curve is diagonal — input equals output — that's clean signal. When it bends at the top and bottom, that's where harmonics are being added. The more bent it is, the more distorted the sound. Watch the curve change as you move the drive slider.
Hard clipping does exactly what it says. Any sample above a threshold is flattened to that threshold. Any sample below the negative threshold is flattened to that. The result is a signal with the top and bottom sliced off — flat edges instead of rounded peaks. On the transfer curve, this looks like a line with the ends chopped horizontal.
This is much more aggressive than soft clipping. Flattening a sine wave into a square wave adds a huge amount of high-frequency harmonic content instantly. At moderate settings this is overdrive — the classic guitar pedal sound. At extreme settings the waveform becomes almost square and you get full fuzz. The lower the threshold, the more aggressive the effect. The asymmetric version — where the positive and negative thresholds differ — adds even-order harmonics and sounds more like a tube amp pushed hard.
function hardClipCurve(threshold = 0.5, samples = 256) { const curve = new Float32Array(samples); for (let i = 0; i < samples; i++) { const x = (i * 2) / samples - 1; curve[i] = Math.max(-threshold, Math.min(threshold, x)) / threshold; } return curve; } // Asymmetric version — different top and bottom thresholds function asymClipCurve(hi = 0.6, lo = 0.4, samples = 256) { const curve = new Float32Array(samples); for (let i = 0; i < samples; i++) { const x = (i * 2) / samples - 1; curve[i] = x > 0 ? Math.min(x, hi) / hi : Math.max(x, -lo) / lo; } return curve; }
Bit depth and sample rate are two of the most misunderstood numbers in audio. Bit depth defines how many distinct volume levels a sample can represent. 16-bit audio has 65,536 possible values per sample. 8-bit has 256. 4-bit has 16. When you reduce the bit depth, you force each sample to snap to the nearest available step — a process called quantisation. The error between the true value and the stepped value is quantisation noise, and at low bit depths it becomes audible as a gritty, metallic texture.
Sample rate reduction is different — instead of reducing the value resolution, you reduce the time resolution. You hold each sample value for longer before updating, simulating a lower sample rate. Both effects together produce the characteristic lo-fi, vintage digital sound. Early samplers, cheap hardware, game consoles — this is what they actually sounded like, not because anyone wanted it, but because memory was expensive.
// Bitcrusher via ScriptProcessorNode (or AudioWorklet in modern use) function createBitcrusher(ctx, bits = 8, sampleHold = 1) { const proc = ctx.createScriptProcessor(1024, 1, 1); let lastSample = 0, holdCounter = 0; proc.onaudioprocess = (e) => { const input = e.inputBuffer.getChannelData(0); const output = e.outputBuffer.getChannelData(0); const steps = Math.pow(2, bits); // e.g. 256 for 8-bit for (let i = 0; i < input.length; i++) { if (holdCounter++ >= sampleHold) { holdCounter = 0; // Quantise: snap to nearest step lastSample = Math.round(input[i] * steps) / steps; } output[i] = lastSample; } }; return proc; }
// Sample Hold simulates a lower sample rate by holding each sample value for N samples before updating. At 32× hold, you're effectively running at ~1378Hz sample rate. That's not music anymore, that's noise art.
Foldback is the most unusual distortion type here and the least commonly explained. Instead of clipping or squashing samples that exceed the threshold, foldback reflects them. If a sample is, say, 0.3 above the threshold, instead of clamping it at the threshold, the output is placed 0.3 below the threshold. It folds back inward.
The result at low drive settings is a rich metallic shimmer — the reflections create complex inharmonic overtones that pure clipping doesn't produce. At high settings it becomes genuinely chaotic and noisy, folding back on itself multiple times. This is the distortion type you hear in certain metallic synth sounds, aggressive FM tones, and deliberate digital abuse. It's unpredictable in the best possible way.
function foldbackCurve(threshold = 0.5, folds = 2, samples = 512) { const curve = new Float32Array(samples); for (let i = 0; i < samples; i++) { let x = (i * 2) / samples - 1; // Fold x back within [-threshold, +threshold] for (let f = 0; f < folds; f++) { if (x > threshold) x = 2 * threshold - x; if (x < -threshold) x = -2 * threshold - x; } curve[i] = x / threshold; // normalise to -1..1 } return curve; }
Real character comes from stacking these stages. A tiny amount of soft saturation before a moderate hard clip before a light bitcrush produces a sound that's simultaneously warm, punchy and gritty — the sum is more interesting than any individual part. The order matters too: soft clipping first rounds the signal before the hard clipper hits it, producing a different result than hard clipping followed by saturation. Toggle each stage on and off while the signal runs and listen to exactly what each one contributes.
// Start with just the soft clip stage active. Add hard clip and notice how the rounded peaks suddenly hit a ceiling. Add bitcrush at 12 bits — barely audible on a clean signal, but it adds texture to an already-distorted one. Toggle foldback on at threshold 0.8 and listen to the metallic overtones appear on top of everything else.