Layout fixes (user reported BPM/time were still bumping the header at 0.0.3): - All Y coords below the header divider shifted down 6px: BPM 30->38, time 32->38, bar 44->50, train 52->58, setlist tab 66->72, title 82->88. - GRID_TOP 104 -> 110. Restored the Kit-style footer practice log: - LOG_TOP=218, LOG_ROWH=14, LOG_ROWS=6. - MAXLANES dropped from 6 visible to 4 visible (rowh capped at 26 so the grid doesn't run into the log). Tracks with more lanes still play silently. - _build_scene now appends g_log (with a divider above it). - draw_log() draws the current-track log into the footer; load() + _log_play() + the seam apply path all call it. The Practice-log menu entry is kept for the full scrollable history. Editor diagnostics for the firmware push (the user got chunk-1 ACK then the device's MIDI badge went gray, meaning chunks 2+ never reached it): - editor.html + editor-beta.html _pushFirmware() now logs every MIDI output + input it sees along with which ones _isDevicePort() matched, plus per- chunk send/ACK timing for the first 3 chunks and any failed chunk. - This narrows down whether the failure is (a) wrong-port routing (filter doesn't match the Pimoroni Explorer's name), (b) ACK never arriving back to the host, or (c) chunks sent fine but the device's RX buffer is dropping them. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1646 lines
102 KiB
HTML
1646 lines
102 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>PM_E‑1 — PolyMeter Editor · Live Sync (Beta)</title>
|
||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,@BUILD:favicon@">
|
||
<script>
|
||
/* ?embed=1 → strip site chrome (base.css [data-embed]) + auto-size to the host */
|
||
(function(){ if(!/[?&]embed=1/.test(location.search)) return;
|
||
document.documentElement.dataset.embed="1";
|
||
function ph(){ try{ parent.postMessage({type:"varasys-h",h:Math.ceil(document.documentElement.getBoundingClientRect().height)},"*"); }catch(e){} }
|
||
addEventListener("load",ph); addEventListener("resize",ph); setTimeout(ph,300); setTimeout(ph,1000);
|
||
})();
|
||
</script>
|
||
<script>
|
||
// 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 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>
|
||
<!--
|
||
Stackable Metronome — a polymetric groove trainer / metronome.
|
||
Copyright (C) 2026 Varasys
|
||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||
|
||
This program is free software: you can redistribute it and/or modify it
|
||
under the terms of the GNU Affero General Public License as published by
|
||
the Free Software Foundation, either version 3 of the License, or (at your
|
||
option) any later version. It is distributed WITHOUT ANY WARRANTY; without
|
||
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||
PURPOSE. See the GNU Affero General Public License for more details. You
|
||
should have received a copy of the license along with this program (see the
|
||
LICENSE file); if not, see <https://www.gnu.org/licenses/>.
|
||
-->
|
||
<!--
|
||
The full browser app for the Stackable Metronome. (The play-only Pi Pico
|
||
hardware build is mocked up separately as the "player" — see player.html.)
|
||
|
||
DESIGN: a basic metronome (tempo / volume / start-stop) PLUS an arbitrary
|
||
number of "meter lanes". Each lane is its own little metronome with a
|
||
grouping (e.g. 2+2+3), a subdivision, and a sound. All lanes share the global
|
||
tempo; layering lanes with different groupings/subdivisions is what creates
|
||
polymeter / polyrhythm — no special "voice" or ratio mode required.
|
||
|
||
Functions marked PORTS TO FIRMWARE carry over to the RP2040 with little change.
|
||
Web Audio's look-ahead scheduler stands in for the hardware timer.
|
||
-->
|
||
<style>
|
||
/*@BUILD:include:src/base.css@*/
|
||
:root {
|
||
--bg:#14171c; --bg2:#1b212b; --panel:#1d222b; --panel-2:#242b36; --edge:#333d4b;
|
||
--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:#cdd6e0; --ring:#16202c;
|
||
}
|
||
body {
|
||
margin:0; padding:24px; min-height:100vh;
|
||
background: radial-gradient(circle at 50% -10%, var(--bg2), var(--bg));
|
||
color: var(--txt);
|
||
}
|
||
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:13px; font-family:"Courier New",monospace; text-align:left; line-height:1.75; }
|
||
.kbd-legend span { white-space:nowrap; } /* wrap only between shortcut groups, never mid-token */
|
||
.appheader-ctrls { margin-left:auto; } /* push controls right on wide; narrow media query resets to left */
|
||
#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:80px; color:#ffd166; letter-spacing:2px; line-height:1.0; text-shadow:0 0 12px rgba(255,209,102,.5); }
|
||
.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; }
|
||
.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; }
|
||
input[type=range] { width:100%; accent-color:var(--hot); }
|
||
.btnrow { display:flex; gap:10px; flex-wrap:wrap; }
|
||
/* VARASYS brand lockup — show the tagline variant that matches the theme */
|
||
.brand-logo { height:40px; width:auto; display:block; }
|
||
.brand-light { display:none; }
|
||
:root[data-theme="light"] .brand-dark { display:none; }
|
||
:root[data-theme="light"] .brand-light { display:block; }
|
||
button { background:var(--panel); color:var(--txt); border:1px solid var(--edge); border-radius:8px; padding:9px 14px; font-size:13px; cursor:pointer; transition:.12s; }
|
||
button:hover { border-color:var(--muted); }
|
||
button.primary { background:#2e7d32; border-color:#2e7d32; color:#fff; font-weight:600; }
|
||
button.primary.on { background:#c0392b; border-color:#c0392b; }
|
||
button.add { background:#2c3a4d; border-color:#3b5168; color:#cfe3ff; font-weight:600; }
|
||
.seg { display:inline-flex; border:1px solid var(--edge); border-radius:8px; overflow:hidden; }
|
||
.seg button { border:none; border-radius:0; padding:8px 10px; }
|
||
.seg button.active { background:var(--hot); color:#000; }
|
||
input[type=text].txt { background:var(--panel); color:var(--txt); border:1px solid var(--edge); border-radius:8px; padding:8px; font-size:13px; font-family:"Courier New",monospace; }
|
||
.checkrow, .mini-check { display:flex; align-items:center; gap:8px; font-size:13px; }
|
||
.mini-check { color:var(--muted); }
|
||
/* LED strip */
|
||
.strip { display:flex; gap:6px; flex-wrap:wrap; }
|
||
.led { width:30px; height:30px; border-radius:7px; background:var(--led-off); border:1px solid #000; position:relative;
|
||
display:flex; align-items:center; justify-content:center; font-size:9px; color:#4a5562; cursor:default; transition:background .04s, box-shadow .04s; }
|
||
.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.ghost { opacity:.4; box-shadow:none; } /* ghost note — lit but faint */
|
||
.led.ghost::after { content:"·"; position:absolute; top:-4px; font-size:13px; color:#fff; }
|
||
.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::before { content:""; position:absolute; left:-9px; top:4px; bottom:4px; width:2px; background:var(--muted); }
|
||
/* meter lanes — compact single-row controls + strip */
|
||
#meters { display:flex; flex-direction:column; gap:10px; }
|
||
.meter-card { background:var(--panel-2); border:1px solid var(--edge); border-radius:12px; padding:9px 14px; }
|
||
.lane-row { display:flex; gap:9px; align-items:center; flex-wrap:wrap; margin-bottom:0; }
|
||
.lane-row .strip { margin-left:4px; }
|
||
.lane-title { font-weight:700; font-size:13px; min-width:28px; }
|
||
.txt.grp { width:80px; text-align:center; }
|
||
.sum { font-family:"Courier New",monospace; font-size:12px; color:var(--muted); min-width:24px; }
|
||
.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; }
|
||
.gain { font-size:11px; color:var(--muted); border:1px solid var(--edge); border-radius:8px; padding:6px 7px;
|
||
cursor:ns-resize; user-select:none; min-width:42px; text-align:center; font-variant-numeric:tabular-nums;
|
||
touch-action:none; background:var(--panel); }
|
||
.gain:hover { color:var(--txt); }
|
||
.gain.boost { color:#5fd08a; border-color:#2f6b48; }
|
||
.gain.cut { color:#ff9a8a; border-color:#7a3b34; }
|
||
.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: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; }
|
||
|
||
/* subtle live program string: shows the current patch; editable + copy/paste.
|
||
Faded by default so it doesn't compete with the editor; lights up on hover/focus. */
|
||
.patchbar { display:flex; align-items:center; gap:9px; max-width:1100px; margin:16px auto 0;
|
||
padding:6px 11px; border:1px solid var(--edge); border-radius:9px; background:var(--panel);
|
||
opacity:.55; transition:opacity .15s, box-shadow .25s; }
|
||
.patchbar:hover, .patchbar:focus-within { opacity:1; }
|
||
.patchbar > label { flex:0 0 auto; font-size:10px; text-transform:uppercase; letter-spacing:.09em; color:var(--muted); }
|
||
.patchbar input { flex:1; min-width:0; background:transparent; border:0; outline:none; color:var(--txt);
|
||
font-family:"Courier New",monospace; font-size:12px; padding:3px 0; }
|
||
.patchbar button { flex:0 0 auto; font-size:11px; padding:4px 10px; }
|
||
.patchbar.applied { box-shadow:0 0 0 2px var(--cyan) inset; }
|
||
.patchbar.err input { color:#ff8a7a; }
|
||
.patch-msg { flex:0 1 auto; font-size:11px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:46%; }
|
||
.patch-msg.ok { color:#5fd08a; } .patch-msg.bad { color:#ff8a7a; }
|
||
[data-embed] .patchbar { display:none !important; }
|
||
.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; } /* loaded / playing */
|
||
.ex-item.cued { outline:2px solid #ffb454; outline-offset:-2px; } /* cue cursor (coexists with .active) */
|
||
.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:5px; display:flex; align-items:center; gap:8px; }
|
||
.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; }
|
||
/* 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:inherit; color:var(--hot); min-width:64px; }
|
||
.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; }
|
||
.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; }
|
||
/* --- responsive --- */
|
||
@media (max-width: 760px) {
|
||
body { padding: 12px; }
|
||
.device { padding: 13px; border-radius:12px; }
|
||
.row { gap: 14px; }
|
||
/* when the practice column wraps under the others, swap its side rule for a top one */
|
||
.practice-col { border-left:none; padding-left:0; border-top:1px solid var(--edge); padding-top:12px; margin-top:4px; }
|
||
}
|
||
@media (max-width: 580px) { /* narrow: stack the header with logo + buttons above the title */
|
||
.appheader { flex-direction: column-reverse; align-items: flex-start !important; flex-wrap: nowrap !important; } /* !important beats the inline align-items/flex-wrap */
|
||
.appheader .appheader-ctrls { margin-left: 0; }
|
||
}
|
||
@media (max-width: 620px) {
|
||
#routineTray { width:100%; }
|
||
.meter-card .led { width:24px; height:24px; }
|
||
}
|
||
.num { width:54px; background:var(--panel); color:var(--txt); border:1px solid var(--edge); border-radius:8px; padding:6px 6px; font-size:13px; text-align:center; }
|
||
#helpBtn { padding:4px 11px; }
|
||
.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); }
|
||
#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 td { padding:6px 4px; border-bottom:1px solid var(--edge); }
|
||
.kbd-table tr:last-child td { border-bottom:none; }
|
||
.kbd-table td:first-child { width:100px; white-space:nowrap; }
|
||
kbd { background:var(--panel); border:1px solid var(--edge); border-bottom-width:2px; border-radius:5px; padding:2px 7px; font-family:"Courier New",monospace; font-size:12px; color:var(--txt); }
|
||
.setlist-fields textarea { width:100%; background:var(--panel); color:var(--txt); border:1px solid var(--edge); border-radius:8px; padding:8px; font-size:12px; resize:none; overflow:hidden; min-height:34px; box-sizing:border-box; }
|
||
.play { background:#2e7d32; border-color:#2e7d32; color:#fff; padding:3px 10px; }
|
||
.stop { background:#c0392b; border-color:#c0392b; color:#fff; padding:3px 10px; }
|
||
.menu { position:absolute; top:36px; right:0; background:var(--panel-2); border:1px solid var(--edge); border-radius:10px; padding:6px; display:flex; flex-direction:column; gap:4px; box-shadow:0 12px 30px rgba(0,0,0,.5); z-index:70; min-width:150px; }
|
||
.menu[hidden] { display:none; }
|
||
.menu button { text-align:left; }
|
||
/* embed mode: drop the header + legend, keep the editor */
|
||
[data-embed] .appheader, [data-embed] .kbd-legend { display:none !important; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
/*@BUILD:include:src/header.html@*/
|
||
|
||
<div id="app">
|
||
<div class="device">
|
||
<div class="row appheader" style="align-items:center; flex-wrap:wrap; gap:6px 14px; margin-bottom:8px">
|
||
<h1 style="margin:0">PM_E‑1 <span style="font-weight:400; opacity:.75">PolyMeter Editor</span> <span style="font-size:11px; font-weight:600; letter-spacing:.5px; color:#fff; background:#7b3fb8; border-radius:6px; padding:2px 7px; vertical-align:middle">LIVE SYNC β</span></h1>
|
||
<div class="appheader-ctrls" style="display:flex; align-items:center; gap:10px">
|
||
<button id="helpBtn" title="keyboard shortcuts (?)">?</button>
|
||
</div>
|
||
</div>
|
||
<div class="kbd-legend" style="margin-bottom:12px"><span>Space play</span> <span>· T tap</span> <span>· ←→ tempo</span> <span>· ↑↓ cue</span> <span>· ⏎ commit</span> <span>· N/P step</span> <span>· A add</span> <span>· ? help</span></div>
|
||
|
||
<!-- Transport: display + preset/tempo/volume + practice, in three columns -->
|
||
<div class="row">
|
||
<div class="card" style="flex:1">
|
||
<div class="row" style="gap:22px; align-items:flex-start">
|
||
<div style="flex:0 0 260px; min-width:230px">
|
||
<div class="display">
|
||
<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="time countdown" hidden>⏳ <span id="countVal" class="tval">0:00</span></span>
|
||
<span id="barWrap" title="bars remaining in this segment" hidden>▦ <span id="barVal" class="tval">0</span></span>
|
||
</div>
|
||
<div class="ctx" id="ctxDisplay"> </div>
|
||
</div>
|
||
<div class="btnrow" style="margin-top:10px"><button class="primary" id="startBtn">▶ Start</button><button id="tapBtn">Tap</button><span id="saveItemWrap" style="display:inline-flex" title="Select a set-list item to enable Save"><button id="saveItemBtn" disabled>💾 Save</button></span><button id="midiBtn" title="Play a connected PM_K-1 device through this computer's speakers (Web MIDI · Chrome/Edge)">🎹 Device audio</button><button id="syncBtn" title="Mirror a connected PM_K-1 live — edits, transport & tempo flow both ways (Web MIDI · Chrome/Edge/Firefox)">🔗 Live sync</button></div>
|
||
</div>
|
||
|
||
<div style="flex:1; min-width:200px">
|
||
<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>
|
||
</div>
|
||
|
||
<div class="practice-col" style="flex:1; min-width:215px">
|
||
<div class="fbox toggleable" id="trainerBox">
|
||
<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">Mute <input type="number" class="num" id="muteBars" min="0" max="16" value="2"> bars</label>
|
||
</div>
|
||
</div>
|
||
<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" 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>
|
||
<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">
|
||
<label style="font-size:12px">Elapsed (stopwatch)</label>
|
||
<button class="iconbtn" id="elapsedReset" title="reset elapsed">⟲</button>
|
||
</div>
|
||
<div class="row" style="gap:10px; align-items:center; margin-top:6px">
|
||
<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>
|
||
<button class="iconbtn" id="countReset" title="reset countdown">⟲</button>
|
||
</div>
|
||
<div class="row" style="gap:10px; align-items:center; margin-top:6px">
|
||
<label style="font-size:12px">Bars <input type="number" class="num" id="segBarsIn" min="0" max="999" value="0" title="segment length in bars (0 = manual). With Continue on, auto-advances to the next item at the bar boundary." style="width:64px"></label>
|
||
<span class="hint" style="margin:0">0 = manual</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<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>
|
||
<span class="hint" style="margin:0; flex:1">Each beat splits into <i>subdivision</i> pads — click a pad to cycle <b>accent → normal → ghost → mute</b>. Pick a <i>swing</i> subdivision for a triplet feel.</span>
|
||
</div>
|
||
<div id="meters"></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;
|
||
status now shows under the BPM in the display) -->
|
||
</div>
|
||
|
||
<!-- 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>
|
||
<div id="trayMenu" class="menu" hidden>
|
||
<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="saveDeviceBtn" title="Sync your own set lists to the PM_K-1 (the built-in playlists are baked into its firmware)">📟 Save to device</button>
|
||
<button id="loadDeviceBtn" title="Load a device's programs.json into a new set list">📥 Load from device</button>
|
||
<button id="updateFwBtn" title="Check & update the PM_K-1 firmware over USB-MIDI (A/B with auto-rollback)">⬆ Update firmware…</button>
|
||
<button id="clearLogBtn">🗑 Clear log</button>
|
||
<button id="resetAllBtn" style="color:#ff7b6b">♻ Reset everything…</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- editable set-list selector: rename the active list in place; ▾ switches / creates -->
|
||
<div class="lane-row" style="margin-bottom:8px; position:relative">
|
||
<input type="text" class="txt" id="slTitle" placeholder="set list title" style="flex:1; text-align:left">
|
||
<button id="slMenuBtn" title="switch or create a set list" aria-haspopup="true">▾</button>
|
||
<button class="x" id="delSetlistBtn" title="delete set list" style="margin-left:0">✕</button>
|
||
<div id="slMenu" class="menu" hidden style="top:38px; left:0; right:auto; max-height:240px; overflow:auto"></div>
|
||
</div>
|
||
<div class="setlist-fields">
|
||
<textarea id="slDesc" placeholder="description / notes" rows="1"></textarea>
|
||
</div>
|
||
<label class="mini-check" title="when a playing item reaches the end of its countdown or its bar-length, auto-advance to the next item (smooth cutover at the next bar) — give items a countdown or bar count to auto-play a whole song/set" style="margin:8px 0 6px"><input type="checkbox" id="continueMode"> Continue — auto-advance (countdown / bars)</label>
|
||
<div id="itemList"></div>
|
||
<div class="lane-row" style="margin:10px 0 6px">
|
||
<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>
|
||
</div>
|
||
<div class="hint" style="margin-top:6px"><kbd>Alt+↑/↓</kbd> reorder · 💾 save settings to an item.</div>
|
||
|
||
<div id="logView" style="margin-top:18px"></div>
|
||
</aside>
|
||
</div><!-- /#app -->
|
||
|
||
/*@BUILD:include:src/footer.html@*/
|
||
|
||
<!-- Live program string for what's loaded: editable, copy & paste (plain text or base64 set-list code). -->
|
||
<div class="patchbar" id="patchBar" title="Program string for what's loaded — edit and press Enter, or paste a patch / base64 set-list code; it's decoded and checked">
|
||
<label for="patchField">program</label>
|
||
<input id="patchField" spellcheck="false" autocomplete="off" autocapitalize="off"
|
||
placeholder="v1;t120;kick:4;snare:4=.X.X;hat:4/2 (or paste a base64 set-list code)" aria-label="program string (editable)">
|
||
<span id="patchMsg" class="patch-msg"></span>
|
||
<button id="patchCopy" title="Copy the program string">Copy</button>
|
||
</div>
|
||
|
||
<div id="shortcutsOverlay" class="overlay" hidden>
|
||
<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>
|
||
<table class="kbd-table">
|
||
<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>←</kbd> <kbd>→</kbd></td><td>Tempo ±1 BPM (<kbd>⇧</kbd> ±10)</td></tr>
|
||
<tr><td><kbd>A</kbd></td><td>Add meter lane</td></tr>
|
||
<tr><td><kbd>↑</kbd> <kbd>↓</kbd> <kbd>Home</kbd> <kbd>End</kbd></td><td>Move the cue cursor (crosses set lists)</td></tr>
|
||
<tr><td><kbd>PgUp</kbd> <kbd>PgDn</kbd></td><td>Cue the previous / next set list</td></tr>
|
||
<tr><td><kbd>Enter</kbd></td><td>Commit the cued item — switches on the next <b>bar</b> (smooth)</td></tr>
|
||
<tr><td><kbd>⇧Enter</kbd></td><td>Commit now — switches on the next <b>beat</b> (rude)</td></tr>
|
||
<tr><td><kbd>N</kbd> / <kbd>P</kbd></td><td>Load next / previous immediately (rude quick-step)</td></tr>
|
||
<tr><td><kbd>⌥↑</kbd> <kbd>⌥↓</kbd></td><td>Reorder the cued item</td></tr>
|
||
<tr><td><kbd>1</kbd>–<kbd>9</kbd></td><td>Enable / silence 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 · cancel an armed switch</td></tr>
|
||
</table>
|
||
<div class="help-about">
|
||
<p>Source: <a href="https://codeberg.org/VARASYS/metronome" target="_blank" rel="noopener">codeberg.org/VARASYS/metronome</a></p>
|
||
<p><b>Your set lists, items and practice log live only in this browser</b> (localStorage) — nothing is uploaded. To move or share them, use the set-list <b>⋯</b> menu: <b>Share set-list link</b> copies a link encoding the whole set list (open it elsewhere to import a copy); <b>Share settings link</b> shares just the loaded item; <b>Export all / Import file</b> back up everything as a JSON file.</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>
|
||
|
||
<!-- Share dialog: copyable link -->
|
||
<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>
|
||
<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>
|
||
"use strict";
|
||
|
||
// Build version. deploy.sh rewrites this line: a clean commit tagged v<VERSION>
|
||
// stamps the formal "X.Y.Z"; any other build stamps "X.Y.Z-dev.<ts>.<sha>[.dirty]".
|
||
// The literal below is the fallback shown when viewing the un-deployed source.
|
||
const APP_VERSION = "0.0.1-dev";
|
||
|
||
/* =========================================================================
|
||
STATE
|
||
========================================================================= */
|
||
const state = { bpm: 120, volume: 0.7, running: false };
|
||
const trainer = { on: false, playBars: 2, muteBars: 2 };
|
||
const ramp = { on: false, startBpm: 80, amount: 5, everyBars: 4 };
|
||
|
||
let meters = []; // array of meter-lane objects
|
||
let meterSeq = 0; // id counter
|
||
|
||
|
||
/* ---- shared engine: audio voices, scheduler primitives, share-language codec.
|
||
Inlined from src/engine.js by build.sh; identical in player.html. ---- */
|
||
const SAMPLES = {}; // all voices are synthesized (samples removed; 808/909 renders are the kit)
|
||
/*@BUILD:include:src/engine.js@*/
|
||
|
||
/* =========================================================================
|
||
SCHEDULER (PORTS TO FIRMWARE)
|
||
========================================================================= */
|
||
// Master clock: counts bars off the FIRST lane, used by trainer + ramp.
|
||
let masterBeatTime = 0, masterBeat = 0;
|
||
let muteWindows = []; // {start,end} time ranges silenced by the trainer
|
||
|
||
|
||
function advanceMaster(ahead) {
|
||
const mbpb = masterBeatsPerBar();
|
||
while (masterBeatTime < ahead) {
|
||
if (masterBeat % mbpb === 0) {
|
||
const barIndex = Math.floor(masterBeat / mbpb);
|
||
if (barIndex > 0 && ramp.on && (barIndex % ramp.everyBars === 0)) setBpm(state.bpm + ramp.amount);
|
||
if (trainer.on) {
|
||
const cycle = trainer.playBars + trainer.muteBars;
|
||
if (cycle > 0 && (barIndex % cycle) >= trainer.playBars) {
|
||
muteWindows.push({ start: masterBeatTime, end: masterBeatTime + mbpb * (60 / state.bpm) });
|
||
}
|
||
}
|
||
segBarCount = barIndex; // whole bars elapsed in this segment
|
||
if (segBars > 0 && barIndex >= segBars && !pendingSwitch && state.running && continueMode) { // bar-count auto-advance (Continue)
|
||
const nx = nextLoadedTarget();
|
||
if (nx) { pendingSwitch = { sl: nx.sl, item: nx.item, atTime: masterBeatTime, reason: "auto" }; break; } // cut at this downbeat
|
||
}
|
||
}
|
||
masterBeat++;
|
||
masterBeatTime += 60 / state.bpm;
|
||
}
|
||
if (audioCtx) muteWindows = muteWindows.filter((w) => w.end > audioCtx.currentTime - 1);
|
||
}
|
||
|
||
|
||
|
||
function scheduler() {
|
||
const ahead = audioCtx.currentTime + SCHEDULE_AHEAD;
|
||
// While a switch is armed, never advance/schedule past its boundary — so no audio
|
||
// is committed beyond it. advanceMaster may also arm an auto-switch and stop at the boundary.
|
||
advanceMaster(pendingSwitch ? Math.min(ahead, pendingSwitch.atTime) : ahead);
|
||
const cap = pendingSwitch ? Math.min(ahead, pendingSwitch.atTime) : ahead;
|
||
for (const m of meters) {
|
||
while (m.nextTime < cap) {
|
||
scheduleMeterTick(m, m.nextTime);
|
||
m.nextTime += laneStepDur(m, m.tick); // duration of the step just scheduled (swing makes pairs uneven)
|
||
m.tick++;
|
||
}
|
||
}
|
||
// Boundary reached → swap to the new segment seamlessly (scheduler keeps running).
|
||
if (pendingSwitch && masterBeatTime >= pendingSwitch.atTime - 1e-9) performCutover(pendingSwitch);
|
||
}
|
||
|
||
/* =========================================================================
|
||
TRANSPORT
|
||
========================================================================= */
|
||
function start() {
|
||
ensureAudio(); audioCtx.resume();
|
||
state.running = true;
|
||
if (ramp.on) setBpm(ramp.startBpm); // ramp begins from its start BPM
|
||
const t0 = audioCtx.currentTime + 0.08;
|
||
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 = [];
|
||
schedulerTimer = setInterval(scheduler, LOOKAHEAD_MS);
|
||
scheduler(); syncStartBtn();
|
||
}
|
||
function stop() {
|
||
state.running = false;
|
||
clearInterval(schedulerTimer); schedulerTimer = null;
|
||
pendingSwitch = null; segBarCount = 0; // drop any armed switch so it can't fire on next start
|
||
for (const m of meters) m.currentStep = -1;
|
||
syncStartBtn();
|
||
}
|
||
|
||
/* ----- gap-free cutover -----------------------------------------------------
|
||
One mechanism, two quantize targets: "beat" (rude/now) and "bar" (smooth).
|
||
Arming records a future boundary time; the scheduler caps outgoing audio at it
|
||
and rebuilds the meters there — schedulerTimer never stops, so the downbeat is
|
||
continuous (this replaces the old gappy stop()+start() switch). */
|
||
function nextBeatBoundaryTime() { return masterBeatTime; } // time of the next (unscheduled) beat
|
||
function nextBarBoundaryTime() {
|
||
const mbpb = masterBeatsPerBar();
|
||
const toNext = ((mbpb - (masterBeat % mbpb)) % mbpb) || mbpb; // beats until the next downbeat (≥ 1 bar away if on it)
|
||
return masterBeatTime + toNext * (60 / state.bpm);
|
||
}
|
||
function armSwitch(sl, item, reason, quantize) {
|
||
if (!setlists[sl] || !setlists[sl].items[item]) return;
|
||
if (!state.running) { loadItem(item, sl); return; } // not playing → load immediately
|
||
let bt = quantize === "bar" ? nextBarBoundaryTime() : nextBeatBoundaryTime();
|
||
const unit = (quantize === "bar" ? masterBeatsPerBar() : 1) * (60 / state.bpm);
|
||
while (bt <= audioCtx.currentTime + SCHEDULE_AHEAD) bt += unit; // defer past already-committed audio
|
||
pendingSwitch = { sl, item, atTime: bt, reason: reason || "commit" };
|
||
updateCtx();
|
||
}
|
||
function performCutover(ps) {
|
||
const bt = ps.atTime;
|
||
logFinalize(); // close out the outgoing segment's log entry
|
||
pendingSwitch = null;
|
||
applySetup(setlists[ps.sl].items[ps.item]); // rebuilds meters, sets bpm/ramp/trainer/segBars, resets segBarCount
|
||
if (ramp.on) setBpm(ramp.startBpm); // each segment's ramp starts fresh (like start())
|
||
setLoaded(ps.sl, ps.item);
|
||
for (const m of meters) { m.tick = 0; m.nextTime = bt; m.vq = []; m.vqPtr = 0; m.currentStep = -1; m.currentBar = 0; } // first tick on the boundary
|
||
masterBeat = 0; masterBeatTime = bt; muteWindows = [];
|
||
nowPlaying = { at: Date.now(), name: setlists[ps.sl].items[ps.item].name };
|
||
if (activeSL !== loadedSL) { activeSL = loadedSL; renderSetlists(); } else renderItems();
|
||
renderLog(); updateCtx();
|
||
}
|
||
function setBpm(v) {
|
||
state.bpm = Math.max(30, Math.min(300, Math.round(v)));
|
||
bpm.value = state.bpm; bpmVal.textContent = state.bpm; bpmDisplay.textContent = state.bpm;
|
||
}
|
||
|
||
/* =========================================================================
|
||
METER LANES (dynamic add/remove)
|
||
========================================================================= */
|
||
function laneColor(id) { return `hsl(${(id * 67) % 360} 70% 62%)`; }
|
||
|
||
function addMeter(groupsStr = "4", stepsPerBeat = 4, sound = "beep", beatsOn = null, poly = false, swing = false, gainDb = 0) {
|
||
const id = ++meterSeq;
|
||
const p = parseGroups(groupsStr);
|
||
const m = {
|
||
id, groupsStr, groups: p.groups, beatsPerBar: p.beatsPerBar, groupStarts: p.groupStarts,
|
||
stepsPerBeat, sound, enabled: true, poly: !!poly, swing: !!swing, gainDb: gainDb || 0, color: laneColor(id),
|
||
beatsOn: beatsOn ? beatsOn.slice() : [], // per-STEP dynamics mask (one entry per pad: 0 mute / 1 normal / 2 accent)
|
||
tick: 0, nextTime: 0, vq: [], vqPtr: 0, currentStep: -1, currentBar: 0,
|
||
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; }
|
||
meters.push(m);
|
||
buildLaneCard(m);
|
||
renumberLanes();
|
||
updateCtx();
|
||
syncPatchSoon(); // structural change -> coalesced full-state mirror
|
||
}
|
||
|
||
function removeMeter(id) {
|
||
const i = meters.findIndex((m) => m.id === id);
|
||
if (i < 0) return;
|
||
meters[i].el.remove();
|
||
meters.splice(i, 1);
|
||
renumberLanes();
|
||
updateCtx();
|
||
syncPatchSoon(); // structural change -> coalesced full-state mirror
|
||
}
|
||
|
||
// 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 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) {
|
||
const card = document.createElement("div");
|
||
card.className = "meter-card";
|
||
card.innerHTML = `
|
||
<div class="lane-row">
|
||
<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">
|
||
<span class="sum" id="m${m.id}_sum"></span>
|
||
<select class="cmp" id="m${m.id}_sub" title="subdivision — sets how many pads each beat splits into; “swing” delays the off-beats">
|
||
<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="2s">♪ swing 8th</option><option value="4s">swing 16th</option>
|
||
</select>
|
||
<select class="cmp" id="m${m.id}_sound" title="sound">${VOICES.map(([v, n]) => `<option value="${v}">${n}</option>`).join("")}</select>
|
||
<span class="gain" id="m${m.id}_gain" title="lane gain (dB) — drag up/down · scroll · double‑click resets to 0">0 dB</span>
|
||
<div class="strip" id="m${m.id}_strip"></div>
|
||
<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>
|
||
<button class="x" id="m${m.id}_remove" title="remove lane">✕</button>
|
||
</div>`;
|
||
document.getElementById("meters").appendChild(card);
|
||
m.el = card;
|
||
m.stripEl = card.querySelector(`#m${m.id}_strip`);
|
||
m.barEl = card.querySelector(`#m${m.id}_bar`);
|
||
m.titleEl = card.querySelector(`#m${m.id}_title`);
|
||
|
||
// wire controls
|
||
const $c = (sel) => card.querySelector(sel);
|
||
$c(`#m${m.id}_group`).addEventListener("input", (e) => { m.groupsStr = e.target.value; recomputeLane(m); syncPatchSoon(); });
|
||
const sub = $c(`#m${m.id}_sub`); sub.value = m.swing ? (m.stepsPerBeat + "s") : String(m.stepsPerBeat);
|
||
sub.addEventListener("change", (e) => { const v = e.target.value; m.swing = /s$/.test(v); m.stepsPerBeat = parseInt(v, 10) || 1; recomputeLane(m); syncPatchSoon(); });
|
||
const sel = $c(`#m${m.id}_sound`); sel.value = m.sound;
|
||
sel.addEventListener("change", (e) => { m.sound = e.target.value; syncPatchSoon(); });
|
||
// per-lane gain (dB) — drag up/down, scroll, or double-click to reset; applied at
|
||
// schedule time (no stutter), so no replay is needed when it changes mid-play.
|
||
const gainEl = $c(`#m${m.id}_gain`);
|
||
const showGain = () => { const d = m.gainDb || 0; gainEl.textContent = (d > 0 ? "+" : "") + d + " dB";
|
||
gainEl.classList.toggle("boost", d > 0); gainEl.classList.toggle("cut", d < 0); };
|
||
const setGain = (d) => { m.gainDb = Math.max(-24, Math.min(6, d)); showGain(); if (typeof refreshPatchField === "function") refreshPatchField(); syncPatchSoon(); };
|
||
showGain();
|
||
(function () { let down = false, lastY = 0, acc = 0;
|
||
gainEl.addEventListener("pointerdown", (e) => { down = true; lastY = e.clientY; acc = 0; try { gainEl.setPointerCapture(e.pointerId); } catch (_) {} });
|
||
gainEl.addEventListener("pointermove", (e) => { if (!down) return; acc += lastY - e.clientY; lastY = e.clientY; while (Math.abs(acc) >= 4) { const d = acc > 0 ? 1 : -1; acc -= d * 4; setGain((m.gainDb || 0) + d); } });
|
||
gainEl.addEventListener("pointerup", () => down = false);
|
||
gainEl.addEventListener("pointercancel", () => down = false);
|
||
gainEl.addEventListener("dblclick", () => setGain(0));
|
||
gainEl.addEventListener("wheel", (e) => { e.preventDefault(); setGain((m.gainDb || 0) + (e.deltaY < 0 ? 1 : -1)); }, { passive: false });
|
||
})();
|
||
const polyCb = $c(`#m${m.id}_poly`); polyCb.checked = m.poly;
|
||
polyCb.addEventListener("change", (e) => { m.poly = e.target.checked; syncPatchSoon(); });
|
||
const enCb = $c(`#m${m.id}_enable`); enCb.checked = m.enabled;
|
||
enCb.addEventListener("change", (e) => { setLaneEnabled(m, e.target.checked); syncPatchSoon(); });
|
||
card.querySelector(".lane-row").classList.toggle("lane-off", !m.enabled);
|
||
$c(`#m${m.id}_remove`).addEventListener("click", () => removeMeter(m.id));
|
||
|
||
recomputeLane(m);
|
||
}
|
||
|
||
// Per-step dynamics levels: 0 mute · 1 normal · 2 accent · 3 ghost. (Ghost is the new
|
||
// value 3 so set lists saved at the 3-level stage — 0/1/2 — keep their meaning, no migration.)
|
||
const stepDefault = (s) => (s === 0 ? 2 : 1); // default: first step of each beat accented, rest normal
|
||
const NEXT_LEVEL = { 2: 1, 1: 3, 3: 0, 0: 2 }; // click cycle: accent → normal → ghost → mute → accent
|
||
function normLevel(v, dflt) { // coerce a stored value to a level
|
||
if (v === true) return dflt === 2 ? 2 : 1; // legacy boolean "on" → keep accent on downbeats
|
||
if (v === false) return 0;
|
||
if (v == null) return dflt;
|
||
const n = v | 0; return n >= 3 ? 3 : n >= 2 ? 2 : n >= 1 ? 1 : 0;
|
||
}
|
||
function recomputeLane(m) {
|
||
const p = parseGroups(m.groupsStr);
|
||
m.groups = p.groups; m.beatsPerBar = p.beatsPerBar; m.groupStarts = p.groupStarts;
|
||
// Remap the dynamics mask to step resolution (beats × subdivision = one pad each),
|
||
// preserving levels where they line up and defaulting new pads (first-of-beat accent, rest normal).
|
||
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 = stepDefault(s);
|
||
if (b < oldBpb) { // this beat existed before
|
||
if (oldSpb === spb) val = normLevel(prev[b * oldSpb + s], stepDefault(s)); // same resolution → step-for-step
|
||
else if (s === 0) val = normLevel(prev[b * oldSpb], 2); // resolution changed → keep the downbeat; new subs default
|
||
}
|
||
next.push(val);
|
||
}
|
||
}
|
||
m.beatsOn = next; m._maskBpb = m.beatsPerBar; m._maskSpb = spb;
|
||
m.el.querySelector(`#m${m.id}_sum`).textContent = "=" + m.beatsPerBar;
|
||
buildLaneStrip(m);
|
||
}
|
||
|
||
function buildLaneStrip(m) { // one pad per STEP (beats × subdivision)
|
||
m.stripEl.innerHTML = "";
|
||
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");
|
||
cell.className = "led";
|
||
cell.textContent = (s === 0) ? (b + 1) : ""; // label downbeats; subdivisions blank
|
||
cell.style.cursor = "pointer";
|
||
cell.title = "click: accent → normal → ghost → mute · beat " + (b + 1) + (s ? " · sub " + (s + 1) : "");
|
||
cell.addEventListener("click", () => { m.beatsOn[i] = NEXT_LEVEL[m.beatsOn[i] | 0]; renderLaneStrip(m); if (typeof syncBeat === "function") syncBeat(m, i); });
|
||
m.stripEl.appendChild(cell);
|
||
}
|
||
}
|
||
|
||
function renderLaneStrip(m) {
|
||
const cells = m.stripEl.children, spb = m.stepsPerBeat;
|
||
for (let i = 0; i < cells.length; i++) {
|
||
const cell = cells[i];
|
||
const b = Math.floor(i / spb), s = i % spb, onBeat = (s === 0);
|
||
const lvl = m.beatsOn[i] | 0, 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 (lvl >= 1) cls += " on"; // normal / accent / ghost → lit
|
||
if (gs) cls += " groupstart"; // group divider (layout only)
|
||
if (lvl === 2) cls += " accent"; // accented step (▲)
|
||
else if (lvl === 3) cls += " ghost"; // ghost note (faint ·)
|
||
cell.className = cls;
|
||
cell.style.setProperty("--lc", m.color);
|
||
if (state.running && i === m.currentStep) cell.classList.add("playhead");
|
||
}
|
||
if (m.barEl) m.barEl.textContent = state.running ? "bar " + (m.currentBar + 1) : "—";
|
||
}
|
||
|
||
/* =========================================================================
|
||
PRESETS (localStorage)
|
||
========================================================================= */
|
||
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 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, enabled: m.enabled, poly: m.poly, swing: !!m.swing, gainDb: m.gainDb || 0, beatsOn: m.beatsOn.slice() })); }
|
||
function applyLanes(lanes) {
|
||
while (meters.length) removeMeter(meters[0].id);
|
||
for (const c of lanes) {
|
||
addMeter(c.groupsStr, c.stepsPerBeat, c.sound, c.beatsOn, c.poly, c.swing, c.gainDb);
|
||
const m = meters[meters.length - 1];
|
||
setLaneEnabled(m, c.enabled !== undefined ? !!c.enabled : (c.mute === undefined ? true : !c.mute)); // back-compat with old "mute"
|
||
}
|
||
}
|
||
// (Presets removed — set-list items are now the single "saved setup" mechanism.)
|
||
|
||
/* =========================================================================
|
||
SET LISTS + PRACTICE LOG
|
||
A set list = { title, description, items:[{name, bpm, lanes, ...}] }.
|
||
▶ on an item loads its settings and starts; N advances to the next item.
|
||
Each played item is logged (timestamp, name, duration, BPM, conditions).
|
||
========================================================================= */
|
||
let setlists = lsGet(LS.setlists, []);
|
||
let activeSL = 0; // VIEWED set list (the one shown in the panel)
|
||
let activeItem = -1; // loaded item index within loadedSL (-1 = none / free play)
|
||
let loadedSL = 0; // set list the loaded/playing item lives in (may differ from the viewed one)
|
||
let cuedSL = -1, cuedItem = -1; // cue cursor — non-destructive browse pointer (-1 = none)
|
||
let pendingSwitch = null; // armed cutover: { sl, item, atTime, reason }
|
||
let segBars = 0; // bar-length of the loaded segment (0 = manual, no auto-advance)
|
||
let segBarCount = 0; // whole bars elapsed in the current segment
|
||
let nowPlaying = null; // { at, name } for duration logging
|
||
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 }, countMs: timers.totalMs, bars: segBars }; }
|
||
function applySetup(s) {
|
||
setBpm(s.bpm); applyLanes(s.lanes);
|
||
if (s.trainer) Object.assign(trainer, s.trainer);
|
||
if (s.ramp) Object.assign(ramp, s.ramp);
|
||
timers.totalMs = s.countMs || 0; timers.remainingMs = timers.totalMs; // per-item time countdown
|
||
segBars = s.bars || 0; segBarCount = 0; // per-item bar-length + counter
|
||
syncPracticeUI(); updateCtx();
|
||
}
|
||
function syncPracticeUI() {
|
||
$("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;
|
||
$("countTime").value = timers.totalMs > 0 ? fmtClock(timers.totalMs) : "";
|
||
$("segBarsIn").value = segBars || 0;
|
||
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 getSL() { return setlists[activeSL]; } // the VIEWED list
|
||
function loadedItem() { const sl = setlists[loadedSL]; return (sl && activeItem >= 0) ? sl.items[activeItem] : null; }
|
||
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; saveSetlists(); renderSetlists(); // view the new list; loaded item keeps playing in its own
|
||
}
|
||
function deleteSetlist() {
|
||
if (!setlists.length || !confirm("Delete this set list?")) return;
|
||
const removed = activeSL;
|
||
setlists.splice(removed, 1);
|
||
const adj = (n) => n > removed ? n - 1 : n;
|
||
if (loadedSL === removed) { activeItem = -1; loadedSL = Math.max(0, removed - 1); } else loadedSL = adj(loadedSL);
|
||
if (cuedSL === removed) { cuedSL = -1; cuedItem = -1; } else cuedSL = adj(cuedSL);
|
||
if (pendingSwitch) { if (pendingSwitch.sl === removed) pendingSwitch = null; else pendingSwitch.sl = adj(pendingSwitch.sl); }
|
||
activeSL = Math.max(0, removed - 1); saveSetlists(); renderSetlists();
|
||
}
|
||
function addItem(name) {
|
||
const sl = getSL(); if (!sl) return;
|
||
sl.items.push({ name: name || ("Item " + (sl.items.length + 1)), ...currentSetup() });
|
||
setLoaded(activeSL, sl.items.length - 1); // the captured item becomes the loaded one
|
||
saveSetlists(); renderItems();
|
||
}
|
||
function removeItem(i) {
|
||
const sl = getSL(); if (!sl) return;
|
||
sl.items.splice(i, 1);
|
||
if (activeSL === loadedSL) { if (activeItem === i) activeItem = -1; else if (activeItem > i) activeItem--; }
|
||
if (activeSL === cuedSL) { if (cuedItem === i) cuedItem = Math.min(cuedItem, sl.items.length - 1); else if (cuedItem > i) cuedItem--; }
|
||
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 moveCuedItem(d) { // keyboard reorder of the cued item (Alt+↑/↓), within the viewed list
|
||
const sl = getSL(); if (!sl || !sl.items.length) return;
|
||
if (cuedSL !== activeSL || cuedItem < 0 || cuedItem >= sl.items.length) { // no cue here yet → seed it
|
||
cuedSL = activeSL;
|
||
cuedItem = (loadedSL === activeSL && activeItem >= 0) ? activeItem : 0;
|
||
}
|
||
const j = cuedItem + d; if (j < 0 || j >= sl.items.length) { renderItems(); return; } // at an edge: just show the cue
|
||
moveItem(cuedItem, d);
|
||
if (loadedSL === activeSL) { if (activeItem === cuedItem) activeItem = j; else if (activeItem === j) activeItem = cuedItem; }
|
||
cuedItem = j; renderItems();
|
||
}
|
||
|
||
// Record the loaded item + sync the cue + history (state only; no audio, no applySetup).
|
||
function setLoaded(sl, i) {
|
||
loadedSL = sl; activeItem = i;
|
||
const it = setlists[sl] && setlists[sl].items[i];
|
||
if (it) historyName = it.name;
|
||
cuedSL = sl; cuedItem = i; // the cue follows the loaded item
|
||
}
|
||
|
||
// --- load: clicking / N / P loads. While playing this is a gap-free RUDE (next-beat) cutover. ---
|
||
function loadItem(i, sl = activeSL) {
|
||
if (!setlists[sl] || !setlists[sl].items[i]) return;
|
||
if (typeof syncSel === "function") syncSel(sl, i); // mirror set-list selection to a synced device
|
||
if (state.running) { armSwitch(sl, i, "load", "beat"); return; } // playing → next beat, no gap
|
||
applySetup(setlists[sl].items[i]);
|
||
setLoaded(sl, i);
|
||
if (activeSL !== sl) { activeSL = sl; renderSetlists(); } else renderItems();
|
||
renderLog();
|
||
}
|
||
function nextItem() { // N — quick-step within the loaded list (rude when playing)
|
||
if (activeItem < 0) { loadItem(0, activeSL); return; }
|
||
const sl = setlists[loadedSL]; if (sl && activeItem + 1 < sl.items.length) loadItem(activeItem + 1, loadedSL);
|
||
}
|
||
function prevItem() { const sl = setlists[loadedSL]; if (sl && activeItem - 1 >= 0) loadItem(activeItem - 1, loadedSL); }
|
||
|
||
// --- cue cursor (browse without loading); commits via Enter/Shift+Enter ---
|
||
function setCue(sl, item) {
|
||
if (sl < 0 || sl >= setlists.length || !setlists[sl].items.length) return;
|
||
cuedSL = sl; cuedItem = Math.max(0, Math.min(item, setlists[sl].items.length - 1));
|
||
if (activeSL !== sl) { activeSL = sl; renderSetlists(); } else renderItems(); // viewed list follows the cue
|
||
}
|
||
function ensureCue() { // seed the cue on first nav (from the loaded item, else the viewed list)
|
||
if (cuedSL >= 0 && cuedItem >= 0 && setlists[cuedSL] && setlists[cuedSL].items[cuedItem]) return;
|
||
if (activeItem >= 0 && setlists[loadedSL]) { cuedSL = loadedSL; cuedItem = activeItem; } else { cuedSL = activeSL; cuedItem = 0; }
|
||
}
|
||
function cueNext() { ensureCue(); if (cuedItem + 1 < setlists[cuedSL].items.length) setCue(cuedSL, cuedItem + 1); else for (let j = cuedSL + 1; j < setlists.length; j++) if (setlists[j].items.length) return setCue(j, 0); }
|
||
function cuePrev() { ensureCue(); if (cuedItem - 1 >= 0) setCue(cuedSL, cuedItem - 1); else for (let j = cuedSL - 1; j >= 0; j--) if (setlists[j].items.length) return setCue(j, setlists[j].items.length - 1); }
|
||
function cueFirst() { for (let j = 0; j < setlists.length; j++) if (setlists[j].items.length) return setCue(j, 0); }
|
||
function cueLast() { for (let j = setlists.length - 1; j >= 0; j--) if (setlists[j].items.length) return setCue(j, setlists[j].items.length - 1); }
|
||
function cueSetlist(d) { ensureCue(); for (let j = cuedSL + d; j >= 0 && j < setlists.length; j += d) if (setlists[j].items.length) return setCue(j, 0); }
|
||
|
||
// The item after the loaded one, crossing into the next non-empty list (for auto-advance). null = end.
|
||
function nextLoadedTarget() {
|
||
const sl = setlists[loadedSL]; if (!sl || activeItem < 0) return null;
|
||
if (activeItem + 1 < sl.items.length) return { sl: loadedSL, item: activeItem + 1 };
|
||
for (let j = loadedSL + 1; j < setlists.length; j++) if (setlists[j].items.length) return { sl: j, item: 0 };
|
||
return null;
|
||
}
|
||
function updateItem() { // Save — overwrite the LOADED item with current settings (keeps its name)
|
||
const sl = setlists[loadedSL]; if (!sl || activeItem < 0 || !sl.items[activeItem]) return;
|
||
sl.items[activeItem] = { name: sl.items[activeItem].name, ...currentSetup() };
|
||
saveSetlists(); renderItems();
|
||
}
|
||
|
||
// Start/stop go through here so internal restarts don't create stray log entries.
|
||
function toggleTransport() {
|
||
if (state.running) { logFinalize(); stop(); }
|
||
else { start(); const it = loadedItem(); if (it) nowPlaying = { at: Date.now(), name: it.name }; }
|
||
renderItems();
|
||
if (typeof syncTransport === "function") syncTransport(); // mirror play/stop to a synced device
|
||
}
|
||
|
||
// --- now-playing info on the main screen (replaces the old preset dropdown) ---
|
||
function renderNowPlaying() {
|
||
const it = loadedItem(); // the LOADED item (may live in a list you're not viewing)
|
||
$("saveItemBtn").disabled = !it; // single save button targets the loaded set-list item
|
||
// A disabled <button> swallows hover, so its title never shows — set it on the wrapper
|
||
// span too, and explain *why* it's disabled when no item is loaded.
|
||
const saveTip = it
|
||
? "Save the current settings to “" + it.name + "” (set-list item " + (activeItem + 1) + ")"
|
||
: "Load a set-list item to enable Save — it overwrites that item with your current settings";
|
||
$("saveItemBtn").title = $("saveItemWrap").title = saveTip;
|
||
if (!it) {
|
||
const vsl = getSL();
|
||
$("npName").textContent = "Free play";
|
||
$("npSub").textContent = "No set-list item loaded — edit the lanes freely.";
|
||
$("npDesc").textContent = (vsl && vsl.description) ? "“" + vsl.title + "” — " + vsl.description : "";
|
||
return;
|
||
}
|
||
const lsl = setlists[loadedSL];
|
||
$("npName").textContent = (activeItem + 1) + ". " + it.name;
|
||
$("npSub").textContent = it.bpm + " BPM · " + it.lanes.map((l) => l.sound + " " + l.groupsStr + (l.poly ? "~" : "") + (l.enabled === false ? " (off)" : "")).join(" · ");
|
||
$("npDesc").textContent = ((lsl && lsl.title) || "") + (lsl && lsl.description ? " — " + lsl.description : "");
|
||
}
|
||
|
||
// --- render ---
|
||
function autoGrow(el) { if (!el) return; el.style.height = "auto"; el.style.height = (el.scrollHeight || 0) + "px"; }
|
||
function buildSlMenu() { // the ▾ dropdown: every list + "+ New" as the last item
|
||
const menu = $("slMenu"); if (!menu) return;
|
||
menu.innerHTML = "";
|
||
setlists.forEach((sl, i) => {
|
||
const b = document.createElement("button");
|
||
b.textContent = (i === activeSL ? "● " : "") + (sl.title || ("Set list " + (i + 1)));
|
||
b.onclick = () => { $("slMenu").hidden = true; activeSL = i; renderSetlists(); }; // view only
|
||
menu.appendChild(b);
|
||
});
|
||
const nb = document.createElement("button");
|
||
nb.textContent = "+ New set list";
|
||
nb.style.cssText = "border-top:1px solid var(--edge); margin-top:2px; padding-top:6px;";
|
||
nb.onclick = () => { $("slMenu").hidden = true; newSetlist(); };
|
||
menu.appendChild(nb);
|
||
}
|
||
function renderSetlists() {
|
||
const has = setlists.length > 0;
|
||
$("slTitle").disabled = $("slDesc").disabled = $("addItemBtn").disabled = $("delSetlistBtn").disabled = !has;
|
||
if (!has) { $("slTitle").value = ""; $("slDesc").value = ""; autoGrow($("slDesc")); buildSlMenu(); renderItems(); return; }
|
||
if (activeSL >= setlists.length) activeSL = setlists.length - 1;
|
||
const sl = getSL();
|
||
$("slTitle").value = sl.title || "";
|
||
$("slDesc").value = sl.description || ""; autoGrow($("slDesc"));
|
||
buildSlMenu(); renderItems();
|
||
}
|
||
function renderItems() {
|
||
const box = $("itemList"); box.innerHTML = ""; const sl = getSL();
|
||
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"
|
||
+ (activeSL === loadedSL && i === activeItem ? " active" : "") // loaded/playing (green)
|
||
+ (activeSL === cuedSL && i === cuedItem ? " cued" : ""); // cue cursor (amber)
|
||
row.title = "Click to load · ↑↓ to cue · Enter to commit · Alt+↑/↓ to reorder";
|
||
row.innerHTML = `<span class="nm">${i + 1}. ${it.name}${it.bars ? ` <span class="lane-meta">${it.bars} bars</span>` : ""}</span>
|
||
<span class="meta">${it.bpm} · ${it.lanes.map((l) => l.groupsStr).join("/")}</span>
|
||
<span class="row-actions">
|
||
<button class="x iconbtn" data-act="del" title="remove this item">✕</button>
|
||
</span>`;
|
||
row.onclick = () => loadItem(i, activeSL);
|
||
row.querySelector('[data-act=del]').onclick = (e) => { e.stopPropagation(); removeItem(i); };
|
||
box.appendChild(row);
|
||
});
|
||
renderNowPlaying();
|
||
}
|
||
|
||
// --- practice log (flat entries, one per played item) ---
|
||
function logFinalize() {
|
||
if (!nowPlaying) return;
|
||
const logs = lsGet(LS.logs, []);
|
||
logs.unshift({ at: nowPlaying.at, name: nowPlaying.name, durationSec: (Date.now() - nowPlaying.at) / 1000, bpm: state.bpm, lanes: snapshotLanes() });
|
||
lsSet(LS.logs, logs); nowPlaying = null; renderLog();
|
||
}
|
||
// Show history for the item being (or last) played, so the user can compare
|
||
// today's BPM/duration against previous days for that specific task.
|
||
function renderLog() {
|
||
const box = $("logView"); box.innerHTML = "";
|
||
if (!historyName) { box.innerHTML = '<div class="hint">Play a set-list item to see its history — compare BPM & duration across days.</div>'; return; }
|
||
const entries = lsGet(LS.logs, []).filter((e) => e.name === historyName);
|
||
|
||
const head = document.createElement("div"); head.className = "log-head";
|
||
head.innerHTML = `<span class="log-head-nm">History — ${historyName}</span>`;
|
||
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 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() {
|
||
const data = { version: 2, exported: new Date().toISOString(), presets: lsGet(LS.presets, {}), setlists: lsGet(LS.setlists, []), logs: lsGet(LS.logs, []) };
|
||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||
const a = document.createElement("a");
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = "metronome-backup-" + new Date().toISOString().slice(0, 10) + ".json";
|
||
a.click(); URL.revokeObjectURL(a.href);
|
||
}
|
||
function importAll(file) {
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
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; activeItem = -1; }
|
||
if (d.logs) lsSet(LS.logs, d.logs);
|
||
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][! disabled]
|
||
========================================================================= */
|
||
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); }
|
||
|
||
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 shareLink(hashPart) { return location.origin + location.pathname + "#" + hashPart; }
|
||
function openShare(title, url, note) {
|
||
$("shareTitle").textContent = title;
|
||
$("shareUrl").value = url;
|
||
$("shareNote").textContent = note || "";
|
||
$("shareOverlay").hidden = false;
|
||
}
|
||
function shareSettings() { openShare("Share settings", shareLink("p=" + currentPatch()), "Encodes tempo, lanes & practice settings."); }
|
||
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)), "Shares the whole set list (each item's settings).");
|
||
}
|
||
|
||
/* Device (PM_K-1) programs.json — the same grooves the firmware reads.
|
||
Save: writes the active set list straight onto the CIRCUITPY drive (File System Access,
|
||
Chrome/Edge) or downloads it to drag on. Load: reads a programs.json into a new set list. */
|
||
// The PM_K-1's built-in playlists are baked into its firmware (they update with firmware and are
|
||
// read-only). "Save to device" syncs only YOUR set lists — i.e. the ones that aren't the built-in demos.
|
||
function userSetlists() {
|
||
const seed = new Set(SEED_SETLISTS.map((s) => s.title));
|
||
return setlists.filter((sl) => !seed.has(sl.title));
|
||
}
|
||
function programsJSON() {
|
||
return JSON.stringify({ setlists: userSetlists().map((sl) => ({
|
||
title: sl.title || "My set list",
|
||
programs: sl.items.map((it) => ({ name: it.name, prog: setupToPatch(it) })) })) }, null, 2);
|
||
}
|
||
function _downloadPrograms(json) {
|
||
const a = document.createElement("a");
|
||
a.href = URL.createObjectURL(new Blob([json], { type: "application/json" }));
|
||
a.download = "programs.json"; a.click(); URL.revokeObjectURL(a.href);
|
||
}
|
||
async function saveToDevice() {
|
||
const uls = userSetlists();
|
||
if (!uls.length) return alert("No custom set lists to save.\n\nThe built-in playlists (Styles / Practice / Song) are baked into the device firmware — they're always there, update with firmware, and can't be changed. Create your own set list and it'll save here.");
|
||
const json = programsJSON();
|
||
// Primary: push to the device over USB-MIDI SysEx (Chromium/Firefox); the firmware writes programs.json.
|
||
if (await _ensureMidi() && _midiOutputs().length) {
|
||
const ascii = json.replace(/[\u0080-\uFFFF]/g, (c) => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0")); // 7-bit-safe JSON
|
||
const bytes = [0xF0, 0x7D, 0x10];
|
||
for (let i = 0; i < ascii.length; i++) bytes.push(ascii.charCodeAt(i) & 0x7F);
|
||
bytes.push(0xF7);
|
||
_send(_clockSysex()); _send(bytes);
|
||
const ok = await new Promise((res) => { _saveCb = res; setTimeout(() => { if (_saveCb) { _saveCb = null; res(null); } }, 2500); });
|
||
if (ok === true) { alert("Saved to device ✓ — " + uls.length + " set list(s) synced (alongside the built-ins)."); return; }
|
||
_downloadPrograms(json);
|
||
alert(ok === false
|
||
? "The device is in editor mode (drive writable by the computer), so I downloaded programs.json — drag it onto the CIRCUITPY drive."
|
||
: "No device answered over USB-MIDI — downloaded programs.json. Connect the device (Chromium/Firefox) and try again, or boot it holding button A and drag the file onto the CIRCUITPY drive.");
|
||
return;
|
||
}
|
||
// Universal fallback (any browser / OS): download + drag onto the drive in editor mode (hold A at power-on).
|
||
_downloadPrograms(json);
|
||
alert("Downloaded programs.json — boot the device holding button A (editor mode) and drag it onto the CIRCUITPY drive.");
|
||
}
|
||
function importPrograms(text) {
|
||
try {
|
||
const d = JSON.parse(text);
|
||
const lists = Array.isArray(d.setlists) ? d.setlists : [{ title: d.title, programs: d.programs || [] }];
|
||
let added = 0;
|
||
for (const sl of lists) {
|
||
const items = (sl.programs || []).map((p) => ({ name: p.name || "Item", ...patchToSetup(p.prog) }));
|
||
if (!items.length) continue;
|
||
setlists.push({ title: sl.title || "Device", description: "", items }); added++;
|
||
}
|
||
if (!added) return alert("No programs found in that file.");
|
||
activeSL = setlists.length - 1; activeItem = 0;
|
||
saveSetlists(); renderSetlists(); applySetup(setlists[activeSL].items[0]);
|
||
alert("Loaded " + added + " set list(s) from the device.");
|
||
} catch (e) { alert("Load failed: " + e.message); }
|
||
}
|
||
async function loadFromDevice() {
|
||
if (window.showOpenFilePicker) {
|
||
try {
|
||
const [h] = await showOpenFilePicker({ types: [{ description: "PolyMeter programs", accept: { "application/json": [".json"] } }] });
|
||
return importPrograms(await (await h.getFile()).text());
|
||
} catch (e) { if (e.name === "AbortError") return; }
|
||
}
|
||
const inp = document.createElement("input"); inp.type = "file"; inp.accept = ".json,application/json";
|
||
inp.onchange = () => { if (inp.files[0]) inp.files[0].text().then(importPrograms); };
|
||
inp.click();
|
||
}
|
||
|
||
/* Device audio (Phase 3): a connected PM_K-1 sends a USB-MIDI note per click; we voice it through
|
||
this page's synth, so the device drives sound out the computer's speakers, locked to its clock. */
|
||
let _midiAccess = null, _midiOn = false, _midiFlash = 0, _midiBeat = 0, _saveCb = null, _verCb = null;
|
||
function _midiInputs() { return _midiAccess ? [..._midiAccess.inputs.values()] : []; }
|
||
function _midiOutputs() { return _midiAccess ? [..._midiAccess.outputs.values()] : []; }
|
||
function _isDevicePort(p) { // recognise PM_K-1 (Pico) and PM_X-1 (Pimoroni Explorer RP2350) USB-MIDI ports;
|
||
const n = (p.name || "").toLowerCase(); // anything unrecognised triggers a broadcast to all outputs - bad for ACK routing.
|
||
return n.includes("pico") || n.includes("circuitpython") || n.includes("usb_midi") ||
|
||
n.includes("pimoroni") || n.includes("explorer") || n.includes("rp2350") || n.includes("varasys");
|
||
}
|
||
function _send(bytes) { // send only to the PM_K-1 (not loopback ports like "Midi Through", which just echo)
|
||
const outs = _midiOutputs(), dev = outs.filter(_isDevicePort);
|
||
for (const o of (dev.length ? dev : outs)) { try { o.send(bytes); } catch (_) {} }
|
||
}
|
||
function _clockSysex() { const d = new Date(); // F0 7D 01 yr-2000 mo dd hh mm ss F7 -> sets the device RTC
|
||
return [0xF0, 0x7D, 0x01, d.getFullYear() - 2000, d.getMonth() + 1, d.getDate(), d.getHours(), d.getMinutes(), d.getSeconds(), 0xF7]; }
|
||
async function _ensureMidi() { // MIDI access WITH SysEx (needed to send/receive SysEx); cached
|
||
if (_midiAccess) return true;
|
||
if (!navigator.requestMIDIAccess) return false;
|
||
try { _midiAccess = await navigator.requestMIDIAccess({ sysex: true }); }
|
||
catch (e) { return false; }
|
||
_midiAccess.onstatechange = _wireMidi; _wireMidi();
|
||
return true;
|
||
}
|
||
function _wireMidi() { for (const inp of _midiInputs()) inp.onmidimessage = onDeviceMidi; updateMidiBtn(); }
|
||
function onDeviceMidi(e) {
|
||
const d = e.data; if (!d) return;
|
||
if (d[0] === 0xF0 && d[1] === 0x7D) { // our SysEx reply
|
||
const cmd = d[2];
|
||
if (cmd === 0x03 && _verCb) { const cb = _verCb; _verCb = null; cb(String.fromCharCode(...d.slice(3, d.length - 1))); } // version
|
||
else if ((cmd === 0x7F || cmd === 0x7E) && _saveCb) { const cb = _saveCb; _saveCb = null; cb(cmd === 0x7F); } // ACK/NAK
|
||
else if (cmd >= 0x40 && cmd <= 0x43 && typeof LiveSync !== "undefined") { // live-sync mirror (beta)
|
||
LiveSync.applyRemote(cmd, String.fromCharCode(...d.slice(3, d.length - 1)));
|
||
}
|
||
return;
|
||
}
|
||
if (_midiOn && (d[0] & 0xf0) === 0x90 && d.length >= 3 && d[2] > 0) { // Note On -> voice it
|
||
const v = d[2], gain = v >= 110 ? 1.0 : v >= 70 ? 0.6 : 0.25; // accent / normal / ghost
|
||
try { ensureAudio(); playInstrument(GM_NUM[d[1]] || "beep", audioCtx.currentTime, gain); } catch (_) {}
|
||
const b = $("midiBtn"); if (b) { b.style.boxShadow = "0 0 0 2px #2fe07a"; clearTimeout(_midiFlash); _midiFlash = setTimeout(() => b.style.boxShadow = "", 90); }
|
||
}
|
||
}
|
||
function _heartbeat(on) { // while Device audio is on: tell the device a host listens + keep its clock synced
|
||
clearInterval(_midiBeat); _midiBeat = 0;
|
||
if (on) { let k = 0; _midiBeat = setInterval(() => {
|
||
_send([0xFE]); // Active Sensing -> device shows "MIDI" + mutes buzzer
|
||
if ((k++ % 12) === 0) _send(_clockSysex()); // resync the clock ~every 3s
|
||
}, 250); }
|
||
}
|
||
function updateMidiBtn() {
|
||
const b = $("midiBtn"); if (!b) return;
|
||
if (!_midiOn) { b.textContent = "🎹 Device audio"; b.classList.remove("primary"); b.style.boxShadow = ""; return; }
|
||
const names = _midiInputs().map((i) => i.name || "MIDI");
|
||
b.textContent = names.length ? "🎹 " + names[0].slice(0, 16) : "🎹 no device";
|
||
b.classList.add("primary");
|
||
}
|
||
async function toggleDeviceAudio() {
|
||
if (_midiOn) { _midiOn = false; _heartbeat(false); updateMidiBtn(); return; } // inputs stay bound (for Save ACKs)
|
||
if (!(await _ensureMidi())) return alert("Playing the device through this computer needs the Web MIDI API — use Chrome, Edge, or Firefox.");
|
||
ensureAudio(); if (audioCtx && audioCtx.state === "suspended") audioCtx.resume();
|
||
_midiOn = true; _heartbeat(true); updateMidiBtn();
|
||
const names = _midiInputs().map((i) => i.name || "MIDI");
|
||
alert(names.length
|
||
? "Device audio ON.\nMIDI input(s): " + names.join(", ") + "\nPress play on the device — the button pulses green per note."
|
||
: "Armed, but no MIDI input yet. Plug in the PM_K-1 (CircuitPython firmware) — it connects automatically.");
|
||
}
|
||
function _queryDeviceVersion() { // ask the device its firmware version (SysEx 0x02 -> reply 0x03)
|
||
return new Promise((res) => { _verCb = res; _send([0xF0, 0x7D, 0x02, 0xF7]); setTimeout(() => { if (_verCb) { _verCb = null; res(null); } }, 1500); });
|
||
}
|
||
// 0.0.23+ devices reply "<id>;<version>" (e.g. "K;0.0.23", "X;0.0.1"); pre-0.0.23 send bare version -> assume K.
|
||
function _parseDeviceReply(s) {
|
||
if (!s) return { id: null, version: null };
|
||
const i = s.indexOf(";");
|
||
return i >= 0 ? { id: s.slice(0, i), version: s.slice(i + 1) } : { id: "K", version: s };
|
||
}
|
||
const FW_PATHS = { K: { py: "/pico-cp-app.py", mpy: "/pico-cp-app.mpy", label: "PM_K-1 Kit" },
|
||
X: { py: "/pico-explorer-app.py", mpy: "/pico-explorer-app.mpy", label: "PM_X-1 Explorer" } };
|
||
async function updateFirmware() { // A/B firmware update over USB-MIDI: push the precompiled .mpy
|
||
console.log("[fw] update start");
|
||
if (!(await _ensureMidi()) || !_midiOutputs().length) {
|
||
console.log("[fw] no MIDI output (outputs:", _midiOutputs().length, ")");
|
||
return alert("Connect the device (Chrome/Edge/Firefox), then try again.");
|
||
}
|
||
console.log("[fw] MIDI ok; outputs:", _midiOutputs().map((o) => o.name));
|
||
const reply = await _queryDeviceVersion();
|
||
const { id: devId, version: dev } = _parseDeviceReply(reply);
|
||
const paths = FW_PATHS[devId] || FW_PATHS.K; // unknown id -> assume Kit (pre-0.0.23 firmware)
|
||
console.log("[fw] device reply:", reply, "-> id:", devId || "(none)", "version:", dev, "paths:", paths.label);
|
||
let latest = null, b64 = null;
|
||
for (const base of ["", "https://metronome.varasys.io"]) {
|
||
try { const t = await (await fetch(base + paths.py, { cache: "no-store" })).text();
|
||
const m = t.match(/APP_VERSION\s*=\s*["']([^"']+)["']/); if (m) latest = m[1]; } catch (_) {}
|
||
try { const r = await fetch(base + paths.mpy, { cache: "no-store" });
|
||
if (r.ok) { b64 = _b64(new Uint8Array(await r.arrayBuffer())); break; } } catch (_) {}
|
||
}
|
||
if (!b64) { // offline: let the user pick app.mpy
|
||
alert("Can't reach the site.\n\nPick the firmware file (app.mpy) to flash — download it from\n" +
|
||
"metronome.varasys.io" + paths.mpy + ", or use the online editor at metronome.varasys.io/editor.html.");
|
||
const u8 = await _pickBinary(); if (!u8) return;
|
||
b64 = _b64(u8); if (!latest) latest = "(picked .mpy)";
|
||
}
|
||
if (!latest) latest = "?";
|
||
console.log("[fw] latest:", latest, "| .mpy base64 length:", b64 && b64.length);
|
||
const upToDate = dev && dev === latest;
|
||
if (!confirm(paths.label + " firmware: " + (dev || "unknown") + "\nNew build: " + latest +
|
||
(upToDate ? "\n\nSame version. Re-install anyway?"
|
||
: "\n\nUpdate now? The device reboots, runs the new build, and auto-rolls-back if it fails to start."))) {
|
||
console.log("[fw] confirm returned false (cancelled, OR the browser is suppressing dialogs -> reload the page)");
|
||
return;
|
||
}
|
||
console.log("[fw] pushing", b64.length, "base64 chars...");
|
||
clearInterval(_midiBeat); _midiBeat = 0; // pause the Device-audio heartbeat (its MIDI traffic stalls the push)
|
||
const err = await _pushFirmware(b64);
|
||
if (_midiOn) _heartbeat(true); // resume it afterwards
|
||
console.log("[fw] push result:", err || "OK");
|
||
if (err) return alert("Update didn't complete (" + err + ").\n\nThe device kept its working firmware — nothing was installed. " +
|
||
"Make sure it's plugged in and NOT in editor mode (don't hold A), then retry.");
|
||
alert("Update sent ✓ — the device verified v" + latest + " and is rebooting into it. It auto-confirms after a few seconds, or rolls back if it won't start.");
|
||
}
|
||
// One ACK/NAK await (the device replies 0x7F=ok / 0x7E=rejected after each SysEx); resolves true/false/null(timeout).
|
||
function _ack(timeout) {
|
||
return new Promise((res) => { _saveCb = res; setTimeout(() => { if (_saveCb) { _saveCb = null; res(null); } }, timeout); });
|
||
}
|
||
function _b64(u8) { let s = ""; for (let i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i]); return btoa(s); }
|
||
// Push the base64-encoded .mpy in small, flow-controlled chunks: begin(0x21) -> data(0x22)* -> commit(0x23),
|
||
// waiting for each ACK. The device base64-decodes each chunk to /app.new, verifies the .mpy header, then
|
||
// A/B-installs + reboots. CH is small (and a multiple of 4) so a chunk fits the Pico's USB-MIDI RX buffer.
|
||
async function _pushFirmware(b64) {
|
||
// Diagnostic: list every MIDI output the editor sees + which pass _isDevicePort
|
||
console.log("[fw] outputs:", _midiOutputs().map(o => ({ name: o.name || "?", match: _isDevicePort(o) })));
|
||
console.log("[fw] inputs:", _midiInputs() .map(i => ({ name: i.name || "?", match: _isDevicePort(i) })));
|
||
console.log("[fw] sending BEGIN (0x21)");
|
||
_send([0xF0, 0x7D, 0x21, 0xF7]);
|
||
const beginA = await _ack(5000);
|
||
console.log("[fw] BEGIN ack:", beginA);
|
||
if (beginA !== true) return "handshake";
|
||
const CH = 64, total = Math.ceil(b64.length / CH); let done = 0;
|
||
const t0 = Date.now();
|
||
for (let o = 0; o < b64.length; o += CH) {
|
||
const part = b64.slice(o, o + CH); const msg = [0xF0, 0x7D, 0x22];
|
||
for (let i = 0; i < part.length; i++) msg.push(part.charCodeAt(i)); // base64 is ASCII
|
||
msg.push(0xF7);
|
||
const sendT = Date.now(); _send(msg);
|
||
const a = await _ack(10000);
|
||
const ackT = Date.now();
|
||
if (done < 3 || a !== true) { // log the first few chunks + any failure
|
||
console.log("[fw] chunk " + (done + 1) + "/" + total + " sent (" + msg.length + " bytes) ack=" + a + " after " + (ackT - sendT) + "ms");
|
||
}
|
||
if (a !== true) return "chunk " + (done + 1) + "/" + total + (a === false ? " rejected" : " no-ack");
|
||
if (++done % 25 === 0) {
|
||
const el = ((Date.now() - t0) / 1000).toFixed(1);
|
||
console.log("[fw] pushed " + done + " / " + total + " chunks (" + el + " s)");
|
||
}
|
||
}
|
||
console.log("[fw] all " + total + " chunks sent; committing...");
|
||
_send([0xF0, 0x7D, 0x23, 0xF7]);
|
||
return (await _ack(10000)) === true ? null : "verify";
|
||
}
|
||
function _pickBinary() { // offline fallback: choose an app.mpy -> Uint8Array (or null if cancelled)
|
||
return new Promise(async (res) => {
|
||
if (window.showOpenFilePicker) {
|
||
try { const [h] = await showOpenFilePicker({ types: [{ description: "PM_K-1 firmware (app.mpy)", accept: { "application/octet-stream": [".mpy"] } }] });
|
||
return res(new Uint8Array(await (await h.getFile()).arrayBuffer())); }
|
||
catch (e) { if (e.name === "AbortError") return res(null); }
|
||
}
|
||
const inp = document.createElement("input"); inp.type = "file"; inp.accept = ".mpy,application/octet-stream";
|
||
inp.onchange = async () => { inp.files[0] ? res(new Uint8Array(await inp.files[0].arrayBuffer())) : res(null); };
|
||
inp.oncancel = () => res(null);
|
||
inp.click();
|
||
});
|
||
}
|
||
|
||
/* Live sync (beta): bidirectional mirror with a connected PM_K-1 over the same
|
||
USB-MIDI SysEx channel. Rides _ensureMidi/_send and onDeviceMidi (above). */
|
||
/*@BUILD:include:src/livesync.js@*/
|
||
|
||
// 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;
|
||
}
|
||
|
||
/*@BUILD:include:src/setlists.js@*/
|
||
|
||
/* =========================================================================
|
||
VISUALS
|
||
========================================================================= */
|
||
function drawLoop() {
|
||
if (audioCtx) {
|
||
const raw = audioCtx.currentTime;
|
||
// playhead follows when the click is HEARD (compensate output latency); timers keep the true clock
|
||
const now = raw - (audioCtx.outputLatency || audioCtx.baseLatency || 0);
|
||
for (const m of meters) {
|
||
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; }
|
||
}
|
||
updateStatus(raw);
|
||
}
|
||
for (const m of meters) renderLaneStrip(m);
|
||
tickTimers();
|
||
requestAnimationFrame(drawLoop);
|
||
}
|
||
|
||
/* =========================================================================
|
||
PRACTICE TIMERS — advance only while the metronome is running
|
||
========================================================================= */
|
||
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"); }
|
||
// 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() {
|
||
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 (timersOn && state.running) {
|
||
timers.elapsedMs += dt;
|
||
if (timers.totalMs > 0) {
|
||
const before = timers.remainingMs;
|
||
timers.remainingMs -= dt;
|
||
// time countdown hit 0 → auto-advance (smooth). Bar-length segments use the
|
||
// bar counter instead (handled in advanceMaster), so only fire when segBars===0.
|
||
if (before > 0 && timers.remainingMs <= 0 && continueMode && segBars === 0 && !pendingSwitch) {
|
||
const nx = nextLoadedTarget();
|
||
if (nx) armSwitch(nx.sl, nx.item, "auto", "bar");
|
||
}
|
||
// otherwise it keeps counting past 0 into negative (overtime); never stops the metronome
|
||
}
|
||
}
|
||
renderTimers();
|
||
}
|
||
function renderTimers() {
|
||
$("dtimers").hidden = !timersOn;
|
||
if (!timersOn) return;
|
||
$("elapsedVal").textContent = fmtClock(timers.elapsedMs);
|
||
const off = timers.totalMs <= 0;
|
||
$("countWrap").hidden = off; // hide time countdown when off
|
||
if (!off) {
|
||
const cd = $("countVal");
|
||
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
|
||
}
|
||
// bar countdown — bars remaining in the current segment (audible bar from lane 1, not the look-ahead master clock)
|
||
const showBars = state.running && segBars > 0;
|
||
$("barWrap").hidden = !showBars;
|
||
if (showBars) {
|
||
const elapsed = meters.length ? meters[0].currentBar : segBarCount;
|
||
const remaining = Math.max(0, segBars - elapsed);
|
||
const bv = $("barVal");
|
||
bv.textContent = remaining;
|
||
bv.classList.toggle("low", remaining <= 1);
|
||
}
|
||
}
|
||
|
||
// 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) {
|
||
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);
|
||
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";
|
||
if (pendingSwitch) { // a switch is armed → show the target
|
||
const it = setlists[pendingSwitch.sl] && setlists[pendingSwitch.sl].items[pendingSwitch.item];
|
||
s += " · → " + (it ? it.name : "next");
|
||
}
|
||
ctxDisplay.textContent = s;
|
||
ctxDisplay.classList.toggle("muted-cue", muted || !!pendingSwitch);
|
||
}
|
||
function updateCtx() { updateStatus(); refreshPatchField(); postProg(); }
|
||
// when embedded (e.g. in the Concepts landing), report the current program to the parent
|
||
function postProg() { if (document.documentElement.dataset.embed !== "1") return;
|
||
try { parent.postMessage({ type: "varasys-prog", patch: currentPatch() }, "*"); } catch (e) {} }
|
||
|
||
/* keep the subtle program-string bar in sync with what's loaded, unless the
|
||
user is mid-edit in it (don't yank text out from under them). */
|
||
function refreshPatchField() {
|
||
const el = $("patchField"); if (!el || document.activeElement === el) return;
|
||
try { el.value = currentPatch(); } catch (e) {}
|
||
const bar = $("patchBar"); if (bar) bar.classList.remove("err");
|
||
const msg = $("patchMsg"); if (msg && !msg.dataset.sticky) msg.textContent = "";
|
||
}
|
||
function setPatchMsg(text, ok) {
|
||
const msg = $("patchMsg"); if (!msg) return;
|
||
msg.textContent = text || ""; msg.classList.toggle("ok", !!ok); msg.classList.toggle("bad", !ok && !!text);
|
||
msg.dataset.sticky = text ? "1" : "";
|
||
if (text) setTimeout(() => { msg.dataset.sticky = ""; if (ok) msg.textContent = ""; }, ok ? 1800 : 4000);
|
||
}
|
||
// Accept a patch string OR a base64 set-list code (or a #p=/#sl= link); decode, lint, load.
|
||
function commitPatchField() {
|
||
const el = $("patchField"), bar = $("patchBar");
|
||
let text = (el.value || "").trim();
|
||
if (!text) { setPatchMsg("", true); return; }
|
||
const m = text.match(/[#?&](p|sl)=([^&\s]+)/);
|
||
let kind = null, payload = text;
|
||
if (m) { kind = m[1]; try { payload = decodeURIComponent(m[2]); } catch (e) { payload = m[2]; } }
|
||
const looksB64 = /^[A-Za-z0-9_-]{12,}$/.test(payload) && !/[;:]/.test(payload);
|
||
try {
|
||
if (kind === "sl" || (kind !== "p" && looksB64)) {
|
||
const sl = codeToSetlist(payload); // base64 set-list code → decode
|
||
if (!sl.items || !sl.items.length) throw new Error("set-list code has no items");
|
||
applySetup(sl.items[0]);
|
||
setPatchMsg("✓ decoded set list “" + sl.title + "” (" + sl.items.length + " item" + (sl.items.length > 1 ? "s" : "") + ") — loaded item 1", true);
|
||
} else {
|
||
const s = patchToSetup(payload); // plain-text patch
|
||
if (!s.lanes.length) throw new Error("no lanes — try e.g. kick:4");
|
||
applyPatch(payload);
|
||
setPatchMsg("✓ " + s.lanes.length + " lane" + (s.lanes.length > 1 ? "s" : "") + " · " + s.bpm + " BPM", true);
|
||
}
|
||
bar.classList.remove("err"); el.blur(); refreshPatchField();
|
||
bar.classList.add("applied"); setTimeout(() => bar.classList.remove("applied"), 450);
|
||
} catch (e) { bar.classList.add("err"); setPatchMsg("✗ " + e.message, false); }
|
||
}
|
||
|
||
/* =========================================================================
|
||
UI WIRING
|
||
========================================================================= */
|
||
const $ = (id) => document.getElementById(id);
|
||
function syncStartBtn() {
|
||
if (state.running) { startBtn.textContent = "■ Stop"; startBtn.classList.add("on"); }
|
||
else { startBtn.textContent = "▶ Start"; startBtn.classList.remove("on"); }
|
||
}
|
||
function toggleShortcuts(show) { const o = $("shortcutsOverlay"); o.hidden = (show === undefined) ? !o.hidden : !show; }
|
||
// theme toggle + version stamp are handled by the shared chrome (src/chrome.js), wired below.
|
||
$("startBtn").addEventListener("click", () => toggleTransport());
|
||
let _taps = [];
|
||
function tapTempo() {
|
||
const now = performance.now();
|
||
_taps = _taps.filter((t) => now - t < 2000);
|
||
_taps.push(now);
|
||
if (_taps.length >= 2) {
|
||
let sum = 0; for (let i = 1; i < _taps.length; i++) sum += _taps[i] - _taps[i - 1];
|
||
setBpm(60000 / (sum / (_taps.length - 1)));
|
||
if (typeof syncBpm === "function") syncBpm();
|
||
}
|
||
}
|
||
$("tapBtn").addEventListener("click", tapTempo);
|
||
$("saveItemBtn").addEventListener("click", () => {
|
||
if (activeItem < 0) return;
|
||
updateItem();
|
||
const b = $("saveItemBtn"), t = b.textContent; b.textContent = "✓ Saved"; setTimeout(() => { b.textContent = t; }, 900);
|
||
});
|
||
$("bpm").addEventListener("input", (e) => { setBpm(+e.target.value); if (typeof syncBpm === "function") syncBpm(); });
|
||
$("vol").addEventListener("input", (e) => {
|
||
state.volume = +e.target.value / 100; volVal.textContent = e.target.value + "%";
|
||
if (masterGain) masterGain.gain.setTargetAtTime(state.volume, audioCtx.currentTime, 0.01);
|
||
if (typeof syncVol === "function") syncVol();
|
||
});
|
||
$("trainerOn").addEventListener("change", (e) => { trainer.on = e.target.checked; refreshFeatureBoxes(); syncPatchSoon(); });
|
||
$("playBars").addEventListener("input", (e) => { trainer.playBars = +e.target.value; syncPatchSoon(); });
|
||
$("muteBars").addEventListener("input", (e) => { trainer.muteBars = +e.target.value; syncPatchSoon(); });
|
||
$("rampOn").addEventListener("change", (e) => { ramp.on = e.target.checked; refreshFeatureBoxes(); syncPatchSoon(); });
|
||
$("rampStart").addEventListener("input", (e) => { ramp.startBpm = +e.target.value; syncPatchSoon(); });
|
||
$("rampAmt").addEventListener("input", (e) => { ramp.amount = +e.target.value; syncPatchSoon(); });
|
||
$("rampEvery").addEventListener("input", (e) => { ramp.everyBars = +e.target.value; syncPatchSoon(); });
|
||
$("addMeterBtn").addEventListener("click", () => addMeter("4", 1, "claves"));
|
||
$("countTime").addEventListener("input", (e) => { timers.totalMs = parseTime(e.target.value); timers.remainingMs = timers.totalMs; renderTimers(); syncPatchSoon(); });
|
||
$("elapsedReset").addEventListener("click", () => { timers.elapsedMs = 0; renderTimers(); });
|
||
$("countReset").addEventListener("click", () => { timers.remainingMs = timers.totalMs; renderTimers(); });
|
||
$("segBarsIn").addEventListener("input", (e) => { segBars = Math.max(0, parseInt(e.target.value, 10) || 0); renderTimers(); syncPatchSoon(); });
|
||
$("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; });
|
||
document.addEventListener("click", (e) => { const m = $("trayMenu"); if (m && !m.hidden && !m.contains(e.target) && e.target.id !== "trayMenuBtn") m.hidden = true; });
|
||
$("delSetlistBtn").addEventListener("click", deleteSetlist);
|
||
$("slMenuBtn").addEventListener("click", (e) => { e.stopPropagation(); buildSlMenu(); $("slMenu").hidden = !$("slMenu").hidden; });
|
||
document.addEventListener("click", (e) => { const m = $("slMenu"); if (m && !m.hidden && !m.contains(e.target) && e.target.id !== "slMenuBtn") m.hidden = true; });
|
||
$("slTitle").addEventListener("input", (e) => { const sl = getSL(); if (sl) { sl.title = e.target.value; saveSetlists(); } }); // rename the active list in place
|
||
$("slDesc").addEventListener("input", (e) => { const sl = getSL(); if (sl) { sl.description = e.target.value; saveSetlists(); } autoGrow(e.target); });
|
||
$("addItemBtn").addEventListener("click", () => { addItem($("itemName").value.trim()); $("itemName").value = ""; });
|
||
$("helpBtn").addEventListener("click", () => toggleShortcuts());
|
||
$("shortcutsClose").addEventListener("click", () => toggleShortcuts(false));
|
||
$("shortcutsOverlay").addEventListener("click", (e) => { if (e.target.id === "shortcutsOverlay") toggleShortcuts(false); });
|
||
$("exportBtn").addEventListener("click", () => { $("trayMenu").hidden = true; exportAll(); });
|
||
$("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 = ""; });
|
||
$("saveDeviceBtn").addEventListener("click", () => { $("trayMenu").hidden = true; saveToDevice(); });
|
||
$("loadDeviceBtn").addEventListener("click", () => { $("trayMenu").hidden = true; loadFromDevice(); });
|
||
$("updateFwBtn").addEventListener("click", () => { $("trayMenu").hidden = true; updateFirmware(); });
|
||
$("midiBtn").addEventListener("click", toggleDeviceAudio);
|
||
$("syncBtn").addEventListener("click", toggleSync); updateSyncBtn();
|
||
$("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(); } });
|
||
|
||
/* program-string bar: apply on Enter / blur, copy button, drop the error tint while typing */
|
||
$("patchField").addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); commitPatchField(); } });
|
||
$("patchField").addEventListener("change", commitPatchField);
|
||
$("patchField").addEventListener("input", () => $("patchBar").classList.remove("err"));
|
||
$("patchCopy").addEventListener("click", async () => {
|
||
const el = $("patchField"); try { await navigator.clipboard.writeText(el.value); const b = $("patchCopy"); b.textContent = "Copied!"; setTimeout(() => b.textContent = "Copy", 1200); } catch (e) { el.select(); }
|
||
});
|
||
$("shareOpen").addEventListener("click", () => window.open($("shareUrl").value, "_blank"));
|
||
window.addEventListener("keydown", (e) => {
|
||
const t = e.target, tag = t ? t.tagName : "", type = (t && t.type ? String(t.type) : "").toLowerCase();
|
||
// 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;
|
||
if (e.altKey && (k === "ArrowUp" || k === "ArrowDown")) { e.preventDefault(); moveCuedItem(k === "ArrowUp" ? -1 : 1); return; } // reorder cued item
|
||
// Enter = commit the cued item. Smooth (next bar) by default; Shift+Enter = rude (next beat).
|
||
// (shiftKey is NOT in the modifier guard below, so Shift+Enter reaches here.)
|
||
if (k === "Enter") {
|
||
if (tag === "BUTTON" || tag === "A" || tag === "SELECT") return; // let focused controls keep Enter
|
||
e.preventDefault();
|
||
if (cuedSL >= 0 && cuedItem >= 0) armSwitch(cuedSL, cuedItem, "commit", e.shiftKey ? "beat" : "bar");
|
||
return;
|
||
}
|
||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||
// Transport: Space always = play/stop. preventDefault so it never scrolls the
|
||
// page, toggles a focused checkbox, or re-fires a focused button.
|
||
if (k === " " || e.code === "Space") { e.preventDefault(); toggleTransport(); return; }
|
||
// A focused slider / dropdown uses these keys natively — leave it alone.
|
||
const arrowCtrl = tag === "SELECT" || (tag === "INPUT" && type === "range");
|
||
// ← / → : tempo (±1, Shift ±10).
|
||
if (k === "ArrowRight") { if (arrowCtrl) return; e.preventDefault(); setBpm(state.bpm + (e.shiftKey ? 10 : 1)); return; }
|
||
if (k === "ArrowLeft") { if (arrowCtrl) return; e.preventDefault(); setBpm(state.bpm - (e.shiftKey ? 10 : 1)); return; }
|
||
// ↑ ↓ Home End : move the cue cursor; PgUp/PgDn : cue across set lists. (Enter commits.)
|
||
if (k === "ArrowUp") { if (arrowCtrl) return; e.preventDefault(); cuePrev(); return; }
|
||
if (k === "ArrowDown") { if (arrowCtrl) return; e.preventDefault(); cueNext(); return; }
|
||
if (k === "Home") { if (arrowCtrl) return; e.preventDefault(); cueFirst(); return; }
|
||
if (k === "End") { if (arrowCtrl) return; e.preventDefault(); cueLast(); return; }
|
||
if (k === "PageUp") { if (arrowCtrl) return; e.preventDefault(); cueSetlist(-1); return; }
|
||
if (k === "PageDown") { if (arrowCtrl) return; e.preventDefault(); cueSetlist(1); return; }
|
||
if (k === "t" || k === "T") { tapTempo(); return; }
|
||
if (k === "a" || k === "A") { addMeter("4", 1, "claves"); return; }
|
||
if (k === "p" || k === "P") { prevItem(); return; } // rude quick-step (next beat while playing)
|
||
if (k === "n" || k === "N") { nextItem(); return; }
|
||
if (k === "?") { toggleShortcuts(true); return; }
|
||
if (k === "Escape") {
|
||
if (!$("shareOverlay").hidden) $("shareOverlay").hidden = true;
|
||
else if (!$("shortcutsOverlay").hidden) toggleShortcuts(false);
|
||
else if (pendingSwitch) { pendingSwitch = null; updateCtx(); } // cancel an armed switch
|
||
return;
|
||
}
|
||
if (k >= "1" && k <= "9") { const m = meters[+k - 1]; if (m) setLaneEnabled(m, !m.enabled); }
|
||
});
|
||
|
||
/* init */
|
||
// Seed the demo set lists. Versioned + additive: a newer SEED_VERSION adds any
|
||
// seed list whose title isn't already present, without clobbering the user's lists
|
||
// (and won't re-add one they've deleted at the same version).
|
||
const SEED_VERSION = 3;
|
||
if ((lsGet(LS.seeded, 0) | 0) < SEED_VERSION) {
|
||
for (const s of SEED_SETLISTS) {
|
||
if (!setlists.some((x) => x.title === s.title)) {
|
||
setlists.push({ title: s.title, description: s.description, items: s.items.map(([n, p]) => ({ name: n, ...patchToSetup(p) })) });
|
||
}
|
||
}
|
||
if (activeSL >= setlists.length) activeSL = Math.max(0, setlists.length - 1);
|
||
saveSetlists();
|
||
lsSet(LS.seeded, SEED_VERSION);
|
||
}
|
||
// 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();
|
||
refreshFeatureBoxes();
|
||
$("continueMode").checked = continueMode;
|
||
$("timersOn").checked = timersOn;
|
||
requestAnimationFrame(drawLoop);
|
||
/*@BUILD:include:src/chrome.js@*/
|
||
</script>
|
||
</body>
|
||
</html>
|