metronome/teacher.html
Me Here 9e5c79b3b2 Split each form factor into a lean widget page + a separate info page
Previously every device .html bundled its full narrative (purpose, BOM,
dimensioned drawings, embedding docs) inside a #techinfo block that embed
mode (?embed=1) only CSS-hid — so every embedder and every landing-page
iframe downloaded all of it (showcase 77%, teacher 49% of the file) for
content no embedder ever sees.

Now:
  - <device>.html is the lean widget only: header + front view/controls +
    title + summary + program box. ?embed=1 still collapses to the bare
    widget; the heavy narrative is gone from the payload.
  - info-<device>.html (new, one per form factor) carries all the words —
    purpose, dimensions, priced BOM, embedding docs — and embeds the live
    widget at the top via the existing iframe + auto-resize protocol
    (new shared src/infoembed.html + src/infoembed.js).
  - Each device links out to its info page ("…dimensions & BOM →"); the
    landing panes and viewport bar now offer both Open ↗ and Specs & info ⓘ.
  - Dropped the now-dead "Show info" toggle (CSS + progbox.js).

Branding: adopt the official VARASYS "tagline on the bottom" logos from the
brand kit (light-background variant now matches; dark already did). The
tagline is baked into the PNGs, so remove the CSS .brand-tag / .dev-tag
spans and the showcase canvas-drawn tagline. Brand cyan #0AB3F7 / navy
#1C283F already match the official palette.

build.sh / deploy.sh: build + deploy the six new info-*.html pages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 14:18:23 -05:00

515 lines
32 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>VARASYS PM_T1 — Teacher (studio / lesson console)</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>
<!--
PM-1 "Teacher" — the full-feature desktop console (formerly "Stage"): the same
firmware/engine, drawn with the parts you'd actually solder for an RP2040 build.
It's the hands-on unit for a studio desk or a lesson — a big screen showing every
lane, real buttons, and an instrument pass-through. (The foot-operated live unit
is the separate /stage.html stompbox.) —
• a 2.0″ 320×240 colour IPS TFT (ST7789, e.g. Pimoroni Pico Display 2.0):
the upgrade from the cramped 128×64 mono OLED — full colour, smooth type,
hi-DPI. It also draws all lane patterns (each lane's steps — accent /
normal / ghost / mute — with the playhead), so there's no separate LED bar;
• controls: a big PLAY up top (a plain arcade button — it does NOT change
while playing; the screen shows transport state; an illuminated/RGB arcade
button could reflect it), then a row of PREV (far left), a recessed
thumb-roller for tempo + TAP (centre), and NEXT (far right);
• top-edge I/O (all jacks on the top edge — cables run up off a pedalboard;
the top view shows the ~45 mm total thickness): external trigger in
(footswitch), a 1/4" instrument pass-through
with the click mixed in the ANALOG domain (DAC → summing op-amp → balanced
line driver), a shared 1/4" balanced-TRS main out, plus an analog monitor
amp + speaker. Powered over USB-C (a wall adapter or a power bank; also
carries config), in a bead-blasted matte-black anodised aluminium
enclosure (no glare on stage).
Beside the device: a top-edge view and a bill of materials. The front and top
views carry inch dimensions (≈ 4.7 × 5.5 × 1.8 in / 120 × 140 × 45 mm).
Compare with the initial /player.html. One file, no deps; shares src/engine.js.
-->
<script>
// Set theme before first paint (shared "metronome.theme" with the editor / player).
(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) — the page around the device */
--bg1:#12151c; --bg2:#05070a;
--txt:#c7d0db; --muted:#7f8b9a; --link:#6cb6ff;
--panel-bg:#171b22; --panel-bd:#2a313c; --field-bg:#0e1116; --field-bd:#222a36;
/* device — bead-blasted matte-black anodised aluminium (fixed in both themes);
--silk is the light laser-etched legend colour on the dark case */
--case:#26282d; --case2:#15161a; --device-bd:#33363c; --silk:#aab2bc;
--pcb:#0d2620; --oled-bezel:#04060a; --metal:#3a424e;
}
:root[data-theme="light"]{
--bg1:#f5f8fc; --bg2:#dde4ec;
--txt:#1e2630; --muted:#5c6776; --link:#1769c4;
--panel-bg:#ffffff; --panel-bd:#d2dae4; --field-bg:#f1f4f8; --field-bd:#cdd6e0;
}
body{
margin:0; min-height:100vh; padding:22px 12px 40px;
background:radial-gradient(circle at 50% -8%, var(--bg1), var(--bg2));
color:var(--txt);
display:flex; flex-direction:column; align-items:center; gap:14px;
}
a{color:var(--link)}
.topbar{width:100%; max-width:778px; display:flex; align-items:center; justify-content:space-between; gap:10px; font-size:13px; color:var(--muted); flex-wrap:wrap}
.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: bead-blasted matte-black anodised aluminium ---- */
.device{
width:100%; max-width:380px; position:relative;
background:
radial-gradient(rgba(255,255,255,.022) .5px, transparent .6px) 0 0/3px 3px, /* bead-blast micro-texture */
linear-gradient(160deg, var(--case), var(--case2)); /* matte anodised graphite */
border:1px solid var(--device-bd); border-radius:13px; padding:16px 14px 14px;
box-shadow:0 22px 50px rgba(0,0,0,.6), inset 0 1px 0 rgba(255,255,255,.05), inset 0 -2px 8px rgba(0,0,0,.5);
}
.brandrow{ display:flex; align-items:flex-end; justify-content:space-between; margin:0 2px 12px; }
.silk{ display:flex; align-items:center; gap:8px; color:var(--silk); letter-spacing:.04em }
.dev-logo{ height:24px }
.silk .model{ font-size:10px; text-transform:uppercase; letter-spacing:.18em; opacity:.8 }
.pwr{ display:flex; align-items:center; gap:6px; font-size:9px; color:var(--silk); text-transform:uppercase; letter-spacing:.14em; opacity:.85 }
.pwr .dot{ width:7px; height:7px; border-radius:50%; background:#2fe07a; box-shadow:0 0 7px #2fe07a }
/* ---- 2.0″ 320×240 colour IPS TFT (the OLED upgrade) ---- */
.tft-wrap{ display:flex; justify-content:center; margin:0 4px }
.tft-mod{ background:#000; border-radius:10px; padding:9px;
box-shadow:inset 0 0 0 2px #15181d, inset 0 0 0 3px #000, 0 3px 9px rgba(0,0,0,.55) }
#tft{ display:block; width:320px; height:240px; max-width:100%; border-radius:4px; background:#06080c;
box-shadow:0 0 0 1px #000, inset 0 0 18px rgba(0,0,0,.45) }
.tft-cap{ text-align:center; font-size:10px; color:var(--silk); opacity:.8; margin-top:7px; letter-spacing:.02em }
/* small caption under the screen / I/O (the beat indicator lives on the TFT now) */
.ledbar-cap{ text-align:center; font-size:10px; color:var(--muted); margin:2px 0 0; letter-spacing:.02em }
/* device column (centred; the priced BOM now lives on the info page) */
.cols{ display:flex; flex-wrap:wrap; align-items:flex-start; justify-content:center; gap:18px; width:100% }
.col-left{ display:flex; flex-direction:column; align-items:center; gap:14px; width:380px; max-width:100% }
/* ---- top-edge view: all connectors on the top + total thickness ---- */
.topview{ width:380px; max-width:100%; display:flex; flex-direction:column; gap:5px }
.tv-cap{ text-align:center; font-size:10px; color:var(--muted); letter-spacing:.02em }
.tv-row{ display:flex; align-items:stretch; gap:10px }
.tv-edge{ flex:1; border-radius:8px; padding:11px 10px 9px;
background:
radial-gradient(rgba(255,255,255,.022) .5px, transparent .6px) 0 0/3px 3px,
linear-gradient(160deg, var(--case), var(--case2));
border:1px solid var(--device-bd); box-shadow:inset 0 1px 0 rgba(255,255,255,.05), 0 12px 24px rgba(0,0,0,.5);
display:flex; align-items:flex-start; justify-content:space-between; gap:5px }
.tv-jack{ flex:1; display:flex; flex-direction:column; align-items:center; gap:5px }
.tv-jack i{ width:19px; height:19px; border-radius:50%; background:radial-gradient(circle at 40% 34%, #2b313a, #05070a 72%);
border:2px solid #4a525c; box-shadow:inset 0 0 4px #000 }
.tv-jack.dc i{ background:radial-gradient(circle, #6b7480 0 2.5px, #07090c 3.5px 72%) }
.tv-jack.usb i{ width:23px; height:9px; border-radius:4px; border:2px solid #4a525c; background:#05070a; margin-top:5px }
.tv-jack b{ font-size:7px; font-weight:700; color:var(--silk); letter-spacing:.04em; text-transform:uppercase; text-align:center; line-height:1.25; opacity:.9 }
.tv-dim{ display:flex; flex-direction:column; align-items:center; justify-content:center; font-size:9px; color:var(--muted); letter-spacing:.03em; line-height:1.05; white-space:nowrap }
.tv-dim .ar{ font-size:40px; line-height:.55; opacity:.5 }
/* ---- dimensioned views: top edge + front share a left gutter, so they're the
same width and aligned; inch dims (thickness/height on the left, width below) ---- */
.dim-row{ display:flex; align-items:stretch; gap:6px; width:100% }
.dim-y{ flex:0 0 13px; writing-mode:vertical-rl; transform:rotate(180deg);
display:flex; align-items:center; justify-content:center; text-align:center;
font-size:8.5px; color:var(--muted); letter-spacing:.04em; white-space:nowrap; border-right:1px solid var(--panel-bd) }
.dim-row > .device, .dim-row > .tv-edge{ flex:1 1 0; min-width:0; max-width:none; width:auto }
.dim-x{ text-align:center; font-size:8.5px; color:var(--muted); letter-spacing:.04em;
border-top:1px solid var(--panel-bd); padding-top:3px; margin:3px 0 0 19px }
/* ---- controls: encoder above (under the screen), arcade buttons spread below ----
wheel never hides the readout · PREV far-left / NEXT far-right · big central PLAY */
.controls{ display:flex; flex-direction:column; align-items:center; gap:13px; margin:14px 0 2px }
.enc-wrap{ display:flex; flex-direction:column; align-items:center; gap:5px }
/* recessed thumb-roller (side-mount encoder wheel) — only the edge is exposed */
.roller{ width:28px; height:50px; border-radius:7px; cursor:ns-resize; position:relative; touch-action:none; overflow:hidden;
background:#0a0d12; border:1px solid #04060a; box-shadow:inset 0 0 0 1px rgba(255,255,255,.03), 0 2px 4px rgba(0,0,0,.5) }
.roller::before{ content:""; position:absolute; inset:2px 4px; border-radius:4px; /* ribbed wheel surface, scrolls via --rib */
background:repeating-linear-gradient(0deg, rgba(255,255,255,.11) 0 1px, rgba(0,0,0,.5) 1px 4px);
background-position:0 var(--rib,0px) }
.roller::after{ content:""; position:absolute; inset:0; border-radius:7px; pointer-events:none; /* cylinder sheen: bright centre, curving dark at top/bottom */
background:linear-gradient(180deg, rgba(0,0,0,.72) 0%, rgba(255,255,255,.12) 48%, rgba(255,255,255,.12) 52%, rgba(0,0,0,.72) 100%) }
.enc-wrap small{ font-size:8px; color:var(--silk); letter-spacing:.12em; opacity:.85 }
.keys{ display:flex; align-items:flex-end; justify-content:space-between; width:100%; padding:0 2px }
.key-mid{ display:flex; align-items:flex-end; gap:20px }
.key{ display:flex; flex-direction:column; align-items:center; gap:6px }
.abtn{ width:50px; height:50px; border-radius:50%; border:0; padding:0; cursor:pointer; position:relative; color:#fff; font-size:18px; line-height:1;
background:radial-gradient(circle at 36% 30%, rgba(255,255,255,.6), rgba(255,255,255,0) 42%), radial-gradient(circle at 50% 64%, var(--c1,#33d0ff), var(--c2,#0a7fb0));
box-shadow:0 0 0 3px #0b0d11, 0 0 0 4px #363c46, 0 5px 7px rgba(0,0,0,.5), inset 0 -3px 6px rgba(0,0,0,.35), inset 0 2px 4px rgba(255,255,255,.28);
text-shadow:0 1px 2px rgba(0,0,0,.45); user-select:none; transition:transform .05s, box-shadow .05s, filter .05s }
.abtn:active{ transform:translateY(2px); filter:brightness(.92);
box-shadow:0 0 0 3px #0b0d11, 0 0 0 4px #363c46, 0 2px 3px rgba(0,0,0,.5), inset 0 -2px 4px rgba(0,0,0,.4), inset 0 2px 4px rgba(255,255,255,.2) }
.abtn.nav{ --c1:#33d0ff; --c2:#0a7fb0 }
.abtn.tap{ --c1:#ffd56a; --c2:#c98a1f; color:#3a2a00; text-shadow:0 1px 1px rgba(255,255,255,.35); font-size:12px; font-weight:800; letter-spacing:.04em }
.abtn.play{ --c1:#4ce08e; --c2:#178f49; width:66px; height:66px; font-size:26px } /* static: no play/stop change (plain arcade button; screen shows state) */
.key small{ font-size:8px; color:var(--silk); letter-spacing:.1em; text-transform:uppercase; opacity:.85 }
/* ---- monitor speaker + rear I/O (1/4" jacks + USB-C) ---- */
.grille{ height:11px; margin:13px 8px 9px; border-radius:5px;
background:radial-gradient(circle, #000 1.1px, transparent 1.4px) 0 0/8px 8px; opacity:.5 }
.io{ display:flex; align-items:flex-start; justify-content:space-between; gap:6px; margin:0 2px;
padding:9px 8px 8px; border-radius:9px; background:#0c0f14; border:1px solid #05070a; box-shadow:inset 0 1px 3px rgba(0,0,0,.6) }
.jack{ flex:1; display:flex; flex-direction:column; align-items:center; gap:5px }
.jack i{ width:20px; height:20px; border-radius:50%; background:radial-gradient(circle at 40% 34%, #333a44, #07090c 72%);
border:2px solid #5b6470; box-shadow:inset 0 0 4px #000 }
.jack b{ font-size:7.5px; font-weight:700; color:#8f9aa6; letter-spacing:.05em; text-transform:uppercase; opacity:.9; text-align:center; line-height:1.3 }
.jack.usb i{ width:24px; height:10px; border-radius:4px; border:2px solid #5b6470; background:#07090c; margin-top:5px }
.jack.dc i{ background:radial-gradient(circle, #6b7480 0 2.5px, #0a0d11 3.5px 72%) } /* DC barrel: centre pin */
/* ---- load panel (same as the other pages) ---- */
.panel{ width:100%; max-width:380px; 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 }
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:54px }
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:#d4dbe4; 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 }
/* link out to the spec & BOM info page (the priced BOM lives there now) */
.speclink{ width:100%; max-width:380px; margin:12px 0 0; font-size:12px; color:var(--muted); line-height:1.5 }
.speclink a{ color:var(--link); font-weight:600 }
/* embed mode: just the device (drop the top-edge view, dims, loader, spec link) */
[data-embed] .topview, [data-embed] .panel, [data-embed] .speclink,
[data-embed] .dim-y, [data-embed] .dim-x { display:none !important; }
</style>
</head>
<body>
/*@BUILD:include:src/header.html@*/
<h1 class="ff-title">PM_T1 Teacher</h1>
<p class="ff-sum">Fullfeature studio / lesson desk console — a colour TFT showing every lane, arcade buttons and a thumbroller, with your instrument running through and the click mixed in.</p>
<div class="cols">
<div class="col-left">
<!-- ===================== TOP EDGE (connectors) — technical view, hidden until "Show info" ===================== -->
<div class="topview tech" hidden>
<div class="tv-cap">Top edge — all connectors (cables exit upward; pedalboard-friendly)</div>
<div class="dim-row">
<div class="dim-y">↕ 1.8 in (45 mm)</div>
<div class="tv-edge">
<div class="tv-jack" title="External trigger in — footswitch to start/stop or tap tempo"><i></i><b>Trig In</b></div>
<div class="tv-jack" title="Instrument in — 1/4&quot; pass-through; the click is mixed into your signal"><i></i><b>Inst In</b></div>
<div class="tv-jack" title="Main out — 1/4&quot; balanced TRS (instrument + click); the shared output plug"><i></i><b>Out TRS</b></div>
<div class="tv-jack usb" title="USB-C — power (wall adapter or power bank) &amp; set-list transfer"><i></i><b>USB-C</b></div>
</div>
</div>
<div class="ledbar-cap">Trig in · 1/4″ inst passthrough (click injected) · shared 1/4″ balancedTRS out · USBC power</div>
</div>
<!-- ===================== THE DEVICE (front view) ===================== -->
<div class="dim-row">
<div class="dim-y tech" hidden>↕ 5.5 in (140 mm)</div>
<div class="device">
<div class="brandrow">
<div class="silk"><span class="dev-lock"><img class="dev-logo" src="data:image/png;base64,@BUILD:logo-dark@" alt="VARASYS — Simplifying Complexity" /></span><span class="model">PM_T1 Teacher</span></div>
<div class="pwr"><span class="dot"></span>PWR</div>
</div>
<div class="tft-wrap">
<div class="tft-mod">
<canvas id="tft" width="320" height="240" aria-label="2 inch 320 by 240 colour IPS display"></canvas>
</div>
</div>
<div class="tft-cap">2.0″ 320×240 IPS TFT (ST7789) — tempo, name &amp; all lane patterns</div>
<div class="controls">
<div class="key"><button class="abtn play" id="bPlay" title="play / stop (Space)"></button><small>Play / Stop</small></div>
<div class="keys">
<div class="key"><button class="abtn nav" id="bPrev" title="previous item"></button><small>Prev</small></div>
<div class="key-mid">
<div class="enc-wrap"><div class="roller" id="enc" title="Tempo — roll it (scroll or drag)"></div><small>TEMPO</small></div>
<div class="key"><button class="abtn tap" id="bTap" title="tap tempo (T)">TAP</button><small>Tap</small></div>
</div>
<div class="key"><button class="abtn nav" id="bNext" title="next item"></button><small>Next</small></div>
</div>
</div>
<div class="grille"></div>
</div><!-- /device -->
</div><!-- /dim-row -->
<div class="dim-x tech" hidden>↔ 4.7 in (120 mm) wide</div>
<!-- ===================== LOAD CONFIG ===================== -->
<div class="panel">
<h2>Load a configuration onto the device</h2>
<p class="sub">Same firmware as the initial unit — only the panel hardware differs. 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
<code>#p=…</code>/<code>#sl=…</code> link.</p>
<label for="cfg" class="hint">Patch / setlist code / share link</label>
<textarea id="cfg" spellcheck="false" placeholder="v1;t120;kick:4;snare:4=.X.X;hatClosed:4/2&#10;…or a #sl=… link / base64 set-list code"></textarea>
<div class="row">
<button class="ld" id="bLoad">Load onto device</button>
<span class="hint">or pick a built-in or saved set list:</span>
<select id="storedSel"><option value="">— choose a set list —</option></select>
</div>
<div class="status" id="status"></div>
</div>
</div><!-- /col-left -->
</div><!-- /cols -->
<p class="ff-link pageonly"><a href="/info-teacher.html">Purpose, spec &amp; bill of materials →</a></p>
<script>
const APP_VERSION = "v0.0.1-dev";
const $ = (id) => document.getElementById(id);
/* ========================= ENGINE (shared; 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; }
}
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); }
}
/* ========================= PLAYER ============================================= */
let setlist=null, idx=0;
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);
drawTFT();
}
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(); } }
let rollPos=0;
function nudge(d){ ramp.on=false; setBpm(state.bpm+d); rollPos+=d*2.5; $("enc").style.setProperty("--rib",rollPos+"px"); renderAll(); }
/* ===================== RENDER: 320×240 colour IPS TFT (ST7789) =============== */
const TFT_W=320, TFT_H=240;
const tft=$("tft"), tc=tft.getContext("2d");
(function(){ const dpr=Math.max(1,Math.min(3,window.devicePixelRatio||1));
tft.width=TFT_W*dpr; tft.height=TFT_H*dpr; tc.scale(dpr,dpr); })(); // hi-DPI backing → crisp type
const SND_ABBR={kick:"KICK",snare:"SNR",rim:"RIM",clap:"CLAP",hatClosed:"HAT",hatOpen:"OHAT",ride:"RIDE",crash:"CRSH",
tomLow:"TOM↓",tomMid:"TOM",tomHigh:"TOM↑",tambourine:"TAMB",cowbell:"CBL",woodblock:"WOOD",claves:"CLAV",jamblock:"JAM",beep:"BEEP"};
function soundLabel(s){ if(SND_ABBR[s]) return SND_ABBR[s];
const m=String(s).match(/^([a-zA-Z]+)(\d+)?$/);
return m ? (m[1].slice(0,3).toUpperCase()+(m[2]||"")) : String(s).slice(0,5).toUpperCase(); }
function drawTFT(){
const ref=meters[0];
const g=tc.createLinearGradient(0,0,0,TFT_H); g.addColorStop(0,"#0b0f18"); g.addColorStop(1,"#05070c");
tc.fillStyle=g; tc.fillRect(0,0,TFT_W,TFT_H);
// header
tc.textBaseline="middle"; tc.textAlign="left";
tc.font='600 12px "Segoe UI",system-ui,sans-serif'; tc.fillStyle="#5b7a93";
tc.fillText("♪ "+(setlist?((idx+1)+"/"+setlist.items.length):"/"), 12, 13);
tc.textAlign="right"; tc.fillStyle = state.running ? "#2fe07a" : "#6b7787";
tc.fillText(state.running?"▶ PLAY":"■ STOP", TFT_W-12, 13);
tc.fillStyle="#13283c"; tc.fillRect(12,25,TFT_W-24,1);
// tempo (left) + name (right) — uses the wide upper area
tc.textBaseline="alphabetic"; tc.textAlign="left";
tc.fillStyle="#1fb6f0"; tc.font='800 50px "Segoe UI",system-ui,sans-serif';
const bpm=String(state.bpm); tc.fillText(bpm, 12, 74);
const bx=12+tc.measureText(bpm).width+8;
tc.fillStyle="#5b7a93"; tc.font='700 13px "Segoe UI",system-ui,sans-serif'; tc.fillText("BPM", bx, 48);
tc.font='600 13px "Segoe UI",system-ui,sans-serif'; tc.fillText("♩ "+(ref?(ref.groupsStr.replace(/[^0-9+]/g,"")||ref.beatsPerBar):""), bx, 67);
// item name, right-aligned, ellipsised to the space right of the tempo labels
tc.textAlign="right"; tc.fillStyle="#e9eff7"; tc.font='600 16px "Segoe UI",system-ui,sans-serif';
let nm=setlist?(setlist.items[idx].name||"—"):"—"; const nmMax=(TFT_W-12)-(bx+42);
if(tc.measureText(nm).width>nmMax){ while(nm.length>1 && tc.measureText(nm+"…").width>nmMax) nm=nm.slice(0,-1); nm+="…"; }
tc.fillText(nm, TFT_W-12, 58);
tc.fillStyle="#13283c"; tc.fillRect(12,84,TFT_W-24,1);
// ---- all meter lanes: each a row of step pads (subdivisions · accent/normal/ghost/mute · playhead) ----
const lanes=meters||[], gx0=48, gx1=TFT_W-12, gw=gx1-gx0, top=90, bot=204;
const rowH = lanes.length ? Math.min(20,(bot-top)/lanes.length) : 0;
tc.textBaseline="middle";
lanes.forEach((m,li)=>{
const cy=top+li*rowH+rowH/2, en=m.enabled;
tc.textAlign="left"; tc.font='700 8px "Segoe UI",system-ui,sans-serif'; tc.fillStyle=en?"#9fb4c4":"#4a5560";
tc.fillText(soundLabel(m.sound), 6, cy+0.5);
const nsteps=Math.max(1,m.beatsPerBar*m.stepsPerBeat), cw=gw/nsteps, ch=Math.min(rowH-4,13), cyTop=cy-ch/2;
for(let s=0;s<nsteps;s++){
const cx=gx0+s*cw, lvl=m.beatsOn[s]|0, beatStart=(s%m.stepsPerBeat)===0, cur=state.running&&m.currentStep===s;
const x=cx+0.6, w=Math.max(1,cw-1.4);
let fill = !en ? (lvl?"rgba(120,140,160,.18)":null)
: lvl===2?"#1fb6f0" : lvl===1?"rgba(31,182,240,.62)" : lvl===3?"rgba(31,182,240,.30)" : null;
if(fill){ tc.fillStyle=fill; tc.fillRect(x,cyTop,w,ch); }
else { tc.strokeStyle="rgba(120,140,160,.22)"; tc.lineWidth=1; tc.strokeRect(x+0.5,cyTop+0.5,w-1,ch-1); } // mute = outline
if(beatStart){ tc.fillStyle="rgba(255,209,102,.55)"; tc.fillRect(cx,cyTop-1,1,ch+2); } // beat tick
if(cur){ tc.strokeStyle="#fff"; tc.lineWidth=1.4; tc.strokeRect(x-0.6,cyTop-0.6,w+1.2,ch+1.2); } // playhead
}
});
// bottom strip
tc.fillStyle="#13283c"; tc.fillRect(12,208,TFT_W-24,1);
tc.textBaseline="alphabetic"; tc.textAlign="left"; tc.fillStyle="#7f8b9a"; tc.font='600 13px "Segoe UI",system-ui,sans-serif';
tc.fillText(state.running&&ref ? ("BAR "+(ref.currentBar+1)+" · BEAT "+(Math.floor(ref.currentStep/ref.stepsPerBeat)+1||"")) : "READY", 12, 226);
if(segBars>0){ const rem=Math.max(0,segBars-(ref?ref.currentBar:0));
tc.textAlign="right"; tc.fillStyle="#ffd166"; tc.font='700 14px "Segoe UI",system-ui,sans-serif';
tc.fillText((state.running?rem:segBars)+" BARS", TFT_W-12, 226); }
}
function renderAll(){ drawTFT(); /* PLAY button is static hardware — transport state is shown on the screen */
$("enc").style.setProperty("--rib", rollPos+"px"); }
function draw(){
// latency-compensated clock so the visual playhead lands when the click is HEARD
// (not when it's queued) — avoids the visual leading the audio by the output buffer.
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++; } } }
drawTFT();
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);
$("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); };
/* EC11 rotary encoder — turn it (mouse wheel or vertical drag) for tempo */
(function(){ const k=$("enc"); let drag=false, lastY=0, acc=0;
k.addEventListener("wheel",(e)=>{ e.preventDefault(); nudge(e.deltaY<0 ? (e.shiftKey?5:1) : (e.shiftKey?-5:-1)); }, {passive:false});
k.addEventListener("pointerdown",(e)=>{ drag=true; lastY=e.clientY; acc=0; k.setPointerCapture(e.pointerId); });
k.addEventListener("pointermove",(e)=>{ if(!drag) return; acc+=lastY-e.clientY; lastY=e.clientY; while(Math.abs(acc)>=5){ nudge(acc>0?1:-1); acc+=acc>0?-5:5; } });
k.addEventListener("pointerup",()=>{ drag=false; }); k.addEventListener("pointercancel",()=>{ drag=false; });
})();
/* theme toggle — cycles system → light → dark; shares the editor's "metronome.theme" key */
/*@BUILD:include:src/chrome.js@*/
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=BUILTIN[0]; idx=0; loadSetup(setlist.items[0]); }
renderAll();
requestAnimationFrame(draw);
/*@BUILD:include:src/progbox.js@*/
</script>
/*@BUILD:include:src/footer.html@*/
</body>
</html>