Web Audio Scheduling Kit
Two clocks, one job each. `AudioContext.currentTime` is a sample counter and never drifts, but it cannot wake you up; `setInterval` can wake you up, but only when the main thread is free. The scheduler runs a 90 ms timer whose only responsibility is to ask which beats fall inside the next 300 ms and hand each one an exact audio time. The timer can fire late, early, or twice in a row and the notes still land in the same place.
Web Audio Scheduling Kit
Live preview
The real component, running in this page.
Install
Copies the source into your project. Nothing is imported from us at runtime.
npx shadcn@latest add https://kinetic-seven-taupe.vercel.app/r/web-audio-scheduler.jsonNo runtime dependencies.
Prompt
The whole technique, written for an agent.
Build it with your agent
A single self-contained brief: the technique, the constants and why they hold, the pitfalls, and the definition of done. Paste it and the component gets written in your own conventions instead of copied in ours.
Source
5 files, exactly as installed.
API
Every parameter, its type and what happens when you change it.
| Parameter | Type | Default | Description |
|---|---|---|---|
| bpm | number | = 96 | Tempo. One beat is 60 / bpm seconds on the audio clock. |
| steps | number | = 8 | Beats per bar. Only affects the wrapped step index handed to onBeat. |
| lookahead | number | = 0.3 | Seconds of beats queued in advance. Must exceed the worst-case timer gap. |
| intervalMs | number | = 90 | How often the scheduling timer runs. Independent of the tempo. |
| onBeat | (beat: ScheduledBeat) => void | — | Called once per beat, lookahead seconds early. Schedule audio against beat.time. |
How it works
The technique in order, not the code in order.
nextTimeholds the audio-clock time of the next unqueued beat, initialised toctx.currentTime + START_LEADwhenstart()runs. Every timer tick loopswhile (nextTime < ctx.currentTime + lookahead), callsonBeat({ time: nextTime, beat, step, ctx }), and advancesnextTime += 60 / bpm.The advance accumulates from the previous beat time, never from
ctx.currentTime. Re-basing on the clock each pass folds the timer's jitter into the tempo, and a sequence that re-bases runs measurably sharp inside a minute.It is a
whileand not anifbecause one tick may have to cover several beats — the timer was throttled, the tempo is faster than the interval, or the tab just came back to the foreground.scheduleTonebuilds an oscillator and a gain node per beat, rampsgainfrom its peak to 0.001 overdecay, stops the oscillatorTAILseconds after the ramp ends, and disconnects both nodes inonendedso the graph does not accumulate dead nodes.The visual step index comes from a second, independent pass: a rAF loop reads
ctx.currentTimeonce per frame and pops every queued beat whose time has passed, so the highlighted step is the step you can hear rather than the step that was most recently queued 300 ms ago.phaseis derived in the same loop as1 - (nextQueuedBeat.time - now) / secondsPerBeat, clamped to 0-1, and written to a ref rather than to state — a playhead needs a value every frame and state would re-render the whole sequencer 60 times a second.On
visibilitychangeto hidden the lookahead widens to 1.5s, because a hidden tab clampssetIntervalto roughly one call per second and a 0.3s window would leave gaps of missed beats.
Load-bearing constants
| Constant | Value | Why this number |
|---|---|---|
| intervalMs | = 90 | How often the scheduler wakes. It only has to beat the lookahead window, not the tempo — at 96 bpm a beat is 625 ms, so the timer gets about seven chances to queue each one. Dropping to ~10 ms burns main-thread time for nothing; going past ~250 ms leaves too few chances inside a 300 ms window. |
| lookahead | = 0.3 | Seconds queued in advance. Must exceed the worst-case gap between timer calls, which under main-thread contention is several times intervalMs — 0.3s over a 0.09s interval is a 3x margin. Larger windows make a tempo or pattern change take longer to take effect, because beats already queued cannot be un-queued. |
| HIDDEN_LOOKAHEAD | = 1.5 | Seconds queued while the tab is hidden. Chrome and Firefox clamp background timers to about one call per second, so the window has to be over 1s or the sequence develops audible holes; 1.5s leaves margin over the clamp. |
| START_LEAD | = 0.08 | Seconds before the first beat. Covers constructing the context, resuming it, and the first drain() — all of which happen inside the click handler. Scheduling the downbeat at exactly currentTime means its attack is already partly in the past. |
| SILENCE | = 0.001 | The ramp target, -60 dB. exponentialRampToValueAtTime is undefined at 0 and throws a RangeError; ramping to a literal 0 is the single most common Web Audio mistake. |
| TAIL | = 0.05 | Seconds between the end of the decay ramp and osc.stop(). Stopping exactly at the end of the ramp truncates the waveform mid-cycle, which is an audible click on a triangle or square wave. |
| gain default | = 0.14 | Peak per tone. Three simultaneous rows sum to 0.42, which leaves headroom for their overlapping tails; at 0.4 each the same chord clips the destination. |
Behaviour
What it does about motion sensitivity, teardown and being finished.
step still advances — a step index is information about the sound, not decoration — but phase is pinned to 0, so a playhead bound to it jumps from step to step in place of sliding continuously between them. The demo's playhead therefore renders as a static line on the current step.
The unmount cleanup clears the scheduling interval, empties the pending-beat queue, and calls ctx.close(), which cancels every note already committed to the audio thread. The visibilitychange listener is removed with its effect, and the rAF subscription is released by useRaf when running goes false. Each tone disconnects its own oscillator and gain node in onended.
Press Play: the highlight lands on a cell at the same instant you hear that cell, and stays in step after a minute of playing. Toggle a cell while it runs and the change is audible on the next pass. Switch to another tab for ten seconds and come back — the pattern is still on the grid with no gap or double-trigger. Press Stop and the step counter clears with no trailing note. Unmount while playing and nothing keeps sounding. With reduced motion on, the playhead line sits still on the current step instead of sweeping.
Pitfalls
- Driving the sequence with one
setTimeoutper note. Every note then inherits main-thread jitter, and a single 80 ms layout pass puts that note 80 ms off the beat — clearly audible at any tempo. - Advancing the beat time from
ctx.currentTimeinstead of from the previous beat time. The timer never fires exactly on schedule, so each re-base rounds the interval down and the tempo creeps upward; a sequence started against a metronome is noticeably ahead within a minute. - Using
ifinstead ofwhilein the drain. When the tab returns to the foreground, or the tempo is faster than the timer interval, one tick has several beats to queue — anifqueues one and silently drops the rest. - Setting the lookahead smaller than the worst-case timer gap. The window is the entire safety margin: at a 0.05s lookahead a single long frame means the beat is already in the past when the timer finally runs, and
osc.start(pastTime)plays immediately, so the note is late *and* off-grid. - Constructing the
AudioContextat mount. The autoplay policy hands back asuspendedcontext,osc.start(at)neither throws nor sounds, and the failure looks like a silent bug with a clean console. - Ramping the gain to 0.
exponentialRampToValueAtTime(0, t)throws — the target must be strictly positive. - Leaving each beat's oscillator and gain connected. At 120 bpm that is two dead nodes a beat, 7200 an hour, all still attached to the destination; CPU climbs and the output eventually distorts.
- Counting the visual step with its own rAF counter rather than reading
ctx.currentTime. The two clocks tick at different rates, so the highlight slides out of step with the sound over a few bars — the classic "my metronome light is not on the beat" bug. - Highlighting the step at the moment it was *queued*. That is
lookaheadseconds before it sounds, so the playhead runs a third of a second ahead of the audio and looks broken. - Publishing the beat phase as React state. Sixty re-renders a second of the whole sequencer to move one line; a ref plus a rAF write to a CSS custom property costs nothing.
- Never calling
ctx.close(). Notes already queued keep playing after the component unmounts, and Chrome throws once about six contexts exist in one document.
Notes
- A change to the pattern takes effect at most
lookaheadseconds later, because beats inside the window are already committed to the audio thread. That is inherent to lookahead scheduling, not a defect — shrink the window if you need a tighter response and accept the reduced timing margin. onBeatreceives the context so the caller schedules its own nodes; the hook deliberately does not own the sound. Sequencing and synthesis are separate concerns and pairing them makes both untestable.- For sub-millisecond scheduling with no main-thread involvement at all, the same shape moves into an
AudioWorkletthat posts beat messages. The lookahead pattern is identical; only the timer changes.