- Remove the VCSL sample kit entirely (editor 351K → 141K). All voices are synthesized; the friendly GM names now alias to the punchier 808/909 renders (KIT_ALIAS). build.sh drops the @BUILD:samples inlining; assets/samples.json gone. - Conventions (backward-compatible): GM note-number aliases (36=kick…), '-'/'_' rest aliases in step patterns, Euclidean (k,n[,rot]) shorthand. - Per-lane gain in dB (@<db> in the grammar) applied as a velocity multiplier at schedule time — no stutter; threaded through every host's buildMeters + the editor's lanes (knob UI comes in Phase B). - 15/15 engine round-trip tests pass; pages console-clean. 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,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‑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>
|