metronome/player-asbuilt.html
Me Here df213272ae Add "as-built" player variant: mono 128×64 OLED + 16-px WS2812 beat bar
New /player-asbuilt.html showing the PM-1 with parts you'd actually solder
for an RP2040 build, alongside the idealized /player.html:

- 128×64 MONOCHROME OLED (SSD1306 class): rendered as a true 1-bit
  framebuffer — drawn, then thresholded to crisp on/off pixels and scaled
  with image-rendering:pixelated — so the cramped real layout is honest
  (position / big BPM / grouping / scrolling name / bar·beat).
- Fixed 16-px WS2812 ("NeoPixel") RGB beat bar on a strip PCB: lights the
  first beatsPerBar slots (cyan downbeats, amber group-starts, dim others),
  the rest dark — showing the fixed-count hardware honestly.
- EC11 rotary encoder you actually turn (wheel / vertical drag) for tempo,
  tactile buttons, MAX98357A-style speaker grille, USB-C, PWR LED, matte case.

Shares the same firmware via src/engine.js + src/setlists.js (same seed set
lists, same scheduler); only the panel rendering differs. The device is fixed
dark hardware; the page chrome follows light/dark/system. build.sh + deploy.sh
now assemble/serve all three pages; player.html links to it ("As-built ↗").

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

433 lines
25 KiB
HTML
Raw Permalink 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 128×64 MONOCHROME OLED (SSD1306/SH1106 class): rendered as a true 1-bit
framebuffer (drawn, then thresholded to crisp on/off pixels, scaled with
image-rendering:pixelated) so the cramped real layout is honest;
• a fixed 16-pixel WS2812 ("NeoPixel") RGB beat bar (PIO-driven) — lights the
first beatsPerBar slots, cyan downbeats / amber group-starts / dim others;
• 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 }
/* ---- 128×64 monochrome OLED module ---- */
.oled-wrap{ display:flex; justify-content:center; margin:0 4px }
.oled-mod{ background:#0a0c0f; border:1px solid #000; border-radius:6px; padding:10px 12px;
box-shadow:inset 0 0 0 2px #1a1d22, 0 2px 4px rgba(0,0,0,.5); position:relative }
.oled-mod::before{ content:""; position:absolute; top:6px; right:6px; width:26px; height:5px; border-radius:2px;
background:repeating-linear-gradient(90deg,#2a2f36 0 2px,transparent 2px 4px); opacity:.6 } /* tiny pin header detail */
#oled{ display:block; width:336px; height:168px; image-rendering:pixelated; image-rendering:crisp-edges;
background:#000; border-radius:2px }
.oled-cap{ text-align:center; font-size:10px; color:var(--muted); margin-top:6px; letter-spacing:.02em }
/* ---- WS2812 RGB beat bar (fixed 16 px) ---- */
.ledbar{ display:flex; gap:5px; justify-content:center; align-items:center; margin:16px auto 4px; width:max-content;
background:linear-gradient(180deg,#10221c,var(--pcb)); border:1px solid #07140f; border-radius:5px; padding:7px 9px;
box-shadow:inset 0 1px 2px rgba(0,0,0,.6) }
.npx{ width:16px; height:16px; border-radius:3px; background:#0c0e10; border:1px solid #05measure;
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,.06) } /* 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="oled-wrap">
<div class="oled-mod">
<canvas id="oled" width="128" height="64" aria-label="128 by 64 monochrome OLED"></canvas>
</div>
</div>
<div class="oled-cap">0.96″ 128×64 mono OLED (SSD1306)</div>
<div class="ledbar" id="leds"></div>
<div class="ledbar-cap">16px WS2812 RGB beat bar</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: 128×64 mono OLED ========================== */
const oled=$("oled"), oc=oled.getContext("2d");
const OLED_ON=[150,220,255]; // cool-white/blue monochrome panel
let nameScroll=0;
function oledThreshold(){ // posterize the framebuffer to 1-bit, recolour to the panel
const img=oc.getImageData(0,0,128,64), d=img.data, r=OLED_ON[0], g=OLED_ON[1], b=OLED_ON[2];
for(let i=0;i<d.length;i+=4){ const on=d[i]>110; d[i]=on?r:0; d[i+1]=on?g:0; d[i+2]=on?b:0; d[i+3]=255; }
oc.putImageData(img,0,0);
}
function drawOLED(){
const m=meters[0];
oc.fillStyle="#000"; oc.fillRect(0,0,128,64);
oc.fillStyle="#fff"; oc.textBaseline="top";
// top status row
oc.font='7px "Courier New",monospace'; oc.textAlign="left";
oc.fillText(setlist?((idx+1)+"/"+setlist.items.length):"-/-", 0, 0);
oc.textAlign="right"; oc.fillText(state.running?"PLAY":"STOP", 128, 0);
// big BPM
oc.textAlign="left"; oc.font='bold 27px Arial,sans-serif';
const bpmStr=String(state.bpm); oc.fillText(bpmStr, 0, 8);
const bw=Math.min(oc.measureText(bpmStr).width+5, 96);
oc.font='8px "Courier New",monospace'; oc.fillText("BPM", bw, 12);
if(m){ oc.fillText((m.groupsStr.replace(/[^0-9+]/g,"")||String(m.beatsPerBar)), bw, 24); } // grouping e.g. 2+2+3
oc.fillRect(0,37,128,1); // separator
// item name (marquee if too wide)
oc.font='8px "Courier New",monospace';
const name=setlist?(setlist.items[idx].name||"-"):"-";
const nw=oc.measureText(name).width;
let nx=0;
if(nw>128){ const period=nw+24; nx=128-(nameScroll%period); }
oc.fillText(name, nx, 41);
// bottom row
oc.font='7px "Courier New",monospace'; oc.textAlign="left";
oc.fillText(state.running&&m ? ("bar "+(m.currentBar+1)+" beat "+(Math.floor(m.currentStep/m.stepsPerBeat)+1||"-")) : "ready", 0, 53);
if(segBars>0){ oc.textAlign="right"; const rem=Math.max(0,segBars-(m?m.currentBar:0)); oc.fillText((state.running?rem:segBars)+"b", 128, 53); }
oledThreshold();
}
/* ========================= RENDER: 16-px WS2812 beat bar ===================== */
const LED_COUNT=16, CYAN="#0AB3F7", AMBER="#ffd166";
function buildBar(){ const box=$("leds"); box.innerHTML=""; for(let i=0;i<LED_COUNT;i++){ const d=document.createElement("div"); d.className="npx"; box.appendChild(d); } }
function renderBar(){
const m=meters[0], bpb=m?m.beatsPerBar:0;
const cur=(state.running&&m)? Math.floor(m.currentStep/m.stepsPerBeat) : -1;
const els=$("leds").children;
for(let i=0;i<els.length;i++){
const led=els[i];
if(i>=bpb){ led.style.background="#0c0e10"; led.style.boxShadow="none"; continue; } // unused pixel on the fixed strip
const grp=m.groupStarts.has(i);
if(i===cur){ const c=grp?AMBER:CYAN; led.style.background=c; led.style.boxShadow="0 0 10px "+c+", inset 0 0 4px #fff"; }
else { led.style.background = grp?"rgba(255,209,102,.30)":"rgba(10,179,247,.20)"; led.style.boxShadow="none"; }
}
}
function renderAll(){ drawOLED(); 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++; } } }
nameScroll+=0.6; drawOLED(); 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>