metronome/index.html
Me Here a3a09bc77d pm-mobile: touch-first phone/tablet PWA player (mobile.html)
A new full-screen, touch-first edition of the player aimed at phones through
tablets - no native app, just a web page you can "Add to Home Screen".

Reuses the shared engine + look-ahead scheduler (same player loop as
player.html); new UI is a big pulsing beat display, beat-dot row with accent
grouping, huge BPM (tap to type, vertical drag to scrub), prev/play/next +/-
and tap-tempo, and a bottom sheet for set lists / patch+link loading / volume.

Mobile concerns handled:
- iOS ring/silent switch: navigator.audioSession.type="playback" + a silent
  buffer warmup inside the play gesture, so audio isn't muted by the switch.
- Screen Wake Lock while running (re-acquired on visibilitychange).
- PWA: manifest.webmanifest + apple-touch meta + mobile-sw.js (network-first
  app shell, passthrough for everything else) -> installable + offline.
  Multi-file is fine here since it targets mobile (waives the single-file rule).
- viewport-fit=cover + safe-area insets, no user zoom, touch-action:manipulation,
  overscroll-behavior:none; transport buttons flex-share the row so they never
  overflow a narrow phone; responsive portrait/landscape, phone->tablet.
- Fullscreen toggle where supported (Android/desktop; iOS uses home-screen PWA).

Wired into build.sh + deploy.sh (page + PWA assets) and added to the index
gallery as PM_M-1 Mobile. New metronome app icons generated in assets/.
Conformance suite unaffected (engine untouched): 47 pass, 1 known.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:21:52 -05:00

254 lines
17 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="/pm_e-2.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_E2 Editor</b></span><span><a id="vpInfo" href="/info-pm_e-2.html" target="_blank" rel="noopener">Specs &amp; info ⓘ</a> &nbsp;·&nbsp; <a id="vpOpen" href="/pm_e-2.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 = [
// PM_E-1 (editor.html) is hidden from the landing — PM_E-2 is the focus. The page still exists.
{ key:"pme2", file:"/pm_e-2.html", name:"PM_E2 Editor", chip:"app", h:640, sum:"The PolyMeter 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:"mobile", file:"/mobile.html", name:"PM_M1 Mobile", chip:"app", h:600, sum:"Phone &amp; tablet app — a touchfirst, fullscreen player you can “Add to Home Screen.” Big tap targets, dragtoscrub tempo, a pulsing beat display, screenwakelock, and an iOS fix for the ring/silent switch. Installable &amp; works offline." },
{ 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 = "pme2", 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("pme2");
/*@BUILD:include:src/chrome.js@*/
</script>
</body>
</html>