Most "lo-fi" browser effects are a Web Audio graph: a BiquadFilterNode, a WaveShaperNode, maybe a noise buffer. That's fine for a filter, but two parts of the lo-fi sound don't map to built-in nodes very well: sample-and-hold rate reduction and tape wow and flutter. So for the Lo-Fi & Vintage Sampler we wrote the whole chain as a plain sample loop in TypeScript over AudioBuffer channel data.
This post walks through the four stages and the small decisions that made them sound right.
The chain
input → [sampler] → [tape] → [vinyl] → [clipper] → dry/wet mix → peak limit to -1 dBFS
Every stage can be switched off, and a mix control blends the original back in. Here are the settings:
type LoFiSettings = {
sampler: boolean; bits: number; rate: number;
tape: boolean; wow: number; drive: number; tapeCut: number; hiss: number;
vinyl: boolean; crackle: number;
clipper: boolean; clipDb: number;
mix: number;
};
1. A tiny biquad you can run per sample
Web Audio's BiquadFilterNode only runs inside a graph. Inside a loop you need your own, so we use the RBJ Audio EQ Cookbook formulas (lowpass, highpass and lowshelf) in a 25-line class with run(x) that keeps x1, x2, y1, y2 state. Every stage below uses it.
2. Sampler: rate and bit depth
Old samplers ran at low sample rates with few bits. Two steps make that sound:
const step = sr / Math.max(2000, Math.min(sr, s.rate)); // input samples per held sample
const levels = Math.pow(2, s.bits - 1);
x = aa[1].run(aa[0].run(x)); // 2 lowpass biquads at 0.9 × new Nyquist
acc += 1;
if (acc >= step) { acc -= step; held = Math.round(x * levels) / levels; }
x = held;
- Anti-alias first. Without the two lowpass filters in front, sample-and-hold folds the highs back down as harsh, inharmonic aliasing. With them you get the dull, round sampler tone people mean by "SP-1200-ish". (It's a general recipe, not a model of any one machine.)
-
A fractional step.
stepisn't an integer, so the accumulator keeps any target rate exact instead of snapping tosr / n.
3. Tape: one motor, one wobble
Wow (slow) and flutter (fast) are pitch wobble, and the easy way to get pitch wobble is a delay line whose delay time moves:
const d = maxDelay * 0.5
+ wowDepth * Math.sin(2 * Math.PI * 0.55 * t) // wow ~0.55 Hz, up to 2.5 ms
+ flutterDepth * Math.sin(2 * Math.PI * 6.5 * t + 1.3); // flutter ~6.5 Hz
// read the ring buffer at w - d with linear interpolation
One detail matters more than it looks: both channels use the same t. A real cassette has one motor, so left and right wobble together. Give each channel its own LFO and the stereo image smears and "chorus-es", which reads as a modulation effect, not as tape.
After the wobble come tanh saturation (normalised by tanh(drive) so the level doesn't jump), a +2.5 dB low shelf at 90 Hz (head bump), an adjustable high cut, and highpassed noise for hiss.
4. Vinyl: crackle as rare, decaying clicks
Crackle is not constant noise. It's rare, random impulses that die away quickly:
if (rand() < s.crackle * 0.0004) {
click = (rand() * 2 - 1) * (0.05 + 0.25 * rand()) * s.crackle;
clickDecay = 0.5 + rand() * 0.4;
}
if (click !== 0) { x += crackLp.run(click); click *= clickDecay; }
The clicks go through a 5 kHz lowpass so they "pop" instead of "tick", and the whole signal gets a 45 Hz highpass and a 12 kHz lowpass for the band-limited record sound. The PRNG is a seeded LCG per channel, so a render is repeatable.
5. Clipper and a safe output
A hard clip at clipDb, scaled back up, then one last pass that pulls the peak down to −1 dBFS if anything is over. Whatever settings someone dials in, the downloaded MP3 or WAV never clips.
Keeping the page responsive
A 4-minute stereo song is about 21 million loop iterations. We process in chunks of 65,536 samples and await a setTimeout(0) between them, which updates a progress bar and keeps the tab responsive. No worker was needed for this one.
Testing DSP without ears
The DSP lives in its own module (lofi-dsp.ts), separate from the React page, so it can be tested in Node. The checks are plain assertions on synthetic signals:
- a 4-bit sampler leaves at most 17 distinct levels, and at 11 kHz most samples are repeats (sample-and-hold works)
- the output peak never goes over 0.89 (−1 dBFS)
- the hard clipper turns a loud sine into mostly flat tops
- with wow and flutter on, the period of a steady 1 kHz tone changes from cycle to cycle
- mix at 0% gives back the original, apart from the −1 dB ceiling
- the full chain renders a 3-minute stereo song in under 20 seconds
Two siblings shipped the same day
- Vocal Chop Maker: onset detection cuts a dry vocal into up to 16 chops, which you play from the keyboard or a Web MIDI device and export as a ZIP.
-
Drum Swing & Humanizer: swing delays every second 16th by
(swing − 50%) × 2 × one 16th. That's 40 ms at 62% and 90 BPM. It exports a 4-bar MIDI file and a loop-exact WAV. There's a longer write-up on the maths in What is swing in music.
All three run in the browser (you need a free account). Questions about the DSP are welcome in the comments.
Disclosure: I build SongUp AI.
Top comments (0)