Clean, dependency-light front page. Only three things ship here: - index.html — two-button landing: Mobile -> mobile.html, Desktop -> pm_e-2.html - mobile.html — touch-first PWA (+ mobile-sessions.html practice journal) - pm_e-2.html — engraved-notation editor build.sh/deploy.sh trimmed to just these; deploy mirrors dist/ to the web root with rsync --delete. README/CLAUDE.md rewritten for the slim scope. The full project (PM_E-1 editor, embeddable widget, all hardware form-factor pages, Pico firmware editions, the Rust port, and the KiCad/SPICE hardware design) is preserved on the `concepts` branch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1833 lines
115 KiB
HTML
1833 lines
115 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‑2 — PolyMeter Editor (Notation)</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:none; margin:0 auto; align-items:flex-start; justify-content:center; }
|
||
/* Logo lives in the device's top-left corner (not the header); header fills + keeps just nav. */
|
||
.site-head, .site-foot { max-width:none; }
|
||
.site-head .brand { display:none; }
|
||
.site-head { justify-content:flex-end; }
|
||
.devbrand { line-height:0; }
|
||
.devbrand .brand-logo { height:30px; }
|
||
.device { flex:1 1 auto; min-width:0; max-width:none; 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; }
|
||
/* Top display panel: compact, evenly spaced. A flex column with a small,
|
||
uniform gap replaces the old per-element margins (which stacked into big gaps). */
|
||
.display { background:#0a0d11; border:1px solid #000; border-radius:8px; padding:10px 14px; text-align:center; box-shadow:inset 0 2px 10px rgba(0,0,0,.7);
|
||
display:flex; flex-direction:column; align-items:center; gap:6px; }
|
||
.display .big { font-family:"Courier New",monospace; font-weight:700; font-size:72px; 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:24px; color:#4dd0e1; margin:0; display:flex; gap:8px 16px; justify-content:center; flex-wrap:wrap; line-height:1.1; }
|
||
.display .dtimers[hidden] { display:none; }
|
||
.display .ctx { font-family:"Courier New",monospace; font-size:18px; color:#4dd0e1; min-height:20px; line-height:1.2; }
|
||
.display .ctx.muted-cue { color:#ffb454; }
|
||
/* Gap-trainer indicator in the display: persistent green "GAP p/m" while armed;
|
||
turns amber while the current bars are muted (count-along cue). */
|
||
.display .gap-ind { color:#5fd08a; font-size:20px; }
|
||
.display .gap-ind.muting { color:#ffb454; }
|
||
/* PM_E-2: inactive function slots stay visible but dimmed (always-on status row) */
|
||
.display .dtimers .off { opacity:.28; }
|
||
.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; }
|
||
/* --- PM_E-2 notation surface --- */
|
||
@font-face { font-family:"Bravura"; src:url("data:font/woff2;base64,@BUILD:bravura@") format("woff2"); font-display:block; }
|
||
/* engraved like paper: white sheet, dark ink (theme-independent) */
|
||
.staffwrap { background:#ffffff; border:1px solid var(--edge); border-radius:12px; padding:6px 10px;
|
||
box-shadow:0 1px 6px rgba(0,0,0,.28); margin:2px 0; }
|
||
.staffwrap canvas { width:100%; display:block; cursor:pointer; }
|
||
.staffwrap.konn canvas { cursor:default; } /* konnakol is read-only */
|
||
#konnakolCanvas { height:84px; }
|
||
#staffCanvas { height:172px; }
|
||
#tubsCanvas { height:168px; }
|
||
/* collapsible sections */
|
||
.sect { margin:8px 0; }
|
||
.sect > summary { cursor:pointer; list-style:none; font-size:11px; text-transform:uppercase; letter-spacing:1.2px; color:var(--muted); padding:4px 0; user-select:none; }
|
||
.sect > summary::-webkit-details-marker { display:none; }
|
||
.sect > summary::before { content:"\25be\00a0"; } /* ▾ open */
|
||
.sect:not([open]) > summary::before { content:"\25b8\00a0"; } /* ▸ closed */
|
||
.sect > summary .shint { text-transform:none; letter-spacing:normal; font-size:11px; opacity:.7; }
|
||
/* the two device controls in the header — matching pills (state shown via colour/border in JS) */
|
||
.devctrl{ font:inherit; font-size:12px; line-height:1.5; padding:3px 10px; border-radius:7px;
|
||
border:1px solid var(--edge); background:transparent; color:var(--muted);
|
||
white-space:nowrap; cursor:pointer; }
|
||
.devctrl:hover{ border-color:var(--muted); }
|
||
</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">
|
||
<a class="brand devbrand" href="/" title="VARASYS — Simplifying Complexity" style="flex:0 0 auto">
|
||
<img class="brand-logo brand-dark" src="data:image/png;base64,@BUILD:logo-dark@" alt="VARASYS — Simplifying Complexity" />
|
||
<img class="brand-logo brand-light" src="data:image/png;base64,@BUILD:logo-light@" alt="VARASYS — Simplifying Complexity" />
|
||
</a>
|
||
<h1 style="margin:0">PM_E‑2 <span style="font-weight:400; opacity:.75">PolyMeter Editor</span> <span style="font-weight:400; opacity:.5; font-size:12px">Notation</span></h1>
|
||
<div class="appheader-ctrls" style="display:flex; align-items:center; gap:8px">
|
||
<span id="devBadge" class="devctrl"
|
||
title="Device link (USB-MIDI). Click to connect — Chrome, Edge or Firefox. Turns green and shows the name while a PM device (PM_K-1 / PM_X-1 / PM_G-1) is plugged in; ◎ means none is detected. This only reports the connection — it does not make sound on its own.">◎ connect device</span>
|
||
<button id="midiBtn" class="devctrl"
|
||
title="Device audio — on/off switch. When ON, the notes a connected device sends over USB-MIDI are played through THIS computer's speakers (the device drives the sound, locked to its clock). You can switch it on before plugging in — it doesn't need a device to toggle; plug one in while it's on and it sounds through the computer.">🎹 Device audio</button>
|
||
<button id="midiOutBtn" class="devctrl"
|
||
title="MIDI out — on/off switch. When ON, the groove is sent as MIDI notes to external gear (a drum module / e-kit) on the output port chosen at right: GM drum notes on channel 10, scheduled tightly in sync with playback. Independent of the local synth and of Device audio. Web MIDI · Chrome/Edge/Firefox.">🎛 MIDI out</button>
|
||
<select id="midiOutSel" class="devctrl" title="MIDI output port to drive (your drum module / e-kit)" hidden></select>
|
||
<label id="midiClkWrap" class="devctrl" hidden style="display:inline-flex; align-items:center; gap:5px"
|
||
title="Also send 24-PPQN MIDI clock + Start/Stop on this port, so the gear's tempo/sequencer locks to the editor."><input type="checkbox" id="midiClkChk" checked style="margin:0"> clock</label>
|
||
<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="rampWrap" class="tval" title="tempo ramp" hidden></span>
|
||
<span id="gapWrap" class="tval gap-ind" title="gap / mute trainer — plays N bars, mutes M bars" hidden></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></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 class="row" style="gap:10px; align-items:center; margin-top:6px">
|
||
<label style="font-size:12px">At end
|
||
<select class="txt" id="endAction" title="What this track does after 'rep' cycles (a cycle = Bars above, else one bar). Loop = the metronome default; explicit choices override the global Continue toggle." style="width:108px">
|
||
<option value="loop">loop forever</option>
|
||
<option value="next">next track</option>
|
||
<option value="stop">stop</option>
|
||
<option value="goto">goto ±</option>
|
||
</select>
|
||
</label>
|
||
<label style="font-size:12px" id="gotoWrap" hidden>by <input type="number" class="num" id="endGoto" min="-99" max="99" value="-1" title="relative track offset: -2 = back two (D.S.), +1 = next" style="width:52px"></label>
|
||
<label style="font-size:12px" id="repWrap">× <input type="number" class="num" id="endRep" min="1" max="99" value="1" title="cycles before the end-action fires" style="width:52px"></label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- PM_E-2: Lanes config first, then three collapsible notation lenses (beats aligned across all). -->
|
||
<details class="sect" open>
|
||
<summary>Lanes <span class="shint">— add voices; set grouping, subdivision, swing, polyrhythm, gain</span></summary>
|
||
<div id="meters"></div>
|
||
<div style="margin-top:10px"><button class="add" id="addMeterBtn" title="add meter lane (A)">+</button></div>
|
||
</details>
|
||
|
||
<div class="hint" style="margin:12px 0 2px">Click a step on the <b>Staff</b> or <b>TUBS</b> grid to cycle <b>accent → normal → ghost → rest</b>; <b>Shift-click</b> cycles <b>flam → drag → roll</b>.</div>
|
||
|
||
<details class="sect" open>
|
||
<summary>Konnakol</summary>
|
||
<div class="staffwrap konn"><canvas id="konnakolCanvas"></canvas></div>
|
||
</details>
|
||
<details class="sect" open>
|
||
<summary>Staff</summary>
|
||
<div class="staffwrap"><canvas id="staffCanvas"></canvas></div>
|
||
</details>
|
||
<details class="sect" open>
|
||
<summary>TUBS</summary>
|
||
<div class="staffwrap tubs"><canvas id="tubsCanvas"></canvas></div>
|
||
</details>
|
||
|
||
<!-- (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>
|
||
|
||
<!-- Practice-log header + "log sessions" toggle. When OFF, played sessions are NOT
|
||
appended to the practice log (for noodling / adjusting); existing entries are kept.
|
||
Persisted in localStorage (LS.logging); default ON. -->
|
||
<div class="practice" style="margin-top:18px">
|
||
<div class="tray-head" style="margin-bottom:8px">
|
||
<h2 style="margin:0">Practice log</h2>
|
||
<label class="mini-check" id="loggingToggle" title="When off, sessions you play are NOT recorded to the practice log — handy while adjusting settings or just playing around. Existing history is kept.">
|
||
<input type="checkbox" id="logSessions" checked> log sessions
|
||
</label>
|
||
</div>
|
||
<div id="logView"></div>
|
||
</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, rep: null, end: null };
|
||
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@*/
|
||
/*@BUILD:include:src/notation.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);
|
||
midiOutClock(ahead); // schedule 24-PPQN MIDI clock ticks across the same look-ahead window
|
||
}
|
||
|
||
/* =========================================================================
|
||
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 = [];
|
||
midiOutStart(t0); // MIDI clock Start (if MIDI out + clock on)
|
||
schedulerTimer = setInterval(scheduler, LOOKAHEAD_MS);
|
||
scheduler(); syncStartBtn();
|
||
}
|
||
function stop() {
|
||
midiOutStop(); // MIDI clock Stop (if MIDI out + clock on)
|
||
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, orns = null) {
|
||
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)
|
||
orns: orns ? orns.slice() : [], // per-STEP ornament (0 none / 1 flam / 2 drag / 3 roll), parallel to beatsOn
|
||
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();
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
// 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>
|
||
<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.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); });
|
||
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); });
|
||
const sel = $c(`#m${m.id}_sound`); sel.value = m.sound;
|
||
sel.addEventListener("change", (e) => m.sound = e.target.value);
|
||
// 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(); };
|
||
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);
|
||
const enCb = $c(`#m${m.id}_enable`); enCb.checked = m.enabled;
|
||
enCb.addEventListener("change", (e) => setLaneEnabled(m, e.target.checked));
|
||
card.querySelector(".lane-row").classList.toggle("lane-off", !m.enabled);
|
||
$c(`#m${m.id}_remove`).addEventListener("click", () => removeMeter(m.id));
|
||
|
||
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 || [], prevOrn = m.orns || [], oldBpb = m._maskBpb || 0, oldSpb = m._maskSpb || 1;
|
||
const next = [], ornNext = [];
|
||
for (let b = 0; b < m.beatsPerBar; b++) {
|
||
for (let s = 0; s < spb; s++) {
|
||
let val = stepDefault(s), orn = 0;
|
||
if (b < oldBpb) { // this beat existed before
|
||
if (oldSpb === spb) { val = normLevel(prev[b * oldSpb + s], stepDefault(s)); orn = prevOrn[b * oldSpb + s] | 0; } // same resolution → step-for-step
|
||
else if (s === 0) { val = normLevel(prev[b * oldSpb], 2); orn = prevOrn[b * oldSpb] | 0; } // resolution changed → keep the downbeat; new subs default
|
||
}
|
||
next.push(val); ornNext.push(orn);
|
||
}
|
||
}
|
||
m.beatsOn = next; m.orns = ornNext; m._maskBpb = m.beatsPerBar; m._maskSpb = spb;
|
||
m.el.querySelector(`#m${m.id}_sum`).textContent = "=" + m.beatsPerBar;
|
||
}
|
||
|
||
/* =========================================================================
|
||
PRESETS (localStorage)
|
||
========================================================================= */
|
||
const LS = { presets: "metronome.presets", setlists: "metronome.setlists", logs: "metronome.logs", seeded: "metronome.seeded", continue: "metronome.continue", timers: "metronome.timers", logging: "metronome.logging" };
|
||
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(), orns: (m.orns || []).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, c.orns);
|
||
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
|
||
let loggingOn = lsGet(LS.logging, true); // master switch for recording practice-log sessions (default ON)
|
||
|
||
function currentSetup() { return { bpm: state.bpm, lanes: snapshotLanes(), trainer: { ...trainer }, ramp: { ...ramp }, countMs: timers.totalMs, bars: segBars, rep: state.rep, end: state.end }; }
|
||
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
|
||
state.rep = s.rep != null ? s.rep : null; state.end = s.end != null ? s.end : null; // per-track playback flow (preserved on round-trip)
|
||
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;
|
||
syncEndActionUI();
|
||
refreshFeatureBoxes(); renderTimers();
|
||
}
|
||
// Per-track playback flow control: state.end (null | 'stop' | int offset) + state.rep (cycles).
|
||
function syncEndActionUI() {
|
||
const end = state.end;
|
||
const action = end == null ? "loop" : end === "stop" ? "stop" : end === 1 ? "next" : "goto";
|
||
$("endAction").value = action;
|
||
$("gotoWrap").hidden = action !== "goto";
|
||
$("repWrap").hidden = action === "loop";
|
||
if (action === "goto") $("endGoto").value = typeof end === "number" ? end : -1;
|
||
$("endRep").value = state.rep && state.rep > 1 ? state.rep : 1;
|
||
}
|
||
function readEndActionUI() {
|
||
const action = $("endAction").value;
|
||
$("gotoWrap").hidden = action !== "goto";
|
||
$("repWrap").hidden = action === "loop";
|
||
const rep = Math.max(1, parseInt($("endRep").value, 10) || 1);
|
||
if (action === "loop") { state.end = null; state.rep = null; }
|
||
else if (action === "next") { state.end = 1; state.rep = rep; }
|
||
else if (action === "stop") { state.end = "stop"; state.rep = rep; }
|
||
else { state.end = parseInt($("endGoto").value, 10) || 0; state.rep = rep; }
|
||
updateCtx();
|
||
}
|
||
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 (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();
|
||
}
|
||
|
||
// --- 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;
|
||
if (!loggingOn) { nowPlaying = null; return; } // logging off → discard this session, keep existing history
|
||
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, rep: state.rep, end: state.end }); }
|
||
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 the PM devices' USB-MIDI ports by name;
|
||
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") ||
|
||
// native Rust firmware enumerates as e.g. "PM_G-1 Grid" / "PM_K-1" / "PM_X-1" (VARASYS / PolyMeter)
|
||
n.includes("pm_g") || n.includes("pm_k") || n.includes("pm_x") ||
|
||
n.includes("pm-g") || n.includes("pm-k") || n.includes("pm-x") ||
|
||
n.includes("grid") || n.includes("polymeter");
|
||
}
|
||
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(); updateDevBadge(); populateMidiOutPorts(); }
|
||
function updateDevBadge() { // header badge: lights green while a PM_K-1 / PM_X-1 is connected over USB-MIDI
|
||
const el = $("devBadge"); if (!el) return;
|
||
const dev = _midiAccess ? [..._midiOutputs(), ..._midiInputs()].filter(_isDevicePort) : [];
|
||
if (dev.length) { el.textContent = "● " + (dev[0].name || "device").slice(0, 18); el.style.color = "#2fe07a"; el.style.borderColor = "#2fe07a"; }
|
||
else { el.textContent = _midiAccess ? "◎ no device" : "◎ connect device"; el.style.color = "var(--muted)"; el.style.borderColor = "var(--edge)"; }
|
||
}
|
||
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
|
||
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) { // off: muted pill, matching the device badge's idle look
|
||
b.textContent = "🎹 Device audio";
|
||
b.style.color = "var(--muted)"; b.style.borderColor = "var(--edge)"; b.style.boxShadow = "";
|
||
return;
|
||
}
|
||
const names = _midiInputs().map((i) => i.name || "MIDI"); // on: green, like the connected badge
|
||
b.textContent = "🎹 " + (names.length ? names[0].slice(0, 16) : "audio on");
|
||
b.style.color = "#2fe07a"; b.style.borderColor = "#2fe07a";
|
||
}
|
||
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.");
|
||
}
|
||
/*@BUILD:include:src/midiout.js@*/
|
||
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" },
|
||
G: { py: "/pico-scroll-app.py", mpy: "/pico-scroll-app.mpy", label: "PM_G-1 Grid" } };
|
||
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();
|
||
});
|
||
}
|
||
|
||
// 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
|
||
========================================================================= */
|
||
// Render all three notation lenses (Konnakol / Staff / TUBS) from the same groove model each frame.
|
||
function renderViews() {
|
||
if (typeof NOTATION === "undefined") return;
|
||
// Continuous master-bar fraction (0..1): interpolate within the current step using its real
|
||
// onset/next-onset times so the playhead tracks time smoothly (and lands on the beat at onset).
|
||
let phase = 0;
|
||
const m0 = meters[0];
|
||
if (state.running && m0 && m0.currentStep >= 0 && audioCtx) {
|
||
const now = audioCtx.currentTime - (audioCtx.outputLatency || audioCtx.baseLatency || 0);
|
||
const steps = (m0.beatsPerBar * m0.stepsPerBeat) || 1;
|
||
const t0 = (m0.curStepTime != null) ? m0.curStepTime : now;
|
||
const nx = m0.vq[m0.vqPtr];
|
||
const t1 = nx ? nx.time : t0 + (60 / state.bpm) / (m0.stepsPerBeat || 1);
|
||
const g = t1 > t0 ? Math.max(0, Math.min(1, (now - t0) / (t1 - t0))) : 0;
|
||
phase = (m0.currentStep + g) / steps;
|
||
}
|
||
const lanes = meters.map((m, i) => ({
|
||
idx: i, sound: m.sound, groups: (m.groups || []).slice(), sub: m.stepsPerBeat,
|
||
swing: !!m.swing, poly: !!m.poly, muted: !m.enabled,
|
||
levels: (m.beatsOn || []).slice(), orns: (m.orns || []),
|
||
}));
|
||
// Shared left gutter so the bar (and every beat) lines up vertically across all three views.
|
||
// Width = whatever the staff's clef+time-signature needs, vs the TUBS voice labels (whichever is wider).
|
||
const vis = lanes.filter((l) => !l.muted);
|
||
const EM = 44, clefW = EM * 0.6, tsDigit = EM * 0.4; // staff metrics (staffSpace 11)
|
||
const master = vis.find((l) => !l.poly) || vis[0] || { groups: [4] };
|
||
const mg = master.groups && master.groups.length ? master.groups : [4];
|
||
const mbeats = mg.reduce((a, b) => a + b, 0) || 4;
|
||
const numParts = mg.length > 1 ? mg : [mbeats];
|
||
const numGlyphs = numParts.reduce((a, n) => a + String(n).length, 0) + (numParts.length - 1);
|
||
const staffX0 = 14 + clefW + numGlyphs * tsDigit + 14;
|
||
const labelChars = vis.length ? Math.max(...vis.map((l) => (l.sound || "").length + (l.poly ? 2 : 0) + (/^clave/.test(l.sound || "") ? 7 : 0))) : 6;
|
||
const tubsX0 = 18 + labelChars * 7 + 8;
|
||
const gutter = Math.max(96, Math.ceil(staffX0), Math.ceil(tubsX0));
|
||
const base = { name: (window.npName && npName.textContent) || "", bpm: state.bpm, playing: state.running, phase, lanes, gutter };
|
||
const k = document.getElementById("konnakolCanvas");
|
||
const s = document.getElementById("staffCanvas");
|
||
const t = document.getElementById("tubsCanvas");
|
||
if (k) NOTATION.draw(k, Object.assign({ view: "konnakol" }, base));
|
||
if (s) NOTATION.draw(s, Object.assign({ view: "staff" }, base));
|
||
if (t) NOTATION.draw(t, Object.assign({ view: "tubs" }, base));
|
||
}
|
||
|
||
// Editing on the Staff or TUBS canvas: click a step to cycle its dynamic (accent→normal→ghost→rest);
|
||
// Shift-click cycles its ornament (none→flam→drag→roll). Each view publishes a hit map (cv._hit).
|
||
function editFromHit(e) {
|
||
const cv = e.currentTarget, h = cv._hit; if (!h) return;
|
||
const r = cv.getBoundingClientRect(), cx = e.clientX - r.left, cy = e.clientY - r.top;
|
||
let idx = -1, step = -1;
|
||
if (h.kind === "staff") {
|
||
let best = null, bestD = 1e9; // nearest voice row by vertical position
|
||
for (const L of h.lanes) { const d = Math.abs((h.staffTop + L.p * (h.S / 2)) - cy); if (d < bestD) { bestD = d; best = L; } }
|
||
if (!best || bestD > h.S * 2.2) return;
|
||
const sIdx = Math.floor((cx - h.x0) / (h.barW / best.steps));
|
||
if (sIdx < 0 || sIdx >= best.steps) return;
|
||
idx = best.idx; step = sIdx;
|
||
} else if (h.kind === "tubs") {
|
||
const ri = Math.floor((cy - h.top) / h.rowH); if (ri < 0 || ri >= h.rows.length) return;
|
||
const row = h.rows[ri], cell = (cx - h.x0) / h.cw, sIdx = Math.round((cell - 0.5) / row.span);
|
||
if (sIdx < 0 || sIdx >= row.steps) return;
|
||
if (Math.abs(cell - (sIdx * row.span + 0.5)) > Math.max(0.6, row.span * 0.55)) return; // clicked a gap
|
||
idx = row.idx; step = sIdx;
|
||
}
|
||
if (idx < 0) return;
|
||
const m = meters[idx]; if (!m) return;
|
||
if (e.shiftKey) { while (m.orns.length < m.beatsOn.length) m.orns.push(0); m.orns[step] = ((m.orns[step] | 0) + 1) % 4; }
|
||
else m.beatsOn[step] = NEXT_LEVEL[m.beatsOn[step] | 0];
|
||
renderViews();
|
||
if (typeof refreshPatchField === "function") refreshPatchField();
|
||
}
|
||
["staffCanvas", "tubsCanvas"].forEach((id) => { const cv = document.getElementById(id); if (cv) cv.addEventListener("click", editFromHit); });
|
||
|
||
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.curStepTime = m.vq[m.vqPtr].time; m.vqPtr++; }
|
||
if (m.vqPtr > 512) { m.vq = m.vq.slice(m.vqPtr); m.vqPtr = 0; }
|
||
}
|
||
updateStatus(raw);
|
||
}
|
||
renderViews();
|
||
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 (state.running) {
|
||
timers.elapsedMs += dt; // elapsed stopwatch always runs while playing (like the device)
|
||
if (timersOn && 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();
|
||
}
|
||
// PM_E-2 divergence from PM_E-1: every function slot stays in the display row, dimmed (".off")
|
||
// when inactive and lit when on — so all functions are visible at a glance, space-consciously.
|
||
function renderTimers() {
|
||
$("dtimers").hidden = false;
|
||
$("elapsedVal").textContent = fmtClock(timers.elapsedMs);
|
||
// Tempo ramp
|
||
const rw = $("rampWrap"); rw.hidden = false;
|
||
if (ramp.on) { rw.classList.remove("off"); rw.textContent = (ramp.amount < 0 ? "↘ " : "↗ ") + (ramp.amount >= 0 ? "+" : "") + ramp.amount + "/" + ramp.everyBars + "b"; }
|
||
else { rw.classList.add("off"); rw.textContent = "↗ ramp"; }
|
||
// Gap / mute trainer — "GAP play/mute" when armed; amber while a muted window is active.
|
||
const gw = $("gapWrap"); gw.hidden = false;
|
||
if (trainer.on && trainer.muteBars > 0) {
|
||
gw.classList.remove("off");
|
||
gw.textContent = "GAP " + trainer.playBars + "/" + trainer.muteBars;
|
||
const muting = state.running && typeof isMutedAt === "function" && audioCtx && isMutedAt(audioCtx.currentTime);
|
||
gw.classList.toggle("muting", !!muting);
|
||
} else { gw.classList.add("off"); gw.classList.remove("muting"); gw.textContent = "GAP off"; }
|
||
// Time countdown
|
||
const cw = $("countWrap"), cd = $("countVal"); cw.hidden = false;
|
||
const cdOff = !timersOn || timers.totalMs <= 0;
|
||
if (cdOff) { cw.classList.add("off"); cd.textContent = "--"; cd.classList.remove("over", "low"); }
|
||
else {
|
||
cw.classList.remove("off");
|
||
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
|
||
}
|
||
// Bars in segment — remaining while playing (lit); the configured length when stopped (dimmed).
|
||
const bw = $("barWrap"), bv = $("barVal"); bw.hidden = false;
|
||
const showBars = state.running && segBars > 0;
|
||
if (showBars) {
|
||
bw.classList.remove("off");
|
||
const elapsed = meters.length ? meters[0].currentBar : segBarCount;
|
||
const remaining = Math.max(0, segBars - elapsed);
|
||
bv.textContent = remaining;
|
||
bv.classList.toggle("low", remaining <= 1);
|
||
} else {
|
||
bw.classList.add("off"); bv.classList.remove("low");
|
||
bv.textContent = segBars > 0 ? segBars : "--";
|
||
}
|
||
}
|
||
|
||
// 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 (!payload.includes(":")) throw new Error("no lanes — try e.g. kick:4"); // raw-input check (patchToSetup itself defaults to beep: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)));
|
||
}
|
||
}
|
||
$("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));
|
||
$("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);
|
||
});
|
||
$("trainerOn").addEventListener("change", (e) => { trainer.on = e.target.checked; refreshFeatureBoxes(); renderTimers(); });
|
||
$("playBars").addEventListener("input", (e) => { trainer.playBars = +e.target.value; renderTimers(); });
|
||
$("muteBars").addEventListener("input", (e) => { trainer.muteBars = +e.target.value; renderTimers(); });
|
||
$("rampOn").addEventListener("change", (e) => { ramp.on = e.target.checked; refreshFeatureBoxes(); });
|
||
$("rampStart").addEventListener("input", (e) => ramp.startBpm = +e.target.value);
|
||
$("rampAmt").addEventListener("input", (e) => ramp.amount = +e.target.value);
|
||
$("rampEvery").addEventListener("input", (e) => ramp.everyBars = +e.target.value);
|
||
$("addMeterBtn").addEventListener("click", () => addMeter("4", 1, "claves"));
|
||
$("countTime").addEventListener("input", (e) => { timers.totalMs = parseTime(e.target.value); timers.remainingMs = timers.totalMs; renderTimers(); });
|
||
$("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(); });
|
||
$("endAction").addEventListener("change", readEndActionUI);
|
||
$("endGoto").addEventListener("input", readEndActionUI);
|
||
$("endRep").addEventListener("input", readEndActionUI);
|
||
$("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(); });
|
||
$("logSessions").addEventListener("change", (e) => { loggingOn = e.target.checked; lsSet(LS.logging, loggingOn); }); // practice-log on/off (persisted)
|
||
$("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);
|
||
$("midiOutBtn").addEventListener("click", toggleMidiOut);
|
||
$("devBadge").addEventListener("click", () => { _ensureMidi().then(updateDevBadge); });
|
||
$("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 = 4;
|
||
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;
|
||
$("logSessions").checked = loggingOn;
|
||
// Never prompt for Web MIDI on load. Only auto-reconnect if the user ALREADY granted it (querying
|
||
// the permission does NOT prompt); otherwise just show the badge — the "connect device" badge and
|
||
// the Device-audio button request access on an explicit click.
|
||
if (navigator.requestMIDIAccess && navigator.permissions && navigator.permissions.query) {
|
||
navigator.permissions.query({ name: "midi", sysex: true })
|
||
.then(p => { if (p.state === "granted") _ensureMidi().then(updateDevBadge).catch(() => updateDevBadge()); else updateDevBadge(); })
|
||
.catch(() => updateDevBadge());
|
||
} else updateDevBadge();
|
||
requestAnimationFrame(drawLoop);
|
||
/*@BUILD:include:src/chrome.js@*/
|
||
</script>
|
||
</body>
|
||
</html>
|