Compare commits

...

9 commits

Author SHA1 Message Date
Me Here
72147f5b32 Countdown: keep counting into negative (overtime) instead of stopping at 0
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 18:12:16 -05:00
Me Here
a447df39e9 UX: always-show set list (remove hide toggle), add practice timers, '+' add button
- Removed the set-list show/hide toggle and the R shortcut / Esc-close / close ✕.
  The panel is always visible: sticky side column on desktop, stacked below the
  metronome on mobile. Theme/help buttons stay right-justified.
- Added practice timers in the gap/ramp area: an Elapsed (count-up) timer and an
  adjustable Countdown (minutes; 0 = off), each with a reset. Both advance only
  while the metronome runs; countdown reaching 0 stops it (turns amber under 10s).
- '+ Add meter' button is now just '+'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 18:10:40 -05:00
Me Here
7010ba7cb8 Move live status into the display under the BPM (drop bottom status line)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 18:01:55 -05:00
Me Here
3f04383e2b UX: select-to-load set-list items; now-playing info block; drop preset dropdown
- Set-list rows are now click-to-load (the transport is the only play/stop); the
  per-item ▶/⏹ and ↑/↓ buttons are gone. Reorder via Alt+↑/↓ (tooltip + help).
  Selected row is highlighted and reveals compact 💾 (save-back) / ✕ (remove).
- Replaced the main-screen preset dropdown with a 'Now loaded' info block showing
  the item name, config summary, and the set list's title + description.
- Presets consolidated into set-list items (removed preset functions/UI). N loads
  the next item; shortcuts help + demo description updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 17:55:58 -05:00
Me Here
7207ffe1c7 Add 'Reset everything' to the set-list menu (confirm + wipe localStorage)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 17:38:02 -05:00
Me Here
5320da4325 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>
2026-05-24 17:34:26 -05:00
Me Here
6910f31a2f 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>
2026-05-24 17:27:26 -05:00
Me Here
c0b6628488 Layout: dock the set-list panel beside the metronome (push, not overlay)
- App is now a flex shell (#app): the set-list panel docks as a sticky side
  column that pushes the metronome instead of floating over it.
- Collapses via the header 'Set Lists' toggle; falls back to an overlay drawer
  below 820px. Defaults open on desktop, closed on mobile.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 17:17:44 -05:00
Me Here
c087f11637 Theme: add System (OS-follow) option; fix light-theme playhead ring (was white-on-white)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 17:10:54 -05:00
4 changed files with 2786 additions and 126 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"
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),
# sync that directory too.
if [[ -d "$SRC_DIR/samples" ]]; then

View file

@ -5,13 +5,14 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Stackable Metronome — Mockup</title>
<script>
// Set theme before first paint (avoids a flash). Stored choice wins; else
// follow the OS preference.
// Set theme before first paint (avoids a flash). Preference is system|light|dark
// (default system → follows the OS); "system" resolves to the OS scheme here.
(function () {
try {
var t = localStorage.getItem("metronome.theme");
if (t !== "light" && t !== "dark") t = matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
document.documentElement.dataset.theme = t;
var p = localStorage.getItem("metronome.theme");
if (p !== "light" && p !== "dark" && p !== "system") p = "system";
var eff = p === "system" ? (matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark") : p;
document.documentElement.dataset.theme = eff;
} catch (e) { document.documentElement.dataset.theme = "dark"; }
})();
</script>
@ -30,11 +31,11 @@
<style>
:root {
--bg:#14171c; --bg2:#1b212b; --panel:#1d222b; --panel-2:#242b36; --edge:#333d4b;
--txt:#c7d0db; --muted:#7f8b9a; --hot:#ffd166; --led-off:#2b323d;
--txt:#c7d0db; --muted:#7f8b9a; --hot:#ffd166; --led-off:#2b323d; --ring:#ffffff;
}
:root[data-theme="light"] {
--bg:#e9edf2; --bg2:#f6f8fb; --panel:#ffffff; --panel-2:#eef2f7; --edge:#d2dae4;
--txt:#1e2630; --muted:#5c6776; --hot:#a9760a; --led-off:#dbe2ea;
--txt:#1e2630; --muted:#5c6776; --hot:#a9760a; --led-off:#cdd6e0; --ring:#16202c;
}
* { box-sizing: border-box; }
body {
@ -46,14 +47,16 @@
h1 { font-size:18px; font-weight:600; letter-spacing:.5px; margin:0 0 2px; }
.sub { color:var(--muted); font-size:12px; margin-bottom:18px; }
.kbd-legend { color:var(--muted); font-size:11px; font-family:"Courier New",monospace; text-align:right; }
.device { max-width:1000px; margin:0 auto; background:linear-gradient(180deg, var(--panel), var(--bg));
#app { display:flex; gap:18px; max-width:1400px; margin:0 auto; align-items:flex-start; justify-content:center; }
.device { flex:1 1 auto; min-width:0; max-width:1000px; background:linear-gradient(180deg, var(--panel), var(--bg));
border:1px solid var(--edge); border-radius:16px; padding:18px; box-shadow:0 18px 50px rgba(0,0,0,.5); }
.row { display:flex; gap:18px; flex-wrap:wrap; }
.card { background:var(--panel-2); border:1px solid var(--edge); border-radius:12px; padding:13px; flex:1; min-width:240px; }
.card h2 { font-size:11px; text-transform:uppercase; letter-spacing:1.4px; color:var(--muted); margin:0 0 14px; }
.display { background:#0a0d11; border:1px solid #000; border-radius:8px; padding:8px 14px; text-align:center; box-shadow:inset 0 2px 10px rgba(0,0,0,.7); }
.display .big { font-family:"Courier New",monospace; font-weight:700; font-size:40px; color:#ffd166; letter-spacing:3px; text-shadow:0 0 12px rgba(255,209,102,.5); }
.display .ctx { font-family:"Courier New",monospace; font-size:12px; color:#4dd0e1; height:15px; }
.display .ctx { font-family:"Courier New",monospace; font-size:12px; color:#4dd0e1; min-height:15px; line-height:1.3; }
.display .ctx.muted-cue { color:#ffb454; }
.knob { margin-bottom:10px; }
.knob label { display:flex; justify-content:space-between; font-size:12px; margin-bottom:5px; }
.knob label b { color:#fff; font-variant-numeric:tabular-nums; }
@ -77,7 +80,7 @@
.led.on { background:var(--lc,#888); box-shadow:0 0 8px var(--lc); color:rgba(0,0,0,.55); }
.led.accent { box-shadow:0 0 4px #fff, 0 0 14px var(--lc); }
.led.accent::after { content:"▲"; position:absolute; top:-1px; font-size:7px; color:#fff; }
.led.playhead { outline:2px solid #fff; outline-offset:1px; }
.led.playhead { outline:2px solid var(--ring); outline-offset:1px; }
.led.groupstart { margin-left:16px; }
.led.groupstart::before { content:""; position:absolute; left:-9px; top:4px; bottom:4px; width:2px; background:var(--muted); }
/* meter lanes — compact single-row controls + strip */
@ -95,17 +98,35 @@
.x:hover { color:#ff9a8a; border-color:#c0392b; }
.hint { font-size:11px; color:var(--muted); margin-top:8px; }
code { background:#0d1014; padding:1px 5px; border-radius:4px; color:#cfe3ff; }
.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); }
.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.active { border-color:#2e7d32; box-shadow:inset 3px 0 0 #2e7d32; }
.ex-item .nm { flex:1; }
.ex-item .meta { color:var(--muted); font-family:"Courier New",monospace; font-size:11px; }
.ex-item .row-actions { display:none; gap:4px; }
.ex-item.active .row-actions, .ex-item:hover .row-actions { display:inline-flex; }
.nowplaying { background:var(--panel); border:1px solid var(--edge); border-radius:10px; padding:10px 12px; margin-bottom:12px; }
.np-label { font-size:10px; letter-spacing:1.4px; color:var(--muted); text-transform:uppercase; }
.np-name { font-size:16px; font-weight:600; margin:2px 0; }
.np-sub { font-size:12px; color:var(--muted); font-family:"Courier New",monospace; word-break:break-word; }
.np-desc { font-size:12px; color:var(--muted); margin-top:4px; }
.iconbtn { padding:3px 8px; font-size:12px; }
.log-item { padding:8px 10px; border:1px solid var(--edge); border-radius:8px; margin-bottom:6px; background:var(--panel); }
.log-head { font-weight:600; font-size:13px; margin-bottom:3px; }
.log-seg { font-size:12px; color:var(--muted); margin:2px 0 0 12px; font-family:"Courier New",monospace; }
.practice { border-top:1px solid var(--edge); margin-top:16px; padding-top:4px; }
#routineToggle { position:fixed; top:16px; right:16px; z-index:40; background:#2c3a4d; border-color:#3b5168; color:#cfe3ff; font-weight:600; }
#routineTray { position:fixed; top:0; right:0; height:100%; width:380px; max-width:92vw; background:linear-gradient(180deg, var(--panel), var(--bg)); border-left:1px solid var(--edge); box-shadow:-12px 0 40px rgba(0,0,0,.45); transform:translateX(102%); transition:transform .25s ease; z-index:60; padding:18px; overflow:auto; }
#routineTray.open { transform:translateX(0); }
/* set-list panel: always shown — sticky beside the metronome on desktop,
stacks below it on narrow screens */
#routineTray { flex:0 0 340px; align-self:flex-start; position:sticky; top:18px;
max-height:calc(100vh - 36px); overflow:auto; background:linear-gradient(180deg, var(--panel), var(--bg));
border:1px solid var(--edge); border-radius:14px; padding:16px; box-shadow:0 10px 30px rgba(0,0,0,.25); }
.tval { font-family:"Courier New",monospace; font-size:13px; color:var(--hot); min-width:42px; }
.tval.low { color:#ffb454; }
.tval.over { color:#ff7b6b; }
@media (max-width: 820px) {
#app { display:block; }
#routineTray { position:static; max-height:none; width:auto; margin-top:18px; }
}
.tray-head { display:flex; align-items:center; justify-content:space-between; margin-bottom:14px; }
.practice-col { border-left:1px solid var(--edge); padding-left:18px; }
#themeBtn, #helpBtn { padding:4px 11px; }
@ -127,6 +148,9 @@
.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-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 td { padding:6px 4px; border-bottom:1px solid var(--edge); }
.kbd-table tr:last-child td { border-bottom:none; }
@ -141,11 +165,12 @@
</style>
</head>
<body>
<div id="app">
<div class="device">
<div class="row" style="align-items:baseline; justify-content:space-between; gap:14px; margin-bottom:12px">
<h1 style="margin:0">Stackable Metronome <span class="lane-meta" id="appVersion" title="build version">v0.0.1-dev</span></h1>
<div style="display:flex; align-items:center; gap:10px">
<span class="kbd-legend">Space play · T tap · ↑↓ tempo (⇧×10) · A add · R set&nbsp;lists · N next · ? help</span>
<span class="kbd-legend">Space play · T tap · ↑↓ tempo (⇧×10) · A add · N next · ? help</span>
<button id="themeBtn" title="toggle light / dark theme"></button>
<button id="helpBtn" title="keyboard shortcuts (?)">?</button>
</div>
@ -164,12 +189,11 @@
</div>
<div style="flex:1; min-width:200px">
<div class="knob" style="margin-bottom:10px">
<label>Preset</label>
<div style="display:flex; gap:6px">
<select class="cmp" id="presetSelect" style="flex:1"></select>
<button class="x" id="delPresetBtn" title="delete selected preset" style="margin-left:0"></button>
</div>
<div class="nowplaying">
<div class="np-label">Now loaded</div>
<div class="np-name" id="npName">Free play</div>
<div class="np-sub" id="npSub"></div>
<div class="np-desc" id="npDesc"></div>
</div>
<div class="knob"><label>Tempo (BPM) <b id="bpmVal">120</b></label><input type="range" id="bpm" min="30" max="300" value="120"></div>
<div class="knob" style="margin-bottom:0"><label>Master Volume <b id="volVal">70%</b></label><input type="range" id="vol" min="0" max="100" value="70"></div>
@ -187,6 +211,17 @@
<label style="font-size:12px" title="negative ramps down, positive ramps up"><input type="number" class="num" id="rampAmt" min="-30" max="30" value="5"> BPM</label>
<label style="font-size:12px">every <input type="number" class="num" id="rampEvery" min="1" max="16" value="4"> bars</label>
</div>
<div class="checkrow" style="margin:12px 0 6px; gap:8px"><b style="font-size:11px; text-transform:uppercase; letter-spacing:1px; color:var(--muted)">Timers</b><span class="hint" style="margin:0">run while playing</span></div>
<div class="row" style="gap:10px; align-items:center">
<label style="font-size:12px">Elapsed</label>
<span class="tval" id="elapsedVal">0:00</span>
<button class="iconbtn" id="elapsedReset" title="reset elapsed timer"></button>
</div>
<div class="row" style="gap:10px; align-items:center; margin-top:6px">
<label style="font-size:12px" title="0 = no countdown">Countdown <input type="number" class="num" id="countMin" min="0" max="120" value="5"> min</label>
<span class="tval" id="countVal">5:00</span>
<button class="iconbtn" id="countReset" title="reset countdown"></button>
</div>
</div>
</div>
</div>
@ -197,27 +232,26 @@
<span class="hint" style="margin:0; flex:1">Click a beat pad to toggle it (rest) — e.g. snare on 2 &amp; 4</span>
</div>
<div id="meters"></div>
<div style="margin-top:10px"><button class="add" id="addMeterBtn">+ Add meter</button></div>
<div style="margin-top:10px"><button class="add" id="addMeterBtn" title="add meter lane (A)">+</button></div>
<!-- (presets moved into the Transport card; set lists live in the slide-out tray) -->
<div style="height:14px"></div>
<div class="sub" id="statusLine">Stopped.</div>
<!-- (presets moved into the Transport card; set lists live in the slide-out tray;
status now shows under the BPM in the display) -->
</div>
<!-- Routine slide-out tray (from the right) -->
<button id="routineToggle">☰ Routine &amp; Log</button>
<aside id="routineTray">
<!-- Set-list panel: docked beside the metronome; drawer on narrow screens -->
<aside id="routineTray">
<div class="tray-head">
<h2 style="margin:0">Set Lists</h2>
<div style="display:flex; gap:6px; position:relative">
<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>
<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>
<button id="resetAllBtn" style="color:#ff7b6b">♻ Reset everything…</button>
</div>
</div>
</div>
@ -241,6 +275,7 @@
<div id="logView" style="margin-top:18px"></div>
</aside>
</div><!-- /#app -->
<div id="shortcutsOverlay" class="overlay" hidden>
<div class="overlay-box">
@ -251,8 +286,8 @@
<tr><td><kbd></kbd> <kbd></kbd></td><td>Tempo ±1 BPM</td></tr>
<tr><td><kbd>⇧↑</kbd> <kbd>⇧↓</kbd></td><td>Tempo ±10 BPM</td></tr>
<tr><td><kbd>A</kbd></td><td>Add meter lane</td></tr>
<tr><td><kbd>R</kbd></td><td>Set lists tray</td></tr>
<tr><td><kbd>N</kbd></td><td>Next set-list item</td></tr>
<tr><td><kbd>N</kbd></td><td>Load next set-list item</td></tr>
<tr><td><kbd>⌥↑</kbd> <kbd>⌥↓</kbd></td><td>Reorder selected item</td></tr>
<tr><td><kbd>1</kbd><kbd>9</kbd></td><td>Mute lane 19</td></tr>
<tr><td><kbd>?</kbd></td><td>This help</td></tr>
<tr><td><kbd>Esc</kbd></td><td>Close tray / help</td></tr>
@ -260,6 +295,18 @@
</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>
"use strict";
@ -576,7 +623,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; } }
@ -590,24 +637,7 @@ function applyLanes(lanes) {
const cb = m.el.querySelector(`#m${m.id}_mute`); if (cb) cb.checked = m.mute;
}
}
function savePreset(name) {
const all = lsGet(LS.presets, {});
all[name] = currentSetup();
lsSet(LS.presets, all); refreshPresetList(name);
}
function loadPreset(name) {
const all = lsGet(LS.presets, {}); const p = all[name]; if (!p) return;
const wasRunning = state.running; if (wasRunning) stop();
applySetup(p);
if (wasRunning) start();
}
function deletePreset(name) { const all = lsGet(LS.presets, {}); delete all[name]; lsSet(LS.presets, all); refreshPresetList(); }
function refreshPresetList(sel) {
const all = lsGet(LS.presets, {}); const list = $("presetSelect");
list.innerHTML = '<option value="">— preset —</option><option value="__save__"> Save current as…</option>';
Object.keys(all).sort().forEach((n) => { const o = document.createElement("option"); o.value = n; o.textContent = n; list.appendChild(o); });
list.value = sel || "";
}
// (Presets removed — set-list items are now the single "saved setup" mechanism.)
/* =========================================================================
SET LISTS + PRACTICE LOG
@ -616,10 +646,10 @@ function refreshPresetList(sel) {
Each played item is logged (timestamp, name, duration, BPM, conditions).
========================================================================= */
let setlists = lsGet(LS.setlists, []);
let activeSL = 0; // index of the selected set list
let playingItem = -1; // index of the item currently playing in the active set list
let activeSL = 0; // selected set list
let activeItem = -1; // selected / loaded item in the active set list
let nowPlaying = null; // { at, name } for duration logging
let historyName = null; // name of the item whose past-session history is shown
let historyName = null; // item whose past-session history is shown
function currentSetup() { return { bpm: state.bpm, lanes: snapshotLanes(), trainer: { ...trainer }, ramp: { ...ramp } }; }
function applySetup(s) {
@ -639,44 +669,66 @@ function saveSetlists() { lsSet(LS.setlists, setlists); }
// --- set list CRUD ---
function newSetlist() {
setlists.push({ title: "Set list " + (setlists.length + 1), description: "", items: [] });
activeSL = setlists.length - 1; playingItem = -1; saveSetlists(); renderSetlists();
activeSL = setlists.length - 1; activeItem = -1; saveSetlists(); renderSetlists();
}
function deleteSetlist() {
if (!setlists.length || !confirm("Delete this set list?")) return;
setlists.splice(activeSL, 1); activeSL = Math.max(0, activeSL - 1); playingItem = -1; saveSetlists(); renderSetlists();
setlists.splice(activeSL, 1); activeSL = Math.max(0, activeSL - 1); activeItem = -1; saveSetlists(); renderSetlists();
}
function addItem(name) {
const sl = getSL(); if (!sl) return;
sl.items.push({ name: name || ("Item " + (sl.items.length + 1)), ...currentSetup() });
activeItem = sl.items.length - 1; saveSetlists(); renderItems();
}
function removeItem(i) {
const sl = getSL(); if (!sl) return;
sl.items.splice(i, 1);
if (activeItem === i) activeItem = -1; else if (activeItem > i) activeItem--;
saveSetlists(); renderItems();
}
function removeItem(i) { const sl = getSL(); sl.items.splice(i, 1); if (playingItem >= sl.items.length) playingItem = -1; saveSetlists(); renderItems(); }
function moveItem(i, d) { const sl = getSL(); const j = i + d; if (j < 0 || j >= sl.items.length) return; [sl.items[i], sl.items[j]] = [sl.items[j], sl.items[i]]; saveSetlists(); renderItems(); }
function moveItem(i, d) { const sl = getSL(); const j = i + d; if (j < 0 || j >= sl.items.length) return; [sl.items[i], sl.items[j]] = [sl.items[j], sl.items[i]]; saveSetlists(); }
function moveActiveItem(d) { // keyboard reorder of the selected item (Alt+↑/↓)
const sl = getSL(); if (!sl || activeItem < 0) return;
const j = activeItem + d; if (j < 0 || j >= sl.items.length) return;
moveItem(activeItem, d); activeItem = j; renderItems();
}
// --- play / advance ---
function playItem(i) {
// --- select / advance: clicking an item LOADS it; the transport is the only play/stop ---
function loadItem(i) {
const sl = getSL(); if (!sl || !sl.items[i]) return;
logFinalize(); // close out the previously-playing item
const wasRunning = state.running;
if (wasRunning) logFinalize(); // close out the previous segment
applySetup(sl.items[i]);
if (state.running) stop(); // clean restart so ramp start-BPM + bar counter reset
start();
playingItem = i;
historyName = sl.items[i].name;
nowPlaying = { at: Date.now(), name: sl.items[i].name };
activeItem = i; historyName = sl.items[i].name;
if (wasRunning) { stop(); start(); nowPlaying = { at: Date.now(), name: sl.items[i].name }; } // keep playing the new item
renderItems(); renderLog();
}
function nextItem() { const sl = getSL(); if (sl && playingItem + 1 < sl.items.length) playItem(playingItem + 1); }
function updateItem(i) { // load → adjust → save back to the item
function nextItem() { const sl = getSL(); if (sl && activeItem + 1 < sl.items.length) loadItem(activeItem + 1); }
function updateItem(i) { // overwrite item with current settings (keeps its name)
const sl = getSL(); if (!sl || !sl.items[i]) return;
const nm = sl.items[i].name;
sl.items[i] = { name: nm, ...currentSetup() };
sl.items[i] = { name: sl.items[i].name, ...currentSetup() };
saveSetlists(); renderItems();
}
// Start/stop button + Space route through here so internal restarts don't log.
// Start/stop go through here so internal restarts don't create stray log entries.
function toggleTransport() {
if (state.running) { logFinalize(); playingItem = -1; renderItems(); stop(); }
else start();
if (state.running) { logFinalize(); stop(); }
else { start(); const sl = getSL(); if (activeItem >= 0 && sl && sl.items[activeItem]) nowPlaying = { at: Date.now(), name: sl.items[activeItem].name }; }
renderItems();
}
// --- now-playing info on the main screen (replaces the old preset dropdown) ---
function renderNowPlaying() {
const sl = getSL(); const it = (sl && activeItem >= 0) ? sl.items[activeItem] : null;
if (!it) {
$("npName").textContent = "Free play";
$("npSub").textContent = "No set-list item loaded — edit the lanes freely.";
$("npDesc").textContent = (sl && sl.description) ? "“" + sl.title + "” — " + sl.description : "";
return;
}
$("npName").textContent = (activeItem + 1) + ". " + it.name;
$("npSub").textContent = it.bpm + " BPM · " + it.lanes.map((l) => l.sound + " " + l.groupsStr + (l.poly ? "~" : "") + (l.mute ? " (muted)" : "")).join(" · ");
$("npDesc").textContent = (sl.title || "") + (sl.description ? " — " + sl.description : "");
}
// --- render ---
@ -693,26 +745,24 @@ function renderSetlists() {
}
function renderItems() {
const box = $("itemList"); box.innerHTML = ""; const sl = getSL();
if (!sl) { box.innerHTML = '<div class="hint">Create a set list, then add the current settings as items.</div>'; return; }
if (!sl.items.length) { box.innerHTML = '<div class="hint">No items yet — set up the metronome and “Add current settings”.</div>'; return; }
if (!sl) { box.innerHTML = '<div class="hint">Create a set list, then “Add current settings” to capture items.</div>'; renderNowPlaying(); return; }
if (!sl.items.length) { box.innerHTML = '<div class="hint">No items yet — set up the metronome and “Add current settings”.</div>'; renderNowPlaying(); return; }
sl.items.forEach((it, i) => {
const row = document.createElement("div"); row.className = "ex-item";
const playing = (i === playingItem && state.running);
if (i === playingItem) row.style.borderColor = "#2e7d32";
row.innerHTML = `<button class="${playing ? "stop" : "play"} iconbtn" data-act="play" title="${playing ? "stop" : "load & start"}">${playing ? "⏹" : "▶"}</button>
<span class="nm">${i + 1}. ${it.name}</span>
<span class="meta">${it.bpm}bpm · ${it.lanes.map((l) => l.groupsStr).join("/")}</span>
<button class="iconbtn" data-act="save" title="save current settings to this item">💾</button>
<button class="iconbtn" data-act="up" title="up"></button>
<button class="iconbtn" data-act="down" title="down"></button>
<button class="x iconbtn" data-act="del" title="remove"></button>`;
row.querySelector('[data-act=play]').onclick = () => (i === playingItem && state.running) ? toggleTransport() : playItem(i);
row.querySelector('[data-act=save]').onclick = () => updateItem(i);
row.querySelector('[data-act=up]').onclick = () => moveItem(i, -1);
row.querySelector('[data-act=down]').onclick = () => moveItem(i, 1);
row.querySelector('[data-act=del]').onclick = () => removeItem(i);
const row = document.createElement("div");
row.className = "ex-item" + (i === activeItem ? " active" : "");
row.title = "Click to load into the player · Alt+↑/↓ to reorder";
row.innerHTML = `<span class="nm">${i + 1}. ${it.name}</span>
<span class="meta">${it.bpm} · ${it.lanes.map((l) => l.groupsStr).join("/")}</span>
<span class="row-actions">
<button class="iconbtn" data-act="save" title="overwrite this item with the current settings">💾</button>
<button class="x iconbtn" data-act="del" title="remove this item"></button>
</span>`;
row.onclick = () => loadItem(i);
row.querySelector('[data-act=save]').onclick = (e) => { e.stopPropagation(); updateItem(i); };
row.querySelector('[data-act=del]').onclick = (e) => { e.stopPropagation(); removeItem(i); };
box.appendChild(row);
});
renderNowPlaying();
}
// --- practice log (flat entries, one per played item) ---
@ -734,6 +784,11 @@ function renderLog() {
box.innerHTML = html;
}
function clearLog() { if (confirm("Clear the practice log? (set lists & presets are kept)")) { lsSet(LS.logs, []); renderLog(); } }
function resetAll() {
if (!confirm("Reset EVERYTHING?\n\nThis permanently deletes all saved data on this device — presets, set lists, practice log and theme — and reloads the app to first-run state (demos restored). This cannot be undone.")) return;
try { localStorage.clear(); } catch (e) {}
location.replace(location.origin + location.pathname); // reload clean, no hash
}
// --- backup: export / import everything (presets + set lists + logs) ---
function exportAll() {
@ -750,15 +805,132 @@ function importAll(file) {
try {
const d = JSON.parse(reader.result);
if (d.presets) lsSet(LS.presets, d.presets);
if (d.setlists) { lsSet(LS.setlists, d.setlists); setlists = d.setlists; activeSL = 0; playingItem = -1; }
if (d.setlists) { lsSet(LS.setlists, d.setlists); setlists = d.setlists; activeSL = 0; activeItem = -1; }
if (d.logs) lsSet(LS.logs, d.logs);
refreshPresetList(); renderSetlists(); renderLog();
renderSetlists(); renderLog();
alert("Imported " + Object.keys(d.presets || {}).length + " presets, " + (d.setlists || []).length + " set lists, " + (d.logs || []).length + " log entries.");
} catch (e) { alert("Import failed: " + e.message); }
};
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; }
function renderQR(el, text) {
el.innerHTML = "";
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.");
}
// 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]); activeItem = 0; historyName = sl.items[0].name; }
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
========================================================================= */
@ -772,24 +944,52 @@ function drawLoop() {
updateStatus(now);
}
for (const m of meters) renderLaneStrip(m);
tickTimers();
requestAnimationFrame(drawLoop);
}
function updateCtx() {
ctxDisplay.textContent = meters.length ? `${meters.length} meter${meters.length > 1 ? "s" : ""}` : "no meters";
/* =========================================================================
PRACTICE TIMERS — advance only while the metronome is running
========================================================================= */
const timers = { elapsedMs: 0, totalMs: 5 * 60000, remainingMs: 5 * 60000, last: 0 };
function fmtClock(ms) { const neg = ms < 0; const s = Math.round(Math.abs(ms) / 1000); return (neg ? "-" : "") + Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0"); }
function tickTimers() {
const now = Date.now();
const dt = timers.last ? Math.min(now - timers.last, 1000) : 0; // clamp so backgrounded gaps don't jump
timers.last = now;
if (state.running) {
timers.elapsedMs += dt;
if (timers.totalMs > 0) timers.remainingMs -= dt; // counts past 0 into negative (overtime); never stops the metronome
}
renderTimers();
}
function renderTimers() {
$("elapsedVal").textContent = fmtClock(timers.elapsedMs);
const cd = $("countVal");
if (timers.totalMs <= 0) { cd.textContent = "off"; cd.className = "tval"; return; }
cd.textContent = fmtClock(timers.remainingMs);
cd.classList.toggle("over", timers.remainingMs <= 0); // overtime
cd.classList.toggle("low", timers.remainingMs > 0 && timers.remainingMs <= 10000); // almost up
}
// Status shows in the display, under the BPM. Stopped → meter count; running →
// bar + trainer/ramp flags (kept short for the narrow display column).
function updateStatus() {
if (!state.running) { statusLine.textContent = meters.length ? "Stopped." : "Add a meter to begin."; return; }
if (!state.running) {
ctxDisplay.textContent = meters.length ? (meters.length + " meter" + (meters.length > 1 ? "s" : "") + " · ready") : "no meters";
ctxDisplay.classList.remove("muted-cue");
return;
}
const mbpb = masterBeatsPerBar();
const barIndex = Math.floor(Math.max(0, masterBeat - 1) / mbpb);
let s = `Running · bar ${barIndex + 1} · ${meters.map((m) => m.groups.join("+")).join(" vs ")}`;
if (trainer.on) {
const muted = isMutedAt(audioCtx.currentTime);
s += muted ? " — TRAINER: muted (count!)" : " — TRAINER: playing";
}
if (ramp.on) s += ` — ramp ${ramp.amount >= 0 ? "+" : ""}${ramp.amount}/${ramp.everyBars} bars`;
statusLine.textContent = s;
const muted = trainer.on && isMutedAt(audioCtx.currentTime);
let s = "▶ bar " + (barIndex + 1);
if (trainer.on) s += muted ? " · mute — count!" : " · play";
if (ramp.on) s += " · ramp";
ctxDisplay.textContent = s;
ctxDisplay.classList.toggle("muted-cue", muted);
}
function updateCtx() { updateStatus(); }
/* =========================================================================
UI WIRING
@ -800,13 +1000,18 @@ function syncStartBtn() {
else { startBtn.textContent = "▶ Start"; startBtn.classList.remove("on"); }
}
function toggleShortcuts(show) { const o = $("shortcutsOverlay"); o.hidden = (show === undefined) ? !o.hidden : !show; }
function applyTheme(t) {
document.documentElement.dataset.theme = t;
try { localStorage.setItem("metronome.theme", t); } catch (e) {}
$("themeBtn").textContent = t === "light" ? "🌙" : "☀"; // icon = theme you'd switch TO
const THEMES = ["system", "light", "dark"];
function effectiveTheme(pref) { return pref === "system" ? (matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark") : pref; }
function themePref() { try { const p = localStorage.getItem("metronome.theme"); return (p === "light" || p === "dark" || p === "system") ? p : "system"; } catch (e) { return "system"; } }
function applyTheme(pref) {
try { localStorage.setItem("metronome.theme", pref); } catch (e) {}
document.documentElement.dataset.theme = effectiveTheme(pref);
$("themeBtn").textContent = pref === "system" ? "🖥" : pref === "light" ? "☀" : "🌙";
$("themeBtn").title = "Theme: " + pref + " (click to cycle: system → light → dark)";
}
$("themeBtn").addEventListener("click", () => applyTheme(document.documentElement.dataset.theme === "light" ? "dark" : "light"));
applyTheme(document.documentElement.dataset.theme === "light" ? "light" : "dark");
$("themeBtn").addEventListener("click", () => applyTheme(THEMES[(THEMES.indexOf(themePref()) + 1) % THEMES.length]));
matchMedia("(prefers-color-scheme: light)").addEventListener("change", () => { if (themePref() === "system") applyTheme("system"); });
applyTheme(themePref());
$("startBtn").addEventListener("click", () => toggleTransport());
let _taps = [];
function tapTempo() {
@ -832,19 +1037,14 @@ $("rampStart").addEventListener("input", (e) => ramp.startBpm = +e.target.value)
$("rampAmt").addEventListener("input", (e) => ramp.amount = +e.target.value);
$("rampEvery").addEventListener("input", (e) => ramp.everyBars = +e.target.value);
$("addMeterBtn").addEventListener("click", () => addMeter("4", 1, "claves"));
$("presetSelect").addEventListener("change", (e) => {
const v = e.target.value;
if (v === "__save__") { const n = (prompt("Save current settings as preset:") || "").trim(); if (n) savePreset(n); else refreshPresetList(); }
else if (v) loadPreset(v);
});
$("delPresetBtn").addEventListener("click", () => { const n = $("presetSelect").value; if (n && n !== "__save__" && confirm('Delete preset "' + n + '"?')) deletePreset(n); });
$("routineToggle").addEventListener("click", () => $("routineTray").classList.toggle("open"));
$("routineClose").addEventListener("click", () => $("routineTray").classList.remove("open"));
$("countMin").addEventListener("input", (e) => { timers.totalMs = (+e.target.value || 0) * 60000; timers.remainingMs = timers.totalMs; renderTimers(); });
$("elapsedReset").addEventListener("click", () => { timers.elapsedMs = 0; renderTimers(); });
$("countReset").addEventListener("click", () => { timers.remainingMs = timers.totalMs; renderTimers(); });
$("trayMenuBtn").addEventListener("click", (e) => { e.stopPropagation(); $("trayMenu").hidden = !$("trayMenu").hidden; });
document.addEventListener("click", (e) => { const m = $("trayMenu"); if (m && !m.hidden && !m.contains(e.target) && e.target.id !== "trayMenuBtn") m.hidden = true; });
$("newSetlistBtn").addEventListener("click", newSetlist);
$("delSetlistBtn").addEventListener("click", deleteSetlist);
$("setlistSelect").addEventListener("change", (e) => { activeSL = +e.target.value; playingItem = -1; renderSetlists(); });
$("setlistSelect").addEventListener("change", (e) => { activeSL = +e.target.value; activeItem = -1; renderSetlists(); });
$("slTitle").addEventListener("input", (e) => { const sl = getSL(); if (sl) { sl.title = e.target.value; saveSetlists(); const o = $("setlistSelect").options[activeSL]; if (o) o.textContent = sl.title || ("Set list " + (activeSL + 1)); } });
$("slDesc").addEventListener("input", (e) => { const sl = getSL(); if (sl) { sl.description = e.target.value; saveSetlists(); } });
$("addItemBtn").addEventListener("click", () => { addItem($("itemName").value.trim()); $("itemName").value = ""; });
@ -855,30 +1055,47 @@ $("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(); });
$("resetAllBtn").addEventListener("click", () => { $("trayMenu").hidden = true; resetAll(); });
$("shareSettingsBtn").addEventListener("click", () => { $("trayMenu").hidden = true; shareSettings(); });
$("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) => {
const t = e.target;
if (t && (t.tagName === "INPUT" || t.tagName === "SELECT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
const k = e.key;
if (e.altKey && (k === "ArrowUp" || k === "ArrowDown")) { e.preventDefault(); moveActiveItem(k === "ArrowUp" ? -1 : 1); return; } // reorder selected item
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (e.code === "Space") { e.preventDefault(); toggleTransport(); }
else if (k === "t" || k === "T") { tapTempo(); }
else if (k === "ArrowUp") { e.preventDefault(); setBpm(state.bpm + (e.shiftKey ? 10 : 1)); }
else if (k === "ArrowDown") { e.preventDefault(); setBpm(state.bpm - (e.shiftKey ? 10 : 1)); }
else if (k === "a" || k === "A") { addMeter("4", 1, "claves"); }
else if (k === "r" || k === "R") { $("routineTray").classList.toggle("open"); }
else if (k === "n" || k === "N") { nextItem(); }
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 if (k >= "1" && k <= "9") {
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; }
}
});
/* 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
refreshPresetList();
/* 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: "Click an item to load it, then press Space — 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
}
renderSetlists();
renderLog();
updateCtx();

2297
qrcode.js Normal file

File diff suppressed because it is too large Load diff