Phase B — landing rebuild + editor program I/O + per-lane dB knob

Editor: per-lane gain knob (drag/scroll/double-click, dB, applied at schedule
time — no stutter); program box now decodes base64 set-list codes + lints
(clear ✓/✗ message). When embedded it posts its program to the parent.

Landing (Concepts) rebuilt to the spec: description first, the EDITOR open by
default in a live viewport, a summary pane per form factor (click loads it into
the viewport), and a program I/O box that shows the current program decoded (not
base64), accepts plain text OR a base64 set-list code, lints it, loads it into
the viewport, and copies it. Viewport auto-sizes + reflects the device's posted
program. Engine codec inlined for decode/lint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-05-28 10:58:19 -05:00
parent c0bf81c3fc
commit 31111abbcb
2 changed files with 214 additions and 109 deletions

View file

@ -129,6 +129,12 @@
.sum { font-family:"Courier New",monospace; font-size:12px; color:var(--muted); min-width:24px; } .sum { font-family:"Courier New",monospace; font-size:12px; color:var(--muted); min-width:24px; }
.bar { font-family:"Courier New",monospace; font-size:12px; color:var(--hot); min-width:46px; text-align:right; } .bar { font-family:"Courier New",monospace; font-size:12px; color:var(--hot); min-width:46px; text-align:right; }
.cmp { background:var(--panel); color:var(--txt); border:1px solid var(--edge); border-radius:8px; padding:7px 8px; font-size:12px; } .cmp { background:var(--panel); color:var(--txt); border:1px solid var(--edge); border-radius:8px; padding:7px 8px; font-size:12px; }
.gain { font-size:11px; color:var(--muted); border:1px solid var(--edge); border-radius:8px; padding:6px 7px;
cursor:ns-resize; user-select:none; min-width:42px; text-align:center; font-variant-numeric:tabular-nums;
touch-action:none; background:var(--panel); }
.gain:hover { color:var(--txt); }
.gain.boost { color:#5fd08a; border-color:#2f6b48; }
.gain.cut { color:#ff9a8a; border-color:#7a3b34; }
.meter-card .led { width:26px; height:26px; border-radius:6px; } .meter-card .led { width:26px; height:26px; border-radius:6px; }
.meter-card .led.sub { width:17px; height:17px; } .meter-card .led.sub { width:17px; height:17px; }
.x { background:transparent; border:1px solid var(--edge); color:var(--muted); padding:4px 9px; border-radius:7px; margin-left:auto; } .x { background:transparent; border:1px solid var(--edge); color:var(--muted); padding:4px 9px; border-radius:7px; margin-left:auto; }
@ -148,6 +154,8 @@
.patchbar button { flex:0 0 auto; font-size:11px; padding:4px 10px; } .patchbar button { flex:0 0 auto; font-size:11px; padding:4px 10px; }
.patchbar.applied { box-shadow:0 0 0 2px var(--cyan) inset; } .patchbar.applied { box-shadow:0 0 0 2px var(--cyan) inset; }
.patchbar.err input { color:#ff8a7a; } .patchbar.err input { color:#ff8a7a; }
.patch-msg { flex:0 1 auto; font-size:11px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:46%; }
.patch-msg.ok { color:#5fd08a; } .patch-msg.bad { color:#ff8a7a; }
[data-embed] .patchbar { display:none !important; } [data-embed] .patchbar { display:none !important; }
.ex-item { display:flex; gap:8px; align-items:center; padding:7px 9px; border:1px solid var(--edge); border-radius:8px; margin-bottom:6px; font-size:13px; background:var(--panel); cursor:pointer; } .ex-item { display:flex; gap:8px; align-items:center; padding:7px 9px; border:1px solid var(--edge); border-radius:8px; margin-bottom:6px; font-size:13px; background:var(--panel); cursor:pointer; }
.ex-item:hover { border-color:var(--muted); } .ex-item:hover { border-color:var(--muted); }
@ -369,11 +377,12 @@
/*@BUILD:include:src/footer.html@*/ /*@BUILD:include:src/footer.html@*/
<!-- Subtle live program string for what's loaded: editable, copy & paste. --> <!-- Live program string for what's loaded: editable, copy & paste (plain text or base64 set-list code). -->
<div class="patchbar" id="patchBar" title="Program string for what's loaded — edit it and press Enter (or paste one) to apply"> <div class="patchbar" id="patchBar" title="Program string for what's loaded — edit and press Enter, or paste a patch / base64 set-list code; it's decoded and checked">
<label for="patchField">program</label> <label for="patchField">program</label>
<input id="patchField" spellcheck="false" autocomplete="off" autocapitalize="off" <input id="patchField" spellcheck="false" autocomplete="off" autocapitalize="off"
placeholder="v1;t120;kick:4;snare:4=.X.X;hatClosed:4/2" aria-label="program string (editable)"> placeholder="v1;t120;kick:4;snare:4=.X.X;hat:4/2 (or paste a base64 set-list code)" aria-label="program string (editable)">
<span id="patchMsg" class="patch-msg"></span>
<button id="patchCopy" title="Copy the program string">Copy</button> <button id="patchCopy" title="Copy the program string">Copy</button>
</div> </div>
@ -606,6 +615,7 @@ function buildLaneCard(m) {
<option value="2s">♪ swing 8th</option><option value="4s">swing 16th</option> <option value="2s">♪ swing 8th</option><option value="4s">swing 16th</option>
</select> </select>
<select class="cmp" id="m${m.id}_sound" title="sound">${VOICES.map(([v, n]) => `<option value="${v}">${n}</option>`).join("")}</select> <select class="cmp" id="m${m.id}_sound" title="sound">${VOICES.map(([v, n]) => `<option value="${v}">${n}</option>`).join("")}</select>
<span class="gain" id="m${m.id}_gain" title="lane gain (dB) — drag up/down · scroll · doubleclick resets to 0">0 dB</span>
<div class="strip" id="m${m.id}_strip"></div> <div class="strip" id="m${m.id}_strip"></div>
<span class="bar" id="m${m.id}_bar"></span> <span class="bar" id="m${m.id}_bar"></span>
<label class="mini-check" title="polyrhythm: fit these beats evenly into lane 1's bar"><input type="checkbox" id="m${m.id}_poly"> poly</label> <label class="mini-check" title="polyrhythm: fit these beats evenly into lane 1's bar"><input type="checkbox" id="m${m.id}_poly"> poly</label>
@ -624,6 +634,21 @@ function buildLaneCard(m) {
sub.addEventListener("change", (e) => { const v = e.target.value; m.swing = /s$/.test(v); m.stepsPerBeat = parseInt(v, 10) || 1; recomputeLane(m); }); sub.addEventListener("change", (e) => { const v = e.target.value; m.swing = /s$/.test(v); m.stepsPerBeat = parseInt(v, 10) || 1; recomputeLane(m); });
const sel = $c(`#m${m.id}_sound`); sel.value = m.sound; const sel = $c(`#m${m.id}_sound`); sel.value = m.sound;
sel.addEventListener("change", (e) => m.sound = e.target.value); sel.addEventListener("change", (e) => m.sound = e.target.value);
// per-lane gain (dB) — drag up/down, scroll, or double-click to reset; applied at
// schedule time (no stutter), so no replay is needed when it changes mid-play.
const gainEl = $c(`#m${m.id}_gain`);
const showGain = () => { const d = m.gainDb || 0; gainEl.textContent = (d > 0 ? "+" : "") + d + " dB";
gainEl.classList.toggle("boost", d > 0); gainEl.classList.toggle("cut", d < 0); };
const setGain = (d) => { m.gainDb = Math.max(-24, Math.min(6, d)); showGain(); if (typeof refreshPatchField === "function") refreshPatchField(); };
showGain();
(function () { let down = false, lastY = 0, acc = 0;
gainEl.addEventListener("pointerdown", (e) => { down = true; lastY = e.clientY; acc = 0; try { gainEl.setPointerCapture(e.pointerId); } catch (_) {} });
gainEl.addEventListener("pointermove", (e) => { if (!down) return; acc += lastY - e.clientY; lastY = e.clientY; while (Math.abs(acc) >= 4) { const d = acc > 0 ? 1 : -1; acc -= d * 4; setGain((m.gainDb || 0) + d); } });
gainEl.addEventListener("pointerup", () => down = false);
gainEl.addEventListener("pointercancel", () => down = false);
gainEl.addEventListener("dblclick", () => setGain(0));
gainEl.addEventListener("wheel", (e) => { e.preventDefault(); setGain((m.gainDb || 0) + (e.deltaY < 0 ? 1 : -1)); }, { passive: false });
})();
const polyCb = $c(`#m${m.id}_poly`); polyCb.checked = m.poly; const polyCb = $c(`#m${m.id}_poly`); polyCb.checked = m.poly;
polyCb.addEventListener("change", (e) => m.poly = e.target.checked); polyCb.addEventListener("change", (e) => m.poly = e.target.checked);
const enCb = $c(`#m${m.id}_enable`); enCb.checked = m.enabled; const enCb = $c(`#m${m.id}_enable`); enCb.checked = m.enabled;
@ -1162,23 +1187,49 @@ function updateStatus() {
ctxDisplay.textContent = s; ctxDisplay.textContent = s;
ctxDisplay.classList.toggle("muted-cue", muted || !!pendingSwitch); ctxDisplay.classList.toggle("muted-cue", muted || !!pendingSwitch);
} }
function updateCtx() { updateStatus(); refreshPatchField(); } function updateCtx() { updateStatus(); refreshPatchField(); postProg(); }
// when embedded (e.g. in the Concepts landing), report the current program to the parent
function postProg() { if (document.documentElement.dataset.embed !== "1") return;
try { parent.postMessage({ type: "varasys-prog", patch: currentPatch() }, "*"); } catch (e) {} }
/* keep the subtle program-string bar in sync with what's loaded, unless the /* keep the subtle program-string bar in sync with what's loaded, unless the
user is mid-edit in it (don't yank text out from under them). */ user is mid-edit in it (don't yank text out from under them). */
function refreshPatchField() { function refreshPatchField() {
const el = $("patchField"); if (!el || document.activeElement === el) return; const el = $("patchField"); if (!el || document.activeElement === el) return;
try { el.value = currentPatch(); } catch (e) {} try { el.value = currentPatch(); } catch (e) {}
$("patchBar").classList.remove("err"); const bar = $("patchBar"); if (bar) bar.classList.remove("err");
const msg = $("patchMsg"); if (msg && !msg.dataset.sticky) msg.textContent = "";
} }
function setPatchMsg(text, ok) {
const msg = $("patchMsg"); if (!msg) return;
msg.textContent = text || ""; msg.classList.toggle("ok", !!ok); msg.classList.toggle("bad", !ok && !!text);
msg.dataset.sticky = text ? "1" : "";
if (text) setTimeout(() => { msg.dataset.sticky = ""; if (ok) msg.textContent = ""; }, ok ? 1800 : 4000);
}
// Accept a patch string OR a base64 set-list code (or a #p=/#sl= link); decode, lint, load.
function commitPatchField() { function commitPatchField() {
const el = $("patchField"), bar = $("patchBar"); const el = $("patchField"), bar = $("patchBar");
let text = (el.value || "").trim();
if (!text) { setPatchMsg("", true); return; }
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 looksB64 = /^[A-Za-z0-9_-]{12,}$/.test(payload) && !/[;:]/.test(payload);
try { try {
applyPatch(el.value.trim()); // parse + apply (throws on garbage) if (kind === "sl" || (kind !== "p" && looksB64)) {
bar.classList.remove("err"); const sl = codeToSetlist(payload); // base64 set-list code → decode
el.blur(); refreshPatchField(); // normalise to the canonical string if (!sl.items || !sl.items.length) throw new Error("set-list code has no items");
applySetup(sl.items[0]);
setPatchMsg("✓ decoded set list “" + sl.title + "” (" + sl.items.length + " item" + (sl.items.length > 1 ? "s" : "") + ") — loaded item 1", true);
} else {
const s = patchToSetup(payload); // plain-text patch
if (!s.lanes.length) throw new Error("no lanes — try e.g. kick:4");
applyPatch(payload);
setPatchMsg("✓ " + s.lanes.length + " lane" + (s.lanes.length > 1 ? "s" : "") + " · " + s.bpm + " BPM", true);
}
bar.classList.remove("err"); el.blur(); refreshPatchField();
bar.classList.add("applied"); setTimeout(() => bar.classList.remove("applied"), 450); bar.classList.add("applied"); setTimeout(() => bar.classList.remove("applied"), 450);
} catch (e) { bar.classList.add("err"); } } catch (e) { bar.classList.add("err"); setPatchMsg("✗ " + e.message, false); }
} }
/* ========================================================================= /* =========================================================================

View file

@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VARASYS PolyMeter — Concepts (polymetric groove trainer &amp; metronome)</title> <title>VARASYS PolyMeter — Concepts (polymetric groove trainer &amp; metronome)</title>
<meta name="description" content="PolyMeter — a polymetric groove trainer and metronome. One engine, many form factors: a free web editor, hardware concepts, and an embeddable widget. Try each one live." /> <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@"> <link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,@BUILD:favicon@">
<script> <script>
(function(){ try{ var p = localStorage.getItem("metronome.theme"); (function(){ try{ var p = localStorage.getItem("metronome.theme");
@ -15,44 +15,62 @@
<style> <style>
/*@BUILD:include:src/base.css@*/ /*@BUILD:include:src/base.css@*/
:root{ --bg1:#12151c; --bg2:#05070a; --txt:#c7d0db; --muted:#7f8b9a; --link:#6cb6ff; :root{ --bg1:#12151c; --bg2:#05070a; --txt:#c7d0db; --muted:#7f8b9a; --link:#6cb6ff;
--panel-bg:#161b22; --panel-bd:#2a313c; } --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; :root[data-theme="light"]{ --bg1:#f5f8fc; --bg2:#dde4ec; --txt:#1e2630; --muted:#5c6776; --link:#1769c4;
--panel-bg:#ffffff; --panel-bd:#d2dae4; } --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); body{ margin:0; min-height:100vh; padding:22px 16px 56px; color:var(--txt);
background:radial-gradient(circle at 50% -8%, var(--bg1), var(--bg2)); } background:radial-gradient(circle at 50% -8%, var(--bg1), var(--bg2)); }
a{ color:var(--link); } a{ color:var(--link); }
main{ width:100%; max-width:1040px; margin:0 auto; } main{ width:100%; max-width:1040px; margin:0 auto; }
.hero{ text-align:center; padding:40px 12px 26px; } .intro{ text-align:center; padding:34px 12px 18px; }
.hero h1{ font-size:clamp(34px, 7vw, 58px); margin:0; letter-spacing:-.02em; line-height:1; .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; } background:linear-gradient(90deg, var(--cyan), #6cb6ff); -webkit-background-clip:text; background-clip:text; color:transparent; }
.hero .tagline{ margin:14px auto 0; font-size:clamp(15px, 2.4vw, 19px); color:var(--txt); font-weight:600; } .intro .tagline{ margin:13px auto 0; font-size:clamp(15px,2.4vw,19px); color:var(--txt); font-weight:600; }
.hero .pitch{ margin:12px auto 0; max-width:64ch; color:var(--muted); font-size:14.5px; line-height:1.6; } .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 14px; } .section-label{ text-align:center; font-size:11px; text-transform:uppercase; letter-spacing:.12em; color:var(--muted); margin:26px 0 12px; }
.grid{ display:grid; grid-template-columns:repeat(auto-fit, minmax(300px, 1fr)); gap:18px; align-items:start; } /* summary panes — click to load that version into the viewport */
.card{ background:var(--panel-bg); border:1px solid var(--panel-bd); border-radius:14px; padding:14px; .panes{ display:grid; grid-template-columns:repeat(auto-fit, minmax(225px, 1fr)); gap:12px; }
display:flex; flex-direction:column; gap:10px; } .pane{ text-align:left; background:var(--panel-bg); border:1px solid var(--panel-bd); border-radius:12px; padding:12px 13px;
.card.wide{ grid-column:1 / -1; } cursor:pointer; display:flex; flex-direction:column; gap:6px; transition:border-color .14s, box-shadow .14s; }
.card-head{ display:flex; align-items:center; gap:9px; flex-wrap:wrap; } .pane:hover{ border-color:var(--cyan); }
.card h3{ margin:0; font-size:16px; } .pane.active{ border-color:var(--cyan); box-shadow:0 0 0 1px var(--cyan) inset; }
.chip{ font-size:10px; text-transform:uppercase; letter-spacing:.08em; padding:2px 9px; border-radius:999px; .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); } border:1px solid var(--panel-bd); color:var(--muted); }
.chip.hw{ color:var(--cyan); border-color:rgba(10,179,247,.45); } .pane .chip.hw{ color:var(--cyan); border-color:rgba(10,179,247,.45); }
.chip.app{ color:#2fe07a; border-color:rgba(47,224,160,.45); } .pane .chip.app{ color:#2fe07a; border-color:rgba(47,224,160,.45); }
/* the embedded live widget sits in a recessed frame */ .pane p{ margin:0; font-size:12px; color:var(--muted); line-height:1.45; flex:1; }
.widget{ border:1px solid var(--panel-bd); border-radius:10px; overflow:hidden; background:radial-gradient(circle at 50% 0, rgba(255,255,255,.02), transparent); } .pane .open{ font-size:11px; color:var(--link); text-decoration:none; font-weight:600; align-self:flex-start; }
.widget [data-varasys-metronome], .widget iframe{ display:block; width:100%; }
.card p{ margin:0; font-size:13px; color:var(--muted); line-height:1.5; } /* viewport: the live, selected device */
.card .links{ display:flex; gap:16px; } .viewport{ margin-top:16px; border:1px solid var(--panel-bd); border-radius:14px; overflow:hidden; background:var(--field-bg); }
.card .links a{ color:var(--link); text-decoration:none; font-size:13px; font-weight:600; } .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 */
.philosophy{ margin-top:34px; } .philosophy{ margin-top:34px; }
.phil-grid{ display:grid; grid-template-columns:repeat(auto-fit, minmax(300px, 1fr)); gap:16px; } .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{ 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 h3{ margin:0 0 8px; font-size:15px; display:flex; align-items:center; gap:8px; }
.phil .ic{ font-size:17px; line-height:1; }
.phil p{ margin:0; font-size:13.5px; color:var(--muted); line-height:1.62; } .phil p{ margin:0; font-size:13.5px; color:var(--muted); line-height:1.62; }
.phil p b{ color:var(--txt); } .phil p b{ color:var(--txt); }
</style> </style>
@ -62,93 +80,46 @@
/*@BUILD:include:src/header.html@*/ /*@BUILD:include:src/header.html@*/
<main> <main>
<section class="hero"> <section class="intro">
<h1>PolyMeter</h1> <h1>PolyMeter</h1>
<p class="tagline">Polymetric grooves — one engine, many form factors.</p> <p class="tagline">Polymetric grooves — one engine, one program string, every form factor.</p>
<p class="pitch">Stack independent meter lanes, each with its own subdivision, drum voice and perstep <p>Stack independent meter lanes — each with its own subdivision, drum voice and perstep accents — to build
accents, to build true polymeter and ratio polyrhythm. Design it in the browser, save it as a compact true polymeter and ratio polyrhythm. Design a groove once; it saves to a compact <b>program string</b> that
program string, then run it on any device below — they're all the same engine. Each box is <b>live</b>: plays back identically on the web editor, the hardware concepts, or an embedded widget. The editor is open
press play and try it right here.</p> below — or pick any form factor to load and play the same groove on it.</p>
</section> </section>
<div class="section-label">The PolyMeter family — try each one live</div> <div class="section-label">Pick a form factor — it loads live below</div>
<div class="grid"> <div class="panes" id="panes"></div>
<div class="card wide"> <div class="viewport">
<div class="card-head"><span class="chip app">Web app</span><h3>PE1 — PolyMeter Editor</h3></div> <div class="vp-bar"><span id="vpName"><b>PE1 Editor</b></span><a id="vpOpen" href="/editor.html" target="_blank" rel="noopener">Open full page ↗</a></div>
<div class="widget"><div data-varasys-metronome="editor" data-patch="v1;t120;b16;kick:4=X..x;snare:4=.X.X;hatClosed:4/4;tom:3" data-height="560"></div></div> <iframe id="vp" title="PolyMeter — live viewport" allow="autoplay"></iframe>
<p>The full workbench: stack meter lanes, perstep accents / ghosts / mutes, swing &amp; ratio polyrhythm, </div>
set lists and shareable links. This is where you design grooves.</p>
<div class="links"><a href="/editor.html">Open ↗</a></div>
</div>
<div class="card">
<div class="card-head"><span class="chip hw">Hardware</span><h3>PM1 — Teacher</h3></div>
<div class="widget"><div data-varasys-metronome="teacher" data-patch="v1;t120;b16;kick:4=X.x.;snare:4=.X.X;hatClosed:4/4" data-height="440"></div></div>
<p>Fullfeature desktop console for studio &amp; lessons: colour TFT showing every lane, arcade buttons,
thumbroller, instrument passthrough with analog click.</p>
<div class="links"><a href="/teacher.html">Open ↗</a></div>
</div>
<div class="card">
<div class="card-head"><span class="chip hw">Hardware</span><h3>PM1 — Stage</h3></div>
<div class="widget"><div data-varasys-metronome="stage" data-patch="v1;t120;b16;kick:4=X.x.;snare:4=.X.X;hatClosed:4/4" data-height="430"></div></div>
<p>Footpedal stompbox: two footswitches (tap / next), expressionpedal input, a big floorreadable RGB
beat light, instrument passthrough. DualUSBC daisychain.</p>
<div class="links"><a href="/stage.html">Open ↗</a></div>
</div>
<div class="card">
<div class="card-head"><span class="chip hw">Hardware</span><h3>PMµ — Micro</h3></div>
<div class="widget"><div data-varasys-metronome="micro" data-patch="v1;t120;kick:4;snare:4=.X.X;hatClosed:4/2" data-height="240"></div></div>
<p>Long, narrow inline practice bar: instrument in one end, amp/headphones out the other, click mixed in.
Clickable thumbroller, amber 14segment display, USBC.</p>
<div class="links"><a href="/micro.html">Open ↗</a></div>
</div>
<div class="card">
<div class="card-head"><span class="chip hw">Hardware</span><h3>PMS — Showcase</h3></div>
<div class="widget"><div data-varasys-metronome="showcase" data-patch="v1;t108;kick:4=X..x;snare:4=.X.X;hatClosed:4/4;tom:3~" data-height="540"></div></div>
<p>A display piece shaped like a classic pyramid windup metronome — the pendulum is RGB light easing to
the beat, with light rows for every lane's subdivisions, accents &amp; mutes.</p>
<div class="links"><a href="/showcase.html">Open ↗</a></div>
</div>
<div class="card">
<div class="card-head"><span class="chip">Concept</span><h3>PM1 — Initial</h3></div>
<div class="widget"><div data-varasys-metronome="initial" data-patch="v1;t120;b16;kick:4=X.x.;snare:4=.X.X;hatClosed:4/4" data-height="440"></div></div>
<p>The original idealized device render — full multilane display and setlist navigation. The northstar
concept; the buildable take is the Teacher.</p>
<div class="links"><a href="/player.html">Open ↗</a></div>
</div>
<div class="card">
<div class="card-head"><span class="chip">Widget</span><h3>Embed anywhere</h3></div>
<p style="flex:1">Every form factor above is an embeddable widget — drop one into your own page with a single
<code>&lt;div&gt;</code> + a script, preloaded with a program string and autosizing. (Exactly how the
boxes on this page work.)</p>
<div class="links"><a href="/embed.html">Embed docs ↗</a></div>
</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> </div>
<section class="philosophy"> <section class="philosophy">
<div class="section-label">Philosophy</div> <div class="section-label">Philosophy</div>
<div class="phil-grid"> <div class="phil-grid">
<div class="phil"> <div class="phil">
<h3><span class="ic">🛠️</span> Program on the web, play on any device</h3> <h3>🛠️ Program on the web, play on any device</h3>
<p>The website is the workbench. Design your grooves in the <a href="/editor.html">PE1 editor</a> and <p>The website is the workbench: design in the <a href="/editor.html">editor</a>, and the same
every pattern saves to a compact <b>program string</b> (a whole set list to a single code). That same <b>program string</b> loads into whichever form factor fits the moment. One engine, one language.</p>
string loads into whichever form factor fits the moment — Teacher on a studio desk, Stage on a
pedalboard, Micro inline at the practice desk, or an <a href="/embed.html">embedded widget</a>. One
engine, one language: author once, run it anywhere.</p>
</div> </div>
<div class="phil"> <div class="phil">
<h3><span class="ic">🔌</span> USBC power everywhere — no batteries</h3> <h3>🔌 USBC power everywhere — no batteries</h3>
<p>Every device is powered over a single <b>USBC</b> port — no internal battery to wear out. Plug into a <p>Every device runs over a single <b>USBC</b> port (the larger ones add a passthrough to daisychain).
wall adapter for a permanent install or carry a power bank like you already do for your phone; the No internal battery to wear out; bring a power bank. One connector keeps it all <b>futureproof</b>.</p>
larger units add a second USBC <b>passthrough</b> so pedals daisychain off one source. One connector
across the range keeps builds simple and <b>futureproof</b>.</p>
</div> </div>
</div> </div>
</section> </section>
@ -158,8 +129,91 @@
<script> <script>
const APP_VERSION = "v0.0.1-dev"; 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:"PE1 Editor", chip:"app", h:620, sum:"Design grooves: stack meter lanes, perstep accents/ghosts/mutes, swing &amp; polyrhythm, set lists, perlane dB gain." },
{ key:"teacher", file:"/teacher.html", name:"PM1 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:"PM1 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µ Micro", chip:"hw", h:240, sum:"Inline practice bar — clickable thumbroller, amber 14segment, instrument in/out passthrough." },
{ key:"showcase", file:"/showcase.html",name:"PMS Showcase", chip:"hw", h:540, sum:"Pyramid display piece — an RGBlight pendulum combining every lane's subdivisions &amp; accents." },
{ key:"initial", file:"/player.html", name:"PM1 Initial", 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>
<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@*/ /*@BUILD:include:src/chrome.js@*/
</script> </script>
<script src="/embed.js"></script>
</body> </body>
</html> </html>