Engineering · April 29, 2026 · 8 min read

One scheduler, three layers, four kits

The drums tool started as a 16-step grid that toggles synth drums on and off. Four rounds later it's a full beat sandbox: drums, chord progressions, bass modes, and four drum kits — all driven by one Web Audio scheduler ticking every 25ms. Here's how the layers compose, and why parameter swaps were the cheapest path to multiple sound palettes.

The starting point

/tools/drums shipped as a 16-step drum grid: kick, snare, closed hat, open hat. Each hit synthesized on the fly — a sine wave with a pitch envelope for the kick, high-passed noise plus a triangle-wave body for the snare, filtered noise bursts for the hats. No samples loaded over the network. Click steps, hit play.

That covers rhythm. It doesn't cover harmony or bass — which is most of what you need to sketch a usable beat. Across four follow-up rounds, the same single HTML file grew to handle all three layers plus a kit selector.

3
layers
7
chord progressions
3
bass modes
4
drum kits

Round 107 — chord progression overlay

The first follow-up round added chord progressions as an optional second layer. Pick one of seven (I-V-vi-IV, vi-IV-I-V, ii-V-I, i-VI-III-VII, blues, etc.) and the same look-ahead scheduler that drives the drums also queues a chord at every half-bar boundary (steps 0 and 8 of the 16-step grid).

The chord scheduler reuses the piano roll's chord interval bank. Each chord plays as 3-4 stacked triangle-wave oscillators at midi base 60 (C4) plus the chord's intervals. Per-note gain is held to 0.07 — much lower than the drums' 0.30-0.40 — so the chord stack sits cleanly under the drums without masking them. A 4-chord progression naturally cycles every 2 bars while drums loop every bar, exactly how you'd write it in a DAW.

// Inside the scheduler tick — already running for drums.
// Now also schedules chords at half-bar boundaries.
if (prog.chords.length > 0 && (i === 0 || i === 8)) {
  const chord = prog.chords[progChordIdx % prog.chords.length];
  const chordDur = secondsPerStep * 8 * 0.95;
  scheduleChord(chord, nextStepTime, chordDur);
  progChordIdx = (progChordIdx + 1) % prog.chords.length;
}

The "now-playing" pill in the transport updates at each chord's wall-clock start time via a per-chord setTimeout, so the visual stays in lockstep with the audio.

Round 108 — bassline as third layer

Drums + chords still misses the bottom end. Next round added a bass mode that follows the chord roots one octave below the chord stack. Three modes:

Bass is just one more scheduleBass(midi, when, dur) call inside the same scheduler tick. The synthesis is a sine wave with a 5-cent pitch envelope over the first 50ms — that tiny droop gives the note a natural "thump" without sounding like a synth. Per-note gain is 0.30: bass should drive, so it sits louder than the chord stack but lower than the drums.

Three layers, one scheduler tick. The same 25ms timer queues drums, chords, and bass on the audio thread, and the audio thread keeps them in sync no matter what the JS thread is doing.

Round 109 — drum kit selector

With the layers in place, the next obvious gap is sound variety. The same drum pattern at the same BPM should be able to sound like boom-bap, trap, or lo-fi.

The cheapest path: keep the synthesis topology fixed (kick is sine + pitch envelope, snare is HP-noise + tone, hats are HP-noise) and vary the parameters. A DRUM_KITS table holds parameter sets; the playDrum function reads from the active kit instead of using hardcoded values.

const DRUM_KITS = {
  default: {
    kick: { f0: 60, f1: 30, pitchDur: 0.05, ampDur: 0.18, gain: 0.90 },
    snare: { hpFreq: 1000, noiseGain: 0.40, ... },
    ...
  },
  eight08: {  // boom-bap / 808 — kick rings out
    kick: { f0: 65, f1: 28, pitchDur: 0.07, ampDur: 0.40, gain: 0.95 },
    ...
  },
  lofi: {  // dusty, longer release
    kick: { f0: 55, f1: 30, pitchDur: 0.08, ampDur: 0.25, gain: 0.70 },
    ...
  },
  tight: {  // short punchy envelopes — trap
    kick: { f0: 70, f1: 32, pitchDur: 0.04, ampDur: 0.12, gain: 0.95 },
    ...
  },
};

The 808 kit has a kick that rings out (ampDur 0.40s vs 0.18s default) and a fatter 180Hz snare body. Lo-fi drops every gain by ~25% and lengthens the releases — sounds dusty. Tight cuts every envelope short — punchy, trap-friendly. Adding a fifth kit later is one new entry in the table.

Why parameter swaps beat samples

The instinct here is to load drum samples — proper kicks, snares, hats from a pack. That gives you authentic sound, but you pay for it: each sample is a few hundred KB, the load blocks until they're decoded, the user can't share a pattern that includes the sample bank, and you have to license the sounds.

Synthesized drums sound less authentic than samples, but they ship a four-kit drum machine in zero KB of audio assets. The whole tool — including the chord interval bank, the progression presets, the bass synth, and the look-ahead scheduler — fits in one HTML file under 30KB of JS. A shared URL reproduces the exact sound everywhere.

The resulting sandbox

What started as a step grid is now a sketchpad for a complete beat. Pick a drum preset, pick a kit, layer a chord progression, drop in a walking bass — all locked to one BPM by a single scheduler. The URL hash encodes everything (the 64-step pattern string, BPM, progression id, bass mode, kit id), so a shared link reproduces the exact beat down to the kit selection.

And it pairs cleanly with the rest of the toolkit. The piano roll uses the same scheduling pattern for chord-progression auto-play. The metronome uses the same look-ahead pattern for clicks. Five tools, one timing model.

Try the drum sandbox

Pick a kit, drop in a chord progression, layer a walking bass.

Open /tools/drums →

What's next

The drums tool has reached a plateau — 16-step grid, four kits, three layers all synced. Future work is more likely to add a peer tool (a sample-trimming tool, or a key-modulation helper) than to keep iterating on this one. The pattern that keeps working: ship the small tight version first, layer complexity onto the same scheduler instead of building a new one.

© 2026 StudioMode · One HTML file, four rounds, zero servers.