metronome/player-asbuilt.html
Me Here 4f76783af4 As-built: arcade buttons, use-driven control layout, rear I/O, compact case
Reworked the as-built panel around how it's actually used:

- Buttons: replaced the tactile/keycap ("rubber key") buttons with glossy
  arcade pushbuttons, colour-keyed — cyan PREV/NEXT, a big green/red PLAY,
  amber TAP.
- Layout for use: the rotary encoder sits BELOW the screen (so turning it
  never hides the readout) with the buttons spread edge-to-edge underneath —
  PREV far left, NEXT far right, a bigger central PLAY (+ TAP) — so you're
  far less likely to hit the wrong one mid-performance.
- Rear I/O: external trigger in (footswitch), a 1/4" instrument pass-through
  with the click injected, and a shared 1/4" balanced-TRS main out; plus the
  monitor speaker and USB-C, each a labelled jack with a tooltip.
- Size: shrank the case to hug its content (max-width 560→380, trimmed
  padding and inter-section margins) — the dead margin is gone.

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

470 lines
29 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);
• controls arranged for use: an EC11 encoder below the screen (turning it
never hides the readout) with arcade pushbuttons spread below — PREV far
left, NEXT far right, a big central PLAY — so you don't hit the wrong one;
• rear I/O: external trigger in (footswitch), a 1/4" instrument pass-through
with the click injected, and a shared 1/4" balanced-TRS main out; plus a
MAX98357A-style monitor speaker, USB-C and a PWR LED, in a compact matte 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:22px 12px 40px;
background:radial-gradient(circle at 50% -8%, var(--bg1), var(--bg2));
color:var(--txt);
display:flex; flex-direction:column; align-items:center; gap:14px;
}
a{color:var(--link)}
.topbar{width:100%; max-width:380px; 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:380px; 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:16px 14px 14px;
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 2px 12px; }
.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:4px; width:max-content; margin:12px auto 3px;
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:4px }
.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 above (under the screen), arcade buttons spread below ----
wheel never hides the readout · PREV far-left / NEXT far-right · big central PLAY */
.controls{ display:flex; flex-direction:column; align-items:center; gap:13px; margin:14px 0 2px }
.enc-wrap{ display:flex; flex-direction:column; align-items:center; gap:5px }
.enc{ width:52px; height:52px; 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:12px; background:var(--cyan); border-radius:2px;
transform-origin:50% 19px; 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:.85 }
.keys{ display:flex; align-items:flex-end; justify-content:space-between; width:100%; padding:0 2px }
.key-mid{ display:flex; align-items:flex-end; gap:20px }
.key{ display:flex; flex-direction:column; align-items:center; gap:6px }
.abtn{ width:50px; height:50px; border-radius:50%; border:0; padding:0; cursor:pointer; position:relative; color:#fff; font-size:18px; line-height:1;
background:radial-gradient(circle at 36% 30%, rgba(255,255,255,.6), rgba(255,255,255,0) 42%), radial-gradient(circle at 50% 64%, var(--c1,#33d0ff), var(--c2,#0a7fb0));
box-shadow:0 0 0 3px #0b0d11, 0 0 0 4px #363c46, 0 5px 7px rgba(0,0,0,.5), inset 0 -3px 6px rgba(0,0,0,.35), inset 0 2px 4px rgba(255,255,255,.28);
text-shadow:0 1px 2px rgba(0,0,0,.45); user-select:none; transition:transform .05s, box-shadow .05s, filter .05s }
.abtn:active{ transform:translateY(2px); filter:brightness(.92);
box-shadow:0 0 0 3px #0b0d11, 0 0 0 4px #363c46, 0 2px 3px rgba(0,0,0,.5), inset 0 -2px 4px rgba(0,0,0,.4), inset 0 2px 4px rgba(255,255,255,.2) }
.abtn.nav{ --c1:#33d0ff; --c2:#0a7fb0 }
.abtn.tap{ --c1:#ffd56a; --c2:#c98a1f; color:#3a2a00; text-shadow:0 1px 1px rgba(255,255,255,.35); font-size:12px; font-weight:800; letter-spacing:.04em }
.abtn.play{ --c1:#4ce08e; --c2:#178f49; width:66px; height:66px; font-size:26px }
.abtn.play.on{ --c1:#ff6a6a; --c2:#a82828 }
.key small{ font-size:8px; color:var(--silk); letter-spacing:.1em; text-transform:uppercase; opacity:.85 }
/* ---- monitor speaker + rear I/O (1/4" jacks + USB-C) ---- */
.grille{ height:11px; margin:13px 8px 9px; border-radius:5px;
background:radial-gradient(circle, #000 1.1px, transparent 1.4px) 0 0/8px 8px; opacity:.5 }
.io{ display:flex; align-items:flex-start; justify-content:space-between; gap:6px; margin:0 2px;
padding:9px 8px 8px; border-radius:9px; background:#0c0f14; border:1px solid #05070a; box-shadow:inset 0 1px 3px rgba(0,0,0,.6) }
.jack{ flex:1; display:flex; flex-direction:column; align-items:center; gap:5px }
.jack i{ width:20px; height:20px; border-radius:50%; background:radial-gradient(circle at 40% 34%, #333a44, #07090c 72%);
border:2px solid #5b6470; box-shadow:inset 0 0 4px #000 }
.jack b{ font-size:7.5px; font-weight:700; color:var(--silk); letter-spacing:.05em; text-transform:uppercase; opacity:.85; text-align:center; line-height:1.3 }
.jack.usb i{ width:24px; height:10px; border-radius:4px; border:2px solid #5b6470; background:#07090c; margin-top:5px }
/* ---- load panel (same as the other pages) ---- */
.panel{ width:100%; max-width:380px; 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="enc-wrap"><div class="enc" id="enc" title="Tempo — scroll or drag to turn"></div><small>TEMPO</small></div>
<div class="keys">
<div class="key"><button class="abtn nav" id="bPrev" title="previous item"></button><small>Prev</small></div>
<div class="key-mid">
<div class="key"><button class="abtn play" id="bPlay" title="play / stop (Space)"></button><small>Play</small></div>
<div class="key"><button class="abtn tap" id="bTap" title="tap tempo (T)">TAP</button><small>Tap</small></div>
</div>
<div class="key"><button class="abtn nav" id="bNext" title="next item"></button><small>Next</small></div>
</div>
</div>
<div class="grille"></div>
<div class="io">
<div class="jack" title="External trigger in — footswitch to start/stop or tap tempo"><i></i><b>Trig In</b></div>
<div class="jack" title="Instrument in — 1/4&quot; pass-through; the click is mixed into your signal"><i></i><b>Inst In</b></div>
<div class="jack" title="Main out — 1/4&quot; balanced TRS (instrument + click); the shared output plug"><i></i><b>Out TRS</b></div>
<div class="jack usb" title="USB-C — power &amp; set-list transfer"><i></i><b>USB-C</b></div>
</div>
<div class="ledbar-cap">Trig in · 1/4″ inst passthrough (click injected) · shared 1/4″ balancedTRS out</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>