- Shared header/footer/chrome (src/header.html, src/footer.html, src/chrome.js) now on every page: editor (header above its app toolbar), player, teacher, stage, micro, showcase, embed. chrome.js defers to DOMContentLoaded so the footer version stamps regardless of placement. Player's fullscreen toggle relocated out of the header to a floating control. - Open = Info: each form-factor page is self-contained — a more-detailed description (.about) + an expandable "Spec & BOM" (<details class="spec">, hidden in embed). info-*.html retired; build/deploy/README updated. Next: teacher-style dimensioned front + top/side views + loading panels for Stage, Micro and Showcase. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
292 lines
17 KiB
HTML
292 lines
17 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>
|
||
(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>
|
||
<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 & 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 wind‑up metronome. (No power switch: the real one starts when you lift it from its holder.)</div>
|
||
|
||
<section class="about pageonly">
|
||
<h2>PM‑S — Showcase</h2>
|
||
<div class="ff-tags"><span class="hw">Hardware</span><span>Display piece</span><span>~$41 one‑off</span></div>
|
||
<p>A metronome as an object: the silhouette of a classic pyramid wind‑up unit, but the swinging pendulum is
|
||
pure <b>RGB light</b>. The whole bar is the display — every lane's subdivisions & 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 USB‑C with a second "thru" port to daisy‑chain. 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>
|
||
|
||
<details class="spec pageonly">
|
||
<summary>Spec & bill of materials</summary>
|
||
<div class="spec-body">
|
||
<p class="sub">Rough parts list — a USB‑C‑powered RP2040 display piece driving addressable RGB light.
|
||
Ballpark one‑off 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, USB‑C <span class="spec">— e.g. Waveshare RP2040‑Zero</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 / light‑guide <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 & build</td></tr>
|
||
<tr><td class="part">2× USB‑C (data+power & power‑thru) + PWR LED <span class="spec">— daisy‑chain</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 (one‑off)</td><td class="q"></td><td class="c">≈ $41</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</details>
|
||
</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))); }
|
||
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 ? "■ 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‑S SHOWCASE", 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); });
|
||
// combined-meter LEDs: each lane is a moving point of light along the bar (its current step position)
|
||
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;
|
||
}
|
||
// 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; glows on the beat
|
||
const wy=-bpmToFrac(state.bpm)*ROD, lit=Math.max(0,flash);
|
||
const wc = flashAccent ? "rgb(255,155,46)" : "rgb(51,208,255)";
|
||
g.shadowColor=wc; g.shadowBlur=8+22*lit;
|
||
g.fillStyle="#3a4049"; roundRectP(-15,wy-11,30,22,4); g.fill(); g.shadowBlur=0;
|
||
g.fillStyle=wc; g.globalAlpha=.30+0.7*lit; roundRectP(-13,wy-3.5,26,7,2); g.fill(); g.globalAlpha=1;
|
||
g.fillStyle="rgba(255,255,255,.10)"; roundRectP(-15,wy-11,30,5,2); g.fill();
|
||
// 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);
|
||
/*@BUILD:include:src/chrome.js@*/
|
||
</script>
|
||
</body>
|
||
</html>
|