metronome/player.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

514 lines
30 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 PM1 — hardware player (mockup)</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,@BUILD:favicon@">
<script>
/* ?embed=1 → strip site chrome (base.css [data-embed]) + 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>
<!--
Hardware-device MOCKUP / simulator for the Pi Pico (RP2040) build of the
Stackable Metronome. The physical unit can't show the multi-lane editor or
manage set lists — it just *plays* a configuration expressed in the share
language. So this page is the device front panel: load a patch or set-list
(paste one, open a #p=/#sl= link, or pick a saved one) and it plays it,
driving the OLED + beat LEDs exactly as the firmware would.
Audio here is the synthesized voice set (the firmware uses the same scheduler;
on hardware the voices map to CC0 samples on the I2S DAC). One file, no deps.
-->
<script>
// Set theme before first paint (avoids a flash). Shares the editor's "metronome.theme".
(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{
/* environment (themed): room background + page/panel chrome */
--bg1:#12151c; --bg2:#05070a;
--txt:#c7d0db; --muted:#7f8b9a; --link:#6cb6ff;
--panel-bg:#171b22; --panel-bd:#2a313c; --field-bg:#0e1116; --field-bd:#222a36;
/* the device case — kept a clear step lighter than the room so it reads as an object */
--case:#232a36; --case2:#151a22; --device-bd:#39434f;
--device-shadow:0 0 0 1px rgba(120,150,190,.06), 0 30px 70px rgba(0,0,0,.62), 0 0 46px rgba(10,179,247,.05);
/* device internals — fixed dark-hardware colours in BOTH themes */
--dtxt:#c7d0db; --dmuted:#7f8b9a;
--cyan:#0AB3F7; --amber:#ffd166; --edge:#0b0d11; --bezel:#0a0c10;
--screen:#04140f; --phos:#34e0a0; --phos-dim:#1f6e54; --led-off:#1b2a23;
}
:root[data-theme="light"]{
/* light "desk": the dark device sits on a bright surface → strong contrast */
--bg1:#f5f8fc; --bg2:#dde4ec;
--txt:#1e2630; --muted:#5c6776; --link:#1769c4;
--panel-bg:#ffffff; --panel-bd:#d2dae4; --field-bg:#f1f4f8; --field-bd:#cdd6e0;
--case:#2b3340; --case2:#1b212b; --device-bd:#0f141b;
--device-shadow:0 0 0 1px rgba(0,0,0,.05), 0 26px 50px rgba(20,30,50,.30);
}
body{
margin:0; min-height:100vh; padding:28px 16px 48px;
background:radial-gradient(circle at 50% -8%, var(--bg1), var(--bg2));
color:var(--txt);
display:flex; flex-direction:column; align-items:center; gap:20px;
}
a{color:var(--link)}
.topbar{width:100%; max-width:560px; display:flex; align-items:center; justify-content:space-between; font-size:13px; color:var(--muted)}
.topbar b{color:var(--txt)}
.topbar-right{ display:flex; align-items:center; gap:12px }
.tbtn{ background:transparent; color:var(--muted); border:1px solid var(--panel-bd); border-radius:8px;
padding:3px 9px; font-size:14px; line-height:1; cursor:pointer }
.tbtn:hover{ color:var(--txt) }
/* ---- the device ---- */
.device{
width:100%; max-width:560px; position:relative;
background:linear-gradient(180deg,var(--case),var(--case2));
border:1px solid var(--device-bd); border-radius:22px; padding:22px 22px 26px;
box-shadow:var(--device-shadow), inset 0 1px 0 rgba(255,255,255,.06), inset 0 -2px 8px rgba(0,0,0,.55);
}
.device::before, .device::after,
.device .screw{ content:""; position:absolute; width:9px; height:9px; border-radius:50%;
background:radial-gradient(circle at 35% 30%, #5a626e, #20252d 70%); box-shadow:inset 0 0 2px #000; }
.device::before{ top:11px; left:11px } .device::after{ top:11px; right:11px }
.screw.bl{ bottom:11px; left:11px } .screw.br{ bottom:11px; right:11px }
.brandrow{ display:flex; align-items:center; justify-content:space-between; margin:2px 6px 16px; }
.logo{ display:flex; align-items:baseline; gap:9px }
.logo .vk{ font-weight:800; letter-spacing:.22em; color:#fff; font-size:17px;
background:var(--cyan); padding:2px 8px; border-radius:4px; box-shadow:0 0 10px rgba(10,179,247,.5) }
.logo .model{ color:var(--dmuted); font-size:12px; letter-spacing:.04em }
.pwr{ display:flex; align-items:center; gap:7px; font-size:10px; color:var(--dmuted); text-transform:uppercase; letter-spacing:.12em }
.pwr .dot{ width:8px; height:8px; border-radius:50%; background:#2fe07a; box-shadow:0 0 8px #2fe07a }
/* ---- OLED ---- */
.screen{
background:linear-gradient(180deg,#06181244,var(--screen)); border:2px solid var(--bezel);
border-radius:10px; padding:14px 16px; margin:0 4px;
box-shadow:inset 0 0 24px rgba(0,0,0,.8), inset 0 0 6px rgba(52,224,160,.12), 0 1px 0 rgba(255,255,255,.04);
font-family:"Courier New",ui-monospace,monospace; color:var(--phos);
text-shadow:0 0 6px rgba(52,224,160,.55); position:relative; overflow:hidden;
}
.screen::after{ content:""; position:absolute; inset:0; pointer-events:none;
background:repeating-linear-gradient(0deg, rgba(0,0,0,.18) 0 1px, transparent 1px 3px); opacity:.5 }
.scr-top{ display:flex; justify-content:space-between; align-items:baseline; font-size:13px; color:var(--phos-dim) }
.scr-top .pos{ letter-spacing:.06em }
.scr-top .tempo{ color:var(--phos); font-size:16px }
.scr-top .tempo b{ font-size:22px; font-weight:700 }
.scr-name{ font-size:20px; margin:7px 0 8px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis }
.scr-bot{ display:flex; justify-content:space-between; align-items:baseline; font-size:13px; color:var(--phos-dim) }
.scr-bot .st{ color:var(--phos) }
.scr-bot .st.stopped{ color:var(--phos-dim) }
.scr-bot .bars{ color:var(--amber); text-shadow:0 0 6px rgba(255,209,102,.5) }
/* ---- beat LEDs ---- */
.leds{ display:flex; gap:8px; justify-content:center; flex-wrap:wrap; margin:16px 4px 6px; min-height:18px }
.led{ width:16px; height:16px; border-radius:50%; background:var(--led-off);
border:1px solid #000; box-shadow:inset 0 1px 2px rgba(0,0,0,.7); transition:background .04s, box-shadow .04s }
.led.group{ outline:1px solid #3a4754; outline-offset:2px }
.led.on{ background:var(--cyan); box-shadow:0 0 10px var(--cyan), 0 0 4px #fff inset }
.led.on.group{ background:var(--amber); box-shadow:0 0 12px var(--amber), 0 0 4px #fff inset }
/* ---- controls ---- */
.controls{ display:flex; align-items:center; justify-content:center; gap:12px; margin:14px 4px 4px; flex-wrap:wrap }
.btn{ background:linear-gradient(180deg,#2b323d,#1b212a); color:var(--dtxt); border:1px solid #39424f;
border-radius:11px; padding:12px 14px; font-size:15px; cursor:pointer; min-width:48px;
box-shadow:0 3px 0 #0c0f14, inset 0 1px 0 rgba(255,255,255,.06); user-select:none; transition:transform .04s, box-shadow .04s }
.btn:active{ transform:translateY(2px); box-shadow:0 1px 0 #0c0f14, inset 0 1px 0 rgba(255,255,255,.06) }
.btn small{ display:block; font-size:9px; color:var(--dmuted); letter-spacing:.08em; margin-top:2px }
.btn.play{ min-width:74px; font-size:20px; background:linear-gradient(180deg,#1f7a4d,#155f3b); border-color:#2e7d32; color:#eafff3 }
.btn.play.on{ background:linear-gradient(180deg,#b23b3b,#8f2d2d); border-color:#c0392b }
.knob{ width:52px; height:52px; border-radius:50%; background:radial-gradient(circle at 38% 32%,#3a424e,#171b22 72%);
border:1px solid #444c58; box-shadow:0 3px 8px rgba(0,0,0,.5), inset 0 1px 1px rgba(255,255,255,.08); position:relative; margin-left:4px }
.knob::after{ content:""; position:absolute; left:50%; top:6px; width:2px; height:13px; background:var(--cyan);
border-radius:2px; transform-origin:50% 20px; transform:rotate(var(--a,0deg)); box-shadow:0 0 5px var(--cyan) }
.knob-wrap{ display:flex; flex-direction:column; align-items:center; gap:4px; font-size:9px; color:var(--dmuted); letter-spacing:.1em }
/* ---- speaker grille ---- */
.grille{ height:14px; margin:18px 6px 2px; border-radius:6px;
background:radial-gradient(circle, #000 1.1px, transparent 1.4px) 0 0/9px 9px; opacity:.5 }
/* ---- load panel ---- */
.panel{ width:100%; max-width:560px; background:var(--panel-bg); border:1px solid var(--panel-bd); border-radius:14px; padding:16px }
.panel h2{ margin:0 0 4px; font-size:15px }
.panel p.sub{ margin:0 0 12px; font-size:12px; color:var(--muted); line-height:1.45 }
.panel label{ font-size:12px; color:var(--muted); display:block; margin:10px 0 5px }
textarea{ width:100%; background:var(--field-bg); color:var(--txt); border:1px solid var(--field-bd); border-radius:9px;
padding:10px; font-family:"Courier New",monospace; font-size:12px; resize:vertical; min-height:58px }
select, .ld{ border-radius:9px; padding:8px 10px; font-size:13px }
select{ background:var(--field-bg); color:var(--txt); border:1px solid var(--field-bd) }
.ld{ cursor:pointer; color:var(--dtxt); background:linear-gradient(180deg,#2b323d,#1b212a); border:1px solid #39424f }
.row{ display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-top:8px }
.status{ margin-top:10px; font-size:12px; min-height:1.2em; font-family:"Courier New",monospace }
.status.ok{ color:#5fd08a } .status.err{ color:#ff7a7a }
.hint{ font-size:11px; color:var(--muted) }
code{ background:var(--field-bg); border:1px solid var(--field-bd); border-radius:4px; padding:1px 5px; font-size:11px }
/* ---- full-screen "stage" mode: edge-to-edge, follows light/dark/system ---- */
.fs-ctrl{ display:none; position:fixed; top:max(12px,env(safe-area-inset-top)); z-index:80;
background:rgba(127,139,154,.16); color:var(--txt); border:1px solid rgba(127,139,154,.5);
border-radius:50%; width:40px; height:40px; font-size:17px; line-height:1; cursor:pointer;
backdrop-filter:blur(4px); -webkit-backdrop-filter:blur(4px) }
.fs-ctrl:hover{ background:rgba(127,139,154,.32) }
#fsExit{ right:max(12px,env(safe-area-inset-right)) }
#fsThemeBtn{ right:calc(max(12px,env(safe-area-inset-right)) + 50px) }
.rotate-hint{ display:none }
/* the device frame goes transparent → the themed page background IS the full-screen
skin (light in light mode, dark in dark mode); children flex to fill the screen */
body.stage{ position:fixed; inset:0; padding:0; gap:0; overflow:hidden }
body.stage .topbar, body.stage .panel, body.stage .grille{ display:none }
body.stage .fs-ctrl{ display:block }
body.stage .device{ position:absolute; inset:0; width:auto; max-width:none; margin:0;
background:radial-gradient(circle at 50% -8%, var(--bg1), var(--bg2)); border:none; border-radius:0; box-shadow:none;
display:flex; flex-direction:column;
padding:max(2.2vh,env(safe-area-inset-top)) max(4vw,env(safe-area-inset-right))
max(2.4vh,env(safe-area-inset-bottom)) max(4vw,env(safe-area-inset-left)) }
body.stage .device::before, body.stage .device::after, body.stage .screw{ display:none }
body.stage .brandrow{ flex:0 0 auto; margin:0 0 2vh }
body.stage .pwr{ display:none } /* declutter the corner the floating controls sit in */
body.stage .logo .model, body.stage .knob-wrap{ color:var(--muted) }
body.stage .screen{ flex:1 1 auto; display:flex; flex-direction:column; justify-content:space-between; padding:2.4vh 3vw }
body.stage .scr-top{ font-size:2.8vh }
body.stage .scr-top .tempo{ font-size:3.6vh }
body.stage .scr-top .tempo b{ font-size:9vh }
body.stage .scr-name{ font-size:7vh; margin:0 }
body.stage .scr-bot{ font-size:2.8vh }
body.stage .leds{ flex:0 0 auto; gap:1.8vmin; margin:2.4vh 0 0 }
body.stage .led{ width:4.6vmin; height:4.6vmin }
body.stage .controls{ flex:0 0 auto; gap:1.8vmin; margin-top:2.2vh }
body.stage .controls .btn{ font-size:2.6vh; padding:1.6vh 2vw; min-width:8vw }
body.stage .controls .btn.play{ min-width:12vw; font-size:3.6vh }
body.stage .controls .btn small{ font-size:1.3vh }
/* portrait while staged (mainly iPhone, which can't lock) → prompt to rotate */
@media (orientation: portrait){
body.stage .device{ filter:blur(3px) brightness(.6); pointer-events:none }
body.stage .rotate-hint{ display:flex; position:fixed; inset:0; z-index:90;
flex-direction:column; align-items:center; justify-content:center; gap:18px;
background:var(--bg1); color:var(--txt); font-size:20px; text-align:center; padding:24px }
body.stage .rotate-hint .rh-icon{ font-size:64px; line-height:1; color:var(--cyan) }
}
/* embed mode: just the device */
[data-embed] .panel { 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>PM1</b> · Initial concept</span>
</div>
<nav class="site-nav">
<a href="/editor.html">Editor</a>
<a href="/concepts.html">Concepts</a>
<a href="/info-initial.html">Info</a>
<a href="/embed.html">Embed</a>
<button id="fsBtn" class="tbtn" title="Full screen (landscape)"></button>
<button id="themeBtn" class="tbtn" title="toggle light / dark theme"></button>
</nav>
</header>
<!-- ===================== THE DEVICE ===================== -->
<div class="device">
<span class="screw bl"></span><span class="screw br"></span>
<div class="brandrow">
<div class="logo"><span class="vk">VARASYS</span><span class="model">PM1 · Polymeter Player</span></div>
<div class="pwr"><span class="dot"></span>PWR</div>
</div>
<div class="screen">
<div class="scr-top"><span class="pos" id="sPos">/</span><span class="tempo">♩=<b id="sBpm">120</b></span></div>
<div class="scr-name" id="sName"></div>
<div class="scr-bot"><span class="st stopped" id="sState">⏸ STOP</span><span id="sBar">bar — · beat —</span><span class="bars" id="sBars"></span></div>
</div>
<div class="leds" id="leds"></div>
<div class="controls">
<button class="btn" id="bPrev" title="previous item"><small>PREV</small></button>
<button class="btn" id="bDown" title="tempo "><small>TEMPO</small></button>
<button class="btn play" id="bPlay" title="play / stop (Space)"><small>&nbsp;</small></button>
<button class="btn" id="bUp" title="tempo +">+<small>TEMPO</small></button>
<button class="btn" id="bNext" title="next item"><small>NEXT</small></button>
<button class="btn" id="bTap" title="tap tempo (T)">TAP<small>&nbsp;</small></button>
<div class="knob-wrap"><div class="knob" id="knob"></div>TEMPO</div>
</div>
<div class="grille"></div>
</div>
<!-- ===================== LOAD CONFIG ===================== -->
<div class="panel">
<h2>Load a configuration onto the device</h2>
<p class="sub">On the real unit you'd transfer this over USB / WiFi. Here, paste a <b>patch</b>
(e.g. <code>v1;t120;kick:4;snare:4=.X.X;hatClosed:4/2</code>), a <b>setlist code</b>, or a full
<code>#p=…</code>/<code>#sl=…</code> share link — it's validated before loading.</p>
<label for="cfg">Patch / setlist code / share link</label>
<textarea id="cfg" spellcheck="false" placeholder="v1;t120;kick:4;snare:4=.X.X;hatClosed:4/2/&#10;…or a #sl=… link / base64 set-list code"></textarea>
<div class="row">
<button class="ld" id="bLoad">Load onto device</button>
<span class="hint">or pick a built-in or saved set list:</span>
<select id="storedSel"><option value="">— choose a set list —</option></select>
</div>
<div class="status" id="status"></div>
</div>
<!-- stage-mode overlays (only visible in full-screen "stage" mode) -->
<button id="fsThemeBtn" class="fs-ctrl" title="Theme (system / light / dark)" aria-label="Toggle theme"></button>
<button id="fsExit" class="fs-ctrl" title="Exit full screen (Esc)" aria-label="Exit full screen"></button>
<div id="rotateHint" class="rotate-hint">
<span class="rh-icon"></span>
<span>Rotate your device to landscape</span>
</div>
<script>
const APP_VERSION = "v0.0.1-dev";
const $ = (id) => document.getElementById(id);
/* ========================= ENGINE (mirrors index.html; synth voices only) ===== */
const SAMPLES = {}; // synth-only device — no acoustic samples; the engine uses its synth voices
/*@BUILD:include:src/engine.js@*/
/*@BUILD:include:src/setlists.js@*/
const state={ bpm:120, volume:0.85, running:false };
let meters=[];
let ramp={on:false,startBpm:80,amount:5,everyBars:4}, trainer={on:false,playBars:2,muteBars:2};
let segBars=0, segBarCount=0, pendingAdvance=false;
let masterBeat=0, masterBeatTime=0, muteWindows=[];
function setBpm(v){ state.bpm=Math.max(30,Math.min(300,Math.round(v))); }
function advanceMaster(ahead){
const mbpb=masterBeatsPerBar();
while(masterBeatTime<ahead){
if(masterBeat%mbpb===0){
const barIndex=Math.floor(masterBeat/mbpb);
if(barIndex>0&&ramp.on&&(barIndex%ramp.everyBars===0)) setBpm(state.bpm+ramp.amount);
if(trainer.on){ const cyc=trainer.playBars+trainer.muteBars; if(cyc>0&&(barIndex%cyc)>=trainer.playBars) muteWindows.push({start:masterBeatTime,end:masterBeatTime+mbpb*(60/state.bpm)}); }
segBarCount=barIndex;
if(segBars>0&&barIndex>=segBars&&!pendingAdvance){ pendingAdvance=true; } // bar-count → auto-advance (player always continues)
}
masterBeat++; masterBeatTime+=60/state.bpm;
}
if(audioCtx) muteWindows=muteWindows.filter(w=>w.end>audioCtx.currentTime-1);
}
function scheduler(){
const ahead=audioCtx.currentTime+SCHEDULE_AHEAD;
advanceMaster(ahead);
for(const m of meters){ while(m.nextTime<ahead){ scheduleMeterTick(m,m.nextTime); m.nextTime+=laneStepDur(m,m.tick); m.tick++; } }
if(pendingAdvance){ pendingAdvance=false; setTimeout(()=>gotoItem(idx+1,true),0); } // loops at end (gotoItem wraps)
}
/* ========================= PLAYER ============================================= */
let setlist=null, idx=0;
// Built-in set lists = the editor's seed lists (shared via src/setlists.js).
const BUILTIN = SEED_SETLISTS.map((sl) => ({ title: sl.title, items: sl.items.map(([n, p]) => ({ name: n, ...patchToSetup(p) })) }));
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 loadSetup(s){
ramp=s.ramp?{...s.ramp}:{on:false,startBpm:80,amount:5,everyBars:4};
trainer=s.trainer?{...s.trainer}:{on:false,playBars:2,muteBars:2};
segBars=s.bars||0; segBarCount=0;
setBpm(s.bpm||120);
meters=buildMeters(s.lanes);
rebuildLeds();
}
function startAudio(){
ensureAudio(); audioCtx.resume(); state.running=true;
if(ramp.on) setBpm(ramp.startBpm);
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; m.currentBar=0; }
masterBeat=0; masterBeatTime=t0; muteWindows=[]; pendingAdvance=false;
schedulerTimer=setInterval(scheduler,LOOKAHEAD_MS); scheduler(); renderAll();
}
function stopAudio(){ state.running=false; clearInterval(schedulerTimer); schedulerTimer=null; pendingAdvance=false; for(const m of meters) m.currentStep=-1; renderAll(); }
function toggle(){ state.running?stopAudio():startAudio(); }
function gotoItem(i,keepPlaying){
if(!setlist||!setlist.items.length) return;
const n=setlist.items.length; idx=((i%n)+n)%n;
const wasRunning=state.running||keepPlaying;
if(state.running){ clearInterval(schedulerTimer); schedulerTimer=null; state.running=false; }
loadSetup(setlist.items[idx]);
if(wasRunning) startAudio(); else renderAll();
}
function loadSetlistObj(sl){ setlist=sl; idx=0; const wasRunning=state.running; if(wasRunning){clearInterval(schedulerTimer);schedulerTimer=null;state.running=false;} loadSetup(sl.items[0]); if(wasRunning) startAudio(); else renderAll(); }
let taps=[];
function tapTempo(){ const now=performance.now(); taps=taps.filter(t=>now-t<2000); taps.push(now);
if(taps.length>=2){ let s=0; for(let i=1;i<taps.length;i++) s+=taps[i]-taps[i-1]; setBpm(60000/(s/(taps.length-1))); ramp.on=false; renderAll(); } }
function nudge(d){ ramp.on=false; setBpm(state.bpm+d); renderAll(); }
/* ========================= RENDER ============================================ */
function rebuildLeds(){
const box=$("leds"); box.innerHTML="";
const m=meters[0]; const beats=m?m.beatsPerBar:0;
for(let i=0;i<beats;i++){ const d=document.createElement("div"); d.className="led"+(m.groupStarts.has(i)?" group":""); box.appendChild(d); }
}
function renderLeds(){
const m=meters[0]; if(!m) return;
const cur=state.running? Math.floor(m.currentStep/m.stepsPerBeat) : -1;
const els=$("leds").children;
for(let i=0;i<els.length;i++) els[i].classList.toggle("on", i===cur);
}
function fmtPos(){ return setlist? (idx+1)+"/"+setlist.items.length : "/"; }
function renderScreen(){
$("sPos").textContent="♪ "+fmtPos();
$("sBpm").textContent=state.bpm;
$("sName").textContent=setlist? (setlist.items[idx].name||"—") : "—";
const st=$("sState");
st.textContent=state.running?"▶ PLAY":"⏸ STOP"; st.classList.toggle("stopped",!state.running);
const m=meters[0];
if(state.running&&m){ const beat=Math.floor(m.currentStep/m.stepsPerBeat); $("sBar").textContent="bar "+(m.currentBar+1)+" · beat "+(beat>=0?beat+1:"—"); }
else $("sBar").textContent="bar — · beat —";
if(segBars>0){ const rem=Math.max(0,segBars-(m?m.currentBar:0)); $("sBars").textContent=(state.running?rem:segBars)+" bars"; }
else $("sBars").textContent="";
// knob angle ~ tempo (30..300 → -135..135deg)
const ang=-135+(Math.max(30,Math.min(300,state.bpm))-30)/270*270; $("knob").style.setProperty("--a",ang+"deg");
}
function renderAll(){ renderScreen(); renderLeds(); $("bPlay").textContent=state.running?"■":"▶"; $("bPlay").classList.toggle("on",state.running); }
function draw(){
// latency-compensated clock so the visual playhead lands when the click is HEARD
// (not when it's queued) — see the sync note; avoids the visual leading the audio.
if(audioCtx&&state.running){ const now=audioCtx.currentTime-(audioCtx.outputLatency||audioCtx.baseLatency||0);
for(const m of meters){ while(m.vqPtr<m.vq.length&&m.vq[m.vqPtr].time<=now){ m.currentStep=m.vq[m.vqPtr].step; m.currentBar=m.vq[m.vqPtr].bar; m.vqPtr++; } } }
renderScreen(); renderLeds();
requestAnimationFrame(draw);
}
/* ========================= LOAD / VALIDATE =================================== */
function setStatus(msg,ok){ const s=$("status"); s.textContent=msg; s.className="status "+(ok?"ok":"err"); }
function loadConfig(text,quiet){
text=(text||"").trim(); if(!text){ if(!quiet) setStatus("Paste a patch or set-list code first.",false); return false; }
let payload=text, kind=null;
const m=text.match(/[#?&](p|sl)=([^&\s]+)/);
if(m){ kind=m[1]; payload=m[2]; }
try{ payload=decodeURIComponent(payload); }catch(e){}
try{
if(kind==="sl" || (kind!=="p" && !/[;:]/.test(payload))){
const sl=codeToSetlist(payload);
if(!sl.items.length) throw new Error("set list has no items");
loadSetlistObj(sl);
setStatus("✓ Loaded set list “"+sl.title+"” — "+sl.items.length+" item(s).",true); return true;
}
const setup=patchToSetup(payload);
if(!setup.lanes.length) throw new Error("no valid lanes (need e.g. kick:4)");
loadSetlistObj({title:"Pasted patch",items:[{name:"Patch",...setup}]});
setStatus("✓ Loaded patch — "+setup.lanes.length+" lane(s), "+setup.bpm+" BPM"+(setup.bars?", "+setup.bars+" bars":"")+(setup.ramp.on?", ramp":"")+".",true); return true;
}catch(e){ if(!quiet) setStatus("✗ Invalid configuration: "+e.message,false); return false; }
}
function loadStored(){
let lists=[]; try{ lists=JSON.parse(localStorage.getItem("metronome.setlists")||"[]"); }catch(e){}
const sel=$("storedSel"); sel.innerHTML='<option value="">— choose a set list —</option>';
const og1=document.createElement("optgroup"); og1.label="Built-in";
BUILTIN.forEach((sl,i)=>{ const o=document.createElement("option"); o.value="b"+i; o.textContent=sl.title+" ("+sl.items.length+")"; og1.appendChild(o); });
sel.appendChild(og1);
if(lists.length){ const og2=document.createElement("optgroup"); og2.label="Your saved set lists";
lists.forEach((sl,i)=>{ const o=document.createElement("option"); o.value="s"+i; o.textContent=(sl.title||("Set list "+(i+1)))+" ("+(sl.items?sl.items.length:0)+")"; og2.appendChild(o); });
sel.appendChild(og2); }
sel._lists=lists; sel._builtin=BUILTIN;
}
/* ========================= WIRING ============================================ */
$("bPlay").onclick=toggle;
$("bPrev").onclick=()=>gotoItem(idx-1,state.running);
$("bNext").onclick=()=>gotoItem(idx+1,state.running);
$("bUp").onclick=()=>nudge(+1); $("bDown").onclick=()=>nudge(-1);
$("bTap").onclick=tapTempo;
$("bLoad").onclick=()=>loadConfig($("cfg").value);
$("storedSel").onchange=(e)=>{ const v=e.target.value; if(!v) return;
const sl = v[0]==="b" ? e.target._builtin[+v.slice(1)] : e.target._lists[+v.slice(1)]; if(!sl) return;
loadSetlistObj({title:sl.title,items:(sl.items||[]).map(it=>({...it}))}); setStatus("✓ Loaded set list “"+(sl.title||"set list")+"”.",true); };
/* theme toggle — cycles system → light → dark; shares the editor's "metronome.theme" key */
const THEMES = ["system","light","dark"];
function effectiveTheme(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 = effectiveTheme(p);
const glyph = p==="system" ? "◐" : p==="light" ? "☀" : "☾";
const title = "Theme: "+p+" (click to cycle: system → light → dark)";
for(const id of ["themeBtn","fsThemeBtn"]){ const b=$(id); if(b){ b.textContent=glyph; b.title=title; } }
}
const cycleTheme = ()=> applyTheme(THEMES[(THEMES.indexOf(themePref())+1)%THEMES.length]);
$("themeBtn").onclick = cycleTheme;
$("fsThemeBtn").onclick = cycleTheme;
matchMedia("(prefers-color-scheme: light)").addEventListener("change", ()=>{ if(themePref()==="system") applyTheme("system"); });
applyTheme(themePref());
/* full-screen "stage" mode — real fullscreen + landscape lock where supported (Android/desktop),
CSS pseudo-fullscreen + a rotate hint where not (iPhone). body.stage drives the layout. */
const docEl = document.documentElement;
const reqFS = docEl.requestFullscreen || docEl.webkitRequestFullscreen;
const exitFS = document.exitFullscreen || document.webkitExitFullscreen;
const fsEl = () => document.fullscreenElement || document.webkitFullscreenElement;
let wakeLock = null;
async function requestWake(){ try{ if(navigator.wakeLock) wakeLock = await navigator.wakeLock.request("screen"); }catch(e){} }
function releaseWake(){ try{ wakeLock && wakeLock.release(); }catch(e){} wakeLock = null; }
function isStage(){ return document.body.classList.contains("stage"); }
function syncFsBtn(){ $("fsBtn").title = isStage() ? "Exit full screen" : "Full screen (landscape)"; }
async function enterStage(){
document.body.classList.add("stage");
if(reqFS){
try{ await reqFS.call(docEl); }catch(e){}
try{ await screen.orientation.lock("landscape"); }catch(e){} // Android only; rejects on desktop/iOS — harmless
}
requestWake(); syncFsBtn();
}
function exitStage(){
try{ if(screen.orientation && screen.orientation.unlock) screen.orientation.unlock(); }catch(e){}
if(fsEl() && exitFS){ try{ exitFS.call(document); }catch(e){} }
document.body.classList.remove("stage");
releaseWake(); syncFsBtn();
}
function toggleStage(){ isStage() ? exitStage() : enterStage(); }
$("fsBtn").onclick = toggleStage;
$("fsExit").onclick = exitStage;
function onFsChange(){ if(reqFS && !fsEl() && isStage()){ document.body.classList.remove("stage"); releaseWake(); syncFsBtn(); } }
document.addEventListener("fullscreenchange", onFsChange);
document.addEventListener("webkitfullscreenchange", onFsChange);
document.addEventListener("visibilitychange", ()=>{ if(document.visibilityState==="visible" && isStage()) requestWake(); });
addEventListener("keydown",(e)=>{
const t=e.target, tag=t?t.tagName:""; if(tag==="TEXTAREA"||tag==="INPUT"||tag==="SELECT") return;
const k=e.key;
if(k===" "||e.code==="Space"){ e.preventDefault(); toggle(); }
else if(k==="ArrowRight"){ e.preventDefault(); gotoItem(idx+1,state.running); }
else if(k==="ArrowLeft"){ e.preventDefault(); gotoItem(idx-1,state.running); }
else if(k==="ArrowUp"){ e.preventDefault(); nudge(e.shiftKey?10:1); }
else if(k==="ArrowDown"){ e.preventDefault(); nudge(e.shiftKey?-10:-1); }
else if(k==="t"||k==="T") tapTempo();
else if(k==="f"||k==="F"){ e.preventDefault(); toggleStage(); }
});
/* ========================= INIT ============================================== */
loadStored();
if(location.hash && /(p|sl)=/.test(location.hash)) loadConfig(location.hash, true);
if(!setlist){ setlist=BUILTIN[0]; idx=0; loadSetup(setlist.items[0]); }
renderAll();
requestAnimationFrame(draw);
</script>
</body>
</html>