← code

drum machine

A 16-step sequencer with synthesized drum sounds — no samples, just oscillators and filtered noise through the Web Audio API.

kick
snare
hat
clap

synthesis, not samples

There are no audio files behind this page. Every sound is built at the moment it plays out of two ingredients the Web Audio API gives you for free: oscillators producing pure tones, and buffers of random samples producing white noise. Shaping those into something that reads as a drum is mostly a matter of filtering away the parts you don't want and controlling how fast the volume collapses — the same broad approach as the analog Roland TR-808, which also synthesized its voices rather than replaying recordings of a kit.

the voices

Each voice takes a different route. The kick is a single sine wave whose pitch drops from 150 Hz to 40 Hz in about a tenth of a second — that fast downward sweep, not the steady tone underneath it, is what the ear hears as a beater striking a head. The snare is two sounds at once: a 180 Hz tone for the drum body, plus high-passed noise standing in for the rattle of the wires. The hat is noise with everything below 7 kHz stripped out and a 50 millisecond decay, which is short enough to register as a tick rather than a hiss. The clap is the interesting one: band-passed noise fired several times a few milliseconds apart, because a real room full of people never claps in perfect unison, and reproducing that smear is what keeps it from sounding like a single dry burst.

timing

The timing deserves its own note. Firing notes from a JavaScript timer directly would put every hit at the mercy of whatever else the main thread is doing, and the jitter is audible immediately. Instead this runs the standard two-clock arrangement: a coarse timer wakes up every 25 ms, looks a tenth of a second into the future, and schedules any notes falling in that window against the audio hardware's own clock, which runs independently of page rendering. The timer only decides what to schedule; the audio clock decides exactly when each sound starts.

generated patterns

The grid is programmed by hand here, one step at a time. For the opposite approach — patterns generated from two numbers rather than drawn — the euclidean rhythm page uses the same synthesis ideas but derives its onsets algorithmically, and the karplus-strong page pushes noise-based synthesis further, using a delay line to turn a burst of static into a pitched, ringing string.

view source
import { useEffect, useRef, useState } from 'react';
import { DRUM_VOICES } from '../../lib/voices';

const STEPS = 16;
const TRACKS = [
  { id: 'kick', label: 'kick' },
  { id: 'snare', label: 'snare' },
  { id: 'hat', label: 'hat' },
  { id: 'clap', label: 'clap' },
] as const;

type TrackId = (typeof TRACKS)[number]['id'];
type Pattern = Record<TrackId, boolean[]>;

function stepsAt(active: number[]): boolean[] {
  const row = Array<boolean>(STEPS).fill(false);
  for (const i of active) row[i] = true;
  return row;
}

function emptyPattern(): Pattern {
  return {
    kick: Array<boolean>(STEPS).fill(false),
    snare: Array<boolean>(STEPS).fill(false),
    hat: Array<boolean>(STEPS).fill(false),
    clap: Array<boolean>(STEPS).fill(false),
  };
}

const DEFAULT_PATTERN: Pattern = {
  kick: stepsAt([0, 6, 8, 14]),
  snare: stepsAt([4, 12]),
  hat: stepsAt([0, 2, 4, 6, 8, 10, 12, 14]),
  clap: stepsAt([]),
};

const SCHEDULE_AHEAD = 0.1; // seconds
const LOOKAHEAD_MS = 25;

export default function DrumMachine() {
  const [pattern, setPattern] = useState<Pattern>(DEFAULT_PATTERN);
  const [playing, setPlaying] = useState(false);
  const [bpm, setBpm] = useState(120);
  const [currentStep, setCurrentStep] = useState(-1);

  const patternRef = useRef(pattern);
  const bpmRef = useRef(bpm);
  const audioCtxRef = useRef<AudioContext | null>(null);
  const masterGainRef = useRef<GainNode | null>(null);
  const timerRef = useRef<number | null>(null);
  const nextStepTimeRef = useRef(0);
  const stepIndexRef = useRef(0);

  useEffect(() => {
    patternRef.current = pattern;
  }, [pattern]);
  useEffect(() => {
    bpmRef.current = bpm;
  }, [bpm]);

  useEffect(() => {
    return () => {
      if (timerRef.current) clearInterval(timerRef.current);
      audioCtxRef.current?.close();
    };
  }, []);

  const secondsPerStep = () => 60 / bpmRef.current / 4;

  const scheduler = () => {
    const ctx = audioCtxRef.current;
    const out = masterGainRef.current;
    if (!ctx || !out) return;
    while (nextStepTimeRef.current < ctx.currentTime + SCHEDULE_AHEAD) {
      const index = stepIndexRef.current;
      const time = nextStepTimeRef.current;
      for (const track of TRACKS) {
        if (patternRef.current[track.id][index])
          DRUM_VOICES[track.id](ctx, out, time);
      }
      const delay = Math.max(0, (time - ctx.currentTime) * 1000);
      setTimeout(() => setCurrentStep(index), delay);
      nextStepTimeRef.current += secondsPerStep();
      stepIndexRef.current = (index + 1) % STEPS;
    }
  };

  const start = () => {
    if (!audioCtxRef.current) {
      const ctx = new AudioContext();
      const gain = ctx.createGain();
      gain.gain.value = 0.8;
      gain.connect(ctx.destination);
      audioCtxRef.current = ctx;
      masterGainRef.current = gain;
    }
    const ctx = audioCtxRef.current;
    if (ctx.state === 'suspended') ctx.resume();
    stepIndexRef.current = 0;
    nextStepTimeRef.current = ctx.currentTime + 0.05;
    setPlaying(true);
    timerRef.current = window.setInterval(scheduler, LOOKAHEAD_MS);
  };

  const stop = () => {
    setPlaying(false);
    if (timerRef.current) {
      clearInterval(timerRef.current);
      timerRef.current = null;
    }
    setCurrentStep(-1);
  };

  const toggleStep = (track: TrackId, index: number) => {
    setPattern((prev) => ({
      ...prev,
      [track]: prev[track].map((v, i) => (i === index ? !v : v)),
    }));
  };

  const clear = () => setPattern(emptyPattern());

  return (
    <div className="drum-machine">
      <div className="tracks">
        {TRACKS.map((track) => (
          <div className="track" key={track.id}>
            <span className="track-label">{track.label}</span>
            <div className="steps">
              {pattern[track.id].map((active, i) => (
                <button
                  key={i}
                  type="button"
                  aria-label={`${track.label} step ${i + 1}`}
                  className={[
                    'step',
                    active ? 'active' : '',
                    i === currentStep ? 'current' : '',
                    i % 4 === 0 ? 'beat' : '',
                  ]
                    .filter(Boolean)
                    .join(' ')}
                  onClick={() => toggleStep(track.id, i)}
                />
              ))}
            </div>
          </div>
        ))}
      </div>
      <div className="controls">
        <button type="button" onClick={() => (playing ? stop() : start())}>
          {playing ? 'stop' : 'play'}
        </button>
        <button type="button" onClick={clear}>
          clear
        </button>
        <label className="bpm">
          {bpm} bpm
          <input
            type="range"
            min={60}
            max={180}
            value={bpm}
            onChange={(e) => setBpm(Number(e.target.value))}
          />
        </label>
      </div>
      <style>{`
        .drum-machine {
          border: 1px solid #2a2d31;
          background: #0e1113;
          padding: 1rem;
        }
        .tracks {
          display: flex;
          flex-direction: column;
          gap: 0.5rem;
        }
        .track {
          display: flex;
          align-items: center;
          gap: 0.75rem;
        }
        .track-label {
          width: 3.5rem;
          flex-shrink: 0;
          color: var(--muted);
          font-size: 0.75rem;
          text-transform: uppercase;
          letter-spacing: 0.04em;
        }
        .steps {
          display: grid;
          grid-template-columns: repeat(${STEPS}, 1fr);
          gap: 0.25rem;
          flex: 1;
        }
        .step {
          aspect-ratio: 1;
          border: 1px solid #2a2d31;
          background: transparent;
          padding: 0;
          cursor: pointer;
        }
        .step.beat {
          border-left-color: #3d4147;
        }
        .step:hover {
          border-color: #4a4f56;
        }
        .step.active {
          background: var(--accent);
          border-color: var(--accent);
        }
        .step.current {
          box-shadow: 0 0 0 2px #e6e6e6 inset;
        }
        .controls {
          display: flex;
          align-items: center;
          gap: 0.75rem;
          margin-top: 1rem;
          flex-wrap: wrap;
        }
        .controls button {
          font-family: inherit;
          font-size: 0.75rem;
          color: #8a8f98;
          background: rgba(0, 0, 0, 0.4);
          border: 1px solid #2a2d31;
          padding: 0.25rem 0.75rem;
          cursor: pointer;
        }
        .controls button:hover {
          color: #e6e6e6;
        }
        .bpm {
          display: flex;
          align-items: center;
          gap: 0.5rem;
          color: var(--muted);
          font-size: 0.75rem;
        }
      `}</style>
    </div>
  );
}