metronome/grid.html
Me Here 400d896518 Add PM_G-1 "Grid" form factor (Pimoroni Pico Scroll Pack) + Rust core/driver plan
New form factor: a plain RP2040 Pico + Pico Scroll Pack (PIM545) -- a 17x7
single-colour LED matrix + 4 buttons. The 7x17 matrix maps onto the editor's
lane x step pad grid.

- pico-scroll/: CircuitPython firmware (DEVICE_ID "G"). Engine/scheduler/SysEx/
  live-sync copied verbatim from pico-explorer (engine byte-identical, so it stays
  on the track-format conformance lineage); vendored bulk-framebuffer IS31FL3731
  driver (pins/map verified from pimoroni-pico); three LED views (Grid/Pendulum/BPM);
  4-button input. Audio over USB-MIDI (no onboard speaker); optional P_BUZZER.
- grid.html + info-grid.html: widget page (canvas mirrors the 3 LED views) + spec
  page with a ~$29 BOM.
- Registered in build.sh (precompile + ASCII assert + pm_g1_circuitpy.zip), deploy.sh,
  embed.js, embed.html, index.html gallery, and both editors' FW_PATHS (device id G).
- docs/rust-port.md: core/driver architecture (pm-core no_std engine+protocol; per-board
  drivers behind embedded-hal/embedded-graphics traits). CLAUDE.md + livesync-protocol.md
  note the new edition + device id.

Python firmware stays in parallel with Rust (no abandonment yet).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:30:15 -05:00

326 lines
16 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 PM_G-1 - Grid (Pimoroni Pico Scroll Pack / RP2040)</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,@BUILD:favicon@">
<script>
/* ?embed=1 -> strip site chrome + auto-size to the host */
(function(){ if(!/[?&]embed=1/.test(location.search)) return;
document.documentElement.dataset.embed="1";
function ph(){ try{ parent.postMessage({type:"varasys-h",h:Math.ceil(document.documentElement.getBoundingClientRect().height)},"*"); }catch(e){} }
addEventListener("load",ph); addEventListener("resize",ph); setTimeout(ph,300); setTimeout(ph,1000);
})();
</script>
<!--
PM_G-1 "Grid" - the off-the-shelf Pimoroni Pico Scroll Pack (PIM545) on a plain Raspberry Pi
Pico (RP2040) as a button-driven polymeter metronome. Mirrors the firmware
(../pico-scroll/app.py) visually: a 17x7 single-colour white LED matrix + 4 buttons (A/B/X/Y).
The 7-row x 17-column matrix IS the editor's lane x step pad grid in miniature. Three views
(button B-hold or the on-screen toggle cycles): Grid, Pendulum, BPM. Shares src/engine.js.
-->
<script>
(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{ --bg1:#12151c; --bg2:#05070a; --txt:#c7d0db; --muted:#7f8b9a; --link:#6cb6ff;
--panel-bd:#2a313c; --cyan:#0AB3F7; --panel-bg:#161b22; --field-bg:#0e1116; --field-bd:#2a313c; }
:root[data-theme="light"]{ --bg1:#f5f8fc; --bg2:#dde4ec; --txt:#1e2630; --muted:#5c6776; --link:#1769c4;
--panel-bd:#d2dae4; --panel-bg:#ffffff; --field-bg:#f1f4f8; --field-bd:#d2dae4 }
body{ margin:0; min-height:100vh; padding:26px 14px 46px;
background:radial-gradient(circle at 50% -8%, var(--bg1), var(--bg2)); color:var(--txt);
display:flex; flex-direction:column; align-items:center; gap:16px }
a{ color:var(--link) }
/* the green Raspberry Pi Pico PCB carrying the Scroll Pack */
.device{ width:100%; max-width:380px; position:relative; border-radius:14px; padding:14px 14px 16px;
background:
radial-gradient(rgba(255,255,255,.03) .6px, transparent .7px) 0 0/3px 3px,
linear-gradient(180deg, #0c5a3a, #073f29);
border:1px solid #04301f;
box-shadow:0 26px 52px rgba(0,0,0,.55), inset 0 1px 0 rgba(255,255,255,.10) }
.pcbtop{ display:flex; align-items:center; justify-content:space-between; margin:0 4px 10px }
.dev-logo{ height:15px; filter:brightness(0) invert(1); opacity:.82 }
.silk{ display:flex; align-items:center; gap:7px; color:#bfe6d3 }
.silk .model{ font-size:8.5px; text-transform:uppercase; letter-spacing:.16em; opacity:.85 }
.pin{ font-size:7.5px; color:#bfe6d3; letter-spacing:.12em; text-transform:uppercase; opacity:.65 }
/* the matrix panel: black solder mask, the 119 LEDs rendered on a canvas */
.screen-wrap{ padding:10px 12px; border-radius:8px;
background:linear-gradient(180deg,#0a0c0f,#040506); border:1px solid #02030a;
box-shadow:inset 0 2px 10px rgba(0,0,0,.85), 0 1px 0 rgba(255,255,255,.05) }
#screen{ display:block; width:100%; height:auto; border-radius:3px; image-rendering:pixelated }
/* 4 buttons in a row below the matrix (Pimoroni Pico-pack layout: A B on the left, X Y on the right) */
.btnrow{ display:grid; grid-template-columns:repeat(4,1fr); gap:8px; margin:12px 4px 0 }
.ebtn{ padding:9px 0 6px; border-radius:9px; cursor:pointer; text-align:center;
border:1px solid rgba(0,0,0,.4); background:linear-gradient(180deg,#1b2330,#0e141d); color:#dfe7f1;
font-size:13px; font-weight:800; letter-spacing:.04em;
box-shadow:0 3px 5px rgba(0,0,0,.35), inset 0 1px 0 rgba(255,255,255,.08) }
.ebtn small{ display:block; margin-top:2px; font-size:7.5px; font-weight:700; opacity:.6; letter-spacing:.04em; text-transform:uppercase }
.ebtn:active{ transform:translateY(2px); box-shadow:0 1px 2px rgba(0,0,0,.3) }
.hint{ max-width:380px; text-align:center; font-size:11px; color:var(--muted); line-height:1.55 }
[data-embed] .hint{ display:none !important }
</style>
</head>
<body>
/*@BUILD:include:src/header.html@*/
<h1 class="ff-title">PM_G1 Grid</h1>
<p class="ff-sum">Offtheshelf — the Pimoroni <b>Pico Scroll Pack</b> (a 17×7 white LED matrix + 4 buttons) on a plain Raspberry Pi Pico. The matrix <i>is</i> the editor's lane × step pad grid in miniature: rows are lanes, columns are steps, brightness is accent / normal / ghost. Edit on the web with <b>Live sync</b>; the device mirrors play/stop/tempo/track both ways.</p>
<div class="device">
<div class="pcbtop">
<div class="silk"><img class="dev-logo" src="data:image/png;base64,@BUILD:logo-dark@" alt="VARASYS — Simplifying Complexity" /><span class="model">PM_G1 Grid</span></div>
<span class="pin">RP2040 · 17×7</span>
</div>
<div class="screen-wrap"><canvas id="screen" width="306" height="126" aria-label="17 by 7 LED metronome display"></canvas></div>
<div class="btnrow">
<button class="ebtn" id="btnA" type="button">A<small>play</small></button>
<button class="ebtn" id="btnB" type="button">B<small>track</small></button>
<button class="ebtn" id="btnX" type="button">X<small>bpm</small></button>
<button class="ebtn" id="btnY" type="button">Y<small>+bpm</small></button>
</div>
</div>
<div class="hint">Four buttons: <b>A</b> = play/stop (hold = cycle view: Grid → Pendulum → BPM);
<b>B</b> = next track (hold = next set list); <b>X</b> / <b>Y</b> = tempo /+ (hold to repeat, ±5 after ~1.5 s).
Keyboard: A / B / X / Y, space = play, V = cycle view.</div>
/*@BUILD:include:src/progbox.html@*/
<p class="ff-link pageonly"><a href="/info-grid.html">Wiring, parts &amp; firmware to flash →</a></p>
<script>
const APP_VERSION = "v0.0.1-dev";
const $ = (id) => document.getElementById(id);
/* ========================= ENGINE (shared; synth voices only) ================= */
const SAMPLES = {};
/*@BUILD:include:src/engine.js@*/
/*@BUILD:include:src/setlists.js@*/
const state = { bpm:120, volume:0.85, running:false };
let meters = [], muteWindows = [];
function setBpm(v){ state.bpm = Math.max(5, Math.min(300, Math.round(v))); if(window.progRefresh) progRefresh(); }
function scheduler(){
const ahead = audioCtx.currentTime + SCHEDULE_AHEAD;
for(const m of meters){ while(m.nextTime < ahead){ scheduleMeterTick(m, m.nextTime); m.nextTime += laneStepDur(m, m.tick); m.tick++; } }
}
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,gainDb:c.gainDb||0,
tick:0,nextTime:0,vq:[],vqPtr:0,currentStep:-1,currentBar:0}; });
}
function startAudio(){
ensureAudio(); audioCtx.resume(); state.running=true;
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; }
muteWindows=[];
schedulerTimer=setInterval(scheduler,LOOKAHEAD_MS); scheduler();
}
function stopAudio(){ state.running=false; clearInterval(schedulerTimer); schedulerTimer=null; for(const m of meters) m.currentStep=-1; }
function toggle(){ state.running ? stopAudio() : startAudio(); }
/* ========================= TRACKS (seed grooves, with set-list grouping) ========= */
let tracks = SEED_SETLISTS.flatMap(sl => sl.items.map(([n,p]) => ({ name:n, sl: sl.title, ...patchToSetup(p) })));
let trackIdx = 0;
function tracksFromHash(){
const m=(location.hash||"").match(/[#&](p|sl)=([^&]+)/); if(!m) return null;
let payload=m[2]; try{ payload=decodeURIComponent(payload); }catch(e){}
try{
if(m[1]==="sl"){ const sl=codeToSetlist(payload); return sl.items.length ? sl.items.map(it=>({...it, sl: sl.title})) : null; }
const s=patchToSetup(payload); return s.lanes.length ? [{name:"Patch", sl:"Patch", ...s}] : null;
}catch(e){ return null; }
}
function loadTrack(i){
const n=tracks.length; if(!n) return; trackIdx=((i%n)+n)%n;
const t=tracks[trackIdx]; setBpm(t.bpm||120); meters=buildMeters(t.lanes);
const was=state.running; if(was){ clearInterval(schedulerTimer); schedulerTimer=null; state.running=false; }
if(was) startAudio();
}
// B-hold jumps to the FIRST item of the next set list (matches the firmware's switch_setlist)
function nextSetlist(){
const cur = tracks[trackIdx].sl;
for(let i=1; i<=tracks.length; i++){
const j = (trackIdx + i) % tracks.length;
if(tracks[j].sl !== cur){ loadTrack(j); return; }
}
}
/* ========================= 17x7 MATRIX (canvas mirrors the firmware's LED views) === */
const NX = 17, NY = 7;
const cv=$("screen"), g=cv.getContext("2d");
const CW = cv.width, CH = cv.height; // logical px before DPR scaling
(function(){ const dpr=Math.max(1,Math.min(3,window.devicePixelRatio||1)); cv.width=CW*dpr; cv.height=CH*dpr; g.scale(dpr,dpr); })();
const cellX = CW / NX, cellY = CH / NY, rad = Math.min(cellX, cellY) * 0.34;
let view = 0; // 0 Grid, 1 Pendulum, 2 BPM
let beatFlash = 0; // decays each frame; 1 on a fresh beat
const VIEW_NAMES = ["Grid","Pendulum","BPM"];
// 3x5 digit glyphs (bit2 = leftmost column) - same shapes the firmware draws
const DIGITS = { '0':[7,5,5,5,7],'1':[2,6,2,2,7],'2':[7,1,7,4,7],'3':[7,1,7,1,7],'4':[5,5,7,1,1],
'5':[7,4,7,1,7],'6':[7,4,7,5,7],'7':[7,1,2,2,2],'8':[7,5,7,5,7],'9':[7,5,7,1,7] };
function lvlBright(lvl){ return lvl===2 ? 1.0 : lvl===1 ? 0.30 : lvl===3 ? 0.09 : 0; }
function drawMatrix(bright){
g.fillStyle = "#06080b"; g.fillRect(0,0,CW,CH);
for(let y=0; y<NY; y++) for(let x=0; x<NX; x++){
const cx = (x+0.5)*cellX, cy = (y+0.5)*cellY;
const v = Math.max(0, Math.min(1, bright[y][x]||0));
// an "off" LED is a faint dark dot so the whole 17x7 grid stays visible
g.beginPath(); g.arc(cx, cy, rad, 0, Math.PI*2);
g.fillStyle = "rgba(255,255,255,0.05)"; g.fill();
if(v > 0.01){
if(v > 0.5){ g.save(); g.shadowColor="rgba(255,255,255,0.7)"; g.shadowBlur=rad*1.6; }
g.beginPath(); g.arc(cx, cy, rad, 0, Math.PI*2);
g.fillStyle = "rgba(255,255,255," + v.toFixed(3) + ")"; g.fill();
if(v > 0.5) g.restore();
}
}
}
function blankBright(){ const b=[]; for(let y=0;y<NY;y++){ b.push(new Array(NX).fill(0)); } return b; }
function renderGrid(){
const b = blankBright();
const n = Math.min(meters.length, NY);
const y0 = (NY - n) >> 1;
for(let li=0; li<n; li++){
const L = meters[li], y = y0 + li;
const steps = (L.beatsOn||[]).length || L.beatsPerBar*L.stepsPerBeat;
const off = steps <= NX ? ((NX - steps) >> 1) : 0;
const lit = state.running ? L.currentStep : -1;
for(let s=0; s<steps; s++){
const col = steps <= NX ? (s + off) : Math.floor(s*NX/steps);
const lvl = L.beatsOn[s]|0;
let v = (s === lit) ? (lvl ? 1.0 : 0.28) : lvlBright(lvl);
if(v > b[y][col]) b[y][col] = v;
}
}
drawMatrix(b);
}
function renderPendulum(){
const b = blankBright();
const M = meters[0];
if(M){
const steps = (M.beatsOn||[]).length || M.beatsPerBar*M.stepsPerBeat;
const beats = Math.max(1, Math.round(M.beatsPerBar));
let frac = 0;
if(state.running && M.currentStep >= 0) frac = (M.currentStep % steps) / steps;
const tri = frac < 0.5 ? frac*2 : 2*(1-frac);
const col = Math.round(tri * (NX-1));
const v = beatFlash > 0.5 ? 1.0 : (beatFlash > 0.05 ? 0.6 : 0.35);
for(let y=0; y<NY; y++) b[y][col] = v;
for(let bi=0; bi<beats; bi++){ const bc = Math.floor(bi*NX/beats); if(b[NY-1][bc] < 0.1) b[NY-1][bc] = 0.1; }
}
drawMatrix(b);
}
function renderBpm(){
const b = blankBright();
const s = String(state.bpm).slice(-3);
const w = s.length*4 - 1, x0 = (NX - w) >> 1, y0 = 1;
const v = state.running ? 1.0 : 0.5;
for(let i=0;i<s.length;i++){
const gph = DIGITS[s[i]]; if(!gph) continue;
const bx = x0 + i*4;
for(let ry=0; ry<5; ry++) for(let rx=0; rx<3; rx++)
if(gph[ry] & (1 << (2-rx))) b[y0+ry][bx+rx] = v;
}
drawMatrix(b);
}
function cycleView(){ view = (view+1) % 3; }
/* the engine queues voice events with timestamps; mirror the firmware's playhead off that queue */
function audioLatency(){ return audioCtx ? (audioCtx.outputLatency || audioCtx.baseLatency || 0) : 0; }
function frame(){
const now = audioCtx ? audioCtx.currentTime - audioLatency() : 0;
if(audioCtx && state.running){
let fired=false;
for(const m of meters){
while(m.vqPtr<m.vq.length && m.vq[m.vqPtr].time<=now){
const e=m.vq[m.vqPtr]; m.currentStep=e.step; m.currentBar=e.bar;
if((m.beatsOn[e.step]|0) > 0) fired=true;
m.vqPtr++;
}
if(m.vqPtr>512){ m.vq=m.vq.slice(m.vqPtr); m.vqPtr=0; }
}
if(fired) beatFlash = 1;
}
beatFlash = Math.max(0, beatFlash - 0.08);
if(view===2) renderBpm(); else if(view===1) renderPendulum(); else renderGrid();
requestAnimationFrame(frame);
}
/* ========================= INPUTS (4 buttons; tap vs hold like the firmware) ====== */
const HOLD_MS = 600, REPEAT_FIRST = 350, REPEAT_NEXT = 120, FAST_AFTER = 1500;
const pressT = { A:0, B:0 }; // press-start (ms) for A/B tap-vs-hold
const held = { X:0, Y:0 }; // press-start (ms) for X/Y repeat; 0 = released
const repNext = { X:0, Y:0 };
function nudge(dir){ const fast = held[dir>0?"Y":"X"] && (performance.now() - held[dir>0?"Y":"X"]) > FAST_AFTER;
setBpm(state.bpm + dir*(fast?5:1)); }
function bindBtn(id, downFn, upFn){
const b = $(id);
b.addEventListener("pointerdown", (e) => { e.preventDefault(); b.setPointerCapture?.(e.pointerId); downFn(); });
b.addEventListener("pointerup", (e) => { e.preventDefault(); upFn(); });
b.addEventListener("pointercancel", () => upFn());
b.addEventListener("pointerleave", () => { if (b.hasPointerCapture?.(0)) upFn(); });
}
// A: tap = play/stop, hold = cycle view. B: tap = next track, hold = next set list.
bindBtn("btnA", () => { pressT.A = performance.now(); },
() => { (performance.now()-pressT.A >= HOLD_MS) ? cycleView() : toggle(); });
bindBtn("btnB", () => { pressT.B = performance.now(); },
() => { (performance.now()-pressT.B >= HOLD_MS) ? nextSetlist() : loadTrack(trackIdx+1); });
bindBtn("btnX", () => { held.X = performance.now(); repNext.X = held.X + REPEAT_FIRST; nudge(-1); }, () => { held.X = 0; });
bindBtn("btnY", () => { held.Y = performance.now(); repNext.Y = held.Y + REPEAT_FIRST; nudge(1); }, () => { held.Y = 0; });
setInterval(() => { // hold-repeat for X / Y
const now = performance.now();
if(held.X && now >= repNext.X){ repNext.X = now + REPEAT_NEXT; nudge(-1); }
if(held.Y && now >= repNext.Y){ repNext.Y = now + REPEAT_NEXT; nudge(1); }
}, 30);
/* ========================= KEYBOARD ============================================ */
addEventListener("keydown", (e) => {
const tag = e.target ? e.target.tagName : ""; if(tag==="INPUT"||tag==="TEXTAREA"||tag==="SELECT") return;
const k = e.key.toLowerCase();
if(e.key === " "){ e.preventDefault(); toggle(); }
else if(k === "a"){ toggle(); }
else if(k === "v"){ cycleView(); }
else if(k === "b"){ loadTrack(trackIdx+1); }
else if(k === "x"){ if(!held.X){ held.X = performance.now(); repNext.X = held.X + REPEAT_FIRST; nudge(-1); } }
else if(k === "y"){ if(!held.Y){ held.Y = performance.now(); repNext.Y = held.Y + REPEAT_FIRST; nudge(1); } }
});
addEventListener("keyup", (e) => {
const k = e.key.toLowerCase();
if(k === "x") held.X = 0; else if(k === "y") held.Y = 0;
});
/* theme toggle + version */
/*@BUILD:include:src/chrome.js@*/
/* ========================= INIT ============================================== */
{ const ht = tracksFromHash(); if(ht) tracks = ht; }
loadTrack(0);
requestAnimationFrame(frame);
window.currentProgramString = function(){ var t = tracks[trackIdx] || {}; return setupToPatch({bpm:state.bpm, volume:state.volume, lanes:t.lanes||[]}); };
window.loadProgramString = function(plain){ var s = patchToSetup(plain); tracks = [{name:"Program", sl:"Program", ...s}]; trackIdx = 0; loadTrack(0); };
/*@BUILD:include:src/progbox.js@*/
</script>
/*@BUILD:include:src/footer.html@*/
</body>
</html>