From 6910f31a2f92ac77bd7571b04191d1f1858b88a1 Mon Sep 17 00:00:00 2001 From: Me Here Date: Sun, 24 May 2026 17:27:26 -0500 Subject: [PATCH] Add shareable meter language + share menu + demo set list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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= (readable) and a whole set list as #sl=. 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) --- index.html | 132 ++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 126 insertions(+), 6 deletions(-) diff --git a/index.html b/index.html index 73b5a8a..9a36e0e 100644 --- a/index.html +++ b/index.html @@ -226,8 +226,10 @@ @@ -589,7 +591,7 @@ function renderLaneStrip(m) { /* ========================================================================= 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 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); } +/* ========================================================================= + SHARE LANGUAGE (compact, human-readable; encodes settings/set lists in URLs) + Patch: v1;t;vol;;…[;tr/][;rmp//] + Lane: :[/][=][~ 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 ========================================================================= */ @@ -873,6 +980,8 @@ $("exportBtn").addEventListener("click", () => { $("trayMenu").hidden = true; ex $("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 = ""; }); $("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) => { const t = e.target; 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 */ -addMeter("4", 1, "kick"); // reference bar -addMeter("5", 1, "claves", null, true); // 5 fit into the bar → true 5:4 polyrhythm +/* init */ +// seed the demo set list once (first run only; not re-added if the user deletes it) +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(); renderSetlists(); renderLog();