Visible names/codes updated across all pages, the landing panes, device silk labels, the Showcase canvas legend, and the README: PM_E-1 Editor · PM_T-1 Teacher · PM_S-1 Stage · PM_P-1 Practice (was Micro) · PM_D-1 Display (was Showcase) · PM_C-1 Concept (was Initial). Filenames/URLs and embed variant keys are kept as-is for backward compatibility (existing links and embeds keep working). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
219 lines
14 KiB
HTML
219 lines
14 KiB
HTML
<!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 & 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 .open{ font-size:11px; color:var(--link); text-decoration:none; font-weight:600; align-self:flex-start; }
|
||
|
||
/* 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 per‑step 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>
|
||
|
||
<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_E‑1 Editor</b></span><a id="vpOpen" href="/editor.html" target="_blank" rel="noopener">Open full page ↗</a></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 set‑list 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">
|
||
<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>🔌 USB‑C power everywhere — no batteries</h3>
|
||
<p>Every device runs over a single <b>USB‑C</b> port (the larger ones add a pass‑through to daisy‑chain).
|
||
No internal battery to wear out; bring a power bank. One connector keeps it all <b>future‑proof</b>.</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_E‑1 Editor", chip:"app", h:620, sum:"Design grooves: stack meter lanes, per‑step accents/ghosts/mutes, swing & polyrhythm, set lists, per‑lane dB gain." },
|
||
{ key:"teacher", file:"/teacher.html", name:"PM_T‑1 Teacher", chip:"hw", h:440, sum:"Studio / lesson desk console — colour TFT of every lane, arcade buttons, instrument pass‑through." },
|
||
{ key:"stage", file:"/stage.html", name:"PM_S‑1 Stage", chip:"hw", h:430, sum:"Live foot pedal — two footswitches, expression‑pedal tempo, a big floor‑readable RGB beat light." },
|
||
{ key:"micro", file:"/micro.html", name:"PM_P‑1 Practice", chip:"hw", h:240, sum:"Inline practice bar — clickable thumb‑roller, amber 14‑segment, instrument in/out pass‑through." },
|
||
{ key:"showcase", file:"/showcase.html",name:"PM_D‑1 Display", chip:"hw", h:540, sum:"Pyramid display piece — an RGB‑light pendulum combining every lane's subdivisions & accents." },
|
||
{ key:"initial", file:"/player.html", name:"PM_C‑1 Concept", chip:"", h:440, sum:"The idealized concept render — full multi‑lane display and set‑list 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>
|
||
<a class="open" href="${v.file}" target="_blank" rel="noopener" onclick="event.stopPropagation()">Open full page ↗</a>
|
||
</div>`).join("");
|
||
$("panes").querySelectorAll(".pane").forEach((el) => {
|
||
const k = el.dataset.key;
|
||
el.addEventListener("click", () => loadVersion(k, (box.value || "").trim() || DEFAULT_PROG));
|
||
el.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); loadVersion(k, (box.value || "").trim() || DEFAULT_PROG); } });
|
||
});
|
||
}
|
||
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;
|
||
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();
|
||
box.value = DEFAULT_PROG;
|
||
loadVersion("editor", DEFAULT_PROG);
|
||
/*@BUILD:include:src/chrome.js@*/
|
||
</script>
|
||
</body>
|
||
</html>
|