Skip to content
Kinetic
PrimitivesDOM3 files

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.

web-audioschedulingaudio-visualrafclock-sync

Live preview

The real component, running in this page.

02

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.json

No runtime dependencies.

03

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.

Claude CodeCursorCodexCopilotGemini CLI
.md
# Build: 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.

Build this as a React component in my project. Match my existing conventions —
file layout, styling system, and naming — rather than the ones implied below.
Everything after this line is specification, not code to copy verbatim.

## What it does

Fires an arpeggio and its visual flares off one schedule, so each flash lands on its own note.

- Category: primitive
- Runtime cost: dom
- Dependencies: none beyond React

## How it works

1. Notes are scheduled ahead of time on the audio clock: for step `i`, `at = ctx.currentTime + LEAD + step * i`, then `gain.setValueAtTime(peak, at)` and `gain.exponentialRampToValueAtTime(0.001, at + decay)`. Nothing is timed by `setTimeout`, so a busy main thread cannot skew a note.
2. `audibleAt(ctx, at)` converts that audio time into a `performance.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 is `ts.performanceTime + (at - ts.contextTime) * 1000`. Hardware output latency is already inside that pair; adding `outputLatency` on top double-counts it.
3. When `getOutputTimestamp` is unavailable, or has not rendered a frame yet and returns `contextTime: 0`, the fallback is `performance.now() + (at - ctx.currentTime) * 1000 + min(outputLatency, 0.05) * 1000`. `currentTime` is the write head, not the speaker, so the latency has to be added back in this branch only.
4. 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.
5. 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.
6. The `AudioContext` is constructed inside `fire()`, which the caller invokes from a click handler, and `resume()` is called if the browser still hands it back suspended. Constructing it at mount produces a suspended context whose scheduled notes are silently discarded.
7. `fire(steps)` caps at `chord.length` and 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.

## Constants — use these values

These are tuned. Each one has a reason; do not round them off or replace them
with values that "look cleaner".

- `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.

## API

```ts
function useBurstAudioSync(options?: { chord?: readonly number[]; step?: number; gain?: number; decay?: number; onStep?: (index: number, total: number) => void; muted?: boolean }): { fire: (steps?: number) => number; unlocked: boolean }
```

- `chord: readonly number[]` — default `[261.63, 329.63, 392, 523.25]`
  One frequency in Hz per step. Caps how many notes a single `fire` can schedule.
- `step: number` — default `0.46`
  Seconds between steps. Shared by the audio schedule and the visual queue.
- `gain: number` — default `0.15`
  Peak gain of one note. Divided by the note count when the stagger collapses.
- `decay: number` — default `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` — default `false`
  Skips audio entirely. Visual steps still fire on the same schedule, so the UI is unchanged.

## Reference implementation of the core

This is the part that is hard to get right. Adapt it; do not treat the rest of
the component as fixed.

```tsx
/** `exponentialRampToValueAtTime` is undefined at 0, so silence is -60 dB. */
const SILENCE = 0.001;
/** Seconds of dead air after the ramp before the oscillator is stopped. */
const TAIL = 0.1;
/** Seconds of lead before the first note. Absorbs one graph render quantum. */
const LEAD = 0.02;
/** Ceiling on latency compensation, in seconds. */
const MAX_LATENCY = 0.05;

/**
 * Wall-clock time at which a sample scheduled at `contextTime` is heard.
 *
 * The `getOutputTimestamp()` pair is measured *at the output device*, so the
 * hardware latency is already folded into it — adding `outputLatency` on top
 * would double-count it and push every flare late.
 */
function audibleAt(ctx: AudioContext, contextTime: number): number {
  if (typeof ctx.getOutputTimestamp === 'function') {
    const ts = ctx.getOutputTimestamp();
    // Before the graph has rendered anything both fields are 0. Trusting that
    // pair maps every future note to wall-clock zero, and the whole burst
    // flashes at once on the very next frame.
    if (
      ts.contextTime !== undefined &&
      ts.performanceTime !== undefined &&
      ts.contextTime > 0
    ) {
      return ts.performanceTime + (contextTime - ts.contextTime) * 1000;
    }
  }
  // Fallback. `currentTime` is the time of the sample being *written*, which
  // runs ahead of the speaker by roughly the output latency, so add it back.
  const reported = Number.isFinite(ctx.outputLatency)
    ? ctx.outputLatency
    : ctx.baseLatency;
  const latency = Number.isFinite(reported) ? Math.min(reported, MAX_LATENCY) : 0;
  return performance.now() + (contextTime - ctx.currentTime) * 1000 + latency * 1000;
}

export function useBurstAudioSync(options: UseBurstAudioSyncOptions = {}): BurstAudioSync {
  const { chord = C_MAJOR, step = 0.46, gain = 0.15, decay = 0.4 } = options;
  const reduced = useReducedMotion();

  const [unlocked, setUnlocked] = useState(false);
  const [running, setRunning] = useState(false);

  const ctxRef = useRef<AudioContext | null>(null);
  const queue = useRef<QueuedStep[]>([]);

  // Everything `fire` reads lives in one ref, so `fire` keeps a stable
  // identity and can be handed to a memoised child without re-binding it on
  // every render.
  const config = useRef({ chord, step, gain, decay, reduced, options });
  config.current = { chord, step, gain, decay, reduced, options };

  const fire = useCallback((steps?: number): number => {
    const cfg = config.current;
    const wanted = steps ?? cfg.chord.length;
    const total = Math.min(Math.max(Math.trunc(wanted), 0), cfg.chord.length);
    if (total === 0) return 0;

    // Reduced motion collapses the arpeggio into a single chord: the feedback
    // survives — it was asked for by a click, so it is not decoration — but
    // nothing travels across the screen.
    const stagger = cfg.reduced ? 0 : cfg.step;

    let ctx: AudioContext | null = null;
    if (!cfg.options.muted) {
      ctx = ctxRef.current;
      if (!ctx) {
        // Constructed here and only here: we are inside the gesture handler
        // that called `fire`, which is the one moment the autoplay policy
        // lets a context start.
        ctx = new AudioContext();
        ctxRef.current = ctx;
        setUnlocked(true);
      }
      if (ctx.state === 'suspended') void ctx.resume();
    }

    let baseMs = performance.now();

    if (ctx) {
      const start = ctx.currentTime + LEAD;
      // Four sines at full gain sum to 4x the peak and clip the destination.
      // They only ever overlap when the stagger is gone, so divide then.
      const peak = stagger === 0 ? cfg.gain / total : cfg.gain;

      for (let i = 0; i < total; i++) {
        const freq = cfg.chord[i];
        if (freq === undefined) continue;
        const at = start + stagger * i;

        const osc = ctx.createOscillator();
        const env = ctx.createGain();
        osc.type = 'sine';
        osc.frequency.value = freq;
        env.gain.setValueAtTime(peak, at);
        env.gain.exponentialRampToValueAtTime(SILENCE, at + decay);
        osc.connect(env);
        env.connect(ctx.destination);
        osc.start(at);
        osc.stop(at + decay + TAIL);
        // Without this every burst leaves two live nodes attached to the
        // destination. A few hundred bursts and the graph audibly degrades.
        osc.onended = () => {
          osc.disconnect();
          env.disconnect();
        };
      }

      baseMs = audibleAt(ctx, start);
    }

    const q = queue.current;
    for (let i = 0; i < total; i++) {
      q.push({ at: baseMs + stagger * i * 1000, index: i, total });
    }
    // Two bursts can overlap; the drain below assumes the head is the
    // earliest step, so keep the queue sorted rather than scanning it.
    q.sort((a, b) => a.at - b.at);
    setRunning(true);

    return total;
  }, []);

  // The loop only exists while something is queued. An always-on rAF for a
  // widget that fires twice a minute keeps the browser's animation frame
  // pipeline alive for nothing.
  useRaf(() => {
    const q = queue.current;
    const now = performance.now();
    while (q.length > 0) {
      const head = q[0];
      if (head === undefined || head.at > now) break;
      q.shift();
      config.current.options.onStep?.(head.index, head.total);
    }
    if (q.length === 0) setRunning(false);
  }, running);

  useEffect(
    () => () => {
      queue.current.length = 0;
      const ctx = ctxRef.current;
      ctxRef.current = null;
      // close() stops every scheduled note and releases the hardware. Chrome
      // caps a document at ~6 live contexts and throws on the seventh.
      if (ctx && ctx.state !== 'closed') void ctx.close();
    },
    [],
  );
```

## Requirements

These are not optional. A version of this component that skips any of them is
not finished.

**Cleanup.** 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`.

**Reduced motion.** 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.

**Frame-rate independence.** Any smoothing must be a function of delta time.
`x += (target - x) * 0.1` converges twice as fast at 120 Hz as at 60 Hz; use
`x = target + (x - target) * Math.pow(smoothing, dt)` instead, where
`smoothing` is the fraction of the gap remaining after one second.

**Strict mode.** Assume effects run twice in development. Setup must be
idempotent and teardown must fully undo it.

## Known pitfalls — avoid these specifically

- Constructing the `AudioContext` at mount. Outside a user gesture the browser hands back a `suspended` context; `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 a `RangeError` — the target must be strictly positive. Ramp to 0.001 instead.
- Using `setTimeout` for 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 `outputLatency` on top of a `getOutputTimestamp()` 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 live `AudioContext` objects 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. `fire` is 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.
- `chord` is 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.

## Definition of done

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.

When you are finished, tell me which of the requirements above you implemented
and where, so I can check them.
267 lines · 2581 words
04

Source

3 files, exactly as installed.

installs tocomponents/motion/burst-audio-sync.tsx
'use client';

import { useCallback, useEffect, useRef, useState } from 'react';
import { useRaf } from '@/hooks/use-raf';
import { useReducedMotion } from '@/hooks/use-reduced-motion';

/* ==========================================================================
   Chord-Synced Particle Burst
   --------------------------------------------------------------------------
   One schedule, two clocks. A burst is an arpeggio: N notes, `step` seconds
   apart, each with a visual flare that has to land on the same beat as its
   note. The naive version schedules the notes with `setTimeout` and the
   flares with the same timeouts, and the two drift apart within a second
   because `setTimeout` is a best-effort main-thread timer while audio is
   rendered by a separate, sample-accurate thread.

   The fix is to schedule the audio on `AudioContext.currentTime` — which is
   exact — and then *convert* each note's audio time into a wall-clock time
   the rAF loop can compare against. `getOutputTimestamp()` is the conversion:
   it hands back a matched pair of (sample being played, `performance.now()`
   when it reached the speaker), which is the only honest bridge between the
   two clocks. Everything visual is then driven off one rAF loop that pops a
   sorted queue, so a flare is at worst one frame away from its note.

   Nothing here runs until `fire()` is called. `fire()` must be called from
   inside a user gesture: constructing an `AudioContext` outside one leaves it
   `suspended`, and every note you then schedule is silently swallowed.
   ========================================================================== */

/** C major, ascending: C4 E4 G4 C5. */
const C_MAJOR: readonly number[] = [261.63, 329.63, 392, 523.25];

interface QueuedStep {
  /** `performance.now()` milliseconds at which the flare should appear. */
  at: number;
  index: number;
  total: number;
}

export interface UseBurstAudioSyncOptions {
  /** One frequency in Hz per step. Defaults to a C major arpeggio. */
  chord?: readonly number[];
  /** Seconds between steps. The visual and the note share this number. */
  step?: number;
  /** Peak gain of a single note, before any reduced-motion collapse. */
  gain?: number;
  /** Seconds the exponential decay takes to fall from `gain` to silence. */
  decay?: number;
  /** Fired once per step, on the frame nearest that step's note. */
  onStep?: (index: number, total: number) => void;
  /** Skips all audio. Visual steps still fire on the same schedule. */
  muted?: boolean;
}

export interface BurstAudioSync {
  /**
   * Schedules `steps` notes and `steps` visual callbacks. Returns how many
   * actually fired — capped at `chord.length`, so you never hear more notes
   * than you see flares. MUST be called from a user gesture handler.
   */
  fire: (steps?: number) => number;
  /** True once an AudioContext has been constructed and allowed to start. */
  unlocked: boolean;
}

// #region critical
/** `exponentialRampToValueAtTime` is undefined at 0, so silence is -60 dB. */
const SILENCE = 0.001;
/** Seconds of dead air after the ramp before the oscillator is stopped. */
const TAIL = 0.1;
/** Seconds of lead before the first note. Absorbs one graph render quantum. */
const LEAD = 0.02;
/** Ceiling on latency compensation, in seconds. */
const MAX_LATENCY = 0.05;

/**
 * Wall-clock time at which a sample scheduled at `contextTime` is heard.
 *
 * The `getOutputTimestamp()` pair is measured *at the output device*, so the
 * hardware latency is already folded into it — adding `outputLatency` on top
 * would double-count it and push every flare late.
 */
function audibleAt(ctx: AudioContext, contextTime: number): number {
  if (typeof ctx.getOutputTimestamp === 'function') {
    const ts = ctx.getOutputTimestamp();
    // Before the graph has rendered anything both fields are 0. Trusting that
    // pair maps every future note to wall-clock zero, and the whole burst
    // flashes at once on the very next frame.
    if (
      ts.contextTime !== undefined &&
      ts.performanceTime !== undefined &&
      ts.contextTime > 0
    ) {
      return ts.performanceTime + (contextTime - ts.contextTime) * 1000;
    }
  }
  // Fallback. `currentTime` is the time of the sample being *written*, which
  // runs ahead of the speaker by roughly the output latency, so add it back.
  const reported = Number.isFinite(ctx.outputLatency)
    ? ctx.outputLatency
    : ctx.baseLatency;
  const latency = Number.isFinite(reported) ? Math.min(reported, MAX_LATENCY) : 0;
  return performance.now() + (contextTime - ctx.currentTime) * 1000 + latency * 1000;
}

export function useBurstAudioSync(options: UseBurstAudioSyncOptions = {}): BurstAudioSync {
  const { chord = C_MAJOR, step = 0.46, gain = 0.15, decay = 0.4 } = options;
  const reduced = useReducedMotion();

  const [unlocked, setUnlocked] = useState(false);
  const [running, setRunning] = useState(false);

  const ctxRef = useRef<AudioContext | null>(null);
  const queue = useRef<QueuedStep[]>([]);

  // Everything `fire` reads lives in one ref, so `fire` keeps a stable
  // identity and can be handed to a memoised child without re-binding it on
  // every render.
  const config = useRef({ chord, step, gain, decay, reduced, options });
  config.current = { chord, step, gain, decay, reduced, options };

  const fire = useCallback((steps?: number): number => {
    const cfg = config.current;
    const wanted = steps ?? cfg.chord.length;
    const total = Math.min(Math.max(Math.trunc(wanted), 0), cfg.chord.length);
    if (total === 0) return 0;

    // Reduced motion collapses the arpeggio into a single chord: the feedback
    // survives — it was asked for by a click, so it is not decoration — but
    // nothing travels across the screen.
    const stagger = cfg.reduced ? 0 : cfg.step;

    let ctx: AudioContext | null = null;
    if (!cfg.options.muted) {
      ctx = ctxRef.current;
      if (!ctx) {
        // Constructed here and only here: we are inside the gesture handler
        // that called `fire`, which is the one moment the autoplay policy
        // lets a context start.
        ctx = new AudioContext();
        ctxRef.current = ctx;
        setUnlocked(true);
      }
      if (ctx.state === 'suspended') void ctx.resume();
    }

    let baseMs = performance.now();

    if (ctx) {
      const start = ctx.currentTime + LEAD;
      // Four sines at full gain sum to 4x the peak and clip the destination.
      // They only ever overlap when the stagger is gone, so divide then.
      const peak = stagger === 0 ? cfg.gain / total : cfg.gain;

      for (let i = 0; i < total; i++) {
        const freq = cfg.chord[i];
        if (freq === undefined) continue;
        const at = start + stagger * i;

        const osc = ctx.createOscillator();
        const env = ctx.createGain();
        osc.type = 'sine';
        osc.frequency.value = freq;
        env.gain.setValueAtTime(peak, at);
        env.gain.exponentialRampToValueAtTime(SILENCE, at + decay);
        osc.connect(env);
        env.connect(ctx.destination);
        osc.start(at);
        osc.stop(at + decay + TAIL);
        // Without this every burst leaves two live nodes attached to the
        // destination. A few hundred bursts and the graph audibly degrades.
        osc.onended = () => {
          osc.disconnect();
          env.disconnect();
        };
      }

      baseMs = audibleAt(ctx, start);
    }

    const q = queue.current;
    for (let i = 0; i < total; i++) {
      q.push({ at: baseMs + stagger * i * 1000, index: i, total });
    }
    // Two bursts can overlap; the drain below assumes the head is the
    // earliest step, so keep the queue sorted rather than scanning it.
    q.sort((a, b) => a.at - b.at);
    setRunning(true);

    return total;
  }, []);

  // The loop only exists while something is queued. An always-on rAF for a
  // widget that fires twice a minute keeps the browser's animation frame
  // pipeline alive for nothing.
  useRaf(() => {
    const q = queue.current;
    const now = performance.now();
    while (q.length > 0) {
      const head = q[0];
      if (head === undefined || head.at > now) break;
      q.shift();
      config.current.options.onStep?.(head.index, head.total);
    }
    if (q.length === 0) setRunning(false);
  }, running);

  useEffect(
    () => () => {
      queue.current.length = 0;
      const ctx = ctxRef.current;
      ctxRef.current = null;
      // close() stops every scheduled note and releases the hardware. Chrome
      // caps a document at ~6 live contexts and throws on the seventh.
      if (ctx && ctx.state !== 'closed') void ctx.close();
    },
    [],
  );
  // #endregion critical

  return { fire, unlocked };
}
05

API

Every parameter, its type and what happens when you change it.

function useBurstAudioSync(options?: { chord?: readonly number[]; step?: number; gain?: number; decay?: number; onStep?: (index: number, total: number) => void; muted?: boolean }): { fire: (steps?: number) => number; unlocked: boolean }
Parameters
chordreadonly number[] = [261.63, 329.63, 392, 523.25]One frequency in Hz per step. Caps how many notes a single fire can schedule.
stepnumber = 0.46Seconds between steps. Shared by the audio schedule and the visual queue.
gainnumber = 0.15Peak gain of one note. Divided by the note count when the stagger collapses.
decaynumber = 0.4Seconds for the exponential ramp from gain down to -60 dB.
onStep(index: number, total: number) => voidCalled on the frame nearest each note. index is the position in the chord.
mutedboolean = falseSkips audio entirely. Visual steps still fire on the same schedule, so the UI is unchanged.
06

How it works

The technique in order, not the code in order.

  1. 01

    Notes are scheduled ahead of time on the audio clock: for step i, at = ctx.currentTime + LEAD + step * i, then gain.setValueAtTime(peak, at) and gain.exponentialRampToValueAtTime(0.001, at + decay). Nothing is timed by setTimeout, so a busy main thread cannot skew a note.

  2. 02

    audibleAt(ctx, at) converts that audio time into a performance.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 is ts.performanceTime + (at - ts.contextTime) * 1000. Hardware output latency is already inside that pair; adding outputLatency on top double-counts it.

  3. 03

    When getOutputTimestamp is unavailable, or has not rendered a frame yet and returns contextTime: 0, the fallback is performance.now() + (at - ctx.currentTime) * 1000 + min(outputLatency, 0.05) * 1000. currentTime is the write head, not the speaker, so the latency has to be added back in this branch only.

  4. 04

    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.

  5. 05

    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.

  6. 06

    The AudioContext is constructed inside fire(), which the caller invokes from a click handler, and resume() is called if the browser still hands it back suspended. Constructing it at mount produces a suspended context whose scheduled notes are silently discarded.

  7. 07

    fire(steps) caps at chord.length and 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

Load-bearing constants
step = 0.46Seconds 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.15Peak 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.4Seconds 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.1Extra 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.02Seconds 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.05Ceiling 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.
07

Behaviour

What it does about motion sensitivity, teardown and being finished.

Reduced motion

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.

Cleanup

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.

Definition of done

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 AudioContext at mount. Outside a user gesture the browser hands back a suspended context; 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 a RangeError — the target must be strictly positive. Ramp to 0.001 instead.
  • Using setTimeout for 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 outputLatency on top of a getOutputTimestamp() 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 live AudioContext objects 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. fire is 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.
  • chord is 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.