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.
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:
- Off — chord stack only.
- Roots — single sine root note held for the chord's full half-bar.
- Walking — root on beat 1, fifth on beat 3, both rings ~1.95 beats.
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.