metronome/player-asbuilt.html
Me Here b434520505 As-built: upgrade the mono OLED to a 320×240 colour IPS TFT (ST7789)
The 128×64 mono OLED was too pixelated, so step up to the realistic next
tier: a 2.0″ 320×240 colour IPS TFT (ST7789 — e.g. Pimoroni Pico Display 2.0),
~5× the resolution and full colour.

- Drop the 1-bit threshold + image-rendering:pixelated; render on a hi-DPI
  canvas (backing = 320×240 × devicePixelRatio) with smooth anti-aliased type.
- Richer colour layout: dim header (position + green ▶ PLAY / grey ■ STOP),
  a big cyan tempo with "BPM ♩ <grouping>", the centred item name (ellipsised),
  and a bottom strip with bar·beat + an amber bars countdown.

The screen stays a fixed dark UI (a TFT shows whatever firmware draws); the page
chrome still follows light/dark/system. Beat matrix, encoder, buttons unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 07:11:48 -05:00

450 lines
27 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>VARASYS PM1 — asbuilt (real components)</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,@BUILD:favicon@">
<!--
"As-built" variant of the PM-1 player: the same firmware/engine, drawn with the
parts you'd actually solder for an RP2040 build —
• a 2.0″ 320×240 colour IPS TFT (ST7789, e.g. Pimoroni Pico Display 2.0):
the upgrade from the cramped 128×64 mono OLED — full colour, ~5× the
resolution, smooth anti-aliased type (rendered hi-DPI on a canvas);
• a 4×16 WS2812 ("NeoPixel") RGB matrix (PIO-driven): the bottom row is the
beat (cyan downbeats / amber group-starts), and the three rows above stack
the current beat's subdivisions as they pass (driven by the finest lane);
• an EC11 rotary encoder (turn it: wheel or drag) for tempo, tactile buttons,
a MAX98357A-style speaker, USB-C and a PWR LED in a matte 3D-printed case.
Compare with the idealized /player.html. One file, no deps; shares src/engine.js.
-->
<script>
// Set theme before first paint (shared "metronome.theme" with the editor / player).
(function(){ try{
var p = localStorage.getItem("metronome.theme");
if (p!=="light" && p!=="dark" && p!=="system") p = "system";
document.documentElement.dataset.theme = p==="system" ? (matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark") : p;
} catch(e){ document.documentElement.dataset.theme = "dark"; } })();
</script>
<style>
/*@BUILD:include:src/base.css@*/
:root{
/* environment (themed) — the page around the device */
--bg1:#12151c; --bg2:#05070a;
--txt:#c7d0db; --muted:#7f8b9a; --link:#6cb6ff;
--panel-bg:#171b22; --panel-bd:#2a313c; --field-bg:#0e1116; --field-bd:#222a36;
/* device — fixed matte hardware in both themes */
--case:#22262e; --case2:#15181d; --device-bd:#0b0d11; --silk:#9aa6b2;
--pcb:#0d2620; --oled-bezel:#04060a; --metal:#3a424e;
}
:root[data-theme="light"]{
--bg1:#f5f8fc; --bg2:#dde4ec;
--txt:#1e2630; --muted:#5c6776; --link:#1769c4;
--panel-bg:#ffffff; --panel-bd:#d2dae4; --field-bg:#f1f4f8; --field-bd:#cdd6e0;
}
body{
margin:0; min-height:100vh; padding:28px 16px 48px;
background:radial-gradient(circle at 50% -8%, var(--bg1), var(--bg2));
color:var(--txt);
display:flex; flex-direction:column; align-items:center; gap:20px;
}
a{color:var(--link)}
.topbar{width:100%; max-width:560px; display:flex; align-items:center; justify-content:space-between; gap:10px; font-size:13px; color:var(--muted); flex-wrap:wrap}
.topbar b{color:var(--txt)}
.topbar-right{ display:flex; align-items:center; gap:12px }
.tbtn{ background:transparent; color:var(--muted); border:1px solid var(--panel-bd); border-radius:8px;
padding:3px 9px; font-size:14px; line-height:1; cursor:pointer }
.tbtn:hover{ color:var(--txt) }
/* ---- the device: matte 3D-printed case ---- */
.device{
width:100%; max-width:560px; position:relative;
background:
repeating-linear-gradient(115deg, rgba(255,255,255,.012) 0 2px, transparent 2px 4px),
linear-gradient(180deg, var(--case), var(--case2));
border:1px solid var(--device-bd); border-radius:18px; padding:26px 24px 22px;
box-shadow:0 24px 55px rgba(0,0,0,.55), inset 0 1px 0 rgba(255,255,255,.05), inset 0 -2px 10px rgba(0,0,0,.6);
}
.device::before, .device::after, .device .screw{ content:""; position:absolute; width:10px; height:10px; border-radius:50%;
background:radial-gradient(circle at 35% 30%, #5a626e, #1a1e24 70%); box-shadow:inset 0 0 2px #000, 0 1px 1px rgba(0,0,0,.5); }
.device::before{ top:12px; left:12px } .device::after{ top:12px; right:12px }
.screw.bl{ bottom:12px; left:12px } .screw.br{ bottom:12px; right:12px }
.brandrow{ display:flex; align-items:flex-end; justify-content:space-between; margin:0 4px 16px; }
.silk{ color:var(--silk); letter-spacing:.04em }
.silk .vk{ font-weight:800; letter-spacing:.16em; font-size:15px; color:var(--silk) }
.silk .model{ font-size:10px; text-transform:uppercase; letter-spacing:.18em; opacity:.8 }
.pwr{ display:flex; align-items:center; gap:6px; font-size:9px; color:var(--silk); text-transform:uppercase; letter-spacing:.14em; opacity:.85 }
.pwr .dot{ width:7px; height:7px; border-radius:50%; background:#2fe07a; box-shadow:0 0 7px #2fe07a }
/* ---- 2.0″ 320×240 colour IPS TFT (the OLED upgrade) ---- */
.tft-wrap{ display:flex; justify-content:center; margin:0 4px }
.tft-mod{ background:#000; border-radius:10px; padding:9px;
box-shadow:inset 0 0 0 2px #15181d, inset 0 0 0 3px #000, 0 3px 9px rgba(0,0,0,.55) }
#tft{ display:block; width:320px; height:240px; max-width:100%; border-radius:4px; background:#06080c;
box-shadow:0 0 0 1px #000, inset 0 0 18px rgba(0,0,0,.45) }
.tft-cap{ text-align:center; font-size:10px; color:var(--muted); margin-top:7px; letter-spacing:.02em }
/* ---- 4×16 WS2812 RGB matrix: bottom row = beat, 3 rows above = subdivisions ---- */
.ledgrid{ display:flex; flex-direction:column; gap:5px; width:max-content; margin:16px auto 4px;
background:linear-gradient(180deg,#10221c,var(--pcb)); border:1px solid #07140f; border-radius:5px; padding:8px 9px;
box-shadow:inset 0 1px 2px rgba(0,0,0,.6) }
.ledrow{ display:flex; gap:5px }
.ledrow.beatrow{ margin-top:4px; padding-top:6px; border-top:1px solid rgba(255,255,255,.07) }
.npx{ width:15px; height:15px; border-radius:3px; background:#0c0e10; border:1px solid #060708;
position:relative; transition:background .05s, box-shadow .05s }
.npx::after{ content:""; position:absolute; inset:4px; border-radius:1px; background:rgba(255,255,255,.05) } /* the 5050 die */
.ledbar-cap{ text-align:center; font-size:10px; color:var(--muted); margin:2px 0 0; letter-spacing:.02em }
/* ---- controls: encoder + tactile buttons ---- */
.controls{ display:flex; align-items:center; justify-content:center; gap:14px; margin:16px 4px 4px; flex-wrap:wrap }
.keys{ display:flex; gap:9px; flex-wrap:wrap; justify-content:center }
.key{ display:flex; flex-direction:column; align-items:center; gap:4px }
.cap{ width:46px; height:40px; border-radius:7px; background:linear-gradient(180deg,#2c333d,#1a1f27);
border:1px solid #3a424e; color:#d4dbe4; font-size:15px; cursor:pointer; display:flex; align-items:center; justify-content:center;
box-shadow:0 3px 0 #0b0e12, inset 0 1px 0 rgba(255,255,255,.07); user-select:none; transition:transform .04s, box-shadow .04s }
.cap:active{ transform:translateY(2px); box-shadow:0 1px 0 #0b0e12, inset 0 1px 0 rgba(255,255,255,.07) }
.cap.play{ background:linear-gradient(180deg,#1f7a4d,#155f3b); border-color:#2e7d32; color:#eafff3 }
.cap.play.on{ background:linear-gradient(180deg,#b23b3b,#8f2d2d); border-color:#c0392b }
.key small{ font-size:8px; color:var(--silk); letter-spacing:.1em; text-transform:uppercase; opacity:.8 }
.enc-wrap{ display:flex; flex-direction:column; align-items:center; gap:4px }
.enc{ width:54px; height:54px; border-radius:50%; cursor:ns-resize; position:relative; touch-action:none;
background:repeating-conic-gradient(from 0deg, #424b57 0 7deg, #2c333d 7deg 14deg);
border:2px solid #565f6c; box-shadow:0 3px 8px rgba(0,0,0,.5), inset 0 1px 1px rgba(255,255,255,.12) }
.enc::before{ content:""; position:absolute; inset:9px; border-radius:50%; background:radial-gradient(circle at 38% 32%,#3b434f,#181c22 75%) }
.enc::after{ content:""; position:absolute; left:50%; top:7px; width:3px; height:13px; background:var(--cyan); border-radius:2px;
transform-origin:50% 20px; transform:translateX(-50%) rotate(var(--a,0deg)); box-shadow:0 0 5px var(--cyan) }
.enc-wrap small{ font-size:8px; color:var(--silk); letter-spacing:.12em; opacity:.8 }
/* ---- speaker grille + ports ---- */
.footrow{ display:flex; align-items:center; justify-content:space-between; margin:18px 6px 2px }
.grille{ flex:1; height:12px; margin-right:12px; border-radius:5px;
background:radial-gradient(circle, #000 1.1px, transparent 1.4px) 0 0/8px 8px; opacity:.5 }
.usbc{ font-size:8px; color:var(--silk); letter-spacing:.12em; opacity:.7; display:flex; align-items:center; gap:5px }
.usbc .port{ width:22px; height:8px; border-radius:4px; background:#0a0c0f; border:1px solid #000; box-shadow:inset 0 0 2px #000 }
/* ---- load panel (same as the other pages) ---- */
.panel{ width:100%; max-width:560px; background:var(--panel-bg); border:1px solid var(--panel-bd); border-radius:14px; padding:16px }
.panel h2{ margin:0 0 4px; font-size:15px }
.panel p.sub{ margin:0 0 12px; font-size:12px; color:var(--muted); line-height:1.45 }
textarea{ width:100%; background:var(--field-bg); color:var(--txt); border:1px solid var(--field-bd); border-radius:9px;
padding:10px; font-family:"Courier New",monospace; font-size:12px; resize:vertical; min-height:54px }
select, .ld{ border-radius:9px; padding:8px 10px; font-size:13px }
select{ background:var(--field-bg); color:var(--txt); border:1px solid var(--field-bd) }
.ld{ cursor:pointer; color:#d4dbe4; background:linear-gradient(180deg,#2b323d,#1b212a); border:1px solid #39424f }
.row{ display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-top:8px }
.status{ margin-top:10px; font-size:12px; min-height:1.2em; font-family:"Courier New",monospace }
.status.ok{ color:#5fd08a } .status.err{ color:#ff7a7a }
.hint{ font-size:11px; color:var(--muted) }
code{ background:var(--field-bg); border:1px solid var(--field-bd); border-radius:4px; padding:1px 5px; font-size:11px }
</style>
</head>
<body>
<div class="topbar">
<span><b>VARASYS PM1</b> · asbuilt (real components)</span>
<span class="topbar-right">
<button id="themeBtn" class="tbtn" title="toggle light / dark theme"></button>
<a href="/player.html">Idealized ↗</a>
<a href="/index.html">Editor ↗</a>
</span>
</div>
<!-- ===================== THE DEVICE ===================== -->
<div class="device">
<span class="screw bl"></span><span class="screw br"></span>
<div class="brandrow">
<div class="silk"><span class="vk">VARASYS</span> <span class="model">PM1 Polymeter Player</span></div>
<div class="pwr"><span class="dot"></span>PWR</div>
</div>
<div class="tft-wrap">
<div class="tft-mod">
<canvas id="tft" width="320" height="240" aria-label="2 inch 320 by 240 colour IPS display"></canvas>
</div>
</div>
<div class="tft-cap">2.0″ 320×240 IPS TFT (ST7789)</div>
<div class="ledgrid" id="leds"></div>
<div class="ledbar-cap">4×16 WS2812 — beat (bottom) + 3 subdivision rows</div>
<div class="controls">
<div class="keys">
<div class="key"><button class="cap" id="bPrev" title="previous item"></button><small>Prev</small></div>
<div class="key"><button class="cap play" id="bPlay" title="play / stop (Space)"></button><small>Play</small></div>
<div class="key"><button class="cap" id="bNext" title="next item"></button><small>Next</small></div>
<div class="key"><button class="cap" id="bTap" title="tap tempo (T)">TAP</button><small>Tap</small></div>
</div>
<div class="enc-wrap"><div class="enc" id="enc" title="Tempo — scroll or drag to turn"></div><small>TEMPO</small></div>
</div>
<div class="footrow">
<div class="grille"></div>
<div class="usbc"><span class="port"></span>USBC</div>
</div>
</div>
<!-- ===================== LOAD CONFIG ===================== -->
<div class="panel">
<h2>Load a configuration onto the device</h2>
<p class="sub">Same firmware as the idealized unit — only the panel hardware differs. Paste a <b>patch</b>
(e.g. <code>v1;t120;kick:4;snare:4=.X.X;hatClosed:4/2</code>), a <b>setlist code</b>, or a
<code>#p=…</code>/<code>#sl=…</code> link.</p>
<label for="cfg" class="hint">Patch / setlist code / share link</label>
<textarea id="cfg" spellcheck="false" placeholder="v1;t120;kick:4;snare:4=.X.X;hatClosed:4/2&#10;…or a #sl=… link / base64 set-list code"></textarea>
<div class="row">
<button class="ld" id="bLoad">Load onto device</button>
<span class="hint">or pick a built-in or saved set list:</span>
<select id="storedSel"><option value="">— choose a set list —</option></select>
</div>
<div class="status" id="status"></div>
</div>
<script>
const APP_VERSION = "v0.0.1-dev";
const $ = (id) => document.getElementById(id);
/* ========================= ENGINE (shared; synth voices only) ================= */
const SAMPLES = {}; // synth-only device — no acoustic samples; the engine uses its synth voices
/*@BUILD:include:src/engine.js@*/
/*@BUILD:include:src/setlists.js@*/
const state={ bpm:120, volume:0.85, running:false };
let meters=[];
let ramp={on:false,startBpm:80,amount:5,everyBars:4}, trainer={on:false,playBars:2,muteBars:2};
let segBars=0, segBarCount=0, pendingAdvance=false;
let masterBeat=0, masterBeatTime=0, muteWindows=[];
function setBpm(v){ state.bpm=Math.max(30,Math.min(300,Math.round(v))); }
function advanceMaster(ahead){
const mbpb=masterBeatsPerBar();
while(masterBeatTime<ahead){
if(masterBeat%mbpb===0){
const barIndex=Math.floor(masterBeat/mbpb);
if(barIndex>0&&ramp.on&&(barIndex%ramp.everyBars===0)) setBpm(state.bpm+ramp.amount);
if(trainer.on){ const cyc=trainer.playBars+trainer.muteBars; if(cyc>0&&(barIndex%cyc)>=trainer.playBars) muteWindows.push({start:masterBeatTime,end:masterBeatTime+mbpb*(60/state.bpm)}); }
segBarCount=barIndex;
if(segBars>0&&barIndex>=segBars&&!pendingAdvance){ pendingAdvance=true; }
}
masterBeat++; masterBeatTime+=60/state.bpm;
}
if(audioCtx) muteWindows=muteWindows.filter(w=>w.end>audioCtx.currentTime-1);
}
function scheduler(){
const ahead=audioCtx.currentTime+SCHEDULE_AHEAD;
advanceMaster(ahead);
for(const m of meters){ while(m.nextTime<ahead){ scheduleMeterTick(m,m.nextTime); m.nextTime+=laneStepDur(m,m.tick); m.tick++; } }
if(pendingAdvance){ pendingAdvance=false; setTimeout(()=>gotoItem(idx+1,true),0); }
}
/* ========================= PLAYER ============================================= */
let setlist=null, idx=0;
const BUILTIN = SEED_SETLISTS.map((sl) => ({ title: sl.title, items: sl.items.map(([n, p]) => ({ name: n, ...patchToSetup(p) })) }));
function buildMeters(lanes){
return (lanes||[]).map(c=>{
const p=parseGroups(c.groupsStr);
return {groupsStr:c.groupsStr,groups:p.groups,beatsPerBar:p.beatsPerBar,groupStarts:p.groupStarts,
stepsPerBeat:c.stepsPerBeat||1,sound:c.sound,beatsOn:(c.beatsOn||[]).slice(),poly:!!c.poly,swing:!!c.swing,enabled:c.enabled!==false,
tick:0,nextTime:0,vq:[],vqPtr:0,currentStep:-1,currentBar:0};
});
}
function loadSetup(s){
ramp=s.ramp?{...s.ramp}:{on:false,startBpm:80,amount:5,everyBars:4};
trainer=s.trainer?{...s.trainer}:{on:false,playBars:2,muteBars:2};
segBars=s.bars||0; segBarCount=0;
setBpm(s.bpm||120);
meters=buildMeters(s.lanes);
renderBar();
}
function startAudio(){
ensureAudio(); audioCtx.resume(); state.running=true;
if(ramp.on) setBpm(ramp.startBpm);
const t0=audioCtx.currentTime+0.08;
for(const m of meters){ m.tick=0; m.nextTime=t0; m.vq=[]; m.vqPtr=0; m.currentStep=-1; m.currentBar=0; }
masterBeat=0; masterBeatTime=t0; muteWindows=[]; pendingAdvance=false;
schedulerTimer=setInterval(scheduler,LOOKAHEAD_MS); scheduler(); renderAll();
}
function stopAudio(){ state.running=false; clearInterval(schedulerTimer); schedulerTimer=null; pendingAdvance=false; for(const m of meters) m.currentStep=-1; renderAll(); }
function toggle(){ state.running?stopAudio():startAudio(); }
function gotoItem(i,keepPlaying){
if(!setlist||!setlist.items.length) return;
const n=setlist.items.length; idx=((i%n)+n)%n;
const wasRunning=state.running||keepPlaying;
if(state.running){ clearInterval(schedulerTimer); schedulerTimer=null; state.running=false; }
loadSetup(setlist.items[idx]);
if(wasRunning) startAudio(); else renderAll();
}
function loadSetlistObj(sl){ setlist=sl; idx=0; const wasRunning=state.running; if(wasRunning){clearInterval(schedulerTimer);schedulerTimer=null;state.running=false;} loadSetup(sl.items[0]); if(wasRunning) startAudio(); else renderAll(); }
let taps=[];
function tapTempo(){ const now=performance.now(); taps=taps.filter(t=>now-t<2000); taps.push(now);
if(taps.length>=2){ let s=0; for(let i=1;i<taps.length;i++) s+=taps[i]-taps[i-1]; setBpm(60000/(s/(taps.length-1))); ramp.on=false; renderAll(); } }
let knobAngle=0;
function nudge(d){ ramp.on=false; setBpm(state.bpm+d); knobAngle+=d*9; $("enc").style.setProperty("--a",knobAngle+"deg"); renderAll(); }
/* ===================== RENDER: 320×240 colour IPS TFT (ST7789) =============== */
const TFT_W=320, TFT_H=240;
const tft=$("tft"), tc=tft.getContext("2d");
(function(){ const dpr=Math.max(1,Math.min(3,window.devicePixelRatio||1));
tft.width=TFT_W*dpr; tft.height=TFT_H*dpr; tc.scale(dpr,dpr); })(); // hi-DPI backing → crisp type
function drawTFT(){
const m=meters[0];
const g=tc.createLinearGradient(0,0,0,TFT_H); g.addColorStop(0,"#0b0f18"); g.addColorStop(1,"#05070c");
tc.fillStyle=g; tc.fillRect(0,0,TFT_W,TFT_H);
// header
tc.textBaseline="middle"; tc.textAlign="left";
tc.font='600 13px "Segoe UI",system-ui,sans-serif'; tc.fillStyle="#5b7a93";
tc.fillText("♪ "+(setlist?((idx+1)+"/"+setlist.items.length):"/"), 14, 18);
tc.textAlign="right"; tc.fillStyle = state.running ? "#2fe07a" : "#6b7787";
tc.fillText(state.running?"▶ PLAY":"■ STOP", TFT_W-14, 18);
tc.fillStyle="#13283c"; tc.fillRect(14,32,TFT_W-28,1);
// tempo — the hero number
tc.textBaseline="alphabetic"; tc.textAlign="left";
tc.fillStyle="#1fb6f0"; tc.font='800 86px "Segoe UI",system-ui,sans-serif';
const bpm=String(state.bpm); tc.fillText(bpm, 18, 134);
const bx=18+tc.measureText(bpm).width+9;
tc.fillStyle="#5b7a93"; tc.font='700 17px "Segoe UI",system-ui,sans-serif'; tc.fillText("BPM", bx, 96);
tc.font='600 18px "Segoe UI",system-ui,sans-serif'; tc.fillText("♩ "+(m?(m.groupsStr.replace(/[^0-9+]/g,"")||m.beatsPerBar):""), bx, 122);
// item name — centred, ellipsised
tc.textAlign="center"; tc.fillStyle="#e9eff7"; tc.font='600 23px "Segoe UI",system-ui,sans-serif';
let nm=setlist?(setlist.items[idx].name||"—"):"—";
if(tc.measureText(nm).width>TFT_W-28){ while(nm.length>1 && tc.measureText(nm+"…").width>TFT_W-28) nm=nm.slice(0,-1); nm+="…"; }
tc.fillText(nm, TFT_W/2, 180);
// bottom strip
tc.fillStyle="#13283c"; tc.fillRect(14,200,TFT_W-28,1);
tc.textAlign="left"; tc.fillStyle="#7f8b9a"; tc.font='600 14px "Segoe UI",system-ui,sans-serif';
tc.fillText(state.running&&m ? ("BAR "+(m.currentBar+1)+" BEAT "+(Math.floor(m.currentStep/m.stepsPerBeat)+1||"")) : "READY", 14, 224);
if(segBars>0){ const rem=Math.max(0,segBars-(m?m.currentBar:0));
tc.textAlign="right"; tc.fillStyle="#ffd166"; tc.font='700 15px "Segoe UI",system-ui,sans-serif';
tc.fillText((state.running?rem:segBars)+" BARS", TFT_W-14, 224); }
}
/* ========== RENDER: 4×16 WS2812 matrix — beat row + 3 subdivision rows ========= */
const LED_COLS=16, LED_ROWS=4, CYAN="#0AB3F7", AMBER="#ffd166", SUB="#2fe0a0";
let gridLeds=[];
function buildBar(){
const box=$("leds"); box.innerHTML=""; gridLeds=[[],[],[],[]];
for(let r=LED_ROWS-1;r>=0;r--){ // top row built first → the beat row (r=0) sits at the bottom
const row=document.createElement("div"); row.className="ledrow"+(r===0?" beatrow":"");
for(let c=0;c<LED_COLS;c++){ const d=document.createElement("div"); d.className="npx"; row.appendChild(d); gridLeds[r][c]=d; }
box.appendChild(row);
}
}
function setPx(led,bg,sh){ led.style.background=bg; led.style.boxShadow=sh||"none"; }
function renderBar(){
const ref=meters[0], bpb=ref?ref.beatsPerBar:0;
// beats come from lane 1; subdivisions from the finest lane sharing that beat grid
let disp=ref; if(ref){ for(const m of meters){ if(!m.poly && m.beatsPerBar===ref.beatsPerBar && m.stepsPerBeat>disp.stepsPerBeat) disp=m; } }
const spb=disp?disp.stepsPerBeat:1;
let curBeat=-1, curSub=-1;
if(state.running&&disp&&disp.currentStep>=0){ curBeat=Math.floor(disp.currentStep/spb); curSub=disp.currentStep%spb; }
for(let r=0;r<LED_ROWS;r++) for(let c=0;c<LED_COLS;c++){
const led=gridLeds[r][c];
if(c>=bpb){ setPx(led,"#0c0e10"); continue; } // unused column on the fixed matrix
const grp=ref.groupStarts.has(c);
if(r===0){ // BEAT row (bottom)
if(c===curBeat){ const col=grp?AMBER:CYAN; setPx(led,col,"0 0 9px "+col+", inset 0 0 3px #fff"); }
else setPx(led, grp?"rgba(255,209,102,.26)":"rgba(10,179,247,.15)");
} else if(r<spb && c===curBeat){ // SUBDIVISION rows — climb in the current beat's column
if(curSub>=r) setPx(led,SUB,"0 0 7px "+SUB); // reached this subdivision
else setPx(led,"rgba(47,224,160,.10)"); // ahead: faint slot showing the ladder this beat will climb
} else setPx(led,"#0c0e10"); // off (no such subdivision, or not the current beat)
}
}
function renderAll(){ drawTFT(); renderBar(); $("bPlay").textContent=state.running?"■":"▶"; $("bPlay").classList.toggle("on",state.running);
$("enc").style.setProperty("--a", knobAngle+"deg"); }
function draw(){
if(audioCtx&&state.running){ const now=audioCtx.currentTime;
for(const m of meters){ while(m.vqPtr<m.vq.length&&m.vq[m.vqPtr].time<=now){ m.currentStep=m.vq[m.vqPtr].step; m.currentBar=m.vq[m.vqPtr].bar; m.vqPtr++; } } }
drawTFT(); renderBar();
requestAnimationFrame(draw);
}
/* ========================= LOAD / VALIDATE =================================== */
function setStatus(msg,ok){ const s=$("status"); s.textContent=msg; s.className="status "+(ok?"ok":"err"); }
function loadConfig(text,quiet){
text=(text||"").trim(); if(!text){ if(!quiet) setStatus("Paste a patch or set-list code first.",false); return false; }
let payload=text, kind=null;
const m=text.match(/[#?&](p|sl)=([^&\s]+)/);
if(m){ kind=m[1]; payload=m[2]; }
try{ payload=decodeURIComponent(payload); }catch(e){}
try{
if(kind==="sl" || (kind!=="p" && !/[;:]/.test(payload))){
const sl=codeToSetlist(payload);
if(!sl.items.length) throw new Error("set list has no items");
loadSetlistObj(sl);
setStatus("✓ Loaded set list “"+sl.title+"” — "+sl.items.length+" item(s).",true); return true;
}
const setup=patchToSetup(payload);
if(!setup.lanes.length) throw new Error("no valid lanes (need e.g. kick:4)");
loadSetlistObj({title:"Pasted patch",items:[{name:"Patch",...setup}]});
setStatus("✓ Loaded patch — "+setup.lanes.length+" lane(s), "+setup.bpm+" BPM"+(setup.bars?", "+setup.bars+" bars":"")+(setup.ramp.on?", ramp":"")+".",true); return true;
}catch(e){ if(!quiet) setStatus("✗ Invalid configuration: "+e.message,false); return false; }
}
function loadStored(){
let lists=[]; try{ lists=JSON.parse(localStorage.getItem("metronome.setlists")||"[]"); }catch(e){}
const sel=$("storedSel"); sel.innerHTML='<option value="">— choose a set list —</option>';
const og1=document.createElement("optgroup"); og1.label="Built-in";
BUILTIN.forEach((sl,i)=>{ const o=document.createElement("option"); o.value="b"+i; o.textContent=sl.title+" ("+sl.items.length+")"; og1.appendChild(o); });
sel.appendChild(og1);
if(lists.length){ const og2=document.createElement("optgroup"); og2.label="Your saved set lists";
lists.forEach((sl,i)=>{ const o=document.createElement("option"); o.value="s"+i; o.textContent=(sl.title||("Set list "+(i+1)))+" ("+(sl.items?sl.items.length:0)+")"; og2.appendChild(o); });
sel.appendChild(og2); }
sel._lists=lists; sel._builtin=BUILTIN;
}
/* ========================= WIRING ============================================ */
$("bPlay").onclick=toggle;
$("bPrev").onclick=()=>gotoItem(idx-1,state.running);
$("bNext").onclick=()=>gotoItem(idx+1,state.running);
$("bTap").onclick=tapTempo;
$("bLoad").onclick=()=>loadConfig($("cfg").value);
$("storedSel").onchange=(e)=>{ const v=e.target.value; if(!v) return;
const sl = v[0]==="b" ? e.target._builtin[+v.slice(1)] : e.target._lists[+v.slice(1)]; if(!sl) return;
loadSetlistObj({title:sl.title,items:(sl.items||[]).map(it=>({...it}))}); setStatus("✓ Loaded set list “"+(sl.title||"set list")+"”.",true); };
/* EC11 rotary encoder — turn it (mouse wheel or vertical drag) for tempo */
(function(){ const k=$("enc"); let drag=false, lastY=0, acc=0;
k.addEventListener("wheel",(e)=>{ e.preventDefault(); nudge(e.deltaY<0 ? (e.shiftKey?5:1) : (e.shiftKey?-5:-1)); }, {passive:false});
k.addEventListener("pointerdown",(e)=>{ drag=true; lastY=e.clientY; acc=0; k.setPointerCapture(e.pointerId); });
k.addEventListener("pointermove",(e)=>{ if(!drag) return; acc+=lastY-e.clientY; lastY=e.clientY; while(Math.abs(acc)>=5){ nudge(acc>0?1:-1); acc+=acc>0?-5:5; } });
k.addEventListener("pointerup",()=>{ drag=false; }); k.addEventListener("pointercancel",()=>{ drag=false; });
})();
/* theme toggle — cycles system → light → dark; shares the editor's "metronome.theme" key */
const THEMES = ["system","light","dark"];
function effectiveTheme(p){ return p==="system" ? (matchMedia("(prefers-color-scheme: light)").matches?"light":"dark") : p; }
function themePref(){ try{ const p=localStorage.getItem("metronome.theme"); return (p==="light"||p==="dark"||p==="system")?p:"system"; }catch(e){ return "system"; } }
function applyTheme(p){
try{ localStorage.setItem("metronome.theme",p); }catch(e){}
document.documentElement.dataset.theme = effectiveTheme(p);
$("themeBtn").textContent = p==="system" ? "◐" : p==="light" ? "☀" : "☾";
$("themeBtn").title = "Theme: "+p+" (click to cycle: system → light → dark)";
}
$("themeBtn").onclick = ()=> applyTheme(THEMES[(THEMES.indexOf(themePref())+1)%THEMES.length]);
matchMedia("(prefers-color-scheme: light)").addEventListener("change", ()=>{ if(themePref()==="system") applyTheme("system"); });
applyTheme(themePref());
addEventListener("keydown",(e)=>{
const t=e.target, tag=t?t.tagName:""; if(tag==="TEXTAREA"||tag==="INPUT"||tag==="SELECT") return;
const k=e.key;
if(k===" "||e.code==="Space"){ e.preventDefault(); toggle(); }
else if(k==="ArrowRight"){ e.preventDefault(); gotoItem(idx+1,state.running); }
else if(k==="ArrowLeft"){ e.preventDefault(); gotoItem(idx-1,state.running); }
else if(k==="ArrowUp"){ e.preventDefault(); nudge(e.shiftKey?10:1); }
else if(k==="ArrowDown"){ e.preventDefault(); nudge(e.shiftKey?-10:-1); }
else if(k==="t"||k==="T") tapTempo();
});
/* ========================= INIT ============================================== */
buildBar();
loadStored();
if(location.hash && /(p|sl)=/.test(location.hash)) loadConfig(location.hash, true);
if(!setlist){ setlist=BUILTIN[0]; idx=0; loadSetup(setlist.items[0]); }
renderAll();
requestAnimationFrame(draw);
</script>
</body>
</html>