metronome/explorer.html
Me Here 05ce1d5ce4 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>
2026-05-30 20:55:20 -05:00

418 lines
21 KiB
HTML
Raw 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_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>