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:
parent
c0b6628488
commit
6910f31a2f
1 changed files with 126 additions and 6 deletions
132
index.html
132
index.html
|
|
@ -226,8 +226,10 @@
|
|||
<button class="x" id="trayMenuBtn" title="log & backup" style="margin-left:0">⋯</button>
|
||||
<button class="x" id="routineClose" title="close" style="margin-left:0">✕</button>
|
||||
<div id="trayMenu" class="menu" hidden>
|
||||
<button id="exportBtn">⭳ Export all</button>
|
||||
<button id="importBtn">⭱ Import…</button>
|
||||
<button id="shareSettingsBtn">🔗 Share settings link</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">
|
||||
<button id="clearLogBtn">🗑 Clear log</button>
|
||||
</div>
|
||||
|
|
@ -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<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
|
||||
========================================================================= */
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue