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>
433 lines
25 KiB
HTML
433 lines
25 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 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 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="ledbar" id="leds"></div>
|
||
<div class="ledbar-cap">16‑px 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>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: 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>
|