metronome/player.html
Me Here ec23da5164 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>
2026-05-25 15:04:17 -05:00

470 lines
29 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" />
<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>