metronome/micro.html
Me Here fc05cfe4c7 Site phase 2: embeddable widget standard (?embed=1 + embed.js + embed.html)
- Every form factor supports ?embed=1: a head flag (set on <html>, no flash)
  strips the site chrome (base.css [data-embed]) + page-specific panels, leaving
  just the widget, and posts its height so the host can auto-size it.
- Config/settings string preloads via the existing #p=/#sl= hash. Added that
  hash handling to micro.html (it previously only loaded built-in tracks).
- New embed.js loader: <div data-varasys-metronome="micro" data-patch="…"> + one
  <script> → an auto-sizing iframe to <page>?embed=1#p=…. New embed.html documents
  it and dogfoods a live embedded widget.
- "Embed" nav link added across pages; build.sh/deploy.sh build embed.html and
  serve embed.js.

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

268 lines
16 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µ — 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="/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>