← Sequencer results

Sequencer

Qwen 3.8 27B GGUF · 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 39,378 bytes · SHA-256 107f6d1ad935
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PS-16 · Step Sequencer</title>
<style>
/* ============================================================
   PS-16 — CSS
   ============================================================ */
:root{
  --bg:#0a0c11; --panel:#131720; --panel2:#171c27; --line:#232a3a;
  --text:#e8eaf0; --dim:#8b93a7; --faint:#525b70;
  --led:#ff4d5e; --led-off:#3a2327;
  --led-green:#45e07c;
  --grid-gutter:10px; --row-h:56px;
}
*{box-sizing:border-box; margin:0; padding:0;}
html,body{height:100%;}
body{
  background:
    radial-gradient(1200px 600px at 50% -10%, #151b28 0%, rgba(21,27,40,0) 60%),
    repeating-linear-gradient(0deg, rgba(255,255,255,0.012) 0 1px, transparent 1px 3px),
    var(--bg);
  color:var(--text);
  font-family:'Segoe UI', system-ui, -apple-system, sans-serif;
  display:flex; justify-content:center; align-items:flex-start;
  padding:28px 16px 48px;
  user-select:none; -webkit-user-select:none;
}
#app{
  width:980px; max-width:100%;
  background:linear-gradient(180deg,#161b26 0%, #10141d 100%);
  border:1px solid #2a3145; border-radius:14px;
  box-shadow:0 30px 60px rgba(0,0,0,.55), inset 0 1px 0 rgba(255,255,255,.06);
  padding:18px 20px 20px;
  position:relative;
}
#app::before{ /* top edge highlight */
  content:""; position:absolute; inset:0 0 auto 0; height:1px;
  background:linear-gradient(90deg, transparent, rgba(255,255,255,.15), transparent);
  border-radius:14px 14px 0 0;
}

/* ---------- header ---------- */
header{
  display:flex; align-items:center; justify-content:space-between; gap:16px;
  padding-bottom:14px; border-bottom:1px solid var(--line);
}
.brand{display:flex; align-items:baseline; gap:10px;}
.brand .logo{
  font-family:"Consolas","SF Mono",monospace; font-weight:700; font-size:26px;
  letter-spacing:2px; color:#fff;
  text-shadow:0 0 18px rgba(255,77,94,.45);
}
.brand .logo b{color:var(--led);}
.brand .tag{font-size:10px; letter-spacing:3px; color:var(--dim); text-transform:uppercase;}
.transport{display:flex; align-items:center; gap:10px;}
.tbtn{
  width:52px; height:44px; border-radius:9px; border:1px solid #2c3448;
  background:linear-gradient(180deg,#232a3b,#171c29);
  color:var(--dim); font-size:17px; cursor:pointer;
  display:flex; align-items:center; justify-content:center;
  box-shadow:0 3px 0 #0a0d14, inset 0 1px 0 rgba(255,255,255,.08);
  transition:transform .05s, color .15s, box-shadow .15s;
}
.tbtn:hover{color:var(--text);}
.tbtn:active{transform:translateY(2px); box-shadow:0 1px 0 #0a0d14;}
.tbtn.play{
  width:64px; color:var(--led);
  border-color:#5a2a33;
}
.tbtn.play.on{
  color:#1a0507; background:linear-gradient(180deg,#ff6d7c,#e53245);
  box-shadow:0 3px 0 #6d1420, 0 0 22px rgba(255,77,94,.45), inset 0 1px 0 rgba(255,255,255,.4);
  text-shadow:none;
}
.tbtn.play.on:hover{filter:brightness(1.05);}

/* ---------- controls ---------- */
#controls{
  display:flex; align-items:flex-end; gap:22px; flex-wrap:wrap;
  padding:16px 0 14px; border-bottom:1px solid var(--line);
}
.ctl{display:flex; flex-direction:column; gap:7px;}
.ctl .lbl{
  font-size:9px; letter-spacing:2.5px; color:var(--faint); text-transform:uppercase;
  display:flex; justify-content:space-between; gap:8px;
}
.ctl .lbl .val{color:var(--dim); letter-spacing:1px; font-family:"Consolas",monospace; font-size:10px;}
.knob-row{display:flex; align-items:center; gap:12px;}
.knob{
  width:46px; height:46px; border-radius:50%;
  background:
    radial-gradient(circle at 35% 30%, #39415a 0%, #1c2230 55%, #10141d 100%);
  border:1px solid #313a52; position:relative; cursor:ns-resize;
  box-shadow:0 4px 10px rgba(0,0,0,.5), inset 0 1px 1px rgba(255,255,255,.12);
  touch-action:none;
}
.knob:focus-visible{outline:2px solid #45e07c55;}
.knob .ptr{
  position:absolute; left:50%; top:50%; width:3px; height:17px; border-radius:2px;
  background:#ff5d6e; transform-origin:50% 0%;
  box-shadow:0 0 8px rgba(255,77,94,.8);
  transform:translate(-50%,0) rotate(var(--a,0deg));
}
input[type=range]{
  -webkit-appearance:none; appearance:none;
  width:150px; height:26px; background:transparent; cursor:pointer;
}
input[type=range]::-webkit-slider-runnable-track{
  height:6px; border-radius:3px;
  background:linear-gradient(180deg,#0b0e14,#1a2030);
  border:1px solid #262e42; box-shadow:inset 0 1px 3px rgba(0,0,0,.7);
}
input[type=range]::-webkit-slider-thumb{
  -webkit-appearance:none; width:22px; height:16px; border-radius:4px; margin-top:-6px;
  background:linear-gradient(180deg,#3b445e,#232a3c);
  border:1px solid #4a5578; box-shadow:0 2px 4px rgba(0,0,0,.6), inset 0 1px 0 rgba(255,255,255,.18);
}
input[type=range]::-moz-range-track{
  height:6px; border-radius:3px; background:#141926; border:1px solid #262e42;
}
input[type=range]::-moz-range-thumb{
  width:20px; height:14px; border-radius:4px;
  background:linear-gradient(180deg,#3b445e,#232a3c); border:1px solid #4a5578;
}
#bpmRead{
  font-family:"Consolas","SF Mono",monospace; font-size:30px; font-weight:700;
  letter-spacing:1px; color:#45e07c;
  background:#060a08; border:1px solid #1d3a29; border-radius:8px;
  padding:2px 12px 0; min-width:118px; text-align:right;
  text-shadow:0 0 14px rgba(69,224,124,.55);
  box-shadow:inset 0 2px 8px rgba(0,0,0,.8);
  line-height:1.15;
}
#bpmRead small{font-size:9px; letter-spacing:2px; color:#2e7a4c; display:block; text-align:right;}
.spacer{flex:1;}
.mini-btns{display:flex; gap:8px;}
.mbtn{
  padding:10px 14px; border-radius:8px; border:1px solid #2c3448;
  background:linear-gradient(180deg,#202636,#161b28);
  color:var(--dim); font-size:10px; letter-spacing:2px; cursor:pointer; text-transform:uppercase;
  box-shadow:0 3px 0 #0a0d14, inset 0 1px 0 rgba(255,255,255,.07);
}
.mbtn:hover{color:var(--text);}
.mbtn:active{transform:translateY(2px); box-shadow:0 1px 0 #0a0d14;}
.mbtn.danger:hover{color:#ff7583; border-color:#5a2a33;}

/* ---------- grid ---------- */
#gridWrap{padding:16px 0 6px;}
.grid{
  display:grid;
  grid-template-columns:150px repeat(16, minmax(34px,1fr));
  column-gap:var(--grid-gutter);
}
.corner{display:flex; align-items:center; justify-content:flex-end; padding-right:14px;}
.corner .stepleds{display:flex; gap:3px;}
.corner .stepleds i{
  width:5px; height:5px; border-radius:50%;
  background:#1c2333; border:1px solid #2a3348;
  transition:background .08s, box-shadow .08s;
}
.corner .stepleds i.on{background:var(--led-green); box-shadow:0 0 8px rgba(69,224,124,.9); border-color:var(--led-green);}
.corner .stepleds i.on.beat{background:var(--led); box-shadow:0 0 10px rgba(255,77,94,.9); border-color:var(--led);}
.cellhead{
  height:30px; display:flex; align-items:center; justify-content:center;
  font-family:"Consolas",monospace; font-size:11px; color:var(--faint);
  border-bottom:1px solid var(--line); margin-bottom:2px;
}
.cellhead.beat{color:var(--dim); font-weight:700;}
.trackhead{
  display:flex; flex-direction:column; align-items:flex-start; justify-content:center;
  gap:4px; padding-right:14px;
}
.trackhead .tname{font-size:11px; letter-spacing:1.5px; color:var(--text); text-transform:uppercase;}
.trackhead .ticon{font-size:13px; line-height:1;}
.trackhead .tsub{display:flex; gap:6px; align-items:center;}
.tmute,.tsolo{
  font-family:inherit; font-size:9px; letter-spacing:1px; cursor:pointer;
  padding:3px 7px; border-radius:4px; border:1px solid #2c3448;
  background:#171c29; color:var(--faint);
  box-shadow:0 2px 0 #0a0d14;
}
.tmute:hover,.tsolo:hover{color:var(--dim);}
.tmute.on{color:#1a0507; background:var(--led); border-color:#ff7583; box-shadow:0 2px 0 #6d1420, 0 0 10px rgba(255,77,94,.5); font-weight:700;}
.tsolo.on{color:#0a1a0f; background:var(--led-green); border-color:#7dffb0; box-shadow:0 2px 0 #1d5c36, 0 0 10px rgba(69,224,124,.5); font-weight:700;}
.vol{
  -webkit-appearance:none; appearance:none;
  width:100px; height:18px; background:transparent; cursor:pointer;
}
.vol::-webkit-slider-runnable-track{height:4px; border-radius:2px; background:#0d1119; border:1px solid #262e42;}
.vol::-webkit-slider-thumb{
  -webkit-appearance:none; width:12px; height:12px; border-radius:50%; margin-top:-5px;
  background:radial-gradient(circle at 35% 30%, #55618a, #232a3c);
  border:1px solid #4a5578; box-shadow:0 1px 3px rgba(0,0,0,.6);
}
.vol::-moz-range-track{height:4px; border-radius:2px; background:#0d1119; border:1px solid #262e42;}
.vol::-moz-range-thumb{width:11px; height:11px; border-radius:50%; background:#3b445e; border:1px solid #4a5578;}

.track{display:contents;}
.trackhead, .track .cell{grid-row:var(--row);}
.cell{
  height:var(--row-h); border-radius:7px; cursor:pointer; position:relative;
  background:linear-gradient(180deg,#1d2331 0%, #151a25 100%);
  border:1px solid #262e42;
  box-shadow:0 3px 0 #0a0d14, inset 0 1px 0 rgba(255,255,255,.05);
  transition:background .07s, box-shadow .07s, border-color .07s, opacity .15s, transform .05s;
}
.cell.beat{border-left:2px solid #3a4460; border-radius:7px 4px 4px 7px;}
.cell:hover{border-color:#3d4a6b;}
.cell.accent{box-shadow:0 3px 0 #0a0d14, inset 0 1px 0 rgba(255,255,255,.09);}
.cell.accent::after{
  content:""; position:absolute; right:6px; bottom:5px; width:5px; height:5px; border-radius:50%;
  background:var(--c-dim); box-shadow:0 0 5px var(--c-glow);
}
.cell.active{
  background:linear-gradient(180deg, var(--c-hi) 0%, var(--c-lo) 100%);
  border-color:var(--c-edge);
  box-shadow:0 3px 0 #0a0d14, 0 0 14px var(--c-glow), inset 0 1px 0 rgba(255,255,255,.35);
}
.cell.accent.active::after{background:#fff; box-shadow:0 0 8px #fff;}
.track.muted .cell{opacity:.35; filter:saturate(.4);}
.track.muted .trackhead .tname{color:var(--faint);}
.track.dimmed .cell{opacity:.18; filter:saturate(.25);}
.track.dimmed .trackhead{opacity:.4;}
.cell.playhead{
  border-color:#fff;
  box-shadow:0 0 0 1px #fff, 0 0 18px rgba(255,255,255,.55), 0 3px 0 #0a0d14;
  z-index:2;
}
.cell.active.playhead{
  background:linear-gradient(180deg, #ffffff 0%, var(--c-hi) 70%);
  box-shadow:0 0 0 1.5px #fff, 0 0 26px var(--c-glow), 0 0 10px #fff;
}
.cell.paint{border-color:#fff;}

/* ---------- footer ---------- */
footer{
  display:flex; justify-content:space-between; align-items:center; gap:10px; flex-wrap:wrap;
  padding-top:14px; margin-top:8px; border-top:1px solid var(--line);
  font-size:10px; color:var(--faint); letter-spacing:.5px;
}
footer .hint b{color:var(--dim); font-weight:600;}
#status{font-family:"Consolas",monospace; color:var(--dim); letter-spacing:1px;}
#status.live{color:var(--led); text-shadow:0 0 8px rgba(255,77,94,.5);}
</style>
</head>
<body>
<div id="app">

  <!-- ======================= HEADER ======================= -->
  <header>
    <div class="brand">
      <div class="logo">PS<b>·</b>16</div>
      <div class="tag">Step Sequencer<br>Six Synthesized Voices</div>
    </div>
    <div class="transport">
      <button class="tbtn" id="btnStop" title="Stop (Space)">■</button>
      <button class="tbtn play" id="btnPlay" title="Play (Space)">▶</button>
    </div>
  </header>

  <!-- ======================= CONTROLS ======================= -->
  <div id="controls">
    <div class="ctl">
      <div class="lbl">Tempo <span class="val" id="bpmVal">120 BPM</span></div>
      <div class="knob-row">
        <div class="knob" id="knobBpm" tabindex="0" role="slider" aria-label="Tempo" aria-valuemin="60" aria-valuemax="200" aria-valuenow="120"><div class="ptr"></div></div>
        <div id="bpmRead">120<small>BPM</small></div>
      </div>
    </div>
    <div class="ctl">
      <div class="lbl">Swing <span class="val" id="swingVal">0%</span></div>
      <div class="knob-row">
        <div class="knob" id="knobSwing" tabindex="0" role="slider" aria-label="Swing" aria-valuemin="0" aria-valuemax="60" aria-valuenow="0"><div class="ptr"></div></div>
      </div>
    </div>
    <div class="ctl">
      <div class="lbl">Master <span class="val" id="masterVal">80%</span></div>
      <div class="knob-row">
        <div class="knob" id="knobMaster" tabindex="0" role="slider" aria-label="Master volume" aria-valuemin="0" aria-valuemax="100" aria-valuenow="80"><div class="ptr"></div></div>
      </div>
    </div>
    <div class="spacer"></div>
    <div class="mini-btns">
      <button class="mbtn" id="btnRandom">Random</button>
      <button class="mbtn danger" id="btnClear">Clear</button>
    </div>
  </div>

  <!-- ======================= GRID ======================= -->
  <div id="gridWrap"><div class="grid" id="grid"></div></div>

  <!-- ======================= FOOTER ======================= -->
  <footer>
    <div class="hint">
      <b>Click</b> cell = on &nbsp;·&nbsp; <b>Click again</b> = accent &nbsp;·&nbsp; <b>Click again</b> = off &nbsp;·&nbsp;
      <b>Right-click</b> = accent toggle &nbsp;·&nbsp; <b>Drag</b> = paint / erase &nbsp;·&nbsp; <b>Space</b> = play/stop
    </div>
    <div id="status">STOPPED</div>
  </footer>

</div>

<script>
'use strict';
/* ============================================================
   1. TUNABLE CONSTANTS
   ============================================================ */
const CFG = {
  STEPS: 16,
  BPM_MIN: 60, BPM_MAX: 200,
  SCHEDULER_INTERVAL_MS: 25,   // how often the scheduler wakes (ms)
  LOOKAHEAD_S: 0.12,           // how far ahead notes are scheduled (s)
  SAVE_DEBOUNCE_MS: 400,
  STORAGE_KEY: 'ps16-pattern-v1',
  // Velocity levels: 0=off, 1=normal, 2=accent
  VEL_NORMAL: 0.9, VEL_ACCENT: 1.35,
  // Instrument patch parameters
  PATCH: {
    kick:  { f0: 165, f1: 46, fEnv: 0.10, amp: 0.95, aDec: 0.42, click: 0.22, clickDur: 0.012 },
    snare: { body: 190, bodyDecay: 0.16, noiseAmp: 0.55, noiseDecay: 0.19, hp: 1600, bp: 3200, q: 0.9,
             brightNormal: 2600, brightAccent: 4800 },
    hatC:  { hp: 7800, amp: 0.5, decay: 0.05, bp: 11000, q: 1.2 },
    hatO:  { hp: 8200, amp: 0.42, decay: 0.55, bp: 11000, q: 1.1 },
    clap:  { bp: 1100, q: 1.6, amp: 0.6, decay: 0.13, offsets: [0, 0.018, 0.036] },
    tom:   { f0: 131, f1: 62, fEnv: 0.14, amp: 0.8, aDec: 0.48 } // G3-ish, musically a 5th above kick's E
  }
};

const TRACKS = [
  { id:'kick',  name:'Kick',  icon:'◉', color:'#ff5d6e', hi:'#ff8d99', lo:'#c2273a', edge:'#ff7583', glow:'rgba(255,93,110,.45)', dim:'rgba(255,93,110,.8)' },
  { id:'snare', name:'Snare', icon:'◍', color:'#ffb14d', hi:'#ffc778', lo:'#d17a17', edge:'#ffc06e', glow:'rgba(255,177,77,.45)',  dim:'rgba(255,177,77,.8)' },
  { id:'hatc',  name:'Hat C', icon:'◦', color:'#5fd0ff', hi:'#8fdcff', lo:'#1e8fc7', edge:'#7fdcff', glow:'rgba(95,208,255,.45)',  dim:'rgba(95,208,255,.8)' },
  { id:'hato',  name:'Hat O', icon:'◌', color:'#45e07c', hi:'#7defa6', lo:'#1d9e52', edge:'#7defa6', glow:'rgba(69,224,124,.45)',  dim:'rgba(69,224,124,.8)' },
  { id:'clap',  name:'Clap',  icon:'◈', color:'#c792ff', hi:'#dcb6ff', lo:'#8a4fd1', edge:'#d3aaff', glow:'rgba(199,146,255,.45)', dim:'rgba(199,146,255,.8)' },
  { id:'tom',   name:'Tom',   icon:'◎', color:'#ff8fd8', hi:'#ffbadf', lo:'#c74ba3', edge:'#ffb0e2', glow:'rgba(255,143,216,.45)', dim:'rgba(255,143,216,.8)' }
];

/* ============================================================
   2. DEFAULT PATTERN  (a grooving house/rock hybrid)
   ============================================================ */
function defaultPattern(){
  const P = {};
  TRACKS.forEach(t => P[t.id] = new Array(CFG.STEPS).fill(0));
  // kick:   1 . . . | . . . K | 1 . . . | . . . .
  P.kick  = [1,0,0,0, 0,0,0,1, 1,0,0,0, 0,0,0,0];
  // snare:  backbeats on 3 & 7 (accented on 7)
  P.snare = [0,0,0,0, 1,0,0,0, 0,0,0,0, 2,0,0,0];
  // hat c:  driving 8ths, accents on the downbeats
  P.hatc  = [1,0,1,0, 1,0,1,0, 1,0,1,0, 1,0,1,0];
  // hat o:  open on the "and" before the beat, classic
  P.hato  = [0,0,0,0, 0,0,0,1, 0,0,0,0, 0,0,0,1];
  // clap:   layered ghost on the snare 7
  P.clap  = [0,0,0,0, 0,0,0,0, 0,0,0,0, 0,0,1,0];
  // tom:    little fill at the end of the bar
  P.tom   = [0,0,0,0, 0,0,0,0, 0,0,0,0, 0,0,1,2];
  return P;
}
const DEFAULT_STATE = {
  bpm: 120, swing: 0, master: 0.8,
  vols: { kick:0.9, snare:0.75, hatc:0.55, hato:0.5, clap:0.7, tom:0.7 },
  mutes: { kick:false, snare:false, hatc:false, hato:false, clap:false, tom:false },
  solos: { kick:false, snare:false, hatc:false, hato:false, clap:false, tom:false },
  pattern: defaultPattern()
};

/* ============================================================
   3. AUDIO ENGINE — one shared AudioContext, created on first
      user gesture. All voices are scheduled at exact future
      times with source.start(t). Envelopes start/end at zero.
   ============================================================ */
let actx = null;
let masterGain = null, comp = null, analyser = null;
let noiseBuf = null;

function initAudio(){
  if (actx) { if (actx.state === 'suspended') actx.resume(); return; }
  actx = new (window.AudioContext || window.webkitAudioContext)();
  masterGain = actx.createGain();
  masterGain.gain.value = state.master;
  comp = actx.createDynamicsCompressor();
  comp.threshold.value = -14; comp.knee.value = 24; comp.ratio.value = 3.5;
  comp.attack.value = 0.003; comp.release.value = 0.12;
  masterGain.connect(comp); comp.connect(actx.destination);
  // analysis tap (for level metering / testing)
  analyser = actx.createAnalyser();
  analyser.fftSize = 2048;
  comp.connect(analyser);
  // pre-render 2s of white noise (shared by all noise voices)
  const len = actx.sampleRate * 2;
  noiseBuf = actx.createBuffer(1, len, actx.sampleRate);
  const d = noiseBuf.getChannelData(0);
  for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1;
}

// tiny helpers
function makeNoise(t, dur){
  const src = actx.createBufferSource();
  src.buffer = noiseBuf;
  src.start(t, Math.random() * 1.2, dur + 0.05);
  return src;
}
function envGain(t, peak, decay, attack){
  // gain node with a zero-start envelope (no clicks)
  const g = actx.createGain();
  const a = attack || 0.001;
  g.gain.setValueAtTime(0.0001, t);
  g.gain.linearRampToValueAtTime(peak, t + a);
  g.gain.exponentialRampToValueAtTime(0.0001, t + a + decay);
  return g;
}

const velocityFor = lvl => lvl >= 2 ? CFG.VEL_ACCENT : CFG.VEL_NORMAL;

/* ---------------- voice patches ---------------- */
function playKick(t, vel){
  const p = CFG.PATCH.kick;
  const osc = actx.createOscillator();
  osc.type = 'sine';
  osc.frequency.setValueAtTime(p.f0, t);
  osc.frequency.exponentialRampToValueAtTime(p.f1, t + p.fEnv);
  const g = actx.createGain();
  g.gain.setValueAtTime(0.0001, t);
  g.gain.linearRampToValueAtTime(p.amp * vel, t + 0.002);
  g.gain.exponentialRampToValueAtTime(0.0001, t + p.aDec);
  osc.connect(g); g.connect(masterGain);
  osc.start(t); osc.stop(t + p.aDec + 0.05);
  // click transient
  const n = makeNoise(t, p.clickDur);
  const ng = actx.createGain();
  ng.gain.setValueAtTime(0.0001, t);
  ng.gain.linearRampToValueAtTime(p.click * vel, t + 0.0005);
  ng.gain.exponentialRampToValueAtTime(0.0001, t + p.clickDur + 0.004);
  const hp = actx.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 1200;
  n.connect(hp); hp.connect(ng); ng.connect(masterGain);
  n.stop(t + p.clickDur + 0.05);
}

function playSnare(t, vel, accent){
  const p = CFG.PATCH.snare;
  // tonal body
  const osc = actx.createOscillator(); osc.type = 'triangle';
  osc.frequency.setValueAtTime(p.body, t);
  osc.frequency.exponentialRampToValueAtTime(p.body * 0.72, t + p.bodyDecay);
  const og = envGain(t, 0.5 * vel, p.bodyDecay, 0.0015);
  osc.connect(og); og.connect(masterGain);
  osc.start(t); osc.stop(t + p.bodyDecay + 0.05);
  // noise burst — accent brightens the bandpass (timbre change)
  const n = makeNoise(t, p.noiseDecay + 0.05);
  const hp = actx.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = p.hp;
  const bp = actx.createBiquadFilter(); bp.type = 'bandpass';
  bp.frequency.value = accent ? p.brightAccent : p.brightNormal; bp.Q.value = p.q;
  const ng = envGain(t, p.noiseAmp * vel, p.noiseDecay, 0.001);
  n.connect(hp); hp.connect(bp); bp.connect(ng); ng.connect(masterGain);
  n.stop(t + p.noiseDecay + 0.1);
}

function playHat(t, vel, open){
  const p = open ? CFG.PATCH.hato : CFG.PATCH.hatC;
  const n = makeNoise(t, p.decay + 0.05);
  const hp = actx.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = p.hp;
  const bp = actx.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = p.bp; bp.Q.value = p.q;
  const ng = envGain(t, p.amp * vel, p.decay, 0.0008);
  n.connect(hp); hp.connect(bp); bp.connect(ng); ng.connect(masterGain);
  n.stop(t + p.decay + 0.1);
}

function playClap(t, vel){
  const p = CFG.PATCH.clap;
  p.offsets.forEach((off, i) => {
    const tt = t + off;
    const n = makeNoise(tt, 0.06);
    const bp = actx.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = p.bp; bp.Q.value = p.q;
    const decay = i === p.offsets.length - 1 ? p.decay : 0.03;
    const ng = envGain(tt, (i === 2 ? 1 : 0.6) * p.amp * vel, decay, 0.0008);
    n.connect(bp); bp.connect(ng); ng.connect(masterGain);
    n.stop(tt + decay + 0.08);
  });
}

function playTom(t, vel){
  const p = CFG.PATCH.tom;
  const osc = actx.createOscillator(); osc.type = 'sine';
  osc.frequency.setValueAtTime(p.f0, t);
  osc.frequency.exponentialRampToValueAtTime(p.f1, t + p.fEnv);
  const g = actx.createGain();
  g.gain.setValueAtTime(0.0001, t);
  g.gain.linearRampToValueAtTime(p.amp * vel, t + 0.003);
  g.gain.exponentialRampToValueAtTime(0.0001, t + p.aDec);
  osc.connect(g); g.connect(masterGain);
  osc.start(t); osc.stop(t + p.aDec + 0.05);
}

const VOICES = { kick: playKick, snare: (t,v,a)=>playSnare(t,v,a), hatc: (t,v)=>playHat(t,v,false),
                 hato: (t,v)=>playHat(t,v,true),  clap: playClap, tom: playTom };

/* ============================================================
   4. STATE + PERSISTENCE (localStorage)
   ============================================================ */
let state = loadState();

function loadState(){
  try {
    const raw = localStorage.getItem(CFG.STORAGE_KEY);
    if (!raw) return structuredClone(DEFAULT_STATE);
    const s = JSON.parse(raw);
    const base = structuredClone(DEFAULT_STATE);
    base.bpm = clamp(Number(s.bpm) || base.bpm, CFG.BPM_MIN, CFG.BPM_MAX);
    base.swing = clamp(Number(s.swing) || 0, 0, 60);
    base.master = clamp(Number(s.master) == null ? base.master : Number(s.master), 0, 1);
    TRACKS.forEach(tr => {
      base.vols[tr.id] = clamp(Number(s.vols?.[tr.id]) == null ? base.vols[tr.id] : Number(s.vols[tr.id]), 0, 1);
      base.mutes[tr.id] = !!s.mutes?.[tr.id];
      base.solos[tr.id] = !!s.solos?.[tr.id];
      if (Array.isArray(s.pattern?.[tr.id])) {
        base.pattern[tr.id] = s.pattern[tr.id].slice(0, CFG.STEPS)
          .map(v => v === 1 || v === 2 ? v : 0);
        while (base.pattern[tr.id].length < CFG.STEPS) base.pattern[tr.id].push(0);
      }
    });
    return base;
  } catch(e){ return structuredClone(DEFAULT_STATE); }
}

let saveTimer = null;
function saveState(){
  clearTimeout(saveTimer);
  saveTimer = setTimeout(() => {
    try { localStorage.setItem(CFG.STORAGE_KEY, JSON.stringify(state)); } catch(e){}
  }, CFG.SAVE_DEBOUNCE_MS);
}
const clamp = (v,a,b) => Math.min(b, Math.max(a, v));

/* ============================================================
   5. SCHEDULER — the heart. Lookahead scheduling pattern:
      a 25ms setInterval only *decides* what to schedule; the
      Web Audio clock decides *when*. Step times are derived
      from an absolute step counter:
          t = nextNoteTime + (step % 16) * stepDur + swingOffset(step)
      so there is no accumulated drift. Swing is applied in
      this time math — never by nudging the playhead.
   ============================================================ */
const seq = {
  playing: false,
  currentStep: 0,      // absolute step counter (monotonic)
  nextNoteTime: 0,     // absolute audio-clock time of the next grid step
  timerId: null,
  stepQueue: [],       // scheduled steps {step, time} — playhead source
  _lastStep: -1,       // last step whose time has passed (playhead)
  _lastStepTime: 0
};

const stepDur = () => 60 / state.bpm / 4;   // 16th-note duration in seconds
// Swing: off-beat 16ths (odd steps) are delayed by swing% of one step.
const swingDelay = (step) => (step % 2 === 1) ? (state.swing / 100) * stepDur() : 0;
const stepTime = (step, base) => base + (step % CFG.STEPS) * stepDur() + swingDelay(step);

window.__sched = { events: [], lastDelta: 0 };  // instrumentation for testing

function scheduleStep(step, t){
  const trackStep = step % CFG.STEPS;
  const anySolo = TRACKS.some(tr => state.solos[tr.id]);
  TRACKS.forEach(tr => {
    const lvl = state.pattern[tr.id][trackStep];
    if (!lvl) return;
    if (anySolo ? !state.solos[tr.id] : state.mutes[tr.id]) return;
    const vel = velocityFor(lvl) * state.vols[tr.id];
    if (vel <= 0.001) return;
    VOICES[tr.id](t, vel, lvl === 2);
    window.__sched.events.push({ step, track: tr.id, time: t, vel });
  });
}

// If the browser suspends the context while the tab is hidden, resume on return
document.addEventListener('visibilitychange', () => {
  if (!document.hidden && actx && actx.state === 'suspended') actx.resume();
});

function schedulerTick(){
  if (!seq.playing) return;
  // In background tabs the timer is throttled (~1/s) but the audio clock
  // keeps running — widen the lookahead so one tick always covers the gap.
  const lookahead = document.hidden ? 2.0 : CFG.LOOKAHEAD_S;
  const horizon = actx.currentTime + lookahead;
  while (seq.nextNoteTime < horizon) {
    // nextNoteTime = absolute time of the current BAR start.
    // t is derived from it — no accumulation, no drift.
    const t = stepTime(seq.currentStep, seq.nextNoteTime);
    scheduleStep(seq.currentStep, t);
    seq.stepQueue.push({ step: seq.currentStep, time: t }); // for the playhead
    const ev = window.__sched.events[window.__sched.events.length - 1];
    if (ev) {
      // uniformity instrumentation: delta between consecutive scheduled step times
      if (seq._schedPrev !== undefined) {
        window.__sched.lastDelta = t - seq._schedPrev;
        window.__sched.deltas = window.__sched.deltas || [];
        window.__sched.deltas.push(window.__sched.lastDelta);
        if (window.__sched.deltas.length > 200) window.__sched.deltas.shift();
      }
      seq._schedPrev = t;
    }
    seq.currentStep++;
    if (seq.currentStep % CFG.STEPS === 0) seq.nextNoteTime += CFG.STEPS * stepDur(); // advance the bar
  }
}

function startPlayback(){
  initAudio();
  if (actx.state === 'suspended') actx.resume();
  if (seq.playing) return;
  seq.playing = true;
  seq.currentStep = 0;          // Play always restarts cleanly on step 1
  seq.nextNoteTime = actx.currentTime + 0.08;  // small grace period
  seq._lastStep = -1; seq._lastStepTime = 0;
  seq.stepQueue = [];
  seq._schedPrev = undefined;
  window.__sched.events = []; window.__sched.deltas = [];
  seq.timerId = setInterval(schedulerTick, CFG.SCHEDULER_INTERVAL_MS);
  schedulerTick();              // prime immediately
  updateTransportUI();
  setStatus('LIVE');
}

function stopPlayback(){
  if (!seq.playing) return;
  seq.playing = false;
  clearInterval(seq.timerId); seq.timerId = null;
  // Kill everything already scheduled — stop is immediate, no tail:
  // swap out the master gain node so all pending sources dangle.
  if (actx) {
    try { masterGain.disconnect(); } catch(e){}
    masterGain = actx.createGain();
    masterGain.gain.value = state.master;
    masterGain.connect(comp);
  }
  updateTransportUI();
  setStatus('STOPPED');
  clearPlayhead();
}

/* ============================================================
   6. PLAYHEAD — driven from the audio clock, not the timer.
      rAF only reads actx.currentTime and finds which scheduled
      step's time we have crossed.
   ============================================================ */
function clearPlayhead(){
  document.querySelectorAll('.cell.playhead').forEach(el => el.classList.remove('playhead'));
  document.querySelectorAll('.stepleds i.on').forEach(el => el.classList.remove('on'));
  seq._lastStep = -1;
}

function rAFPlayhead(){
  requestAnimationFrame(rAFPlayhead);
  if (!seq.playing || !actx) return;
  const now = actx.currentTime;
  // advance through every scheduled step whose time has passed —
  // including silent ones, so the playhead never stalls
  let last = seq._lastStep;
  while (seq.stepQueue.length && seq.stepQueue[0].time <= now) {
    last = seq.stepQueue[0].step;
    seq.stepQueue.shift();
  }
  if (last < 0 || last === seq._lastStep) return;
  seq._lastStep = last;
  const col = last % CFG.STEPS;
  document.querySelectorAll('.cell.playhead').forEach(el => el.classList.remove('playhead'));
  document.querySelectorAll(`.cell[data-col="${col}"]`).forEach(el => el.classList.add('playhead'));
  document.querySelectorAll('.stepleds i').forEach((el, i) => {
    el.classList.toggle('on', i === col);
    el.classList.toggle('beat', i === col && col % 4 === 0);
  });
}
requestAnimationFrame(rAFPlayhead);

/* ============================================================
   7. UI BUILD
   ============================================================ */
const gridEl = document.getElementById('grid');
const cellEls = {};   // cellEls[trackId][step]

function buildGrid(){
  gridEl.innerHTML = '';
  // corner: step LEDs
  const corner = document.createElement('div');
  corner.className = 'corner';
  const leds = document.createElement('div'); leds.className = 'stepleds';
  for (let s = 0; s < CFG.STEPS; s++){ const i = document.createElement('i'); leds.appendChild(i); }
  corner.appendChild(leds);
  gridEl.appendChild(corner);
  // step number headers
  for (let s = 0; s < CFG.STEPS; s++){
    const h = document.createElement('div');
    h.className = 'cellhead' + (s % 4 === 0 ? ' beat' : '');
    h.textContent = s + 1;
    gridEl.appendChild(h);
  }
  // tracks
  TRACKS.forEach((tr, ti) => {
    const row = document.createElement('div');
    row.className = 'track'; row.dataset.track = tr.id; row.style.setProperty('--row', ti + 2);
    const head = document.createElement('div');
    head.className = 'trackhead';
    head.style.setProperty('--row', ti + 2);
    head.innerHTML = `
      <div class="tname"><span class="ticon" style="color:${tr.color}">${tr.icon}</span> ${tr.name}</div>
      <div class="tsub">
        <button class="tmute" title="Mute">M</button>
        <button class="tsolo" title="Solo">S</button>
      </div>
      <input class="vol" type="range" min="0" max="100" value="${Math.round(state.vols[tr.id]*100)}" title="${tr.name} volume">`;
    row.appendChild(head);
    cellEls[tr.id] = [];
    for (let s = 0; s < CFG.STEPS; s++){
      const c = document.createElement('div');
      c.className = 'cell' + (s % 4 === 0 ? ' beat' : '');
      c.dataset.track = tr.id; c.dataset.step = s; c.dataset.col = s;
      c.style.setProperty('--c-hi', tr.hi);
      c.style.setProperty('--c-lo', tr.lo);
      c.style.setProperty('--c-edge', tr.edge);
      c.style.setProperty('--c-glow', tr.glow);
      c.style.setProperty('--c-dim', tr.dim);
      row.appendChild(c);
      cellEls[tr.id][s] = c;
    }
    gridEl.appendChild(row);
    // track head controls
    const muteBtn = head.querySelector('.tmute');
    const soloBtn = head.querySelector('.tsolo');
    const vol = head.querySelector('.vol');
    muteBtn.addEventListener('click', e => { e.stopPropagation(); state.mutes[tr.id] = !state.mutes[tr.id]; refreshTrackUI(tr.id); saveState(); });
    soloBtn.addEventListener('click', e => { e.stopPropagation(); state.solos[tr.id] = !state.solos[tr.id]; refreshTrackUI(tr.id); saveState(); });
    vol.addEventListener('input', () => { state.vols[tr.id] = vol.value / 100; saveState(); });
  });
  refreshAllUI();
}

function refreshCell(trId, s){
  const el = cellEls[trId][s];
  const lvl = state.pattern[trId][s];
  el.classList.toggle('active', lvl > 0);
  el.classList.toggle('accent', lvl === 2);
}
function refreshTrackUI(trId){
  for (let s = 0; s < CFG.STEPS; s++) refreshCell(trId, s);
  const row = gridEl.querySelector(`.track[data-track="${trId}"]`);
  row.classList.toggle('muted', state.mutes[trId]);
  row.classList.toggle('dimmed', TRACKS.some(t => t.id !== trId && state.solos[t.id]));
  const head = row.querySelector('.trackhead');
  head.querySelector('.tmute').classList.toggle('on', state.mutes[trId]);
  head.querySelector('.tsolo').classList.toggle('on', state.solos[trId]);
  head.querySelector('.vol').value = Math.round(state.vols[trId] * 100);
}
function refreshAllUI(){ TRACKS.forEach(t => refreshTrackUI(t.id)); }

/* ---------------- grid interaction: click / drag / right-click ---------------- */
let painting = false, paintMode = 0;   // 0 = off, 1 = on, 2 = accent

function cellFromEvent(e){
  const el = e.target.closest('.cell');
  return el ? { trId: el.dataset.track, s: +el.dataset.step, el } : null;
}
function applyPaint(trId, s, mode){
  const cur = state.pattern[trId][s];
  if (mode === 2){ state.pattern[trId][s] = cur === 2 ? 1 : 2; }
  else if (mode === 1){ state.pattern[trId][s] = cur === 1 ? 2 : 1; } // click cycles on→accent
  else { state.pattern[trId][s] = 0; }
  refreshCell(trId, s);
}
gridEl.addEventListener('pointerdown', e => {
  const c = cellFromEvent(e);
  if (!c) return;
  initAudio();
  if (e.button === 2){ // right-click: accent toggle (no paint drag)
    e.preventDefault();
    applyPaint(c.trId, c.s, 2); saveState(); return;
  }
  if (e.button !== 0) return;
  painting = true;
  const cur = state.pattern[c.trId][c.s];
  paintMode = cur === 1 ? 2 : (cur === 2 ? 0 : 1);  // decide the cycle step this press starts on
  applyPaint(c.trId, c.s, paintMode);
  c.el.setPointerCapture?.(e.pointerId);
  saveState();
});
gridEl.addEventListener('pointermove', e => {
  if (!painting) return;
  const c = cellFromEvent(e);
  if (!c) return;
  if (state.pattern[c.trId][c.s] !== (paintMode === 0 ? 0 : paintMode)) applyPaint(c.trId, c.s, paintMode);
  saveState();
});
window.addEventListener('pointerup', () => { painting = false; });
gridEl.addEventListener('contextmenu', e => e.preventDefault());

/* ============================================================
   8. CONTROLS — knobs (drag), sliders, transport, buttons
   ============================================================ */
function makeKnob(el, { min, max, value, onChange }){
  const ptr = el.querySelector('.ptr');
  function set(v, fire){
    v = clamp(Math.round(v), min, max);
    el.setAttribute('aria-valuenow', v);
    const frac = (v - min) / (max - min);
    ptr.style.setProperty('--a', (-135 + frac * 270) + 'deg');
    if (fire) onChange(v);
    return v;
  }
  el._set = set;
  let dragging = false, startY = 0, startV = value;
  el.addEventListener('pointerdown', e => {
    dragging = true; startY = e.clientY; startV = Number(el.getAttribute('aria-valuenow')) || value;
    el.setPointerCapture(e.pointerId);
    e.preventDefault();
  });
  el.addEventListener('pointermove', e => {
    if (!dragging) return;
    const dy = startY - e.clientY;
    set(startV + (dy / 150) * (max - min), true);
  });
  el.addEventListener('pointerup', () => dragging = false);
  el.addEventListener('wheel', e => {
    e.preventDefault();
    set(Number(el.getAttribute('aria-valuenow')) + (e.deltaY < 0 ? 1 : -1) * (max > 100 ? 1 : 1), true);
  }, { passive: false });
  el.addEventListener('dblclick', () => set(value, true));
  set(value, false);
  return el;
}

const bpmVal = document.getElementById('bpmVal');
const bpmRead = document.getElementById('bpmRead');
const swingVal = document.getElementById('swingVal');
const masterVal = document.getElementById('masterVal');

function setBpm(v){
  state.bpm = clamp(Math.round(v), CFG.BPM_MIN, CFG.BPM_MAX);
  bpmVal.textContent = state.bpm + ' BPM';
  bpmRead.innerHTML = state.bpm + '<small>BPM</small>';
  knobBpm._set(state.bpm, false);
  saveState();
}
function setSwing(v){
  state.swing = clamp(Math.round(v), 0, 60);
  swingVal.textContent = state.swing + '%';
  knobSwing._set(state.swing, false);
  saveState();
}
function setMaster(v){
  state.master = clamp(v, 0, 1);
  masterVal.textContent = Math.round(state.master * 100) + '%';
  knobMaster._set(Math.round(state.master * 100), false);
  if (actx && masterGain) masterGain.gain.setTargetAtTime(state.master, actx.currentTime, 0.02);
  saveState();
}

const knobBpm = makeKnob(document.getElementById('knobBpm'),
  { min: CFG.BPM_MIN, max: CFG.BPM_MAX, value: state.bpm, onChange: v => setBpm(v) });
const knobSwing = makeKnob(document.getElementById('knobSwing'),
  { min: 0, max: 60, value: state.swing, onChange: v => setSwing(v) });
const knobMaster = makeKnob(document.getElementById('knobMaster'),
  { min: 0, max: 100, value: Math.round(state.master * 100), onChange: v => setMaster(v / 100) });

// keyboard: arrow keys on focused knobs
[['knobBpm', 1], ['knobSwing', 1], ['knobMaster', 1]].forEach(([id, step]) => {
  const el = document.getElementById(id);
  el.addEventListener('keydown', e => {
    if (e.key === 'ArrowUp' || e.key === 'ArrowRight'){ e.preventDefault(); el._set(Number(el.getAttribute('aria-valuenow')) + step, true); }
    if (e.key === 'ArrowDown' || e.key === 'ArrowLeft'){ e.preventDefault(); el._set(Number(el.getAttribute('aria-valuenow')) - step, true); }
  });
});

/* ---------------- transport & buttons ---------------- */
const btnPlay = document.getElementById('btnPlay');
const btnStop = document.getElementById('btnStop');
const statusEl = document.getElementById('status');

function updateTransportUI(){
  btnPlay.classList.toggle('on', seq.playing);
  btnPlay.textContent = seq.playing ? '❚❚' : '▶';
}
function setStatus(txt){
  statusEl.textContent = txt;
  statusEl.classList.toggle('live', txt === 'LIVE');
}
function togglePlay(){ seq.playing ? stopPlayback() : startPlayback(); }

btnPlay.addEventListener('click', togglePlay);
btnStop.addEventListener('click', stopPlayback);
window.addEventListener('keydown', e => {
  if (e.code === 'Space' && !/INPUT|BUTTON/.test(document.activeElement?.tagName || '')){
    e.preventDefault(); togglePlay();
  }
});

document.getElementById('btnClear').addEventListener('click', () => {
  TRACKS.forEach(t => { state.pattern[t.id] = new Array(CFG.STEPS).fill(0); refreshTrackUI(t.id); });
  saveState();
});
document.getElementById('btnRandom').addEventListener('click', () => {
  // musical random: kick on beats, snares on backbeats, hats busy
  const dens = { kick: 0.42, snare: 0.18, hatc: 0.62, hato: 0.12, clap: 0.10, tom: 0.15 };
  TRACKS.forEach(tr => {
    for (let s = 0; s < CFG.STEPS; s++){
      let on = Math.random() < dens[tr.id];
      if (tr.id === 'kick' && s % 4 === 0) on = Math.random() < 0.85;
      if (tr.id === 'snare' && s % 4 === 2) on = Math.random() < 0.8;
      const acc = on && Math.random() < 0.22 ? 2 : (on ? 1 : 0);
      state.pattern[tr.id][s] = acc;
    }
    refreshTrackUI(tr.id);
  });
  saveState();
});

/* ============================================================
   9. BOOT
   ============================================================ */
buildGrid();
// knobs first (setters reference them), then apply persisted values
setBpm(state.bpm); setSwing(state.swing); setMaster(state.master);
setStatus('STOPPED');
</script>
</body>
</html>