metronome/micro.html
Me Here d78fb762e5 Per-version info pages; move Stage BOM out to info-stage; add Micro BOM
Each form factor now has an information page (purpose + live embedded widget),
with priced BOMs only on the buildable hardware (Stage ≈$59, Micro ≈$28):
- info-editor.html / info-initial.html — purpose only (web app / concept)
- info-stage.html — purpose + the priced BOM moved out of stage.html
- info-micro.html — purpose + a new ~$28 practice-unit BOM
stage.html drops the BOM panel (+ its .bom CSS) and gains a "Spec & BOM" link;
the shared .bom/.sub table CSS lives in src/base.css. "Info" added to every
page nav and to the concept cards. Wired into build.sh + deploy.sh.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 12:02:17 -05:00

269 lines
16 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µ — micro (home practice)</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,@BUILD:favicon@">
<script>
/* ?embed=1 → strip site chrome (base.css [data-embed]) + 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>
<!--
"Micro" — a stripped-down home-practice metronome on the same RP2040 firmware.
Hardware is just: ONE depressable scroll/rotary encoder (tempo), a red 7-seg
LED BPM display, a small speaker, and a USB-C port for power. No screen, no
buttons. Interaction lives entirely in the encoder:
• spin → tempo
• press (click) → start / stop
• hold + spin → switch track (the LED shows the track number)
Built-in tracks are the editor's seed grooves, flattened. Shares src/engine.js.
-->
<script>
// Set theme before first paint (shared "metronome.theme" with the other pages).
(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; --dmuted:#5a626c;
--cyan:#0AB3F7;
}
:root[data-theme="light"]{ --bg1:#f5f8fc; --bg2:#dde4ec; --txt:#1e2630; --muted:#5c6776; --link:#1769c4; --panel-bd:#d2dae4 }
body{ margin:0; min-height:100vh; padding:30px 14px 44px;
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) }
.topbar{ width:100%; max-width:330px; display:flex; align-items:center; justify-content:space-between; gap:8px; font-size:12px; color:var(--muted); flex-wrap:wrap }
.topbar b{ color:var(--txt) }
.topbar-right{ display:flex; align-items:center; gap:10px }
.tbtn{ background:transparent; color:var(--muted); border:1px solid var(--panel-bd); border-radius:8px; padding:3px 9px; font-size:14px; line-height:1; cursor:pointer }
.tbtn:hover{ color:var(--txt) }
/* ---- the micro device: small brushed-aluminium box ---- */
.device{ width:100%; max-width:330px; position:relative; border-radius:13px; padding:16px 16px 14px;
background:
radial-gradient(rgba(255,255,255,.022) .5px, transparent .6px) 0 0/3px 3px, /* bead-blast micro-texture */
linear-gradient(160deg, #26282d, #15161a); /* matte anodised graphite */
border:1px solid var(--device-bd);
box-shadow:0 22px 46px rgba(0,0,0,.6), inset 0 1px 0 rgba(255,255,255,.05), inset 0 -2px 8px rgba(0,0,0,.5) }
.brandrow{ display:flex; align-items:center; justify-content:space-between; margin:0 2px 14px }
.brand-logo{ height:14px; width:auto; display:block }
.silk{ display:flex; align-items:center; gap:7px; color:var(--silk) }
.silk .model{ font-size:9px; text-transform:uppercase; letter-spacing:.16em; opacity:.85 }
.pwr{ display:flex; align-items:center; gap:6px; font-size:8px; color:var(--silk); text-transform:uppercase; letter-spacing:.14em; opacity:.85 }
.pwr .dot{ width:7px; height:7px; border-radius:50%; background:#2fe07a; box-shadow:0 0 7px #2fe07a }
/* ---- red 7-segment LED display ---- */
.led-win{ background:#160403; border:2px solid #050100; border-radius:8px; padding:8px 14px; margin:0 2px;
box-shadow:inset 0 0 16px rgba(0,0,0,.85), inset 0 0 7px rgba(255,40,30,.14), 0 1px 0 rgba(255,255,255,.3) }
#led{ display:block; width:100%; max-width:240px; height:84px; margin:0 auto }
.inds{ display:flex; justify-content:center; gap:16px; margin:9px 0 2px }
.ind{ display:flex; align-items:center; gap:5px; font-size:8.5px; color:var(--silk); letter-spacing:.12em; text-transform:uppercase; opacity:.85 }
.ind .d{ width:7px; height:7px; border-radius:50%; background:#4a0b09; box-shadow:inset 0 0 2px #000; transition:background .08s, box-shadow .08s }
.ind.on .d{ background:#ff3b30; box-shadow:0 0 7px #ff3b30 }
.ind.play.on .d{ background:#2fe07a; box-shadow:0 0 7px #2fe07a }
/* ---- single push encoder ---- */
.knob-wrap{ display:flex; justify-content:center; margin:16px 0 6px }
.knob{ width:96px; height:96px; border-radius:50%; cursor:pointer; position:relative; touch-action:none;
background:repeating-conic-gradient(from 0deg, #3c444f 0 6deg, #2a313b 6deg 12deg);
border:2px solid #565f6c; box-shadow:0 6px 14px rgba(0,0,0,.5), inset 0 1px 2px rgba(255,255,255,.12) }
.knob::before{ content:""; position:absolute; inset:13px; border-radius:50%;
background:radial-gradient(circle at 38% 32%, #4a525e, #1b212a 76%); box-shadow:inset 0 1px 2px rgba(255,255,255,.1), inset 0 -2px 4px rgba(0,0,0,.5) }
.knob::after{ content:""; position:absolute; left:50%; top:9px; width:4px; height:17px; background:#ff3b30; border-radius:2px;
transform:translateX(-50%) rotate(var(--a,0deg)); transform-origin:50% 39px; box-shadow:0 0 6px #ff3b30 }
.knob.press{ box-shadow:0 2px 6px rgba(0,0,0,.5), inset 0 1px 2px rgba(255,255,255,.1); transform:translateY(2px) }
/* ---- speaker grille + USB-C ---- */
.grille{ height:11px; margin:16px 8px 9px; border-radius:5px;
background:radial-gradient(circle, #000 1.1px, transparent 1.4px) 0 0/8px 8px; opacity:.5 }
.usb{ display:flex; align-items:center; justify-content:center; gap:6px; font-size:8px; color:var(--silk); letter-spacing:.12em; text-transform:uppercase; opacity:.8 }
.usb .port{ width:24px; height:9px; border-radius:4px; background:#0a0c0f; border:2px solid #5b6470; box-shadow:inset 0 0 2px #000 }
.hint{ max-width:330px; text-align:center; font-size:11px; color:var(--muted); line-height:1.5 }
/* embed mode: just the device */
[data-embed] .hint { display:none !important; }
</style>
</head>
<body>
<header class="site-head">
<div class="head-left">
<a class="brand" href="/" title="VARASYS — Simplifying Complexity">
<img class="brand-logo brand-dark" src="data:image/png;base64,@BUILD:logo-dark@" alt="VARASYS — Simplifying Complexity" />
<img class="brand-logo brand-light" src="data:image/png;base64,@BUILD:logo-light@" alt="VARASYS — Simplifying Complexity" />
</a>
<span class="page-name"><b>PMµ</b> · Micro (home practice)</span>
</div>
<nav class="site-nav">
<a href="/index.html">Editor</a>
<a href="/concepts.html">Concepts</a>
<a href="/info-micro.html">Info</a>
<a href="/embed.html">Embed</a>
<button id="themeBtn" class="tbtn" title="toggle light / dark theme"></button>
</nav>
</header>
<div class="device">
<div class="brandrow">
<div class="silk"><img class="brand-logo" src="data:image/png;base64,@BUILD:logo-dark@" alt="VARASYS" /><span class="model">PMµ Micro</span></div>
<div class="pwr"><span class="dot"></span>PWR</div>
</div>
<div class="led-win"><canvas id="led" width="240" height="84" aria-label="LED tempo display"></canvas></div>
<div class="inds">
<div class="ind on" id="indBpm"><span class="d"></span>BPM</div>
<div class="ind" id="indTrk"><span class="d"></span>Track</div>
<div class="ind play" id="indPlay"><span class="d"></span></div>
</div>
<div class="knob-wrap"><div class="knob" id="enc" title="Spin = tempo · Press = start/stop · Hold + spin = switch track"></div></div>
<div class="grille"></div>
<div class="usb"><span class="port"></span>USBC (power)</div>
</div>
<div class="hint">Spin the dial = <b>tempo</b> · press = <b>start / stop</b> · hold &amp; spin = <b>switch track</b></div>
<script>
const APP_VERSION = "v0.0.1-dev";
const $ = (id) => document.getElementById(id);
/* ========================= ENGINE (shared; synth voices only) ================= */
const SAMPLES = {}; // synth-only
/*@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))); }
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,
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; }
muteWindows=[]; schedulerTimer=setInterval(scheduler,LOOKAHEAD_MS); scheduler(); render();
}
function stopAudio(){ state.running=false; clearInterval(schedulerTimer); schedulerTimer=null; render(); }
function toggle(){ state.running ? stopAudio() : startAudio(); }
/* ========================= TRACKS (built-in seed grooves, flattened) ========= */
let tracks = SEED_SETLISTS.flatMap(sl => sl.items.map(([n,p]) => ({ name:n, ...patchToSetup(p) })));
let trackIdx=0, previewIdx=0;
// preload from a share link / embed config string (#p=<patch> or #sl=<set-list code>)
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; previewIdx=trackIdx;
const t=tracks[trackIdx]; setBpm(t.bpm||120); meters=buildMeters(t.lanes); // ramps/bars ignored — a steady practice loop
const was=state.running; if(was){ clearInterval(schedulerTimer); schedulerTimer=null; state.running=false; }
if(was) startAudio(); else render();
}
/* ========================= 7-SEGMENT LED ===================================== */
const led=$("led"), lc=led.getContext("2d"), LW=240, LH=84;
(function(){ const dpr=Math.max(1,Math.min(3,window.devicePixelRatio||1)); led.width=LW*dpr; led.height=LH*dpr; lc.scale(dpr,dpr); })();
const LED_ON="#ff3b30", LED_OFF="#330807", LED_BG="#160403";
const SEG7={ // a,b,c,d,e,f,g
"0":[1,1,1,1,1,1,0],"1":[0,1,1,0,0,0,0],"2":[1,1,0,1,1,0,1],"3":[1,1,1,1,0,0,1],"4":[0,1,1,0,0,1,1],
"5":[1,0,1,1,0,1,1],"6":[1,0,1,1,1,1,1],"7":[1,1,1,0,0,0,0],"8":[1,1,1,1,1,1,1],"9":[1,1,1,1,0,1,1],
" ":[0,0,0,0,0,0,0],"-":[0,0,0,0,0,0,1],"P":[1,1,0,0,1,1,1] };
let displayMode="bpm";
function ledText(){ return displayMode==="track" ? String(previewIdx+1).padStart(3," ") : String(state.bpm).padStart(3," "); }
function drawDigit(dx,dy,dw,dh,ch){
const segs=SEG7[ch]||SEG7[" "], t=Math.max(3,Math.round(dw*0.17)), vh=(dh-3*t)/2;
const put=(on,x,y,w,h)=>{ if(on){ lc.fillStyle=LED_ON; lc.shadowColor=LED_ON; lc.shadowBlur=7; } else { lc.fillStyle=LED_OFF; lc.shadowBlur=0; }
lc.fillRect(x,y,w,h); lc.shadowBlur=0; };
put(segs[0], dx+t, dy, dw-2*t, t); // a
put(segs[5], dx, dy+t, t, vh); // f
put(segs[1], dx+dw-t, dy+t, t, vh); // b
put(segs[6], dx+t, dy+t+vh, dw-2*t, t); // g
put(segs[4], dx, dy+2*t+vh, t, vh); // e
put(segs[2], dx+dw-t, dy+2*t+vh, t, vh); // c
put(segs[3], dx+t, dy+dh-t, dw-2*t, t); // d
}
function drawLED(){
lc.fillStyle=LED_BG; lc.fillRect(0,0,LW,LH);
const txt=ledText(), pad=12, gap=12, n=3, dw=(LW-2*pad-(n-1)*gap)/n, dh=LH-2*pad;
for(let i=0;i<n;i++) drawDigit(pad+i*(dw+gap), pad, dw, dh, txt[i]||" ");
}
function render(){
drawLED();
$("indBpm").classList.toggle("on", displayMode==="bpm");
$("indTrk").classList.toggle("on", displayMode==="track");
$("indPlay").classList.toggle("on", state.running);
}
/* ========================= ENCODER (the only control) ======================== */
let knobAngle=0;
function nudge(d){ setBpm(state.bpm+d); displayMode="bpm"; knobAngle+=d*10; $("enc").style.setProperty("--a",knobAngle+"deg"); render(); }
let revertT=null;
function previewTrack(d){ previewIdx=((previewIdx+d)%tracks.length+tracks.length)%tracks.length; displayMode="track"; render(); }
function commitTrack(){ loadTrack(previewIdx); displayMode="track"; render(); clearTimeout(revertT); revertT=setTimeout(()=>{ displayMode="bpm"; render(); }, 1000); }
(function(){
const k=$("enc"); let down=false, moved=false, lastY=0, acc=0;
k.addEventListener("wheel",(e)=>{ e.preventDefault(); nudge(e.deltaY<0 ? (e.shiftKey?5:1) : (e.shiftKey?-5:-1)); }, {passive:false});
k.addEventListener("pointerdown",(e)=>{ down=true; moved=false; lastY=e.clientY; acc=0; previewIdx=trackIdx; k.classList.add("press"); k.setPointerCapture(e.pointerId); });
k.addEventListener("pointermove",(e)=>{ if(!down) return; acc += lastY - e.clientY; lastY=e.clientY;
while(Math.abs(acc) >= 9){ const d = acc>0?1:-1; acc -= d*9; moved=true; previewTrack(d); } });
k.addEventListener("pointerup",()=>{ if(!down) return; down=false; k.classList.remove("press");
if(moved) commitTrack(); else toggle(); }); // hold+spin → track ; quick press → start/stop
k.addEventListener("pointercancel",()=>{ down=false; k.classList.remove("press"); });
})();
/* theme toggle — cycles system → light → dark; shares the "metronome.theme" key */
const THEMES=["system","light","dark"];
function effectiveTheme(p){ return p==="system" ? (matchMedia("(prefers-color-scheme: light)").matches?"light":"dark") : p; }
function themePref(){ try{ const p=localStorage.getItem("metronome.theme"); return (p==="light"||p==="dark"||p==="system")?p:"system"; }catch(e){ return "system"; } }
function applyTheme(p){ try{ localStorage.setItem("metronome.theme",p); }catch(e){}
document.documentElement.dataset.theme = effectiveTheme(p);
$("themeBtn").textContent = p==="system" ? "◐" : p==="light" ? "☀" : "☾"; $("themeBtn").title="Theme: "+p+" (system → light → dark)"; }
$("themeBtn").onclick = ()=> applyTheme(THEMES[(THEMES.indexOf(themePref())+1)%THEMES.length]);
matchMedia("(prefers-color-scheme: light)").addEventListener("change", ()=>{ if(themePref()==="system") applyTheme("system"); });
applyTheme(themePref());
addEventListener("keydown",(e)=>{
const tag=e.target?e.target.tagName:""; if(tag==="INPUT"||tag==="TEXTAREA"||tag==="SELECT") return;
const k=e.key;
if(k===" "||e.code==="Space"){ e.preventDefault(); toggle(); }
else if(k==="ArrowUp"){ e.preventDefault(); nudge(e.shiftKey?10:1); }
else if(k==="ArrowDown"){ e.preventDefault(); nudge(e.shiftKey?-10:-1); }
else if(k==="ArrowRight"){ e.preventDefault(); previewIdx=trackIdx+1; commitTrack(); }
else if(k==="ArrowLeft"){ e.preventDefault(); previewIdx=trackIdx-1; commitTrack(); }
});
/* ========================= INIT ============================================== */
{ const ht=tracksFromHash(); if(ht) tracks=ht; } // a #p=/#sl= link (or embed config) overrides the built-ins
loadTrack(0);
render();
</script>
</body>
</html>