Make kicks, snares, hats, claps and resonating percussion with synthesized drum voices you can tune and shape.
The analog drum machines as circuits — nine note-triggered per-voice audio sources (siblings of modal / saw), knob-tuned like the hardware, plus one pitch-tracked waveguide-mesh membrane, with no sample anywhere: a song built on them is fully portable and byte-deterministic. Circuit-level models, not SPICE (see Circuit Modeling §5.8). The 808 kick and snare are true bridged-T resonators — a second-order ZDF filter kicked by a trigger pulse, whose resonant frequency shifts with its own oscillation amplitude, so the pitch dip is intrinsic and drive-dependent, not a scheduled sweep (§below). The 909 kick is genuinely different — a VCO-style core with a real pitch-envelope circuit, where a scheduled sweep is faithful-in-kind — and the hat is the metallic square-oscillator bank. Each voice adds seeded noise and normalized tanh rails.
Hear it and change it#
Listen for: a low resonant kick, a noisy snare, and metallic hats. These sources synthesize the hits, so this program needs no drum recordings.
tempo = 118bpm
timingVersion = 2
kickvoice = patch { source = kick808 0.45 0.4 0.25, gain = vel * 0.6 }
snarevoice = patch { source = snare808 0.45 0.55, gain = vel * 0.35 }
hatvoice = patch { source = hat606 0.12 0.55, gain = vel * 0.25 }
play kickvoice "c3*4"
play snarevoice "~ c3 ~ c3"
play hatvoice "c5*8" |> gain "[0.7 0.3]*4"
Make it yours: change the kick's decay from 0.4 to 0.7. Then listen to whether the longer tail leaves room for the next hit. The classic drum sources use their own tuning knobs; the note pattern triggers them. Drum synthesis covers the full kit.
1. The voices#
boom = macro 0.4 -- the kick's weight
snappy = macro 0.5 -- (`snap` is a builtin — the slice-grid verb)
sizzle = macro 0.4
kick = patch { source = kick808 0.42 boom (0.2 + boom * 0.4), gain = vel * 0.75 + 0.25 }
nine = patch { source = kick909 0.5 0.42 0.7 }
snare = patch { source = snare808 0.45 snappy }
hat = patch { source = hat606 (trackParam "open" 0.12) sizzle }
clap = patch { source = clap909 0.4 }
kick808 tune decay [drive]#
A true bridged-T resonator: a ZDF two-integrator (SVF) bandpass struck by a drive-scaled trigger pulse, plus a 2 ms strike click, into a normalized tanh saturator. The resonant frequency f_eff = f0·(1 + shift) where shift follows the ring's own amplitude (a 3 ms power follower → a fast-attack / slow-release peak hold) — a hard hit shifts the operating point further up, so the kick starts sharper and settles to f0 as it decays. The pitch dip is intrinsic to the circuit, not a scheduled chirp.
tune0..1 → 30..90 Hz settled fundamental.decaylog-maps onto an 80 ms..1.2 s T60. Dampingkcomes from the 2-pole amplitude-decay lawamplitude ∝ exp(−ω0·k·t/2), sok = ln(1000)/(π·f0·T60)places the ring's T60 where the knob asks (the brief pitch shift makes the measured T60 land a touch shorter, still in range).drive(default 0.25) — one knob, two jobs: it scales the strike pulse into the resonator (a hotter hit rings harder ⇒ a deeper, longer dip) and the output saturatortanh(g·x)/tanh(g), g = 1..7. A mild fixed-pivot tanh on the bandpass state (the transistor) keeps it stable and lets the hottest hits compress.
Committed behavior — the drive-dependent pitch dip. Measured through the engine (tune 0.5 ⇒ f0 = 60 Hz, decay 0.5; early 8–40 ms vs settled 150–300 ms window, Hann + parabolic peak, 2026-07-21):
| drive | early pitch | settled | dip |
|---|---|---|---|
| 0.1 | 73 Hz | 60 Hz | ≈ 334 cents |
| 0.9 | 84 Hz | 60 Hz | ≈ 581 cents |
The dip grows ~1.7× from soft to hard (a hot strike dips markedly more), and both strengths settle to the same tuned f0 (< 0.5 % apart). Gated by engine/tests/dsp_validation.rs::kick808_pitch_dip_is_drive_dependent.
kick909 tune decay [click]#
The four-on-the-floor punch. Faithful-in-kind distinction from the 808: the 909 kick's pitch envelope is a real envelope on a VCO in the hardware (not a bridged-T operating-point shift), so a scheduled pitch sweep is the honest model here — kept as-is: a bigger sweep (3.5×→1 over ~45 ms) plus a 7 kHz highpassed-noise click layer, under light fixed saturation. The click filter derives its coefficient from the render sample rate, so its colour stays fixed at 44.1, 48, and 96 kHz.
tune0..1 → 40..110 Hz;decay→ 60..720 ms T60.click(default 0.5) — the transient layer's level.
snare808 tone [snappy]#
Two bridged-T shell resonators at the 1:1.83 ratio (T60 200 ms / 130 ms), each with its own intrinsic pitch dip under a fixed internal strike (there is no drive knob), plus the snappy noise path (one-pole HPF → exp decay).
tone0..1 tunes the shell pair 155..260 Hz and opens the noise HPF (800..2000 Hz).snappy(default 0.5) — the hardware's blend knob: 0 ≈ pure shell, 1 mostly snap (shells duck 45%, noise T60 stretches 180→330 ms).
hat606 decay [tone]#
The metallic bank: six square oscillators at the published TR-808 cymbal frequencies (205.3/304.4/369.6/522.7/540.0/800.0 Hz) summed → bandpass → 6 kHz highpass → fast VCA. Provenance: literature-derived from memory (Werner et al.'s DAFx 808 voice-circuit analyses) — no service manual access. They replaced the earlier tune-by-ear set (263/400/421/474/587/845 Hz); the metallic bandpass/HP stack owns the timbre, so the exact fundamentals mainly set the density of the inharmonic comb the filters carve. The 7–10 kHz bandpass was left unchanged — the hat stays bright (zero-crossing-rate check still passes) with the new bank.
decaylog-maps 40 ms (closed tick) → 800 ms (open splash). One patch plays both: drive it from a per-note lane —hat606 (trackParam "open" 0.12) t+!"c5 c5 c5(open=0.85)".tone(default 0.5) scales the bank (±) and slides the bandpass center 7..10 kHz.
Rendered as the analog circuit (2026-11, evolve round 6). hat606, cymbal606 and cowbell808 render in engine/src/metal.rs: each square edge adds the exact band-limited response of the analog bandpass/highpass prototypes (no aliasing, analog magnitude and phase), the loud start of a note runs at 2× through a short halfband so the rails do not alias, and the voice falls silent below −73 dB. Against the continuous-time circuit the hat's signal-to-error ratio is 49-52 dB (the naive host-rate squares and matched-z one-pole highpass it replaces: 6-7 dB; cymbal 14 → 40, cowbell 17 → 36). The voice is 8 samples (0.17 ms) later than before (the halfband). The corrected 6 kHz highpass passes ~3.4 dB more than the old one-pole did (3.7 dB low at 8 kHz), so hat606 carries a fixed −3.42 dB output trim after its rails (metal::HAT606_TRIM_DB): the tone and the saturation are the circuit's, the level the one songs were voiced with (RMS within ±0.25 dB of the old voice; peaks ≈ 0.76).
clap909 spread#
Bandpassed noise (1.1 kHz, Q 2, 400 Hz HP) under the 909's segmented envelope: three retriggered bursts (τ 3.5 ms) then a body tail at 0.55×.
spread0..1 → 6..20 ms between bursts; the tail T60 stretches with it (0.2..0.4 s).
1b. The rest of the box (Stage 2, 2026-09-01)#
Four more model strings on the same NodeKind::Drum — additive in every layer, same per-hit knob latch, same seeding.
tomHi = patch { source = tom808 (trackParam "tune" 0.65) 0.5 0.3 }
rim = patch { source = rim808 } -- bare: the stock hit
bell = patch { source = cowbell808 0.6 } -- applied: one optional trim
ride = patch { source = cymbal606 0.85 wash }
tom808 tune decay [noise]#
A bridged-T pair under a fixed strike (there is no drive knob): the fundamental on the decay knob's T60 and an overtone at 1.5× on half of it, each with the 808's intrinsic pitch droop, plus the hardware's short lowpassed noise puff (1.8 kHz, 60 ms).
tune0..1 log-maps 55..330 Hz (low tom → conga);decaylog-maps 100 ms..900 ms.noise(default 0.3) — the puff's level. One patch plays a whole tom rack from a per-note lane:tom808 (trackParam "tune" 0.35) 0.5+!"c3 c3(tune=0.6) c3(tune=0.85)".
rim808 [tone] · cowbell808 [decay] — the knobless one-shots#
The hardware has no knobs on either, so both are bare values as well as functions — source = rim808 is the stock hit (the noise duality: a bare signal-source name is the signal; applied, it takes its optional knob).
rim808— a 1 ms strike drives two short ringing click filters (≈505 Hz body, ≈1720 Hz rim; T60 35 / 20 ms) hard into the saturator, plus a seeded click.tone(default 0.5) pulls both rings ±20 %. Frequencies are literature-derived (no service manual access).cowbell808— the 540 / 800 Hz squares (two members of the cymbal bank, "pulled" together) summed through the 2.64 kHz clank bandpass, plus a 400 Hz-highpassed taste of the raw pair so the two tones read, under a VCA with the 808's accent spike (τ 6 ms) on the front.decay(default 0.5) log-maps 120..600 ms.
cymbal606 decay [tone]#
The hat606 bank through the two-band swing-VCA topology: the six squares split into a mid band (≈3.4 kHz, Q 0.9) on the long "swing" decay and a high band (≈7.5 kHz, Q 1.1 → 5 kHz HP) on a fast one (22 % of the knob's T60), so the strike sizzles and the wash rides.
decaylog-maps 300 ms (choked crash) → 3 s (ride wash).tone(default 0.5) scales the bank ±, slides both bands, and tilts the balance toward the sizzle.
1c. mesh freq tension damping [size] [strike] — the waveguide membrane (Stage 3, 2026-09-01)#
The one pitch-tracked drum. A rectangular 2-D digital waveguide mesh (Van Duyne & Smith): a grid of 4-port scattering junctions joined by unit delays, clamped at the rim, struck with a raised-cosine mallet pulse feathered over a 3×3 neighbourhood, read at an off-centre pickup (0.31, 0.69 — no symmetric nodal line silences it), through a normalized soft limiter.
tom = patch { source = mesh (freq pitch) 0.6 0.35 0.4 0.35 }
timpani = patch { source = mesh (freq pitch) 0.35 0.2 0.85 0.2 }
gong = patch { source = mesh (freq pitch) 0.25 0.05 1.0 0.75 }
How pitch tracking works. A mesh's fundamental is fixed by its grid and its clock, so freq sets the clock: the mesh ticks r = freq / f_mesh times per output sample (f_mesh = the clamped membrane's natural fundamental in cycles per tick, engine/src/mesh.rs::natural_fundamental) and the tick stream is 4-point-Hermite-interpolated back onto the render rate. Consequences the doc commits to:
- cost ≈
r·N·Mjunction updates per sample — cheap at tom pitches (a 100 Hz tom on the default grid ticks ~18× slower than the render rate), capped atr ≤ 2(the voice-cap guard; above it the pitch stops rising); - the membrane's bandwidth is
r·sr/2— dark by construction, like a real head — so a separate 2.5 ms seeded stick click (brighter on a tight head) carries the attack; freqis read per block (themodaldiscipline); glides step per block.
Tension modulation is real. A membrane hit hard goes SHARP and settles (f ∝ √T, and T rises with displacement): the mesh's own energy (a 4 ms follower over the junction field) pushes the clock up, with a depth that falls as (1 − tension)² — deep on a loose head, gone on a tight one.
tension0..1 — head tightness: the pitch-glide depth above, and the rim reflection's lowpass (loose = dark rim, tight = bright, near-lossless).damping0..1 — the T60, 4 s → 60 ms (thestringlaw), applied as a per-tick loss that follows the clock so the knob means seconds at every pitch.size(default 0.5) — baked at compile time (a constant, likestring'sstiffness: amacrofreezes at its initial, a live signal such asvelis a lowering error): it sizes the junction grid throughvolta_compiler::ir::mesh_grid— 6..16 junctions on the short side, the long side 1.27× (a non-square membrane keeps its mode pairs from degenerating) — so the compiler's cost model (cost.rs, ∝ N·M) charges exactly the grid the engine builds. Bigger = denser modes (gongs, timpani) and a wider body bandwidth for the same pitch.strike(default 0.5) — strike position, centre (fundamental-heavy) → along the diagonal to the rim (overtones).
Committed behavior — pitch and glide. Measured through engine/src/mesh.rs (11 × 14 grid, 48 kHz, Hann DFT peak, 2026-09-01): a tight head (tension
- settles on the played 110 Hz exactly (0 cents); a loose head (
tension - starts ≈ +230 cents sharp in the first 50 ms and settles to the played pitch (the rim-lowpass period stretch is folded into the clock, so
freqis the settled fundamental at every tension). Two derivation facts that were wrong on the first pass and are now pinned by tests: the clamped length that fits the measured fundamental is N junctions, not N + 1 (the rim reflection returns one tick later, inverted), and the rim one-pole adds ≈ 2·(1 − a)/a ticks per period (RIM_DELAY_K).
Modal form (2026-11, evolve round 6). The engine runs the mesh in its modal form: with the clamped rim the junction grid's eigenvectors are the separable sines sin(pπ(i+½)/N)·sin(qπ(j+½)/M), so the strike, pickup, per-tick loss, rim loss (per mode: the rim one-pole's |H(ω)| at the rate the mode's group velocity reaches the rims), clock, Hermite resampler, glide, click and limiter are kept while each audible mode is one 2-pole resonator. The mode frequencies are the ideal membrane's (f_pq ∝ √((p/N)² + (q/M)²)) instead of the rectilinear mesh's dispersion, which put axial modes up to ~100 cents flat: weighted mode error vs the 2-D wave equation 1-4 cents (junction mesh 9-31), body T60 and spectral balance within ~5 % / ~1 dB of the junction mesh below 4.6·f1 (loose heads keep somewhat more of their upper partials late in the decay), and 15-40× cheaper (2-6 ns per voice and sample at song settings; cost.rs still charges the junction count).
tension / damping / strike latch at note-on like every drum knob. The grid is allocated once per voice slot (boxed MeshState) and re-armed in place across retriggers; only the click re-seeds per hit (performance coordinate + node id + "mesh").
1d. lib/machines.volta — the kits (Stage 4, 2026-09-01)#
Three Configurable library modules over the raw voices, with tuned defaults, gain staging, and per-instance macros:
import "machines" (tr808, tr909, skins)
box = tr808 { tune = 0.5, decay = 0.6 } -- box.kick … box.cymbal, knobs box.drive / box.snappy / box.tone
nine = tr909 { punch = 0.7, tone = 0.55 } -- nine.kick, nine.clap, nine.ride, nine.crash …
skin = skins { tension = 0.5, damping = 0.45 } -- skin.tom / timpani / gong / tabla — play PITCHES
tr808(tune, decay): kick, snare, hat (opens through an (open=…) per-note param), ohat, tomLo/tomMid/tomHi, rim, cowbell, clap, cymbal. tr909(punch, tone): kick, snare, hat, ohat, clap, ride, crash. skins(tension, damping): four mesh membranes at different grid sizes. Every instance's macros are namespaced (box.drive vs nine.snappy), so a scene with { box.drive = 0.6 } bumps one box's NEXT kick. See The standard library.
2. Semantics#
- Knob-tuned, not pitch-tracked — the pattern's pitches are ignored (faithful to the hardware); write any token. Velocity works normally.
- Knobs latch at note-on (per-hit semantics): a moving macro sculpts each NEW hit;
randomPerNote/vel/trackParamknob expressions land exactly once per hit. Mid-note sweeps do nothing until the next trigger. - Fixed IR arity —
NodeKind::Drum { model, params }; the lowerer fills trailing-knob defaults (kick drives 0.25, tom noise 0.3, everything else 0.5 —lower/expr.rs::drum_knobs) so backends never special-case an absent knob. Wrong arity is a lowering error naming the expected signature. The barerim808/cowbell808forms lower through the same table with every slot at its default.meshis its own node (NodeKind::Mesh) withsizebaked andstrikedefaulted to 0.5. - Deterministic — noise is seeded per voice occurrence (performance coordinate + node id +
"drum"): replays are bit-identical per backend, simultaneous voices decorrelate.refine.rstreats every voice as a bounded bipolar source and visits knob inputs. - Output is hard-bounded to [-1, 1] (the saturator clamps its >1 transient overshoot region — "the rails").
3. Backends#
- Native engine (reference) —
engine/src/drum.rs:DrumStateowned byvoice/state.rs'sNodeState::Drum, allocation-free per sample; the bridged-T resonators + RBJ bandpass + one-pole HP/LP configured at note-on;physical::XorShift32noise. The square-bank voices (hat606 / cymbal606 / cowbell808) render block-wise inengine/src/metal.rsfrom a boxed state (allocated at a slot's first hit, re-armed in place).engine/src/mesh.rs:MeshState(boxed,NodeState::Mesh), the one heap allocation at first eval, re-armed in place. This is also the default browser path via the engine-wasm build (./build-engine-wasm.sh— rebuild after any drum change). - Browser — the native engine in an AudioWorklet is the only browser path (the legacy TypeScript workbench and its
physical-processor.jsworklet were deleted 2026-08-09, Workbench Convergence §6c), so every voice here renders bit-identically on every target.runtime/src/types.tsmirrors theDrumandMeshIR arms for the page-side host.
4. Tests#
engine/src/drum.rsunit tests (15): every model (all nine) sounds-then-decays and stays in [-1, 1]; bit-identical replay + seed decorrelation; kick pitch followstune(zero-crossing measure); decay knobs stretch T60 (kick + hat closed/open); snare snappy blend; clap multi-burst attack count; knob latch-at-note-on; Stage 2 — tom pitch/decay/ noise-puff, rimshot length + register +tone, cowbell register +decay, cymbal rides past a second where the hat can't + the high band decays faster than the mid.engine/src/mesh.rsunit tests (8): sounds/decays/bounded across the knob corners; pitch tracksfreqwithin 50 cents (Hann DFT peak, tight head); damping stretches T60; loose head bends sharp then settles while a tight one holds; a bigger grid spreads energy over more modes; bit-identical replay + re-arm replays the hit; knob latch; the clock cap.engine/tests/drum_voices.rse2e: full source → IR → engine render for all nine box voices (applied AND bare forms; sound + decay + bounded), the mesh tracking an octave through the real engine, byte-identical re-renders, trailing-knob defaults play.engine/tests/dsp_validation.rs(Stage E gates): the drive-dependent pitch dip (kick808_pitch_dip_is_drive_dependent), the decay-knob T60 span (kick808_decay_knob_still_spans_the_range), and finite/bounded across all five voices (drum_voices_are_finite_and_bounded).engine/tests/golden_audio.rs: thekick808/kick909/snare808/hat606/clap909clips are hashed in the manifest; the Stage-E sound change re-blessed kick808/snare808/hat606 (kick909/clap909 stayed bit-identical, 2026-07-21). Stage 2/3 addedtom808,rim808,cowbell808,cymbal606,mesh_tom(the five Stage-1 clips stayed bit-identical, 2026-09-01).- Compiler: lowering tests (default fill, model names, arity errors, the Stage 2 bare/applied forms,
mesh's baked size + strike default + the non-constant-size rejection); golden IR snapshotsmachinedrum.json,machines.json,machinetour.json.
5. Deferred#
@keytracked tune-follows-pitch variant for the box models (the mesh is the pitch-tracked drum; a tom rack from tom808 uses a per-note tune lane instead), choke groups (the 606 open/closed pair), a rim808-style clave — see Design a drum kit §6.