Add shareable meter language + share menu + demo set list

- Compact human-readable patch language encodes tempo, lanes (sound/grouping/
  subdivision/pattern/poly/mute) and practice settings (trainer, ramp).
- Share menu (⋯) copies links: settings as #p=<patch> (readable) and a whole
  set list as #sl=<base64url>. Opening such a link applies/imports it on load.
- Seeds a ' Demos' set list (once) with 10 examples authored in the language:
  grooves, 5:4 / 3:2 / 3-lane polyrhythms, 7/8 6/8 5/4, triplets, ramp, trainer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-05-24 17:27:26 -05:00
parent c0b6628488
commit 6910f31a2f

View file

@ -226,8 +226,10 @@
<button class="x" id="trayMenuBtn" title="log &amp; backup" style="margin-left:0"></button> <button class="x" id="trayMenuBtn" title="log &amp; backup" style="margin-left:0"></button>
<button class="x" id="routineClose" title="close" style="margin-left:0"></button> <button class="x" id="routineClose" title="close" style="margin-left:0"></button>
<div id="trayMenu" class="menu" hidden> <div id="trayMenu" class="menu" hidden>
<button id="exportBtn">⭳ Export all</button> <button id="shareSettingsBtn">🔗 Share settings link</button>
<button id="importBtn">⭱ Import…</button> <button id="shareSetlistBtn">🔗 Share set-list link</button>
<button id="exportBtn">⭳ Export all (file)</button>
<button id="importBtn">⭱ Import file…</button>
<input type="file" id="importFile" accept="application/json" style="display:none"> <input type="file" id="importFile" accept="application/json" style="display:none">
<button id="clearLogBtn">🗑 Clear log</button> <button id="clearLogBtn">🗑 Clear log</button>
</div> </div>
@ -589,7 +591,7 @@ function renderLaneStrip(m) {
/* ========================================================================= /* =========================================================================
PRESETS (localStorage) PRESETS (localStorage)
========================================================================= */ ========================================================================= */
const LS = { presets: "metronome.presets", setlists: "metronome.setlists", logs: "metronome.logs" }; const LS = { presets: "metronome.presets", setlists: "metronome.setlists", logs: "metronome.logs", seeded: "metronome.seeded" };
function lsGet(k, fb) { try { const v = localStorage.getItem(k); return v ? JSON.parse(v) : fb; } catch (e) { return fb; } } function lsGet(k, fb) { try { const v = localStorage.getItem(k); return v ? JSON.parse(v) : fb; } catch (e) { return fb; } }
function lsSet(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); return true; } catch (e) { console.warn("localStorage unavailable", e); return false; } } function lsSet(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); return true; } catch (e) { console.warn("localStorage unavailable", e); return false; } }
@ -772,6 +774,111 @@ function importAll(file) {
reader.readAsText(file); reader.readAsText(file);
} }
/* =========================================================================
SHARE LANGUAGE (compact, human-readable; encodes settings/set lists in URLs)
Patch: v1;t<bpm>;vol<pct>;<lane>;…[;tr<play>/<mute>][;rmp<start>/<step>/<every>]
Lane: <sound>:<grouping>[/<sub>][=<pattern x/.>][~ poly][! mute]
========================================================================= */
function laneCfgToStr(c) {
const bpb = parseGroups(c.groupsStr).beatsPerBar;
let s = c.sound + ":" + c.groupsStr;
if ((c.stepsPerBeat || 1) !== 1) s += "/" + c.stepsPerBeat;
const on = (c.beatsOn || []).slice(0, bpb);
if (on.length && !on.every(Boolean)) s += "=" + Array.from({ length: bpb }, (_, i) => (on[i] ? "x" : ".")).join("");
if (c.poly) s += "~";
if (c.mute) s += "!";
return s;
}
function laneStrToCfg(tok) {
let poly = false, mute = false;
while (/[~!]$/.test(tok)) { if (tok.endsWith("!")) mute = true; else poly = true; tok = tok.slice(0, -1); }
const ci = tok.indexOf(":"); if (ci < 0) return null;
let sound = tok.slice(0, ci), rest = tok.slice(ci + 1), pattern = null;
const eq = rest.indexOf("="); if (eq >= 0) { pattern = rest.slice(eq + 1); rest = rest.slice(0, eq); }
let groupsStr = rest, sub = 1; const sl = rest.indexOf("/");
if (sl >= 0) { groupsStr = rest.slice(0, sl); sub = parseInt(rest.slice(sl + 1), 10) || 1; }
const bpb = parseGroups(groupsStr).beatsPerBar;
const beatsOn = pattern ? pattern.split("").map((ch) => ch === "x" || ch === "X" || ch === "1")
: new Array(bpb).fill(true);
if (!DRUMS[sound]) sound = "beep";
return { groupsStr, stepsPerBeat: sub, sound, beatsOn, poly, mute };
}
function setupToPatch(s) {
const parts = ["v1", "t" + s.bpm];
if (s.volume != null) parts.push("vol" + Math.round(s.volume * 100));
(s.lanes || []).forEach((c) => parts.push(laneCfgToStr(c)));
if (s.trainer && s.trainer.on) parts.push("tr" + s.trainer.playBars + "/" + s.trainer.muteBars);
if (s.ramp && s.ramp.on) parts.push("rmp" + s.ramp.startBpm + "/" + s.ramp.amount + "/" + s.ramp.everyBars);
return parts.join(";");
}
function patchToSetup(str) {
const s = { bpm: 120, volume: null, lanes: [], trainer: { on: false, playBars: 2, muteBars: 2 }, ramp: { on: false, startBpm: 80, amount: 5, everyBars: 4 } };
for (let tok of String(str).split(";")) {
tok = tok.trim(); if (!tok || tok === "v1") continue;
if (tok.includes(":")) { const c = laneStrToCfg(tok); if (c) s.lanes.push(c); }
else if (tok.startsWith("vol")) s.volume = (parseInt(tok.slice(3), 10) || 0) / 100;
else if (tok.startsWith("tr")) { const [p, m] = tok.slice(2).split("/"); s.trainer = { on: true, playBars: +p || 1, muteBars: +m || 0 }; }
else if (tok.startsWith("rmp")) { const [a, b, c] = tok.slice(3).split("/"); s.ramp = { on: true, startBpm: +a || 80, amount: +b || 0, everyBars: +c || 1 }; }
else if (tok.startsWith("t")) s.bpm = parseInt(tok.slice(1), 10) || 120;
}
return s;
}
function currentPatch() { return setupToPatch({ bpm: state.bpm, volume: state.volume, lanes: snapshotLanes(), trainer, ramp }); }
function setVolume(pct) {
state.volume = Math.max(0, Math.min(1, pct / 100));
$("vol").value = Math.round(state.volume * 100); volVal.textContent = Math.round(state.volume * 100) + "%";
if (masterGain && audioCtx) masterGain.gain.setTargetAtTime(state.volume, audioCtx.currentTime, 0.01);
}
function applyPatch(str) { const s = patchToSetup(str); if (s.volume != null) setVolume(s.volume * 100); applySetup(s); }
// base64url(JSON) for set lists — safely carries free-text titles/names
function b64u(str) { return btoa(unescape(encodeURIComponent(str))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); }
function unb64u(s) { s = s.replace(/-/g, "+").replace(/_/g, "/"); return decodeURIComponent(escape(atob(s))); }
function setlistToCode(sl) { return b64u(JSON.stringify({ t: sl.title, d: sl.description, i: sl.items.map((it) => ({ n: it.name, p: setupToPatch(it) })) })); }
function codeToSetlist(code) {
const o = JSON.parse(unb64u(code));
return { title: o.t || "Shared set list", description: o.d || "", items: (o.i || []).map((x) => ({ name: x.n || "Item", ...patchToSetup(x.p) })) };
}
function shareLink(hashPart) { return location.origin + location.pathname + "#" + hashPart; }
async function copyShare(txt, label) {
try { await navigator.clipboard.writeText(txt); alert(label + " link copied:\n\n" + txt); }
catch (e) { prompt(label + " link (copy it):", txt); }
}
function shareSettings() { copyShare(shareLink("p=" + currentPatch()), "Settings"); }
function shareSetlist() { const sl = getSL(); if (!sl) return alert("No set list selected to share."); copyShare(shareLink("sl=" + setlistToCode(sl)), "Set list"); }
// Apply a shared link on load. Returns true if it set the metronome state.
function applyHashShare() {
const h = location.hash || "";
try {
if (h.startsWith("#p=")) { applyPatch(decodeURIComponent(h.slice(3))); history.replaceState(null, "", location.pathname); return true; }
if (h.startsWith("#sl=")) {
const sl = codeToSetlist(decodeURIComponent(h.slice(4)));
setlists.push(sl); activeSL = setlists.length - 1; saveSetlists(); renderSetlists();
if (sl.items[0]) applySetup(sl.items[0]);
history.replaceState(null, "", location.pathname);
alert("Imported set list: " + sl.title + " (" + sl.items.length + " items)");
return true;
}
} catch (e) { console.warn("ignored bad share link", e); }
return false;
}
// Demo set list (each item authored in the share language — also exercises the parser).
const DEMOS = [
["Four-on-the-floor", "t120;kick:4;snare:4=.x.x;hatClosed:4/2"],
["5 over 4 polyrhythm", "t100;kick:4;claves:5~"],
["3 over 2 hemiola", "t96;woodblock:2;cowbell:3~"],
["2 & 4 & 3 over one bar", "t100;kick:3;cowbell:2~;claves:4~"],
["7/8 (2+2+3)", "t130;kick:2+2+3=x..x..x;hatClosed:2+2+3/2"],
["6/8 groove", "t100;kick:3+3=x..x..;snare:3+3=...x..;hatClosed:3+3/2"],
["5/4 (3+2)", "t112;kick:3+2=x..x.;snare:3+2=..x..;hatClosed:3+2/2"],
["Triplet hats", "t100;kick:4;snare:4=.x.x;hatClosed:4/3"],
["Tempo builder 80↑", "t80;woodblock:4;rmp80/4/4"],
["Gap trainer (play 2 / rest 2)", "t100;kick:4;hatClosed:4/2;tr2/2"],
];
/* ========================================================================= /* =========================================================================
VISUALS VISUALS
========================================================================= */ ========================================================================= */
@ -873,6 +980,8 @@ $("exportBtn").addEventListener("click", () => { $("trayMenu").hidden = true; ex
$("importBtn").addEventListener("click", () => { $("trayMenu").hidden = true; $("importFile").click(); }); $("importBtn").addEventListener("click", () => { $("trayMenu").hidden = true; $("importFile").click(); });
$("importFile").addEventListener("change", (e) => { if (e.target.files[0]) importAll(e.target.files[0]); e.target.value = ""; }); $("importFile").addEventListener("change", (e) => { if (e.target.files[0]) importAll(e.target.files[0]); e.target.value = ""; });
$("clearLogBtn").addEventListener("click", () => { $("trayMenu").hidden = true; clearLog(); }); $("clearLogBtn").addEventListener("click", () => { $("trayMenu").hidden = true; clearLog(); });
$("shareSettingsBtn").addEventListener("click", () => { $("trayMenu").hidden = true; shareSettings(); });
$("shareSetlistBtn").addEventListener("click", () => { $("trayMenu").hidden = true; shareSetlist(); });
window.addEventListener("keydown", (e) => { window.addEventListener("keydown", (e) => {
const t = e.target; const t = e.target;
if (t && (t.tagName === "INPUT" || t.tagName === "SELECT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return; if (t && (t.tagName === "INPUT" || t.tagName === "SELECT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
@ -893,9 +1002,20 @@ window.addEventListener("keydown", (e) => {
} }
}); });
/* init: two example lanes (4/4 vs 3/4) to show polymeter immediately */ /* init */
addMeter("4", 1, "kick"); // reference bar // seed the demo set list once (first run only; not re-added if the user deletes it)
addMeter("5", 1, "claves", null, true); // 5 fit into the bar → true 5:4 polyrhythm if (!lsGet(LS.seeded, false)) {
if (!setlists.length) {
setlists.push({ title: "✨ Demos", description: "Tap ▶ on an item to hear it — meters, polyrhythms, odd time, subdivisions & practice tools.", items: DEMOS.map(([n, p]) => ({ name: n, ...patchToSetup(p) })) });
activeSL = 0; saveSetlists();
}
lsSet(LS.seeded, true);
}
// a shared link (#p=… settings / #sl=… set list) sets the state; otherwise default lanes
if (!applyHashShare()) {
addMeter("4", 1, "kick"); // reference bar
addMeter("5", 1, "claves", null, true); // 5 fit into the bar → true 5:4 polyrhythm
}
refreshPresetList(); refreshPresetList();
renderSetlists(); renderSetlists();
renderLog(); renderLog();