- Shared header/footer/chrome (src/header.html, src/footer.html, src/chrome.js) now on every page: editor (header above its app toolbar), player, teacher, stage, micro, showcase, embed. chrome.js defers to DOMContentLoaded so the footer version stamps regardless of placement. Player's fullscreen toggle relocated out of the header to a floating control. - Open = Info: each form-factor page is self-contained — a more-detailed description (.about) + an expandable "Spec & BOM" (<details class="spec">, hidden in embed). info-*.html retired; build/deploy/README updated. Next: teacher-style dimensioned front + top/side views + loading panels for Stage, Micro and Showcase. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
559 lines
36 KiB
HTML
559 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 — 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 }
|
||
.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 }
|
||
|
||
/* 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@*/
|
||
|
||
<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 usb" title="USB-C — power (wall adapter or power bank) & 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 · 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 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 & 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 -->
|
||
</div><!-- /cols -->
|
||
|
||
<section class="about pageonly">
|
||
<h2>PM‑1 — Teacher</h2>
|
||
<div class="ff-tags"><span class="hw">Hardware</span><span>Studio / lesson console</span><span>~$59 one‑off</span></div>
|
||
<p>The full‑feature desktop console: a colour readout of every lane, fast set‑list navigation, and your
|
||
instrument running straight through with the click mixed in — the hands‑on unit for a studio desk or a
|
||
teaching room, on a non‑reflective matte‑black case. (For hands‑free live use, see the foot‑operated
|
||
<a href="/stage.html">Stage</a> stompbox.)</p>
|
||
<p>Top‑mounted 1/4″ jacks keep cabling tidy; the metronome click is summed into the signal in the
|
||
<b>analog domain</b> (no re‑digitising, no added latency) and sent to a balanced 1/4″ TRS output for the
|
||
desk or interface, plus a small monitor speaker. Powered over USB‑C — a wall adapter or a power bank. The
|
||
colour TFT shows tempo, the item name and all lane patterns; arcade buttons + a recessed thumb‑roller make
|
||
it quick to drive while you teach or track.</p>
|
||
</section>
|
||
|
||
<details class="spec pageonly">
|
||
<summary>Spec & bill of materials</summary>
|
||
<div class="spec-body">
|
||
<p class="sub">Rough parts list — a desk/studio RP2040 build (USB‑C powered) 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">— wall adapter or power bank; same port carries config; no battery</span></td><td class="q">1</td><td class="c">1</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">≈ $56</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>
|
||
</details>
|
||
|
||
<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(){
|
||
// 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);
|
||
</script>
|
||
/*@BUILD:include:src/footer.html@*/
|
||
</body>
|
||
</html>
|