Skip to content
Vince.
All case studies

Web Audio · Timing

Sequencer

Sample-accurate drum machine with synthesised voices

A step sequencer whose timing does not drift, does not stutter under load, and does not fall apart in a background tab. Every drum sound is synthesised from oscillators and shaped noise, so the whole instrument ships as zero bytes of audio assets.

  • Web Audio API
  • TypeScript
  • React

In short

  • Two-clock scheduling: a coarse timer schedules ahead, the audio clock places the notes
  • Verified drift-free over 200 deliberately irregular polling intervals, to nine decimal places
  • Swing shifts reported times without touching the underlying grid, so it cannot accumulate error
  • Kick, snare, hats, clap and toms synthesised from oscillators and filtered noise — no samples

setInterval is not a musical clock

The naive drum machine calls setInterval at the step duration and triggers a sound in the callback. It sounds wrong immediately. Timers fire late under layout and garbage collection, they are throttled hard in background tabs, and every late callback is a note that lands late — the errors accumulate rather than cancelling.

Worse, the lateness is variable. Constant lag would be inaudible; jitter of a few milliseconds that changes bar to bar is exactly what the ear is most sensitive to in rhythm.

Two clocks

The fix is the arrangement Chris Wilson described in 'A Tale of Two Clocks'. A coarse timer wakes up frequently and looks a short distance into the future. Every note falling inside that window is handed to the Web Audio API with an explicit start time, and the audio hardware places it exactly.

This inverts the reliability requirement in a useful way. The timer no longer has to be punctual — it only has to wake up more often than the lookahead window is long. A callback that arrives 40 ms late costs nothing when the scheduler is already 100 ms ahead. The audio clock, which runs on its own thread and is sample-accurate by construction, does the part that needs precision.

Timing you can unit-test

The timing arithmetic is separated from Web Audio entirely. The clock holds no timer and calls nothing: something else tells it what time it is and asks what is due. That one decision makes the hardest part of the system the easiest part to test.

The suite drives it with a fake clock through 200 deliberately uneven wake-ups — 1 ms here, 200 ms there, mimicking a throttled tab — and asserts that every emitted step time is exactly its index times the step duration, to nine decimal places. Drift is not sampled for; it is proven absent.

Swing gets the same treatment. It displaces the reported time of the off-beats without touching the underlying grid, so the on-beats stay locked no matter how long the pattern runs. That is asserted directly: with swing at maximum, the on-beats still land on exact multiples of the beat after four seconds of playback.

Drums from first principles

Every voice is synthesised. A kick is a sine whose frequency drops from 150 Hz to 50 Hz in about 40 ms, with an amplitude envelope that decays a little more slowly — the pitch drop is what the ear reads as the beater strike. A snare is band-passed white noise plus a short tone body. Hats are high-passed noise with a very fast decay, closed and open differing only in envelope length.

It is a real constraint that pays off twice: the demo loads instantly with no audio files to fetch, and the parameters stay open to the interface, so tuning a drum is a slider rather than a new asset.

Source

  • src/lib/audio/scheduler.ts
  • src/lib/audio/scheduler.test.ts
  • src/lib/audio/voices.ts