Compare commits
9 commits
a7770eaf47
...
72147f5b32
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72147f5b32 | ||
|
|
a447df39e9 | ||
|
|
7010ba7cb8 | ||
|
|
3f04383e2b | ||
|
|
7207ffe1c7 | ||
|
|
5320da4325 | ||
|
|
6910f31a2f | ||
|
|
c0b6628488 | ||
|
|
c087f11637 |
4 changed files with 2786 additions and 126 deletions
143
README.md
Normal file
143
README.md
Normal 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, per‑beat
|
||||
on/off pattern (rests), mute, live measure counter.
|
||||
- **Polyrhythm** — a per‑lane *poly* toggle fits a lane's beats evenly into lane 1's
|
||||
bar (e.g. 5‑over‑4, 3‑over‑2).
|
||||
- **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 cross‑day 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, human‑readable text encodes a full configuration (a *patch*). It's what
|
||||
goes in a share link, and you can hand‑write 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 0–100 | `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`** — per‑beat 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` | eighth‑note hi‑hats |
|
||||
| `claves:5~` | 5 evenly across lane 1's bar (5‑over‑4 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 re‑import.
|
||||
|
||||
## Sharing & QR
|
||||
|
||||
In the set‑list panel's **⋯** menu:
|
||||
- **Share settings link** / **Share set‑list 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 set‑list 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 set‑list panel |
|
||||
| `N` | next set‑list 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` (version‑stamped) and `qrcode.js` into the Caddy
|
||||
web root and smoke‑tests 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).
|
||||
|
|
@ -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
|
||||
|
|
|
|||
469
index.html
469
index.html
|
|
@ -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 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 & 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 & 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 & 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 1–9</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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue