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