Landing page: explorer.html widget mockup

A visual mockup of the Pimoroni Explorer Kit (PIM744) - yellow PCB body,
6 coloured buttons (A/B/C left, X/Y/Z right) flanking the central 320x240 LCD,
piezo + mini-breadboard at the bottom, USB-C / RP2350 silkscreen.

Canvas mirrors the firmware UI: VARASYS logo + version + run-state dot in the
header (3-channel lerp for the per-beat pulse, no more red-channel-only blend
bug); setlist tab + track name + big BPM + bar/time meters; pad grid up to
6 lanes with main-beat squares + subdivision circles + vertical gridlines.

Inputs: A = play/stop, B = tap, C = next setlist; X/Z = prev/next track with
350 ms first-repeat + 120 ms repeat; Y = -1 bpm (after 1.5 s held step = -5);
X+Z chord within 100 ms = +1 bpm (mirrors Y). Keyboard: A B C X Y Z + space.

Landing page (index.html) Explorer pane now points at /explorer.html with
h:500 (the LCD is half-height of the Kit's so the widget is more compact).
The /info-explorer.html embed handler still works for the "Specs & info" link.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-05-30 20:55:20 -05:00
parent cd619cfeb2
commit 05ce1d5ce4
4 changed files with 421 additions and 3 deletions

View file

@ -42,7 +42,7 @@ def build(name):
out.write_text(src)
return out.stat().st_size
for name in ("index.html","editor.html","editor-beta.html","player.html","teacher.html","stage.html","micro.html","showcase.html","kit.html",
for name in ("index.html","editor.html","editor-beta.html","player.html","teacher.html","stage.html","micro.html","showcase.html","kit.html","explorer.html",
"embed.html",
"info-editor.html","info-player.html","info-teacher.html","info-stage.html","info-micro.html","info-showcase.html","info-kit.html","info-explorer.html"):
print("built %s (%dKB)" % (name, build(name) // 1024))

View file

@ -40,7 +40,7 @@ fi
# stamp the version into the built copy only (source stays clean)
echo "deployed v$BUILD -> $DEST_DIR"
for f in index.html editor.html editor-beta.html player.html teacher.html stage.html micro.html showcase.html kit.html \
for f in index.html editor.html editor-beta.html player.html teacher.html stage.html micro.html showcase.html kit.html explorer.html \
embed.html \
info-editor.html info-player.html info-teacher.html info-stage.html info-micro.html info-showcase.html info-kit.html info-explorer.html; do
sed "s|const APP_VERSION = \"[^\"]*\";|const APP_VERSION = \"$BUILD\";|" "$DIST_DIR/$f" > "$DEST_DIR/$f"

418
explorer.html Normal file
View file

@ -0,0 +1,418 @@
<!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_X-1 - Explorer (Pimoroni PIM744 / RP2350)</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_X-1 "Explorer" - the off-the-shelf Pimoroni Explorer Kit (PIM744, RP2350) as a
button-driven polymeter metronome. Mirrors the firmware (../pico-explorer/app.py)
visually: 320x240 landscape ST7789V + 6 side buttons (A/B/C left, X/Y/Z right) +
piezo. No touchscreen, no joystick, no RGB - just buttons and an on-screen run dot.
Shares src/engine.js with every other device.
-->
<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; --device-bd:#33363c; --silk:#231b06; --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) }
/* Pimoroni-Explorer-style yellow PCB. Wider than tall to fit the 320x240 LCD + side buttons. */
.device{ width:100%; max-width:420px; position:relative; border-radius:14px; padding:12px 12px 14px;
background:
radial-gradient(rgba(0,0,0,.045) .6px, transparent .7px) 0 0/3px 3px,
linear-gradient(180deg, #e6c64b, #c19b25);
border:1px solid #8b6e1f;
box-shadow:0 26px 52px rgba(0,0,0,.55), inset 0 1px 0 rgba(255,255,255,.18) }
.pcbtop{ display:flex; align-items:center; justify-content:space-between; margin:0 4px 8px }
.dev-logo{ height:16px; filter:invert(15%) sepia(80%) saturate(360%) hue-rotate(355deg) brightness(35%) } /* tint dark on yellow PCB */
:root[data-theme="light"] .dev-logo{ filter:invert(8%) sepia(60%) saturate(0%) brightness(45%) }
.silk{ display:flex; align-items:center; gap:7px; color:var(--silk) }
.silk .model{ font-size:8.5px; text-transform:uppercase; letter-spacing:.16em; opacity:.85 }
.pin{ font-size:7.5px; color:var(--silk); letter-spacing:.12em; text-transform:uppercase; opacity:.7 }
/* main body: side button columns flanking a landscape LCD */
.body{ display:grid; grid-template-columns:auto 1fr auto; gap:9px; align-items:stretch }
.lcol, .rcol{ display:flex; flex-direction:column; justify-content:space-between; gap:6px; padding:6px 0 }
/* the buttons are coloured caps Pimoroni-style: A red, B amber, C teal, X violet, Y yellow, Z blue */
.ebtn{ width:46px; padding:10px 0 6px; border-radius:10px; cursor:pointer;
border:1px solid rgba(0,0,0,.35); color:#0b0e12; font-size:13px; font-weight:900; letter-spacing:.04em; text-transform:uppercase;
box-shadow:0 3px 5px rgba(0,0,0,.35), inset 0 1px 0 rgba(255,255,255,.4) }
.ebtn small{ display:block; margin-top:2px; font-size:7.5px; font-weight:700; opacity:.7; letter-spacing:.06em }
.ebtn:active{ transform:translateY(2px); box-shadow:0 1px 2px rgba(0,0,0,.3) }
#btnA{ background:radial-gradient(circle at 40% 30%, #ff8a8a, #db3838 70%, #9c1d1d) }
#btnB{ background:radial-gradient(circle at 40% 30%, #ffd084, #ec8a18 70%, #a55b06) }
#btnC{ background:radial-gradient(circle at 40% 30%, #8be3c8, #1faa86 70%, #0a6a52) }
#btnX{ background:radial-gradient(circle at 40% 30%, #c1aaff, #794ee0 70%, #4a23a6) }
#btnY{ background:radial-gradient(circle at 40% 30%, #fff19a, #e6c91a 70%, #9b8505); color:#1a1500 }
#btnZ{ background:radial-gradient(circle at 40% 30%, #98c9ff, #2a83f0 70%, #134a98) }
/* the ST7789V LCD: 320x240 landscape, deep black bezel */
.screen-wrap{ padding:6px; border-radius:8px;
background:linear-gradient(180deg,#05070a,#020406); border:1px solid #04060a;
box-shadow:inset 0 2px 8px rgba(0,0,0,.85), 0 1px 0 rgba(255,255,255,.06) }
#screen{ display:block; width:100%; height:auto; border-radius:4px; background:#06080c; image-rendering:pixelated }
/* PCB footer: piezo + breadboard suggestion + silk */
.pcbbot{ display:flex; align-items:center; justify-content:space-between; margin:10px 4px 0; padding:8px 4px 2px;
border-top:1px dashed rgba(0,0,0,.18); color:var(--silk) }
.piezo{ width:22px; height:22px; border-radius:50%; background:radial-gradient(circle at 50% 40%, #3a3a3a, #0c0c0c); border:2px solid #5b4a14; position:relative }
.piezo::after{ content:""; position:absolute; left:50%; top:50%; width:5px; height:5px; margin:-2.5px 0 0 -2.5px; border-radius:50%; background:#05070a }
.breadboard{ flex:1; height:14px; margin:0 12px; border-radius:3px; background:
repeating-linear-gradient(0deg, rgba(0,0,0,.18) 0 2px, transparent 2px 4px),
linear-gradient(180deg, #f6e8a3, #d8be57);
border:1px solid rgba(0,0,0,.22) }
.pcbbot .silk-tag{ font-size:8px; letter-spacing:.14em; text-transform:uppercase; opacity:.8 }
.hint{ max-width:420px; 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_X1 Explorer</h1>
<p class="ff-sum">Offtheshelf — the Pimoroni Explorer Kit (RP2350, 2.8″ LCD, 6 buttons, piezo) as a buttondriven sibling to the PM_K1 Kit. Edit grooves on the web with <b>Live sync</b>; the device mirrors play/stop/tempo/track changes 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_X1 Explorer</span></div>
<span class="pin">RP2350 · USBC</span>
</div>
<div class="body">
<div class="lcol">
<button class="ebtn" id="btnA" type="button">A<small>play</small></button>
<button class="ebtn" id="btnB" type="button">B<small>tap</small></button>
<button class="ebtn" id="btnC" type="button">C<small>list</small></button>
</div>
<div class="screen-wrap"><canvas id="screen" width="320" height="240" aria-label="metronome display"></canvas></div>
<div class="rcol">
<button class="ebtn" id="btnX" type="button">X<small>prev</small></button>
<button class="ebtn" id="btnY" type="button">Y<small>-bpm</small></button>
<button class="ebtn" id="btnZ" type="button">Z<small>next</small></button>
</div>
</div>
<div class="pcbbot">
<div class="piezo" title="Piezo speaker"></div>
<div class="breadboard" title="Mini breadboard (sensors / I/O)"></div>
<span class="silk-tag">Pimoroni Explorer · PIM744</span>
</div>
</div>
<div class="hint">All hardware, no touch: <b>A</b> = play/stop, <b>B</b> = tap, <b>C</b> = switch playlist.
<b>X</b> / <b>Z</b> = prev / next track; <b>Y</b> = tempo 1 (hold for 5). <b>X+Z</b> chord = tempo +1.
Hold buttons to repeat. Keyboard: A / B / C / X / Y / Z, space = play.</div>
/*@BUILD:include:src/progbox.html@*/
<p class="ff-link pageonly"><a href="/info-explorer.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 for C) === */
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();
}
// C cycles to the FIRST item of the next setlist (matches the firmware's set-list-tab swap)
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; }
}
}
/* ========================= SCREEN (canvas mirrors the firmware UI at 320x240) ==== */
const cv=$("screen"), g=cv.getContext("2d"), SW=320, SH=240;
(function(){ const dpr=Math.max(1,Math.min(3,window.devicePixelRatio||1)); cv.width=SW*dpr; cv.height=SH*dpr; g.scale(dpr,dpr); })();
const COL={ bg:"#06090e", txt:"#c7d0db", mute:"#788494", cyan:"#0AB3F7", amber:"#ff9b2e",
violet:"#967bff", green:"#2fe07a", dim:"#243240", btn:"#1c222c", panel:"#1C222C",
runIdle:"#2fe07a", runGo:"#ff5a5a", runPulse:"#ffec78", grid:"#1a2330" };
const PRIO={2:3,1:2,3:1};
let runPulse=0, beatIdx=-1, segStart=0;
function drawScreen(){
g.fillStyle=COL.bg; g.fillRect(0,0,SW,SH);
// ----- header (y 0..28) -----
g.textBaseline="alphabetic"; g.textAlign="left";
g.fillStyle=COL.cyan; g.font="700 14px 'Segoe UI',Roboto,Arial,sans-serif"; g.fillText("VARASYS",10,20);
g.fillStyle=COL.mute; g.font="600 9px 'Segoe UI',Roboto,Arial,sans-serif"; g.fillText("v"+(window.APP_VERSION||"0.0.1"),74,20);
// run dot (top-right corner; replaces the Kit's WS2812 RGB LED)
const dotX = SW-14, dotY = 13;
let dotCol = state.running ? COL.runGo : COL.runIdle;
if(runPulse > 0.02){
// 3-channel blend dotCol -> runPulse weighted by runPulse value
const t = runPulse;
const lerp = (a, b) => Math.round(a*(1-t) + b*t);
const rA = parseInt(dotCol.slice(1,3),16), gA = parseInt(dotCol.slice(3,5),16), bA = parseInt(dotCol.slice(5,7),16);
const rB = parseInt(COL.runPulse.slice(1,3),16), gB = parseInt(COL.runPulse.slice(3,5),16), bB = parseInt(COL.runPulse.slice(5,7),16);
dotCol = "#" + lerp(rA,rB).toString(16).padStart(2,"0") + lerp(gA,gB).toString(16).padStart(2,"0") + lerp(bA,bB).toString(16).padStart(2,"0");
}
g.fillStyle = dotCol;
g.beginPath(); g.arc(dotX, dotY, 4, 0, Math.PI*2); g.fill();
// MIDI / USB badges (small markers; mirror the firmware's icon spots)
g.fillStyle = COL.dim;
g.fillRect(dotX-22, dotY-5, 10, 10);
g.fillRect(dotX-38, dotY-5, 10, 10);
// divider
g.fillStyle=COL.panel; g.fillRect(0,28,SW,1);
// ----- setlist tab + CONT (y ~ 32..44) -----
const t = tracks[trackIdx] || {};
const slLabel = (t.sl || "Set list").slice(0,18) + " " + (trackIdx+1) + "/" + tracks.length;
g.fillStyle=COL.mute; g.font="600 11px 'Segoe UI',Roboto,Arial,sans-serif"; g.fillText(slLabel,10,42);
g.textAlign="right"; g.fillStyle=COL.dim; g.fillText("CONT",SW-10,42);
// track name (y 48..60)
g.textAlign="left"; g.fillStyle=COL.txt; g.font="700 14px 'Segoe UI',Roboto,Arial,sans-serif";
g.fillText((t.name||"-").slice(0,22),10,60);
// ----- BPM big (right) + time/bar (left) (y 56..96) -----
g.textAlign="right"; g.fillStyle=COL.txt; g.font="800 38px 'Segoe UI',Roboto,Arial,sans-serif";
g.fillText(String(state.bpm),SW-10,86);
g.textAlign="left"; g.fillStyle=COL.txt; g.font="600 11px 'Segoe UI',Roboto,Arial,sans-serif";
const seg = state.running ? Math.max(0, (audioCtx ? audioCtx.currentTime - audioLatency() : 0) - segStart) : 0;
const fmt = s => { s = Math.floor(s); return Math.floor(s/60) + ":" + (s%60).toString().padStart(2,"0"); };
g.fillText(fmt(seg), 10, 72);
const m0 = meters[0]; const mlen = m0 ? m0.beatsPerBar*(m0.stepsPerBeat||1) : 1;
const bar = state.running && m0 ? Math.max(1, Math.floor((m0.tick-1)/mlen) + 1) : "-";
g.fillStyle=COL.mute; g.fillText("bar " + bar, 10, 88);
// ----- pad grid (y 100..240) -----
const top = 100, gridH = SH - top - 6;
const n = Math.min(meters.length, 6);
if(n === 0) return;
const rowh = Math.min(22, Math.floor(gridH / n));
const px0 = 58, usable = SW - 8 - px0 - 8;
// vertical gridlines at the master lane's beats
const M = meters[0];
const mbeats = Math.max(1, Math.floor(M.beatsPerBar));
g.fillStyle = COL.grid;
for(let bc=0; bc<mbeats; bc++){
const xc = px0 + 6 + Math.floor((bc * usable) / mbeats);
g.fillRect(xc, top, 1, n * rowh);
}
for(let li=0; li<n; li++){
const L = meters[li];
const y = top + li * rowh, cy = y + Math.floor(rowh / 2);
g.fillStyle = COL.mute; g.font="600 10px 'Segoe UI',Roboto,Arial,sans-serif"; g.textAlign="left";
g.fillText((L.sound||"?").slice(0,7), 6, cy + 3);
const steps = (L.beatsOn || []).length || L.beatsPerBar * L.stepsPerBeat;
const stepw = Math.max(1, Math.floor(usable / steps));
const side = Math.max(4, Math.min(12, stepw - 1, rowh - 6));
const rad = Math.max(2, Math.min(Math.floor(side/2), Math.floor(stepw/2) - 1));
const sub = L.stepsPerBeat || 1;
for(let s=0; s<steps; s++){
const cxp = px0 + 6 + Math.floor((s * usable) / steps);
const lvl = (L.beatsOn[s]|0); // 0=mute 1=normal 2=accent 3=ghost
const lit = state.running && (L.currentStep === s);
let col;
if(lvl === 0) col = lit ? "#39414D" : "#10161E";
else if(lvl === 2) col = lit ? COL.amber : "#4A3010";
else if(lvl === 3) col = lit ? COL.violet : "#2A1D4A";
else col = lit ? COL.cyan : "#0A3A52";
g.fillStyle = col;
if(s % sub === 0){
g.fillRect(cxp - Math.floor(side/2), cy - Math.floor(side/2), side, side);
} else {
g.beginPath(); g.arc(cxp, cy, rad, 0, Math.PI*2); g.fill();
}
}
}
}
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=[];
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;
const lvl=m.beatsOn[e.step]|0; if(lvl>0) fired.push(lvl);
if(m===meters[0] && e.step % m.stepsPerBeat===0) beatIdx = e.step/m.stepsPerBeat;
m.vqPtr++;
}
if(m.vqPtr>512){ m.vq=m.vq.slice(m.vqPtr); m.vqPtr=0; }
}
if(fired.length){ runPulse=1; }
}
runPulse=Math.max(0, runPulse - 0.08);
drawScreen();
requestAnimationFrame(frame);
}
/* ========================= INPUTS (6 buttons + hold-repeat + X+Z chord) =========== */
let taps=[];
function tapTempo(){ const now=performance.now(); taps.push(now); taps=taps.filter(x=>now-x<2400);
if(taps.length>=2){ let s=0; for(let i=1;i<taps.length;i++) s+=taps[i]-taps[i-1];
const bpm=Math.round(60000/(s/(taps.length-1))); if(bpm>=5&&bpm<=300) setBpm(bpm); } }
const held = { X:0, Y:0, Z:0 }; // press start time (ms); 0 = released
const repNext = { X:0, Y:0, Z:0 }; // next auto-repeat deadline
const REPEAT_FIRST = 350, REPEAT_NEXT = 120, FAST_AFTER = 1500; // ms thresholds
let chordXZ = 0; // 0 = not in chord; else press start (ms)
function doX(){ loadTrack(trackIdx - 1); }
function doZ(){ loadTrack(trackIdx + 1); }
function doY(){
const fast = held.Y && (performance.now() - held.Y) > FAST_AFTER;
setBpm(state.bpm + (fast ? -5 : -1));
}
function doChordUp(){
const fast = chordXZ && (performance.now() - chordXZ) > FAST_AFTER;
setBpm(state.bpm + (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(); });
}
bindBtn("btnA", () => toggle(), () => {});
bindBtn("btnB", () => tapTempo(), () => {});
bindBtn("btnC", () => nextSetlist(), () => {});
function pressX(){
const now = performance.now();
if(held.Z && (now - held.Z) < 100){ chordXZ = Math.min(held.Z, now); doChordUp(); }
else { doX(); }
held.X = now; repNext.X = now + REPEAT_FIRST;
}
function pressZ(){
const now = performance.now();
if(held.X && (now - held.X) < 100){ chordXZ = Math.min(held.X, now); doChordUp(); }
else { doZ(); }
held.Z = now; repNext.Z = now + REPEAT_FIRST;
}
function pressY(){
const now = performance.now(); held.Y = now; repNext.Y = now + REPEAT_FIRST; doY();
}
function releaseX(){ held.X = 0; if(!held.Z) chordXZ = 0; }
function releaseZ(){ held.Z = 0; if(!held.X) chordXZ = 0; }
function releaseY(){ held.Y = 0; }
bindBtn("btnX", pressX, releaseX);
bindBtn("btnY", pressY, releaseY);
bindBtn("btnZ", pressZ, releaseZ);
// hold-repeat loop for X / Y / Z
setInterval(() => {
const now = performance.now();
if(held.X && !held.Z && now >= repNext.X){ repNext.X = now + REPEAT_NEXT; doX(); }
if(held.Z && !held.X && now >= repNext.Z){ repNext.Z = now + REPEAT_NEXT; doZ(); }
if(held.X && held.Z && now >= Math.max(repNext.X, repNext.Z)){
repNext.X = repNext.Z = now + REPEAT_NEXT; doChordUp();
}
if(held.Y && now >= repNext.Y){ repNext.Y = now + REPEAT_NEXT; doY(); }
}, 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 === "b"){ tapTempo(); }
else if(k === "c"){ nextSetlist(); }
else if(k === "x"){ pressX(); }
else if(k === "y"){ pressY(); }
else if(k === "z"){ pressZ(); }
});
addEventListener("keyup", (e) => {
const k = e.key.toLowerCase();
if(k === "x") releaseX();
else if(k === "y") releaseY();
else if(k === "z") releaseZ();
});
/* theme toggle + version */
/*@BUILD:include:src/chrome.js@*/
/* ========================= INIT ============================================== */
{ const ht = tracksFromHash(); if(ht) tracks = ht; }
loadTrack(0);
requestAnimationFrame(frame);
// reset segment timer on play; rolls into draw_meters' "X of TOTAL"
const _origStart = startAudio;
startAudio = function(){ segStart = audioCtx ? audioCtx.currentTime + 0.08 : 0; _origStart(); };
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>

View file

@ -139,7 +139,7 @@ const SAMPLES = {}; let state = { bpm:120, volume:0.85 }, meters = [], muteWindo
const VERSIONS = [
{ key:"editor", file:"/editor.html", name:"PM_E1 Editor", chip:"app", h:620, sum:"Design grooves: stack meter lanes, perstep accents/ghosts/mutes, swing &amp; polyrhythm, set lists, perlane dB gain." },
{ key:"kit", file:"/kit.html", name:"PM_K1 Kit", chip:"hw", h:560, sum:"Build it today — a Raspberry Pi Pico on the 52Pi touchscreen kit; tap the 3.5″ screen, joystick tempo, RGB beat light, buzzer. MicroPython firmware included." },
{ key:"explorer", file:"/info-explorer.html", name:"PM_X1 Explorer", chip:"hw", h:560, sum:"Offtheshelf — the Pimoroni Explorer (RP2350, 2.8″ LCD, 6 buttons, piezo) as a buttondriven sibling to the Kit. Edit on the web with Live sync; the device mirrors play/stop/tempo/track changes both ways." },
{ key:"explorer", file:"/explorer.html", name:"PM_X1 Explorer", chip:"hw", h:500, sum:"Offtheshelf — the Pimoroni Explorer (RP2350, 2.8″ LCD, 6 buttons, piezo) as a buttondriven sibling to the Kit. Edit on the web with Live sync; the device mirrors play/stop/tempo/track changes both ways." },
{ key:"teacher", file:"/teacher.html", name:"PM_T1 Teacher", chip:"hw", h:440, sum:"Studio / lesson desk console — colour TFT of every lane, arcade buttons, instrument passthrough." },
{ key:"stage", file:"/stage.html", name:"PM_S1 Stage", chip:"hw", h:430, sum:"Live foot pedal — two footswitches, expressionpedal tempo, a big floorreadable RGB beat light." },
{ key:"micro", file:"/micro.html", name:"PM_P1 Practice", chip:"hw", h:240, sum:"Inline practice bar — clickable thumbroller, amber 14segment, instrument in/out passthrough." },