vibes.diy/docs Home Discord
creator docs · reference · generated

Web Audio API: Fundamentals, Echo with FX-in-Feedback, Mic Monitoring + Metronome, Timing Architecture, and a Complete Drum Machine

This is the same document the Vibes code generator reads when it builds apps — rendered from prompts/pkg/llms/web-audio.md on every deploy, so it can't drift from what the platform actually does. Read it from your terminal any time with npx vibes-diy skills.

Authoritative source: Issue #228 research threads — comments 3192681700, 3192696052, 3192806626.

Web Audio is a browser built-in. Use window.AudioContext (with the window.webkitAudioContext fallback) directly, gated on a user gesture as shown below.

iOS Safari: unlock audio synchronously inside the gesture. WebKit requires playback to directly result from a trusted handler (pointerdown, touchend, click, keydown). Inside that handler you must, synchronously and before any async work: create-or-resume the AudioContext and start at least one real sound (a ~10ms blip at gain ≈ 0.0001 is enough). A resume/start that runs from a downstream setTimeout, Promise.then, await, requestAnimationFrame, a React state-update→effect, a worker/scheduler task, or a media canplay/canplaythrough callback does not count and will stay silent on iOS. Once the context is running, all of those are fine for scheduling. iOS also suspends audio after backgrounding/lock, so re-check audioCtx.state on every gesture and resume again if needed. See the unlockAudio() gate in §1.

A drum machine, beat maker or step sequencer starts from §9 — a complete, runnable one with a synthesized kit, sixteen steps, tempo, swing, saturation and volume.

1) Fundamentals and Core Nodes

  • AudioContext — master interface and clock (audioCtx.currentTime). Resume on a user gesture.
  • OscillatorNode — synthesis; set type and frequency.
  • AudioBufferSourceNode — decoded-file playback; schedule with .start(when, offset?, duration?).
  • GainNode — volume control and envelopes.
  • BiquadFilterNode — EQ/tonal shaping (type, frequency, Q, etc.).
  • AnalyserNode — FFT/time-domain visualization.

Examples

js// 1) Context + iOS-safe unlock gate.
let audioCtx;
function unlockAudio() {
  audioCtx ||= new (window.AudioContext || window.webkitAudioContext)();
  // Re-check every gesture — iOS suspends audio after backgrounding/lock.
  if (audioCtx.state !== "running") {
    audioCtx.resume();
    // Start one real (silent) sound synchronously, inside the gesture, so WebKit
    // actually unlocks output. This is the part a resume()-alone often misses.
    const osc = audioCtx.createOscillator();
    const gain = audioCtx.createGain();
    gain.gain.value = 0.0001;
    osc.connect(gain).connect(audioCtx.destination);
    osc.start();
    osc.stop(audioCtx.currentTime + 0.01);
  }
  return audioCtx;
}

// Call unlockAudio() FIRST, synchronously, in a trusted gesture — before any
// app/audio logic. After it returns, the context is running and timers,
// requestAnimationFrame, sequencers, and workers are all safe to schedule with.
document.querySelector("#start-audio")?.addEventListener("pointerdown", () => {
  unlockAudio();
  // now safe to create/start nodes, kick off the transport, etc.
});

// ✗ What breaks on iOS — the unlock does NOT count if audio first starts from a
// downstream callback, because it no longer "directly results" from the gesture:
//   el.addEventListener("click", () => requestAnimationFrame(() => audioCtx.resume()));  // risky
//   setTimeout(...), Promise.then(...), await fetch(...), await import(...),
//   a React state update then effect, a scheduler/task-queue callback, or a
//   media canplay/canplaythrough callback — all too late. Unlock in the handler.

// 2) Simple tone
const osc = audioCtx.createOscillator();
osc.type = "sine";
osc.frequency.value = 440;
osc.connect(audioCtx.destination);
osc.start();
osc.stop(audioCtx.currentTime + 1);

// 3) Load/decode and play a file
const buf = await fetch("/path/audio.mp3")
  .then((r) => r.arrayBuffer())
  .then((b) => audioCtx.decodeAudioData(b));
const src = audioCtx.createBufferSource();
src.buffer = buf;
src.connect(audioCtx.destination);
src.start();

// 4) Gain and Filter in series
const gain = audioCtx.createGain();
gain.gain.value = 0.5;
const filter = audioCtx.createBiquadFilter();
filter.type = "lowpass";
filter.frequency.value = 1000;
osc.disconnect();
osc.connect(filter).connect(gain).connect(audioCtx.destination);

Practical: clean up disconnected nodes; check browser support; use headphones to avoid feedback when monitoring.

2) Echo/Delay with Effects Inside the Feedback Loop

Graph (node names are exact):

  • Dry: source → dryGain:GainNode → destination
  • Wet: source → delay:DelayNode → wetGain:GainNode → destination
  • Feedback loop with FX: delay → filter:BiquadFilterNode → distortion:WaveShaperNode → reverb:ConvolverNode → feedbackGain:GainNode → delay

Parameters to expose

  • delay.delayTime (s), feedbackGain.gain (0–1, keep < 1.0)
  • filter.type, filter.frequency
  • distortion.curve (Float32Array)
  • convolver.buffer (IR AudioBuffer)
  • wetGain.gain, dryGain.gain

Notes: Prevent runaway by capping feedback below 1.0; ConvolverNode requires a loaded impulse response; zero-delay cycles are disallowed.

jsconst delay = audioCtx.createDelay(5.0);
const feedbackGain = audioCtx.createGain();
const filter = audioCtx.createBiquadFilter();
const distortion = audioCtx.createWaveShaper();
const reverb = audioCtx.createConvolver();
const wetGain = audioCtx.createGain();
const dryGain = audioCtx.createGain();

delay.delayTime.value = 0.35;
feedbackGain.gain.value = 0.5; // < 1.0
filter.type = "lowpass";
filter.frequency.value = 8000;
// distortion.curve = yourFloat32Curve;
// reverb.buffer = yourImpulseResponseAudioBuffer;
wetGain.gain.value = 0.4;
dryGain.gain.value = 1.0;

// Dry and wet
source.connect(dryGain).connect(audioCtx.destination);
source.connect(delay);
delay.connect(wetGain).connect(audioCtx.destination);

// Feedback with FX
delay.connect(filter);
filter.connect(distortion);
distortion.connect(reverb);
reverb.connect(feedbackGain);
feedbackGain.connect(delay);

Helper (load IR):

jsasync function loadImpulseResponse(url) {
  const res = await fetch(url, { mode: "cors" });
  if (!res.ok) throw new Error(`Failed to fetch IR ${url}: ${res.status} ${res.statusText}`);
  const ab = await res.arrayBuffer();
  try {
    return await audioCtx.decodeAudioData(ab);
  } catch (err) {
    console.error("decodeAudioData failed for IR", url, err);
    throw err; // Surface decoding/CORS-related failures clearly
  }
}

3) Microphone Monitoring + Metronome Overlay

Mic capture: request permission with navigator.mediaDevices.getUserMedia({ audio: { echoCancellation, noiseSuppression, autoGainControl } }). Create MediaStreamAudioSourceNode and route to a GainNode → destination.

Metronome: synthesize a short click (e.g., square/sine burst through a gain envelope). Schedule by audio clock at AudioContext.currentTime with lookahead.

Mix graph: micGain + metronomeGain → master → destination.

jsconst master = audioCtx.createGain();
master.connect(audioCtx.destination);
const micGain = audioCtx.createGain();
const metronomeGain = audioCtx.createGain();
micGain.connect(master);
metronomeGain.connect(master);

async function initMic() {
  const stream = await navigator.mediaDevices.getUserMedia({
    audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: false },
  });
  const micSrc = audioCtx.createMediaStreamSource(stream);
  micSrc.connect(micGain);
}

function scheduleClick(atTime, downbeat = false) {
  const osc = audioCtx.createOscillator();
  const env = audioCtx.createGain();
  osc.type = "square";
  osc.frequency.setValueAtTime(downbeat ? 2000 : 1600, atTime);
  env.gain.setValueAtTime(0.0001, atTime);
  env.gain.exponentialRampToValueAtTime(1.0, atTime + 0.001);
  env.gain.exponentialRampToValueAtTime(0.0001, atTime + 0.03);
  osc.connect(env).connect(metronomeGain);
  osc.start(atTime);
  osc.stop(atTime + 0.05);
  // Cleanup to avoid accumulating nodes during long sessions
  osc.onended = () => {
    try {
      osc.disconnect();
    } catch {}
    try {
      env.disconnect();
    } catch {}
  };
}

function startMetronome({ bpm = 120, beatsPerBar = 4 } = {}) {
  const spb = 60 / bpm; // seconds per beat
  let next = audioCtx.currentTime + 0.1;
  let beat = 0;
  const lookaheadMs = 25,
    ahead = 0.2;
  const id = setInterval(() => {
    while (next < audioCtx.currentTime + ahead) {
      scheduleClick(next, beat % beatsPerBar === 0);
      next += spb;
      beat = (beat + 1) % beatsPerBar;
    }
  }, lookaheadMs);
  return () => clearInterval(id);
}

Latency and safety: start/resume on user gesture; clean up per-tick nodes after ended to prevent buildup in long-running metronomes; use headphones while monitoring; mobile devices have higher base latency.

4) Time Synchronization and Scheduling Model

Clocks/time domains

  • Master: AudioContext.currentTime — sample-accurate; schedule everything on this timeline.
  • UI/high-res: performance.now() — for UI timers and Web MIDI timestamps.
  • Mapping: capture (tPerf0 = performance.now(), tAudio0 = audioCtx.currentTime), convert MIDI/perf timestamps with tAudio = tAudio0 + (timeStamp - tPerf0)/1000.
  • Hints: audioCtx.baseLatency, audioCtx.getOutputTimestamp?.() — estimate DAC/output delay if aligning to “heard” time.

Scheduling primitives

  • AudioBufferSourceNode.start(when, offset?, duration?) for one-shots/loops.
  • AudioParam automation (setValueAtTime, linearRampToValueAtTime, setTargetAtTime, setValueCurveAtTime).
  • Avoid requestAnimationFrame/setTimeout for timing; use an AudioWorklet for custom DSP/tight jitter when needed.

Tempo transport and lookahead

  • Tempo mapping: secondsPerBeat = 60 / bpm; compute bars:beats:ticks → seconds on the audio clock (choose PPQ, e.g., 480/960).
  • Lookahead window: maintain ~50–200 ms rolling schedule; enqueue with absolute when times in audio seconds.

Multi‑channel sample playback

  • Pre‑decode all samples; never decode on hit.
  • Per hit: create a fresh AudioBufferSourceNode and call .start(when).
  • For phase‑aligned layers (kick+clap, etc.), schedule all sources with the same when to guarantee sample‑accurate overlap.
  • Routing: per‑track GainNode/optional FX → master bus; allow overlapping retriggers; compute flams as small when offsets.
  • Pattern changes: compute the next bar boundary on the audio clock and enqueue new pattern hits relative to that time.

MIDI synth playback

  • Live input: map MIDIMessageEvent.timeStamp (perf.now domain) → audio clock as above; buffer a short lookahead (5–20 ms) to reduce jitter.
  • SMF playback: convert PPQ ticks using the tempo map; schedule noteOn/noteOff separately; sustain (CC64) defers noteOff until pedal release.
  • Voice management: one voice per active note; allow overlapping envelopes; define voice‑steal policy if a polyphony cap is hit.

External sync and drift

  • For MIDI Clock/MTC, derive BPM/phase from incoming ticks, convert to audio time, and drive the transport. Correct small phase error between beats with bounded micro‑nudges—avoid discontinuities.

5) Practical Notes

  • User gesture required to start/resume AudioContext and to access the mic. On iOS Safari the unlock must be synchronous inside the gesture and start one real sound (the unlockAudio() gate in §1); a resume from a downstream timer/promise/await/rAF/effect/media callback is too late. Re-check audioCtx.state on every gesture, since iOS suspends audio after backgrounding/lock.
  • Convolver IRs: host with CORS if cross‑origin; decode before use.
  • Latency budget: device baseLatency + your lookahead + any Worklet buffering.
  • Headphones recommended for monitoring to avoid acoustic feedback.

6) File layout — audio apps split, proactively

Audio apps run long: an engine (context/graph/scheduler), a playable surface (keyboard, pads, step grid), transport/parameter controls, and a preset list each carry real code. A single-file synth blows past the ~500-line App.jsx split threshold almost immediately — so split from the start rather than after the fact. The sandbox serves raw ES modules: relative imports work, local .css imports do not (style stays in App.jsx).

The shape that works (filenames illustrative — split by responsibility, matched to the app's actual surfaces, not by count):

  • lib/audio-engine.js — everything that touches AudioContext: the unlockAudio() gate, node graph construction, voice management, the lookahead scheduler. Plain module, no React; export functions/objects the UI calls. Keeping the engine React-free is what makes gesture-synchronous unlock (§1) and clean scheduling (§4) easy — no re-render can rebuild the graph.
  • components/Keyboard.jsx (or StepGrid.jsx, Pads.jsx) — the playable surface; calls engine functions from pointerdown-family handlers.
  • components/TransportControls.jsx, components/PresetList.jsx — parameter knobs/sliders and preset browsing; presets live in the database, the engine consumes plain parameter objects.
  • App.jsx — layout, styling, database wiring; imports the above (import { createEngine } from "./lib/audio-engine.js").

7) Restraint — sound belongs on the core actions

  • No sound on every keystroke, hover, or list render — that reads as noise, not life.
  • Never autoplay audio on load; playback must follow a user gesture (the unlockAudio() gate in §1).
  • Wire brief feedback to the app's one or two satisfying core actions — the "it worked" beats (a point scored, a post landing, a match made) — and route it through the resume-on-user-gesture unlock so it sounds on mobile. Quiet/utility apps skip audio entirely.

8) Heavyweight audio engines arrive by runtime import

Music written as text patterns — Strudel, Tidal-style mini-notation, a groovebox whose pads write a pattern — has its own skill, strudel, built on the same runtime-import shape; this section is the general rule for any engine.

When the app needs a serious sound engine — sampled drums, a full synth framework — rather than hand-built oscillators, import the engine with its full pinned esm.sh URL in a memoized module-level loader, and call that loader only from a user gesture. Laziness is doing two jobs here: a body-position await import("engine-name") with a bare specifier ships unrewritten — the import rewrite covers only the top-of-file import region — and dies at runtime with Failed to resolve module specifier, so the specifier is the complete versioned URL; and an engine plus its sample bank stays off first paint entirely.

The gesture handler unlocks synchronously FIRST, before any await: call unlockAudio() (§1) at the top of the handler and keep its return — the context the gesture unlocked. Then await the loader, and bind the engine to that SAME context before creating any voice: Tone.setContext(ctx) drives the engine on the already-running context, and Tone.getContext().rawContext === ctx verifies the identity. Without the bind, Tone.start() resumes a context the engine created lazily after the await — a different context from the one the gesture unlocked. Verified on desktop Chromium; on mobile the engine's first sound still follows the loader's await, so claim only what desktop evidence shows (iOS counts a synchronous start inside the gesture, and a lazily imported engine starts after an await).

A stop control silences everything — pending playback, retained voices, the transport and its future callbacks. Plays and stops share one generation counter: Stop increments it, and a playback still awaiting the loader checks it after every await and stays silent when it moved — otherwise Play → Stop → load-resolves creates and plays a voice after Stop cleared the set. Then release the retained voices and stop/cancel the transport: stopping the transport alone leaves sounding voices droning on.

Check the engine's license first: reach for a permissively licensed engine (the worked sample below is Tone.js 15.1.22, MIT), credit it visibly in the UI, and load it at runtime from the CDN — its code lives at its URL, not in the app.

js// Memoized module-level loader — one network fetch no matter how many pads play it.
let tonePromise;
function loadTone() {
  tonePromise ??= import("https://esm.sh/[email protected]");
  return tonePromise;
}

// Every voice the app created, so Stop can silence retained voices too.
const liveVoices = new Set();
// Plays AND stops count here: a playback still awaiting the loader belongs to
// an older generation once Stop runs, and must never sound.
let playEpoch = 0;
jsasync function handlePlay() {
  const ctx = unlockAudio(); // §1 gate — synchronous, in the gesture
  const myTurn = playEpoch;
  const Tone = await loadTone();
  if (myTurn !== playEpoch) return; // Stop ran while loading — stay silent
  // The engine drives the context the gesture unlocked: bind once per context,
  // then verify the identity before creating any voice.
  if (Tone.getContext().rawContext !== ctx) Tone.setContext(ctx);
  const synth = new Tone.MembraneSynth().toDestination();
  if (myTurn !== playEpoch) {
    synth.dispose?.(); // Stop landed between the bind and here — no sound
    return;
  }
  liveVoices.add(synth);
  synth.triggerAttackRelease("C2", "8n");
}

function handleStop() {
  playEpoch++; // cancel any playback still waiting on the loader
  // Silence retained voices first, then the transport and its future callbacks.
  for (const voice of liveVoices) {
    try {
      voice.triggerRelease?.();
      voice.dispose?.();
    } catch {
      /* a voice that already ended needs nothing */
    }
  }
  liveVoices.clear();
  loadTone().then((Tone) => {
    Tone.getTransport().stop();
    Tone.getTransport().cancel();
  });
}

The Play button names what it plays ("Play the drums"), the Stop button is beside it from the first sound, and a small line credits the engine ("Sounds by Tone.js (MIT)") — the audience hears drums, not infrastructure.

9) A complete drum machine — the shell every beat vibe starts from

This is a full, runnable two-file app: a synthesized four-voice kit (kick, snare, closed and open hat), a sixteen-step sequencer, tempo and swing, and global saturation and volume on the master bus. No samples, no downloads — every sound is built from oscillators and noise, so it plays the moment the person taps. Start here for any drum machine, beat maker or groovebox and grow it: more voices, more steps, accents, pattern banks.

Why it sounds good, in four moves:

  • Kick — a sine that sweeps 150 Hz → 55 Hz in 50 ms, then settles to 45 Hz: the sweep is the punch, the tail is the boom. A few milliseconds of band-passed noise on top is the beater, so the kick still reads on phone speakers that cannot reproduce 45 Hz.
  • Snare — two tuned triangle "shell" tones under high-passed noise with a presence lift at 5 kHz for the wires.
  • Hats — the 808 recipe: six square waves at inharmonic ratios of 40 Hz, band-passed at 10 kHz and high-passed at 7 kHz. A closed hat chokes a ringing open hat through the open hat's own gate gain, like the real machine.
  • Master bus — voices → bus (headroom) → drive gain → tanh WaveShaper (4× oversampled, so the new harmonics do not alias into fizz) → makeup gain → volume → a fast DynamicsCompressor as a limiter. The makeup gain falls as the drive rises, so Saturation changes the colour, not the loudness; Volume is squared so the slider feels even.

The engine is a plain module (§6): the gate from §1, the lookahead scheduler from §4, and a playhead() the interface polls each animation frame, so the lit step follows what is reaching the speakers rather than the timer. The interface calls getDrums() synchronously at the top of every tap handler — Play, and each step — which is what unlocks sound on iOS.

lib/drum-engine.js

js// Everything that touches AudioContext lives here — no React, so a re-render never rebuilds the graph.
export const STEPS = 16;
export const TRACKS = [
  { id: "kick", label: "Kick" },
  { id: "snare", label: "Snare" },
  { id: "closed", label: "Closed hat" },
  { id: "open", label: "Open hat" },
];

let audioCtx;
let drums;

// §1's gate: synchronous, inside the tap, with one real (silent) sound.
function unlockAudio() {
  audioCtx ||= new (window.AudioContext || window.webkitAudioContext)();
  if (audioCtx.state !== "running") {
    audioCtx.resume();
    const osc = audioCtx.createOscillator();
    const gain = audioCtx.createGain();
    gain.gain.value = 0.0001;
    osc.connect(gain).connect(audioCtx.destination);
    osc.start();
    osc.stop(audioCtx.currentTime + 0.01);
  }
  return audioCtx;
}

// Call from a tap handler, synchronously: unlocks audio and builds the one engine on that context.
export function getDrums() {
  const ctx = unlockAudio();
  drums ||= createDrums(ctx);
  return drums;
}

function createDrums(ctx) {
  const settings = { pattern: {}, bpm: 112, swing: 0, drive: 0.3, volume: 0.8 };

  // Master chain: voices → bus → drive → tanh shaper → makeup → volume → limiter → speakers.
  const bus = ctx.createGain();
  const drive = ctx.createGain();
  const shaper = ctx.createWaveShaper();
  const makeup = ctx.createGain();
  const volume = ctx.createGain();
  const limiter = ctx.createDynamicsCompressor();
  bus.gain.value = 0.7; // headroom: a kick and a hat on the same step stay under full scale
  shaper.curve = tanhCurve(MAX_DRIVE);
  shaper.oversample = "4x"; // keeps the new harmonics from folding back down as fizz
  limiter.threshold.value = -3;
  limiter.knee.value = 0;
  limiter.ratio.value = 20;
  limiter.attack.value = 0.002;
  limiter.release.value = 0.1;
  bus.connect(drive).connect(shaper).connect(makeup).connect(volume).connect(limiter).connect(ctx.destination);

  const noise = noiseBuffer(ctx);
  const live = new Set(); // every scheduled source and its start time, so Stop can cancel the future
  let openChoke = null; // the ringing open hat's gate — a closed hat shuts it, like the real machine

  function chokeOpen(at) {
    openChoke?.gain.setTargetAtTime(0, at, 0.006);
    openChoke = null;
  }

  function play(source, at, end, nodes = []) {
    const entry = { source, at };
    live.add(entry);
    source.onended = () => {
      live.delete(entry);
      source.disconnect();
      for (const node of nodes) node.disconnect();
    };
    source.start(at);
    source.stop(end);
  }

  // A percussive envelope: 2 ms attack, exponential decay to silence.
  function envelope(at, peak, decay, out = bus) {
    const amp = ctx.createGain();
    amp.gain.setValueAtTime(0.0001, at);
    amp.gain.exponentialRampToValueAtTime(peak, at + 0.002);
    amp.gain.exponentialRampToValueAtTime(0.0001, at + decay);
    amp.connect(out);
    return amp;
  }

  function filter(type, frequency, q = 0.7) {
    const node = ctx.createBiquadFilter();
    node.type = type;
    node.frequency.value = frequency;
    node.Q.value = q;
    return node;
  }

  // 808-style metal: six square waves at inharmonic ratios, band-passed high.
  function hat(at, decay, peak, out) {
    const band = filter("bandpass", 10000);
    const high = filter("highpass", 7000);
    const amp = envelope(at, peak, decay, out);
    band.connect(high).connect(amp);
    [2, 3, 4.16, 5.43, 6.79, 8.21].forEach((ratio, i) => {
      const osc = ctx.createOscillator();
      osc.type = "square";
      osc.frequency.value = 40 * ratio;
      osc.connect(band);
      play(osc, at, at + decay + 0.02, i === 0 ? [band, high, amp] : []);
    });
  }

  const voices = {
    kick(at) {
      // A sine that drops from 150 Hz to 45 Hz: the pitch sweep is the punch, the tail is the boom.
      const body = ctx.createOscillator();
      body.frequency.setValueAtTime(150, at);
      body.frequency.exponentialRampToValueAtTime(55, at + 0.05);
      body.frequency.exponentialRampToValueAtTime(45, at + 0.4);
      const amp = envelope(at, 1, 0.5);
      body.connect(amp);
      play(body, at, at + 0.55, [amp]);
      // The beater: a few ms of filtered noise, so the kick still reads on phone speakers.
      const click = ctx.createBufferSource();
      click.buffer = noise;
      const tone = filter("bandpass", 3500, 0.8);
      const clickAmp = envelope(at, 0.3, 0.012);
      click.connect(tone).connect(clickAmp);
      play(click, at, at + 0.03, [tone, clickAmp]);
    },
    snare(at) {
      // Two tuned shell tones under a bright burst of noise for the wires.
      for (const [freq, level] of [[185, 0.45], [330, 0.25]]) {
        const shell = ctx.createOscillator();
        shell.type = "triangle";
        shell.frequency.setValueAtTime(freq, at);
        shell.frequency.exponentialRampToValueAtTime(freq * 0.85, at + 0.1);
        const amp = envelope(at, level, 0.14);
        shell.connect(amp);
        play(shell, at, at + 0.16, [amp]);
      }
      const wires = ctx.createBufferSource();
      wires.buffer = noise;
      const high = filter("highpass", 1500);
      const air = filter("peaking", 5000);
      air.gain.value = 6;
      const amp = envelope(at, 0.6, 0.22);
      wires.connect(high).connect(air).connect(amp);
      play(wires, at, at + 0.25, [high, air, amp]);
    },
    closed(at) {
      chokeOpen(at);
      hat(at, 0.06, 0.32, bus);
    },
    open(at) {
      chokeOpen(at); // one open hat at a time, so the next closed hat always silences it
      openChoke = ctx.createGain();
      openChoke.connect(bus);
      hat(at, 0.45, 0.24, openChoke);
    },
  };

  // The scheduler: a 25 ms timer tops up 120 ms of sound ahead of the audio clock (§4).
  const LOOKAHEAD = 0.12;
  let timer = 0;
  let step = 0;
  let gridTime = 0;
  let lit = -1;
  const lights = []; // { step, at }: the playhead follows the sound, not the timer

  function tick() {
    const sixteenth = 60 / settings.bpm / 4;
    // A throttled background tab can fall far behind the audio clock: skip the missed steps
    // (keeping the beat's phase) instead of scheduling a burst of hits in the past.
    if (gridTime < ctx.currentTime) {
      const missed = Math.ceil((ctx.currentTime - gridTime) / sixteenth);
      gridTime += missed * sixteenth;
      step = (step + missed) % STEPS;
    }
    while (gridTime < ctx.currentTime + LOOKAHEAD) {
      // Swing pushes every off-beat sixteenth late; 1 is a full triplet shuffle.
      const at = gridTime + (step % 2 ? (settings.swing * sixteenth) / 3 : 0);
      for (const { id } of TRACKS) if (settings.pattern[id]?.[step]) voices[id](at);
      lights.push({ step, at });
      gridTime += sixteenth;
      step = (step + 1) % STEPS;
    }
  }

  function update(next) {
    Object.assign(settings, next);
    const now = ctx.currentTime;
    // Drive 0 is warm, 1 is crunchy. Makeup gain keeps loudness level as the drive rises,
    // so the slider changes the colour, not the volume.
    const gain = 1 + settings.drive * (MAX_DRIVE - 1);
    drive.gain.setTargetAtTime(gain / MAX_DRIVE, now, 0.02);
    makeup.gain.setTargetAtTime(1.3 / Math.sqrt(gain), now, 0.02);
    volume.gain.setTargetAtTime(settings.volume ** 2, now, 0.02); // squared, so the slider feels even
  }
  update({});

  return {
    update,
    start() {
      if (timer) return;
      step = 0;
      gridTime = ctx.currentTime + 0.05;
      tick();
      timer = setInterval(tick, 25);
    },
    stop() {
      clearInterval(timer);
      timer = 0;
      lights.length = 0;
      lit = -1;
      // Cancel every hit that is scheduled but not yet sounding; ringing tails finish on their own.
      for (const entry of live) if (entry.at > ctx.currentTime) entry.source.stop();
    },
    // Audition one sound now — a tap on a step while stopped.
    hit(id) {
      voices[id](ctx.currentTime + 0.005);
    },
    // The newest step whose sound has reached the speakers; poll it from requestAnimationFrame.
    playhead() {
      // currentTime is what the engine has rendered; subtract the output latency for what is heard
      // (it matters on Bluetooth headphones).
      const heard = ctx.currentTime - (ctx.outputLatency || ctx.baseLatency || 0);
      while (lights.length && lights[0].at <= heard) lit = lights.shift().step;
      return lit;
    },
  };
}

const MAX_DRIVE = 10;

// tanh(MAX_DRIVE·x) across the shaper's −1…1 input; the drive gain picks how far up the curve we go.
function tanhCurve(k) {
  const curve = new Float32Array(4096);
  for (let i = 0; i < curve.length; i++) curve[i] = Math.tanh(k * ((i / (curve.length - 1)) * 2 - 1));
  return curve;
}

function noiseBuffer(ctx) {
  const buffer = ctx.createBuffer(1, ctx.sampleRate, ctx.sampleRate);
  const data = buffer.getChannelData(0);
  for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1;
  return buffer;
}

A tap on a step toggles it, and while stopped it plays that sound so the person hears what they placed. Every slider writes through to the engine, and the scheduler reads the settings on its next step, so edits land in time without a restart. Grooves save to the database as one document each; the save button waits on can.create, the same verdict the server enforces.

App.jsx

jsximport React, { useEffect, useRef, useState } from "react";
import { useFireproof } from "use-fireproof";
import { useVibe, useViewer } from "use-vibes";
import { Badge, Button, Card, Field, Heading, Icon, IconButton, Input, Row, Skeleton, Spacer, Stack, Text } from "@vibes.diy/pouf";
import { Slider } from "@vibes.diy/pouf/interactive";
import { STEPS, TRACKS, getDrums } from "./lib/drum-engine.js";

const START = {
  kick: [1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0],
  snare: [0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1],
  closed: [1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 0, 0],
  open: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0],
};

function Knob({ label, value, min, max, step = 1, shown, onChange }) {
  return (
    <Stack gap={1}>
      <Row gap={2} align="center">
        <Text size="sm">{label}</Text>
        <Spacer />
        <Text size="sm" muted num>
          {shown}
        </Text>
      </Row>
      <Slider value={[value]} onChange={([v]) => onChange(v)} min={min} max={max} step={step} label={label} />
    </Stack>
  );
}

export default function App() {
  const { database, useLiveQuery } = useFireproof("drumMachine");
  const { me, can, ready } = useVibe("drumMachine");
  const { requestLogin, ViewerTag } = useViewer();
  const { docs: grooves } = useLiveQuery("type", { key: "groove" });

  const [grid, setGrid] = useState(START);
  const [bpm, setBpm] = useState(112);
  const [swing, setSwing] = useState(0);
  const [drive, setDrive] = useState(30);
  const [volume, setVolume] = useState(80);
  const [playing, setPlaying] = useState(false);
  const [step, setStep] = useState(-1);
  const [name, setName] = useState("");
  const [saving, setSaving] = useState(false);
  const drumsRef = useRef(null);

  const settings = { pattern: grid, bpm, swing: swing / 100, drive: drive / 100, volume: volume / 100 };

  // Every change reaches the engine; the scheduler reads it on its next step, so edits land in time.
  useEffect(() => {
    drumsRef.current?.update(settings);
  }, [grid, bpm, swing, drive, volume]);

  // Silence on leaving the page.
  useEffect(() => () => drumsRef.current?.stop(), []);

  // The lit step follows the sound: each frame asks the engine which step has reached the speakers.
  useEffect(() => {
    if (!playing) return;
    let raf = 0;
    const frame = () => {
      setStep(drumsRef.current.playhead());
      raf = requestAnimationFrame(frame);
    };
    raf = requestAnimationFrame(frame);
    return () => cancelAnimationFrame(raf);
  }, [playing]);

  // Called synchronously at the top of a tap handler — that is what unlocks audio on iOS.
  function drums() {
    const engine = getDrums();
    if (!drumsRef.current) engine.update(settings);
    drumsRef.current = engine;
    return engine;
  }

  function togglePlay() {
    const engine = drums();
    if (playing) {
      engine.stop();
      setStep(-1);
    } else {
      engine.start();
    }
    setPlaying(!playing);
  }

  function toggleStep(track, i) {
    const engine = drums();
    const on = !grid[track][i];
    setGrid({ ...grid, [track]: grid[track].map((v, j) => (j === i ? (on ? 1 : 0) : v)) });
    if (on && !playing) engine.hit(track); // hear the sound you just placed
  }

  function load(groove) {
    setGrid(groove.grid);
    setBpm(groove.bpm);
    setSwing(groove.swing ?? 0);
    setDrive(groove.drive ?? 30);
  }

  const draft = { type: "groove", name: name || "untitled", grid, bpm, swing, drive, authorHandle: me?.userHandle };
  const verdict = can.create(draft);

  // Clear the field as the save starts and hold the button until the write lands; a save that
  // did not land hands the name back, keeping anything typed since.
  async function save() {
    const typed = name;
    setSaving(true);
    setName("");
    try {
      const result = await database.put({ ...draft, createdAt: Date.now() });
      if (!result.ok) setName((now) => now || typed);
    } finally {
      setSaving(false);
    }
  }

  return (
    <main className="min-h-screen bg-background text-foreground p-4 md:p-8">
      <Stack gap={4}>
        <Row gap={2} align="center" wrap>
          <Heading level={1}>Drum machine</Heading>
          <Spacer />
          <Badge tone={playing ? "up" : "idle"}>{playing ? `Playing · ${bpm} bpm` : "Ready"}</Badge>
        </Row>

        <Card variant="tight">
          <Stack gap={3}>
            {TRACKS.map(({ id, label }) => (
              <Stack key={id} gap={1}>
                <Text size="sm" muted>
                  {label}
                </Text>
                {/* Eight steps a line on a phone, all sixteen on a wider screen. */}
                <div className="grid grid-cols-8 md:grid-cols-16 justify-items-center gap-1 select-none touch-manipulation">
                  {Array.from({ length: STEPS }, (_, i) => {
                    const on = Boolean(grid[id][i]);
                    return (
                      <IconButton
                        key={i}
                        size="sm"
                        label={`${label} step ${i + 1}${on ? ", on" : ", off"}`}
                        variant={on || i === step ? "solid" : "quiet"}
                        tone={i === step ? "info" : on ? "up" : "idle"}
                        icon={<Icon name={on ? "idle" : i % 4 === 0 ? "dots" : "off"} size="sm" />}
                        onClick={() => toggleStep(id, i)}
                      />
                    );
                  })}
                </div>
              </Stack>
            ))}
          </Stack>
        </Card>

        <Card variant="tight">
          <Stack gap={3}>
            <Row gap={2} align="center">
              <Button onClick={togglePlay}>{playing ? "Stop" : "Play the drums"}</Button>
              <Spacer />
              <Button size="sm" variant="quiet" onClick={() => setGrid(Object.fromEntries(TRACKS.map(({ id }) => [id, Array(STEPS).fill(0)])))}>
                Clear
              </Button>
            </Row>
            <Knob label="Tempo" value={bpm} min={60} max={180} shown={`${bpm} bpm`} onChange={setBpm} />
            <Knob label="Swing" value={swing} min={0} max={100} shown={`${swing}%`} onChange={setSwing} />
            <Knob label="Saturation" value={drive} min={0} max={100} shown={`${drive}%`} onChange={setDrive} />
            <Knob label="Volume" value={volume} min={0} max={100} shown={`${volume}%`} onChange={setVolume} />
          </Stack>
        </Card>

        <Card variant="tight">
          <Stack gap={3}>
            <Heading level={2}>Saved grooves</Heading>
            {!ready && (
              <div aria-busy="true">
                <Skeleton variant="row" />
              </div>
            )}
            {ready && !me && (
              <Row gap={2} align="center" wrap>
                <Text muted>Sign in to save this groove.</Text>
                <Button size="sm" variant="quiet" onClick={requestLogin}>
                  Sign in
                </Button>
              </Row>
            )}
            {ready && me && verdict.ok && (
              <Row gap={2} align="top" wrap>
                <Field label="Groove name">{(id, d) => <Input id={id} describedBy={d} value={name} onChange={setName} />}</Field>
                <Button size="sm" loading={saving} onClick={save}>
                  Save groove
                </Button>
              </Row>
            )}
            {ready && me && !verdict.ok && <Text muted>Saving grooves is closed here. You can still play and load the saved ones.</Text>}
            {grooves.map((g) => (
              <Row key={g._id} gap={2} align="center" wrap>
                <Button size="sm" variant="quiet" onClick={() => load(g)}>
                  {g.name}
                </Button>
                <Text size="sm" muted num>
                  {g.bpm} bpm
                </Text>
                {g.authorHandle && <ViewerTag userHandle={g.authorHandle} />}
              </Row>
            ))}
          </Stack>
        </Card>
      </Stack>
    </main>
  );
}

— End —