Add QR share dialog (vendored qrcode.js) + README documenting the language

- Share menu now opens a dialog with a copyable link AND a QR code (scan to open
  on a phone); generated locally by vendored qrcode-generator (MIT). deploy.sh
  publishes qrcode.js alongside index.html.
- README documents the share language grammar, sounds, URL forms, shortcuts,
  versioning and deploy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-05-24 17:34:26 -05:00
parent 6910f31a2f
commit 5320da4325
4 changed files with 2480 additions and 6 deletions

143
README.md Normal file
View file

@ -0,0 +1,143 @@
# Stackable Metronome
A browser **polymetric groove trainer / metronome** — and the design mockup for a
Raspberry Pi Pico hardware build. Stack as many "meter lanes" as you like; each is
its own little metronome with a grouping, subdivision, drum voice and per-beat
pattern. Layering lanes produces polymeter and true ratio polyrhythm.
**Live:** https://metronome.varasys.io
It's a single page (`index.html`) plus a vendored QR library — no build step,
no framework. State (presets, set lists, practice log, theme) lives in
`localStorage`.
## Features
- **Meter lanes** — grouping (odd meters), subdivision, GM drum voice, perbeat
on/off pattern (rests), mute, live measure counter.
- **Polyrhythm** — a perlane *poly* toggle fits a lane's beats evenly into lane 1's
bar (e.g. 5over4, 3over2).
- **Practice** — gap/mute trainer (play N / mute M bars) and a tempo ramp with a
start BPM and signed step.
- **Set lists** — named, ordered lists of saved setups; ▶ loads + starts an item,
**N** advances; each play is logged for crossday comparison.
- **Sharing** — copy a link (with QR) to your current settings or a whole set list.
- **Theming** — System / Light / Dark.
## The share language
A compact, humanreadable text encodes a full configuration (a *patch*). It's what
goes in a share link, and you can handwrite or edit it.
### Patch grammar
```
v1 ; t<bpm> [; vol<pct>] ; <lane> ; <lane> … [; tr<play>/<mute>] [; rmp<start>/<step>/<every>]
```
| Token | Meaning | Example |
|-------|---------|---------|
| `v1` | format version (always first) | `v1` |
| `t<bpm>` | tempo | `t120` |
| `vol<pct>` | master volume 0100 | `vol70` |
| `tr<play>/<mute>` | gap trainer: play N bars, mute M | `tr2/2` |
| `rmp<start>/<step>/<every>` | tempo ramp: start BPM, ±step, every N bars | `rmp80/5/4` |
| `<lane>` | a meter lane (see below) | `kick:4` |
Tokens are joined with `;`. `tr` and `rmp` are omitted when off.
### Lane grammar
```
<sound> : <grouping> [ / <sub> ] [ = <pattern> ] [ ~ ] [ ! ]
```
- **sound** — one of:
`beep`, `kick`, `snare`, `rim`, `clap`, `hatClosed`, `hatOpen`, `ride`, `crash`,
`tomLow`, `tomMid`, `tomHigh`, `tambourine`, `cowbell`, `woodblock`, `claves`,
`jamblock` (unknown → `beep`).
- **grouping** — beats per bar, optionally grouped for odd meters: `4`, `3`,
`2+2+3`. The first beat of each group is accented.
- **`/sub`** — subdivision (clicks per beat): `1` quarter (default), `2` eighth,
`3` triplet, `4` sixteenth, `6` sextuplet. Omit for quarter.
- **`=pattern`** — perbeat on/off as `x`/`.`, length = beats per bar. Omit = all on.
e.g. `=.x.x` puts a backbeat on 2 & 4.
- **`~`** — polyrhythm: fit this lane's beats evenly into **lane 1's** bar.
- **`!`** — mute the lane.
### Examples
| Patch / lane | What it is |
|---|---|
| `kick:4` | kick on 4 quarter beats |
| `snare:4=.x.x` | snare backbeat (2 & 4) |
| `hatClosed:4/2` | eighthnote hihats |
| `claves:5~` | 5 evenly across lane 1's bar (5over4 if lane 1 is `4`) |
| `kick:2+2+3=x..x..x` | 7/8, kick on each group start |
| `cowbell:3+2/2` | 5/4 grouped 3+2, eighth subdivision |
| **Full:** `v1;t120;kick:4;snare:4=.x.x;hatClosed:4/2;tr2/2` | backbeat groove with gap trainer |
### In URLs
- **Settings:** `…/#p=<patch>` — readable, e.g.
`…/#p=v1;t120;kick:4;claves:5~`
- **Set list:** `…/#sl=<base64url>` — a JSON `{title, description, items[]}` where
each item's config is a patch string. Used because titles/notes are free text.
Opening such a link applies the settings (or imports the set list) on load, then
clears the hash so a refresh won't reimport.
## Sharing & QR
In the setlist panel's **⋯** menu:
- **Share settings link** / **Share setlist link** open a dialog with the link and
a **QR code** (scan to open on a phone). Copy or Open from there.
- **Export all / Import file** back up presets + set lists + logs as a JSON file.
QR codes are generated locally by the vendored `qrcode.js`; the link never leaves
your browser. Very long setlist links may exceed QR capacity — copy those instead.
## Keyboard shortcuts
| Key | Action |
|-----|--------|
| `Space` | start / stop |
| `T` | tap tempo |
| `↑` / `↓` | tempo ±1 (`Shift` = ±10) |
| `A` | add meter lane |
| `1``9` | mute lane N |
| `R` | toggle the setlist panel |
| `N` | next setlist item |
| `?` | shortcuts help |
| `Esc` | close dialog / panel |
## Versioning
`VERSION` holds the formal version. `deploy.sh` stamps the served page:
- **Formal** — a clean commit tagged `v<VERSION>``X.Y.Z`.
- **Dev** — anything else → `X.Y.Z-dev.<utc-timestamp>.<short-sha>[.dirty]`.
Cut a release with `./release.sh [X.Y.Z]` (bumps `VERSION` + tags `v<VERSION>`),
then push the tag and deploy.
## Deploy
`./deploy.sh` copies `index.html` (versionstamped) and `qrcode.js` into the Caddy
web root and smoketests the live URL. No restart needed (`file_server` picks up
changes immediately).
## Files
| File | Purpose |
|------|---------|
| `index.html` | the whole app |
| `qrcode.js` | vendored QR generator (Kazuhiko Arase, MIT) |
| `deploy.sh` | publish to the Caddy web root |
| `release.sh` | tag a formal version |
| `VERSION` | formal version string |
## Credits
QR generation by [qrcode-generator](https://github.com/kazuhikoarase/qrcode-generator)
(© Kazuhiko Arase, MIT).

View file

@ -37,6 +37,9 @@ fi
sed "s|const APP_VERSION = \"[^\"]*\";|const APP_VERSION = \"$BUILD\";|" "$SRC_DIR/index.html" > "$DEST_DIR/index.html" sed "s|const APP_VERSION = \"[^\"]*\";|const APP_VERSION = \"$BUILD\";|" "$SRC_DIR/index.html" > "$DEST_DIR/index.html"
echo "deployed v$BUILD ($(stat -c '%s' "$DEST_DIR/index.html") bytes) -> $DEST_DIR" echo "deployed v$BUILD ($(stat -c '%s' "$DEST_DIR/index.html") bytes) -> $DEST_DIR"
# vendored assets served alongside index.html
[[ -f "$SRC_DIR/qrcode.js" ]] && cp "$SRC_DIR/qrcode.js" "$DEST_DIR/qrcode.js" && echo "deployed qrcode.js"
# If real audio samples are added later (see the plan's GM-sample note), # If real audio samples are added later (see the plan's GM-sample note),
# sync that directory too. # sync that directory too.
if [[ -d "$SRC_DIR/samples" ]]; then if [[ -d "$SRC_DIR/samples" ]]; then

View file

@ -138,6 +138,9 @@
.overlay { position:fixed; inset:0; background:rgba(0,0,0,.55); display:flex; align-items:center; justify-content:center; z-index:80; } .overlay { position:fixed; inset:0; background:rgba(0,0,0,.55); display:flex; align-items:center; justify-content:center; z-index:80; }
.overlay[hidden] { display:none; } .overlay[hidden] { display:none; }
.overlay-box { background:var(--panel-2); border:1px solid var(--edge); border-radius:14px; padding:16px 20px; width:380px; max-width:92vw; box-shadow:0 20px 60px rgba(0,0,0,.6); } .overlay-box { background:var(--panel-2); border:1px solid var(--edge); border-radius:14px; padding:16px 20px; width:380px; max-width:92vw; box-shadow:0 20px 60px rgba(0,0,0,.6); }
.qr { background:#fff; border-radius:8px; padding:10px; text-align:center; margin-bottom:12px; }
.qr img { display:block; margin:0 auto; image-rendering:pixelated; max-width:100%; }
#shareUrl { width:100%; resize:vertical; background:var(--panel); color:var(--txt); border:1px solid var(--edge); border-radius:8px; padding:8px; font-family:"Courier New",monospace; font-size:12px; }
.kbd-table { width:100%; border-collapse:collapse; font-size:13px; } .kbd-table { width:100%; border-collapse:collapse; font-size:13px; }
.kbd-table td { padding:6px 4px; border-bottom:1px solid var(--edge); } .kbd-table td { padding:6px 4px; border-bottom:1px solid var(--edge); }
.kbd-table tr:last-child td { border-bottom:none; } .kbd-table tr:last-child td { border-bottom:none; }
@ -275,6 +278,18 @@
</div> </div>
</div> </div>
<!-- Share dialog: copyable link + QR code -->
<div id="shareOverlay" class="overlay" hidden>
<div class="overlay-box">
<div class="tray-head"><h2 id="shareTitle" style="margin:0">Share</h2><button class="x" id="shareClose" title="close" style="margin-left:0"></button></div>
<div id="shareQr" class="qr"></div>
<textarea id="shareUrl" readonly rows="3"></textarea>
<div class="btnrow" style="margin-top:8px"><button id="shareCopy">Copy link</button><button id="shareOpen">Open ↗</button></div>
<div class="hint" id="shareNote"></div>
</div>
</div>
<script src="qrcode.js"></script>
<script> <script>
"use strict"; "use strict";
@ -841,12 +856,24 @@ function codeToSetlist(code) {
} }
function shareLink(hashPart) { return location.origin + location.pathname + "#" + hashPart; } function shareLink(hashPart) { return location.origin + location.pathname + "#" + hashPart; }
async function copyShare(txt, label) { function renderQR(el, text) {
try { await navigator.clipboard.writeText(txt); alert(label + " link copied:\n\n" + txt); } el.innerHTML = "";
catch (e) { prompt(label + " link (copy it):", txt); } if (typeof qrcode !== "function") { el.textContent = "(QR library not loaded)"; return; }
try { const qr = qrcode(0, "M"); qr.addData(text); qr.make(); el.innerHTML = qr.createImgTag(4, 10); }
catch (e) { el.textContent = "Link too long to fit a QR — use Copy."; }
}
function openShare(title, url, note) {
$("shareTitle").textContent = title;
$("shareUrl").value = url;
$("shareNote").textContent = note || "";
renderQR($("shareQr"), url);
$("shareOverlay").hidden = false;
}
function shareSettings() { openShare("Share settings", shareLink("p=" + currentPatch()), "Encodes tempo, lanes & practice settings. Scan the QR or copy the link."); }
function shareSetlist() {
const sl = getSL(); if (!sl) return alert("No set list selected to share.");
openShare("Share “" + (sl.title || "set list") + "”", shareLink("sl=" + setlistToCode(sl)), "Whole set list. Long links may not scan well — use Copy.");
} }
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. // Apply a shared link on load. Returns true if it set the metronome state.
function applyHashShare() { function applyHashShare() {
@ -982,6 +1009,10 @@ $("importFile").addEventListener("change", (e) => { if (e.target.files[0]) impor
$("clearLogBtn").addEventListener("click", () => { $("trayMenu").hidden = true; clearLog(); }); $("clearLogBtn").addEventListener("click", () => { $("trayMenu").hidden = true; clearLog(); });
$("shareSettingsBtn").addEventListener("click", () => { $("trayMenu").hidden = true; shareSettings(); }); $("shareSettingsBtn").addEventListener("click", () => { $("trayMenu").hidden = true; shareSettings(); });
$("shareSetlistBtn").addEventListener("click", () => { $("trayMenu").hidden = true; shareSetlist(); }); $("shareSetlistBtn").addEventListener("click", () => { $("trayMenu").hidden = true; shareSetlist(); });
$("shareClose").addEventListener("click", () => $("shareOverlay").hidden = true);
$("shareOverlay").addEventListener("click", (e) => { if (e.target.id === "shareOverlay") $("shareOverlay").hidden = true; });
$("shareCopy").addEventListener("click", async () => { try { await navigator.clipboard.writeText($("shareUrl").value); const b = $("shareCopy"); b.textContent = "Copied!"; setTimeout(() => b.textContent = "Copy link", 1200); } catch (e) { $("shareUrl").select(); } });
$("shareOpen").addEventListener("click", () => window.open($("shareUrl").value, "_blank"));
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;
@ -995,7 +1026,7 @@ window.addEventListener("keydown", (e) => {
else if (k === "r" || k === "R") { $("routineTray").classList.toggle("open"); } else if (k === "r" || k === "R") { $("routineTray").classList.toggle("open"); }
else if (k === "n" || k === "N") { nextItem(); } else if (k === "n" || k === "N") { nextItem(); }
else if (k === "?") { toggleShortcuts(true); } else if (k === "?") { toggleShortcuts(true); }
else if (k === "Escape") { if (!$("shortcutsOverlay").hidden) toggleShortcuts(false); else $("routineTray").classList.remove("open"); } else if (k === "Escape") { if (!$("shareOverlay").hidden) $("shareOverlay").hidden = true; else if (!$("shortcutsOverlay").hidden) toggleShortcuts(false); else $("routineTray").classList.remove("open"); }
else if (k >= "1" && k <= "9") { else if (k >= "1" && k <= "9") {
const m = meters[+k - 1]; const m = meters[+k - 1];
if (m) { m.mute = !m.mute; const cb = m.el.querySelector(`#m${m.id}_mute`); if (cb) cb.checked = m.mute; } if (m) { m.mute = !m.mute; const cb = m.el.querySelector(`#m${m.id}_mute`); if (cb) cb.checked = m.mute; }

2297
qrcode.js Normal file

File diff suppressed because it is too large Load diff