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>
418 lines
21 KiB
HTML
418 lines
21 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_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_X‑1 Explorer</h1>
|
||
<p class="ff-sum">Off‑the‑shelf — the Pimoroni Explorer Kit (RP2350, 2.8″ LCD, 6 buttons, piezo) as a button‑driven sibling to the PM_K‑1 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_X‑1 Explorer</span></div>
|
||
<span class="pin">RP2350 · USB‑C</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 & 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>
|