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>
284 lines
15 KiB
HTML
284 lines
15 KiB
HTML
<!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‑S — 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>PM‑S</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 wind‑up
|
||
metronome); the light rows below show each lane's <b>subdivisions, accents & 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("PM‑S 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>
|