metronome/showcase.html
Me Here dc936fdc11 Proper VARASYS logo (wordmark + tagline) everywhere; embed defaults to the set lists
Logos: the brand is now a consistent lockup — wordmark image + a crisp CSS
"Simplifying Complexity" tagline — in the shared header, device silkscreens
(teacher/stage/micro), the player (was a CSS text box) and the showcase canvas
(was drawn text; now the real logo image + tagline). Cropped the baked tagline
out of logo-light.b64 so both themes render the tagline once. Renamed device
silk logos to .dev-logo so they no longer shrink the shared header logo.

Embeds: every form factor now loads its default set lists when embedded with no
config — and the Concepts landing embeds them that way (viewport loads
<device>?embed=1 with no forced #p=; the program box reflects what the device
reports and only overrides on explicit Load).

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

340 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)
const LOGO = new Image(); let logoReady=false; LOGO.onload=function(){ logoReady=true; }; LOGO.src = "data:image/png;base64,@BUILD:logo-dark@";
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();
// proper VARASYS logo (wordmark image) + tagline + model
const lw=60, lh=Math.round(lw*82/304), lx=CW/2-lw/2, ly=15;
if(logoReady) g.drawImage(LOGO, lx, ly, lw, lh);
g.textAlign="center";
g.fillStyle="#8f9aa6"; g.font="600 5px 'Segoe UI',Roboto,Arial,sans-serif"; g.globalAlpha=.85;
try{ g.letterSpacing="1.4px"; }catch(e){}
g.fillText("SIMPLIFYING COMPLEXITY", CW/2, ly+lh+7);
try{ g.letterSpacing="0px"; }catch(e){} g.globalAlpha=1;
g.fillStyle="#aab2bc"; g.font="600 7px 'Segoe UI',Roboto,Arial,sans-serif"; g.fillText("PM_D1 DISPLAY", CW/2, ly+lh+17);
}
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>