metronome/kit.html
Me Here eecea625d3 Add PM_K-1 "Kit" — buildable Pico touchscreen unit + MicroPython firmware
A new, first-actually-buildable form factor for the 52Pi EP-0172 "Pico Breadboard
Kit Plus" (Raspberry Pi Pico; 3.5" ST7796 320x480 cap-touch via GT911, PSP
joystick on ADC0/1, WS2812 RGB on GP12, buzzer GP13, buttons GP14/15):

  - pico/main.py — one self-contained MicroPython file: ST7796 direct-draw driver,
    GT911 touch (16-bit register addressing), WS2812 RGB (neopixel), PWM buzzer,
    ADC joystick, buttons. It parses the project's own program-string language
    (verified against the web engine's semantics) and runs a non-blocking
    ticks_us scheduler with an on-screen touch UI. CONFIG flags cover panel /
    colour / touch / joystick calibration. pico/README.md has flashing +
    calibration steps.
  - kit.html — lean widget that mirrors the firmware's on-screen UI (portrait
    320x480 canvas) plus a joystick / RGB / buzzer / A-B buttons; plays via the
    shared engine. info-kit.html — the real EP-0172 pinout, a parts list
    (~$45 incl. Pico) and the firmware to flash (downloads /pico-main.py, links
    the README + source).
  - Landing + embed page list the Kit; build.sh/deploy.sh build the two pages and
    serve pico/main.py as /pico-main.py for download.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 16:10:31 -05:00

308 lines
17 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_K1 — Kit (Raspberry Pi Pico touchscreen build)</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_K-1 "Kit" — the buildable touchscreen unit: a Raspberry Pi Pico on the 52Pi EP-0172
breadboard kit (3.5" ST7796 320x480 cap-touch, GT911 touch, PSP joystick on ADC0/1,
WS2812 RGB on GP12, buzzer GP13, buttons GP14/15). This page mirrors the MicroPython
firmware's on-screen UI so the web simulator looks like the real device. 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; --device-bd:#33363c; --silk:#aab2bc; --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 kit: a Pico carrier board with the screen + joystick + RGB + buzzer + buttons */
.device{ width:100%; max-width:330px; position:relative; border-radius:16px; padding:14px 14px 18px;
background:
radial-gradient(rgba(255,255,255,.022) .5px, transparent .6px) 0 0/3px 3px,
linear-gradient(180deg, #1f3a2e, #0c2a20); /* 52Pi green PCB vibe */
border:1px solid #2d5c47;
box-shadow:0 26px 52px rgba(0,0,0,.6), inset 0 1px 0 rgba(255,255,255,.05) }
.pcbrow{ display:flex; align-items:center; justify-content:space-between; margin:0 2px 10px }
.dev-logo{ height:18px }
.silk{ display:flex; align-items:center; gap:7px; color:#bfe6d4 }
.silk .model{ font-size:8.5px; text-transform:uppercase; letter-spacing:.16em; opacity:.9 }
.pin{ font-size:7px; color:#9fd2bd; letter-spacing:.1em; text-transform:uppercase; opacity:.8 }
.screen-wrap{ padding:8px; border-radius:10px; background:linear-gradient(180deg,#05070a,#020406);
border:1px solid #04060a; box-shadow:inset 0 2px 10px rgba(0,0,0,.8) }
#screen{ display:block; width:100%; height:auto; border-radius:5px; background:#06080c; touch-action:manipulation; cursor:pointer }
.hw{ display:flex; align-items:center; justify-content:space-between; gap:10px; margin:14px 4px 0 }
/* PSP joystick */
.joy{ width:74px; height:74px; border-radius:50%; position:relative; flex:0 0 auto; touch-action:none; cursor:grab;
background:radial-gradient(circle at 40% 34%, #2a2f37, #0c0f13 72%); border:2px solid #0a3a2a;
box-shadow:inset 0 2px 6px rgba(0,0,0,.6) }
.joy .nub{ position:absolute; left:50%; top:50%; width:34px; height:34px; margin:-17px 0 0 -17px; border-radius:50%;
background:radial-gradient(circle at 38% 32%, #e9eef3, #aab2bc 46%, #6c7480 72%, #3b424c);
box-shadow:0 3px 6px rgba(0,0,0,.5); transition:transform .04s }
.joy .cap{ position:absolute; left:0; right:0; bottom:-15px; text-align:center; font-size:7px; color:#9fd2bd; letter-spacing:.08em; text-transform:uppercase; opacity:.85 }
.mids{ display:flex; flex-direction:column; align-items:center; gap:8px; flex:1 }
.led{ width:30px; height:30px; border-radius:50%; background:#0c0f14;
box-shadow:0 0 4px #000 inset; transition:none }
.led-cap, .buz-cap{ font-size:7px; color:#9fd2bd; letter-spacing:.1em; text-transform:uppercase; opacity:.85 }
.buz{ width:26px; height:26px; border-radius:50%; background:radial-gradient(circle at 50% 40%, #2a2f37, #0c0f13);
border:2px solid #0a3a2a; position:relative }
.buz::after{ content:""; position:absolute; left:50%; top:50%; width:6px; height:6px; margin:-3px 0 0 -3px; border-radius:50%; background:#05070a }
.btns{ display:flex; flex-direction:column; gap:9px; flex:0 0 auto }
.pbtn{ width:60px; padding:9px 0; border-radius:9px; border:1px solid #0a3a2a; cursor:pointer;
background:radial-gradient(circle at 40% 30%, #d7dde3, #8b939e 70%, #5a626c); color:#0c1116;
font-size:9px; font-weight:800; letter-spacing:.06em; text-transform:uppercase;
box-shadow:0 3px 5px rgba(0,0,0,.4) }
.pbtn:active{ transform:translateY(2px) }
.pbtn small{ display:block; font-size:6.5px; font-weight:600; opacity:.7 }
.hint{ max-width:330px; text-align:center; font-size:11px; color:var(--muted); line-height:1.5 }
[data-embed] .hint{ display:none !important }
</style>
</head>
<body>
/*@BUILD:include:src/header.html@*/
<h1 class="ff-title">PM_K1 Kit</h1>
<p class="ff-sum">The buildityourself touchscreen unit — a Raspberry Pi Pico on the 52Pi breadboard kit (3.5″ captouch, joystick, RGB, buzzer). Tap the screen, nudge tempo with the stick; runs the same program strings, with MicroPython firmware you flash yourself.</p>
<div class="device">
<div class="pcbrow">
<div class="silk"><span class="dev-lock"><img class="dev-logo" src="data:image/png;base64,@BUILD:logo-dark@" alt="VARASYS — Simplifying Complexity" /></span><span class="model">PM_K1 Kit</span></div>
<span class="pin">Pico · USBC</span>
</div>
<div class="screen-wrap"><canvas id="screen" width="320" height="480" aria-label="touchscreen metronome"></canvas></div>
<div class="hw">
<div class="joy" id="joy" title="Joystick — up/down tempo · left/right groove"><div class="nub" id="nub"></div><span class="cap">Joystick</span></div>
<div class="mids">
<div class="led" id="led"></div><span class="led-cap">RGB</span>
<div class="buz"></div><span class="buz-cap">Buzzer</span>
</div>
<div class="btns">
<button class="pbtn" id="btnA">A<small>play</small></button>
<button class="pbtn" id="btnB">B<small>tap</small></button>
</div>
</div>
</div>
<div class="hint">Tap the onscreen buttons (the real unit is capacitive touch). The <b>joystick</b> sets tempo (up/down)
and switches grooves (left/right); <b>A</b> = play/stop, <b>B</b> = tap. The RGB LED flashes each beat and the buzzer clicks.</div>
/*@BUILD:include:src/progbox.html@*/
<p class="ff-link pageonly"><a href="/info-kit.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(30, 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, flattened) =================== */
let tracks = SEED_SETLISTS.flatMap(sl => sl.items.map(([n,p]) => ({ name:n, ...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})) : null; }
const s=patchToSetup(payload); return s.lanes.length ? [{name:"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();
}
/* ========================= SCREEN (canvas mirrors the firmware UI) ============ */
const cv=$("screen"), g=cv.getContext("2d"), SW=320, SH=480;
(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:"#6e7a8a", cyan:"#0AB3F7", amber:"#ff9b2e",
violet:"#967bff", green:"#2fe07a", dim:"#243240", btn:"#1c222c", panel:"#12161e" };
const PRIO={2:3,1:2,3:1};
const LEDCOL={2:[255,110,0],1:[0,150,255],3:[130,70,255]};
let flash=0, flashLevel=1, beatIdx=-1, btnRects=[];
function rrect(x,y,w,h,r){ g.beginPath(); g.moveTo(x+r,y); g.arcTo(x+w,y,x+w,y+h,r); g.arcTo(x+w,y+h,x,y+h,r); g.arcTo(x,y+h,x,y,r); g.arcTo(x,y,x+w,y,r); g.closePath(); }
function layoutButtons(){
btnRects=[]; const bw=96, bh=54, gap=(SW-3*bw)/4, xs=[gap, gap*2+bw, gap*3+bw*2];
[["prev",300],["play",300],["next",300]].forEach((b,i)=>btnRects.push({x:xs[i],y:300,w:bw,h:bh,key:["prev","play","next"][i]}));
["minus","tap","plus"].forEach((k,i)=>btnRects.push({x:xs[i],y:370,w:bw,h:bh,key:k}));
}
layoutButtons();
function drawScreen(){
g.fillStyle=COL.bg; g.fillRect(0,0,SW,SH);
// header
g.textBaseline="alphabetic"; g.textAlign="left";
g.fillStyle=COL.cyan; g.font="700 18px 'Segoe UI',Roboto,Arial,sans-serif"; g.fillText("VARASYS",12,26);
g.fillStyle=COL.mute; g.font="700 11px 'Segoe UI',Roboto,Arial,sans-serif"; g.textAlign="right"; g.fillText("PM_K1 KIT",SW-12,24);
g.fillStyle=COL.panel; g.fillRect(0,34,SW,2);
// BPM
g.textAlign="left"; g.fillStyle=COL.mute; g.font="700 20px 'Segoe UI',Roboto,Arial,sans-serif"; g.fillText("BPM",12,150);
g.textAlign="right"; g.fillStyle=COL.txt; g.font="800 92px 'Segoe UI',Roboto,Arial,sans-serif"; g.fillText(String(state.bpm),SW-12,176);
// beat dots
const m=meters[0]; if(m){ const bpb=m.beatsPerBar, sz=18, sp=26, x0=Math.max(12,SW-12-bpb*sp), y=196;
for(let i=0;i<bpb;i++){ const accent=m.groupStarts.has(i)||(m.beatsOn[i*m.stepsPerBeat]|0)>=2;
const on=state.running && i===beatIdx; g.fillStyle = on ? (accent?COL.amber:COL.cyan) : COL.dim;
g.fillRect(x0+i*sp,y,sz,sz); } }
// status
g.textAlign="left"; g.fillStyle=state.running?COL.green:COL.mute; g.font="700 16px 'Segoe UI',Roboto,Arial,sans-serif";
g.fillText(state.running?"▶ RUN":"■ STOP",12,256);
const nm=(tracks[trackIdx]&&tracks[trackIdx].name||"—").slice(0,18);
g.textAlign="right"; g.fillStyle=COL.txt; g.fillText(nm,SW-12,256);
g.textAlign="left"; g.fillStyle=COL.mute; g.font="600 11px 'Segoe UI',Roboto,Arial,sans-serif"; g.fillText((trackIdx+1)+"/"+tracks.length,12,276);
// touch buttons
const lbl={prev:"",play:state.running?"▮▮":"▶",next:"",minus:"",tap:"TAP",plus:"+"};
for(const b of btnRects){ g.fillStyle=COL.btn; rrect(b.x,b.y,b.w,b.h,9); g.fill();
g.fillStyle = b.key==="play" ? COL.green : COL.txt;
g.font = (b.key==="minus"||b.key==="plus") ? "800 30px 'Segoe UI',Roboto,Arial,sans-serif" : "800 22px 'Segoe UI',Roboto,Arial,sans-serif";
g.textAlign="center"; g.textBaseline="middle"; g.fillText(lbl[b.key], b.x+b.w/2, b.y+b.h/2+2); g.textBaseline="alphabetic"; }
g.textAlign="left"; g.fillStyle=COL.mute; g.font="600 10px 'Segoe UI',Roboto,Arial,sans-serif";
g.fillText("joystick: tempo / groove · A play B tap", 12, SH-14);
}
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){ flashLevel = fired.sort((a,b)=>PRIO[b]-PRIO[a])[0]; flash=1; }
}
flash=Math.max(0,flash-0.08);
// RGB LED element
const c=LEDCOL[flashLevel]||LEDCOL[1], lit=flash;
const led=$("led");
if(lit>0.02){ const rgb="rgb("+c.map(v=>Math.round(v*lit)).join(",")+")";
led.style.background=rgb; led.style.boxShadow="0 0 "+(8+lit*22)+"px rgb("+c.join(",")+")"; }
else { led.style.background="#0c0f14"; led.style.boxShadow="0 0 4px #000 inset"; }
drawScreen();
requestAnimationFrame(frame);
}
/* ========================= INPUTS ============================================ */
function dispatch(key){
if(key==="play") toggle();
else if(key==="prev") loadTrack(trackIdx-1);
else if(key==="next") loadTrack(trackIdx+1);
else if(key==="minus") setBpm(state.bpm-1);
else if(key==="plus") setBpm(state.bpm+1);
else if(key==="tap") tapTempo();
}
cv.addEventListener("pointerdown",(e)=>{
const r=cv.getBoundingClientRect(); const x=(e.clientX-r.left)*SW/r.width, y=(e.clientY-r.top)*SH/r.height;
for(const b of btnRects){ if(x>=b.x&&x<=b.x+b.w&&y>=b.y&&y<=b.y+b.h){ dispatch(b.key); return; } }
});
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>=30&&bpm<=300) setBpm(bpm); } }
$("btnA").addEventListener("click",()=>toggle());
$("btnB").addEventListener("click",()=>tapTempo());
/* joystick: drag up/down = tempo, left/right = groove */
(function(){
const joy=$("joy"), nub=$("nub"); let dragging=false, R=26, last=0, itemLatch=0;
function at(e){ const r=joy.getBoundingClientRect(); return {x:e.clientX-r.left-r.width/2, y:e.clientY-r.top-r.height/2}; }
function move(e){ if(!dragging) return; e.preventDefault();
let {x,y}=at(e); const d=Math.hypot(x,y)||1, k=Math.min(1,R/d); let nx=x*k, ny=y*k;
nub.style.transform="translate("+nx+"px,"+ny+"px)";
const fy=-ny/R, fx=nx/R, now=performance.now();
if(Math.abs(fy)>0.45 && now-last>80){ setBpm(state.bpm+(fy>0?1:-1)*(Math.abs(fy)>0.8?5:1)); last=now; }
if(Math.abs(fx)>0.6){ if(now-itemLatch>320){ loadTrack(trackIdx+(fx>0?1:-1)); itemLatch=now; } }
}
function up(){ dragging=false; nub.style.transform="translate(0,0)"; }
joy.addEventListener("pointerdown",(e)=>{ dragging=true; try{joy.setPointerCapture(e.pointerId);}catch(_){ } move(e); });
joy.addEventListener("pointermove",move);
joy.addEventListener("pointerup",up); joy.addEventListener("pointercancel",up);
})();
/* theme toggle + version */
/*@BUILD:include:src/chrome.js@*/
addEventListener("keydown",(e)=>{
const tag=e.target?e.target.tagName:""; if(tag==="INPUT"||tag==="TEXTAREA"||tag==="SELECT") return;
if(e.key===" "){ e.preventDefault(); toggle(); }
else if(e.key==="t"||e.key==="T"){ tapTempo(); }
else if(e.key==="ArrowUp"){ e.preventDefault(); setBpm(state.bpm+1); }
else if(e.key==="ArrowDown"){ e.preventDefault(); setBpm(state.bpm-1); }
else if(e.key==="ArrowRight"){ loadTrack(trackIdx+1); }
else if(e.key==="ArrowLeft"){ loadTrack(trackIdx-1); }
});
/* ========================= 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", ...s}]; trackIdx=0; loadTrack(0); };
/*@BUILD:include:src/progbox.js@*/
</script>
/*@BUILD:include:src/footer.html@*/
</body>
</html>