- Remove the VCSL sample kit entirely (editor 351K → 141K). All voices are synthesized; the friendly GM names now alias to the punchier 808/909 renders (KIT_ALIAS). build.sh drops the @BUILD:samples inlining; assets/samples.json gone. - Conventions (backward-compatible): GM note-number aliases (36=kick…), '-'/'_' rest aliases in step patterns, Euclidean (k,n[,rot]) shorthand. - Per-lane gain in dB (@<db> in the grammar) applied as a velocity multiplier at schedule time — no stutter; threaded through every host's buildMeters + the editor's lanes (knob UI comes in Phase B). - 15/15 engine round-trip tests pass; pages console-clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
506 lines
30 KiB
HTML
506 lines
30 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||
<title>VARASYS PM‑1 — 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) }
|
||
}
|
||
/* fullscreen toggle (relocated out of the shared header) */
|
||
.fs-float{ background:transparent; color:var(--muted); border:1px solid var(--panel-bd); border-radius:8px;
|
||
padding:4px 11px; font-size:15px; line-height:1; cursor:pointer; }
|
||
.fs-float:hover{ color:var(--txt); }
|
||
body.stage .fs-float{ display:none; }
|
||
/* embed mode: just the device */
|
||
[data-embed] .panel, [data-embed] .fs-float { display:none !important; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
/*@BUILD:include:src/header.html@*/
|
||
|
||
<!-- fullscreen "stage mode" toggle — floats over the page (was a header button before the shared header) -->
|
||
<button id="fsBtn" class="fs-float" title="Full screen (landscape)">⛶</button>
|
||
|
||
<!-- ===================== 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 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,gainDb:c.gainDb||0,
|
||
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 */
|
||
/*@BUILD:include:src/chrome.js@*/
|
||
|
||
/* 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>
|
||
<section class="about pageonly">
|
||
<h2>PM‑1 — Initial</h2>
|
||
<div class="ff-tags"><span>Concept</span><span>Idealized device</span><span>Not buildable as drawn</span></div>
|
||
<p>The idealized PM‑1: the player as a clean, screen‑first device with no concession to mechanical parts yet.
|
||
It's the look we design <i>toward</i> — full set‑list navigation, a colour beat display showing every lane,
|
||
light/dark theming, and a fullscreen landscape "stage" view. It runs the same engine and program strings as
|
||
everything else in the family, but as an <i>idealized</i> object, before deciding which buttons, encoders,
|
||
jacks and enclosure actually make it real.</p>
|
||
<p>Because it's a concept, there's <b>no bill of materials</b> — there's nothing to source for a render. The
|
||
buildable realization of this idea is the <a href="/teacher.html">PM‑1 Teacher</a> (full priced BOM there);
|
||
for the smallest practical unit, see the <a href="/micro.html">PM‑µ Micro</a>.</p>
|
||
</section>
|
||
|
||
/*@BUILD:include:src/footer.html@*/
|
||
</body>
|
||
</html>
|