metronome/kit.html
Me Here fe56673bea PM_K-1: drop the VARASYS wordmark from the screen UI (it's on the case)
The on-screen header showed VARASYS + the model; since the wordmark is already
silkscreened on the case/PCB, keep only the PM_K-1 KIT label on the display.
Applied to both pico/main.py (draw_static) and the kit.html web simulator.

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

307 lines
17 KiB
HTML
Raw Permalink 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 (the VARASYS logo lives on the case, not the screen)
g.textBaseline="alphabetic"; g.textAlign="left";
g.fillStyle=COL.cyan; g.font="700 18px 'Segoe UI',Roboto,Arial,sans-serif"; g.fillText("PM_K1 KIT",12,26);
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>