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>