Replace the single 16-px strip with a 4×16 WS2812 matrix (four 16-px strips, still PIO-driven on the RP2040): - bottom row = the beat (cyan downbeats / amber group-starts, current beat bright, the rest a dim grid) — separated from the rows above by a divider; - the three rows above stack the CURRENT beat's subdivisions as they pass: a column climbs row-by-row with each subdivision and resets on the next beat, with faint "slots" showing the ladder it will climb. Subdivisions are driven by the finest lane that shares lane 1's beat grid (non-poly, same beatsPerBar, max stepsPerBeat) — so an 8th-note hat shows one row, 16ths show three, straight quarters show none. Also fixed a stray "#05measure" typo left in the old .npx border rule. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
453 lines
26 KiB
HTML
453 lines
26 KiB
HTML
<!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 PM‑1 — as‑built (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 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 }
|
||
|
||
/* ---- 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 }
|
||
|
||
/* ---- 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 PM‑1</b> · as‑built (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">PM‑1 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="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>USB‑C</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>set‑list code</b>, or a
|
||
<code>#p=…</code>/<code>#sl=…</code> link.</p>
|
||
<label for="cfg" class="hint">Patch / set‑list code / share link</label>
|
||
<textarea id="cfg" spellcheck="false" placeholder="v1;t120;kick:4;snare:4=.X.X;hatClosed:4/2 …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: 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(){ 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>
|