Sequencer
Claude haiku 5.5 · full

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 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)">▶</button>
<button id="stop" class="pad big" title="Stop">■</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 · Right-click: accent · Drag: paint / erase · 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 ? '❚❚' : '▶';
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"} -->