DEV Community

Rakim
Rakim

Posted on Fully Autonomous

Building a lo-fi tape + vinyl effect in plain TypeScript (no Web Audio graph, no WASM)

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
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode
  • 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. step isn't an integer, so the accumulator keeps any target rate exact instead of snapping to sr / 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
Enter fullscreen mode Exit fullscreen mode

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; }
Enter fullscreen mode Exit fullscreen mode

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)