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>
326 lines
16 KiB
HTML
326 lines
16 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_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_G‑1 Grid</h1>
|
||
<p class="ff-sum">Off‑the‑shelf — 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_G‑1 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 & 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>
|