metronome/stage.html
Me Here 31e472c17a Stage + micro: matte-black bead-blasted anodised finish (no stage glare)
Replace the shiny brushed-aluminium look with a bead-blasted matte-black
anodised finish on both the stage and micro mockups:
- flat dark graphite case + a fine bead-blast micro-texture, and the specular
  top highlight removed, so the case doesn't reflect stage lights;
- light laser-etched legend colour (--silk) and the light VARASYS logo variant
  on the dark case; lighter metallic rims on the top-edge connector openings so
  they read on black;
- stage BOM enclosure + comments updated to "die-cast aluminium, bead-blasted,
  matte-black Type II anodise, laser-etched legends".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 11:10:20 -05:00

555 lines
36 KiB
HTML
Raw Permalink 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 PM1 — stage (pedalboard build)</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,@BUILD:favicon@">
<!--
"As-built" variant of the PM-1 player: the same firmware/engine, drawn with the
parts you'd actually solder for an RP2040 build —
• 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 from a standard 9 V DC pedal jack (2.1 mm centre-
negative, pedalboard-friendly) or USB-C (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 }
.brand-logo{ height:16px; width:auto; display:block }
.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 }
/* side-by-side: player on the left, BOM on the right (stacks when narrow) */
.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% }
.bom-panel{ width:380px; max-width:100%; align-self:flex-start }
/* ---- 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 }
/* ---- bill of materials ---- */
.bom{ width:100%; border-collapse:collapse; font-size:12px; margin-top:8px }
.bom th, .bom td{ text-align:left; padding:5px 6px; border-bottom:1px solid var(--panel-bd); vertical-align:top }
.bom th{ color:var(--muted); font-weight:600; font-size:10px; text-transform:uppercase; letter-spacing:.05em }
.bom th.q, .bom th.c, .bom td.q, .bom td.c{ text-align:right; white-space:nowrap }
.bom td.q, .bom td.c{ color:var(--muted) }
.bom .grp td{ color:var(--cyan); font-weight:700; font-size:10px; text-transform:uppercase; letter-spacing:.07em; padding-top:11px }
.bom .part{ color:var(--txt) }
.bom .part .spec{ color:var(--muted); font-weight:400 }
.bom tr.total td{ font-weight:700; color:var(--txt); border-top:2px solid var(--panel-bd); border-bottom:none; padding-top:8px }
</style>
</head>
<body>
<div class="topbar">
<span><b>VARASYS PM1</b> · stage (pedalboard build)</span>
<span class="topbar-right">
<button id="themeBtn" class="tbtn" title="toggle light / dark theme"></button>
<a href="/micro.html">Micro ↗</a>
<a href="/player.html">Initial ↗</a>
<a href="/index.html">Editor ↗</a>
</span>
</div>
<div class="cols">
<div class="col-left">
<!-- ===================== TOP EDGE (connectors) ===================== -->
<div class="topview">
<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 dc" title="9 V DC in — standard 2.1 mm centre-negative pedal power"><i></i><b>9V DC</b></div>
<div class="tv-jack usb" title="USB-C — power &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 · 9 V DC / USBC power</div>
</div>
<!-- ===================== THE DEVICE (front view) ===================== -->
<div class="dim-row">
<div class="dim-y">↕ 5.5 in (140 mm)</div>
<div class="device">
<div class="brandrow">
<div class="silk"><img class="brand-logo" src="data:image/png;base64,@BUILD:logo-dark@" alt="VARASYS" /><span class="model">PM1 Polymeter Player</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">↔ 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 -->
<!-- ===================== BILL OF MATERIALS ===================== -->
<div class="panel bom-panel">
<h2>Bill of materials</h2>
<p class="sub">Rough parts list for the device above — a pedalboardfriendly RP2040 build (9 V DC or USBC) with analog click injection.
Ballpark one-off prices (USD); cheaper at volume.</p>
<table class="bom">
<thead><tr><th>Part</th><th class="q">Qty</th><th class="c">~$</th></tr></thead>
<tbody>
<tr class="grp"><td colspan="3">Brain &amp; display</td></tr>
<tr><td class="part">RP2040 board, USBC <span class="spec">— e.g. Waveshare RP2040Zero / Picoclone</span></td><td class="q">1</td><td class="c">4</td></tr>
<tr><td class="part">2.0″ 320×240 IPS TFT, ST7789 <span class="spec">— SPI</span></td><td class="q">1</td><td class="c">8</td></tr>
<tr class="grp"><td colspan="3">Controls</td></tr>
<tr><td class="part">Arcade pushbutton, 24 mm <span class="spec">— Prev · Next · Tap</span></td><td class="q">3</td><td class="c">4</td></tr>
<tr><td class="part">Arcade pushbutton, 30 mm <span class="spec">— Play</span></td><td class="q">1</td><td class="c">2</td></tr>
<tr><td class="part">Detented encoder (EC11 / PEC12) + sidemount thumbroller <span class="spec">— recessed; nothing to snap off</span></td><td class="q">1</td><td class="c">2</td></tr>
<tr class="grp"><td colspan="3">Audio — analog click injection</td></tr>
<tr><td class="part">PCM5102A I²S DAC <span class="spec">— linelevel click</span></td><td class="q">1</td><td class="c">3</td></tr>
<tr><td class="part">Dual opamp, NE5532 / OPA2134 <span class="spec">— hiZ instrument buffer + summing mixer</span></td><td class="q">1</td><td class="c">1</td></tr>
<tr><td class="part">Balanced line driver, DRV134 <span class="spec">— (or crosscoupled opamp) → 1/4″ TRS out</span></td><td class="q">1</td><td class="c">4</td></tr>
<tr><td class="part">PAM8302A mono ClassD + 8 Ω 2 W speaker <span class="spec">— monitor</span></td><td class="q">1</td><td class="c">4</td></tr>
<tr class="grp"><td colspan="3">Connectors &amp; power</td></tr>
<tr><td class="part">1/4″ jack <span class="spec">— Inst In (TS) · Out (TRS) · Trig In (TS)</span></td><td class="q">3</td><td class="c">3</td></tr>
<tr><td class="part">USBC bus power (5 V) + PWR LED <span class="spec">— same port carries config; no battery</span></td><td class="q">1</td><td class="c">1</td></tr>
<tr><td class="part">9 V DC pedal jack (2.1 mm centreneg) + 9 V→5 V buck + reversepolarity protect <span class="spec">— pedalboard power</span></td><td class="q">1</td><td class="c">3</td></tr>
<tr class="grp"><td colspan="3">Build</td></tr>
<tr><td class="part">Custom PCB (or perfboard)</td><td class="q">1</td><td class="c">5</td></tr>
<tr><td class="part">Passives, headers, wire <span class="spec">— R/C for the analog stage + decoupling</span></td><td class="q"></td><td class="c">3</td></tr>
<tr><td class="part">Diecast aluminium enclosure (Hammond 1590style) <span class="spec">— beadblasted, matteblack Type II anodise, laseretched legends</span></td><td class="q">1</td><td class="c">12</td></tr>
<tr class="total"><td>Total (oneoff)</td><td class="q"></td><td class="c">≈ $59</td></tr>
</tbody>
</table>
<p class="sub" style="margin-top:12px">Audio is summed in the <b>analog domain</b>: the DAC's click is mixed with a highimpedance
buffer of the 1/4″ instrument input, then fed to the balanced line driver (1/4″ TRS out) and the monitor amp —
so your instrument is never redigitised (no added latency).</p>
</div>
</div><!-- /cols -->
<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,
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(){
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++; } } }
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 */
const THEMES = ["system","light","dark"];
function effectiveTheme(p){ return p==="system" ? (matchMedia("(prefers-color-scheme: light)").matches?"light":"dark") : p; }
function themePref(){ try{ const p=localStorage.getItem("metronome.theme"); return (p==="light"||p==="dark"||p==="system")?p:"system"; }catch(e){ return "system"; } }
function applyTheme(p){
try{ localStorage.setItem("metronome.theme",p); }catch(e){}
document.documentElement.dataset.theme = effectiveTheme(p);
$("themeBtn").textContent = p==="system" ? "◐" : p==="light" ? "☀" : "☾";
$("themeBtn").title = "Theme: "+p+" (click to cycle: system → light → dark)";
}
$("themeBtn").onclick = ()=> applyTheme(THEMES[(THEMES.indexOf(themePref())+1)%THEMES.length]);
matchMedia("(prefers-color-scheme: light)").addEventListener("change", ()=>{ if(themePref()==="system") applyTheme("system"); });
applyTheme(themePref());
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);
</script>
</body>
</html>