Add hardware-device player mockup at /player.html

A self-contained simulator of the RP2040 "PM-1" unit: it plays the share
language (synth voices, same scheduler) and drives an OLED + beat-LED
display like the firmware would. Loads from a #p=/#sl= link, the editor's
saved set lists (localStorage), or a pasted patch / set-list code — with
validation. Transport: play/stop, prev/next item, tempo ±, tap; bar-count
segments auto-advance. deploy.sh now version-stamps and publishes it too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-05-25 15:04:17 -05:00
parent 8633d535f2
commit ec23da5164
2 changed files with 472 additions and 0 deletions

View file

@ -36,6 +36,8 @@ fi
# stamp the version into the deployed copy only (source stays clean)
sed "s|const APP_VERSION = \"[^\"]*\";|const APP_VERSION = \"$BUILD\";|" "$SRC_DIR/index.html" > "$DEST_DIR/index.html"
echo "deployed v$BUILD ($(stat -c '%s' "$DEST_DIR/index.html") bytes) -> $DEST_DIR"
sed "s|const APP_VERSION = \"[^\"]*\";|const APP_VERSION = \"$BUILD\";|" "$SRC_DIR/player.html" > "$DEST_DIR/player.html"
echo "deployed player.html ($(stat -c '%s' "$DEST_DIR/player.html") bytes)"
# If real audio samples are added later (see the plan's GM-sample note),
# sync that directory too.

470
player.html Normal file
View file

@ -0,0 +1,470 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VARASYS PM1 — hardware player (mockup)</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMiAzMiI+PHJlY3Qgd2lkdGg9IjMyIiBoZWlnaHQ9IjMyIiByeD0iNyIgZmlsbD0iIzFDMjgzRiIvPjxwYXRoIGQ9Ik0xMiA2aDhsNCAyMUg4eiIgZmlsbD0iIzBBQjNGNyIvPjxwYXRoIGQ9Ik0xNiAyNEwxOS42IDkiIHN0cm9rZT0iIzFDMjgzRiIgc3Ryb2tlLXdpZHRoPSIxLjgiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgZmlsbD0ibm9uZSIvPjxyZWN0IHg9IjE2LjQiIHk9IjEzLjIiIHdpZHRoPSI0LjQiIGhlaWdodD0iMi44IiByeD0iMC42IiB0cmFuc2Zvcm09InJvdGF0ZSgtMTMgMTguNiAxNC42KSIgZmlsbD0iIzFDMjgzRiIvPjwvc3ZnPgo=">
<!--
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.
-->
<style>
:root{
--case:#1b1f27; --case2:#11141a; --edge:#0b0d11; --bezel:#0a0c10;
--txt:#c7d0db; --muted:#7f8b9a; --cyan:#0AB3F7; --amber:#ffd166;
--screen:#04140f; --phos:#34e0a0; --phos-dim:#1f6e54; --led-off:#1b2a23;
}
*{box-sizing:border-box}
body{
margin:0; min-height:100vh; padding:28px 16px 48px;
background:radial-gradient(circle at 50% -8%, #20242c, #0c0e12);
color:var(--txt); font-family:"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
-webkit-font-smoothing:antialiased; display:flex; flex-direction:column; align-items:center; gap:20px;
}
a{color:#6cb6ff}
.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)}
/* ---- the device ---- */
.device{
width:100%; max-width:560px; position:relative;
background:linear-gradient(180deg,var(--case),var(--case2));
border:1px solid #2a313c; border-radius:22px; padding:22px 22px 26px;
box-shadow:0 26px 60px rgba(0,0,0,.55), inset 0 1px 0 rgba(255,255,255,.05), inset 0 -2px 8px rgba(0,0,0,.6);
}
.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(--muted); font-size:12px; letter-spacing:.04em }
.pwr{ display:flex; align-items:center; gap:7px; font-size:10px; color:var(--muted); 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(--txt); 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(--muted); 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(--muted); 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:#171b22; border:1px solid #2a313c; 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:#0e1116; color:var(--txt); border:1px solid var(--edge); border-radius:9px;
padding:10px; font-family:"Courier New",monospace; font-size:12px; resize:vertical; min-height:58px }
select, .ld{ background:#0e1116; color:var(--txt); border:1px solid #39424f; border-radius:9px; padding:8px 10px; font-size:13px }
.ld{ cursor:pointer; background:linear-gradient(180deg,#2b323d,#1b212a) }
.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:#0e1116; border:1px solid var(--edge); border-radius:4px; padding:1px 5px; font-size:11px }
</style>
</head>
<body>
<div class="topbar">
<span><b>VARASYS PM1</b> · hardware player (mockup)</span>
<a href="/index.html">Open editor ↗</a>
</div>
<!-- ===================== 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 set list you saved in the editor:</span>
<select id="storedSel"><option value="">— your saved set lists —</option></select>
</div>
<div class="status" id="status"></div>
</div>
<script>
const APP_VERSION = "v0.0.1-dev";
const $ = (id) => document.getElementById(id);
/* ========================= ENGINE (mirrors index.html; synth voices only) ===== */
let audioCtx=null, masterGain=null, noiseBuf=null, schedulerTimer=null;
const LOOKAHEAD_MS=25, SCHEDULE_AHEAD=0.12;
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 parseGroups(str){
const parts=String(str).split(/[^0-9]+/).map(s=>parseInt(s,10)).filter(n=>n>=1&&n<=12);
let total=0; const groups=[];
for(const p of parts){ if(total+p>12) break; groups.push(p); total+=p; }
if(!groups.length) groups.push(4);
const beatsPerBar=groups.reduce((a,b)=>a+b,0);
const groupStarts=new Set(); let acc=0;
for(const g of groups){ groupStarts.add(acc); acc+=g; }
return {groups,beatsPerBar,groupStarts};
}
function ensureAudio(){
if(audioCtx) return;
audioCtx=new (window.AudioContext||window.webkitAudioContext)();
masterGain=audioCtx.createGain(); masterGain.gain.value=state.volume; masterGain.connect(audioCtx.destination);
}
function getNoise(){
if(!noiseBuf){ const n=Math.floor(audioCtx.sampleRate*1.0); noiseBuf=audioCtx.createBuffer(1,n,audioCtx.sampleRate);
const d=noiseBuf.getChannelData(0); for(let i=0;i<n;i++) d[i]=Math.random()*2-1; }
return noiseBuf;
}
function ampEnv(time,peak,dur,attack){ const g=audioCtx.createGain(); peak=Math.max(0.0003,peak);
g.gain.setValueAtTime(0.0001,time); g.gain.exponentialRampToValueAtTime(peak,time+(attack||0.001));
g.gain.exponentialRampToValueAtTime(0.0001,time+dur); return g; }
function tone(time,type,f0,f1,dur){ const o=audioCtx.createOscillator(); o.type=type; o.frequency.setValueAtTime(f0,time);
if(f1&&f1!==f0) o.frequency.exponentialRampToValueAtTime(Math.max(1,f1),time+Math.min(dur,0.09)); o.start(time); o.stop(time+dur+0.02); return o; }
function noiseSrc(time,dur){ const s=audioCtx.createBufferSource(); s.buffer=getNoise(); s.start(time); s.stop(time+dur+0.02); return s; }
function filt(type,freq,q){ const f=audioCtx.createBiquadFilter(); f.type=type; f.frequency.value=freq; if(q) f.Q.value=q; return f; }
function v_tone(time,level,type,f0,f1,dur,peak){ const o=tone(time,type,f0,f1,dur),g=ampEnv(time,peak*level,dur,0.002); o.connect(g); g.connect(masterGain); }
function v_noise(time,level,fType,freq,q,dur,peak,attack){ const n=noiseSrc(time,dur),f=filt(fType,freq,q),g=ampEnv(time,peak*level,dur,attack); n.connect(f); f.connect(g); g.connect(masterGain); }
function metalHat(time,level,dur,hpFreq,peak){ const fund=40,ratios=[2,3,4.16,5.43,6.79,8.21];
const bp=filt("bandpass",10000,0.8),hp=filt("highpass",hpFreq,0),g=ampEnv(time,peak*level,dur,0.001);
ratios.forEach(r=>{const o=audioCtx.createOscillator();o.type="square";o.frequency.value=fund*r;o.start(time);o.stop(time+dur+0.02);o.connect(bp);});
bp.connect(hp); hp.connect(g); g.connect(masterGain); }
const DRUMS={
beep:(t,l)=>v_tone(t,l,"square",l>=1?1600:1100,0,0.04,0.5),
kick:(t,l)=>v_tone(t,l,"sine",150,50,0.18,1.0),
snare:(t,l)=>{v_tone(t,l,"triangle",190,140,0.12,0.45);v_noise(t,l,"highpass",1500,0,0.2,0.8);},
rim:(t,l)=>{const o=tone(t,"square",1700,0,0.04),bp=filt("bandpass",1700,4),g=ampEnv(t,0.6*l,0.04);o.connect(bp);bp.connect(g);g.connect(masterGain);},
clap:(t,l)=>{const bp=filt("bandpass",1200,1.4);bp.connect(masterGain);[0,0.012,0.024].forEach((d,i)=>{const n=noiseSrc(t+d,0.05),e=ampEnv(t+d,(i<2?0.5:0.85)*l,0.06);n.connect(e);e.connect(bp);});},
hatClosed:(t,l)=>v_noise(t,l,"highpass",7000,0,0.045,0.5),
hatOpen:(t,l)=>v_noise(t,l,"highpass",7000,0,0.32,0.45,0.002),
ride:(t,l)=>{v_noise(t,l,"bandpass",6000,0.8,0.4,0.32,0.002);v_tone(t,l,"square",5200,0,0.1,0.13);},
crash:(t,l)=>v_noise(t,l,"highpass",4000,0,0.8,0.5,0.002),
tomLow:(t,l)=>v_tone(t,l,"sine",150,100,0.25,0.9),
tomMid:(t,l)=>v_tone(t,l,"sine",220,150,0.23,0.9),
tomHigh:(t,l)=>v_tone(t,l,"sine",300,210,0.20,0.9),
tambourine:(t,l)=>v_noise(t,l,"highpass",8000,0,0.12,0.5),
cowbell:(t,l)=>{const sum=audioCtx.createGain(),bp=filt("bandpass",2640,1.2),g=ampEnv(t,0.8*l,0.3);[540,800].forEach(f=>tone(t,"square",f,0,0.3).connect(sum));sum.connect(bp);bp.connect(g);g.connect(masterGain);},
woodblock:(t,l)=>v_tone(t,l,"triangle",1800,1500,0.06,0.8),
claves:(t,l)=>v_tone(t,l,"sine",2500,0,0.045,0.85),
jamblock:(t,l)=>{const o=tone(t,"square",2600,2000,0.045),bp=filt("bandpass",2000,6),g=ampEnv(t,0.8*l,0.045);o.connect(bp);bp.connect(g);g.connect(masterGain);},
kick808:(t,l)=>{v_tone(t,l,"sine",120,45,0.7,1.0);v_noise(t,l*0.5,"highpass",2000,0,0.008,0.4,0.001);},
snare808:(t,l)=>{v_tone(t,l,"triangle",178,168,0.16,0.4);v_tone(t,l,"triangle",331,320,0.12,0.18);v_noise(t,l,"highpass",1000,0,0.16,0.7);},
clap808:(t,l)=>{const bp=filt("bandpass",1100,1.3);bp.connect(masterGain);[0,0.01,0.02,0.032].forEach((d,i)=>{const n=noiseSrc(t+d,0.05),e=ampEnv(t+d,(i<3?0.5:0.85)*l,0.05);n.connect(e);e.connect(bp);});},
hat808:(t,l)=>metalHat(t,l,0.045,7000,0.4),
openHat808:(t,l)=>metalHat(t,l,0.34,7000,0.38),
cowbell808:(t,l)=>{const sum=audioCtx.createGain(),bp=filt("bandpass",2640,1.2),g=ampEnv(t,0.8*l,0.3);[540,800].forEach(f=>tone(t,"square",f,0,0.3).connect(sum));sum.connect(bp);bp.connect(g);g.connect(masterGain);},
tom808:(t,l)=>v_tone(t,l,"sine",120,78,0.34,0.9),
kick909:(t,l)=>{v_tone(t,l,"sine",110,46,0.26,1.0);v_tone(t,l,"triangle",280,60,0.035,0.5);v_noise(t,l*0.6,"highpass",3000,0,0.01,0.5,0.001);},
snare909:(t,l)=>{v_tone(t,l,"triangle",190,162,0.09,0.28);v_noise(t,l,"highpass",1200,0,0.2,0.85);},
clap909:(t,l)=>{const bp=filt("bandpass",1000,1.0);bp.connect(masterGain);[0,0.009,0.018].forEach(d=>{const n=noiseSrc(t+d,0.05),e=ampEnv(t+d,0.6*l,0.05);n.connect(e);e.connect(bp);});const tail=noiseSrc(t+0.018,0.2),te=ampEnv(t+0.018,0.35*l,0.2,0.001);tail.connect(te);te.connect(bp);},
hat909:(t,l)=>metalHat(t,l,0.05,9000,0.4),
ride909:(t,l)=>{metalHat(t,l,0.5,6000,0.3);v_noise(t,l,"bandpass",7000,0.7,0.18,0.18,0.002);},
crash909:(t,l)=>{metalHat(t,l,0.9,5000,0.34);v_noise(t,l,"highpass",4000,0,0.9,0.4,0.002);},
};
function playInstrument(type,time,level){ (DRUMS[type]||DRUMS.beep)(time,level); }
function masterBeatsPerBar(){ return meters.length ? meters[0].beatsPerBar : 4; }
function setBpm(v){ state.bpm=Math.max(30,Math.min(300,Math.round(v))); }
function isMutedAt(t){ return muteWindows.some(w=>t>=w.start&&t<w.end); }
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 scheduleMeterTick(m,time){
const spb=m.stepsPerBeat, barLen=m.beatsPerBar*spb, tickInBar=((m.tick%barLen)+barLen)%barLen;
m.vq.push({time,step:tickInBar,bar:Math.floor(m.tick/barLen)});
if(!m.enabled||isMutedAt(time)) return;
const lvl=m.beatsOn[tickInBar]|0; if(!lvl) return;
playInstrument(m.sound,time,lvl===2?1.0:lvl===3?0.25:0.6);
}
function refBarDur(){ return (meters.length?meters[0].beatsPerBar:4)*(60/state.bpm); }
const SWING_RATIO=2/3;
function laneStepDur(m,tick){
if(m.poly) return refBarDur()/(m.beatsPerBar*m.stepsPerBeat);
const beat=60/state.bpm;
if(m.swing&&m.stepsPerBeat%2===0){ const pairDur=beat/(m.stepsPerBeat/2); return ((tick%m.stepsPerBeat)%2)===0?SWING_RATIO*pairDur:(1-SWING_RATIO)*pairDur; }
return beat/m.stepsPerBeat;
}
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)
}
/* ----- patch / set-list parsing (mirrors index.html) ----- */
function laneStrToCfg(tok){
let poly=false,disabled=false;
while(/[~!]$/.test(tok)){ if(tok.endsWith("!")) disabled=true; else poly=true; tok=tok.slice(0,-1); }
const ci=tok.indexOf(":"); if(ci<0) return null;
let sound=tok.slice(0,ci),rest=tok.slice(ci+1),pattern=null;
const eq=rest.indexOf("="); if(eq>=0){ pattern=rest.slice(eq+1); rest=rest.slice(0,eq); }
let groupsStr=rest,sub=1,swing=false; const sl=rest.indexOf("/");
if(sl>=0){ groupsStr=rest.slice(0,sl); const sp=rest.slice(sl+1); swing=/s$/i.test(sp); sub=parseInt(sp,10)||1; }
const bpb=parseGroups(groupsStr).beatsPerBar;
const beatsOn=pattern ? pattern.split("").map(ch=>ch==="X"?2:ch==="g"?3:(ch==="x"||ch==="1")?1:0)
: Array.from({length:bpb*sub},(_,i)=>((i%sub)===0?2:1));
if(!DRUMS[sound]) sound="beep";
return {groupsStr,stepsPerBeat:sub,sound,beatsOn,poly,swing,enabled:!disabled};
}
function patchToSetup(str){
const s={bpm:120,volume:null,countMs:0,bars:0,lanes:[],trainer:{on:false,playBars:2,muteBars:2},ramp:{on:false,startBpm:80,amount:5,everyBars:4}};
for(let tok of String(str).split(";")){
tok=tok.trim(); if(!tok||tok==="v1") continue;
if(tok.includes(":")){ const c=laneStrToCfg(tok); if(c) s.lanes.push(c); }
else if(tok.startsWith("vol")) s.volume=(parseInt(tok.slice(3),10)||0)/100;
else if(tok.startsWith("cd")) s.countMs=(parseInt(tok.slice(2),10)||0)*1000;
else if(tok.startsWith("b")) s.bars=parseInt(tok.slice(1),10)||0;
else if(tok.startsWith("tr")){ const [p,m]=tok.slice(2).split("/"); s.trainer={on:true,playBars:+p||1,muteBars:+m||0}; }
else if(tok.startsWith("rmp")){ const [a,b,c]=tok.slice(3).split("/"); s.ramp={on:true,startBpm:+a||80,amount:+b||0,everyBars:+c||1}; }
else if(tok.startsWith("t")) s.bpm=parseInt(tok.slice(1),10)||120;
}
return s;
}
function unb64u(s){ s=s.replace(/-/g,"+").replace(/_/g,"/"); return decodeURIComponent(escape(atob(s))); }
function codeToSetlist(code){
const o=JSON.parse(unb64u(code));
return { title:o.t||"Shared set list", description:o.d||"", items:(o.i||[]).map(x=>({name:x.n||"Item",...patchToSetup(x.p)})) };
}
/* ========================= PLAYER ============================================= */
let setlist=null, idx=0;
function mkSetlist(title,arr){ return {title, items:arr.map(([n,p])=>({name:n,...patchToSetup(p)}))}; }
const DEMO = mkSetlist("Demo song", [
["Intro", "t96;b8;kick:4=X.x.;hatClosed:4/2=gggggggg"],
["Backbeat", "t96;b12;kick:4=X.x.;snare:4=.X.X;hatClosed:4/2"],
["Build ↑", "t96;b12;rmp96/4/2;kick:4;snare:4=.X.X;hatClosed:4/2"],
["909 floor", "t124;b16;kick909:4;clap909:4=.X.X;hat909:4/2=.X.X.X.X"],
]);
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(){
if(audioCtx&&state.running){ const now=audioCtx.currentTime;
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="">— your saved set lists —</option>';
lists.forEach((sl,i)=>{ const o=document.createElement("option"); o.value=i; o.textContent=(sl.title||("Set list "+(i+1)))+" ("+(sl.items?sl.items.length:0)+")"; sel.appendChild(o); });
sel._lists=lists;
}
/* ========================= 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=e.target._lists[+v]; if(!sl) return;
loadSetlistObj({title:sl.title,items:(sl.items||[]).map(it=>({...it}))}); setStatus("✓ Loaded saved set list “"+(sl.title||"set list")+"”.",true); };
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();
});
/* ========================= INIT ============================================== */
loadStored();
if(location.hash && /(p|sl)=/.test(location.hash)) loadConfig(location.hash, true);
if(!setlist){ setlist=DEMO; idx=0; loadSetup(setlist.items[0]); }
renderAll();
requestAnimationFrame(draw);
</script>
</body>
</html>