← Sequencer results

Sequencer

Deepseek v4.1 Flash · full

Initial view of Sequencer

Reviews & generation details

Reviews

No human review yet. Unreviewed does not mean unsuccessful.

Generation context

Imported historical artifact. Its exact original instructions and lineage are not known.

Generation prompt · exact text unavailable

See the current prompt for reference. It may differ from the instructions used to generate this artifact.

Inspect original source 37,447 bytes · SHA-256 28bf88f23929
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>TR-8086 · Step Sequencer</title>
<style>
/* ============================================================
   THEME / LAYOUT
   ============================================================ */
:root{
  --bg:#0b0d10;
  --panel:#14181d;
  --panel2:#1b2027;
  --line:#2a323c;
  --line2:#333d49;
  --txt:#c9d4e0;
  --dim:#69788a;
  --accent:#ff5a2b;
  --accent2:#ffb03a;
  --led:#4dff88;
  --leddim:#1f3a2a;
  --glow:0 0 10px rgba(255,90,43,.55);
  --mono:"SFMono-Regular",Consolas,"Liberation Mono",Menlo,monospace;
}
*{box-sizing:border-box}
html,body{height:100%}
body{
  margin:0;background:
    radial-gradient(1200px 600px at 50% -10%, #1a2029 0%, transparent 60%),
    repeating-linear-gradient(180deg,#0b0d10 0 2px,#0a0c0f 2px 4px);
  color:var(--txt);
  font-family:var(--mono);
  font-size:13px;
  -webkit-font-smoothing:antialiased;
  user-select:none;
  display:flex;justify-content:center;
  padding:18px 14px 40px;
}
#app{width:100%;max-width:1180px}

/* ---------- header ---------- */
header{
  display:flex;align-items:center;gap:18px;flex-wrap:wrap;
  background:linear-gradient(180deg,#1d232b,#131820);
  border:1px solid var(--line);
  border-radius:12px;
  padding:12px 16px;
  box-shadow:0 10px 30px rgba(0,0,0,.55), inset 0 1px 0 rgba(255,255,255,.04);
  margin-bottom:14px;
}
.brand{display:flex;flex-direction:column;line-height:1.15}
.brand b{font-size:17px;letter-spacing:.16em;color:#fff}
.brand span{font-size:10px;letter-spacing:.24em;color:var(--dim)}
.transport{display:flex;gap:8px;align-items:center}

/* ---------- buttons ---------- */
button{
  font-family:var(--mono);font-size:11px;letter-spacing:.09em;
  color:var(--txt);background:linear-gradient(180deg,#242b34,#171c23);
  border:1px solid var(--line2);border-radius:7px;
  padding:9px 13px;cursor:pointer;text-transform:uppercase;
  box-shadow:0 2px 0 #0a0d11, inset 0 1px 0 rgba(255,255,255,.05);
  transition:filter .1s, transform .04s, background .12s;
}
button:hover{filter:brightness(1.22)}
button:active{transform:translateY(1px);box-shadow:none}
button.on{
  background:linear-gradient(180deg,#ff7a44,#e0461c);
  border-color:#ff8f61;color:#1a0d07;font-weight:700;
  box-shadow:0 0 16px rgba(255,90,43,.45), inset 0 1px 0 rgba(255,255,255,.35);
}
#btnPlay{min-width:96px}
#btnPlay.on{background:linear-gradient(180deg,#63ff9d,#16c25f);border-color:#8dffb5;color:#04180c}

/* ---------- readouts ---------- */
.readout{
  background:#07090c;border:1px solid #000;border-radius:8px;
  padding:6px 12px;min-width:126px;text-align:right;
  box-shadow:inset 0 2px 8px rgba(0,0,0,.9);
}
.readout .lbl{font-size:9px;letter-spacing:.22em;color:var(--dim);display:block;text-align:left}
.readout .val{font-size:22px;color:var(--accent2);text-shadow:0 0 12px rgba(255,176,58,.55);font-variant-numeric:tabular-nums}
.readout .val small{font-size:10px;color:var(--dim);text-shadow:none}
.stepLed{
  display:flex;gap:5px;align-items:center;
  background:#07090c;border:1px solid #000;border-radius:8px;padding:9px 12px;
  box-shadow:inset 0 2px 8px rgba(0,0,0,.9);
}
.stepLed i{
  width:9px;height:9px;border-radius:50%;background:#1c2229;
  box-shadow:inset 0 0 4px #000;display:block;transition:background .05s;
}
.stepLed i.beat{background:#2c3742}
.stepLed i.cur{background:var(--led);box-shadow:0 0 10px var(--led),0 0 22px rgba(77,255,136,.6)}
.statusBox{display:flex;align-items:center;gap:7px;font-size:9.5px;letter-spacing:.18em;color:var(--dim);text-transform:uppercase}
.statusBox .sLed{width:7px;height:7px;border-radius:50%;background:var(--led);box-shadow:0 0 9px var(--led);flex:0 0 7px}

/* ---------- slider controls ---------- */
.knobs{display:flex;gap:18px;align-items:center;flex-wrap:wrap;margin-left:auto}
.knob{display:flex;flex-direction:column;gap:4px;min-width:140px}
.knob .top{display:flex;justify-content:space-between;font-size:9px;letter-spacing:.18em;color:var(--dim)}
.knob .top b{color:var(--accent2);letter-spacing:0;font-size:10px}
input[type=range]{
  -webkit-appearance:none;appearance:none;width:100%;height:16px;background:transparent;cursor:pointer;
}
input[type=range]::-webkit-slider-runnable-track{height:6px;border-radius:4px;background:linear-gradient(90deg,#ff7a44,#2a323c);border:1px solid #000}
input[type=range]::-webkit-slider-thumb{
  -webkit-appearance:none;width:15px;height:15px;margin-top:-6px;border-radius:4px;
  background:linear-gradient(180deg,#e9eef4,#9aa7b6);border:1px solid #05080b;
  box-shadow:0 1px 3px rgba(0,0,0,.8), inset 0 1px 0 #fff;
}
input[type=range]::-moz-range-track{height:6px;border-radius:4px;background:#2a323c;border:1px solid #000}
input[type=range]::-moz-range-thumb{width:14px;height:14px;border-radius:4px;background:#cfd8e3;border:1px solid #05080b}

/* ---------- grid ---------- */
.gridPanel{
  background:linear-gradient(180deg,#161b21,#101419);
  border:1px solid var(--line);border-radius:12px;
  padding:12px;box-shadow:0 10px 30px rgba(0,0,0,.5), inset 0 1px 0 rgba(255,255,255,.04);
  overflow-x:auto;
}
.grid{min-width:820px}
.row{
  display:grid;
  grid-template-columns:132px repeat(16,1fr) 54px 40px;
  gap:5px;align-items:center;
  padding:4px 4px;border-radius:8px;
}
.row.muted{opacity:.42}
.row.head{position:sticky;top:0;z-index:3;background:#101419;padding-bottom:6px}
.num{
  text-align:center;font-size:10px;color:var(--dim);padding-bottom:2px;
  border-bottom:2px solid transparent;
}
.num.beat{border-bottom-color:var(--line2)}
.num.cur{color:var(--led);text-shadow:0 0 8px rgba(77,255,136,.8)}
.num b{display:block;font-size:9px;letter-spacing:.14em;font-weight:400}

/* track label cell */
.tlabel{display:flex;align-items:center;gap:8px;padding-left:4px;overflow:hidden}
.tlabel .ico{
  width:26px;height:26px;flex:0 0 26px;border-radius:6px;display:grid;place-items:center;
  background:#0c1014;border:1px solid var(--line2);font-size:14px;
  box-shadow:inset 0 0 8px rgba(0,0,0,.8);
}
.tlabel .nm{font-size:10px;letter-spacing:.1em;color:var(--txt);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}

/* pads */
.pad{
  position:relative;height:40px;border-radius:8px;cursor:pointer;
  background:linear-gradient(180deg,#222931,#161b21);
  border:1px solid #05080b;
  box-shadow:inset 0 1px 0 rgba(255,255,255,.05), inset 0 -3px 6px rgba(0,0,0,.6), 0 1px 0 #2c3742;
  transition:background .09s, box-shadow .09s, transform .05s;
  touch-action:none;
}
.pad.beatL{background:linear-gradient(180deg,#252d37,#1a2028);border-left-color:rgba(130,150,175,.34)}
.pad:hover{filter:brightness(1.3)}
.pad:active{transform:translateY(1px)}
.pad.on::after{
  content:"";position:absolute;inset:5px;border-radius:5px;
  background:linear-gradient(180deg,var(--pc1),var(--pc2));
  box-shadow:0 0 14px var(--pcg), inset 0 1px 0 rgba(255,255,255,.4);
}
.pad.acc::after{inset:2px;filter:brightness(1.25) saturate(1.15)}
.pad.acc{box-shadow:inset 0 1px 0 rgba(255,255,255,.05), 0 0 0 1px rgba(255,255,255,.16), 0 1px 0 #2c3742}
.pad.hit::after{animation:pop .16s ease-out}
.pad.hit{box-shadow:0 0 18px var(--pcg), inset 0 1px 0 rgba(255,255,255,.1)}
.pad.col{border-top-color:rgba(77,255,136,.85)}
@keyframes pop{from{transform:scale(1.12);filter:brightness(1.8)}to{transform:scale(1)}}

/* per-track controls */
.tvol{display:flex;align-items:center}
.tvol input[type=range]{height:14px}
.tbtn{
  width:34px;height:30px;padding:0;font-size:9px;
}
.tbtn.muteOn{background:linear-gradient(180deg,#ff5f5f,#c01f1f);border-color:#ff9090;color:#210606}
.tbtn.soloOn{background:linear-gradient(180deg,#ffd15c,#d99a0a);border-color:#ffe08e;color:#241a02}

/* ---------- footer / help ---------- */
footer{margin-top:12px;display:flex;gap:16px;flex-wrap:wrap;align-items:center;color:var(--dim);font-size:10.5px;letter-spacing:.06em}
kbd{background:#1d232b;border:1px solid var(--line2);border-bottom-width:2px;border-radius:4px;padding:1px 5px;color:var(--txt);font-size:10px}
.legend{display:flex;gap:12px;align-items:center}
.legend i{display:inline-block;width:12px;height:12px;border-radius:3px;vertical-align:-2px;margin-right:4px}
</style>
</head>
<body>
<div id="app">
  <header>
    <div class="brand"><b>TR&#8209;8086</b><span>RHYTHM COMPOSER</span></div>

    <div class="transport">
      <button id="btnPlay">&#9654; PLAY</button>
      <button id="btnClear">Clear</button>
      <button id="btnRand">Random</button>
    </div>

    <div class="readout">
      <span class="lbl">TEMPO</span>
      <span class="val" id="bpmVal">120<small> BPM</small></span>
    </div>
    <div class="statusBox"><i class="sLed"></i><span id="status">ready</span></div>
    <div class="stepLed" id="stepLed"></div>

    <div class="knobs">
      <div class="knob">
        <div class="top"><span>SWING</span><b id="swingVal">0%</b></div>
        <input type="range" id="swing" min="0" max="60" step="1" value="0">
      </div>
      <div class="knob">
        <div class="top"><span>MASTER</span><b id="volVal">80%</b></div>
        <input type="range" id="master" min="0" max="100" step="1" value="80">
      </div>
      <div class="knob">
        <div class="top"><span>TEMPO</span><b id="bpmVal2">120</b></div>
        <input type="range" id="bpm" min="60" max="200" step="1" value="120">
      </div>
    </div>
  </header>

  <div class="gridPanel">
    <div class="grid" id="grid"></div>
  </div>

  <footer>
    <span><kbd>Space</kbd> play/stop</span>
    <span><kbd>Click</kbd> toggle</span>
    <span><kbd>Drag</kbd> paint/erase</span>
    <span><kbd>Right&#8209;click</kbd> cycle hit&#8202;/&#8202;accent&#8202;/&#8202;off</span>
    <span><kbd>Shift&#8209;Click</kbd> accent</span>
    <span><kbd>M</kbd> mute &middot; <kbd>Shift&#8209;M</kbd> solo</span>
    <span class="legend">
      <span><i style="background:linear-gradient(180deg,#ff8a55,#e0461c)"></i>hit</span>
      <span><i style="background:linear-gradient(180deg,#ffd98a,#ff9a1f)"></i>accent</span>
      <span><i style="background:#1c2229"></i>off</span>
    </span>
  </footer>
</div>
<script>
/* ============================================================================
   TR-8086 — 16-step drum sequencer
   Vanilla JS + Web Audio API. No assets, no libraries.

   SECTION MAP
     1. CONSTANTS / PATCH PARAMS      2. STATE MODEL
     3. AUDIO ENGINE (context, buses, noise)
     4. INSTRUMENTS (synthesized voices)      5. SCHEDULER (lookahead)
     6. GRID UI (pads, velocity, paint/drag)  7. TRACK CONTROLS
     8. TRANSPORT / CONTROLS       9. PLAYHEAD (audio-clock driven)
    10. PERSISTENCE                11. KEYBOARD / BOOT
   ========================================================================== */
'use strict';

/* ============================ 1. CONSTANTS ================================ */
const C = {
  STEPS: 16,                       // steps per bar
  TRACKS: 6,                       // instrument count
  BPM_MIN: 60, BPM_MAX: 200, BPM_DEF: 120,
  SWING_MAX: 60,                   // percent
  // swing delay (in step-fractions) at 100% swing; 0.55 ~= hard triplet
  // shuffle at the 60% ceiling. Applied per-step in the time calculation.
  SWING_SCALE: 0.55,
  LOOKAHEAD_MS: 25,                // how often the scheduler wakes up
  SCHEDULE_AHEAD: 0.10,            // seconds of audio scheduled in advance
  START_PAD: 0.06,                 // safety offset for the very first step
  VEL_NORMAL: 0.78,                // per-track gain multiplier, normal hit
  VEL_ACCENT: 1.18,                // ...accented hit
  LS_KEY: 'tr8086.pattern.v1'
};

/* Instrument patch parameters (all tunable in one place) */
const PATCH = {
  kick:  { f0:150, f1:48,  pDecay:0.075, decay:0.52, click:0.14 },
  snare: { tone:186, tone2:236, tDecay:0.12, noiseDecay:0.20,
           hp:1150, hpAcc:2400, bp:1800 },
  hatC:  { hp:7600, bp:9500, decay:0.055, q:0.9 },
  hatO:  { hp:6600, bp:8200, decay:0.33,  q:0.8 },
  clap:  { bp:1150, q:1.4, spacing:0.0095, bursts:4, decay:0.20 },
  tom:   { f0:210, f1:118, pDecay:0.10, decay:0.30 }
};

const TRACKS = [
  { id:'kick',   name:'KICK',     icon:'\u25CF', c1:'#ff8a55', c2:'#e0461c', cg:'rgba(255,90,43,.75)', vol:0.95 },
  { id:'snare',  name:'SNARE',    icon:'\u25B2', c1:'#ffd98a', c2:'#f09000', cg:'rgba(255,176,58,.7)',  vol:0.80 },
  { id:'hatC',   name:'HI-HAT',   icon:'\u25C6', c1:'#9fe8ff', c2:'#2b9ec4', cg:'rgba(90,200,255,.7)',  vol:0.55 },
  { id:'hatO',   name:'OPEN HAT', icon:'\u25CB', c1:'#8fe0ff', c2:'#207fa8', cg:'rgba(80,190,255,.6)',  vol:0.38 },
  { id:'clap',   name:'CLAP',     icon:'\u2737', c1:'#ffb2e6', c2:'#c02f8e', cg:'rgba(255,120,200,.65)',vol:0.62 },
  { id:'tom',    name:'TOM',      icon:'\u25AD', c1:'#b6a6ff', c2:'#6a4fd8', cg:'rgba(150,120,255,.65)',vol:0.66 }
];
const DEFAULT_PATTERN = [
  // kick       snare      hatC       hatO       clap       tom
  [2,0,0,0, 2,0,0,0, 2,0,0,0, 2,0,0,0], // 8th-note thump with accents on 1 & 3
  [0,0,0,0, 2,0,0,0, 0,0,0,0, 2,0,0,1],
  [2,0,1,0, 2,0,1,0, 2,0,1,0, 2,0,1,0],
  [0,0,0,0, 0,0,0,0, 0,0,0,0, 0,0,1,0],
  [0,0,0,0, 2,0,0,0, 0,0,0,0, 0,0,0,0],
  [0,0,0,0, 0,0,0,0, 0,0,0,0, 0,0,0,2]
];

/* ============================ 2. STATE =================================== */
const state = {
  pattern: DEFAULT_PATTERN.map(r => r.slice()),   // 0=off 1=hit 2=accent
  bpm: C.BPM_DEF,
  swing: 0,           // percent
  master: 0.80,
  trackVol: TRACKS.map(t => t.vol),
  trackMute: TRACKS.map(() => false),
  trackSolo: TRACKS.map(() => false),
  playing: false
};

/* ============================ 3. AUDIO ENGINE ============================ */
let ac = null;                 // single shared AudioContext
let masterGain, limiter, trackGain = [];
let noiseBuf = null;

function initAudio(){
  if (ac) { if (ac.state === 'suspended') ac.resume(); return ac; }
  ac = new (window.AudioContext || window.webkitAudioContext)();

  // master bus: gain -> soft limiter -> speakers
  masterGain = ac.createGain();
  masterGain.gain.value = state.master;
  limiter = ac.createDynamicsCompressor();
  limiter.threshold.value = -6; limiter.knee.value = 4;
  limiter.ratio.value = 12; limiter.attack.value = 0.002; limiter.release.value = 0.12;
  masterGain.connect(limiter).connect(ac.destination);

  // one gain node per track
  for (let i = 0; i < C.TRACKS; i++){
    const g = ac.createGain();
    g.gain.value = state.trackVol[i];
    g.connect(masterGain);
    trackGain.push(g);
  }

  // shared white-noise buffer (reused by every noise voice)
  const n = Math.floor(ac.sampleRate * 2);
  noiseBuf = ac.createBuffer(1, n, ac.sampleRate);
  const d = noiseBuf.getChannelData(0);
  for (let i = 0; i < n; i++) d[i] = Math.random() * 2 - 1;
  return ac;
}

function noiseSource(t){
  const s = ac.createBufferSource();
  s.buffer = noiseBuf;
  s.loop = true;
  s.playbackRate.value = 0.9 + Math.random() * 0.2;   // tiny variation = life
  const off = ac.sampleRate * 0.05 * Math.random();
  s.start(t, off);
  return s;
}

/* output bus for a track, respecting mute / solo */
function busFor(i){
  const anySolo = state.trackSolo.some(Boolean);
  const audible = anySolo ? state.trackSolo[i] : !state.trackMute[i];
  return { g: trackGain[i], audible, gain: audible ? 1 : 0 };
}

/* ============================ 4. INSTRUMENTS ============================= */
/* Every voice: gain envelopes begin at 0 and end at ~0 → no clicks/pops. */

function voiceKick(t, v, out){
  const P = PATCH.kick;
  const o = ac.createOscillator(), g = ac.createGain();
  o.type = 'sine';
  o.frequency.setValueAtTime(P.f0 * (1 + (v - 1) * 0.12), t);
  o.frequency.exponentialRampToValueAtTime(P.f1, t + P.pDecay);
  g.gain.setValueAtTime(0, t);
  g.gain.linearRampToValueAtTime(0.001, t + 0.002);
  g.gain.linearRampToValueAtTime(1.0 * v, t + 0.006);
  g.gain.exponentialRampToValueAtTime(0.0008, t + P.decay);
  o.connect(g).connect(out);
  o.start(t); o.stop(t + P.decay + 0.02);

  // beater click for attack definition
  const n = noiseSource(t), hp = ac.createBiquadFilter(), ng = ac.createGain();
  hp.type = 'highpass'; hp.frequency.value = 1400;
  ng.gain.setValueAtTime(0, t);
  ng.gain.linearRampToValueAtTime(P.click * v, t + 0.001);
  ng.gain.exponentialRampToValueAtTime(0.0005, t + 0.018);
  n.connect(hp).connect(ng).connect(out);
  n.stop(t + 0.03);
}

function voiceSnare(t, v, out){
  const P = PATCH.snare;
  // tonal body
  const body = ac.createGain();
  body.gain.setValueAtTime(0, t);
  body.gain.linearRampToValueAtTime(0.001, t + 0.002);
  body.gain.linearRampToValueAtTime(0.5 * v, t + 0.004);
  body.gain.exponentialRampToValueAtTime(0.0006, t + P.tDecay);
  body.connect(out);
  [P.tone, P.tone2].forEach((f, k) => {
    const o = ac.createOscillator(), og = ac.createGain();
    o.type = k ? 'sine' : 'triangle';
    o.frequency.setValueAtTime(f * (1 + (v - 1) * 0.06), t);
    o.frequency.exponentialRampToValueAtTime(f * 0.72, t + P.tDecay * 1.1);
    og.gain.value = k ? 0.5 : 1;
    o.connect(og).connect(body);
    o.start(t); o.stop(t + P.tDecay + 0.03);
  });
  // snap: noise through a highpass (brighter when accented) + bandpass body
  const n = noiseSource(t), hp = ac.createBiquadFilter(), bp = ac.createBiquadFilter(),
        ng = ac.createGain();
  hp.type = 'highpass';
  hp.frequency.setValueAtTime(v > 1 ? P.hpAcc : P.hp, t);
  bp.type = 'bandpass'; bp.frequency.value = P.bp; bp.Q.value = 0.7;
  const amp = 0.85 * (v > 1 ? 1.15 : 0.92);
  ng.gain.setValueAtTime(0, t);
  ng.gain.linearRampToValueAtTime(amp * v * 0.7, t + 0.002);
  ng.gain.exponentialRampToValueAtTime(0.0005, t + P.noiseDecay);
  n.connect(hp).connect(bp).connect(ng).connect(out);
  n.stop(t + P.noiseDecay + 0.05);
}

function voiceHat(t, v, out, open){
  const P = open ? PATCH.hatO : PATCH.hatC;
  const n = noiseSource(t), hp = ac.createBiquadFilter(), bp = ac.createBiquadFilter(),
        g = ac.createGain();
  hp.type = 'highpass'; hp.frequency.value = P.hp * (v > 1 ? 1.15 : 1);
  bp.type = 'bandpass'; bp.frequency.value = P.bp; bp.Q.value = P.q;
  const amp = open ? 0.55 : 0.5;
  g.gain.setValueAtTime(0, t);
  g.gain.linearRampToValueAtTime(amp * (v > 1 ? 1.1 : 0.85), t + 0.0015);
  g.gain.exponentialRampToValueAtTime(0.0004, t + P.decay * (v > 1 ? 1.15 : 1));
  n.connect(hp).connect(bp).connect(g).connect(out);
  n.stop(t + P.decay * 1.3 + 0.05);
}

function voiceClap(t, v, out){
  const P = PATCH.clap;
  const n = noiseSource(t), bp = ac.createBiquadFilter(), g = ac.createGain();
  bp.type = 'bandpass'; bp.frequency.value = P.bp; bp.Q.value = P.q;
  const peak = 0.75 * (v > 1 ? 1.1 : 0.85);
  g.gain.setValueAtTime(0, t);
  // several tight bursts in quick succession...
  for (let k = 0; k < P.bursts; k++){
    const bt = t + k * P.spacing;
    const p = peak * (1 - k * 0.13);
    g.gain.linearRampToValueAtTime(0.001, bt);
    g.gain.linearRampToValueAtTime(p, bt + 0.0012);
    g.gain.exponentialRampToValueAtTime(0.02, bt + P.spacing * 0.92);
  }
  // ...then the room tail
  const tt = t + P.bursts * P.spacing;
  g.gain.linearRampToValueAtTime(peak * 0.8, tt);
  g.gain.exponentialRampToValueAtTime(0.0004, tt + P.decay);
  n.connect(bp).connect(g).connect(out);
  n.stop(tt + P.decay + 0.06);
}

function voiceTom(t, v, out){
  const P = PATCH.tom;
  const o = ac.createOscillator(), g = ac.createGain();
  o.type = 'sine';
  o.frequency.setValueAtTime(P.f0 * (1 + (v - 1) * 0.08), t);
  o.frequency.exponentialRampToValueAtTime(P.f1, t + P.pDecay);
  g.gain.setValueAtTime(0, t);
  g.gain.linearRampToValueAtTime(0.001, t + 0.002);
  g.gain.linearRampToValueAtTime(0.8 * v, t + 0.005);
  g.gain.exponentialRampToValueAtTime(0.0006, t + P.decay);
  o.connect(g).connect(out);
  o.start(t); o.stop(t + P.decay + 0.04);
  // a touch of noise transient
  const n = noiseSource(t), bp = ac.createBiquadFilter(), ng = ac.createGain();
  bp.type = 'bandpass'; bp.frequency.value = 900; bp.Q.value = 1.2;
  ng.gain.setValueAtTime(0, t);
  ng.gain.linearRampToValueAtTime(0.16 * v, t + 0.001);
  ng.gain.exponentialRampToValueAtTime(0.0004, t + 0.05);
  n.connect(bp).connect(ng).connect(out);
  n.stop(t + 0.08);
}

const VOICES = [voiceKick, voiceSnare,
  (t,v,o)=>voiceHat(t,v,o,false), (t,v,o)=>voiceHat(t,v,o,true),
  voiceClap, voiceTom];

/* ============================ 5. SCHEDULER =============================== */
/* The classic lookahead pattern: a slow JS timer wakes up every LOOKAHEAD_MS
   and pushes every step that falls inside the next SCHEDULE_AHEAD seconds
   onto the *Web Audio clock*. Audio events are never fired from the timer —
   only scheduled. Step times come from an absolute step counter times the
   step duration, added to a fixed anchor time (no per-tick re-anchoring, so
   timer jitter cannot accumulate). */

let timerId = null;
let stepCounter = 0;        // absolute index since playback started
let nextStepTime = 0;       // audio-clock time of the next UNSWUNG step
let schedTimes = [];        // future {step, time} queue → drives the playhead
const MAX_QUEUE = 64;
let events = [];            // instrumentation: window.__events mirror
let voiceLog = [];          // instrumentation: which voices actually fired

function logVoice(track, vel, time){
  voiceLog.push({ track, vel, time: +time.toFixed(5) });
  if (voiceLog.length > 600) voiceLog.splice(0, voiceLog.length - 600);
}

function secPerStep(){ return 60 / state.bpm / 4; }   // 16th note

function swingOffset(step){
  // every second 16th ("off-beat") is pushed late by a fraction of a step.
  return (step % 2 === 1) ? (state.swing / 100) * C.SWING_SCALE * secPerStep() : 0;
}

function scheduleStep(step, t){
  const s = step % C.STEPS;
  for (let i = 0; i < C.TRACKS; i++){
    const cell = state.pattern[i][s];
    if (!cell) continue;
    const bus = busFor(i);
    if (!bus.audible) continue;               // muted/soloed-out: no voice at all
    const v = cell === 2 ? C.VEL_ACCENT : C.VEL_NORMAL;
    VOICES[i](t, v, bus.g);
    logVoice(i, v, t);
  }
  schedTimes.push({ step: step, time: t });
  if (schedTimes.length > MAX_QUEUE) schedTimes.splice(0, schedTimes.length - MAX_QUEUE);
  events.push({ step: step, time: t, grid: t - swingOffset(step), bpm: state.bpm });
  if (events.length > 400) events.splice(0, events.length - 400);
}

let lastWake = 0;           // for adaptive lookahead under timer throttling
let timerEvery = C.LOOKAHEAD_MS;

function scheduler(){
  const now = ac.currentTime;
  /* Adaptive lookahead: normally we keep SCHEDULE_AHEAD seconds of audio
     queued. If the timer woke up late (background-tab throttling stretches
     setInterval to ~1s), we queue proportionally further ahead so the audio
     never runs dry. Timing itself is unaffected — only how far in advance
     events are placed on the audio clock. */
  const sinceWake = lastWake ? now - lastWake : 0;
  lastWake = now;
  const horizon = Math.min(3, Math.max(C.SCHEDULE_AHEAD, sinceWake * 2.2));

  while (nextStepTime < now + horizon){
    const t = nextStepTime + swingOffset(stepCounter);
    scheduleStep(stepCounter, t);
    nextStepTime += secPerStep();     // absolute grid advance (no swing drift)
    stepCounter++;
  }
}

function armTimer(){
  if (timerId) clearInterval(timerId);
  timerId = setInterval(scheduler, timerEvery);
}

function startTransport(){
  initAudio();
  if (ac.state === 'suspended') ac.resume();
  state.playing = true;
  stepCounter = 0;
  schedTimes = [];
  events = [];
  voiceLog = [];
  curStepAbs = -1;
  lastPainted = -2;
  nextStepTime = ac.currentTime + C.START_PAD;
  lastWake = 0;
  scheduler();
  armTimer();
  uiPlay(true);
}

function stopTransport(){
  state.playing = false;
  if (timerId){ clearInterval(timerId); timerId = null; }
  lastWake = 0;
  // silence anything already scheduled in the lookahead window
  if (ac){
    const now = ac.currentTime;
    masterGain.gain.cancelScheduledValues(now);
    masterGain.gain.setValueAtTime(masterGain.gain.value, now);
    masterGain.gain.linearRampToValueAtTime(0, now + 0.015);
    masterGain.gain.setTargetAtTime(state.master, now + 0.02, 0.005);
  }
  schedTimes = [];
  curStepAbs = -1;
  drawPlayhead(-1);
  uiPlay(false);
}

/* tempo changes: re-anchor the pending step so the grid glides smoothly */
function reanchorTempo(oldSps){
  if (!state.playing || !ac) return;
  const now = ac.currentTime;
  const rem = Math.max(0, (nextStepTime - now) / oldSps);   // step-fractions left
  nextStepTime = now + rem * secPerStep();
}

/* ============================ 6. GRID UI ================================= */
const gridEl = document.getElementById('grid');
let pads = [];               // pads[track][step]
let nums = [];

function buildGrid(){
  gridEl.innerHTML = '';
  pads = TRACKS.map(() => []);
  nums = [];
  muteBtns = [];

  // header row: step numbers + beat grouping
  const head = el('div', 'row head');
  head.appendChild(el('div', 'num'));                      // label spacer
  for (let s = 0; s < C.STEPS; s++){
    const n = el('div', 'num' + (s % 4 === 0 ? ' beat' : ''));
    n.innerHTML = (s % 4 === 0 ? '&bull;' : '') + '<b>' + (s + 1) + '</b>';
    head.appendChild(n); nums.push(n);
  }
  head.appendChild(el('div', 'num'));
  head.appendChild(el('div', 'num'));
  gridEl.appendChild(head);

  TRACKS.forEach((tr, i) => {
    const row = el('div', 'row');
    row.dataset.track = i;

    const lab = el('div', 'tlabel');
    lab.innerHTML = '<span class="ico">' + tr.icon + '</span><span class="nm">' + tr.name + '</span>';
    row.appendChild(lab);

    for (let s = 0; s < C.STEPS; s++){
      const p = el('div', 'pad' + (s % 4 === 0 ? ' beatL' : ''));
      p.style.setProperty('--pc1', tr.c1);
      p.style.setProperty('--pc2', tr.c2);
      p.style.setProperty('--pcg', tr.cg);
      p.dataset.track = i; p.dataset.step = s;
      row.appendChild(p);
      pads[i].push(p);
    }

    // per-track volume
    const volCell = el('div', 'tvol');
    const vs = document.createElement('input');
    vs.type = 'range'; vs.min = 0; vs.max = 100; vs.step = 1;
    vs.value = Math.round(state.trackVol[i] * 100);
    vs.title = tr.name + ' volume';
    vs.addEventListener('input', () => {
      state.trackVol[i] = +vs.value / 100;
      setTrackGain(i);
      save();
    });
    volCell.appendChild(vs);
    row.appendChild(volCell);

    // mute / solo toggle (click = mute, shift-click or dbl = solo)
    const mb = el('button', 'tbtn', 'M');
    mb.title = tr.name + ' mute';
    mb.addEventListener('click', e => {
      if (e.shiftKey){ toggleSolo(i); } else { toggleMute(i); }
    });
    mb.addEventListener('contextmenu', e => { e.preventDefault(); toggleSolo(i); });
    muteBtns.push(mb);
    row.appendChild(mb);

    gridEl.appendChild(row);
  });

  // LED strip in the header
  const led = document.getElementById('stepLed');
  led.innerHTML = '';
  for (let s = 0; s < C.STEPS; s++){
    const d = document.createElement('i');
    if (s % 4 === 0) d.className = 'beat';
    led.appendChild(d);
  }
}

function el(tag, cls, txt){
  const e = document.createElement(tag);
  if (cls) e.className = cls;
  if (txt != null) e.textContent = txt;
  return e;
}

/* ---- pad painting ---- */
function paintCell(i, s, val){
  state.pattern[i][s] = val;
  const p = pads[i][s];
  p.classList.toggle('on', val > 0);
  p.classList.toggle('acc', val === 2);
}

function cellAt(x, y){
  const e = document.elementFromPoint(x, y);
  if (!e) return null;
  const p = e.closest ? e.closest('.pad') : null;
  if (!p) return null;
  return { t: +p.dataset.track, s: +p.dataset.step };
}

let paint = null;            // {mode:0|1|2, lastStart:timestamp}

gridEl.addEventListener('pointerdown', e => {
  const c = cellAt(e.clientX, e.clientY);
  if (!c) return;
  e.preventDefault();
  if (e.button === 2){                       // right-click: cycle 0→1→2→0
    const v = (state.pattern[c.t][c.s] + 1) % 3;
    paintCell(c.t, c.s, v);
    save();
    return;
  }
  // left-click: toggle (0↔1). Note-off cells paint as accents with Shift.
  const cur = state.pattern[c.t][c.s];
  paint = { mode: cur > 0 ? 0 : (e.shiftKey ? 2 : 1), last: c };
  paintCell(c.t, c.s, paint.mode);
  try { gridEl.setPointerCapture(e.pointerId); } catch (err) { /* capture unsupported */ }
});

gridEl.addEventListener('pointermove', e => {
  if (!paint) return;
  const c = cellAt(e.clientX, e.clientY);
  if (!c) return;
  if (c.t === paint.last.t && c.s === paint.last.s) return;
  paint.last = c;
  paintCell(c.t, c.s, paint.mode);
});

function endPaint(){ if (paint){ paint = null; save(); } }
gridEl.addEventListener('pointerup', endPaint);
gridEl.addEventListener('pointercancel', endPaint);
gridEl.addEventListener('contextmenu', e => e.preventDefault());
window.addEventListener('blur', () => { if (paint) endPaint(); });

/* ============================ 7. TRACK CONTROLS ========================== */
let muteBtns = [];

function setTrackGain(i){
  const anySolo = state.trackSolo.some(Boolean);
  const audible = anySolo ? state.trackSolo[i] : !state.trackMute[i];
  const g = trackGain[i];
  if (!g) return;
  const target = state.trackVol[i] * (audible ? 1 : 0);
  g.gain.setTargetAtTime(target, ac.currentTime, 0.01);
}

function refreshTrackRows(){
  const anySolo = state.trackSolo.some(Boolean);
  TRACKS.forEach((tr, i) => {
    const row = gridEl.children[i + 1];
    const audible = anySolo ? state.trackSolo[i] : !state.trackMute[i];
    row.classList.toggle('muted', !audible);
    const b = muteBtns[i];
    b.classList.toggle('muteOn', state.trackMute[i]);
    b.classList.toggle('soloOn', state.trackSolo[i]);
    b.textContent = state.trackSolo[i] ? 'S' : 'M';
    setTrackGain(i);
  });
}
function toggleMute(i){ state.trackMute[i] = !state.trackMute[i]; refreshTrackRows(); save(); }
function toggleSolo(i){ state.trackSolo[i] = !state.trackSolo[i]; refreshTrackRows(); save(); }
/* ============================ 9. PLAYHEAD ================================ */
/* The playhead is read from the AudioContext clock, NOT from the timer and
   NOT from step counting in rAF: we look at the queue of steps that were
   scheduled on the audio clock and pick the latest one whose time has passed. */

let rafId = null;
let lastPainted = -2;
let curStepAbs = -1;        // last step whose scheduled time has passed (audio clock)

function drawPlayhead(step){
  if (step === lastPainted) return;
  lastPainted = step;
  const cur = step < 0 ? -1 : step;
  nums.forEach((n, s) => n.classList.toggle('cur', s === cur));
  const leds = document.getElementById('stepLed').children;
  for (let s = 0; s < C.STEPS; s++) leds[s].classList.toggle('cur', s === cur);
  TRACKS.forEach((tr, i) => {
    for (let s = 0; s < C.STEPS; s++){
      const p = pads[i][s];
      p.classList.toggle('col', s === cur);
      p.classList.toggle('hit', s === cur && state.pattern[i][s] > 0);
    }
  });
}

function frame(){
  rafId = requestAnimationFrame(frame);
  if (!ac || !state.playing) return;
  const now = ac.currentTime;
  while (schedTimes.length && schedTimes[0].time <= now + 0.0002){
    curStepAbs = schedTimes.shift().step;      // most recent step that has begun
  }
  drawPlayhead(curStepAbs < 0 ? -1 : curStepAbs % C.STEPS);
}

function startRaf(){ if (rafId == null) rafId = requestAnimationFrame(frame); }

/* ============================ 8. TRANSPORT / CONTROLS ==================== */
function uiPlay(on){
  const b = document.getElementById('btnPlay');
  b.classList.toggle('on', on);
  b.textContent = on ? '\u25A0 STOP' : '\u25B6 PLAY';
  document.getElementById('status').textContent = on ? 'playing' : 'stopped';
}

const btnPlay = document.getElementById('btnPlay');
btnPlay.addEventListener('click', () => { state.playing ? stopTransport() : startTransport(); });

document.getElementById('btnClear').addEventListener('click', () => {
  for (let i = 0; i < C.TRACKS; i++) for (let s = 0; s < C.STEPS; s++) paintCell(i, s, 0);
  save();
});

document.getElementById('btnRand').addEventListener('click', () => { randomize(); save(); });

function randomize(){
  // musical probabilities: kick on the grid, hats on 8ths, snare on 2 & 4
  const kern = [
    s => (s % 8 === 0 ? 3 : s % 4 === 0 ? 2 : s % 2 === 0 ? 0.25 : 0.05),  // kick
    s => (s === 4 || s === 12 ? 1.0 : s === 15 ? 0.35 : 0.04),            // snare
    s => (s % 2 === 0 ? 0.85 : 0.10),                                     // closed hat
    s => (s === 6 || s === 14 ? 0.22 : 0.03),                             // open hat
    s => (s === 4 || s === 12 ? 0.30 : 0),                                // clap
    s => (s === 10 ? 0.25 : s === 7 ? 0.18 : 0.04)                        // tom
  ];
  for (let i = 0; i < C.TRACKS; i++)
    for (let s = 0; s < C.STEPS; s++){
      let v = 0;
      if (Math.random() < kern[i](s)) v = Math.random() < 0.25 ? 2 : 1;
      paintCell(i, s, v);
    }
}

/* --- tempo --- */
const bpmSlider = document.getElementById('bpm');
function setBpm(v, fromSlider){
  const oldSps = secPerStep();
  state.bpm = Math.max(C.BPM_MIN, Math.min(C.BPM_MAX, Math.round(v)));
  if (!fromSlider) bpmSlider.value = state.bpm;
  document.getElementById('bpmVal').innerHTML = state.bpm + '<small> BPM</small>';
  document.getElementById('bpmVal2').textContent = state.bpm;
  reanchorTempo(oldSps);
  save();
}
bpmSlider.addEventListener('input', () => setBpm(+bpmSlider.value, true));

/* --- swing / master --- */
const swingSlider = document.getElementById('swing');
swingSlider.addEventListener('input', () => {
  state.swing = +swingSlider.value;
  document.getElementById('swingVal').textContent = state.swing + '%';
  save();
});
const masterSlider = document.getElementById('master');
masterSlider.addEventListener('input', () => {
  state.master = +masterSlider.value / 100;
  document.getElementById('volVal').textContent = masterSlider.value + '%';
  if (masterGain) masterGain.gain.setTargetAtTime(state.master, ac.currentTime, 0.01);
  save();
});

/* ============================ 10. PERSISTENCE ============================ */
let saveTimer = null;
function save(){
  if (saveTimer) return;
  saveTimer = setTimeout(() => {
    saveTimer = null;
    const data = {
      pattern: state.pattern, bpm: state.bpm, swing: state.swing, master: state.master,
      trackVol: state.trackVol, trackMute: state.trackMute, trackSolo: state.trackSolo
    };
    try { localStorage.setItem(C.LS_KEY, JSON.stringify(data)); } catch (e) { /* opaque origin */ }
    const st = document.getElementById('status');
    if (st) st.textContent = state.playing ? 'playing \u00b7 saved' : 'saved';
  }, 250);
}

function load(){
  let raw = null;
  try { raw = localStorage.getItem(C.LS_KEY); } catch (e) { raw = null; }
  if (!raw) return false;
  try {
    const d = JSON.parse(raw);
    if (Array.isArray(d.pattern) && d.pattern.length === C.TRACKS){
      state.pattern = d.pattern.map(r => Array.from({ length: C.STEPS }, (_, s) => (r[s] | 0) % 3));
    }
    if (typeof d.bpm === 'number') state.bpm = Math.max(C.BPM_MIN, Math.min(C.BPM_MAX, Math.round(d.bpm)));
    if (typeof d.swing === 'number') state.swing = Math.max(0, Math.min(C.SWING_MAX, d.swing));
    if (typeof d.master === 'number') state.master = Math.max(0, Math.min(1, d.master));
    if (Array.isArray(d.trackVol)) state.trackVol = d.trackVol.slice(0, C.TRACKS).map(Number);
    if (Array.isArray(d.trackMute)) state.trackMute = d.trackMute.slice(0, C.TRACKS).map(Boolean);
    if (Array.isArray(d.trackSolo)) state.trackSolo = d.trackSolo.slice(0, C.TRACKS).map(Boolean);
    return true;
  } catch (e) { return false; }
}

/* ============================ 11. KEYBOARD / BOOT ======================== */
window.addEventListener('keydown', e => {
  if (e.code === 'Space'){
    e.preventDefault();
    state.playing ? stopTransport() : startTransport();
  }
});

function syncUiFromState(){
  bpmSlider.value = state.bpm;
  document.getElementById('bpmVal').innerHTML = state.bpm + '<small> BPM</small>';
  document.getElementById('bpmVal2').textContent = state.bpm;
  swingSlider.value = state.swing;
  document.getElementById('swingVal').textContent = state.swing + '%';
  masterSlider.value = Math.round(state.master * 100);
  document.getElementById('volVal').textContent = Math.round(state.master * 100) + '%';
  TRACKS.forEach((tr, i) => {
    for (let s = 0; s < C.STEPS; s++) paintCell(i, s, state.pattern[i][s]);
    gridEl.children[i + 1].querySelector('input[type=range]').value =
      Math.round(state.trackVol[i] * 100);
  });
}

buildGrid();
load();
syncUiFromState();
refreshTrackRows();
startRaf();

/* test hooks (automated verification) */
window.__seq = {
  state: state,
  get events(){ return events; },
  get voiceLog(){ return voiceLog; },
  get schedTimes(){ return schedTimes; },
  secPerStep: secPerStep,
  get ctx(){ return ac; },
  get now(){ return ac ? ac.currentTime : 0; },
  swingOffset: swingOffset,
  setBpm: setBpm,
  start: startTransport,
  stop: stopTransport,
  randomize: randomize,
  save: save,
  load: load,
  patchCell: paintCell,
  scheduler: scheduler,
  setTimerInterval: (ms) => { timerEvery = ms; if (state.playing) armTimer(); },
  get timerId(){ return timerId; }
};
</script>
</body>
</html>
<!-- agent-meta {"model":"deepseek/deepseek-v4.1-flash","provider":"openrouter","persona":"full","sessionId":"a1019073-3496-4389-a7d6-398140c5b308","tokensIn":4157233,"tokensOut":46062,"tokensTotal":4203295,"cost":0.05027674199999998,"turns":64,"toolCalls":63,"failedToolCalls":1,"timestamp":"2026-09-10T23:52:18.470Z"} -->