← Mike Higgins Home
Custom Code Experiment •

RD-6

Source Code View HTML / CSS / JavaScript
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>cobalt/8 rhythm programmer</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Archivo:wdth,wght@62..125,400..800&display=swap" rel="stylesheet">
<style>
:root{
  box-sizing:border-box;
  padding-top:env(safe-area-inset-top,0px);
  padding-bottom:env(safe-area-inset-bottom,0px);
  --page:#d5dbe5;
  --ink:#152035;
  --muted:#53607a;
  --line:#b5bfce;
  --cell:#f7f9fc;
  --cell-line:#c2cad7;
  --cell-on:#2453b3;
  --cur:rgba(255,170,20,.30);
  /* the instrument itself keeps its colours in both themes */
  --blue-hi:#3a6dd2;
  --blue:#2554b5;
  --blue-lo:#1d469c;
  --silk:#eef3fb;
  --silk-dim:#b4c6e8;
  --led:#ff3b22;
  --led-off:#4d1a12;
  --amber:#ffb01a;
  --pedal:#f2761f;
  --pedal-lo:#d95f0e;
  --font:'Archivo','Helvetica Neue',Arial,sans-serif;
}
@media (prefers-color-scheme: dark){
  :root:not([data-theme="light"]){
    --page:#0e1421; --ink:#e0e7f3; --muted:#8f9bb2; --line:#2a3550;
    --cell:#172034; --cell-line:#2c3955; --cell-on:#5d8dff; --cur:rgba(255,176,26,.22);
  }
}
:root[data-theme="dark"]{
  --page:#0e1421; --ink:#e0e7f3; --muted:#8f9bb2; --line:#2a3550;
  --cell:#172034; --cell-line:#2c3955; --cell-on:#5d8dff; --cur:rgba(255,176,26,.22);
}
html{scroll-padding-top:env(safe-area-inset-top,0px)}
*,*::before,*::after{box-sizing:inherit}
body{margin:0;background:var(--page);color:var(--ink);font-family:var(--font);-webkit-font-smoothing:antialiased}
button{font-family:inherit}
.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}

.stage{max-width:1220px;margin:0 auto;padding:clamp(14px,3vw,40px) clamp(10px,2.5vw,28px) 48px}

/* ---------- enclosure ---------- */
.machine{
  position:relative;color:var(--silk);
  border-radius:18px;padding:24px 28px 30px;
  background:
    repeating-linear-gradient(90deg,rgba(255,255,255,.028) 0 1px,transparent 1px 3px),
    linear-gradient(180deg,var(--blue-hi) 0%,var(--blue) 16%,var(--blue-lo) 100%);
  box-shadow:
    inset 0 1px 0 rgba(255,255,255,.4),
    inset 0 -3px 0 rgba(0,0,0,.25),
    0 34px 60px -24px rgba(8,22,58,.6),
    0 10px 20px -6px rgba(8,22,58,.35);
  user-select:none;-webkit-user-select:none;
}
.screw{position:absolute;width:11px;height:11px;border-radius:50%;
  background:radial-gradient(circle at 40% 35%,#e9edf3,#8e98a8 70%);
  box-shadow:0 1px 1px rgba(0,0,0,.45)}
.screw::after{content:'';position:absolute;left:2px;right:2px;top:4.5px;height:2px;background:#5f6878;transform:rotate(35deg)}
.screw.tl{left:10px;top:10px}.screw.tr{right:10px;top:10px}
.screw.bl{left:10px;bottom:10px}.screw.br{right:10px;bottom:10px}

.legend{font-size:12px;font-stretch:75%;font-weight:500;color:var(--silk-dim);letter-spacing:.01em;line-height:1}

/* ---------- top row ---------- */
.top{display:grid;grid-template-columns:1fr auto auto;align-items:center;gap:22px 34px;margin:4px 6px 22px}
.logo{display:block;font-size:clamp(28px,3.4vw,40px);font-weight:800;font-stretch:125%;letter-spacing:-.015em;line-height:.9;color:var(--silk)}
.sub{display:block;margin-top:8px}
.display{background:#140807;border-radius:9px;padding:10px 14px 9px;
  box-shadow:inset 0 3px 8px rgba(0,0,0,.85),0 1px 0 rgba(255,255,255,.28)}
.display svg{display:block;width:150px;height:auto}
.seg polygon,.seg circle{fill:#3c130d}
.seg .on{fill:var(--led);filter:drop-shadow(0 0 2.5px rgba(255,59,34,.9))}
.masters{display:flex;gap:20px;align-items:flex-end}

/* ---------- knobs ---------- */
.kw{display:flex;flex-direction:column;align-items:center;gap:5px}
.knob{position:relative;width:var(--ks,48px);height:var(--ks,48px);border-radius:50%;touch-action:none;cursor:ns-resize;outline:none}
.knob.lg{--ks:62px}
.knob .ticks{position:absolute;inset:0;width:100%;height:100%;stroke:var(--silk);stroke-width:2.4;stroke-linecap:round;opacity:.8}
.knob .cap{position:absolute;inset:15%;border-radius:50%;
  background:radial-gradient(circle at 38% 30%,#5d636f 0,#262a32 46%,#111318 100%);
  box-shadow:0 3px 4px rgba(0,0,0,.5),0 0 0 2px rgba(0,0,0,.32),inset 0 1px 1px rgba(255,255,255,.28)}
.knob .ind{position:absolute;left:50%;top:7%;width:3px;margin-left:-1.5px;height:34%;border-radius:2px;background:#f5f2e9}
.knob:focus-visible{box-shadow:0 0 0 2px var(--amber)}

/* ---------- voices ---------- */
.voices{display:grid;grid-template-columns:minmax(0,1fr) 236px;gap:20px;align-items:stretch}
.bank{display:grid;grid-template-columns:repeat(9,minmax(0,1fr));
  border:1.5px solid rgba(238,243,251,.5);border-radius:12px;padding:6px 4px}
.strip{position:relative;display:flex;flex-direction:column;align-items:center;gap:12px;padding:12px 2px 10px}
.strip+.strip::before{content:'';position:absolute;left:0;top:14px;bottom:14px;width:1px;background:rgba(238,243,251,.2)}
.strip.acc{background:rgba(0,0,0,.08);border-radius:8px}
.kspace{height:var(--ks,48px);width:48px;display:flex;align-items:center;justify-content:center}
.kspace+.legend{visibility:hidden}
.vsel{display:flex;flex-direction:column;align-items:center;gap:7px;background:none;border:0;color:var(--silk);cursor:pointer;padding:4px 2px;border-radius:6px;margin-top:auto}
.vsel .pad{width:38px;height:22px;border-radius:5px;display:grid;place-items:center;
  background:linear-gradient(#30343c,#17191e);box-shadow:0 3px 0 #06070a,0 4px 6px rgba(0,0,0,.35),inset 0 1px 0 rgba(255,255,255,.16)}
.vsel:active .pad{transform:translateY(2px);box-shadow:0 1px 0 #06070a,inset 0 1px 0 rgba(255,255,255,.16)}
.vsel .vname{font-size:14px;font-stretch:75%;font-weight:600;white-space:nowrap}
.vsel:focus-visible{outline:2px solid var(--amber);outline-offset:2px}
.strip.muted .vname{text-decoration:line-through;opacity:.65}
.strip.muted .kw{opacity:.5}

.led{display:block;width:8px;height:8px;border-radius:50%;background:var(--led-off);box-shadow:inset 0 1px 1px rgba(0,0,0,.6)}
.led.on{background:var(--led);box-shadow:0 0 7px 1px rgba(255,59,34,.85)}
.led.blink{animation:blink .5s steps(2,start) infinite}
@keyframes blink{to{background:var(--led);box-shadow:0 0 7px 1px rgba(255,59,34,.85)}}

/* ---------- drive pedal ---------- */
.pedal{position:relative;border-radius:14px;color:#1d1206;padding:16px 16px 14px;
  background:linear-gradient(180deg,#f78a33,var(--pedal) 30%,var(--pedal-lo));
  box-shadow:inset 0 2px 0 rgba(255,255,255,.4),inset 0 -3px 0 rgba(0,0,0,.18),0 7px 14px rgba(0,0,0,.35);
  display:grid;grid-template-columns:repeat(3,1fr);grid-template-areas:"k k k" "n n n" "f f f";gap:12px 6px}
.pk{grid-area:k;display:flex;justify-content:space-between}
.pedal .knob{--ks:50px}
.pedal .knob .ticks{stroke:#2a1708}
.pedal .knob .cap{background:radial-gradient(circle at 50% 50%,#dfe3e8 0,#aab1bb 27%,#16181c 29%,#23262c 100%)}
.pedal .knob .ind{background:#f5f2e9;top:2%;height:22%}
.pedal .legend{color:#3a2210;font-weight:600}
.pname{grid-area:n;display:flex;align-items:center;gap:8px;padding:2px 2px 0}
.ptitle{white-space:nowrap;font-size:20px;font-weight:800;font-stretch:125%;letter-spacing:-.01em;line-height:1}
.psub{font-size:12px;font-stretch:75%;font-weight:600;margin-left:auto;opacity:.75;text-align:right}
.foot{grid-area:f;height:68px;border:0;border-radius:9px;cursor:pointer;
  background:repeating-linear-gradient(0deg,#2c2f35 0 4px,#212328 4px 7px);
  box-shadow:0 5px 0 #0c0d10,0 7px 12px rgba(0,0,0,.4),inset 0 1px 0 rgba(255,255,255,.12)}
.foot:active{transform:translateY(3px);box-shadow:0 2px 0 #0c0d10,inset 0 1px 0 rgba(255,255,255,.12)}
.foot:focus-visible{outline:2px solid #1d1206;outline-offset:3px}

/* ---------- sequencer ---------- */
.seq{margin-top:22px;display:grid;gap:22px}
.ctrls{display:flex;flex-wrap:wrap;gap:18px 30px;align-items:flex-end}
.cgroup{display:flex;flex-direction:column;gap:10px}
.cgroup.grow{flex:1}
.ccap{display:flex;align-items:center;gap:8px}
.ccap::before,.ccap::after{content:'';flex:1;height:1.5px;min-width:12px;background:rgba(238,243,251,.45)}
.cbtns{display:flex;gap:14px;justify-content:space-between}
.rb{display:flex;flex-direction:column;align-items:center;gap:7px;background:none;border:0;color:var(--silk);cursor:pointer;padding:2px 2px 0;min-width:44px;border-radius:6px}
.rb .cap{width:30px;height:30px;border-radius:50%;
  background:radial-gradient(circle at 40% 34%,#40454f,#17191e 72%);
  box-shadow:0 3px 0 #06070a,0 5px 7px rgba(0,0,0,.4),inset 0 1px 0 rgba(255,255,255,.2)}
.rb:active .cap{transform:translateY(2px);box-shadow:0 1px 0 #06070a,inset 0 1px 0 rgba(255,255,255,.2)}
.rb .lbl{font-size:13px;font-stretch:75%;font-weight:600;white-space:nowrap}
.rb:focus-visible{outline:2px solid var(--amber);outline-offset:2px}

.steps{display:grid;grid-template-columns:118px 1fr;gap:18px;align-items:start}
.startbtn{height:70px;border:0;border-radius:9px;cursor:pointer;color:#f2f4f7;
  display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;
  background:linear-gradient(#383c45,#1b1d22);
  box-shadow:0 4px 0 #050608,0 7px 12px rgba(0,0,0,.4),inset 0 1px 0 rgba(255,255,255,.18);
  font-size:14px;font-weight:700;font-stretch:87.5%}
.startbtn:active{transform:translateY(3px);box-shadow:0 1px 0 #050608,inset 0 1px 0 rgba(255,255,255,.18)}
.startbtn:focus-visible{outline:2px solid var(--amber);outline-offset:3px}
.keys{display:grid;grid-template-columns:repeat(4,1fr);gap:14px}
.kgroup{display:grid;grid-template-columns:repeat(4,1fr);gap:7px}
.key{position:relative;height:70px;border:0;border-radius:8px;cursor:pointer;padding:9px 0 9px;
  display:flex;flex-direction:column;align-items:center;justify-content:space-between;
  font-size:14px;font-weight:700;font-stretch:75%;color:#433c2e;
  background:linear-gradient(#f7f2e6,#dcd3be);
  box-shadow:0 4px 0 #a1977f,0 7px 11px rgba(0,0,0,.35),inset 0 1px 0 #fff}
.kgroup:nth-child(even) .key{color:#1c2533;background:linear-gradient(#c6cedb,#a0acbe);box-shadow:0 4px 0 #6c788a,0 7px 11px rgba(0,0,0,.35),inset 0 1px 0 rgba(255,255,255,.7)}
.key:active{transform:translateY(3px);box-shadow:0 1px 0 #a1977f,0 2px 4px rgba(0,0,0,.3)}
.kgroup:nth-child(even) .key:active{box-shadow:0 1px 0 #6c788a,0 2px 4px rgba(0,0,0,.3)}
.key .led{width:18px;height:6px;border-radius:2px}
.key.dim{filter:saturate(.4) brightness(.78)}
.key::after{content:'';position:absolute;left:30%;right:30%;bottom:-15px;height:3px;border-radius:2px;background:rgba(0,0,0,.28)}
.key.cur::after{background:var(--amber);box-shadow:0 0 8px 2px rgba(255,176,26,.75)}
.key:focus-visible{outline:2px solid var(--amber);outline-offset:3px}

/* ---------- pattern sheet ---------- */
.sheet{margin-top:40px}
.sheet-head{display:flex;flex-wrap:wrap;align-items:flex-end;gap:12px 28px}
.sheet h2{margin:0;font-size:clamp(26px,3vw,34px);font-weight:750;font-stretch:125%;letter-spacing:-.015em;line-height:1}
.help{margin:10px 0 0;color:var(--muted);font-size:15px;line-height:1.5;max-width:64ch}
.len{margin-left:auto;display:flex;align-items:center;gap:10px;font-size:15px;font-weight:600}
.len output{min-width:2ch;text-align:center;font-variant-numeric:tabular-nums;font-size:18px}
.tb{font-size:14px;font-weight:600;color:var(--ink);background:transparent;border:1px solid var(--line);border-radius:7px;padding:7px 12px;cursor:pointer}
.tb:hover{background:color-mix(in srgb,var(--ink) 7%,transparent)}
.tb:focus-visible{outline:2px solid var(--cell-on);outline-offset:2px}
.tb.sq{width:34px;padding:7px 0}
.tb.warn{border-color:#d9452b;color:#d9452b}
.gscroll{overflow-x:auto;margin-top:18px;padding-bottom:4px}
.grid{display:grid;grid-template-columns:148px repeat(16,minmax(30px,1fr));gap:4px;min-width:660px}
.ghead{text-align:center;font-size:12px;font-weight:600;font-stretch:75%;color:var(--muted);padding:2px 0 4px;border-bottom:2px solid transparent}
.ghead.cur{color:var(--ink);border-bottom-color:var(--amber)}
.glabel{display:flex;align-items:center;gap:4px;padding-right:6px}
.gname{flex:1;min-width:0;text-align:left;background:none;border:0;color:var(--ink);font-size:15px;font-weight:600;font-stretch:87.5%;padding:5px 8px;border-radius:5px;cursor:pointer;white-space:nowrap}
.glabel.sel .gname{background:var(--cell-on);color:#fff}
.gmute{width:26px;height:26px;flex:none;border-radius:5px;border:1px solid var(--line);background:transparent;color:var(--muted);font-size:12px;font-weight:700;cursor:pointer}
.gmute[aria-pressed="true"]{background:#d9452b;border-color:#d9452b;color:#fff}
.gname:focus-visible,.gmute:focus-visible,.gcell:focus-visible{outline:2px solid var(--cell-on);outline-offset:1px}
.gcell{position:relative;height:32px;padding:0;border-radius:5px;cursor:pointer;
  background:var(--cell);border:1px solid var(--cell-line)}
.gcell.alt{background:color-mix(in srgb,var(--cell) 88%,var(--cell-on) 12%)}
.gcell.on::before{content:'';position:absolute;left:50%;top:50%;width:16px;height:16px;margin:-8px 0 0 -8px;border-radius:50%;background:var(--cell-on)}
.gcell.acc.on::before{background:#e0391f;border-radius:3px;width:14px;height:14px;margin:-7px 0 0 -7px}
.gcell.out{opacity:.32}
.gcell.cur{box-shadow:inset 0 0 0 40px var(--cur)}
.grow-muted{opacity:.45}

.song{margin-top:34px;border-top:1px solid var(--line);padding-top:22px}
.song-head{display:flex;flex-wrap:wrap;align-items:center;gap:10px 18px}
.song h3{margin:0;font-size:20px;font-weight:750;font-stretch:112.5%}
.song-meta{color:var(--muted);font-size:15px}
.song-actions{margin-left:auto;display:flex;gap:8px}
.chips{list-style:none;display:flex;flex-wrap:wrap;gap:6px;padding:0;margin:14px 0 0}
.chips li{font-size:14px;font-weight:600;font-stretch:75%;padding:6px 9px;border:1px solid var(--line);border-radius:5px;font-variant-numeric:tabular-nums}
.chips li.now{background:var(--cell-on);border-color:var(--cell-on);color:#fff}
.chips .empty{border:0;padding:0;font-size:15px;font-weight:400;font-stretch:100%;color:var(--muted)}
.foot-note{margin-top:34px;border-top:1px solid var(--line);padding-top:18px;display:flex;flex-wrap:wrap;gap:12px 24px;align-items:center;color:var(--muted);font-size:14px;line-height:1.5}
.foot-note p{margin:0;max-width:72ch}
.foot-note .tb{margin-left:auto}
kbd{font-family:inherit;font-weight:700;font-size:13px;border:1px solid var(--line);border-bottom-width:2px;border-radius:4px;padding:0 5px;color:var(--ink)}

/* ---------- responsive ---------- */
@media (max-width:1080px){
  .voices{grid-template-columns:1fr}
  .pedal{grid-template-columns:auto 1fr 180px;grid-template-areas:"k n f";align-items:center;gap:14px 22px}
  .pk{gap:18px}
  .pname{flex-direction:column;align-items:flex-start;gap:6px}
  .psub{margin-left:0}
  .foot{height:62px}
}
@media (max-width:780px){
  .machine{padding:20px 14px 24px;border-radius:14px}
  .top{grid-template-columns:auto 1fr;margin:2px 4px 18px}
  .brand{grid-column:1/-1}
  .masters{justify-content:flex-end;gap:14px}
  .bank{grid-template-columns:repeat(5,minmax(0,1fr));row-gap:6px}
  .strip+.strip::before{display:none}
  .pedal{grid-template-columns:1fr;grid-template-areas:"k" "n" "f"}
  .pname{flex-direction:row;align-items:center}
  .psub{margin-left:auto}
  .steps{grid-template-columns:1fr}
  .startbtn{height:52px;flex-direction:row}
  .keys{grid-template-columns:repeat(2,1fr);row-gap:26px}
  .cbtns{gap:8px}
  .rb{min-width:40px}
}
@media (max-width:460px){
  .bank{grid-template-columns:repeat(3,minmax(0,1fr))}
  .top{grid-template-columns:1fr}
  .display{justify-self:start}
  .masters{justify-content:space-between}
  .knob{--ks:44px}
  .knob.lg{--ks:54px}
  .kgroup{gap:5px}
  .key{height:60px}
  .ctrls{gap:16px}
  .grid{grid-template-columns:104px repeat(16,minmax(30px,1fr));min-width:600px}
  .gname{font-size:14px;padding:5px 6px}
}
@media (prefers-reduced-motion:reduce){
  .led.blink{animation:none;background:#a8301d}
}
</style>
</head>
<body>
<main class="stage">
  <div class="machine" id="machine">
    <span class="screw tl"></span><span class="screw tr"></span><span class="screw bl"></span><span class="screw br"></span>

    <div class="top">
      <div class="brand">
        <span class="logo">cobalt/8</span>
        <span class="sub legend">eight-voice analog-style rhythm programmer</span>
      </div>
      <div class="display"><svg id="seg" class="seg" viewBox="0 0 186 64" role="img" aria-label="Display"></svg></div>
      <div class="masters" id="masters"></div>
    </div>

    <div class="voices">
      <div class="bank" id="bank"></div>
      <div class="pedal">
        <div class="pk" id="pedalKnobs"></div>
        <div class="pname"><span class="led" id="distLed"></span><span class="ptitle">drive unit</span><span class="psub">hard-clip distortion</span></div>
        <button class="foot" id="foot" aria-pressed="false" aria-label="Distortion on/off"></button>
      </div>
    </div>

    <div class="seq">
      <div class="ctrls">
        <div class="cgroup"><span class="ccap legend">mode</span><div class="cbtns" id="modeBtns"></div></div>
        <div class="cgroup"><span class="ccap legend">bank</span><div class="cbtns" id="bankBtns"></div></div>
        <div class="cgroup grow"><span class="ccap legend">edit</span><div class="cbtns" id="fnBtns"></div></div>
      </div>
      <div class="steps">
        <button class="startbtn" id="startBtn"><span class="led" id="startLed"></span><span>start / stop</span></button>
        <div class="keys" id="keys"></div>
      </div>
    </div>
  </div>

  <section class="sheet" aria-label="Pattern sheet">
    <div class="sheet-head">
      <div>
        <h2 id="patTitle">Pattern I-01</h2>
        <p class="help" id="help"></p>
      </div>
      <div class="len">
        <span>Length</span>
        <button class="tb sq" id="lenDown" aria-label="Shorter pattern">−</button>
        <output id="lenOut">16</output>
        <button class="tb sq" id="lenUp" aria-label="Longer pattern">+</button>
      </div>
    </div>
    <div class="gscroll"><div class="grid" id="grid"></div></div>

    <div class="song">
      <div class="song-head">
        <h3>Song</h3>
        <span class="song-meta" id="songMeta"></span>
        <div class="song-actions">
          <button class="tb" id="songUndo">Remove last</button>
          <button class="tb" id="songClear">Clear song</button>
        </div>
      </div>
      <ol class="chips" id="chips"></ol>
    </div>

    <div class="foot-note">
      <p><kbd>Space</kbd> starts and stops. <kbd>1</kbd>–<kbd>8</kbd> play the voices, and record them live in step write while running. Hold <kbd>Shift</kbd> (or tap the shift button) with a voice to mute it, with a step key to set length, or with clear to wipe the whole pattern. Drag knobs up and down; double-click resets them. Your patterns are kept in this browser.</p>
      <button class="tb" id="restore">Restore demo patterns</button>
    </div>
  </section>
</main>

<script>
(() => {
'use strict';

/* =================== data model =================== */
const VOICES = [
  {id:'bd', name:'bass drum',  tweak:'decay',  code:'bd'},
  {id:'sd', name:'snare',      tweak:'snappy', code:'Sd'},
  {id:'lt', name:'low tom',    tweak:'tune',   code:'Lt'},
  {id:'ht', name:'high tom',   tweak:'tune',   code:'Ht'},
  {id:'cy', name:'cymbal',     tweak:'decay',  code:'Cy'},
  {id:'oh', name:'open hat',   tweak:'decay',  code:'OH'},
  {id:'ch', name:'closed hat', tweak:'decay',  code:'CH'},
  {id:'cp', name:'clap',       tweak:'decay',  code:'CP'},
];
const VID = VOICES.map(v => v.id);
const ROWS = ['acc', ...VID];
const NAME = {acc:'accent'}; VOICES.forEach(v => NAME[v.id] = v.name);
const CODE = {acc:'AC'}; VOICES.forEach(v => CODE[v.id] = v.code);
const BANKS = ['I', 'II'];
const KEY = 'cobalt8.v1';
const pad2 = n => String(n).padStart(2, '0');
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
const $ = s => document.querySelector(s);
const el = (t, c, txt) => { const e = document.createElement(t); if (c) e.className = c; if (txt != null) e.textContent = txt; return e; };

const blank = () => { const p = {len:16}; ROWS.forEach(r => p[r] = new Array(16).fill(0)); return p; };
const fromSpec = spec => {
  const p = blank();
  for (const [r, s] of Object.entries(spec)) for (let i = 0; i < 16; i++) p[r][i] = s[i] === 'x' ? 1 : 0;
  return p;
};
function demoPatterns() {
  const b1 = [
    {acc:'x...x...x...x...', bd:'x...x...x...x...', sd:'....x.......x...', ch:'x.x.x.x.x.x.x.x.'},
    {acc:'x...x...x...x...', bd:'x...x...x...x...', cp:'....x.......x...', oh:'..x...x...x...x.', ch:'...x...x...x...x'},
    {acc:'x...x...x...x..x', bd:'x...x...x...x...', sd:'....x.......x..x', ch:'xxxxxxxxxxxxxxxx', lt:'.............x..', ht:'..............x.'},
    {acc:'x.....x.....x...', bd:'x.....x...x.....', sd:'....x.......x...', cp:'....x.......x...', ch:'x.x.x.x.x.x.x.xx', ht:'.........x......'},
    {acc:'....x.......x...', bd:'x.x.......x.....', sd:'....x..x.x..x...', ch:'x.x.x.x.x.x.x...', oh:'..............x.'},
    {acc:'x.......x.......', bd:'x..x..x.x..x..x.', cp:'....x.......x...', cy:'x...............', ch:'..x...x...x...x.'},
    {acc:'x...x...x...x...', bd:'x...x...x...x...', ch:'..x...x...x...x.', lt:'.......x........', ht:'...............x', cp:'............x...'},
    {acc:'x.......x.....xx', bd:'x...............', cy:'x...............', ht:'x.x.x.x.........', lt:'........x.x.x.x.', sd:'..............xx'},
  ].map(fromSpec);
  while (b1.length < 16) b1.push(blank());
  const b2 = Array.from({length:16}, blank);
  return [b1, b2];
}
const demoSong = () => [[0,0],[0,0],[0,1],[0,1],[0,2],[0,2],[0,2],[0,7]];

const S = {
  bpm:124, swing:50, volume:80, accent:60,
  level:{bd:85, sd:72, lt:68, ht:64, cy:58, oh:60, ch:62, cp:68},
  tweak:{bd:35, sd:55, lt:50, ht:50, cy:50, oh:40, ch:30, cp:45},
  dist:{on:false, drive:45, tone:55, level:55},
  mutes:{},
  patterns:demoPatterns(), song:demoSong(),
  mode:'pattern', bank:0, sel:[0,0], play:[0,0], queued:null, voice:'bd',
  playing:false, step:-1, songPos:0, shift:false, clip:null,
};
const DEF = {level:{...S.level}, tweak:{...S.tweak}, dist:{...S.dist}};
let physShift = false;

const getPat = ([b, i]) => S.patterns[b][i];
const patName = ([b, i]) => `${BANKS[b]}-${pad2(i + 1)}`;
const isSong = () => S.mode === 'song' || S.mode === 'songwrite';

/* ---------- persistence (this browser only) ---------- */
function cleanPattern(p) {
  const out = blank();
  if (!p || typeof p !== 'object') return out;
  out.len = clamp(parseInt(p.len, 10) || 16, 1, 16);
  ROWS.forEach(r => { if (Array.isArray(p[r])) for (let i = 0; i < 16; i++) out[r][i] = p[r][i] ? 1 : 0; });
  return out;
}
function load() {
  try {
    const raw = localStorage.getItem(KEY);
    if (!raw) return;
    const d = JSON.parse(raw);
    if (Array.isArray(d.patterns) && d.patterns.length === 2)
      S.patterns = d.patterns.map(bank => Array.from({length:16}, (_, i) => cleanPattern(Array.isArray(bank) ? bank[i] : null)));
    if (Array.isArray(d.song))
      S.song = d.song.filter(e => Array.isArray(e) && (e[0] === 0 || e[0] === 1) && e[1] >= 0 && e[1] < 16).slice(0, 256);
    const num = (v, a, b, f) => (typeof v === 'number' && isFinite(v)) ? clamp(v, a, b) : f;
    S.bpm = num(d.bpm, 40, 300, S.bpm);
    S.swing = num(d.swing, 50, 75, S.swing);
    S.volume = num(d.volume, 0, 100, S.volume);
    S.accent = num(d.accent, 0, 100, S.accent);
    VID.forEach(v => {
      if (d.level) S.level[v] = num(d.level[v], 0, 100, S.level[v]);
      if (d.tweak) S.tweak[v] = num(d.tweak[v], 0, 100, S.tweak[v]);
      if (d.mutes && d.mutes[v]) S.mutes[v] = true;
    });
    if (d.dist) {
      S.dist.on = !!d.dist.on;
      ['drive','tone','level'].forEach(k => S.dist[k] = num(d.dist[k], 0, 100, S.dist[k]));
    }
  } catch (e) { /* storage unavailable or corrupt: keep defaults */ }
}
let saveT = null;
function save() {
  clearTimeout(saveT);
  saveT = setTimeout(() => {
    try {
      localStorage.setItem(KEY, JSON.stringify({
        patterns:S.patterns, song:S.song, bpm:S.bpm, swing:S.swing, volume:S.volume, accent:S.accent,
        level:S.level, tweak:S.tweak, dist:S.dist, mutes:S.mutes,
      }));
    } catch (e) { /* ignore */ }
  }, 350);
}

load();

/* =================== sound engine =================== */
/*ENGINE-START*/
const A = {ctx:null, out:{}, noise:null, soft:null, clip:null, lastOH:null};
const METAL = [205.3, 304.4, 369.6, 522.7, 540, 800];

function curves() {
  const n = 2048, soft = new Float32Array(n), clip = new Float32Array(n);
  for (let i = 0; i < n; i++) {
    const x = i / (n - 1) * 2 - 1;
    soft[i] = Math.tanh(1.6 * x) / Math.tanh(1.6);
    clip[i] = x >= 0 ? Math.tanh(2.4 * x) : Math.tanh(1.9 * x) * 0.9;
  }
  A.soft = soft; A.clip = clip;
}
function buildGraph(ctx, dest) {
  A.ctx = ctx;
  if (!A.soft) curves();
  const len = ctx.sampleRate * 2, nb = ctx.createBuffer(1, len, ctx.sampleRate), d = nb.getChannelData(0);
  for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1;
  A.noise = nb;

  A.bus = ctx.createGain();
  A.master = ctx.createGain();
  A.dry = ctx.createGain();
  A.wet = ctx.createGain();
  const inHP = filt('highpass', 35, 0.7);
  A.drive = ctx.createGain();
  A.shaper = ctx.createWaveShaper(); A.shaper.curve = A.clip; A.shaper.oversample = '4x';
  const lp = filt('lowpass', 950, 0.5), hp = filt('highpass', 900, 0.5), fizz = filt('lowpass', 7600, 0.6);
  A.lpG = ctx.createGain(); A.hpG = ctx.createGain(); A.dLevel = ctx.createGain();
  const comp = ctx.createDynamicsCompressor();
  comp.threshold.value = -9; comp.knee.value = 8; comp.ratio.value = 4; comp.attack.value = 0.003; comp.release.value = 0.16;

  A.bus.connect(A.dry); A.dry.connect(A.master);
  A.bus.connect(inHP); inHP.connect(A.drive); A.drive.connect(A.shaper);
  A.shaper.connect(lp); A.shaper.connect(hp); lp.connect(A.lpG); hp.connect(A.hpG);
  A.lpG.connect(fizz); A.hpG.connect(fizz); fizz.connect(A.dLevel); A.dLevel.connect(A.wet); A.wet.connect(A.master);
  A.master.connect(comp); comp.connect(dest || ctx.destination);
  VID.forEach(v => { const g = ctx.createGain(); g.connect(A.bus); A.out[v] = g; });
}

function filt(type, f, q) { const b = A.ctx.createBiquadFilter(); b.type = type; b.frequency.value = f; b.Q.value = q == null ? 0.7 : q; return b; }
function osc(type, t, dur) { const o = A.ctx.createOscillator(); o.type = type; o.start(t); o.stop(t + dur); return o; }
function nz(t, dur) { const s = A.ctx.createBufferSource(); s.buffer = A.noise; s.loop = true; s.start(t, Math.random() * 1.5); s.stop(t + dur); return s; }
function vca(t, peak, decay, attack) {
  attack = attack || 0.0015;
  const g = A.ctx.createGain();
  g.gain.setValueAtTime(0.0001, t);
  g.gain.linearRampToValueAtTime(peak, t + attack);
  g.gain.exponentialRampToValueAtTime(0.0001, t + attack + decay);
  return g;
}
function metal(t, dur, amt) {
  const sum = A.ctx.createGain(); sum.gain.value = amt;
  METAL.forEach(f => { const o = osc('square', t, dur); o.frequency.value = f; o.connect(sum); });
  return sum;
}

const SYN = {
  bd(t, a, acc, out) {
    const p = S.tweak.bd / 100, dec = 0.16 + p * 0.8, f = 50;
    const o = osc('sine', t, dec + 0.05);
    o.frequency.setValueAtTime(f * (acc ? 3.8 : 3.2), t);
    o.frequency.exponentialRampToValueAtTime(f * 1.22, t + 0.017);
    o.frequency.exponentialRampToValueAtTime(f, t + 0.1 + p * 0.1);
    const sh = A.ctx.createWaveShaper(); sh.curve = A.soft;
    const g = vca(t, a, dec, 0.002);
    o.connect(sh); sh.connect(g); g.connect(out);
    const n = nz(t, 0.03), bp = filt('bandpass', 3000, 0.9), cg = vca(t, a * (acc ? 0.9 : 0.6), 0.014, 0.0005);
    n.connect(bp); bp.connect(cg); cg.connect(out);
  },
  sd(t, a, acc, out) {
    const sn = S.tweak.sd / 100;
    const o1 = osc('sine', t, 0.3), o2 = osc('sine', t, 0.25);
    o1.frequency.setValueAtTime(240, t); o1.frequency.exponentialRampToValueAtTime(186, t + 0.03);
    o2.frequency.setValueAtTime(410, t); o2.frequency.exponentialRampToValueAtTime(332, t + 0.02);
    const g1 = vca(t, a * 0.62, 0.17), g2 = vca(t, a * 0.34, 0.11);
    o1.connect(g1); o2.connect(g2); g1.connect(out); g2.connect(out);
    const n = nz(t, 0.6), hp = filt('highpass', 1700, 0.6), lp = filt('lowpass', 11500, 0.5);
    const ng = vca(t, a * (0.25 + sn * 0.95), 0.14 + sn * 0.24, 0.001);
    n.connect(hp); hp.connect(lp); lp.connect(ng); ng.connect(out);
  },
  tom(t, a, out, base, dec, tw) {
    const f = base * Math.pow(2, (tw / 100 - 0.5) * 1.2);
    const o = osc('sine', t, dec + 0.05);
    o.frequency.setValueAtTime(f * 1.32, t);
    o.frequency.exponentialRampToValueAtTime(f, t + 0.035);
    o.frequency.exponentialRampToValueAtTime(f * 0.9, t + dec);
    const g = vca(t, a * 0.95, dec); o.connect(g); g.connect(out);
    const o2 = osc('triangle', t, dec * 0.6);
    o2.frequency.setValueAtTime(f * 1.8, t); o2.frequency.exponentialRampToValueAtTime(f * 1.5, t + 0.03);
    const g2 = vca(t, a * 0.14, dec * 0.45); o2.connect(g2); g2.connect(out);
    const n = nz(t, 0.03), bp = filt('bandpass', f * 6, 1.2), ng = vca(t, a * 0.35, 0.016, 0.0005);
    n.connect(bp); bp.connect(ng); ng.connect(out);
  },
  lt(t, a, acc, out) { SYN.tom(t, a, out, 118, 0.5, S.tweak.lt); },
  ht(t, a, acc, out) { SYN.tom(t, a, out, 172, 0.42, S.tweak.ht); },
  hat(t, a, out, dec, centre) {
    const m = metal(t, dec + 0.05, 0.3), n = nz(t, dec + 0.05), ng = A.ctx.createGain(); ng.gain.value = 0.55;
    n.connect(ng);
    const hp = filt('highpass', 7000, 0.7), bp = filt('bandpass', centre, 0.55);
    m.connect(hp); ng.connect(hp); hp.connect(bp);
    const g = vca(t, a * 1.25, dec, 0.0008);
    bp.connect(g);
    return g;
  },
  ch(t, a, acc, out) {
    const dec = 0.035 + (S.tweak.ch / 100) * 0.15;
    SYN.hat(t, a, out, dec, 10500).connect(out);
    const o = A.lastOH;
    if (o && !o.choked && t > o.t) { o.g.gain.setValueAtTime(1, t); o.g.gain.setTargetAtTime(0.0001, t, 0.006); o.choked = true; }
  },
  oh(t, a, acc, out) {
    const dec = 0.22 + (S.tweak.oh / 100) * 0.95;
    const choke = A.ctx.createGain(); choke.gain.value = 1;
    SYN.hat(t, a * 1.1, out, dec, 9500).connect(choke); choke.connect(out);
    const o = A.lastOH;
    if (o && !o.choked && t > o.t) { o.g.gain.setValueAtTime(1, t); o.g.gain.setTargetAtTime(0.0001, t, 0.006); o.choked = true; }
    A.lastOH = {g:choke, t, choked:false};
  },
  cy(t, a, acc, out) {
    const dec = 0.9 + (S.tweak.cy / 100) * 2.1;
    const m = metal(t, dec + 0.05, 0.3), n = nz(t, dec + 0.05), ng = A.ctx.createGain(); ng.gain.value = 0.3;
    n.connect(ng);
    const bp = filt('bandpass', 4300, 0.8), hp = filt('highpass', 8000, 0.6);
    m.connect(bp); m.connect(hp); ng.connect(hp);
    const g1 = vca(t, a * 0.9, dec * 0.33), g2 = vca(t, a * 1.4, dec);
    bp.connect(g1); hp.connect(g2); g1.connect(out); g2.connect(out);
  },
  cp(t, a, acc, out) {
    const tail = 0.18 + (S.tweak.cp / 100) * 0.6, k = a * 3.4;
    const n = nz(t, 0.04 + tail + 0.05), hp = filt('highpass', 650, 0.7), bp = filt('bandpass', 1150, 1.5);
    const g = A.ctx.createGain(), gg = g.gain;
    gg.setValueAtTime(0.0001, t);
    for (let i = 0; i < 3; i++) {
      const ti = t + i * 0.0105;
      gg.setValueAtTime(k, ti);
      gg.exponentialRampToValueAtTime(k * 0.1, ti + 0.0095);
    }
    const tt = t + 0.0315;
    gg.setValueAtTime(k * 0.85, tt);
    gg.exponentialRampToValueAtTime(0.0001, tt + tail);
    n.connect(hp); hp.connect(bp); bp.connect(g); g.connect(out);
  },
};

function trigger(v, t, acc) {
  const a = acc ? 0.8 * (1 + 0.8 * S.accent / 100) : 0.8;
  SYN[v](t, a, acc, A.out[v]);
}
function applyLevel(v) { if (!A.ctx) return; const L = S.level[v] / 100; A.out[v].gain.setTargetAtTime(L * L * 1.25, A.ctx.currentTime, 0.01); }
function applyVolume() { if (!A.ctx) return; const v = S.volume / 100; A.master.gain.setTargetAtTime(v * v * 0.95, A.ctx.currentTime, 0.015); }
function applyDist() {
  if (!A.ctx) return;
  const t = A.ctx.currentTime, on = S.dist.on, dr = S.dist.drive / 100, tn = S.dist.tone / 100;
  A.dry.gain.setTargetAtTime(on ? 0 : 1, t, 0.008);
  A.wet.gain.setTargetAtTime(on ? 1 : 0, t, 0.008);
  A.drive.gain.setTargetAtTime(1.5 + dr * dr * 45, t, 0.01);
  A.lpG.gain.setTargetAtTime(Math.cos(tn * Math.PI / 2), t, 0.01);
  A.hpG.gain.setTargetAtTime(Math.sin(tn * Math.PI / 2) * 1.4, t, 0.01);
  A.dLevel.gain.setTargetAtTime(S.dist.level / 100 * 0.5, t, 0.01);
}
function applyAll() { VID.forEach(applyLevel); applyVolume(); applyDist(); }
/*ENGINE-END*/

function ensureAudio() {
  if (A.ctx) { if (A.ctx.state === 'suspended') A.ctx.resume(); return true; }
  const AC = window.AudioContext || window.webkitAudioContext;
  if (!AC) { flash('Err', 2000); return false; }
  buildGraph(new AC({latencyHint:'interactive'}));
  applyAll();
  return true;
}
function audition(v) {
  if (v === 'acc' || !ensureAudio()) return;
  trigger(v, A.ctx.currentTime + 0.004, false);
}

/* =================== sequencer clock =================== */
let timer = null, nextBase = 0, stepIdx = 0, drawQ = [], recent = [];
const LOOKAHEAD = 0.12;

function tick() {
  const ctx = A.ctx;
  while (nextBase < ctx.currentTime + LOOKAHEAD) {
    const dur = 60 / S.bpm / 4;
    const t = nextBase + (stepIdx % 2 ? (S.swing - 50) / 50 * dur : 0);
    playStep(stepIdx, Math.max(t, ctx.currentTime));
    nextBase += dur;
    stepIdx++;
    if (stepIdx >= getPat(S.play).len) { stepIdx = 0; barEnd(); }
  }
}
function playStep(i, t) {
  const p = getPat(S.play), acc = !!p.acc[i];
  // open hat before closed hat so a closed hat on the same step doesn't choke it
  for (const v of VID) if (p[v][i] && !S.mutes[v]) trigger(v, t, acc);
  const e = {i, t, pat:S.play.slice(), sp:S.songPos};
  drawQ.push(e);
  recent.push(e); if (recent.length > 6) recent.shift();
}
function barEnd() {
  if (isSong() && S.song.length) {
    S.songPos = (S.songPos + 1) % S.song.length;
    S.play = S.song[S.songPos].slice();
  } else if (S.queued) {
    S.play = S.queued; S.queued = null;
  } else if (S.mode === 'write') {
    S.play = S.sel.slice();
  }
}
function start() {
  if (!ensureAudio()) return;
  if (isSong() && S.song.length) { S.songPos = 0; S.play = S.song[0].slice(); S.sel = S.play.slice(); }
  else { if (isSong()) flash('nonE'); S.play = S.sel.slice(); }
  stepIdx = 0; drawQ = []; recent = [];
  nextBase = A.ctx.currentTime + 0.06;
  S.playing = true;
  tick();
  timer = setInterval(tick, 25);
  refresh();
}
function stop() {
  clearInterval(timer); timer = null;
  S.playing = false; S.step = -1; drawQ = [];
  if (S.queued) { S.sel = S.queued; S.queued = null; }
  refresh();
}
const togglePlay = () => S.playing ? stop() : start();

function frame() {
  if (S.playing && A.ctx) {
    const now = A.ctx.currentTime;
    let changed = false;
    while (drawQ.length && drawQ[0].t <= now) {
      const e = drawQ.shift();
      S.step = e.i;
      if (S.sel[0] !== e.pat[0] || S.sel[1] !== e.pat[1]) {
        if (!(S.mode === 'write' && !isSong())) S.sel = e.pat.slice();
      }
      S.drawSongPos = e.sp;
      changed = true;
    }
    if (changed) refresh();
  }
  requestAnimationFrame(frame);
}

/* =================== 7-segment display =================== */
const SEGMAP = {
  '0':'abcdef','1':'bc','2':'abdeg','3':'abcdg','4':'bcfg','5':'acdfg','6':'acdefg','7':'abc','8':'abcdefg','9':'abcdfg',
  'A':'abcefg','b':'cdefg','C':'adef','c':'deg','d':'bcdeg','E':'adefg','F':'aefg','G':'acdef','H':'bcefg','h':'cefg',
  'I':'ef','i':'e','L':'def','n':'ceg','O':'abcdef','o':'cdeg','P':'abefg','r':'eg','S':'acdfg','t':'defg',
  'U':'bcdef','u':'cde','y':'bcdfg','Y':'bcdfg','-':'g','_':'d',' ':'',
};
const segEls = [], dpEls = [];
(function buildSeg() {
  const svg = $('#seg'), T = 6, NS = 'http://www.w3.org/2000/svg';
  const H = (x1, x2, y) => `${x1},${y} ${x1+T/2},${y-T/2} ${x2-T/2},${y-T/2} ${x2},${y} ${x2-T/2},${y+T/2} ${x1+T/2},${y+T/2}`;
  const V = (x, y1, y2) => `${x},${y1} ${x+T/2},${y1+T/2} ${x+T/2},${y2-T/2} ${x},${y2} ${x-T/2},${y2-T/2} ${x-T/2},${y1+T/2}`;
  const geo = {a:H(5,29,4), b:V(31,6,29), c:V(31,33,56), d:H(5,29,58), e:V(3,33,56), f:V(3,6,29), g:H(5,29,31)};
  for (let d = 0; d < 4; d++) {
    const g = document.createElementNS(NS, 'g');
    g.setAttribute('transform', `translate(${10 + d * 44},2) skewX(-7)`);
    const segs = {};
    for (const [s, pts] of Object.entries(geo)) {
      const p = document.createElementNS(NS, 'polygon'); p.setAttribute('points', pts); g.appendChild(p); segs[s] = p;
    }
    const dp = document.createElementNS(NS, 'circle'); dp.setAttribute('cx', 38); dp.setAttribute('cy', 58); dp.setAttribute('r', 3.2);
    g.appendChild(dp);
    svg.appendChild(g); segEls.push(segs); dpEls.push(dp);
  }
})();
function setSeg(text) {
  const cells = [];
  for (const ch of String(text)) {
    if (ch === '.' && cells.length) { cells[cells.length - 1].dp = true; continue; }
    cells.push({ch, dp:false});
  }
  while (cells.length < 4) cells.unshift({ch:' ', dp:false});
  cells.slice(0, 4).forEach((c, d) => {
    const on = SEGMAP[c.ch] ?? SEGMAP[c.ch.toUpperCase()] ?? SEGMAP[c.ch.toLowerCase()] ?? '';
    for (const s of 'abcdefg') segEls[d][s].classList.toggle('on', on.includes(s));
    dpEls[d].classList.toggle('on', c.dp);
  });
  $('#seg').setAttribute('aria-label', 'Display ' + String(text).trim());
}
let flashText = null, flashT = null;
function flash(txt, ms) {
  flashText = txt;
  clearTimeout(flashT);
  flashT = setTimeout(() => { flashText = null; renderDisplay(); }, ms || 1000);
  renderDisplay();
}
const renderDisplay = () => setSeg(flashText ?? String(Math.round(S.bpm)).padStart(4, ' '));
const num4 = v => String(Math.round(v)).padStart(4, ' ');

/* =================== knobs =================== */
const TICKS = (() => {
  let s = '<svg class="ticks" viewBox="0 0 100 100" aria-hidden="true">';
  for (let k = 0; k < 11; k++) {
    const a = (-135 + k * 27) * Math.PI / 180, r1 = 44, r2 = k % 5 === 0 ? 50 : 48.5;
    s += `<line x1="${(50 + r1 * Math.sin(a)).toFixed(2)}" y1="${(50 - r1 * Math.cos(a)).toFixed(2)}" x2="${(50 + r2 * Math.sin(a)).toFixed(2)}" y2="${(50 - r2 * Math.cos(a)).toFixed(2)}"/>`;
  }
  return s + '</svg>';
})();

function knob({label, aria, min = 0, max = 100, value, def, size, onChange, fmt}) {
  const wrap = el('div', 'kw');
  const k = el('div', 'knob' + (size ? ' ' + size : ''));
  k.tabIndex = 0;
  k.setAttribute('role', 'slider');
  k.setAttribute('aria-label', aria || label);
  k.setAttribute('aria-valuemin', min);
  k.setAttribute('aria-valuemax', max);
  k.innerHTML = TICKS + '<div class="cap"><span class="ind"></span></div>';
  wrap.append(k, el('span', 'legend', label));
  const cap = k.querySelector('.cap');
  let v = value;
  const render = () => {
    cap.style.transform = `rotate(${-135 + (v - min) / (max - min) * 270}deg)`;
    k.setAttribute('aria-valuenow', Math.round(v));
    k.setAttribute('aria-valuetext', fmt ? fmt(v) : String(Math.round(v)));
  };
  const set = nv => {
    nv = clamp(nv, min, max);
    if (Math.round(nv) !== Math.round(v)) { v = nv; render(); onChange(Math.round(v)); }
    else v = nv;
  };
  k.addEventListener('pointerdown', e => {
    e.preventDefault(); k.focus(); ensureAudio();
    k.setPointerCapture(e.pointerId);
    const sy = e.clientY, sx = e.clientX, sv = v;
    const move = ev => {
      const d = (sy - ev.clientY) + (ev.clientX - sx) * 0.5;
      set(sv + d * (max - min) / (ev.shiftKey ? 700 : 170));
    };
    const up = () => { k.removeEventListener('pointermove', move); k.removeEventListener('pointerup', up); k.removeEventListener('pointercancel', up); v = Math.round(v); };
    k.addEventListener('pointermove', move); k.addEventListener('pointerup', up); k.addEventListener('pointercancel', up);
  });
  k.addEventListener('wheel', e => {
    e.preventDefault();
    set(Math.round(v) - Math.sign(e.deltaY) * (e.shiftKey ? 1 : Math.max(1, (max - min) / 50)));
  }, {passive:false});
  k.addEventListener('keydown', e => {
    const big = Math.max(1, (max - min) / 10);
    const map = {ArrowUp:1, ArrowRight:1, ArrowDown:-1, ArrowLeft:-1, PageUp:big, PageDown:-big};
    if (e.key in map) { e.preventDefault(); set(Math.round(v) + map[e.key]); }
    else if (e.key === 'Home') { e.preventDefault(); set(min); }
    else if (e.key === 'End') { e.preventDefault(); set(max); }
  });
  k.addEventListener('dblclick', () => set(def));
  render();
  return {el:wrap};
}

/* =================== build the panel =================== */
const masters = $('#masters');
masters.append(
  knob({label:'tempo', size:'lg', min:40, max:300, value:S.bpm, def:124, fmt:v => v + ' BPM',
    onChange:v => { S.bpm = v; flashText = null; renderDisplay(); save(); }}).el,
  knob({label:'swing', aria:'Swing', min:50, max:75, value:S.swing, def:50, fmt:v => v + '%',
    onChange:v => { S.swing = v; flash(num4(v)); save(); }}).el,
  knob({label:'volume', aria:'Master volume', value:S.volume, def:80,
    onChange:v => { S.volume = v; applyVolume(); flash(num4(v)); save(); }}).el,
);

const strips = {};
[{id:'acc', name:'accent'}, ...VOICES].forEach(r => {
  const s = el('div', 'strip' + (r.id === 'acc' ? ' acc' : ''));
  if (r.id === 'acc') {
    const sp = el('div', 'kw'); sp.append(el('div', 'kspace'), el('span', 'legend', '\u00a0'));
    s.append(sp, knob({label:'amount', aria:'Accent amount', value:S.accent, def:60,
      onChange:v => { S.accent = v; flash(num4(v)); save(); }}).el);
  } else {
    s.append(
      knob({label:r.tweak, aria:`${r.name} ${r.tweak}`, value:S.tweak[r.id], def:DEF.tweak[r.id],
        onChange:v => { S.tweak[r.id] = v; flash(num4(v)); save(); }}).el,
      knob({label:'level', aria:`${r.name} level`, value:S.level[r.id], def:DEF.level[r.id],
        onChange:v => { S.level[r.id] = v; applyLevel(r.id); flash(num4(v)); save(); }}).el,
    );
  }
  const b = el('button', 'vsel');
  b.innerHTML = `<span class="pad"><span class="led"></span></span><span class="vname">${r.name}</span>`;
  b.setAttribute('aria-label', r.id === 'acc' ? 'Edit accent steps' : `Select and play ${r.name}`);
  b.addEventListener('click', e => voicePress(r.id, e.shiftKey));
  s.append(b);
  $('#bank').append(s);
  strips[r.id] = {el:s, led:b.querySelector('.led'), btn:b};
});

$('#pedalKnobs').append(
  knob({label:'drive', aria:'Distortion drive', value:S.dist.drive, def:45, onChange:v => { S.dist.drive = v; applyDist(); flash(num4(v)); save(); }}).el,
  knob({label:'tone', aria:'Distortion tone', value:S.dist.tone, def:55, onChange:v => { S.dist.tone = v; applyDist(); flash(num4(v)); save(); }}).el,
  knob({label:'level', aria:'Distortion level', value:S.dist.level, def:55, onChange:v => { S.dist.level = v; applyDist(); flash(num4(v)); save(); }}).el,
);
$('#foot').addEventListener('click', () => {
  ensureAudio();
  S.dist.on = !S.dist.on; applyDist(); flash(S.dist.on ? 'On' : 'OFF'); save(); refresh();
});

function rbtn(label, aria, onClick) {
  const b = el('button', 'rb');
  b.innerHTML = `<span class="led"></span><span class="cap"></span><span class="lbl">${label}</span>`;
  if (aria) b.setAttribute('aria-label', aria);
  b.addEventListener('click', onClick);
  return {btn:b, led:b.querySelector('.led')};
}
const MODES = [
  {id:'pattern', label:'pattern', show:'PAt'},
  {id:'write', label:'step write', show:'Edit'},
  {id:'song', label:'song', show:'SonG'},
  {id:'songwrite', label:'song write', show:'SEdt'},
];
const modeBtns = MODES.map(m => {
  const r = rbtn(m.label, `${m.label} mode`, () => setMode(m.id));
  $('#modeBtns').append(r.btn); return {...r, id:m.id};
});
const bankBtns = [0, 1].map(b => {
  const r = rbtn(BANKS[b], `Bank ${BANKS[b]}`, () => setBank(b));
  $('#bankBtns').append(r.btn); return r;
});
const FN = {
  shift: rbtn('shift', 'Shift', () => { S.shift = !S.shift; refresh(); }),
  clear: rbtn('clear', 'Clear', e => doClear(e.shiftKey)),
  copy:  rbtn('copy', 'Copy pattern', () => { S.clip = JSON.parse(JSON.stringify(getPat(S.sel))); flash('CoPy'); }),
  paste: rbtn('paste', 'Paste pattern', () => doPaste()),
  tap:   rbtn('tap', 'Tap to play the selected voice, and record it in step write while running', () => tapVoice(S.voice)),
};
Object.values(FN).forEach(f => $('#fnBtns').append(f.btn));

const keys = [];
for (let g = 0; g < 4; g++) {
  const grp = el('div', 'kgroup');
  for (let j = 0; j < 4; j++) {
    const i = g * 4 + j;
    const b = el('button', 'key');
    b.innerHTML = `<span class="led"></span><span>${i + 1}</span>`;
    b.addEventListener('click', e => keyPress(i, e.shiftKey));
    grp.append(b);
    keys.push({btn:b, led:b.querySelector('.led')});
  }
  $('#keys').append(grp);
}
$('#startBtn').addEventListener('click', togglePlay);

/* ---------- pattern sheet grid ---------- */
const cells = {}, labels = {}, heads = [];
(function buildGrid() {
  const grid = $('#grid');
  grid.append(el('div'));
  for (let i = 0; i < 16; i++) { const h = el('div', 'ghead', i + 1); grid.append(h); heads.push(h); }
  ROWS.forEach(r => {
    const lab = el('div', 'glabel');
    const nb = el('button', 'gname', NAME[r]);
    nb.addEventListener('click', e => voicePress(r, e.shiftKey));
    lab.append(nb);
    let mb = null;
    if (r !== 'acc') {
      mb = el('button', 'gmute', 'M');
      mb.setAttribute('aria-label', `Mute ${NAME[r]}`);
      mb.addEventListener('click', () => toggleMute(r));
      lab.append(mb);
    }
    grid.append(lab);
    labels[r] = {el:lab, mute:mb};
    cells[r] = [];
    for (let i = 0; i < 16; i++) {
      const c = el('button', 'gcell' + (r === 'acc' ? ' acc' : '') + (Math.floor(i / 4) % 2 ? ' alt' : ''));
      c.setAttribute('aria-label', `${NAME[r]} step ${i + 1}`);
      c.addEventListener('click', () => toggleStep(r, i));
      grid.append(c);
      cells[r].push(c);
    }
  });
})();

/* =================== actions =================== */
function consumeShift(eventShift) {
  const s = eventShift || physShift || S.shift;
  if (S.shift) S.shift = false;
  return s;
}
function setMode(m) {
  const wasSong = isSong();
  S.mode = m;
  if (S.playing && isSong() && !wasSong) S.songPos = -1;   // song starts from the top at the next bar
  if (!isSong()) S.queued = null;
  if (m === 'write' || m === 'pattern') S.bank = S.sel[0];
  flash(MODES.find(x => x.id === m).show);
  refresh();
}
function setBank(b) {
  S.bank = b;
  if (S.mode === 'write') { S.sel = [b, S.sel[1]]; if (S.playing) S.play = S.sel.slice(); }
  flash('b-' + (b + 1));
  refresh();
}
function selectPattern(b, i) {
  if (S.playing) S.queued = [b, i];
  else { S.sel = [b, i]; S.play = [b, i]; }
  flash(`${b + 1}-${pad2(i + 1)}`);
  refresh();
}
function keyPress(i, evShift) {
  ensureAudio();
  const sh = consumeShift(evShift);
  switch (S.mode) {
    case 'pattern': selectPattern(S.bank, i); break;
    case 'write':
      if (sh) setLen(i + 1);
      else toggleStep(S.voice, i);
      break;
    case 'song': flash('SonG'); refresh(); break;
    case 'songwrite':
      if (S.song.length >= 256) { flash('FULL'); break; }
      S.song.push([S.bank, i]); renderSong(); save();
      flash('S.' + String(S.song.length).padStart(3, '0'));
      refresh();
      break;
  }
}
function toggleStep(r, i) {
  const p = getPat(S.sel);
  p[r][i] = p[r][i] ? 0 : 1;
  if (p[r][i] && !S.playing) audition(r);
  save(); refresh();
}
function setLen(n) {
  getPat(S.sel).len = clamp(n, 1, 16);
  flash('L' + String(getPat(S.sel).len).padStart(3, ' '));
  save(); refresh();
}
function voicePress(r, evShift) {
  const sh = consumeShift(evShift);
  if (sh && r !== 'acc') { toggleMute(r); return; }
  S.voice = r;
  flash(CODE[r]);
  audition(r);
  refresh();
}
function toggleMute(r) {
  S.mutes[r] = !S.mutes[r];
  if (!S.mutes[r]) delete S.mutes[r];
  flash(S.mutes[r] ? 'nUtE' : 'On');
  save(); refresh();
}
function tapVoice(r) {
  if (r === 'acc') return;
  audition(r);
  if (!(S.playing && S.mode === 'write') || !recent.length) return;
  const now = A.ctx.currentTime;
  let best = recent[0];
  for (const e of recent) if (Math.abs(e.t - now) < Math.abs(best.t - now)) best = e;
  const p = getPat(best.pat);
  if (best.pat[0] === S.sel[0] && best.pat[1] === S.sel[1] && !p[r][best.i]) { p[r][best.i] = 1; save(); refresh(); }
}
function doClear(evShift) {
  const sh = consumeShift(evShift);
  if (isSong()) {
    if (sh) { S.song = []; flash('CLr'); }
    else if (S.song.length) { S.song.pop(); flash('dEL'); }
    renderSong(); save(); refresh(); return;
  }
  const p = getPat(S.sel);
  if (sh) { ROWS.forEach(r => p[r].fill(0)); flash('CLr.P'); }
  else { p[S.voice].fill(0); flash('CLr'); }
  save(); refresh();
}
function doPaste() {
  if (!S.clip) { flash('nonE'); return; }
  const [b, i] = S.sel;
  S.patterns[b][i] = JSON.parse(JSON.stringify(S.clip));
  flash('PASt'); save(); refresh();
}

/* ---------- confirm-twice buttons for destructive sheet actions ---------- */
function twoStep(btn, label, confirmLabel, action) {
  let armed = null;
  btn.addEventListener('click', () => {
    if (armed) { clearTimeout(armed); armed = null; btn.textContent = label; btn.classList.remove('warn'); action(); return; }
    btn.textContent = confirmLabel; btn.classList.add('warn');
    armed = setTimeout(() => { armed = null; btn.textContent = label; btn.classList.remove('warn'); }, 3000);
  });
}
$('#songUndo').addEventListener('click', () => { if (S.song.length) { S.song.pop(); renderSong(); save(); refresh(); } });
twoStep($('#songClear'), 'Clear song', 'Click again to clear', () => { S.song = []; renderSong(); save(); refresh(); });
twoStep($('#restore'), 'Restore demo patterns', 'Click again to replace your patterns', () => {
  S.patterns = demoPatterns(); S.song = demoSong(); S.sel = [0, 0]; S.play = [0, 0]; S.queued = null; S.bank = 0;
  renderSong(); save(); refresh();
});
$('#lenDown').addEventListener('click', () => setLen(getPat(S.sel).len - 1));
$('#lenUp').addEventListener('click', () => setLen(getPat(S.sel).len + 1));

/* ---------- keyboard ---------- */
window.addEventListener('keydown', e => {
  if (e.key === 'Shift') { physShift = true; refreshShift(); return; }
  if (e.metaKey || e.ctrlKey || e.altKey) return;
  if (e.code === 'Space') { e.preventDefault(); if (!e.repeat) togglePlay(); return; }
  const m = /^Digit([1-8])$/.exec(e.code);
  if (m && !e.repeat) {
    const v = VID[+m[1] - 1];
    if (e.shiftKey) toggleMute(v);
    else { if (S.voice !== v) { S.voice = v; refresh(); } tapVoice(v); }
  }
});
window.addEventListener('keyup', e => {
  if (e.key === 'Shift') { physShift = false; refreshShift(); }
  if (e.code === 'Space') e.preventDefault();
});
window.addEventListener('blur', () => { physShift = false; refreshShift(); });

/* =================== rendering =================== */
const HELP = {
  pattern: () => `Press a step key to choose a pattern from bank ${BANKS[S.bank]}. While running, the new pattern starts when the current bar ends.`,
  write: () => S.voice === 'acc'
    ? 'Step keys now place accents. Every voice hits harder on an accented step; the accent knob sets how much.'
    : `Step keys place ${NAME[S.voice]} hits. Pick another voice with its button, or hold shift and press a step key to set the pattern length.`,
  song: () => S.song.length ? 'Start plays the song from the top and loops it.' : 'The song is empty. Switch to song write and press step keys to add patterns.',
  songwrite: () => `Each step key adds that pattern from bank ${BANKS[S.bank]} to the end of the song. Clear removes the last one.`,
};
let chipEls = [];
function renderSong() {
  const ol = $('#chips');
  ol.textContent = '';
  chipEls = S.song.map(p => { const li = el('li', '', patName(p)); ol.append(li); return li; });
  if (!S.song.length) ol.append(el('li', 'empty', 'Empty. In song write, each step key adds a pattern here.'));
  $('#songMeta').textContent = S.song.length ? `${S.song.length} bar${S.song.length > 1 ? 's' : ''}, up to 256` : '';
}
function refreshShift() { FN.shift.led.classList.toggle('on', S.shift || physShift); }

function refresh() {
  const p = getPat(S.sel);
  const cur = S.playing ? S.step : -1;

  modeBtns.forEach(m => { const on = S.mode === m.id; m.led.classList.toggle('on', on); m.btn.setAttribute('aria-pressed', on); });
  bankBtns.forEach((b, i) => { const on = S.bank === i; b.led.classList.toggle('on', on); b.btn.setAttribute('aria-pressed', on); });
  refreshShift();
  $('#startLed').classList.toggle('on', S.playing);
  $('#distLed').classList.toggle('on', S.dist.on);
  $('#foot').setAttribute('aria-pressed', S.dist.on);
  for (const r of ROWS) {
    strips[r].led.classList.toggle('on', S.voice === r);
    strips[r].el.classList.toggle('muted', !!S.mutes[r]);
  }

  // step keys
  const lastSong = S.song[S.song.length - 1];
  keys.forEach((k, i) => {
    let on = false, blink = false, dim = false;
    if (S.mode === 'write') { on = !!p[S.voice][i]; dim = i >= p.len; }
    else if (S.mode === 'pattern') {
      on = S.sel[0] === S.bank && S.sel[1] === i;
      blink = !!S.queued && S.queued[0] === S.bank && S.queued[1] === i && !on;
    } else if (S.mode === 'song') on = S.playing && S.sel[0] === S.bank && S.sel[1] === i;
    else on = !!lastSong && lastSong[0] === S.bank && lastSong[1] === i;
    k.led.classList.toggle('on', on);
    k.led.classList.toggle('blink', blink);
    k.btn.classList.toggle('dim', dim);
    k.btn.classList.toggle('cur', i === cur);
    k.btn.setAttribute('aria-label', S.mode === 'write' ? `Step ${i + 1}${on ? ', on' : ''}` : `Pattern ${i + 1}`);
  });

  // sheet grid
  for (const r of ROWS) {
    const row = p[r], muted = !!S.mutes[r];
    cells[r].forEach((c, i) => {
      c.classList.toggle('on', !!row[i]);
      c.setAttribute('aria-pressed', !!row[i]);
      c.classList.toggle('out', i >= p.len);
      c.classList.toggle('cur', i === cur);
      c.classList.toggle('grow-muted', muted);
    });
    labels[r].el.classList.toggle('sel', S.voice === r);
    if (labels[r].mute) labels[r].mute.setAttribute('aria-pressed', muted);
  }
  heads.forEach((h, i) => h.classList.toggle('cur', i === cur));

  $('#patTitle').textContent = `Pattern ${patName(S.sel)}` + (S.queued ? `, ${patName(S.queued)} next` : '');
  $('#help').textContent = HELP[S.mode]();
  $('#lenOut').textContent = p.len;

  const sp = S.playing && isSong() ? S.drawSongPos : -1;
  chipEls.forEach((c, i) => c.classList.toggle('now', i === sp));
}

/* =================== boot =================== */
renderSong();
renderDisplay();
refresh();
requestAnimationFrame(frame);
document.addEventListener('pointerdown', () => ensureAudio(), {once:true});
})();
</script>
</body>
</html>