← Sequencer results

Sequencer

Claude haiku 5.5 · full

Initial view of Sequencer

Reviews & generation details

Reviews

No human review yet. Unreviewed does not mean unsuccessful.

Generation context

Task text captured before this run. Effective runtime system instructions were not captured.

Generation prompt · 52625112ac
# Task: Build a Step Sequencer / Drum Machine

Build a 16-step drum sequencer (vanilla JS, no external assets, no CDN, no libraries) with fully synthesized instruments. The top priority is **tight, sample-accurate timing** — a sequencer that drifts or stutters is a failed sequencer.

## Core Specification

**Timing Engine (the hard part — get this right)**
- Use the **lookahead scheduling pattern**: a short-interval timer (`setInterval`/`setTimeout`, ~25ms) that schedules notes ahead of time on the Web Audio clock (`AudioContext.currentTime`, ~100ms lookahead)
- All note triggers are scheduled with `oscillator.start(t)` / `source.start(t)` at precise future times — never trigger audio directly from the timer callback or from `requestAnimationFrame`
- Scheduling must be derived from an absolute step counter and tempo — **never accumulate `currentTime += stepDuration` from a drifting origin**, and never drive timing from `setInterval` delay alone
- Consequence: playback must survive background-tab throttling gracefully (timer slows, but scheduled audio stays gapless because of the lookahead)

**Sequencer Grid**
- 16 steps × 6 instrument tracks; click a cell to toggle, drag across cells to paint/erase
- Steps grouped visually in 4s (beat separators); current step highlighted live during playback (playhead driven from the audio clock, not the timer)
- Per-step velocity: click cycles or right-click sets accent (e.g. normal / accented / off), shown visually (cell brightness or size)
- Controls: play/stop, tempo (BPM slider + numeric readout, 60–200), swing/shuffle amount (0–60%), master volume, clear pattern, random pattern

**Instruments (all synthesized with the Web Audio API — no audio files)**
- **Kick:** sine osc with fast pitch envelope (~150Hz → 50Hz) + amplitude envelope
- **Snare:** filtered noise burst + tonal body (~180Hz), snap from a highpass on the noise
- **Hi-hat (closed & open):** highpass-filtered noise, short vs. longer decay
- **Clap:** several short noise bursts in quick succession through a bandpass
- **Tom/synth stab:** pitched osc with decay — tune it musically relative to the kick
- Each instrument gets a per-track volume slider and mute button; solos optional

**Musical Feel**
- Swing delays off-beat 8th notes by the swing fraction of a step — applied in the scheduler's time calculation, not by nudging the playhead
- Velocity affects gain (and subtly, timbre — e.g. accented snare is brighter)
- Ships with one good-sounding default pattern loaded so pressing Play immediately grooves

**Persistence**
- The full pattern (steps, velocities, tempo, swing, track volumes/mutes) serializes to `localStorage` and restores on reload

## Visual Style
- Dark, hardware-drum-machine aesthetic: chunky pads, glowing active cells, an LED-style moving playhead, BPM readout
- Instrument rows labeled with icons or names; muted tracks dimmed
- Everything readable at a glance while the pattern plays

## Technical Requirements
- Vanilla JS + Web Audio API; code organized with clear section comments
- One shared `AudioContext`, created/resumed on first user gesture
- Tunable constants block at the top (BPM range, lookahead interval, schedule-ahead time, instrument patch parameters)
- No clicks/pops: envelopes must start/end at zero gain

## Quality Bar (must hit all)
- Timing is audibly rock-solid at 60 and 200 BPM — no drift, no doubled or swallowed steps
- Swing is audible and continuous from straight (0%) to hard shuffle (60%)
- The visual playhead tracks the audio exactly
- Muted tracks are silent; accents are louder; the pattern persists across reload
- Stop halts immediately; Play after Stop restarts cleanly on step 1

## Self-Check Before Finishing
Verify with the browser tools:
- Instrument the scheduler (e.g. expose scheduled step times on `window`) and probe: the delta between consecutive scheduled step times at a fixed BPM must be uniform within a small tolerance
- Toggle cells mid-playback and confirm the change is heard on the next pass
- Change BPM mid-playback — tempo shifts without glitches
- Reload and confirm the pattern, tempo, and swing are restored

Inspect original source 29,425 bytes · SHA-256 85887ec06c0f
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>STEP·16 — Drum Sequencer</title>
<style>
:root{
  --bg:#0b0d10; --panel:#101318; --edge:#262b34; --txt:#c9d1d9; --dim:#5b6472;
  --cyan:#2fd6c8; --hot:#ffd23f; --red:#ff3b2e;
}
*{box-sizing:border-box}
html,body{margin:0}
body{
  background:radial-gradient(ellipse at top,#1b1f27,#07090c 70%);
  color:var(--txt); font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
  min-height:100vh; display:flex; justify-content:center; padding:24px 12px; user-select:none;
}
#app{
  width:min(980px,100%); background:linear-gradient(#1c2028,#12151b);
  border:1px solid var(--edge); border-radius:18px; padding:20px;
  box-shadow:0 20px 60px rgba(0,0,0,.6), inset 0 1px 0 rgba(255,255,255,.04);
}
header{display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;margin-bottom:14px}
h1{font-size:13px;letter-spacing:.35em;margin:0;color:#8b95a5;font-weight:800}
.leds{display:flex;gap:12px;align-items:flex-end}
.led-wrap{display:flex;flex-direction:column;align-items:center;gap:3px}
.led{
  font-family:"Courier New",Consolas,monospace;font-weight:700;font-size:34px;letter-spacing:.08em;
  color:var(--red);background:#0b0404;border:1px solid #2a0d0a;border-radius:8px;
  padding:4px 12px;min-width:112px;text-align:center;
  text-shadow:0 0 6px var(--red),0 0 16px rgba(255,59,46,.6);
  box-shadow:inset 0 0 14px rgba(255,0,0,.25);
}
.led-lbl{font-size:9px;letter-spacing:.3em;color:#6d7584;font-weight:700}

.panel{
  display:flex;gap:18px;flex-wrap:wrap;align-items:center;justify-content:space-between;
  background:var(--panel);border:1px solid #0a0c10;border-radius:14px;padding:14px;margin-bottom:16px;
  box-shadow:inset 0 1px 0 rgba(255,255,255,.03);
}
.transport,.actions{display:flex;gap:10px;align-items:center}
.knobs{display:flex;gap:18px;flex-wrap:wrap;flex:1;min-width:300px}
.knobs label{display:flex;flex-direction:column;gap:4px;font-size:10px;letter-spacing:.2em;color:#7d8694;min-width:160px;font-weight:700}
.knobs output{font-family:"Courier New",monospace;color:var(--hot);font-size:13px;letter-spacing:0;text-align:right}
.knobs .hdr{display:flex;justify-content:space-between}
input[type=range]{accent-color:var(--cyan);width:100%;margin:0}

.pad{
  font:inherit;font-size:12px;font-weight:800;letter-spacing:.14em;color:#cfd6df;cursor:pointer;
  background:linear-gradient(#2c3139,#1a1d23);border:1px solid #07080a;border-radius:10px;padding:10px 14px;
  box-shadow:0 4px 0 #050608,0 6px 14px rgba(0,0,0,.5),inset 0 1px 0 rgba(255,255,255,.08);
  transition:transform .04s;
}
.pad:active{transform:translateY(3px);box-shadow:0 1px 0 #050608,inset 0 2px 6px rgba(0,0,0,.6)}
.pad.big{width:76px;height:60px;font-size:22px;padding:0}
#play.active{background:linear-gradient(#5dff9a,#17b35a);color:#03210e;box-shadow:0 4px 0 #0a5a2b,0 0 18px rgba(80,255,150,.55)}
#stop{color:#ff8a7a}

.hrow,.row{display:grid;grid-template-columns:118px 1fr 160px;gap:14px;align-items:center;margin-bottom:8px}
.steps{display:grid;grid-template-columns:repeat(16,1fr);gap:6px}
.steps>:nth-child(4n+1):not(:first-child){margin-left:10px}
.hn{text-align:center;font:700 11px "Courier New",monospace;color:#434b59;padding-bottom:2px}
.hn.ph{color:var(--red);text-shadow:0 0 8px var(--red)}
.label{font-size:12px;font-weight:800;letter-spacing:.15em;color:#aab3c0;display:flex;gap:8px;align-items:center}
.ico{width:26px;height:26px;border-radius:50%;display:grid;place-items:center;background:#0a0c10;border:1px solid #2a2f38;color:var(--cyan);font-size:13px;flex:none}
.tctl{display:flex;gap:6px;align-items:center}
.tctl input{flex:1;min-width:50px}
.mbtn{
  width:28px;height:28px;flex:none;border-radius:7px;font:800 11px system-ui;color:#9aa3b0;cursor:pointer;
  background:linear-gradient(#2a2f38,#171a20);border:1px solid #07080a;box-shadow:0 2px 0 #050608;
}
.mbtn.mute.act{background:linear-gradient(#ff7a5c,#c8371c);color:#fff}
.mbtn.solo.act{background:linear-gradient(#ffe27a,#d9a300);color:#1a1300}

.cell{
  height:50px;border-radius:8px;cursor:pointer;position:relative;
  background:linear-gradient(#252931,#15181e);border:1px solid #07080a;
  box-shadow:inset 0 2px 5px rgba(0,0,0,.7),0 1px 0 rgba(255,255,255,.05);
  transition:background-color .06s,box-shadow .06s,filter .06s,transform .06s;
}
.cell.on{
  background:radial-gradient(circle at 50% 30%,#9ff9f0,#1ea99e 70%);border-color:#0b5550;
  box-shadow:0 0 10px rgba(47,214,200,.55),inset 0 -3px 0 rgba(0,0,0,.25);
}
.cell.acc{
  background:radial-gradient(circle at 50% 30%,#fffbd0,#ffd23f 55%,#d98a00);border-color:#7a4e00;
  box-shadow:0 0 20px rgba(255,200,40,.9),inset 0 -3px 0 rgba(0,0,0,.2);transform:scale(1.06);
}
.cell.ph{filter:brightness(1.4)}
.cell.ph:not(.on):not(.acc){background:linear-gradient(#3a404c,#20242c);box-shadow:inset 0 0 0 1px rgba(255,255,255,.14),0 0 10px rgba(255,255,255,.08)}
.row.muted .cell.on,.row.muted .cell.acc{opacity:.3;filter:saturate(0)}
.row.muted .label{color:#4a5160}
.row.muted .ico{color:#4a5160}

footer{margin-top:14px;font-size:11px;color:var(--dim);letter-spacing:.06em;text-align:center}
@media (max-width:640px){
  .hrow,.row{grid-template-columns:70px 1fr}
  .tctl{grid-column:1/-1}
  .cell{height:40px}
  .label span:last-child{display:none}
  .steps{gap:3px}
  .led{font-size:24px;min-width:84px}
}
</style>
</head>
<body>
<div id="app">
  <!-- ======================= HEADER / LEDS ======================= -->
  <header>
    <h1>STEP·16 &nbsp;DRUM MACHINE</h1>
    <div class="leds">
      <div class="led-wrap"><div class="led" id="bpmLed">118</div><div class="led-lbl">BPM</div></div>
      <div class="led-wrap"><div class="led" id="stepLed">--</div><div class="led-lbl">STEP</div></div>
    </div>
  </header>

  <!-- ======================= TRANSPORT & CONTROLS ======================= -->
  <section class="panel">
    <div class="transport">
      <button id="play" class="pad big" title="Play / Pause (Space)">&#9654;</button>
      <button id="stop" class="pad big" title="Stop">&#9632;</button>
    </div>
    <div class="knobs">
      <label><div class="hdr"><span>TEMPO</span><output id="bpmOut">118</output></div>
        <input type="range" id="bpm" min="60" max="200" step="1"></label>
      <label><div class="hdr"><span>SWING</span><output id="swingOut">15%</output></div>
        <input type="range" id="swing" min="0" max="60" step="1"></label>
      <label><div class="hdr"><span>MASTER</span><output id="masterOut">80</output></div>
        <input type="range" id="master" min="0" max="1" step="0.01"></label>
    </div>
    <div class="actions">
      <button id="rand" class="pad">RANDOM</button>
      <button id="clear" class="pad">CLEAR</button>
    </div>
  </section>

  <!-- ======================= SEQUENCER GRID ======================= -->
  <div id="grid"></div>

  <footer>Click: toggle &nbsp;·&nbsp; Right-click: accent &nbsp;·&nbsp; Drag: paint / erase &nbsp;·&nbsp; Space: play / stop</footer>
</div>

<script>
/* =====================================================================
   1. TUNABLE CONSTANTS
   ===================================================================== */
const CFG = {
  BPM_MIN: 60,
  BPM_MAX: 200,
  DEFAULT_BPM: 118,
  DEFAULT_SWING: 0.15,          // 0..0.6 — fraction of a 16th step that odd steps are delayed
  SWING_MAX: 0.6,
  STEPS: 16,
  LOOKAHEAD_MS: 25,             // scheduler tick interval (runs in a Worker so it survives background tabs)
  SCHEDULE_AHEAD: 0.10,         // seconds of audio scheduled ahead of the audio clock
  START_DELAY: 0.06,            // first step lands this many seconds after pressing Play
  NORMAL_VEL: 0.6,              // velocity factor for normal hits
  ACCENT_VEL: 1.0,              // velocity factor for accented hits
  DEFAULT_MASTER: 0.8,
  DEFAULT_TRACK_VOL: 0.85,
  STORAGE_KEY: 'drumseq.pattern.v1',
  PATCH: {
    kick:     { startHz: 150, endHz: 50, pitchTime: 0.06, decay: 0.42, peak: 1.0 },
    snare:    { toneHz: 180, toneStartHz: 230, toneDecay: 0.12, toneGain: 0.35,
                noiseDecay: 0.20, noiseGain: 0.70, hpHz: 1800, accentHpHz: 2800 },
    hatClosed:{ hpHz: 7000, decay: 0.05, gain: 0.35 },
    hatOpen:  { hpHz: 6500, decay: 0.42, gain: 0.30 },
    clap:     { bpHz: 1200, q: 0.9, gap: 0.011, burstDecay: 0.012, tailDecay: 0.16, gain: 0.80 },
    // G2 (98 Hz) = exactly one octave above the kick's 49 Hz (G1) end-pitch: tuned to the kick.
    tom:      { hz: 98, startHz: 140, pitchTime: 0.05, decay: 0.35, gain: 0.80 },
  },
};

const TRACKS = [
  { name: 'KICK',  icon: '◉' },
  { name: 'SNARE', icon: '✱' },
  { name: 'CLAP',  icon: '◎' },
  { name: 'CL-HAT',icon: '▮' },
  { name: 'OP-HAT',icon: '▯' },
  { name: 'TOM',   icon: '◆' },
];
const SYM = ['.', 'x', 'X'];        // 0 off, 1 normal, 2 accent (used in serialization)

// Default groove: x = normal, X = accent
const DEFAULT_PATTERN = [
  'X.......X.x..x..',   // kick
  '....x.......X...',   // snare
  '....X.......x...',   // clap
  'x.x.X.x.x.x.X...',   // closed hat
  '......x.......x.',   // open hat
  '...........x...x',   // tom
];

/* =====================================================================
   2. STATE (persisted) & TRANSPORT STATE (runtime only)
   ===================================================================== */
const state = {
  bpm: CFG.DEFAULT_BPM,
  swing: CFG.DEFAULT_SWING,
  master: CFG.DEFAULT_MASTER,
  tracks: TRACKS.map(() => ({ vol: CFG.DEFAULT_TRACK_VOL, mute: false, solo: false })),
  pattern: TRACKS.map(() => new Array(CFG.STEPS).fill(0)),
};

const T = {
  playing: false,
  nextStep: 0,                                 // absolute step counter of next unscheduled step
  anchor: { time: 0, step: 0, dur: 0.125 },    // timing anchor: nominal time of a step, re-based on tempo change
  queue: [],                                   // {step,time} events for the visual playhead
  ph: -1,                                      // currently displayed playhead column
  worker: null, timerId: null,
  uiRunning: false,
};

// Debug hook: scheduled step times are logged here (used by self-checks).
window.__seq = { log: [], T, state, get ctx() { return ctx; } };

/* =====================================================================
   3. AUDIO GRAPH
   ===================================================================== */
let ctx = null, master = null, limiter = null, bus = null, trackGains = [], noiseBuf = null;

function ensureAudio() {
  if (ctx) { if (ctx.state === 'suspended') ctx.resume(); return; }
  const AC = window.AudioContext || window.webkitAudioContext;
  ctx = new AC({ latencyHint: 'interactive' });
  limiter = ctx.createDynamicsCompressor();          // gentle bus limiter against clipping
  limiter.threshold.value = -10; limiter.knee.value = 6; limiter.ratio.value = 8;
  limiter.attack.value = 0.003; limiter.release.value = 0.15;
  master = ctx.createGain();
  master.gain.value = state.master;
  master.connect(limiter).connect(ctx.destination);

  // 2 seconds of white noise, shared by all noise-based voices
  noiseBuf = ctx.createBuffer(1, ctx.sampleRate * 2, ctx.sampleRate);
  const d = noiseBuf.getChannelData(0);
  for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;

  buildBus();
  if (ctx.state === 'suspended') ctx.resume();
}

// Per-session routing. On Stop we tear this down, which instantly silences any
// voices that were already scheduled into the lookahead window.
function buildBus() {
  if (bus) bus.disconnect();
  bus = ctx.createGain();
  bus.connect(master);
  trackGains = TRACKS.map((_, i) => {
    const g = ctx.createGain();
    g.gain.value = audibleTrack(i) ? state.tracks[i].vol : 0;
    g.connect(bus);
    return g;
  });
}

function audibleTrack(i) {
  const anySolo = state.tracks.some(t => t.solo);
  const t = state.tracks[i];
  return !t.mute && (!anySolo || t.solo);
}

// Live mix update (volume / mute / solo / master) — smooth, click-free.
function applyMix(instant) {
  if (!ctx) return;
  const now = ctx.currentTime;
  trackGains.forEach((g, i) => {
    const target = audibleTrack(i) ? state.tracks[i].vol : 0;
    if (instant) { g.gain.setValueAtTime(target, now); }
    else { g.gain.cancelScheduledValues(now); g.gain.setTargetAtTime(target, now, 0.01); }
  });
  master.gain.setTargetAtTime(state.master, now, 0.02);
}

/* ----- envelope helper: always starts and ends at zero gain ----- */
function env(param, t, peak, attack, decay) {
  param.cancelScheduledValues(t);
  param.setValueAtTime(0, t);
  param.linearRampToValueAtTime(peak, t + attack);
  param.exponentialRampToValueAtTime(0.0001, t + attack + decay);
  param.setValueAtTime(0, t + attack + decay + 0.01);
}
function gainNode(out) { const g = ctx.createGain(); g.gain.value = 0; g.connect(out); return g; }
function noiseSrc(t, dur, offsetMax = 0.5) {
  const s = ctx.createBufferSource();
  s.buffer = noiseBuf;
  s.start(t, Math.random() * offsetMax, dur + 0.02);
  return s;
}

/* =====================================================================
   4. INSTRUMENTS (all synthesized; `vel` = gain factor, `acc` = accent timbre)
   ===================================================================== */
function voiceKick(t, out, vel, acc) {
  const P = CFG.PATCH.kick;
  const o = ctx.createOscillator(); o.type = 'sine';
  o.frequency.setValueAtTime(P.startHz, t);
  o.frequency.exponentialRampToValueAtTime(P.endHz, t + P.pitchTime);
  const g = gainNode(out);
  env(g.gain, t, P.peak * vel, 0.002, P.decay);
  o.connect(g); o.start(t); o.stop(t + P.decay + 0.05);
  if (acc) {   // accent: small beater click
    const n = noiseSrc(t, 0.01);
    const hp = ctx.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 2500;
    const gc = gainNode(out); env(gc.gain, t, 0.35 * vel, 0.0005, 0.008);
    n.connect(hp).connect(gc);
  }
}

function voiceSnare(t, out, vel, acc) {
  const P = CFG.PATCH.snare;
  // tonal body (~180 Hz) with a quick downward pitch settle
  const o = ctx.createOscillator(); o.type = 'triangle';
  o.frequency.setValueAtTime(P.toneStartHz, t);
  o.frequency.exponentialRampToValueAtTime(P.toneHz, t + 0.03);
  const gt = gainNode(out);
  env(gt.gain, t, P.toneGain * vel, 0.001, P.toneDecay);
  o.connect(gt); o.start(t); o.stop(t + P.toneDecay + 0.05);
  // noise snap through a highpass (brighter on accent)
  const n = noiseSrc(t, P.noiseDecay);
  const hp = ctx.createBiquadFilter(); hp.type = 'highpass';
  hp.frequency.value = acc ? P.accentHpHz : P.hpHz; hp.Q.value = 0.7;
  const gn = gainNode(out);
  env(gn.gain, t, P.noiseGain * vel, 0.001, P.noiseDecay);
  n.connect(hp).connect(gn);
}

function voiceHat(t, out, vel, acc, open) {
  const P = open ? CFG.PATCH.hatOpen : CFG.PATCH.hatClosed;
  const n = noiseSrc(t, P.decay);
  const hp = ctx.createBiquadFilter(); hp.type = 'highpass';
  hp.frequency.value = P.hpHz * (acc ? 1.2 : 1);
  const g = gainNode(out);
  env(g.gain, t, P.gain * vel, 0.001, P.decay);
  n.connect(hp).connect(g);
}

function voiceClap(t, out, vel, acc) {
  const P = CFG.PATCH.clap;
  const bp = ctx.createBiquadFilter(); bp.type = 'bandpass';
  bp.frequency.value = P.bpHz * (acc ? 1.15 : 1); bp.Q.value = P.q;
  bp.connect(out);
  for (let i = 0; i < 4; i++) {              // 3 quick bursts + one decaying tail
    const bt = t + i * P.gap;
    const last = i === 3;
    const dur = last ? P.tailDecay : P.burstDecay;
    const n = noiseSrc(bt, dur + 0.02);
    const g = gainNode(bp);
    env(g.gain, bt, P.gain * vel * (last ? 0.9 : 1), 0.001, dur);
    n.connect(g);
  }
}

function voiceTom(t, out, vel, acc) {
  const P = CFG.PATCH.tom;
  const o = ctx.createOscillator(); o.type = 'sine';
  o.frequency.setValueAtTime(P.startHz, t);
  o.frequency.exponentialRampToValueAtTime(P.hz, t + P.pitchTime);
  const g = gainNode(out);
  env(g.gain, t, P.gain * vel, 0.002, P.decay);
  o.connect(g); o.start(t); o.stop(t + P.decay + 0.05);
  if (acc) {   // accent: extra bright 2nd partial
    const o2 = ctx.createOscillator(); o2.type = 'triangle'; o2.frequency.value = P.hz * 2;
    const g2 = gainNode(out);
    env(g2.gain, t, 0.22 * vel, 0.002, 0.12);
    o2.connect(g2); o2.start(t); o2.stop(t + 0.2);
  }
}

// Track index → voice (matches TRACKS order)
const VOICES = [
  voiceKick,
  voiceSnare,
  voiceClap,
  (t, o, v, a) => voiceHat(t, o, v, a, false),
  (t, o, v, a) => voiceHat(t, o, v, a, true),
  voiceTom,
];

/* =====================================================================
   5. LOOKAHEAD SCHEDULER
   Step n's nominal time is derived from the absolute step counter and
   the anchor — never accumulated from the previous step.
   ===================================================================== */
function stepDur() { return 60 / state.bpm / 4; }      // one 16th note, seconds

// Nominal (straight) time of step n
function nominalTime(n) { return T.anchor.time + (n - T.anchor.step) * T.anchor.dur; }

// Actual time step n sounds: odd 16ths (the off-beat 8ths) are pushed late by swing × step
function stepTime(n) {
  let t = nominalTime(n);
  if (n % 2 === 1) t += state.swing * T.anchor.dur;
  return t;
}

function scheduleStep(n, t) {
  const s = n % CFG.STEPS;
  for (let i = 0; i < TRACKS.length; i++) {
    const v = state.pattern[i][s];
    if (v) VOICES[i](t, trackGains[i], v === 2 ? CFG.ACCENT_VEL : CFG.NORMAL_VEL, v === 2);
  }
  T.queue.push({ step: s, time: t });
  const L = window.__seq.log;
  L.push({ n, step: s, t });
  if (L.length > 4000) L.splice(0, L.length - 4000);
}

function tick() {
  if (!T.playing || !ctx) return;
  const now = ctx.currentTime;
  // If the audio clock got ahead of us (e.g. context was suspended), re-anchor
  // forward once instead of firing a burst of stale notes.
  if (stepTime(T.nextStep) < now) {
    T.anchor = { time: now + 0.03, step: T.nextStep, dur: T.anchor.dur };
  }
  const horizon = now + CFG.SCHEDULE_AHEAD;
  while (stepTime(T.nextStep) < horizon) {
    scheduleStep(T.nextStep, stepTime(T.nextStep));
    T.nextStep++;
  }
}

// Tick source: a Worker-based timer (not throttled like background-tab timers),
// with a setInterval fallback.
function startTicker() {
  stopTicker();
  const fallback = () => { T.timerId = setInterval(tick, CFG.LOOKAHEAD_MS); };
  try {
    const src = `setInterval(function(){postMessage(0)},${CFG.LOOKAHEAD_MS});`;
    const w = new Worker(URL.createObjectURL(new Blob([src], { type: 'application/javascript' })));
    w.onmessage = tick;
    w.onerror = () => { w.terminate(); if (T.worker === w) { T.worker = null; fallback(); } };
    T.worker = w;
  } catch (e) { fallback(); }
  tick();
}
function stopTicker() {
  if (T.worker) { T.worker.terminate(); T.worker = null; }
  if (T.timerId) { clearInterval(T.timerId); T.timerId = null; }
}

/* =====================================================================
   6. TRANSPORT
   ===================================================================== */
function play() {
  ensureAudio();
  if (T.playing) return;
  T.playing = true;
  T.nextStep = 0;                                       // always restart on step 1
  T.anchor = { time: ctx.currentTime + CFG.START_DELAY, step: 0, dur: stepDur() };
  T.queue = [];
  applyMix(false);
  startTicker();
  setPlayUI(true);
  if (!T.uiRunning) { T.uiRunning = true; requestAnimationFrame(uiLoop); }
}

function stop() {
  if (!T.playing) return;
  T.playing = false;
  stopTicker();
  T.queue = [];
  buildBus();                                           // kills all ringing / pending voices immediately
  applyMix(true);
  setPlayhead(-1);
  setPlayUI(false);
}

// Tempo change mid-playback: re-anchor at the next unscheduled step so the
// grid continues seamlessly at the new rate.
function setBpm(bpm) {
  state.bpm = bpm;
  if (T.playing) {
    const n = T.nextStep;
    const base = nominalTime(n);
    T.anchor = { time: base, step: n, dur: stepDur() };
  }
  updateReadouts();
  save();
}

/* Visual playhead: driven by the AUDIO clock. We pop events whose scheduled
   time has been reached by ctx.currentTime. */
function uiLoop() {
  if (!T.playing) { T.uiRunning = false; return; }
  const now = ctx.currentTime;
  let cur = -1;
  while (T.queue.length && T.queue[0].time <= now) cur = T.queue.shift().step;
  if (cur >= 0) setPlayhead(cur);
  requestAnimationFrame(uiLoop);
}

/* =====================================================================
   7. PATTERN EDITING
   ===================================================================== */
function setCell(t, s, v) {
  state.pattern[t][s] = v;
  paintCell(t, s);
  save();
}
function cycleAccent(t, s) {
  const v = state.pattern[t][s];
  setCell(t, s, v === 2 ? 1 : 2);
}
function clearPattern() {
  state.pattern = TRACKS.map(() => new Array(CFG.STEPS).fill(0));
  renderCells(); save();
}
function randomPattern() {
  const P = [0.35, 0.22, 0.12, 0.5, 0.15, 0.12];   // density per track
  state.pattern = TRACKS.map((_, i) =>
    Array.from({ length: CFG.STEPS }, (_, s) => {
      if (i === 0 && s === 0) return 2;
      return Math.random() < P[i] ? (Math.random() < 0.2 ? 2 : 1) : 0;
    }));
  renderCells(); save();
}

/* =====================================================================
   8. UI RENDERING
   ===================================================================== */
const $ = s => document.querySelector(s);
const grid = $('#grid');
let cells = [], headNums = [];

function buildGrid() {
  grid.innerHTML = '';
  // header row (step numbers)
  const hr = document.createElement('div'); hr.className = 'hrow';
  hr.innerHTML = '<div></div><div class="steps"></div><div></div>';
  const hs = hr.querySelector('.steps');
  for (let s = 0; s < CFG.STEPS; s++) {
    const h = document.createElement('div'); h.className = 'hn';
    h.textContent = String(s + 1);
    hs.append(h); headNums.push(h);
  }
  grid.append(hr);

  TRACKS.forEach((tr, i) => {
    const row = document.createElement('div');
    row.className = 'row'; row.dataset.t = i;
    row.innerHTML =
      `<div class="label"><span class="ico">${tr.icon}</span><span>${tr.name}</span></div>` +
      `<div class="steps"></div>` +
      `<div class="tctl">
         <input type="range" class="tvol" min="0" max="1" step="0.01" value="${state.tracks[i].vol}" title="Track volume">
         <button class="mbtn mute" title="Mute">M</button>
         <button class="mbtn solo" title="Solo">S</button>
       </div>`;
    const steps = row.querySelector('.steps');
    cells[i] = [];
    for (let s = 0; s < CFG.STEPS; s++) {
      const c = document.createElement('div');
      c.className = 'cell'; c.dataset.t = i; c.dataset.s = s;
      steps.append(c); cells[i][s] = c;
    }
    grid.append(row);
  });
}

function paintCell(t, s) {
  const v = state.pattern[t][s];
  const c = cells[t][s];
  c.classList.toggle('on', v === 1);
  c.classList.toggle('acc', v === 2);
}
function renderCells() {
  TRACKS.forEach((_, t) => { for (let s = 0; s < CFG.STEPS; s++) paintCell(t, s); });
}

function renderMix() {
  document.querySelectorAll('.row').forEach(row => {
    const i = +row.dataset.t;
    const tr = state.tracks[i];
    row.classList.toggle('muted', !audibleTrack(i));
    row.querySelector('.mute').classList.toggle('act', tr.mute);
    row.querySelector('.solo').classList.toggle('act', tr.solo);
    row.querySelector('.tvol').value = tr.vol;
  });
}

function updateReadouts() {
  $('#bpmLed').textContent = state.bpm;
  $('#bpmOut').textContent = state.bpm;
  $('#bpm').value = state.bpm;
  $('#swing').value = Math.round(state.swing * 100);
  $('#swingOut').textContent = Math.round(state.swing * 100) + '%';
  $('#master').value = state.master;
  $('#masterOut').textContent = Math.round(state.master * 100);
}

function setPlayUI(on) {
  $('#play').classList.toggle('active', on);
  $('#play').innerHTML = on ? '&#10074;&#10074;' : '&#9654;';
  if (!on) $('#stepLed').textContent = '--';
}

// Light the playhead column (cells + header LED)
function setPlayhead(s) {
  if (T.ph === s) return;
  if (T.ph >= 0) {
    headNums[T.ph].classList.remove('ph');
    TRACKS.forEach((_, t) => cells[t][T.ph].classList.remove('ph'));
  }
  T.ph = s;
  if (s >= 0) {
    headNums[s].classList.add('ph');
    TRACKS.forEach((_, t) => cells[t][s].classList.add('ph'));
    $('#stepLed').textContent = String(s + 1).padStart(2, '0');
  } else {
    $('#stepLed').textContent = '--';
  }
}

/* =====================================================================
   9. PERSISTENCE (localStorage)
   ===================================================================== */
function save() {
  try {
    localStorage.setItem(CFG.STORAGE_KEY, JSON.stringify({
      v: 1,
      bpm: state.bpm,
      swing: state.swing,
      master: state.master,
      tracks: state.tracks,
      pattern: state.pattern.map(r => r.map(v => SYM[v]).join('')),
    }));
  } catch (e) { /* storage unavailable — ignore */ }
}

function load() {
  // default pattern
  state.pattern = DEFAULT_PATTERN.map(str => [...str].map(ch => Math.max(0, SYM.indexOf(ch))));
  try {
    const raw = localStorage.getItem(CFG.STORAGE_KEY);
    if (!raw) return;
    const p = JSON.parse(raw);
    if (typeof p.bpm === 'number') state.bpm = Math.min(CFG.BPM_MAX, Math.max(CFG.BPM_MIN, Math.round(p.bpm)));
    if (typeof p.swing === 'number') state.swing = Math.min(CFG.SWING_MAX, Math.max(0, p.swing));
    if (typeof p.master === 'number') state.master = Math.min(1, Math.max(0, p.master));
    if (Array.isArray(p.tracks) && p.tracks.length === TRACKS.length) {
      p.tracks.forEach((t, i) => {
        state.tracks[i] = {
          vol: Math.min(1, Math.max(0, +t.vol || 0)),
          mute: !!t.mute, solo: !!t.solo,
        };
      });
    }
    if (Array.isArray(p.pattern) && p.pattern.length === TRACKS.length) {
      state.pattern = p.pattern.map(str => {
        const row = new Array(CFG.STEPS).fill(0);
        for (let s = 0; s < CFG.STEPS && s < str.length; s++) row[s] = Math.max(0, SYM.indexOf(str[s]));
        return row;
      });
    }
  } catch (e) { /* corrupt storage — fall back to defaults */ }
}

/* =====================================================================
   10. EVENT WIRING
   ===================================================================== */
// Grid: click toggles, right-click accents, drag paints/erases
let dragMode = null;   // null | 1 (painting) | 0 (erasing)
function applyPaint(c) {
  const t = +c.dataset.t, s = +c.dataset.s;
  const cur = state.pattern[t][s];
  if (dragMode === 1 && cur === 0) setCell(t, s, 1);
  else if (dragMode === 0 && cur !== 0) setCell(t, s, 0);
}

grid.addEventListener('pointerdown', e => {
  ensureAudio();
  const row = e.target.closest('.row');
  if (!row) return;
  const c = e.target.closest('.cell');
  if (c) {
    const t = +c.dataset.t, s = +c.dataset.s;
    e.preventDefault();
    if (e.button === 2) { cycleAccent(t, s); dragMode = null; return; }
    if (e.button !== 0) return;
    dragMode = state.pattern[t][s] ? 0 : 1;
    grid.setPointerCapture(e.pointerId);
    applyPaint(c);
  }
});
grid.addEventListener('pointermove', e => {
  if (dragMode === null) return;
  const el = document.elementFromPoint(e.clientX, e.clientY);
  const c = el && el.closest && el.closest('.cell');
  if (c && grid.contains(c)) applyPaint(c);
});
const endDrag = () => { dragMode = null; };
window.addEventListener('pointerup', endDrag);
window.addEventListener('pointercancel', endDrag);
grid.addEventListener('contextmenu', e => e.preventDefault());

// Track controls (delegated)
grid.addEventListener('click', e => {
  const row = e.target.closest('.row');
  if (!row) return;
  const i = +row.dataset.t;
  const tr = state.tracks[i];
  if (e.target.classList.contains('mute')) { tr.mute = !tr.mute; }
  else if (e.target.classList.contains('solo')) { tr.solo = !tr.solo; }
  else return;
  applyMix(false); renderMix(); save();
});
grid.addEventListener('input', e => {
  if (!e.target.classList.contains('tvol')) return;
  const i = +e.target.closest('.row').dataset.t;
  state.tracks[i].vol = +e.target.value;
  applyMix(false); save();
});

$('#play').addEventListener('click', () => (T.playing ? stop() : play()));
$('#stop').addEventListener('click', stop);
$('#bpm').addEventListener('input', e => setBpm(+e.target.value));
$('#swing').addEventListener('input', e => {
  state.swing = +e.target.value / 100;
  updateReadouts(); save();
});
$('#master').addEventListener('input', e => {
  state.master = +e.target.value;
  updateReadouts(); applyMix(false); save();
});
$('#clear').addEventListener('click', clearPattern);
$('#rand').addEventListener('click', randomPattern);

window.addEventListener('keydown', e => {
  if (e.code === 'Space') { e.preventDefault(); if (!e.repeat) (T.playing ? stop() : play()); }
});

/* =====================================================================
   11. INIT
   ===================================================================== */
load();
buildGrid();
renderCells();
renderMix();
updateReadouts();
setPlayUI(false);
</script>
</body>
</html>
<!-- agent-meta {"model":"anthropic/claude-haiku-5.5","provider":"openrouter","persona":"full","sessionId":"e7f0dd10-3ea1-4cd5-8ebd-9465b2ce0eb4","tokensIn":84524,"tokensOut":92079,"tokensTotal":176603,"cost":0.04971918999999999,"turns":5,"toolCalls":5,"failedToolCalls":0,"timestamp":"2026-10-08T01:15:03.586Z"} -->