metronome/index.html
Me Here cb54b4d689 Preserve notation + grammar feature work (verified complete + green)
The parallel agent's full session, committed now that it's solo:
- Grammar: flam/drag/roll ornaments (f/F d/D z/Z, per-lane orns channel) across
  src/engine.js, pico-cp/pico-explorer/pico-scroll app.py, pico/main.py, rust/track-format,
  + golden vectors / conformance (tests/, rust/track-format/tests).
- Live-sync deep-sync: SysEx 0x44 SLSYNC + 0x45 LOGSYNC (docs/livesync-protocol.md, src/livesync.js).
- PM_E-2 notation: web engine (pm_e-2.html, build/deploy/index/embed wiring) + Rust device port
  (pm-ui draw_notation rewrite + LaneView.groups, pm-kit ViewMode, uisim notesim).

Verified: node tests/run.mjs 47 pass / 1 known; ./rust/run.sh green; pm-kit firmware + uisim compile.
2026-06-02 13:45:26 -05:00

253 lines
17 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VARASYS PolyMeter — Concepts (polymetric groove trainer &amp; metronome)</title>
<meta name="description" content="PolyMeter — a polymetric groove trainer and metronome. Design grooves in the editor and play them on any form factor; one engine, one program string." />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,@BUILD:favicon@">
<script>
(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{ --bg1:#12151c; --bg2:#05070a; --txt:#c7d0db; --muted:#7f8b9a; --link:#6cb6ff;
--panel-bg:#161b22; --panel-bd:#2a313c; --field-bg:#0e1116; --field-bd:#2a313c; }
: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:#d2dae4; }
body{ margin:0; min-height:100vh; padding:22px 16px 56px; color:var(--txt);
background:radial-gradient(circle at 50% -8%, var(--bg1), var(--bg2)); }
a{ color:var(--link); }
main{ width:100%; max-width:1040px; margin:0 auto; }
.intro{ text-align:center; padding:34px 12px 18px; }
.intro h1{ font-size:clamp(32px, 6.5vw, 54px); margin:0; letter-spacing:-.02em; line-height:1;
background:linear-gradient(90deg, var(--cyan), #6cb6ff); -webkit-background-clip:text; background-clip:text; color:transparent; }
.intro .tagline{ margin:13px auto 0; font-size:clamp(15px,2.4vw,19px); color:var(--txt); font-weight:600; }
.intro p{ margin:11px auto 0; max-width:66ch; color:var(--muted); font-size:14.5px; line-height:1.6; }
.section-label{ text-align:center; font-size:11px; text-transform:uppercase; letter-spacing:.12em; color:var(--muted); margin:26px 0 12px; }
/* summary panes — click to load that version into the viewport */
.panes{ display:grid; grid-template-columns:repeat(auto-fit, minmax(225px, 1fr)); gap:12px; }
.pane{ text-align:left; background:var(--panel-bg); border:1px solid var(--panel-bd); border-radius:12px; padding:12px 13px;
cursor:pointer; display:flex; flex-direction:column; gap:6px; transition:border-color .14s, box-shadow .14s; }
.pane:hover{ border-color:var(--cyan); }
.pane.active{ border-color:var(--cyan); box-shadow:0 0 0 1px var(--cyan) inset; }
.pane .ph{ display:flex; align-items:center; gap:8px; }
.pane h3{ margin:0; font-size:14px; }
.pane .chip{ font-size:9px; text-transform:uppercase; letter-spacing:.07em; padding:2px 7px; border-radius:999px;
border:1px solid var(--panel-bd); color:var(--muted); }
.pane .chip.hw{ color:var(--cyan); border-color:rgba(10,179,247,.45); }
.pane .chip.app{ color:#2fe07a; border-color:rgba(47,224,160,.45); }
.pane p{ margin:0; font-size:12px; color:var(--muted); line-height:1.45; flex:1; }
.pane .links{ display:flex; gap:14px; align-items:center; flex-wrap:wrap; }
.pane .open{ font-size:11px; color:var(--link); text-decoration:none; font-weight:600; }
/* viewport: the live, selected device */
.viewport{ margin-top:16px; border:1px solid var(--panel-bd); border-radius:14px; overflow:hidden; background:var(--field-bg); }
.vp-bar{ display:flex; align-items:center; justify-content:space-between; gap:10px; padding:8px 12px; border-bottom:1px solid var(--panel-bd); font-size:12px; color:var(--muted); }
.vp-bar b{ color:var(--txt); }
.vp-bar a{ font-size:12px; }
#vp{ display:block; width:100%; height:620px; border:0; background:var(--field-bg); transition:height .15s; }
/* program I/O — decoded program string in/out (plain text or base64), linted */
.prog{ display:flex; align-items:center; gap:9px; flex-wrap:wrap; margin-top:12px; padding:9px 12px;
border:1px solid var(--panel-bd); border-radius:11px; background:var(--panel-bg); }
.prog > label{ flex:0 0 auto; font-size:10px; text-transform:uppercase; letter-spacing:.09em; color:var(--muted); }
.prog input{ flex:1; min-width:180px; background:var(--field-bg); color:var(--txt); border:1px solid var(--field-bd);
border-radius:8px; padding:8px 10px; font-family:"Courier New",monospace; font-size:12.5px; }
.prog input.err{ border-color:#c0392b; }
.prog button{ flex:0 0 auto; background:var(--field-bg); color:var(--txt); border:1px solid var(--field-bd); border-radius:8px;
padding:8px 13px; font-size:13px; cursor:pointer; }
.prog button.primary{ background:linear-gradient(180deg,#34c6ff,var(--cyan)); color:#04121b; border-color:transparent; font-weight:600; }
.prog button:hover{ border-color:var(--cyan); }
.prog-msg{ flex:1 1 100%; font-size:11.5px; color:var(--muted); min-height:1.1em; }
.prog-msg.ok{ color:#5fd08a; } .prog-msg.bad{ color:#ff8a7a; }
.prog-hint{ flex:1 1 100%; font-size:11px; color:var(--muted); }
.philosophy{ margin-top:34px; }
.phil-grid{ display:grid; grid-template-columns:repeat(auto-fit, minmax(300px, 1fr)); gap:16px; }
.phil{ background:var(--panel-bg); border:1px solid var(--panel-bd); border-radius:14px; padding:18px 18px 16px; }
.phil h3{ margin:0 0 8px; font-size:15px; display:flex; align-items:center; gap:8px; }
.phil p{ margin:0; font-size:13.5px; color:var(--muted); line-height:1.62; }
.phil p b{ color:var(--txt); }
</style>
</head>
<body>
/*@BUILD:include:src/header.html@*/
<main>
<section class="intro">
<h1>PolyMeter</h1>
<p class="tagline">Polymetric grooves — one engine, one program string, every form factor.</p>
<p>Stack independent meter lanes — each with its own subdivision, drum voice and perstep accents — to build
true polymeter and ratio polyrhythm. Design a groove once; it saves to a compact <b>program string</b> that
plays back identically on the web editor, the hardware concepts, or an embedded widget. The editor is open
below — or pick any form factor to load and play the same groove on it.</p>
</section>
<section class="philosophy">
<div class="section-label">Philosophy</div>
<div class="phil-grid">
<div class="phil">
<h3>🛠️ Program on the web, play on any device</h3>
<p>The website is the workbench: design in the <a href="/editor.html">editor</a>, and the same
<b>program string</b> loads into whichever form factor fits the moment. One engine, one language.</p>
</div>
<div class="phil">
<h3>🔌 USBC power everywhere — no batteries</h3>
<p>Every device runs over a single <b>USBC</b> port (the larger ones add a passthrough to daisychain).
No internal battery to wear out; bring a power bank. One connector keeps it all <b>futureproof</b>.</p>
</div>
</div>
</section>
<div class="section-label">Pick a form factor — it loads live below</div>
<div class="panes" id="panes"></div>
<div class="viewport">
<div class="vp-bar"><span id="vpName"><b>PM_E1 Editor</b></span><span><a id="vpInfo" href="/info-editor.html" target="_blank" rel="noopener">Specs &amp; info ⓘ</a> &nbsp;·&nbsp; <a id="vpOpen" href="/editor.html" target="_blank" rel="noopener">Open full page ↗</a></span></div>
<iframe id="vp" title="PolyMeter — live viewport" allow="autoplay"></iframe>
</div>
<div class="prog">
<label for="prog">program</label>
<input id="prog" spellcheck="false" autocomplete="off" autocapitalize="off"
placeholder="v1;t120;kick:4;snare:4=.X.X;hat:4/2 — or paste a base64 set-list code">
<button id="progLoad" class="primary" title="Decode, check, and load into the viewport">Load ▸</button>
<button id="progCopy" title="Copy the program string">Copy</button>
<div class="prog-msg" id="progMsg"></div>
<div class="prog-hint">The current program, decoded (not base64). Paste a patch <i>or</i> a base64 setlist code; it's checked, then loaded.
Conventions: GM names or numbers (<code>kick</code> / <code>36</code>), <code>=X.x-</code> steps, <code>/2</code> subdivision, <code>(3,8)</code> euclidean, <code>@-3</code> dB, <code>~</code> polymeter.</div>
</div>
<section class="philosophy" id="rust">
<div class="section-label">Firmware · Rust (developer · alpha)</div>
<div class="phil-grid">
<div class="phil">
<h3>🦀 NativeRust firmware — PM_K1 (RP2350 / Pico 2)</h3>
<p>An experimental nativeRust build of the Kit firmware: one <code>no_std</code> core (track codec + scheduler,
validated against the same golden vectors as the web and CircuitPython builds) plus perboard drivers.
It now runs as a working metronome — boots, drives the ST7796 display, plays builtin grooves with audio
clicks, and has a drumnotation view. Controls: <b>A</b> = play/stop, <b>B</b> = grid/notation, joystick =
tempo &amp; groove. (Alpha; built and reviewed in a host simulator.)</p>
<p style="margin-top:12px">
<a href="/pm-kit.uf2" download
style="display:inline-block; padding:10px 18px; border-radius:9px; font-weight:600; text-decoration:none;
color:#04121b; background:linear-gradient(180deg,#34c6ff,var(--cyan))">Download pmkit.uf2 ↓</a>
</p>
<p class="prog-hint" style="margin-top:10px">Flash: hold <b>BOOTSEL</b> on the Pico 2, plug in USB, and drag the
<code>.uf2</code> onto the RP2350 drive. Recover anytime with BOOTSEL + a CircuitPython <code>.uf2</code>.
Source &amp; staged plan live in <code>rust/</code> and <code>docs/rust-port.md</code>.</p>
</div>
</div>
</section>
</main>
/*@BUILD:include:src/footer.html@*/
<script>
const APP_VERSION = "v0.0.1-dev";
const $ = (id) => document.getElementById(id);
/* engine codec (for decode + lint) + seed set lists (default program). No audio here — the viewport plays. */
const SAMPLES = {}; let state = { bpm:120, volume:0.85 }, meters = [], muteWindows = [];
/*@BUILD:include:src/engine.js@*/
/*@BUILD:include:src/setlists.js@*/
const VERSIONS = [
{ key:"editor", file:"/editor.html", name:"PM_E1 Editor", chip:"app", h:620, sum:"Design grooves: stack meter lanes, perstep accents/ghosts/mutes, swing &amp; polyrhythm, set lists, perlane dB gain." },
{ key:"pme2", file:"/pm_e-2.html", name:"PM_E2 Editor", chip:"app", h:640, sum:"Secondgeneration editor built around engraved drum notation — a 5line percussion staff (Bravura/SMuFL) with Staff / TUBS / Konnakol views, editonstaff, plus flams/drags/rolls, odd meters &amp; clave." },
{ key:"kit", file:"/kit.html", name:"PM_K1 Kit", chip:"hw", h:560, sum:"Build it today — a Raspberry Pi Pico on the 52Pi touchscreen kit; tap the 3.5″ screen, joystick tempo, RGB beat light, buzzer. MicroPython firmware included." },
{ key:"explorer", file:"/explorer.html", name:"PM_X1 Explorer", chip:"hw", h:500, sum:"Offtheshelf — the Pimoroni Explorer (RP2350, 2.8″ LCD, 6 buttons, piezo) as a buttondriven sibling to the Kit. Edit on the web with Live sync; the device mirrors play/stop/tempo/track changes both ways." },
{ key:"grid", file:"/grid.html", name:"PM_G1 Grid", chip:"hw", h:470, sum:"Offtheshelf — a Pimoroni Pico Scroll Pack (17×7 white LED matrix + 4 buttons) on a Raspberry Pi Pico. The matrix IS the editor's lane × step pad grid in miniature; edit on the web with Live sync." },
{ key:"teacher", file:"/teacher.html", name:"PM_T1 Teacher", chip:"hw", h:440, sum:"Studio / lesson desk console — colour TFT of every lane, arcade buttons, instrument passthrough." },
{ key:"stage", file:"/stage.html", name:"PM_S1 Stage", chip:"hw", h:430, sum:"Live foot pedal — two footswitches, expressionpedal tempo, a big floorreadable RGB beat light." },
{ key:"micro", file:"/micro.html", name:"PM_P1 Practice", chip:"hw", h:240, sum:"Inline practice bar — clickable thumbroller, amber 14segment, instrument in/out passthrough." },
{ key:"showcase", file:"/showcase.html",name:"PM_D1 Display", chip:"hw", h:540, sum:"Pyramid display piece — an RGBlight pendulum combining every lane's subdivisions &amp; accents." },
{ key:"initial", file:"/player.html", name:"PM_C1 Concept", chip:"", h:440, sum:"The idealized concept render — full multilane display and setlist navigation." },
];
// "Specs & info" link helper - usually swaps /<dev>.html for /info-<dev>.html, but if the
// pane already POINTS at the info page (PM_X-1 has no separate widget yet), keep it as is.
const infoOf = (f) => f.startsWith("/info-") ? f : f.replace("/", "/info-");
const DEFAULT_PROG = (typeof SEED_SETLISTS !== "undefined" && SEED_SETLISTS[0] && SEED_SETLISTS[0].items[0] && SEED_SETLISTS[0].items[0][1]) || "v1;t120;kick:4;snare:4=.X.X;hat:4/2";
let cur = "editor", userEditing = false;
const vp = $("vp"), box = $("prog");
const verOf = (k) => VERSIONS.find((v) => v.key === k);
function renderPanes() {
$("panes").innerHTML = VERSIONS.map((v) => `
<div class="pane" data-key="${v.key}" role="button" tabindex="0">
<div class="ph"><span class="chip ${v.chip}">${v.chip === "app" ? "Web app" : v.chip === "hw" ? "Hardware" : "Concept"}</span><h3>${v.name}</h3></div>
<p>${v.sum}</p>
<div class="links">
<a class="open" href="${v.file}" target="_blank" rel="noopener" onclick="event.stopPropagation()">Open ↗</a>
<a class="open" href="${infoOf(v.file)}" target="_blank" rel="noopener" onclick="event.stopPropagation()">Specs &amp; info ⓘ</a>
</div>
</div>`).join("");
$("panes").querySelectorAll(".pane").forEach((el) => {
const k = el.dataset.key;
el.addEventListener("click", () => loadVersion(k));
el.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); loadVersion(k); } });
});
}
function loadVersion(key, prog) {
cur = key; const v = verOf(key);
$("panes").querySelectorAll(".pane").forEach((p) => p.classList.toggle("active", p.dataset.key === key));
$("vpName").innerHTML = "<b>" + v.name + "</b>";
$("vpOpen").href = v.file;
$("vpInfo").href = infoOf(v.file);
vp.style.height = (v.h || 440) + "px";
vp.src = v.file + "?embed=1" + (prog ? "#p=" + encodeURIComponent(prog) : "");
if (prog && !userEditing) box.value = prog;
}
/* program I/O: lint a patch OR a base64 set-list code, return canonical plain text */
function lintProgram(text) {
text = (text || "").trim(); if (!text) return { ok:false, msg:"empty — type or paste a program" };
const m = text.match(/[#?&](p|sl)=([^&\s]+)/); let kind = null, payload = text;
if (m) { kind = m[1]; try { payload = decodeURIComponent(m[2]); } catch (e) { payload = m[2]; } }
const b64 = /^[A-Za-z0-9_-]{12,}$/.test(payload) && !/[;:]/.test(payload);
try {
if (kind === "sl" || (kind !== "p" && b64)) {
const sl = codeToSetlist(payload);
if (!sl.items || !sl.items.length) throw new Error("set-list code has no items");
return { ok:true, plain:setupToPatch(sl.items[0]), msg:"decoded set list “" + sl.title + "” (" + sl.items.length + " item" + (sl.items.length>1?"s":"") + ") — loading item 1" };
}
const s = patchToSetup(payload);
if (!s.lanes.length) throw new Error("no lanes — try e.g. kick:4");
return { ok:true, plain:setupToPatch(s), msg:s.lanes.length + " lane" + (s.lanes.length>1?"s":"") + " · " + s.bpm + " BPM" };
} catch (e) { return { ok:false, msg:"✗ " + e.message }; }
}
function setMsg(t, ok) { const m = $("progMsg"); m.textContent = t || ""; m.classList.toggle("ok", !!ok && !!t); m.classList.toggle("bad", !ok && !!t); }
function doLoad() {
const r = lintProgram(box.value);
if (!r.ok) { box.classList.add("err"); setMsg(r.msg, false); return; }
box.classList.remove("err"); box.value = r.plain; setMsg("✓ " + r.msg, true);
loadVersion(cur, r.plain);
}
$("progLoad").addEventListener("click", doLoad);
$("prog").addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); doLoad(); } });
$("prog").addEventListener("focus", () => userEditing = true);
$("prog").addEventListener("blur", () => userEditing = false);
$("prog").addEventListener("input", () => box.classList.remove("err"));
$("progCopy").addEventListener("click", async () => { try { await navigator.clipboard.writeText(box.value); const b = $("progCopy"); b.textContent = "Copied!"; setTimeout(() => b.textContent = "Copy", 1200); } catch (e) { box.select(); } });
/* the viewport reports its height + current program back to us */
addEventListener("message", (e) => {
if (!e.data || e.source !== vp.contentWindow) return;
if (e.data.type === "varasys-h" && typeof e.data.h === "number") vp.style.height = e.data.h + "px";
else if (e.data.type === "varasys-prog" && typeof e.data.patch === "string" && !userEditing) { box.value = e.data.patch; box.classList.remove("err"); }
});
renderPanes();
// default = each device's built-in set lists (no forced program); the box fills from what the device reports
loadVersion("editor");
/*@BUILD:include:src/chrome.js@*/
</script>
</body>
</html>