← 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
view source
import { useEffect, useRef, useState } from 'react';

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([]),
};

function noiseBuffer(ctx: AudioContext, duration: number): AudioBuffer {
  const buffer = ctx.createBuffer(1, ctx.sampleRate * duration, ctx.sampleRate);
  const data = buffer.getChannelData(0);
  for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1;
  return buffer;
}

function playKick(ctx: AudioContext, out: AudioNode, time: number) {
  const osc = ctx.createOscillator();
  const gain = ctx.createGain();
  osc.type = 'sine';
  osc.frequency.setValueAtTime(150, time);
  osc.frequency.exponentialRampToValueAtTime(40, time + 0.15);
  gain.gain.setValueAtTime(1, time);
  gain.gain.exponentialRampToValueAtTime(0.001, time + 0.3);
  osc.connect(gain).connect(out);
  osc.start(time);
  osc.stop(time + 0.3);
}

function playSnare(ctx: AudioContext, out: AudioNode, time: number) {
  const noise = ctx.createBufferSource();
  noise.buffer = noiseBuffer(ctx, 0.2);
  const filter = ctx.createBiquadFilter();
  filter.type = 'highpass';
  filter.frequency.value = 1000;
  const noiseGain = ctx.createGain();
  noiseGain.gain.setValueAtTime(1, time);
  noiseGain.gain.exponentialRampToValueAtTime(0.01, time + 0.2);
  noise.connect(filter).connect(noiseGain).connect(out);
  noise.start(time);
  noise.stop(time + 0.2);

  const osc = ctx.createOscillator();
  const oscGain = ctx.createGain();
  osc.type = 'triangle';
  osc.frequency.value = 180;
  oscGain.gain.setValueAtTime(0.7, time);
  oscGain.gain.exponentialRampToValueAtTime(0.01, time + 0.1);
  osc.connect(oscGain).connect(out);
  osc.start(time);
  osc.stop(time + 0.1);
}

function playHat(ctx: AudioContext, out: AudioNode, time: number) {
  const noise = ctx.createBufferSource();
  noise.buffer = noiseBuffer(ctx, 0.05);
  const filter = ctx.createBiquadFilter();
  filter.type = 'highpass';
  filter.frequency.value = 7000;
  const gain = ctx.createGain();
  gain.gain.setValueAtTime(0.5, time);
  gain.gain.exponentialRampToValueAtTime(0.01, time + 0.05);
  noise.connect(filter).connect(gain).connect(out);
  noise.start(time);
  noise.stop(time + 0.05);
}

function playClap(ctx: AudioContext, out: AudioNode, time: number) {
  for (const offset of [0, 0.02, 0.04]) {
    const noise = ctx.createBufferSource();
    noise.buffer = noiseBuffer(ctx, 0.08);
    const filter = ctx.createBiquadFilter();
    filter.type = 'bandpass';
    filter.frequency.value = 1200;
    const gain = ctx.createGain();
    gain.gain.setValueAtTime(0.6, time + offset);
    gain.gain.exponentialRampToValueAtTime(0.01, time + offset + 0.08);
    noise.connect(filter).connect(gain).connect(out);
    noise.start(time + offset);
    noise.stop(time + offset + 0.08);
  }
}

const VOICES: Record<TrackId, (ctx: AudioContext, out: AudioNode, time: number) => void> = {
  kick: playKick,
  snare: playSnare,
  hat: playHat,
  clap: playClap,
};

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]) 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>
  );
}