metronome/index.html
Me Here eecea625d3 Add PM_K-1 "Kit" — buildable Pico touchscreen unit + MicroPython firmware
A new, first-actually-buildable form factor for the 52Pi EP-0172 "Pico Breadboard
Kit Plus" (Raspberry Pi Pico; 3.5" ST7796 320x480 cap-touch via GT911, PSP
joystick on ADC0/1, WS2812 RGB on GP12, buzzer GP13, buttons GP14/15):

  - pico/main.py — one self-contained MicroPython file: ST7796 direct-draw driver,
    GT911 touch (16-bit register addressing), WS2812 RGB (neopixel), PWM buzzer,
    ADC joystick, buttons. It parses the project's own program-string language
    (verified against the web engine's semantics) and runs a non-blocking
    ticks_us scheduler with an on-screen touch UI. CONFIG flags cover panel /
    colour / touch / joystick calibration. pico/README.md has flashing +
    calibration steps.
  - kit.html — lean widget that mirrors the firmware's on-screen UI (portrait
    320x480 canvas) plus a joystick / RGB / buzzer / A-B buttons; plays via the
    shared engine. info-kit.html — the real EP-0172 pinout, a parts list
    (~$45 incl. Pico) and the firmware to flash (downloads /pico-main.py, links
    the README + source).
  - Landing + embed page list the Kit; build.sh/deploy.sh build the two pages and
    serve pico/main.py as /pico-main.py for download.

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

225 lines
14 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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>
</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:"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:"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." },
];
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="${v.file.replace("/", "/info-")}" 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 = v.file.replace("/", "/info-");
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>