Compare commits

...

9 commits

Author SHA1 Message Date
Me Here
c2a88e5014 Reclaim Space for play/stop; narrow the keyboard focus guard
The real conflict wasn't the key: shortcuts bailed whenever ANY form
control (slider/checkbox/menu/button) had focus, and those keep focus
after use — so P/T/A/N seemed dead most of the time.

- Guard now stands down only for text-entry fields (text/number/textarea/
  contenteditable), so shortcuts work right after you touch a slider or
  checkbox.
- Space always = play/stop (preventDefault so it won't scroll, toggle a
  focused checkbox, or re-fire a focused button) — the DAW standard,
  which also fixes the original Space/checkbox conflict.
- Arrow keys still defer to a focused range slider / select.
- Legend, help overlay, README updated back to Space.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 08:08:37 -05:00
Me Here
9cb7c2c193 Per-step pad grid (beats × subdivision); drop confusing Sig dropdown; help: repo link + offline note
- Pad row now shows beatsPerBar × subdivision pads, each individually
  toggleable (the subdivision control sets pad resolution). Subdivision
  pads render smaller; downbeats labeled; group/beat gaps preserved.
- Mask (beatsOn) is now per-step; playhead tracks the current step.
  recomputeLane remaps on grouping/subdivision change and migrates legacy
  per-beat masks (saved data, short share patterns) by expanding across subs.
- Share language: =pattern is now per-step (len = beats × sub); short
  per-beat patterns still accepted and expanded. README updated.
- Removed the Sig time-signature preset dropdown (confusing vs subdivision).
- Help dialog: link to git.varasys.io/VARASYS/metronome + note that it's a
  single-page app you can save & run offline, but file:// won't auto-save
  the set list (export a backup).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 08:01:17 -05:00
Me Here
61b5dfb6de Countdown display value inherits the large display font (was stuck at 13px via .tval)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 07:49:51 -05:00
Me Here
17492fdfb0 Single Save button by Tap (disabled when nothing loaded); per-entry + Clear-all history delete; bigger display text
- Move per-item 💾 out of set-list rows into one 💾 Save next to Tap; it
  overwrites the loaded item and is disabled when no item is loaded.
- History list: red ✕ on hover deletes one session; Clear all wipes
  history for the current item only.
- Bump dark-display text again (BPM 80px, timers 26px, status 19px);
  widen the display column to fit.
- README: play key Space -> P.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 07:45:26 -05:00
Me Here
1be6920827 Bigger display + legend text; switch play/stop from Space to P (Space conflicted with focused controls)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 07:39:21 -05:00
Me Here
333477afdb Timers: add enable checkbox; collapse settings + hide display readouts when off
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 19:08:28 -05:00
Me Here
a011a89100 Collapse a feature's parameters until it's enabled (hide fbody when off)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 18:52:36 -05:00
Me Here
ba752745b7 Per-lane enable (replacing mute), feature boxes, set-list continue mode, external QR
- Each meter lane has an 'enable' checkbox right after its number (default on,
  green-dim row when off); replaces the right-side 'mute'. Renamed mute→enabled
  throughout (scheduler, snapshot, share '!' flag, 1–9 keys, now-playing). Old
  saved data still loads (back-compat).
- Features area redesigned into highlighting boxes (Gap trainer / Tempo ramp /
  Timers); trainer & ramp boxes light up + un-dim when enabled.
- Set list 'Continue' mode: per-item countdown (saved in each item, 'cd' token),
  and when a playing item's countdown hits 0 it auto-loads the next — so a list
  with countdowns plays straight through.
- Removed the in-app QR (vendored qrcode.js); 'QR ↗' now opens api.qrserver.com
  with the link, behind a banner warning it's a third party (verify it decodes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 18:47:16 -05:00
Me Here
285d78b499 Timers: show stopwatch + countdown in the BPM display; countdown blank-default, hⓂ️s input
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 18:35:58 -05:00
4 changed files with 274 additions and 2442 deletions

View file

@ -58,10 +58,14 @@ Tokens are joined with `;`. `tr` and `rmp` are omitted when off.
`jamblock` (unknown → `beep`). `jamblock` (unknown → `beep`).
- **grouping** — beats per bar, optionally grouped for odd meters: `4`, `3`, - **grouping** — beats per bar, optionally grouped for odd meters: `4`, `3`,
`2+2+3`. The first beat of each group is accented. `2+2+3`. The first beat of each group is accented.
- **`/sub`** — subdivision (clicks per beat): `1` quarter (default), `2` eighth, - **`/sub`** — subdivision: `1` quarter (default), `2` eighth, `3` triplet,
`3` triplet, `4` sixteenth, `6` sextuplet. Omit for quarter. `4` sixteenth, `6` sextuplet. This also sets how many **pads** each beat splits
- **`=pattern`** — perbeat on/off as `x`/`.`, length = beats per bar. Omit = all on. into (a beat becomes `sub` individuallytoggleable steps). Omit for quarter.
e.g. `=.x.x` puts a backbeat on 2 & 4. - **`=pattern`** — per**step** on/off as `x`/`.`, length = beats per bar × `sub`
(one char per pad). Omit = all on. e.g. `4=.x.x` is a backbeat on 2 & 4;
`4/4=x..x..x.x...x...` is a sixteenthgrid pattern. A short pattern whose length
equals just the beat count is still accepted and expanded across each beat's
subdivisions (backcompat).
- **`~`** — polyrhythm: fit this lane's beats evenly into **lane 1's** bar. - **`~`** — polyrhythm: fit this lane's beats evenly into **lane 1's** bar.
- **`!`** — mute the lane. - **`!`** — mute the lane.
@ -87,21 +91,21 @@ Tokens are joined with `;`. `tr` and `rmp` are omitted when off.
Opening such a link applies the settings (or imports the set list) on load, then Opening such a link applies the settings (or imports the set list) on load, then
clears the hash so a refresh won't reimport. clears the hash so a refresh won't reimport.
## Sharing & QR ## Sharing
In the setlist panel's **⋯** menu: In the setlist panel's **⋯** menu:
- **Share settings link** / **Share setlist link** open a dialog with the link and - **Share settings link** / **Share setlist link** open a dialog with the link to
a **QR code** (scan to open on a phone). Copy or Open from there. **Copy** or **Open**.
- **QR ↗** opens a thirdparty QR service (api.qrserver.com) with the link in its
URL so you can scan it on a phone. A banner warns you it's external — confirm the
QR decodes to the shown link before trusting it. (No QR is generated locally.)
- **Export all / Import file** back up presets + set lists + logs as a JSON file. - **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 ## Keyboard shortcuts
| Key | Action | | Key | Action |
|-----|--------| |-----|--------|
| `Space` | start / stop | | `Space` | play / stop (except while typing in a text field) |
| `T` | tap tempo | | `T` | tap tempo |
| `↑` / `↓` | tempo ±1 (`Shift` = ±10) | | `↑` / `↓` | tempo ±1 (`Shift` = ±10) |
| `A` | add meter lane | | `A` | add meter lane |
@ -123,7 +127,7 @@ then push the tag and deploy.
## Deploy ## Deploy
`./deploy.sh` copies `index.html` (versionstamped) and `qrcode.js` into the Caddy `./deploy.sh` copies `index.html` (versionstamped) into the Caddy
web root and smoketests the live URL. No restart needed (`file_server` picks up web root and smoketests the live URL. No restart needed (`file_server` picks up
changes immediately). changes immediately).
@ -132,12 +136,6 @@ changes immediately).
| File | Purpose | | File | Purpose |
|------|---------| |------|---------|
| `index.html` | the whole app | | `index.html` | the whole app |
| `qrcode.js` | vendored QR generator (Kazuhiko Arase, MIT) |
| `deploy.sh` | publish to the Caddy web root | | `deploy.sh` | publish to the Caddy web root |
| `release.sh` | tag a formal version | | `release.sh` | tag a formal version |
| `VERSION` | formal version string | | `VERSION` | formal version string |
## Credits
QR generation by [qrcode-generator](https://github.com/kazuhikoarase/qrcode-generator)
(© Kazuhiko Arase, MIT).

View file

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

View file

@ -46,7 +46,7 @@
} }
h1 { font-size:18px; font-weight:600; letter-spacing:.5px; margin:0 0 2px; } h1 { font-size:18px; font-weight:600; letter-spacing:.5px; margin:0 0 2px; }
.sub { color:var(--muted); font-size:12px; margin-bottom:18px; } .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; } .kbd-legend { color:var(--muted); font-size:13px; font-family:"Courier New",monospace; text-align:right; }
#app { display:flex; gap:18px; max-width:1400px; margin:0 auto; align-items:flex-start; justify-content:center; } #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)); .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); } border:1px solid var(--edge); border-radius:16px; padding:18px; box-shadow:0 18px 50px rgba(0,0,0,.5); }
@ -54,8 +54,10 @@
.card { background:var(--panel-2); border:1px solid var(--edge); border-radius:12px; padding:13px; flex:1; min-width:240px; } .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; } .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 { 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 .big { font-family:"Courier New",monospace; font-weight:700; font-size:80px; color:#ffd166; letter-spacing:2px; line-height:1.0; text-shadow:0 0 12px rgba(255,209,102,.5); }
.display .ctx { font-family:"Courier New",monospace; font-size:12px; color:#4dd0e1; min-height:15px; line-height:1.3; } .display .dtimers { font-family:"Courier New",monospace; font-size:26px; color:#4dd0e1; margin:6px 0; display:flex; gap:18px; justify-content:center; flex-wrap:wrap; }
.display .dtimers[hidden] { display:none; }
.display .ctx { font-family:"Courier New",monospace; font-size:19px; color:#4dd0e1; min-height:22px; line-height:1.25; }
.display .ctx.muted-cue { color:#ffb454; } .display .ctx.muted-cue { color:#ffb454; }
.knob { margin-bottom:10px; } .knob { margin-bottom:10px; }
.knob label { display:flex; justify-content:space-between; font-size:12px; margin-bottom:5px; } .knob label { display:flex; justify-content:space-between; font-size:12px; margin-bottom:5px; }
@ -81,6 +83,8 @@
.led.accent { box-shadow:0 0 4px #fff, 0 0 14px var(--lc); } .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.accent::after { content:"▲"; position:absolute; top:-1px; font-size:7px; color:#fff; }
.led.playhead { outline:2px solid var(--ring); outline-offset:1px; } .led.playhead { outline:2px solid var(--ring); outline-offset:1px; }
.led.sub { width:20px; height:20px; opacity:.9; } /* subdivision step — smaller than a downbeat */
.led.beatstart { margin-left:11px; } /* extra gap between beats within a group */
.led.groupstart { margin-left:16px; } .led.groupstart { margin-left:16px; }
.led.groupstart::before { content:""; position:absolute; left:-9px; top:4px; bottom:4px; width:2px; background:var(--muted); } .led.groupstart::before { content:""; position:absolute; left:-9px; top:4px; bottom:4px; width:2px; background:var(--muted); }
/* meter lanes — compact single-row controls + strip */ /* meter lanes — compact single-row controls + strip */
@ -94,6 +98,7 @@
.bar { font-family:"Courier New",monospace; font-size:12px; color:var(--hot); min-width:46px; text-align:right; } .bar { font-family:"Courier New",monospace; font-size:12px; color:var(--hot); min-width:46px; text-align:right; }
.cmp { background:var(--panel); color:var(--txt); border:1px solid var(--edge); border-radius:8px; padding:7px 8px; font-size:12px; } .cmp { background:var(--panel); color:var(--txt); border:1px solid var(--edge); border-radius:8px; padding:7px 8px; font-size:12px; }
.meter-card .led { width:26px; height:26px; border-radius:6px; } .meter-card .led { width:26px; height:26px; border-radius:6px; }
.meter-card .led.sub { width:17px; height:17px; }
.x { background:transparent; border:1px solid var(--edge); color:var(--muted); padding:4px 9px; border-radius:7px; margin-left:auto; } .x { background:transparent; border:1px solid var(--edge); color:var(--muted); padding:4px 9px; border-radius:7px; margin-left:auto; }
.x:hover { color:#ff9a8a; border-color:#c0392b; } .x:hover { color:#ff9a8a; border-color:#c0392b; }
.hint { font-size:11px; color:var(--muted); margin-top:8px; } .hint { font-size:11px; color:var(--muted); margin-top:8px; }
@ -112,15 +117,20 @@
.np-desc { font-size:12px; color:var(--muted); margin-top:4px; } .np-desc { font-size:12px; color:var(--muted); margin-top:4px; }
.iconbtn { padding:3px 8px; font-size:12px; } .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-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-head { font-weight:600; font-size:13px; margin-bottom:5px; display:flex; align-items:center; gap:8px; }
.log-seg { font-size:12px; color:var(--muted); margin:2px 0 0 12px; font-family:"Courier New",monospace; } .log-head-nm { flex:1; }
.hist-row { display:flex; align-items:center; gap:6px; margin:2px 0 0 12px; }
.hist-txt { flex:1; font-size:12px; color:var(--muted); font-family:"Courier New",monospace; }
.hist-del { display:none; background:transparent; border:none; color:#ff6b5e; cursor:pointer; font-size:12px; line-height:1; padding:2px 4px; border-radius:4px; }
.hist-del:hover { background:rgba(255,107,94,.15); }
.hist-row:hover .hist-del, .hist-row:focus-within .hist-del { display:inline; }
.practice { border-top:1px solid var(--edge); margin-top:16px; padding-top:4px; } .practice { border-top:1px solid var(--edge); margin-top:16px; padding-top:4px; }
/* set-list panel: always shown — sticky beside the metronome on desktop, /* set-list panel: always shown — sticky beside the metronome on desktop,
stacks below it on narrow screens */ stacks below it on narrow screens */
#routineTray { flex:0 0 340px; align-self:flex-start; position:sticky; top:18px; #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)); 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); } 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 { font-family:"Courier New",monospace; font-size:inherit; color:var(--hot); min-width:64px; }
.tval.low { color:#ffb454; } .tval.low { color:#ffb454; }
.tval.over { color:#ff7b6b; } .tval.over { color:#ff7b6b; }
@media (max-width: 820px) { @media (max-width: 820px) {
@ -129,6 +139,14 @@
} }
.tray-head { display:flex; align-items:center; justify-content:space-between; margin-bottom:14px; } .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; } .practice-col { border-left:1px solid var(--edge); padding-left:18px; }
.fbox { border:1px solid var(--edge); border-radius:10px; padding:9px 11px; margin-bottom:10px; transition:border-color .15s, background .15s; }
.fbox .fhead { display:flex; align-items:center; gap:8px; }
.fbox .ftitle { font-weight:600; font-size:12px; }
.fbox .fbody { margin-top:8px; }
.fbox.on { border-color:#2e7d32; background:rgba(46,125,50,.12); }
.fbox.toggleable:not(.on) .fbody { display:none; } /* hide a feature's parameters until it's enabled */
.lane-enable { accent-color:#2e7d32; margin:0 2px; cursor:pointer; }
.lane-row.lane-off { opacity:.5; }
#themeBtn, #helpBtn { padding:4px 11px; } #themeBtn, #helpBtn { padding:4px 11px; }
/* --- responsive --- */ /* --- responsive --- */
@media (max-width: 760px) { @media (max-width: 760px) {
@ -148,9 +166,13 @@
.overlay { position:fixed; inset:0; background:rgba(0,0,0,.55); display:flex; align-items:center; justify-content:center; z-index:80; } .overlay { position:fixed; inset:0; background:rgba(0,0,0,.55); display:flex; align-items:center; justify-content:center; z-index:80; }
.overlay[hidden] { display:none; } .overlay[hidden] { display:none; }
.overlay-box { background:var(--panel-2); border:1px solid var(--edge); border-radius:14px; padding:16px 20px; width:380px; max-width:92vw; box-shadow:0 20px 60px rgba(0,0,0,.6); } .overlay-box { background:var(--panel-2); border:1px solid var(--edge); border-radius:14px; padding:16px 20px; width:380px; max-width:92vw; box-shadow:0 20px 60px rgba(0,0,0,.6); }
.qr { background:#fff; border-radius:8px; padding:10px; text-align:center; margin-bottom:12px; } .ext-banner { font-size:11px; color:#3a2f10; background:#ffe2a8; border:1px solid #d9a441; border-radius:8px; padding:8px 10px; margin-top:10px; line-height:1.35; }
.qr img { display:block; margin:0 auto; image-rendering:pixelated; max-width:100%; } .ext-banner[hidden] { display:none; }
#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; } #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; }
.help-about { margin-top:14px; padding-top:12px; border-top:1px solid var(--edge); font-size:12px; color:var(--muted); line-height:1.45; }
.help-about p { margin:0 0 8px; }
.help-about p:last-child { margin-bottom:0; }
.help-about a { color:#6cb6ff; }
.kbd-table { width:100%; border-collapse:collapse; font-size:13px; } .kbd-table { width:100%; border-collapse:collapse; font-size:13px; }
.kbd-table td { padding:6px 4px; border-bottom:1px solid var(--edge); } .kbd-table td { padding:6px 4px; border-bottom:1px solid var(--edge); }
.kbd-table tr:last-child td { border-bottom:none; } .kbd-table tr:last-child td { border-bottom:none; }
@ -180,12 +202,16 @@
<div class="row"> <div class="row">
<div class="card" style="flex:1"> <div class="card" style="flex:1">
<div class="row" style="gap:22px; align-items:flex-start"> <div class="row" style="gap:22px; align-items:flex-start">
<div style="flex:0 0 190px; min-width:170px"> <div style="flex:0 0 260px; min-width:230px">
<div class="display"> <div class="display">
<div class="big" id="bpmDisplay">120</div> <div class="big" id="bpmDisplay">120</div>
<div class="dtimers" id="dtimers">
<span title="elapsed (stopwatch)"><span id="elapsedVal">0:00</span></span>
<span id="countWrap" title="countdown" hidden><span id="countVal" class="tval">0:00</span></span>
</div>
<div class="ctx" id="ctxDisplay">&nbsp;</div> <div class="ctx" id="ctxDisplay">&nbsp;</div>
</div> </div>
<div class="btnrow" style="margin-top:10px"><button class="primary" id="startBtn">▶ Start</button><button id="tapBtn">Tap</button></div> <div class="btnrow" style="margin-top:10px"><button class="primary" id="startBtn">▶ Start</button><button id="tapBtn">Tap</button><button id="saveItemBtn" disabled title="overwrite the loaded set-list item with the current settings">💾 Save</button></div>
</div> </div>
<div style="flex:1; min-width:200px"> <div style="flex:1; min-width:200px">
@ -200,36 +226,42 @@
</div> </div>
<div class="practice-col" style="flex:1; min-width:215px"> <div class="practice-col" style="flex:1; min-width:215px">
<div class="checkrow" style="margin-bottom:8px"><input type="checkbox" id="trainerOn"><label for="trainerOn">Gap / mute trainer</label></div> <div class="fbox toggleable" id="trainerBox">
<div class="row" style="gap:14px; align-items:center"> <label class="fhead"><input type="checkbox" id="trainerOn"><span class="ftitle">Gap / mute trainer</span></label>
<div class="fbody row" style="gap:14px; align-items:center">
<label style="font-size:12px">Play <input type="number" class="num" id="playBars" min="1" max="16" value="2"></label> <label style="font-size:12px">Play <input type="number" class="num" id="playBars" min="1" max="16" value="2"></label>
<label style="font-size:12px">Mute <input type="number" class="num" id="muteBars" min="0" max="16" value="2"> bars</label> <label style="font-size:12px">Mute <input type="number" class="num" id="muteBars" min="0" max="16" value="2"> bars</label>
</div> </div>
<div class="checkrow" style="margin:10px 0 8px"><input type="checkbox" id="rampOn"><label for="rampOn">Tempo ramp</label></div> </div>
<div class="row" style="gap:12px 14px; align-items:center; flex-wrap:wrap"> <div class="fbox toggleable" id="rampBox">
<label class="fhead"><input type="checkbox" id="rampOn"><span class="ftitle">Tempo ramp</span></label>
<div class="fbody row" style="gap:12px 14px; align-items:center; flex-wrap:wrap">
<label style="font-size:12px">from <input type="number" class="num" id="rampStart" min="30" max="300" value="80"> BPM</label> <label style="font-size:12px">from <input type="number" class="num" id="rampStart" min="30" max="300" value="80"> BPM</label>
<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" 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> <label style="font-size:12px">every <input type="number" class="num" id="rampEvery" min="1" max="16" value="4"> bars</label>
</div> </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>
<div class="fbox toggleable" id="timerBox">
<label class="fhead"><input type="checkbox" id="timersOn"><span class="ftitle">Timers</span><span class="hint" style="margin:0">run while playing</span></label>
<div class="fbody">
<div class="row" style="gap:10px; align-items:center"> <div class="row" style="gap:10px; align-items:center">
<label style="font-size:12px">Elapsed</label> <label style="font-size:12px">Elapsed (stopwatch)</label>
<span class="tval" id="elapsedVal">0:00</span> <button class="iconbtn" id="elapsedReset" title="reset elapsed"></button>
<button class="iconbtn" id="elapsedReset" title="reset elapsed timer"></button>
</div> </div>
<div class="row" style="gap:10px; align-items:center; margin-top:6px"> <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> <label style="font-size:12px">Countdown <input type="text" class="txt" id="countTime" placeholder="m:ss" title="blank = off · h:mm:ss, m:ss, or plain minutes" style="width:80px; text-align:center"></label>
<span class="tval" id="countVal">5:00</span>
<button class="iconbtn" id="countReset" title="reset countdown"></button> <button class="iconbtn" id="countReset" title="reset countdown"></button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
<div class="row" style="align-items:center; gap:12px; margin:14px 0 8px"> <div class="row" style="align-items:center; gap:12px; margin:14px 0 8px">
<h2 style="font-size:11px; text-transform:uppercase; letter-spacing:1.4px; color:var(--muted); margin:0">Meter lanes</h2> <h2 style="font-size:11px; text-transform:uppercase; letter-spacing:1.4px; color:var(--muted); margin:0">Meter lanes</h2>
<span class="hint" style="margin:0; flex:1">Click a beat pad to toggle it (rest) — e.g. snare on 2 &amp; 4</span> <span class="hint" style="margin:0; flex:1">Each beat splits into <i>subdivision</i> pads — click any pad to toggle it (rest). e.g. snare on 2 &amp; 4</span>
</div> </div>
<div id="meters"></div> <div id="meters"></div>
<div style="margin-top:10px"><button class="add" id="addMeterBtn" title="add meter lane (A)">+</button></div> <div style="margin-top:10px"><button class="add" id="addMeterBtn" title="add meter lane (A)">+</button></div>
@ -270,8 +302,9 @@
<input type="text" class="txt" id="itemName" placeholder="item name" style="flex:1; min-width:110px; text-align:left"> <input type="text" class="txt" id="itemName" placeholder="item name" style="flex:1; min-width:110px; text-align:left">
<button id="addItemBtn">+ Add current settings</button> <button id="addItemBtn">+ Add current settings</button>
</div> </div>
<label class="mini-check" title="when a playing item's countdown reaches 0, auto-load the next item — give every item a countdown to auto-play the whole list" style="margin-bottom:6px"><input type="checkbox" id="continueMode"> Continue — auto-advance on countdown</label>
<div id="itemList"></div> <div id="itemList"></div>
<div class="hint" style="margin-top:6px">▶ loads &amp; starts an item · <kbd>N</kbd> advances · 💾 saves current settings back to an item.</div> <div class="hint" style="margin-top:6px">Click to load · <kbd>N</kbd> next · <kbd>Alt+↑/↓</kbd> reorder · 💾 save settings to an item.</div>
<div id="logView" style="margin-top:18px"></div> <div id="logView" style="margin-top:18px"></div>
</aside> </aside>
@ -281,17 +314,21 @@
<div class="overlay-box"> <div class="overlay-box">
<div class="tray-head"><h2 style="margin:0">Keyboard shortcuts</h2><button class="x" id="shortcutsClose" style="margin-left:0"></button></div> <div class="tray-head"><h2 style="margin:0">Keyboard shortcuts</h2><button class="x" id="shortcutsClose" style="margin-left:0"></button></div>
<table class="kbd-table"> <table class="kbd-table">
<tr><td><kbd>Space</kbd></td><td>Start / stop</td></tr> <tr><td><kbd>Space</kbd></td><td>Play / stop (works everywhere except while typing in a text field)</td></tr>
<tr><td><kbd>T</kbd></td><td>Tap tempo</td></tr> <tr><td><kbd>T</kbd></td><td>Tap tempo</td></tr>
<tr><td><kbd></kbd> <kbd></kbd></td><td>Tempo ±1 BPM</td></tr> <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>⇧↑</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>A</kbd></td><td>Add meter lane</td></tr>
<tr><td><kbd>N</kbd></td><td>Load 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>⌥↑</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>1</kbd><kbd>9</kbd></td><td>Enable / silence lane 19</td></tr>
<tr><td><kbd>?</kbd></td><td>This help</td></tr> <tr><td><kbd>?</kbd></td><td>This help</td></tr>
<tr><td><kbd>Esc</kbd></td><td>Close tray / help</td></tr> <tr><td><kbd>Esc</kbd></td><td>Close tray / help</td></tr>
</table> </table>
<div class="help-about">
<p>Source: <a href="https://git.varasys.io/VARASYS/metronome" target="_blank" rel="noopener">git.varasys.io/VARASYS/metronome</a></p>
<p>This is a single-page app — save this page (<kbd>Ctrl/⌘+S</kbd>) and open the file to run it fully offline, no server needed. One catch when running from a local <code>file://</code>: it <b>won't auto-save your set list</b> between sessions, so export a backup (set-list <b></b> menu → <b>Export all</b>) to keep your work.</p>
</div>
</div> </div>
</div> </div>
@ -299,14 +336,13 @@
<div id="shareOverlay" class="overlay" hidden> <div id="shareOverlay" class="overlay" hidden>
<div class="overlay-box"> <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 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> <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="btnrow" style="margin-top:8px"><button id="shareCopy">Copy link</button><button id="shareOpen">Open ↗</button><button id="shareQrExt">QR ↗</button></div>
<div class="hint" id="shareNote"></div> <div class="hint" id="shareNote"></div>
<div class="ext-banner" id="shareExtBanner" hidden>⚠ “QR ↗” opens an external site (api.qrserver.com) with this link embedded in its URL. It is a third party — after scanning, confirm the QR decodes to the link above before trusting it.</div>
</div> </div>
</div> </div>
<script src="qrcode.js"></script>
<script> <script>
"use strict"; "use strict";
@ -448,9 +484,9 @@ function scheduleMeterTick(m, time) {
const tickInBar = ((m.tick % barLen) + barLen) % barLen; const tickInBar = ((m.tick % barLen) + barLen) % barLen;
const onBeat = (tickInBar % spb) === 0; const onBeat = (tickInBar % spb) === 0;
const beatIndex = Math.floor(tickInBar / spb); const beatIndex = Math.floor(tickInBar / spb);
if (onBeat) m.vq.push({ time, beat: beatIndex, bar: Math.floor(m.tick / barLen) }); // playhead + measure (advance even when muted) m.vq.push({ time, step: tickInBar, bar: Math.floor(m.tick / barLen) }); // playhead (per step) + measure (advance even when muted)
if (m.mute || isMutedAt(time)) return; if (!m.enabled || isMutedAt(time)) return;
if (!m.beatsOn[beatIndex]) return; // beat toggled off → rest (incl. its subdivisions) if (!m.beatsOn[tickInBar]) return; // this step toggled off → rest
if (onBeat) { if (onBeat) {
const groupStart = m.groupStarts.has(beatIndex); const groupStart = m.groupStarts.has(beatIndex);
playInstrument(m.sound, time, groupStart ? 1.0 : 0.7); // beat — louder on group start playInstrument(m.sound, time, groupStart ? 1.0 : 0.7); // beat — louder on group start
@ -486,7 +522,7 @@ function start() {
state.running = true; state.running = true;
if (ramp.on) setBpm(ramp.startBpm); // ramp begins from its start BPM if (ramp.on) setBpm(ramp.startBpm); // ramp begins from its start BPM
const t0 = audioCtx.currentTime + 0.08; const t0 = audioCtx.currentTime + 0.08;
for (const m of meters) { m.tick = 0; m.nextTime = t0; m.vq = []; m.vqPtr = 0; m.currentBeat = -1; m.currentBar = 0; } for (const m of meters) { m.tick = 0; m.nextTime = t0; m.vq = []; m.vqPtr = 0; m.currentStep = -1; m.currentBar = 0; }
masterBeat = 0; masterBeatTime = t0; muteWindows = []; masterBeat = 0; masterBeatTime = t0; muteWindows = [];
schedulerTimer = setInterval(scheduler, LOOKAHEAD_MS); schedulerTimer = setInterval(scheduler, LOOKAHEAD_MS);
scheduler(); syncStartBtn(); scheduler(); syncStartBtn();
@ -494,7 +530,7 @@ function start() {
function stop() { function stop() {
state.running = false; state.running = false;
clearInterval(schedulerTimer); schedulerTimer = null; clearInterval(schedulerTimer); schedulerTimer = null;
for (const m of meters) m.currentBeat = -1; for (const m of meters) m.currentStep = -1;
syncStartBtn(); syncStartBtn();
} }
function setBpm(v) { function setBpm(v) {
@ -512,11 +548,16 @@ function addMeter(groupsStr = "4", stepsPerBeat = 4, sound = "beep", beatsOn = n
const p = parseGroups(groupsStr); const p = parseGroups(groupsStr);
const m = { const m = {
id, groupsStr, groups: p.groups, beatsPerBar: p.beatsPerBar, groupStarts: p.groupStarts, id, groupsStr, groups: p.groups, beatsPerBar: p.beatsPerBar, groupStarts: p.groupStarts,
stepsPerBeat, sound, mute: false, poly: !!poly, color: laneColor(id), stepsPerBeat, sound, enabled: true, poly: !!poly, color: laneColor(id),
beatsOn: beatsOn ? beatsOn.slice() : [], // per-beat on/off mask (rests) beatsOn: beatsOn ? beatsOn.slice() : [], // per-STEP on/off mask (one entry per pad = beats × subdivision)
tick: 0, nextTime: 0, vq: [], vqPtr: 0, currentBeat: -1, currentBar: 0, tick: 0, nextTime: 0, vq: [], vqPtr: 0, currentStep: -1, currentBar: 0,
el: null, stripEl: null, barEl: null, el: null, stripEl: null, barEl: null,
}; };
// Tell recomputeLane the resolution the incoming mask was authored at, so it can
// remap/expand it: matches steps → per-step (new), matches beats → legacy per-beat.
if (m.beatsOn.length === p.beatsPerBar * stepsPerBeat) { m._maskBpb = p.beatsPerBar; m._maskSpb = stepsPerBeat; }
else if (m.beatsOn.length === p.beatsPerBar) { m._maskBpb = p.beatsPerBar; m._maskSpb = 1; }
else { m._maskBpb = 0; m._maskSpb = 1; } // empty/unknown → recompute fills all-on
if (state.running) { m.nextTime = audioCtx.currentTime + 0.05; } if (state.running) { m.nextTime = audioCtx.currentTime + 0.05; }
meters.push(m); meters.push(m);
buildLaneCard(m); buildLaneCard(m);
@ -535,6 +576,11 @@ function removeMeter(id) {
// lane labels track position (1-based) so number-key shortcuts line up with what's shown // lane labels track position (1-based) so number-key shortcuts line up with what's shown
function renumberLanes() { meters.forEach((m, i) => { if (m.titleEl) m.titleEl.textContent = i + 1; }); } function renumberLanes() { meters.forEach((m, i) => { if (m.titleEl) m.titleEl.textContent = i + 1; }); }
function setLaneEnabled(m, on) {
m.enabled = on;
const cb = m.el && m.el.querySelector(`#m${m.id}_enable`); if (cb) cb.checked = on;
if (m.el) m.el.querySelector(".lane-row").classList.toggle("lane-off", !on);
}
function buildLaneCard(m) { function buildLaneCard(m) {
const card = document.createElement("div"); const card = document.createElement("div");
@ -542,14 +588,10 @@ function buildLaneCard(m) {
card.innerHTML = ` card.innerHTML = `
<div class="lane-row"> <div class="lane-row">
<span class="lane-title" id="m${m.id}_title" style="color:${m.color}">${m.id}</span> <span class="lane-title" id="m${m.id}_title" style="color:${m.color}">${m.id}</span>
<input type="checkbox" class="lane-enable" id="m${m.id}_enable" title="enable / silence this lane" checked>
<input type="text" class="txt grp" id="m${m.id}_group" value="${m.groupsStr}" spellcheck="false" title="grouping, e.g. 2+2+3"> <input type="text" class="txt grp" id="m${m.id}_group" value="${m.groupsStr}" spellcheck="false" title="grouping, e.g. 2+2+3">
<span class="sum" id="m${m.id}_sum"></span> <span class="sum" id="m${m.id}_sum"></span>
<select class="cmp" id="m${m.id}_preset" title="time-signature presets"> <select class="cmp" id="m${m.id}_sub" title="subdivision — also sets how many pads each beat splits into">
<option value="">sig…</option>
<option value="4">4/4</option><option value="3">3/4</option><option value="3+3">6/8</option>
<option value="2+3">5/8</option><option value="2+2+3">7/8</option><option value="3+2+2">7/8b</option>
</select>
<select class="cmp" id="m${m.id}_sub" title="subdivision (clicks per beat)">
<option value="1">♩ quarter</option><option value="2">♪ eighth</option> <option value="1">♩ quarter</option><option value="2">♪ eighth</option>
<option value="3">3·triplet</option><option value="4">16th</option><option value="6">6·sext</option> <option value="3">3·triplet</option><option value="4">16th</option><option value="6">6·sext</option>
</select> </select>
@ -557,7 +599,6 @@ function buildLaneCard(m) {
<div class="strip" id="m${m.id}_strip"></div> <div class="strip" id="m${m.id}_strip"></div>
<span class="bar" id="m${m.id}_bar"></span> <span class="bar" id="m${m.id}_bar"></span>
<label class="mini-check" title="polyrhythm: fit these beats evenly into lane 1's bar"><input type="checkbox" id="m${m.id}_poly"> poly</label> <label class="mini-check" title="polyrhythm: fit these beats evenly into lane 1's bar"><input type="checkbox" id="m${m.id}_poly"> poly</label>
<label class="mini-check"><input type="checkbox" id="m${m.id}_mute"> mute</label>
<button class="x" id="m${m.id}_remove" title="remove lane"></button> <button class="x" id="m${m.id}_remove" title="remove lane"></button>
</div>`; </div>`;
document.getElementById("meters").appendChild(card); document.getElementById("meters").appendChild(card);
@ -569,18 +610,15 @@ function buildLaneCard(m) {
// wire controls // wire controls
const $c = (sel) => card.querySelector(sel); const $c = (sel) => card.querySelector(sel);
$c(`#m${m.id}_group`).addEventListener("input", (e) => { m.groupsStr = e.target.value; recomputeLane(m); }); $c(`#m${m.id}_group`).addEventListener("input", (e) => { m.groupsStr = e.target.value; recomputeLane(m); });
const preset = $c(`#m${m.id}_preset`);
preset.addEventListener("change", (e) => {
if (!e.target.value) return;
m.groupsStr = e.target.value; $c(`#m${m.id}_group`).value = e.target.value; e.target.value = ""; recomputeLane(m);
});
const sub = $c(`#m${m.id}_sub`); sub.value = String(m.stepsPerBeat); const sub = $c(`#m${m.id}_sub`); sub.value = String(m.stepsPerBeat);
sub.addEventListener("change", (e) => { m.stepsPerBeat = +e.target.value; recomputeLane(m); }); sub.addEventListener("change", (e) => { m.stepsPerBeat = +e.target.value; recomputeLane(m); });
const sel = $c(`#m${m.id}_sound`); sel.value = m.sound; const sel = $c(`#m${m.id}_sound`); sel.value = m.sound;
sel.addEventListener("change", (e) => m.sound = e.target.value); sel.addEventListener("change", (e) => m.sound = e.target.value);
const polyCb = $c(`#m${m.id}_poly`); polyCb.checked = m.poly; const polyCb = $c(`#m${m.id}_poly`); polyCb.checked = m.poly;
polyCb.addEventListener("change", (e) => m.poly = e.target.checked); polyCb.addEventListener("change", (e) => m.poly = e.target.checked);
$c(`#m${m.id}_mute`).addEventListener("change", (e) => m.mute = e.target.checked); const enCb = $c(`#m${m.id}_enable`); enCb.checked = m.enabled;
enCb.addEventListener("change", (e) => setLaneEnabled(m, e.target.checked));
card.querySelector(".lane-row").classList.toggle("lane-off", !m.enabled);
$c(`#m${m.id}_remove`).addEventListener("click", () => removeMeter(m.id)); $c(`#m${m.id}_remove`).addEventListener("click", () => removeMeter(m.id));
recomputeLane(m); recomputeLane(m);
@ -589,33 +627,57 @@ function buildLaneCard(m) {
function recomputeLane(m) { function recomputeLane(m) {
const p = parseGroups(m.groupsStr); const p = parseGroups(m.groupsStr);
m.groups = p.groups; m.beatsPerBar = p.beatsPerBar; m.groupStarts = p.groupStarts; m.groups = p.groups; m.beatsPerBar = p.beatsPerBar; m.groupStarts = p.groupStarts;
const prev = m.beatsOn || []; // resize mask, preserve, default new beats on // Remap the on/off mask to step resolution (beats × subdivision = one entry per pad),
m.beatsOn = []; // preserving the old pattern where it lines up and defaulting new pads to ON.
for (let b = 0; b < m.beatsPerBar; b++) m.beatsOn[b] = (b < prev.length) ? !!prev[b] : true; const spb = m.stepsPerBeat;
const prev = m.beatsOn || [], oldBpb = m._maskBpb || 0, oldSpb = m._maskSpb || 1;
const next = [];
for (let b = 0; b < m.beatsPerBar; b++) {
for (let s = 0; s < spb; s++) {
let val = true;
if (b < oldBpb) { // this beat existed before
const oi = (oldSpb === spb) ? b * oldSpb + s // same resolution → step-for-step
: b * oldSpb; // resolution changed → use the beat's downbeat
if (oi < prev.length) val = !!prev[oi];
}
next.push(val);
}
}
m.beatsOn = next; m._maskBpb = m.beatsPerBar; m._maskSpb = spb;
m.el.querySelector(`#m${m.id}_sum`).textContent = "=" + m.beatsPerBar; m.el.querySelector(`#m${m.id}_sum`).textContent = "=" + m.beatsPerBar;
buildLaneStrip(m); buildLaneStrip(m);
} }
function buildLaneStrip(m) { function buildLaneStrip(m) { // one pad per STEP (beats × subdivision)
m.stripEl.innerHTML = ""; m.stripEl.innerHTML = "";
for (let b = 0; b < m.beatsPerBar; b++) { const spb = m.stepsPerBeat, total = m.beatsPerBar * spb;
for (let i = 0; i < total; i++) {
const b = Math.floor(i / spb), s = i % spb;
const cell = document.createElement("div"); const cell = document.createElement("div");
cell.className = "led"; cell.textContent = b + 1; cell.className = "led";
cell.style.cursor = "pointer"; cell.title = "toggle beat " + (b + 1); cell.textContent = (s === 0) ? (b + 1) : ""; // label downbeats; subdivisions blank
cell.addEventListener("click", () => { m.beatsOn[b] = !m.beatsOn[b]; renderLaneStrip(m); }); cell.style.cursor = "pointer";
cell.title = (s === 0) ? ("toggle beat " + (b + 1)) : ("toggle beat " + (b + 1) + " · sub " + (s + 1));
cell.addEventListener("click", () => { m.beatsOn[i] = !m.beatsOn[i]; renderLaneStrip(m); });
m.stripEl.appendChild(cell); m.stripEl.appendChild(cell);
} }
} }
function renderLaneStrip(m) { function renderLaneStrip(m) {
const cells = m.stripEl.children; const cells = m.stripEl.children, spb = m.stepsPerBeat;
for (let b = 0; b < cells.length; b++) { for (let i = 0; i < cells.length; i++) {
const cell = cells[b]; const cell = cells[i];
const on = m.beatsOn[b], gs = m.groupStarts.has(b); const b = Math.floor(i / spb), s = i % spb, onBeat = (s === 0);
let cls = "led"; if (on) cls += " on"; if (gs) cls += " groupstart"; if (on && gs) cls += " accent"; const on = m.beatsOn[i], gs = onBeat && m.groupStarts.has(b);
let cls = "led";
if (!onBeat) cls += " sub"; // subdivision pad (smaller/dimmer)
else if (i > 0 && !gs) cls += " beatstart"; // gap between beats within a group
if (on) cls += " on";
if (gs) cls += " groupstart";
if (on && gs) cls += " accent";
cell.className = cls; cell.className = cls;
cell.style.setProperty("--lc", m.color); cell.style.setProperty("--lc", m.color);
if (state.running && b === m.currentBeat) cell.classList.add("playhead"); if (state.running && i === m.currentStep) cell.classList.add("playhead");
} }
if (m.barEl) m.barEl.textContent = state.running ? "bar " + (m.currentBar + 1) : "—"; if (m.barEl) m.barEl.textContent = state.running ? "bar " + (m.currentBar + 1) : "—";
} }
@ -623,18 +685,17 @@ function renderLaneStrip(m) {
/* ========================================================================= /* =========================================================================
PRESETS (localStorage) PRESETS (localStorage)
========================================================================= */ ========================================================================= */
const LS = { presets: "metronome.presets", setlists: "metronome.setlists", logs: "metronome.logs", seeded: "metronome.seeded" }; const LS = { presets: "metronome.presets", setlists: "metronome.setlists", logs: "metronome.logs", seeded: "metronome.seeded", continue: "metronome.continue", timers: "metronome.timers" };
function lsGet(k, fb) { try { const v = localStorage.getItem(k); return v ? JSON.parse(v) : fb; } catch (e) { return fb; } } 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; } } function lsSet(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); return true; } catch (e) { console.warn("localStorage unavailable", e); return false; } }
function snapshotLanes() { return meters.map((m) => ({ groupsStr: m.groupsStr, stepsPerBeat: m.stepsPerBeat, sound: m.sound, mute: m.mute, poly: m.poly, beatsOn: m.beatsOn.slice() })); } function snapshotLanes() { return meters.map((m) => ({ groupsStr: m.groupsStr, stepsPerBeat: m.stepsPerBeat, sound: m.sound, enabled: m.enabled, poly: m.poly, beatsOn: m.beatsOn.slice() })); }
function applyLanes(lanes) { function applyLanes(lanes) {
while (meters.length) removeMeter(meters[0].id); while (meters.length) removeMeter(meters[0].id);
for (const c of lanes) { for (const c of lanes) {
addMeter(c.groupsStr, c.stepsPerBeat, c.sound, c.beatsOn, c.poly); addMeter(c.groupsStr, c.stepsPerBeat, c.sound, c.beatsOn, c.poly);
const m = meters[meters.length - 1]; const m = meters[meters.length - 1];
m.mute = !!c.mute; setLaneEnabled(m, c.enabled !== undefined ? !!c.enabled : (c.mute === undefined ? true : !c.mute)); // back-compat with old "mute"
const cb = m.el.querySelector(`#m${m.id}_mute`); if (cb) cb.checked = m.mute;
} }
} }
// (Presets removed — set-list items are now the single "saved setup" mechanism.) // (Presets removed — set-list items are now the single "saved setup" mechanism.)
@ -650,17 +711,27 @@ let activeSL = 0; // selected set list
let activeItem = -1; // selected / loaded item in the active set list let activeItem = -1; // selected / loaded item in the active set list
let nowPlaying = null; // { at, name } for duration logging let nowPlaying = null; // { at, name } for duration logging
let historyName = null; // item whose past-session history is shown let historyName = null; // item whose past-session history is shown
let continueMode = lsGet(LS.continue, false); // auto-advance to next item when countdown ends
let timersOn = lsGet(LS.timers, true); // master switch for the elapsed/countdown timers
function currentSetup() { return { bpm: state.bpm, lanes: snapshotLanes(), trainer: { ...trainer }, ramp: { ...ramp } }; } function currentSetup() { return { bpm: state.bpm, lanes: snapshotLanes(), trainer: { ...trainer }, ramp: { ...ramp }, countMs: timers.totalMs }; }
function applySetup(s) { function applySetup(s) {
setBpm(s.bpm); applyLanes(s.lanes); setBpm(s.bpm); applyLanes(s.lanes);
if (s.trainer) Object.assign(trainer, s.trainer); if (s.trainer) Object.assign(trainer, s.trainer);
if (s.ramp) Object.assign(ramp, s.ramp); if (s.ramp) Object.assign(ramp, s.ramp);
timers.totalMs = s.countMs || 0; timers.remainingMs = timers.totalMs; // per-item countdown
syncPracticeUI(); updateCtx(); syncPracticeUI(); updateCtx();
} }
function syncPracticeUI() { function syncPracticeUI() {
$("trainerOn").checked = trainer.on; $("playBars").value = trainer.playBars; $("muteBars").value = trainer.muteBars; $("trainerOn").checked = trainer.on; $("playBars").value = trainer.playBars; $("muteBars").value = trainer.muteBars;
$("rampOn").checked = ramp.on; $("rampStart").value = ramp.startBpm; $("rampAmt").value = ramp.amount; $("rampEvery").value = ramp.everyBars; $("rampOn").checked = ramp.on; $("rampStart").value = ramp.startBpm; $("rampAmt").value = ramp.amount; $("rampEvery").value = ramp.everyBars;
$("countTime").value = timers.totalMs > 0 ? fmtClock(timers.totalMs) : "";
refreshFeatureBoxes(); renderTimers();
}
function refreshFeatureBoxes() {
$("trainerBox").classList.toggle("on", trainer.on);
$("rampBox").classList.toggle("on", ramp.on);
$("timerBox").classList.toggle("on", timersOn);
} }
function fmtDur(sec) { sec = Math.round(sec); const m = Math.floor(sec / 60); return m + ":" + String(sec % 60).padStart(2, "0"); } function fmtDur(sec) { sec = Math.round(sec); const m = Math.floor(sec / 60); return m + ":" + String(sec % 60).padStart(2, "0"); }
function getSL() { return setlists[activeSL]; } function getSL() { return setlists[activeSL]; }
@ -720,6 +791,7 @@ function toggleTransport() {
// --- now-playing info on the main screen (replaces the old preset dropdown) --- // --- now-playing info on the main screen (replaces the old preset dropdown) ---
function renderNowPlaying() { function renderNowPlaying() {
const sl = getSL(); const it = (sl && activeItem >= 0) ? sl.items[activeItem] : null; const sl = getSL(); const it = (sl && activeItem >= 0) ? sl.items[activeItem] : null;
$("saveItemBtn").disabled = !it; // single save button targets the loaded item
if (!it) { if (!it) {
$("npName").textContent = "Free play"; $("npName").textContent = "Free play";
$("npSub").textContent = "No set-list item loaded — edit the lanes freely."; $("npSub").textContent = "No set-list item loaded — edit the lanes freely.";
@ -727,7 +799,7 @@ function renderNowPlaying() {
return; return;
} }
$("npName").textContent = (activeItem + 1) + ". " + it.name; $("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(" · "); $("npSub").textContent = it.bpm + " BPM · " + it.lanes.map((l) => l.sound + " " + l.groupsStr + (l.poly ? "~" : "") + (l.enabled === false ? " (off)" : "")).join(" · ");
$("npDesc").textContent = (sl.title || "") + (sl.description ? " — " + sl.description : ""); $("npDesc").textContent = (sl.title || "") + (sl.description ? " — " + sl.description : "");
} }
@ -754,11 +826,9 @@ function renderItems() {
row.innerHTML = `<span class="nm">${i + 1}. ${it.name}</span> 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="meta">${it.bpm} · ${it.lanes.map((l) => l.groupsStr).join("/")}</span>
<span class="row-actions"> <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> <button class="x iconbtn" data-act="del" title="remove this item"></button>
</span>`; </span>`;
row.onclick = () => loadItem(i); 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); }; row.querySelector('[data-act=del]').onclick = (e) => { e.stopPropagation(); removeItem(i); };
box.appendChild(row); box.appendChild(row);
}); });
@ -777,11 +847,41 @@ function logFinalize() {
function renderLog() { function renderLog() {
const box = $("logView"); box.innerHTML = ""; const box = $("logView"); box.innerHTML = "";
if (!historyName) { box.innerHTML = '<div class="hint">Play a set-list item to see its history — compare BPM &amp; duration across days.</div>'; return; } if (!historyName) { box.innerHTML = '<div class="hint">Play a set-list item to see its history — compare BPM &amp; duration across days.</div>'; return; }
const logs = lsGet(LS.logs, []).filter((e) => e.name === historyName); const entries = lsGet(LS.logs, []).filter((e) => e.name === historyName);
let html = `<div class="log-head" style="margin-bottom:5px">History — ${historyName}</div>`;
if (!logs.length) html += '<div class="hint">No past sessions for this item yet.</div>'; const head = document.createElement("div"); head.className = "log-head";
else html += logs.map((e) => `<div class="log-seg">${new Date(e.at).toLocaleString()} · ${fmtDur(e.durationSec)} @ ${e.bpm}bpm</div>`).join(""); head.innerHTML = `<span class="log-head-nm">History — ${historyName}</span>`;
box.innerHTML = html; if (entries.length) {
const clr = document.createElement("button"); clr.className = "iconbtn"; clr.textContent = "Clear all";
clr.title = "delete all history for this item";
clr.onclick = () => clearItemHistory();
head.appendChild(clr);
}
box.appendChild(head);
if (!entries.length) {
const h = document.createElement("div"); h.className = "hint"; h.textContent = "No past sessions for this item yet.";
box.appendChild(h); return;
}
entries.forEach((e) => {
const row = document.createElement("div"); row.className = "hist-row";
const txt = document.createElement("span"); txt.className = "hist-txt";
txt.textContent = `${new Date(e.at).toLocaleString()} · ${fmtDur(e.durationSec)} @ ${e.bpm}bpm`;
const del = document.createElement("button"); del.className = "hist-del"; del.textContent = "✕";
del.title = "delete this entry";
del.onclick = () => deleteHistoryEntry(e.at);
row.appendChild(txt); row.appendChild(del); box.appendChild(row);
});
}
function deleteHistoryEntry(at) { // remove one session by its timestamp
const logs = lsGet(LS.logs, []).filter((e) => !(e.at === at && e.name === historyName));
lsSet(LS.logs, logs); renderLog();
}
function clearItemHistory() { // clear every session for the current item
if (!historyName) return;
if (!confirm("Clear all history for “" + historyName + "”? (other items, set lists & presets are kept)")) return;
const logs = lsGet(LS.logs, []).filter((e) => e.name !== historyName);
lsSet(LS.logs, logs); renderLog();
} }
function clearLog() { if (confirm("Clear the practice log? (set lists & presets are kept)")) { lsSet(LS.logs, []); renderLog(); } } function clearLog() { if (confirm("Clear the practice log? (set lists & presets are kept)")) { lsSet(LS.logs, []); renderLog(); } }
function resetAll() { function resetAll() {
@ -817,21 +917,20 @@ function importAll(file) {
/* ========================================================================= /* =========================================================================
SHARE LANGUAGE (compact, human-readable; encodes settings/set lists in URLs) 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>] Patch: v1;t<bpm>;vol<pct>;<lane>;…[;tr<play>/<mute>][;rmp<start>/<step>/<every>]
Lane: <sound>:<grouping>[/<sub>][=<pattern x/.>][~ poly][! mute] Lane: <sound>:<grouping>[/<sub>][=<pattern x/.>][~ poly][! disabled]
========================================================================= */ ========================================================================= */
function laneCfgToStr(c) { function laneCfgToStr(c) {
const bpb = parseGroups(c.groupsStr).beatsPerBar;
let s = c.sound + ":" + c.groupsStr; let s = c.sound + ":" + c.groupsStr;
if ((c.stepsPerBeat || 1) !== 1) s += "/" + c.stepsPerBeat; if ((c.stepsPerBeat || 1) !== 1) s += "/" + c.stepsPerBeat;
const on = (c.beatsOn || []).slice(0, bpb); const on = c.beatsOn || []; // per-step mask; one char per pad
if (on.length && !on.every(Boolean)) s += "=" + Array.from({ length: bpb }, (_, i) => (on[i] ? "x" : ".")).join(""); if (on.length && !on.every(Boolean)) s += "=" + on.map((v) => (v ? "x" : ".")).join("");
if (c.poly) s += "~"; if (c.poly) s += "~";
if (c.mute) s += "!"; if (c.enabled === false) s += "!"; // "!" = silenced / disabled
return s; return s;
} }
function laneStrToCfg(tok) { function laneStrToCfg(tok) {
let poly = false, mute = false; let poly = false, disabled = false;
while (/[~!]$/.test(tok)) { if (tok.endsWith("!")) mute = true; else poly = true; tok = tok.slice(0, -1); } while (/[~!]$/.test(tok)) { if (tok.endsWith("!")) disabled = true; else poly = true; tok = tok.slice(0, -1); }
const ci = tok.indexOf(":"); if (ci < 0) return null; const ci = tok.indexOf(":"); if (ci < 0) return null;
let sound = tok.slice(0, ci), rest = tok.slice(ci + 1), pattern = 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); } const eq = rest.indexOf("="); if (eq >= 0) { pattern = rest.slice(eq + 1); rest = rest.slice(0, eq); }
@ -841,22 +940,24 @@ function laneStrToCfg(tok) {
const beatsOn = pattern ? pattern.split("").map((ch) => ch === "x" || ch === "X" || ch === "1") const beatsOn = pattern ? pattern.split("").map((ch) => ch === "x" || ch === "X" || ch === "1")
: new Array(bpb).fill(true); : new Array(bpb).fill(true);
if (!DRUMS[sound]) sound = "beep"; if (!DRUMS[sound]) sound = "beep";
return { groupsStr, stepsPerBeat: sub, sound, beatsOn, poly, mute }; return { groupsStr, stepsPerBeat: sub, sound, beatsOn, poly, enabled: !disabled };
} }
function setupToPatch(s) { function setupToPatch(s) {
const parts = ["v1", "t" + s.bpm]; const parts = ["v1", "t" + s.bpm];
if (s.volume != null) parts.push("vol" + Math.round(s.volume * 100)); if (s.volume != null) parts.push("vol" + Math.round(s.volume * 100));
if (s.countMs > 0) parts.push("cd" + Math.round(s.countMs / 1000));
(s.lanes || []).forEach((c) => parts.push(laneCfgToStr(c))); (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.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); if (s.ramp && s.ramp.on) parts.push("rmp" + s.ramp.startBpm + "/" + s.ramp.amount + "/" + s.ramp.everyBars);
return parts.join(";"); return parts.join(";");
} }
function patchToSetup(str) { 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 } }; const s = { bpm: 120, volume: null, countMs: 0, lanes: [], trainer: { on: false, playBars: 2, muteBars: 2 }, ramp: { on: false, startBpm: 80, amount: 5, everyBars: 4 } };
for (let tok of String(str).split(";")) { for (let tok of String(str).split(";")) {
tok = tok.trim(); if (!tok || tok === "v1") continue; tok = tok.trim(); if (!tok || tok === "v1") continue;
if (tok.includes(":")) { const c = laneStrToCfg(tok); if (c) s.lanes.push(c); } 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("vol")) s.volume = (parseInt(tok.slice(3), 10) || 0) / 100;
else if (tok.startsWith("cd")) s.countMs = (parseInt(tok.slice(2), 10) || 0) * 1000;
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("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("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; else if (tok.startsWith("t")) s.bpm = parseInt(tok.slice(1), 10) || 120;
@ -881,23 +982,17 @@ function codeToSetlist(code) {
} }
function shareLink(hashPart) { return location.origin + location.pathname + "#" + hashPart; } 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) { function openShare(title, url, note) {
$("shareTitle").textContent = title; $("shareTitle").textContent = title;
$("shareUrl").value = url; $("shareUrl").value = url;
$("shareNote").textContent = note || ""; $("shareNote").textContent = note || "";
renderQR($("shareQr"), url); $("shareExtBanner").hidden = true; // reset the external-QR warning
$("shareOverlay").hidden = false; $("shareOverlay").hidden = false;
} }
function shareSettings() { openShare("Share settings", shareLink("p=" + currentPatch()), "Encodes tempo, lanes & practice settings. Scan the QR or copy the link."); } function shareSettings() { openShare("Share settings", shareLink("p=" + currentPatch()), "Encodes tempo, lanes & practice settings."); }
function shareSetlist() { function shareSetlist() {
const sl = getSL(); if (!sl) return alert("No set list selected to share."); 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."); openShare("Share “" + (sl.title || "set list") + "”", shareLink("sl=" + setlistToCode(sl)), "Shares the whole set list (each item's settings).");
} }
// Apply a shared link on load. Returns true if it set the metronome state. // Apply a shared link on load. Returns true if it set the metronome state.
@ -938,7 +1033,7 @@ function drawLoop() {
if (audioCtx) { if (audioCtx) {
const now = audioCtx.currentTime; const now = audioCtx.currentTime;
for (const m of meters) { for (const m of meters) {
while (m.vqPtr < m.vq.length && m.vq[m.vqPtr].time <= now) { m.currentBeat = m.vq[m.vqPtr].beat; m.currentBar = m.vq[m.vqPtr].bar; m.vqPtr++; } while (m.vqPtr < m.vq.length && m.vq[m.vqPtr].time <= now) { m.currentStep = m.vq[m.vqPtr].step; m.currentBar = m.vq[m.vqPtr].bar; m.vqPtr++; }
if (m.vqPtr > 512) { m.vq = m.vq.slice(m.vqPtr); m.vqPtr = 0; } if (m.vqPtr > 512) { m.vq = m.vq.slice(m.vqPtr); m.vqPtr = 0; }
} }
updateStatus(now); updateStatus(now);
@ -951,22 +1046,44 @@ function drawLoop() {
/* ========================================================================= /* =========================================================================
PRACTICE TIMERS — advance only while the metronome is running PRACTICE TIMERS — advance only while the metronome is running
========================================================================= */ ========================================================================= */
const timers = { elapsedMs: 0, totalMs: 5 * 60000, remainingMs: 5 * 60000, last: 0 }; const timers = { elapsedMs: 0, totalMs: 0, remainingMs: 0, last: 0 }; // countdown off by default
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 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"); }
// Parse a countdown duration: blank = off; "h:mm:ss" / "m:ss" (seconds-last); a plain number = minutes.
function parseTime(str) {
str = (str || "").trim(); if (!str) return 0;
if (!str.includes(":")) { const m = parseFloat(str); return isFinite(m) && m > 0 ? Math.round(m * 60000) : 0; }
const p = str.split(":").map((x) => parseInt(x, 10) || 0);
let h = 0, m = 0, s = 0;
if (p.length >= 3) { h = p[0]; m = p[1]; s = p[2]; } else { m = p[0]; s = p[1]; }
const ms = ((h * 60 + m) * 60 + s) * 1000;
return ms > 0 ? ms : 0;
}
function tickTimers() { function tickTimers() {
const now = Date.now(); const now = Date.now();
const dt = timers.last ? Math.min(now - timers.last, 1000) : 0; // clamp so backgrounded gaps don't jump const dt = timers.last ? Math.min(now - timers.last, 1000) : 0; // clamp so backgrounded gaps don't jump
timers.last = now; timers.last = now;
if (state.running) { if (timersOn && state.running) {
timers.elapsedMs += dt; timers.elapsedMs += dt;
if (timers.totalMs > 0) timers.remainingMs -= dt; // counts past 0 into negative (overtime); never stops the metronome if (timers.totalMs > 0) {
const before = timers.remainingMs;
timers.remainingMs -= dt;
if (before > 0 && timers.remainingMs <= 0 && continueMode) { // countdown hit 0 → auto-advance
const sl = getSL();
if (sl && activeItem >= 0 && activeItem + 1 < sl.items.length) loadItem(activeItem + 1);
}
// otherwise it keeps counting past 0 into negative (overtime); never stops the metronome
}
} }
renderTimers(); renderTimers();
} }
function renderTimers() { function renderTimers() {
$("dtimers").hidden = !timersOn;
if (!timersOn) return;
$("elapsedVal").textContent = fmtClock(timers.elapsedMs); $("elapsedVal").textContent = fmtClock(timers.elapsedMs);
const off = timers.totalMs <= 0;
$("countWrap").hidden = off; // hide countdown when off
if (off) return;
const cd = $("countVal"); const cd = $("countVal");
if (timers.totalMs <= 0) { cd.textContent = "off"; cd.className = "tval"; return; }
cd.textContent = fmtClock(timers.remainingMs); cd.textContent = fmtClock(timers.remainingMs);
cd.classList.toggle("over", timers.remainingMs <= 0); // overtime cd.classList.toggle("over", timers.remainingMs <= 0); // overtime
cd.classList.toggle("low", timers.remainingMs > 0 && timers.remainingMs <= 10000); // almost up cd.classList.toggle("low", timers.remainingMs > 0 && timers.remainingMs <= 10000); // almost up
@ -1024,22 +1141,29 @@ function tapTempo() {
} }
} }
$("tapBtn").addEventListener("click", tapTempo); $("tapBtn").addEventListener("click", tapTempo);
$("saveItemBtn").addEventListener("click", () => {
if (activeItem < 0) return;
updateItem(activeItem);
const b = $("saveItemBtn"), t = b.textContent; b.textContent = "✓ Saved"; setTimeout(() => { b.textContent = t; }, 900);
});
$("bpm").addEventListener("input", (e) => setBpm(+e.target.value)); $("bpm").addEventListener("input", (e) => setBpm(+e.target.value));
$("vol").addEventListener("input", (e) => { $("vol").addEventListener("input", (e) => {
state.volume = +e.target.value / 100; volVal.textContent = e.target.value + "%"; state.volume = +e.target.value / 100; volVal.textContent = e.target.value + "%";
if (masterGain) masterGain.gain.setTargetAtTime(state.volume, audioCtx.currentTime, 0.01); if (masterGain) masterGain.gain.setTargetAtTime(state.volume, audioCtx.currentTime, 0.01);
}); });
$("trainerOn").addEventListener("change", (e) => trainer.on = e.target.checked); $("trainerOn").addEventListener("change", (e) => { trainer.on = e.target.checked; refreshFeatureBoxes(); });
$("playBars").addEventListener("input", (e) => trainer.playBars = +e.target.value); $("playBars").addEventListener("input", (e) => trainer.playBars = +e.target.value);
$("muteBars").addEventListener("input", (e) => trainer.muteBars = +e.target.value); $("muteBars").addEventListener("input", (e) => trainer.muteBars = +e.target.value);
$("rampOn").addEventListener("change", (e) => ramp.on = e.target.checked); $("rampOn").addEventListener("change", (e) => { ramp.on = e.target.checked; refreshFeatureBoxes(); });
$("rampStart").addEventListener("input", (e) => ramp.startBpm = +e.target.value); $("rampStart").addEventListener("input", (e) => ramp.startBpm = +e.target.value);
$("rampAmt").addEventListener("input", (e) => ramp.amount = +e.target.value); $("rampAmt").addEventListener("input", (e) => ramp.amount = +e.target.value);
$("rampEvery").addEventListener("input", (e) => ramp.everyBars = +e.target.value); $("rampEvery").addEventListener("input", (e) => ramp.everyBars = +e.target.value);
$("addMeterBtn").addEventListener("click", () => addMeter("4", 1, "claves")); $("addMeterBtn").addEventListener("click", () => addMeter("4", 1, "claves"));
$("countMin").addEventListener("input", (e) => { timers.totalMs = (+e.target.value || 0) * 60000; timers.remainingMs = timers.totalMs; renderTimers(); }); $("countTime").addEventListener("input", (e) => { timers.totalMs = parseTime(e.target.value); timers.remainingMs = timers.totalMs; renderTimers(); });
$("elapsedReset").addEventListener("click", () => { timers.elapsedMs = 0; renderTimers(); }); $("elapsedReset").addEventListener("click", () => { timers.elapsedMs = 0; renderTimers(); });
$("countReset").addEventListener("click", () => { timers.remainingMs = timers.totalMs; renderTimers(); }); $("countReset").addEventListener("click", () => { timers.remainingMs = timers.totalMs; renderTimers(); });
$("continueMode").addEventListener("change", (e) => { continueMode = e.target.checked; lsSet(LS.continue, continueMode); });
$("timersOn").addEventListener("change", (e) => { timersOn = e.target.checked; lsSet(LS.timers, timersOn); refreshFeatureBoxes(); renderTimers(); });
$("trayMenuBtn").addEventListener("click", (e) => { e.stopPropagation(); $("trayMenu").hidden = !$("trayMenu").hidden; }); $("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; }); 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); $("newSetlistBtn").addEventListener("click", newSetlist);
@ -1062,24 +1186,31 @@ $("shareClose").addEventListener("click", () => $("shareOverlay").hidden = true)
$("shareOverlay").addEventListener("click", (e) => { if (e.target.id === "shareOverlay") $("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(); } }); $("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")); $("shareOpen").addEventListener("click", () => window.open($("shareUrl").value, "_blank"));
$("shareQrExt").addEventListener("click", () => {
$("shareExtBanner").hidden = false; // warn that we're handing the link to a third party
window.open("https://api.qrserver.com/v1/create-qr-code/?size=320x320&data=" + encodeURIComponent($("shareUrl").value), "_blank", "noopener");
});
window.addEventListener("keydown", (e) => { window.addEventListener("keydown", (e) => {
const t = e.target; const t = e.target, tag = t ? t.tagName : "", type = (t && t.type ? String(t.type) : "").toLowerCase();
if (t && (t.tagName === "INPUT" || t.tagName === "SELECT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return; // Text entry is sacred — never hijack typing in a text field.
if (t && (t.isContentEditable || tag === "TEXTAREA" ||
(tag === "INPUT" && /^(text|number|search|email|url|tel|password)$/.test(type)))) return;
const k = e.key; const k = e.key;
if (e.altKey && (k === "ArrowUp" || k === "ArrowDown")) { e.preventDefault(); moveActiveItem(k === "ArrowUp" ? -1 : 1); return; } // reorder selected item 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.metaKey || e.ctrlKey || e.altKey) return;
if (e.code === "Space") { e.preventDefault(); toggleTransport(); } // Transport: Space always = play/stop. preventDefault so it never scrolls the
else if (k === "t" || k === "T") { tapTempo(); } // page, toggles a focused checkbox, or re-fires a focused button.
else if (k === "ArrowUp") { e.preventDefault(); setBpm(state.bpm + (e.shiftKey ? 10 : 1)); } if (k === " " || e.code === "Space") { e.preventDefault(); toggleTransport(); return; }
else if (k === "ArrowDown") { e.preventDefault(); setBpm(state.bpm - (e.shiftKey ? 10 : 1)); } // Leave arrow keys to a focused slider / menu so they still adjust it.
else if (k === "a" || k === "A") { addMeter("4", 1, "claves"); } const arrowCtrl = tag === "SELECT" || (tag === "INPUT" && type === "range");
else if (k === "n" || k === "N") { nextItem(); } if (k === "ArrowUp") { if (arrowCtrl) return; e.preventDefault(); setBpm(state.bpm + (e.shiftKey ? 10 : 1)); return; }
else if (k === "?") { toggleShortcuts(true); } if (k === "ArrowDown") { if (arrowCtrl) return; e.preventDefault(); setBpm(state.bpm - (e.shiftKey ? 10 : 1)); return; }
else if (k === "Escape") { if (!$("shareOverlay").hidden) $("shareOverlay").hidden = true; else if (!$("shortcutsOverlay").hidden) toggleShortcuts(false); } if (k === "t" || k === "T") { tapTempo(); return; }
else if (k >= "1" && k <= "9") { if (k === "a" || k === "A") { addMeter("4", 1, "claves"); return; }
const m = meters[+k - 1]; if (k === "n" || k === "N") { nextItem(); return; }
if (m) { m.mute = !m.mute; const cb = m.el.querySelector(`#m${m.id}_mute`); if (cb) cb.checked = m.mute; } if (k === "?") { toggleShortcuts(true); return; }
} if (k === "Escape") { if (!$("shareOverlay").hidden) $("shareOverlay").hidden = true; else if (!$("shortcutsOverlay").hidden) toggleShortcuts(false); return; }
if (k >= "1" && k <= "9") { const m = meters[+k - 1]; if (m) setLaneEnabled(m, !m.enabled); }
}); });
/* init */ /* init */
@ -1099,6 +1230,9 @@ if (!applyHashShare()) {
renderSetlists(); renderSetlists();
renderLog(); renderLog();
updateCtx(); updateCtx();
refreshFeatureBoxes();
$("continueMode").checked = continueMode;
$("timersOn").checked = timersOn;
$("appVersion").textContent = "v" + APP_VERSION; $("appVersion").textContent = "v" + APP_VERSION;
requestAnimationFrame(drawLoop); requestAnimationFrame(drawLoop);
</script> </script>

2297
qrcode.js

File diff suppressed because it is too large Load diff