metronome/showcase.html
Me Here 17053719f1 New Stage (foot-pedal) + Showcase (RGB pendulum); fix audio/visual sync
Sync: the visual playhead now advances on a latency-compensated clock
(currentTime − outputLatency||baseLatency) so the on-screen pulse lands when the
click is HEARD, not when it's queued — previously the visual could lead the audio
by the output buffer / Bluetooth latency (up to ~a subdivision). Applied to
editor, player, teacher, and the new pages; also bound the visual queue (vq trim).
No data races: single-threaded; only the rAF draw touches vqPtr/currentStep, and
each vq entry carries the exact scheduled time of its sound.

stage.html — foot-pedal stompbox: two heavy footswitches (Tap=tempo / hold=start-
stop, Next=item / hold=prev), 1/4" expression-pedal input → tempo sweep, big
floor-readable RGB beat light + angled TFT, analog instrument pass-through.
showcase.html — pyramid display piece: an RGB-light pendulum easing to each beat
plus per-lane segment rows showing subdivisions/accents/mutes (canvas).
Both: dual USB-C (data+power and power-thru) to daisy-chain off one source.

Wired into embed.js (stage, showcase variants), build.sh, deploy.sh, the
concepts gallery + landing cards, info-stage.html (~$52) + info-showcase.html
(~$39) with BOMs, and the README.

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

284 lines
15 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 PMS — Showcase (RGB pendulum metronome)</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-S "Showcase" — a display-piece metronome shaped like a classic pyramid wind-up
unit, but the swinging pendulum is simulated with RGB light (a glowing bob on a
rod, easing to the extremes on each beat exactly like the mechanical original),
and rows of RGB segment lights show every lane's subdivisions / accents / mutes.
Same RGB-everywhere RP2040 firmware/engine; USB-C powered (dual-port daisy-chain).
Visuals are latency-compensated so the swing lands when the click is HEARD.
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; --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; --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:14px }
a{ color:var(--link) }
.device{ width:100%; max-width:300px; filter:drop-shadow(0 22px 34px rgba(0,0,0,.6)); }
#stage{ display:block; width:100%; height:auto }
.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:46px; font-size:15px }
.tempo, .trk{ display:flex; align-items:center; gap:8px; font-size:13px; color:var(--muted) }
.tempo b, .trk b{ color:var(--txt); min-width:34px; text-align:center; display:inline-block; font-variant-numeric:tabular-nums }
.u{ font-size:10px; letter-spacing:.08em; text-transform:uppercase }
.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>
<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>PMS</b> · Showcase (RGB pendulum)</span>
</div>
<nav class="site-nav">
<a href="/editor.html">Editor</a>
<a href="/concepts.html">Concepts</a>
<a href="/info-showcase.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"><canvas id="stage" width="300" height="440" aria-label="RGB pendulum metronome"></canvas></div>
<div class="ctrls">
<button id="play" title="Start / stop (Space)"></button>
<div class="tempo"><button id="slower" title="Slower"></button><b id="bpmLbl">120</b><span class="u">bpm</span><button id="faster" title="Faster">+</button></div>
<div class="trk"><button id="prev" title="Previous"></button><b id="trkLbl"></b><button id="next" title="Next"></button></div>
</div>
<div class="hint">A showcase piece: the <b>RGB pendulum</b> swings in time (easing to the beat like a real windup
metronome); the light rows below show each lane's <b>subdivisions, accents &amp; mutes</b>. Click the piece to
start/stop · scroll it for tempo.</div>
<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))); }
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; 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 ? "■" : "▶";
$("bpmLbl").textContent = state.bpm; $("trkLbl").textContent = (trackIdx+1)+"/"+tracks.length; }
/* ========================= RGB PENDULUM + LANE LIGHTS (canvas) =============== */
const cv=$("stage"), g=cv.getContext("2d"), CW=300, CH=440;
(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.42; // pendulum swing (rad) ~24°
let beatCount=-1, lastBeatTime=0, pend=0, flash=0, flashAccent=false;
function audioLatency(){ return audioCtx ? (audioCtx.outputLatency || audioCtx.baseLatency || 0) : 0; }
const LEVELCOL = { 2:"#ff9b2e", 1:"#33d0ff", 3:"#9b7bff", 0:"#39424f" }; // accent / normal / ghost / mute
function roundRect(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 drawBody(){
// truncated-pyramid silhouette (classic metronome), matte graphite
g.clearRect(0,0,CW,CH);
const tlx=92, trx=208, blx=24, brx=276, topY=14, botY=420;
const grd=g.createLinearGradient(0,0,0,botY); grd.addColorStop(0,"#2c2e34"); grd.addColorStop(1,"#141518");
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();
// left-edge sheen
g.beginPath(); g.moveTo(tlx,topY); g.lineTo(blx,botY); g.lineWidth=2; g.strokeStyle="rgba(255,255,255,.05)"; g.stroke();
// brand silk
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, 30);
g.font="600 7.5px 'Segoe UI',Roboto,Arial,sans-serif"; g.globalAlpha=.8; g.fillText("PMS SHOWCASE", CW/2, 41); g.globalAlpha=1;
}
function drawWindow(){
// recessed dark window for the pendulum
roundRect(70,56,160,212,10); g.fillStyle="#05070a"; g.fill();
g.lineWidth=1; g.strokeStyle="rgba(0,0,0,.8)"; g.stroke();
// faint scale ticks (an arc the bob sweeps across)
const px=CW/2, py=258; // pivot near the bottom of the window
g.strokeStyle="rgba(255,255,255,.07)"; g.lineWidth=1;
for(let k=-3;k<=3;k++){ const a=(k/3)*MAXANG; const x1=px+Math.sin(a)*150, y1=py-Math.cos(a)*150, x2=px+Math.sin(a)*162, y2=py-Math.cos(a)*162;
g.beginPath(); g.moveTo(px+Math.sin(a)*70,py-Math.cos(a)*70); g.lineTo(px+Math.sin(a)*150,py-Math.cos(a)*150); g.globalAlpha=.05; g.stroke(); g.globalAlpha=1; }
return {px,py};
}
function drawPendulum(piv){
const px=piv.px, py=piv.py, L=176, a=pend;
const tipX=px+Math.sin(a)*L, tipY=py-Math.cos(a)*L;
const bobX=px+Math.sin(a)*L*0.66, bobY=py-Math.cos(a)*L*0.66;
// rod
g.strokeStyle="rgba(170,180,196,.5)"; g.lineWidth=3; g.lineCap="round";
g.beginPath(); g.moveTo(px,py); g.lineTo(tipX,tipY); g.stroke();
// pivot hub
g.beginPath(); g.arc(px,py,6,0,7); g.fillStyle="#2a2f37"; g.fill();
g.beginPath(); g.arc(px,py,2.5,0,7); g.fillStyle="#aab2bc"; g.fill();
// glowing RGB bob (brightens on the beat tick)
const col = flashAccent ? "#ff9b2e" : "#33d0ff", lit=0.4+0.6*Math.max(0,flash);
g.shadowColor=col; g.shadowBlur=18+26*Math.max(0,flash);
g.beginPath(); g.arc(bobX,bobY,12,0,7);
const bg=g.createRadialGradient(bobX-3,bobY-3,2,bobX,bobY,12); bg.addColorStop(0,"#ffffff"); bg.addColorStop(.4,col); bg.addColorStop(1,"rgba(0,0,0,.2)");
g.globalAlpha=lit; g.fillStyle=bg; g.fill(); g.globalAlpha=1; g.shadowBlur=0;
// tip glow dot
g.beginPath(); g.arc(tipX,tipY,3.5,0,7); g.fillStyle=col; g.shadowColor=col; g.shadowBlur=8*Math.max(.2,flash); g.fill(); g.shadowBlur=0;
}
function drawLanes(){
// RGB segment rows for each lane: subdivisions / accents / mutes, with the playhead
const lanes=meters.slice(0,4), x0=40, x1=260, top=294, rowH=26;
g.textAlign="left";
for(let li=0; li<lanes.length; li++){ const m=lanes[li], steps=m.beatsPerBar*m.stepsPerBeat, y=top+li*rowH;
const cw=(x1-x0)/Math.max(1,steps), pad=Math.min(3, cw*0.18);
for(let s=0;s<steps;s++){ const lvl=m.beatsOn[s]|0, x=x0+s*cw, isBeat=(s%m.stepsPerBeat===0);
const cur = state.running && s===m.currentStep;
const base = cur ? 1 : (lvl===0 ? 0.10 : 0.18);
g.globalAlpha = base; g.fillStyle = LEVELCOL[lvl]||LEVELCOL[0];
const h = isBeat ? 13 : 9, yy = y + (13-h)/2;
if(cur){ g.shadowColor=LEVELCOL[lvl]||"#33d0ff"; g.shadowBlur=12; }
roundRect(x+pad, yy, cw-2*pad, h, 2); g.fill(); g.shadowBlur=0; g.globalAlpha=1;
}
}
// BPM + item name on the base plinth
g.textAlign="center"; g.fillStyle="#c7d0db"; g.font="700 22px 'Segoe UI',Roboto,Arial,sans-serif";
g.fillText(String(state.bpm), CW/2-2, 410); g.font="600 9px 'Segoe UI',Roboto,Arial,sans-serif"; g.fillStyle="#7f8b9a";
g.fillText("BPM", CW/2+44, 410);
const nm=(tracks[trackIdx]&&tracks[trackIdx].name)||"—";
g.fillStyle="#8f9aa6"; g.font="600 9px 'Segoe UI',Roboto,Arial,sans-serif"; g.fillText(nm.length>30?nm.slice(0,29)+"…":nm, CW/2, 396);
}
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; }
}
}
// pendulum target angle (eases to extreme each beat, like a real metronome)
let tgt=0;
if(state.running && beatCount>=0){ let frac=(now-lastBeatTime)/(60/state.bpm); if(frac<0)frac=0; if(frac>1.2)frac=1.2;
tgt = MAXANG*Math.cos(Math.PI*(beatCount+frac)); }
pend += (tgt-pend)*(state.running?1:0.12);
flash = Math.max(0, flash-0.08);
drawBody(); const piv=drawWindow(); drawLanes(); drawPendulum(piv);
requestAnimationFrame(draw);
}
/* ========================= CONTROLS ========================================== */
cv.addEventListener("click", ()=>toggle());
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 = (e)=>{ e.stopPropagation(); toggle(); };
$("slower").onclick = ()=>{ setBpm(state.bpm-1); syncBtns(); };
$("faster").onclick = ()=>{ setBpm(state.bpm+1); syncBtns(); };
$("prev").onclick = ()=>loadTrack(trackIdx-1);
$("next").onclick = ()=>loadTrack(trackIdx+1);
/* theme toggle */
const THEMES=["system","light","dark"];
function effTheme(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 = effTheme(p);
$("themeBtn").textContent = p==="system" ? "◐" : p==="light" ? "☀" : "☾"; $("themeBtn").title="Theme: "+p; }
$("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;
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);
</script>
</body>
</html>