metronome/showcase.html
Me Here 1faf9cad41 Embed version dropdown; Display weight static/behind lights; Practice beat flash; move Philosophy up
- Embed page: a form-factor dropdown that rewrites every snippet (drop-in +
  plain iframe), the live demo, and the name for the chosen version; variant
  table completed to all six.
- Display (Showcase): the tempo weight no longer flashes and is drawn BEHIND the
  pendulum lights so it never hides a beat flash.
- Practice (Micro): a beat/sub-beat flash — the whole 14-seg display washes amber
  on each step (subtle on sub-beats, brighter on the beat, full on the "1"),
  latency-compensated like the other devices.
- Landing: Philosophy section moved above "Pick a form factor".

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

333 lines
20 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_D1 — Display (RGB pendulum metronome)</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,@BUILD:favicon@">
<script>
(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-S "Showcase" — a display-piece metronome shaped like a classic pyramid wind-up
unit. The pendulum is the whole show: a single RGB LED bar where every lane's
subdivisions / accents are combined ALONG its length (each lane is a moving point
of light), it carries a printed TEMPO scale on the vertical axis, and a sliding
WEIGHT sets the tempo (drag it — up = slower, like the real thing). The canvas is
transparent outside the body. There's no on-screen power switch: the real unit
starts when you lift it from its holder / set it swinging — here that's an external
button. Latency-compensated visuals. 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; --panel-bg:#161b22; --field-bg:#0e1116; --field-bd:#2a313c; --cyan:#0AB3F7; }
:root[data-theme="light"]{ --bg1:#f5f8fc; --bg2:#dde4ec; --txt:#1e2630; --muted:#5c6776; --link:#1769c4; --panel-bd:#d2dae4; --panel-bg:#fff; --field-bg:#f1f4f8; --field-bd:#d2dae4 }
body{ margin:0; min-height:100vh; padding:24px 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) }
main{ display:flex; flex-direction:column; align-items:center; gap:16px; width:100% }
.device{ width:100%; max-width:300px; filter:drop-shadow(0 22px 34px rgba(0,0,0,.55)); }
#stage{ display:block; width:100%; height:auto; touch-action:none; cursor:ns-resize }
.ctrls{ display:flex; align-items:center; gap:14px; flex-wrap:wrap; justify-content:center }
.ctrls button{ background:var(--field-bg); color:var(--txt); border:1px solid var(--field-bd); border-radius:9px;
padding:7px 12px; font-size:14px; line-height:1; cursor:pointer }
.ctrls button:hover{ border-color:var(--cyan) }
#play{ min-width:64px; font-size:14px }
.trk{ display:flex; align-items:center; gap:8px; font-size:13px; color:var(--muted) }
.trk b{ color:var(--txt); min-width:34px; text-align:center; display:inline-block; font-variant-numeric:tabular-nums }
.hint{ max-width:340px; 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@*/
<main>
<h1 class="ff-title">PM_D1 Display</h1>
<p class="ff-sum">A displaypiece metronome — the pendulum is an RGB light bar that combines every lane's subdivisions &amp; accents; a printed tempo scale with a sliding weight sets the tempo.</p>
<div class="device"><canvas id="stage" width="300" height="470" aria-label="RGB pendulum metronome"></canvas></div>
<div class="ctrls">
<button id="play" title="Start / stop (Space) — the real unit starts when lifted from its holder">▶ Start</button>
<div class="trk"><button id="prev" title="Previous"></button><b id="trkLbl"></b><button id="next" title="Next"></button></div>
</div>
<div class="hint">The pendulum <b>is</b> the display: every lane's subdivisions &amp; accents ride along the bar as
moving RGB light. Drag the <b>weight</b> up/down (or scroll) to set tempo — the scale is printed on the bar,
just like a windup metronome. (No power switch: the real one starts when you lift it from its holder.)</div>
/*@BUILD:include:src/progbox.html@*/
<label class="info-toggle pageonly"><input type="checkbox" id="infoToggle"> Show technical info (dimensions, BOM, embedding)</label>
<div id="techinfo" class="pageonly" hidden>
<section class="about">
<h2>PM_D1 — Display</h2>
<div class="ff-tags"><span class="hw">Hardware</span><span>Display piece</span><span>~$41 oneoff</span></div>
<p>A metronome as an object: the silhouette of a classic pyramid windup unit, but the swinging pendulum is
pure <b>RGB light</b>. The whole bar is the display — every lane's subdivisions &amp; accents ride along its
length as moving points of light (all meters combined), a printed tempo scale runs up the vertical axis,
and a sliding <b>weight</b> sets the tempo just like the mechanical original.</p>
<p>It's a beautiful, glanceable tempo reference for the shelf, the studio, or a shop window: accents glow
amber, normal steps cyan, ghosts soft violet, and the pendulum eases to each beat exactly as a weighted rod
would. It runs the same grooves as everything else (load any program string), plays the click through a
small speaker, and is powered over USBC with a second "thru" port to daisychain. There's no power switch —
the real unit starts when you lift it from its holder / set it swinging. No instrument I/O; it's a showpiece.</p>
</section>
<div class="dview">
<p class="cap">Dimensions &amp; profile — ≈ 4.7 × 7.1 × 3.1 in (120 × 180 × 80 mm), a truncatedpyramid plinth</p>
<div class="drow">
<div class="dvy">↕ 7.1 in (180 mm)</div>
<div class="dschem" style="height:184px">
<span class="scap">Front</span>
<div style="position:absolute; inset:6px; clip-path:polygon(34% 2%, 66% 2%, 93% 98%, 7% 98%);
background:linear-gradient(180deg,#2c2e34,#15171b); border:1px solid #33363c;"></div>
<div style="position:absolute; left:calc(50% - 4px); top:24px; width:8px; height:120px; border-radius:4px;
background:linear-gradient(180deg,#33d0ff,#178fb0); box-shadow:0 0 10px #33d0ff;"></div>
<div class="jl" style="left:0; right:0; bottom:5px">RGBlight pendulum bar + tempo scale</div>
</div>
</div>
<div class="dvx">↔ 4.7 in (120 mm) base</div>
<div class="drow" style="margin-top:12px">
<div class="dvy">↕ 7.1 in (180 mm)</div>
<div class="dschem" style="height:184px; max-width:200px">
<span class="scap">Side</span>
<div style="position:absolute; inset:6px; clip-path:polygon(40% 2%, 60% 2%, 86% 98%, 14% 98%);
background:linear-gradient(180deg,#26282d,#141519); border:1px solid #33363c;"></div>
<div class="jk u" style="left:calc(50% - 7px); bottom:14px; top:auto"></div>
<div class="jl" style="left:0; right:0; bottom:1px">USBC in base</div>
</div>
</div>
<div class="dvx">↔ 3.1 in (80 mm) deep</div>
</div>
<details class="spec pageonly">
<summary>Spec &amp; bill of materials</summary>
<div class="spec-body">
<p class="sub">Rough parts list — a USBCpowered RP2040 display piece driving addressable RGB light.
Ballpark oneoff prices (USD); cheaper at volume.</p>
<table class="bom">
<thead><tr><th>Part</th><th class="q">Qty</th><th class="c">~$</th></tr></thead>
<tbody>
<tr class="grp"><td colspan="3">Brain</td></tr>
<tr><td class="part">RP2040 board, USBC <span class="spec">— e.g. Waveshare RP2040Zero</span></td><td class="q">1</td><td class="c">4</td></tr>
<tr class="grp"><td colspan="3">RGB light</td></tr>
<tr><td class="part">Addressable RGB LEDs (WS2812B) <span class="spec">— a strip down the pendulum bar, ~40 px</span></td><td class="q">1</td><td class="c">5</td></tr>
<tr><td class="part">Frosted acrylic diffuser / lightguide <span class="spec">— the glowing pendulum bar</span></td><td class="q">1</td><td class="c">3</td></tr>
<tr class="grp"><td colspan="3">Audio</td></tr>
<tr><td class="part">MAX98357A I²S amp + small speaker <span class="spec">— the click</span></td><td class="q">1</td><td class="c">4</td></tr>
<tr class="grp"><td colspan="3">Power &amp; build</td></tr>
<tr><td class="part">2× USBC (data+power &amp; powerthru) + PWR LED <span class="spec">— daisychain</span></td><td class="q">1</td><td class="c">3</td></tr>
<tr><td class="part">Tilt / lift sensor (accelerometer) <span class="spec">— starts when lifted from its holder</span></td><td class="q">1</td><td class="c">2</td></tr>
<tr><td class="part">Custom PCB (or perfboard)</td><td class="q">1</td><td class="c">4</td></tr>
<tr><td class="part">Passives, wire</td><td class="q"></td><td class="c">2</td></tr>
<tr><td class="part">Pyramid enclosure <span class="spec">— cast/CNC aluminium or hardwood, frosted front panel</span></td><td class="q">1</td><td class="c">14</td></tr>
<tr class="total"><td>Total (oneoff)</td><td class="q"></td><td class="c">≈ $41</td></tr>
</tbody>
</table>
</div>
</details>
<p class="sub" style="max-width:760px;margin:14px auto 0">Embed this widget elsewhere with one <code>&lt;div&gt;</code> + a script —
see <a href="/embed.html">the embed docs</a>.</p>
</div><!-- /#techinfo -->
</main>
/*@BUILD:include:src/footer.html@*/
<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; }
beatCount=-1; lastBeatTime=t0; muteWindows=[];
schedulerTimer=setInterval(scheduler,LOOKAHEAD_MS); scheduler(); syncBtns();
}
function stopAudio(){ state.running=false; clearInterval(schedulerTimer); schedulerTimer=null; for(const m of meters) m.currentStep=-1; syncBtns(); }
function toggle(){ state.running ? stopAudio() : startAudio(); }
/* ========================= TRACKS ============================================ */
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(); syncBtns();
}
function syncBtns(){ $("play").textContent = state.running ? "■ Stop" : "▶ Start";
$("trkLbl").textContent = (trackIdx+1)+"/"+tracks.length; }
/* ========================= RGB PENDULUM (canvas) ============================= */
const cv=$("stage"), g=cv.getContext("2d"), CW=300, CH=470;
(function(){ const dpr=Math.max(1,Math.min(3,window.devicePixelRatio||1)); cv.width=CW*dpr; cv.height=CH*dpr; g.scale(dpr,dpr); })();
const MAXANG=0.40;
const PIVX=150, PIVY=380, ROD=292; // pivot near the base; rod points up
const F_FAST=0.30, F_SLOW=0.94; // weight fraction along rod at 240 / 40 BPM (top=slow)
let beatCount=-1, lastBeatTime=0, pend=0, flash=0, flashAccent=false;
function audioLatency(){ return audioCtx ? (audioCtx.outputLatency || audioCtx.baseLatency || 0) : 0; }
const LEVELCOL = { 2:[255,155,46], 1:[51,208,255], 3:[155,123,255], 0:[70,80,95] }; // accent / normal / ghost / mute (rgb)
function bpmToFrac(b){ return F_SLOW - (Math.max(40,Math.min(240,b))-40)/200*(F_SLOW-F_FAST); }
function fracToBpm(f){ return Math.round(240 - (Math.max(F_FAST,Math.min(F_SLOW,f))-F_FAST)/(F_SLOW-F_FAST)*200); }
function drawBody(){
g.clearRect(0,0,CW,CH); // transparent everywhere outside the body
const tlx=98,trx=202,topY=18, blx=20,brx=280,botY=440;
const grd=g.createLinearGradient(0,0,0,botY); grd.addColorStop(0,"#2c2e34"); grd.addColorStop(1,"#131419");
g.beginPath(); g.moveTo(tlx,topY); g.lineTo(trx,topY); g.lineTo(brx,botY); g.lineTo(blx,botY); g.closePath();
g.fillStyle=grd; g.fill(); g.lineWidth=1.5; g.strokeStyle="rgba(255,255,255,.06)"; g.stroke();
g.beginPath(); g.moveTo(tlx,topY); g.lineTo(blx,botY); g.lineWidth=2; g.strokeStyle="rgba(255,255,255,.05)"; g.stroke();
g.textAlign="center"; g.fillStyle="#aab2bc";
g.font="700 9px 'Segoe UI',Roboto,Arial,sans-serif"; g.fillText("V A R A S Y S", CW/2, 33);
g.globalAlpha=.8; g.font="600 7.5px 'Segoe UI',Roboto,Arial,sans-serif"; g.fillText("PM_D1 DISPLAY", CW/2, 44); g.globalAlpha=1;
}
function drawPendulum(){
g.save(); g.translate(PIVX,PIVY); g.rotate(pend); // rod frame: up = -y, tilts with the swing
// rod
g.strokeStyle="rgba(150,160,176,.45)"; g.lineWidth=3.5; g.lineCap="round";
g.beginPath(); g.moveTo(0,0); g.lineTo(0,-ROD); g.stroke();
// printed tempo scale (numbers along the bar)
g.textAlign="right"; g.font="600 8px 'Segoe UI',Roboto,Arial,sans-serif";
[40,60,80,100,120,160,200,240].forEach(function(b){ const y=-bpmToFrac(b)*ROD;
g.strokeStyle="rgba(180,190,205,.5)"; g.lineWidth=1; g.beginPath(); g.moveTo(4,y); g.lineTo(10,y); g.stroke();
g.fillStyle="rgba(180,190,205,.6)"; g.fillText(String(b), 2, y+3); });
// fixed bob (drives the swing) near the bottom of the rod
g.fillStyle="#2a2f37"; roundRectP(-9,-58,18,30,4); g.fill();
g.fillStyle="rgba(255,255,255,.06)"; roundRectP(-9,-58,18,5,2); g.fill();
// sliding WEIGHT = tempo — STATIC (no flash) and drawn BEHIND the lights so it never hides a beat flash
const wy=-bpmToFrac(state.bpm)*ROD;
g.fillStyle="#3a4049"; roundRectP(-15,wy-11,30,22,4); g.fill();
g.fillStyle="rgba(150,160,176,.5)"; roundRectP(-13,wy-3.5,26,7,2); g.fill(); // index mark (static)
g.fillStyle="rgba(255,255,255,.10)"; roundRectP(-15,wy-11,30,5,2); g.fill(); // top sheen
// combined-meter LEDs: each lane is a moving point of light along the bar (its current step position).
// Drawn LAST so the flashes sit on top of the weight.
for(const m of meters){ if(m.currentStep<0 || !state.running) continue;
const steps=m.beatsPerBar*m.stepsPerBeat, fr=steps?((m.currentStep%steps)/steps):0;
const y=-(0.16 + fr*(0.96-0.16))*ROD, lvl=m.beatsOn[m.currentStep]|0; if(lvl===0) continue;
const c=LEVELCOL[lvl]||LEVELCOL[1], rgb="rgb("+c[0]+","+c[1]+","+c[2]+")";
g.shadowColor=rgb; g.shadowBlur=14; g.fillStyle=rgb;
g.beginPath(); g.arc(0,y, lvl>=2?6:4.5, 0,7); g.fill(); g.shadowBlur=0;
}
// pivot hub
g.restore();
g.beginPath(); g.arc(PIVX,PIVY,6,0,7); g.fillStyle="#2a2f37"; g.fill();
g.beginPath(); g.arc(PIVX,PIVY,2.5,0,7); g.fillStyle="#aab2bc"; g.fill();
}
function roundRectP(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 drawReadout(){
g.textAlign="center";
g.fillStyle="#c7d0db"; g.font="700 20px 'Segoe UI',Roboto,Arial,sans-serif"; g.fillText(String(state.bpm), CW/2-2, 420);
g.fillStyle="#7f8b9a"; g.font="600 8px 'Segoe UI',Roboto,Arial,sans-serif"; g.fillText("BPM", CW/2+38, 420);
const nm=(tracks[trackIdx]&&tracks[trackIdx].name)||"—";
g.fillStyle="#8f9aa6"; g.font="600 8.5px 'Segoe UI',Roboto,Arial,sans-serif"; g.fillText(nm.length>32?nm.slice(0,31)+"…":nm, CW/2, 432);
}
function draw(){
const now = audioCtx ? audioCtx.currentTime - audioLatency() : 0;
if(audioCtx && state.running){
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;
if(m===meters[0] && e.step % m.stepsPerBeat === 0){
beatCount++; lastBeatTime=e.time;
const lvl=m.beatsOn[e.step]|0; flashAccent = lvl>=2 || m.groupStarts.has(e.step/m.stepsPerBeat);
if(lvl!==0) flash=1;
}
m.vqPtr++;
}
if(m.vqPtr>512){ m.vq=m.vq.slice(m.vqPtr); m.vqPtr=0; }
}
}
let tgt=0;
if(state.running && beatCount>=0){ let fr=(now-lastBeatTime)/(60/state.bpm); if(fr<0)fr=0; if(fr>1.2)fr=1.2;
tgt = MAXANG*Math.cos(Math.PI*(beatCount+fr)); }
pend += (tgt-pend)*(state.running?1:0.12);
flash = Math.max(0, flash-0.08);
drawBody(); drawPendulum(); drawReadout();
requestAnimationFrame(draw);
}
/* ========================= CONTROLS ========================================== */
// drag anywhere on the piece to set tempo via the weight (vertical axis); scroll too.
let dragging=false;
function tempoFromY(clientY){ const r=cv.getBoundingClientRect(); const yy=(clientY-r.top)/r.height*CH;
const f=(PIVY-yy)/ROD; setBpm(fracToBpm(f)); syncBtns(); }
cv.addEventListener("pointerdown",(e)=>{ dragging=true; try{cv.setPointerCapture(e.pointerId);}catch(_){ } tempoFromY(e.clientY); });
cv.addEventListener("pointermove",(e)=>{ if(dragging) tempoFromY(e.clientY); });
cv.addEventListener("pointerup",()=>{ dragging=false; });
cv.addEventListener("pointercancel",()=>{ dragging=false; });
cv.addEventListener("wheel",(e)=>{ e.preventDefault(); setBpm(state.bpm+(e.deltaY<0?(e.shiftKey?5:1):(e.shiftKey?-5:-1))); syncBtns(); }, {passive:false});
$("play").onclick = ()=>toggle();
$("prev").onclick = ()=>loadTrack(trackIdx-1);
$("next").onclick = ()=>loadTrack(trackIdx+1);
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==="ArrowUp"){ e.preventDefault(); setBpm(state.bpm+(e.shiftKey?10:1)); syncBtns(); }
else if(e.key==="ArrowDown"){ e.preventDefault(); setBpm(state.bpm-(e.shiftKey?10:1)); syncBtns(); }
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); syncBtns();
requestAnimationFrame(draw);
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); syncBtns(); };
/*@BUILD:include:src/progbox.js@*/
/*@BUILD:include:src/chrome.js@*/
</script>
</body>
</html>