Chord-Synced Particle Burst
A burst is N notes a fixed interval apart, each with a visual flare that has to appear on the same beat as its note. Audio is scheduled on `AudioContext.currentTime`, which is sample-accurate; the flares are driven by a rAF loop, which is not. This hook schedules both from one set of offsets and converts the audio times into wall-clock times via `getOutputTimestamp()`, so a flare is never more than one frame from its note however long the burst runs.
Chord-Synced Particle Burst
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/burst-audio-sync.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
3 files, exactly as installed.
API
Every parameter, its type and what happens when you change it.
| Parameter | Type | Default | Description |
|---|---|---|---|
| chord | readonly number[] | = [261.63, 329.63, 392, 523.25] | One frequency in Hz per step. Caps how many notes a single fire can schedule. |
| step | number | = 0.46 | Seconds between steps. Shared by the audio schedule and the visual queue. |
| gain | number | = 0.15 | Peak gain of one note. Divided by the note count when the stagger collapses. |
| decay | number | = 0.4 | Seconds for the exponential ramp from gain down to -60 dB. |
| onStep | (index: number, total: number) => void | — | Called on the frame nearest each note. index is the position in the chord. |
| muted | boolean | = false | Skips audio entirely. Visual steps still fire on the same schedule, so the UI is unchanged. |
How it works
The technique in order, not the code in order.
Notes are scheduled ahead of time on the audio clock: for step
i,at = ctx.currentTime + LEAD + step * i, thengain.setValueAtTime(peak, at)andgain.exponentialRampToValueAtTime(0.001, at + decay). Nothing is timed bysetTimeout, so a busy main thread cannot skew a note.audibleAt(ctx, at)converts that audio time into aperformance.now()millisecond value.ctx.getOutputTimestamp()returns a matched pair — the sample frame currently leaving the output device and the wall-clock instant it left — so the mapping ists.performanceTime + (at - ts.contextTime) * 1000. Hardware output latency is already inside that pair; addingoutputLatencyon top double-counts it.When
getOutputTimestampis unavailable, or has not rendered a frame yet and returnscontextTime: 0, the fallback isperformance.now() + (at - ctx.currentTime) * 1000 + min(outputLatency, 0.05) * 1000.currentTimeis the write head, not the speaker, so the latency has to be added back in this branch only.Each step is pushed onto a queue sorted by wall-clock time, and a rAF loop pops every entry whose time has passed and calls
onStep(index, total). Overlapping bursts merge into one sorted queue rather than one loop per burst.The loop is subscribed only while the queue is non-empty —
useRaf(tick, running)— and unsubscribes itself on the frame it drains the last step, so an idle instrument holds no animation frame.The
AudioContextis constructed insidefire(), which the caller invokes from a click handler, andresume()is called if the browser still hands it back suspended. Constructing it at mount produces a suspended context whose scheduled notes are silently discarded.fire(steps)caps atchord.lengthand returns the count actually scheduled, so a caller that has fewer flares available than the chord has notes hears exactly as many notes as it shows.
Load-bearing constants
| Constant | Value | Why this number |
|---|---|---|
| step | = 0.46 | Seconds between notes, and the same number the visuals use. Around 130 bpm in eighths — fast enough to read as one gesture rather than four separate events, slow enough that each flare is individually visible. Below ~0.2s the flares smear into one flash; above ~0.7s the burst stops sounding like a chord and starts sounding like four taps. |
| gain | = 0.15 | Peak amplitude of a single sine. Four notes overlapping at 1.0 would sum past full scale and hard-clip the destination; 0.15 leaves headroom for the tails of earlier notes still decaying under later ones. |
| SILENCE | = 0.001 | -60 dB, the ramp target. exponentialRampToValueAtTime is mathematically undefined at 0 and throws; ramping to a literal 0 is the single most common Web Audio error. -60 dB is inaudible. |
| decay | = 0.4 | Seconds from peak to silence. Slightly shorter than the 0.46s step so a note has essentially finished before the next arrives — overlap past about 0.6s turns the arpeggio into a chord and the individual attacks stop being audible. |
| TAIL | = 0.1 | Extra seconds before osc.stop(). Stopping an oscillator exactly at the end of its ramp truncates the last samples and can produce a faint click; 0.1s of -60 dB signal costs nothing and guarantees the stop is inaudible. |
| LEAD | = 0.02 | Seconds of lead before the first note. One render quantum at 44.1 kHz is 128 samples, about 2.9 ms; 20 ms covers several quanta plus the jitter in getting from the click to the scheduling call. Scheduling at exactly currentTime means the first note is already partly in the past and its attack is clipped. |
| MAX_LATENCY | = 0.05 | Ceiling on the fallback latency compensation, in seconds. Bluetooth output can report 150-300 ms; delaying the visual by that much to match makes the click itself feel broken. Capping trades exact sync on pathological hardware for a UI that still responds. |
Behaviour
What it does about motion sensitivity, teardown and being finished.
The stagger collapses to zero: all notes are scheduled at the same audio time and every onStep fires on one frame, so the burst is a chord rather than a sequence travelling across the screen. Audio is not muted — it is direct feedback for a click, not ambient motion — but the per-note gain is divided by the note count because the notes now overlap.
The unmount cleanup empties the pending-step queue and calls ctx.close(), which cancels every note still scheduled in the future and releases the hardware. The rAF subscription is owned by useRaf and released when running goes false or the component unmounts. Each oscillator disconnects itself and its gain node in onended.
Click a string: four flares appear 0.46s apart and each one coincides with a note — watch the fourth flare and hear the fourth note land together, not a beat apart. Click a string at either end and only one flare appears and only one note sounds. Nothing plays before the first click. Toggle sound off and the flares keep exactly the same timing. With reduced motion on, all four strings light in the same frame and you hear a chord. Unmount mid-burst and the remaining notes do not play.
Pitfalls
- Constructing the
AudioContextat mount. Outside a user gesture the browser hands back asuspendedcontext;osc.start(at)on it neither throws nor plays, so the bug presents as "audio just does not work" with a clean console. - Ramping the gain to 0.
exponentialRampToValueAtTime(0, t)throws aRangeError— the target must be strictly positive. Ramp to 0.001 instead. - Using
setTimeoutfor the notes so audio and visuals share a timer. Both then inherit the main thread's jitter, and on a frame that takes 80 ms the note lands 80 ms late while the sample-accurate alternative would not have moved. - Adding
outputLatencyon top of agetOutputTimestamp()mapping. That pair is measured at the output device and already contains the latency, so adding it again pushes every flare a full hardware buffer behind its note — the exact defect the conversion exists to prevent. - Trusting
getOutputTimestamp()immediately after construction. Before the graph has rendered a quantum both fields read 0, which maps every future note to wall-clock zero and dumps the entire burst onto the next frame. - Never disconnecting the oscillator and gain. Both stay attached to the destination after the note ends; a few hundred bursts in one session and the graph is carrying hundreds of dead nodes, which shows up as rising CPU and eventually distortion.
- Keeping a rAF loop running so the flare queue can be polled. The queue is empty almost all the time — subscribe on
fire, unsubscribe on the frame the last step drains. - Letting the notes overlap at full gain when reduced motion collapses the stagger. Four sines at 0.15 sum to 0.6 and the summed peak clips; the per-note gain has to be divided by the note count in the collapsed case.
- Forgetting
ctx.close()on unmount. Chrome allows roughly six liveAudioContextobjects per document and throws on the next one, so a page that mounts this in a list silently stops producing sound after the sixth item.
Notes
- The hook deliberately exposes no
play()that can be called from an effect.fireis the only entry point and its contract is "call me from a gesture handler" — encoding the autoplay policy in the API rather than in a comment. - Two clocks, two policies. Reduced motion collapses the stagger but does not mute anything: the sound is feedback for a click the user made, not ambient decoration, and silencing it removes information.
chordis any frequency list. A minor triad is[261.63, 311.13, 392]; a single frequency makes it a one-shot tick with the same scheduling guarantees.