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>
555 lines
36 KiB
HTML
555 lines
36 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 — 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 PM‑1</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" 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" 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 & set-list transfer"><i></i><b>USB-C</b></div>
|
||
</div>
|
||
</div>
|
||
<div class="ledbar-cap">Trig in · 1/4″ inst pass‑through (click injected) · shared 1/4″ balanced‑TRS out · 9 V DC / USB‑C 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">PM‑1 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 & 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>set‑list code</b>, or a
|
||
<code>#p=…</code>/<code>#sl=…</code> link.</p>
|
||
<label for="cfg" class="hint">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>
|
||
</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 pedalboard‑friendly RP2040 build (9 V DC or USB‑C) 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 & display</td></tr>
|
||
<tr><td class="part">RP2040 board, USB‑C <span class="spec">— e.g. Waveshare RP2040‑Zero / Pico‑clone</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) + side‑mount thumb‑roller <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">— line‑level click</span></td><td class="q">1</td><td class="c">3</td></tr>
|
||
<tr><td class="part">Dual op‑amp, NE5532 / OPA2134 <span class="spec">— hi‑Z 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 cross‑coupled op‑amp) → 1/4″ TRS out</span></td><td class="q">1</td><td class="c">4</td></tr>
|
||
<tr><td class="part">PAM8302A mono Class‑D + 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 & 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">USB‑C 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 centre‑neg) + 9 V→5 V buck + reverse‑polarity 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">Die‑cast aluminium enclosure (Hammond 1590‑style) <span class="spec">— bead‑blasted, matte‑black Type II anodise, laser‑etched legends</span></td><td class="q">1</td><td class="c">12</td></tr>
|
||
<tr class="total"><td>Total (one‑off)</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 high‑impedance
|
||
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 re‑digitised (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>
|