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>
470 lines
29 KiB
HTML
470 lines
29 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>VARASYS PM‑1 — 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 PM‑1</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">PM‑1 · 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> </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> </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>set‑list code</b>, or a full
|
||
<code>#p=…</code>/<code>#sl=…</code> share link — it's validated before loading.</p>
|
||
|
||
<label for="cfg">Patch / set‑list code / share link</label>
|
||
<textarea id="cfg" spellcheck="false" placeholder="v1;t120;kick:4;snare:4=.X.X;hatClosed:4/2/ …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>
|