Inline the compact cyan VARASYS banner (base64 data URI) in the header, between the shortcut legend and the theme/help buttons, linked to varasys.io. Inlined rather than shipped as a file so deploy.sh's single-file publish still covers it; the self-contained banner reads the same on light and dark themes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1259 lines
87 KiB
HTML
1259 lines
87 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>Stackable Metronome — Mockup</title>
|
||
<script>
|
||
// Set theme before first paint (avoids a flash). 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/>.
|
||
-->
|
||
<!--
|
||
Browser mockup / simulator for the Pi Pico metronome.
|
||
|
||
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>
|
||
: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;
|
||
}
|
||
* { box-sizing: border-box; }
|
||
body {
|
||
margin:0; padding:24px; min-height:100vh;
|
||
background: radial-gradient(circle at 50% -10%, var(--bg2), var(--bg));
|
||
color: var(--txt); font-family: "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||
-webkit-font-smoothing: antialiased;
|
||
}
|
||
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:right; }
|
||
#app { display:flex; gap:18px; max-width:1400px; margin:0 auto; align-items:flex-start; justify-content:center; }
|
||
.device { flex:1 1 auto; min-width:0; max-width:1000px; background:linear-gradient(180deg, var(--panel), var(--bg));
|
||
border:1px solid var(--edge); border-radius:16px; padding:18px; box-shadow:0 18px 50px rgba(0,0,0,.5); }
|
||
.row { display:flex; gap:18px; flex-wrap:wrap; }
|
||
.card { background:var(--panel-2); border:1px solid var(--edge); border-radius:12px; padding:13px; flex:1; min-width:240px; }
|
||
.card h2 { font-size:11px; text-transform:uppercase; letter-spacing:1.4px; color:var(--muted); margin:0 0 14px; }
|
||
.display { background:#0a0d11; border:1px solid #000; border-radius:8px; padding:8px 14px; text-align:center; box-shadow:inset 0 2px 10px rgba(0,0,0,.7); }
|
||
.display .big { font-family:"Courier New",monospace; font-weight:700; font-size:80px; color:#ffd166; letter-spacing:2px; line-height:1.0; text-shadow:0 0 12px rgba(255,209,102,.5); }
|
||
.display .dtimers { font-family:"Courier New",monospace; font-size:26px; color:#4dd0e1; margin:6px 0; display:flex; gap:18px; justify-content:center; flex-wrap:wrap; }
|
||
.display .dtimers[hidden] { display:none; }
|
||
.display .ctx { font-family:"Courier New",monospace; font-size:19px; color:#4dd0e1; min-height:22px; line-height:1.25; }
|
||
.display .ctx.muted-cue { color:#ffb454; }
|
||
.knob { margin-bottom:10px; }
|
||
.knob label { display:flex; justify-content:space-between; font-size:12px; margin-bottom:5px; }
|
||
.knob label b { color:#fff; font-variant-numeric:tabular-nums; }
|
||
input[type=range] { width:100%; accent-color:var(--hot); }
|
||
.btnrow { display:flex; gap:10px; flex-wrap:wrap; }
|
||
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.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; }
|
||
.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; }
|
||
.ex-item { display:flex; gap:8px; align-items:center; padding:7px 9px; border:1px solid var(--edge); border-radius:8px; margin-bottom:6px; font-size:13px; background:var(--panel); cursor:pointer; }
|
||
.ex-item:hover { border-color:var(--muted); }
|
||
.ex-item.active { border-color:#2e7d32; box-shadow:inset 3px 0 0 #2e7d32; }
|
||
.ex-item .nm { flex:1; }
|
||
.ex-item .meta { color:var(--muted); font-family:"Courier New",monospace; font-size:11px; }
|
||
.ex-item .row-actions { display:none; gap:4px; }
|
||
.ex-item.active .row-actions, .ex-item:hover .row-actions { display:inline-flex; }
|
||
.nowplaying { background:var(--panel); border:1px solid var(--edge); border-radius:10px; padding:10px 12px; margin-bottom:12px; }
|
||
.np-label { font-size:10px; letter-spacing:1.4px; color:var(--muted); text-transform:uppercase; }
|
||
.np-name { font-size:16px; font-weight:600; margin:2px 0; }
|
||
.np-sub { font-size:12px; color:var(--muted); font-family:"Courier New",monospace; word-break:break-word; }
|
||
.np-desc { font-size:12px; color:var(--muted); margin-top:4px; }
|
||
.iconbtn { padding:3px 8px; font-size:12px; }
|
||
.log-item { padding:8px 10px; border:1px solid var(--edge); border-radius:8px; margin-bottom:6px; background:var(--panel); }
|
||
.log-head { font-weight:600; font-size:13px; margin-bottom: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: 620px) {
|
||
.kbd-legend { display:none; } /* the ? overlay covers discovery on small screens */
|
||
#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); }
|
||
.ext-banner { font-size:11px; color:#3a2f10; background:#ffe2a8; border:1px solid #d9a441; border-radius:8px; padding:8px 10px; margin-top:10px; line-height:1.35; }
|
||
.ext-banner[hidden] { display:none; }
|
||
#shareUrl { width:100%; resize:vertical; background:var(--panel); color:var(--txt); border:1px solid var(--edge); border-radius:8px; padding:8px; font-family:"Courier New",monospace; font-size:12px; }
|
||
.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:vertical; min-height:40px; }
|
||
.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; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="app">
|
||
<div class="device">
|
||
<div class="row" style="align-items:baseline; justify-content:space-between; gap:14px; margin-bottom:12px">
|
||
<h1 style="margin:0">Stackable Metronome <span class="lane-meta" id="appVersion" title="build version">v0.0.1-dev</span></h1>
|
||
<div style="display:flex; align-items:center; gap:10px">
|
||
<span class="kbd-legend">Space play · T tap · ↑↓ tempo (⇧×10) · A add · N next · ? help</span>
|
||
<a class="brand" href="https://varasys.io" target="_blank" rel="noopener"
|
||
title="VARASYS — varasys.io" style="display:inline-flex; align-items:center">
|
||
<img src="data:image/png;base64,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"
|
||
alt="VARASYS" style="height:22px; width:auto; display:block" />
|
||
</a>
|
||
<button id="themeBtn" title="toggle light / dark theme">☀</button>
|
||
<button id="helpBtn" title="keyboard shortcuts (?)">?</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Transport: display + preset/tempo/volume + practice, in three columns -->
|
||
<div class="row">
|
||
<div class="card" style="flex:1">
|
||
<div class="row" style="gap:22px; align-items:flex-start">
|
||
<div style="flex:0 0 260px; min-width:230px">
|
||
<div class="display">
|
||
<div class="big" id="bpmDisplay">120</div>
|
||
<div class="dtimers" id="dtimers">
|
||
<span title="elapsed (stopwatch)">⏱ <span id="elapsedVal">0:00</span></span>
|
||
<span id="countWrap" title="countdown" hidden>⏳ <span id="countVal" class="tval">0:00</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><button id="saveItemBtn" disabled title="overwrite the loaded set-list item with the current settings">💾 Save</button></div>
|
||
</div>
|
||
|
||
<div style="flex:1; min-width:200px">
|
||
<div class="nowplaying">
|
||
<div class="np-label">Now loaded</div>
|
||
<div class="np-name" id="npName">Free play</div>
|
||
<div class="np-sub" id="npSub"></div>
|
||
<div class="np-desc" id="npDesc"></div>
|
||
</div>
|
||
<div class="knob"><label>Tempo (BPM) <b id="bpmVal">120</b></label><input type="range" id="bpm" min="30" max="300" value="120"></div>
|
||
<div class="knob" style="margin-bottom:0"><label>Master Volume <b id="volVal">70%</b></label><input type="range" id="vol" min="0" max="100" value="70"></div>
|
||
</div>
|
||
|
||
<div class="practice-col" style="flex:1; min-width:215px">
|
||
<div class="fbox toggleable" id="trainerBox">
|
||
<label class="fhead"><input type="checkbox" id="trainerOn"><span class="ftitle">Gap / mute trainer</span></label>
|
||
<div class="fbody row" style="gap:14px; align-items:center">
|
||
<label style="font-size:12px">Play <input type="number" class="num" id="playBars" min="1" max="16" value="2"></label>
|
||
<label style="font-size:12px">Mute <input type="number" class="num" id="muteBars" min="0" max="16" value="2"> bars</label>
|
||
</div>
|
||
</div>
|
||
<div class="fbox toggleable" id="rampBox">
|
||
<label class="fhead"><input type="checkbox" id="rampOn"><span class="ftitle">Tempo ramp</span></label>
|
||
<div class="fbody row" style="gap:12px 14px; align-items:center; flex-wrap:wrap">
|
||
<label style="font-size:12px">from <input type="number" class="num" id="rampStart" min="30" max="300" value="80"> BPM</label>
|
||
<label style="font-size:12px" title="negative ramps down, positive ramps up"><input type="number" class="num" id="rampAmt" min="-30" max="30" value="5"> BPM</label>
|
||
<label style="font-size:12px">every <input type="number" class="num" id="rampEvery" min="1" max="16" value="4"> bars</label>
|
||
</div>
|
||
</div>
|
||
<div class="fbox toggleable" id="timerBox">
|
||
<label class="fhead"><input type="checkbox" id="timersOn"><span class="ftitle">Timers</span><span class="hint" style="margin:0">run while playing</span></label>
|
||
<div class="fbody">
|
||
<div class="row" style="gap:10px; align-items:center">
|
||
<label style="font-size:12px">Elapsed (stopwatch)</label>
|
||
<button class="iconbtn" id="elapsedReset" title="reset elapsed">⟲</button>
|
||
</div>
|
||
<div class="row" style="gap:10px; align-items:center; margin-top:6px">
|
||
<label style="font-size:12px">Countdown <input type="text" class="txt" id="countTime" placeholder="m:ss" title="blank = off · h:mm:ss, m:ss, or plain minutes" style="width:80px; text-align:center"></label>
|
||
<button class="iconbtn" id="countReset" title="reset countdown">⟲</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="row" style="align-items:center; gap:12px; margin:14px 0 8px">
|
||
<h2 style="font-size:11px; text-transform:uppercase; letter-spacing:1.4px; color:var(--muted); margin:0">Meter lanes</h2>
|
||
<span class="hint" style="margin:0; flex:1">Each beat splits into <i>subdivision</i> pads — click any pad to toggle it (rest). e.g. snare on 2 & 4</span>
|
||
</div>
|
||
<div id="meters"></div>
|
||
<div style="margin-top:10px"><button class="add" id="addMeterBtn" title="add meter lane (A)">+</button></div>
|
||
|
||
<!-- (presets moved into the Transport card; set lists live in the slide-out tray;
|
||
status now shows under the BPM in the display) -->
|
||
</div>
|
||
|
||
<!-- Set-list panel: docked beside the metronome; drawer on narrow screens -->
|
||
<aside id="routineTray">
|
||
<div class="tray-head">
|
||
<h2 style="margin:0">Set Lists</h2>
|
||
<div style="display:flex; gap:6px; position:relative">
|
||
<button class="x" id="trayMenuBtn" title="log & backup" style="margin-left:0">⋯</button>
|
||
<div id="trayMenu" class="menu" hidden>
|
||
<button id="shareSettingsBtn">🔗 Share settings link</button>
|
||
<button id="shareSetlistBtn">🔗 Share set-list link</button>
|
||
<button id="exportBtn">⭳ Export all (file)</button>
|
||
<button id="importBtn">⭱ Import file…</button>
|
||
<input type="file" id="importFile" accept="application/json" style="display:none">
|
||
<button id="clearLogBtn">🗑 Clear log</button>
|
||
<button id="resetAllBtn" style="color:#ff7b6b">♻ Reset everything…</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="lane-row" style="margin-bottom:8px">
|
||
<select class="cmp" id="setlistSelect" style="flex:1"></select>
|
||
<button id="newSetlistBtn">+ New</button>
|
||
<button class="x" id="delSetlistBtn" title="delete set list" style="margin-left:0">✕</button>
|
||
</div>
|
||
<div class="setlist-fields">
|
||
<input type="text" class="txt" id="slTitle" placeholder="set list title" style="width:100%; text-align:left; margin-bottom:6px">
|
||
<textarea id="slDesc" placeholder="description / notes"></textarea>
|
||
</div>
|
||
|
||
<div class="lane-row" style="margin:12px 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>
|
||
<label class="mini-check" title="when a playing item's countdown reaches 0, auto-load the next item — give every item a countdown to auto-play the whole list" style="margin-bottom:6px"><input type="checkbox" id="continueMode"> Continue — auto-advance on countdown</label>
|
||
<div id="itemList"></div>
|
||
<div class="hint" style="margin-top:6px">Click to load · <kbd>N</kbd> next · <kbd>Alt+↑/↓</kbd> reorder · 💾 save settings to an item.</div>
|
||
|
||
<div id="logView" style="margin-top:18px"></div>
|
||
</aside>
|
||
</div><!-- /#app -->
|
||
|
||
<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</td></tr>
|
||
<tr><td><kbd>⇧↑</kbd> <kbd>⇧↓</kbd></td><td>Tempo ±10 BPM</td></tr>
|
||
<tr><td><kbd>A</kbd></td><td>Add meter lane</td></tr>
|
||
<tr><td><kbd>N</kbd></td><td>Load next set-list item</td></tr>
|
||
<tr><td><kbd>⌥↑</kbd> <kbd>⌥↓</kbd></td><td>Reorder selected item</td></tr>
|
||
<tr><td><kbd>1</kbd>–<kbd>9</kbd></td><td>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</td></tr>
|
||
</table>
|
||
<div class="help-about">
|
||
<p>Source: <a href="https://git.varasys.io/VARASYS/metronome" target="_blank" rel="noopener">git.varasys.io/VARASYS/metronome</a></p>
|
||
<p>This is a single-page app — save this page (<kbd>Ctrl/⌘+S</kbd>) and open the file to run it fully offline, no server needed. One catch when running from a local <code>file://</code>: it <b>won't auto-save your set list</b> between sessions, so export a backup (set-list <b>⋯</b> menu → <b>Export all</b>) to keep your work.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Share dialog: copyable link + QR code -->
|
||
<div id="shareOverlay" class="overlay" hidden>
|
||
<div class="overlay-box">
|
||
<div class="tray-head"><h2 id="shareTitle" style="margin:0">Share</h2><button class="x" id="shareClose" title="close" style="margin-left:0">✕</button></div>
|
||
<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><button id="shareQrExt">QR ↗</button></div>
|
||
<div class="hint" id="shareNote"></div>
|
||
<div class="ext-banner" id="shareExtBanner" hidden>⚠ “QR ↗” opens an external site (api.qrserver.com) with this link embedded in its URL. It is a third party — after scanning, confirm the QR decodes to the link above before trusting it.</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
"use strict";
|
||
|
||
// Build version. deploy.sh rewrites this line: a clean commit tagged v<VERSION>
|
||
// stamps the formal "X.Y.Z"; any other build stamps "X.Y.Z-dev.<ts>.<sha>[.dirty]".
|
||
// The literal below is the fallback shown when viewing the un-deployed source.
|
||
const APP_VERSION = "0.0.1-dev";
|
||
|
||
/* =========================================================================
|
||
STATE
|
||
========================================================================= */
|
||
const state = { bpm: 120, volume: 0.7, running: false };
|
||
const trainer = { on: false, playBars: 2, muteBars: 2 };
|
||
const ramp = { on: false, startBpm: 80, amount: 5, everyBars: 4 };
|
||
|
||
let meters = []; // array of meter-lane objects
|
||
let meterSeq = 0; // id counter
|
||
|
||
/* =========================================================================
|
||
GROUPING (PORTS TO FIRMWARE)
|
||
"2+2+3" -> groups, beatsPerBar, and the beat indices that start a group.
|
||
========================================================================= */
|
||
function parseGroups(str) {
|
||
const parts = String(str).split(/[^0-9]+/).map((s) => parseInt(s, 10)).filter((n) => n >= 1 && n <= 12);
|
||
let total = 0; const groups = [];
|
||
for (const p of parts) { if (total + p > 12) break; groups.push(p); total += p; }
|
||
if (!groups.length) groups.push(4);
|
||
const beatsPerBar = groups.reduce((a, b) => a + b, 0);
|
||
const groupStarts = new Set(); let acc = 0;
|
||
for (const g of groups) { groupStarts.add(acc); acc += g; }
|
||
return { groups, beatsPerBar, groupStarts };
|
||
}
|
||
|
||
/* =========================================================================
|
||
AUDIO (Web Audio look-ahead scheduler = stand-in for the RP2040 timer)
|
||
========================================================================= */
|
||
let audioCtx = null, masterGain = null, noiseBuf = null;
|
||
let schedulerTimer = null;
|
||
const LOOKAHEAD_MS = 25, SCHEDULE_AHEAD = 0.12;
|
||
|
||
function ensureAudio() {
|
||
if (audioCtx) return;
|
||
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||
masterGain = audioCtx.createGain();
|
||
masterGain.gain.value = state.volume;
|
||
masterGain.connect(audioCtx.destination);
|
||
}
|
||
function getNoise() {
|
||
if (!noiseBuf) {
|
||
const n = Math.floor(audioCtx.sampleRate * 1.0);
|
||
noiseBuf = audioCtx.createBuffer(1, n, audioCtx.sampleRate);
|
||
const d = noiseBuf.getChannelData(0);
|
||
for (let i = 0; i < n; i++) d[i] = Math.random() * 2 - 1;
|
||
}
|
||
return noiseBuf;
|
||
}
|
||
|
||
// --- instrument voices (synthesized GM-style kit). On hardware these map 1:1 to
|
||
// real CC0/GPL General-MIDI percussion samples played via the I2S DAC;
|
||
// playInstrument() is the single swap point. level = velocity (accent/ghost). ---
|
||
function ampEnv(time, peak, dur, attack) {
|
||
const g = audioCtx.createGain();
|
||
peak = Math.max(0.0003, peak);
|
||
g.gain.setValueAtTime(0.0001, time);
|
||
g.gain.exponentialRampToValueAtTime(peak, time + (attack || 0.001));
|
||
g.gain.exponentialRampToValueAtTime(0.0001, time + dur);
|
||
return g;
|
||
}
|
||
function tone(time, type, f0, f1, dur) {
|
||
const o = audioCtx.createOscillator(); o.type = type;
|
||
o.frequency.setValueAtTime(f0, time);
|
||
if (f1 && f1 !== f0) o.frequency.exponentialRampToValueAtTime(Math.max(1, f1), time + Math.min(dur, 0.09));
|
||
o.start(time); o.stop(time + dur + 0.02); return o;
|
||
}
|
||
function noiseSrc(time, dur) { const s = audioCtx.createBufferSource(); s.buffer = getNoise(); s.start(time); s.stop(time + dur + 0.02); return s; }
|
||
function filt(type, freq, q) { const f = audioCtx.createBiquadFilter(); f.type = type; f.frequency.value = freq; if (q) f.Q.value = q; return f; }
|
||
function v_tone(time, level, type, f0, f1, dur, peak) { const o = tone(time, type, f0, f1, dur), g = ampEnv(time, peak * level, dur, 0.002); o.connect(g); g.connect(masterGain); }
|
||
function v_noise(time, level, fType, freq, q, dur, peak, attack) { const n = noiseSrc(time, dur), f = filt(fType, freq, q), g = ampEnv(time, peak * level, dur, attack); n.connect(f); f.connect(g); g.connect(masterGain); }
|
||
|
||
const DRUMS = {
|
||
beep: (t, l) => v_tone(t, l, "square", l >= 1 ? 1600 : 1100, 0, 0.04, 0.5),
|
||
kick: (t, l) => v_tone(t, l, "sine", 150, 50, 0.18, 1.0),
|
||
snare: (t, l) => { v_tone(t, l, "triangle", 190, 140, 0.12, 0.45); v_noise(t, l, "highpass", 1500, 0, 0.2, 0.8); },
|
||
rim: (t, l) => { const o = tone(t, "square", 1700, 0, 0.04), bp = filt("bandpass", 1700, 4), g = ampEnv(t, 0.6 * l, 0.04); o.connect(bp); bp.connect(g); g.connect(masterGain); },
|
||
clap: (t, l) => { const bp = filt("bandpass", 1200, 1.4); bp.connect(masterGain); [0, 0.012, 0.024].forEach((d, i) => { const n = noiseSrc(t + d, 0.05), e = ampEnv(t + d, (i < 2 ? 0.5 : 0.85) * l, 0.06); n.connect(e); e.connect(bp); }); },
|
||
hatClosed: (t, l) => v_noise(t, l, "highpass", 7000, 0, 0.045, 0.5),
|
||
hatOpen: (t, l) => v_noise(t, l, "highpass", 7000, 0, 0.32, 0.45, 0.002),
|
||
ride: (t, l) => { v_noise(t, l, "bandpass", 6000, 0.8, 0.4, 0.32, 0.002); v_tone(t, l, "square", 5200, 0, 0.1, 0.13); },
|
||
crash: (t, l) => v_noise(t, l, "highpass", 4000, 0, 0.8, 0.5, 0.002),
|
||
tomLow: (t, l) => v_tone(t, l, "sine", 150, 100, 0.25, 0.9),
|
||
tomMid: (t, l) => v_tone(t, l, "sine", 220, 150, 0.23, 0.9),
|
||
tomHigh: (t, l) => v_tone(t, l, "sine", 300, 210, 0.20, 0.9),
|
||
tambourine:(t, l) => v_noise(t, l, "highpass", 8000, 0, 0.12, 0.5),
|
||
cowbell: (t, l) => { const sum = audioCtx.createGain(), bp = filt("bandpass", 2640, 1.2), g = ampEnv(t, 0.8 * l, 0.3); [540, 800].forEach((f) => tone(t, "square", f, 0, 0.3).connect(sum)); sum.connect(bp); bp.connect(g); g.connect(masterGain); },
|
||
woodblock: (t, l) => v_tone(t, l, "triangle", 1800, 1500, 0.06, 0.8),
|
||
claves: (t, l) => v_tone(t, l, "sine", 2500, 0, 0.045, 0.85),
|
||
jamblock: (t, l) => { const o = tone(t, "square", 2600, 2000, 0.045), bp = filt("bandpass", 2000, 6), g = ampEnv(t, 0.8 * l, 0.045); o.connect(bp); bp.connect(g); g.connect(masterGain); },
|
||
};
|
||
const VOICES = [
|
||
["beep", "beep"], ["kick", "kick"], ["snare", "snare"], ["rim", "rim/stick"], ["clap", "clap"],
|
||
["hatClosed", "hat closed"], ["hatOpen", "hat open"], ["ride", "ride"], ["crash", "crash"],
|
||
["tomLow", "tom low"], ["tomMid", "tom mid"], ["tomHigh", "tom high"], ["tambourine", "tambourine"],
|
||
["cowbell", "cowbell"], ["woodblock", "wood block"], ["claves", "claves"], ["jamblock", "jam block"],
|
||
];
|
||
function playInstrument(type, time, level) { (DRUMS[type] || DRUMS.beep)(time, level); }
|
||
|
||
/* =========================================================================
|
||
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 masterBeatsPerBar() { return meters.length ? meters[0].beatsPerBar : 4; }
|
||
|
||
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) });
|
||
}
|
||
}
|
||
}
|
||
masterBeat++;
|
||
masterBeatTime += 60 / state.bpm;
|
||
}
|
||
if (audioCtx) muteWindows = muteWindows.filter((w) => w.end > audioCtx.currentTime - 1);
|
||
}
|
||
function isMutedAt(t) { return muteWindows.some((w) => t >= w.start && t < w.end); }
|
||
|
||
function scheduleMeterTick(m, time) {
|
||
const spb = m.stepsPerBeat;
|
||
const barLen = m.beatsPerBar * spb;
|
||
const tickInBar = ((m.tick % barLen) + barLen) % barLen;
|
||
const onBeat = (tickInBar % spb) === 0;
|
||
const beatIndex = Math.floor(tickInBar / spb);
|
||
m.vq.push({ time, step: tickInBar, bar: Math.floor(m.tick / barLen) }); // playhead (per step) + measure (advance even when muted)
|
||
if (!m.enabled || isMutedAt(time)) return;
|
||
if (!m.beatsOn[tickInBar]) return; // this step toggled off → rest
|
||
if (onBeat) {
|
||
const groupStart = m.groupStarts.has(beatIndex);
|
||
playInstrument(m.sound, time, groupStart ? 1.0 : 0.7); // beat — louder on group start
|
||
} else {
|
||
playInstrument(m.sound, time, 0.4); // subdivision — same voice, softer
|
||
}
|
||
}
|
||
|
||
// Reference bar = lane 1's bar; poly lanes fit their beats evenly into it.
|
||
function refBarDur() { return (meters.length ? meters[0].beatsPerBar : 4) * (60 / state.bpm); }
|
||
function laneStepDur(m) {
|
||
if (m.poly) return refBarDur() / (m.beatsPerBar * m.stepsPerBeat); // true ratio polyrhythm
|
||
return (60 / state.bpm) / m.stepsPerBeat; // normal: shared quarter-note grid
|
||
}
|
||
|
||
function scheduler() {
|
||
const ahead = audioCtx.currentTime + SCHEDULE_AHEAD;
|
||
advanceMaster(ahead);
|
||
for (const m of meters) {
|
||
while (m.nextTime < ahead) {
|
||
scheduleMeterTick(m, m.nextTime);
|
||
m.tick++;
|
||
m.nextTime += laneStepDur(m);
|
||
}
|
||
}
|
||
}
|
||
|
||
/* =========================================================================
|
||
TRANSPORT
|
||
========================================================================= */
|
||
function start() {
|
||
ensureAudio(); audioCtx.resume();
|
||
state.running = true;
|
||
if (ramp.on) setBpm(ramp.startBpm); // ramp begins from its start BPM
|
||
const t0 = audioCtx.currentTime + 0.08;
|
||
for (const m of meters) { m.tick = 0; m.nextTime = t0; m.vq = []; m.vqPtr = 0; m.currentStep = -1; m.currentBar = 0; }
|
||
masterBeat = 0; masterBeatTime = t0; muteWindows = [];
|
||
schedulerTimer = setInterval(scheduler, LOOKAHEAD_MS);
|
||
scheduler(); syncStartBtn();
|
||
}
|
||
function stop() {
|
||
state.running = false;
|
||
clearInterval(schedulerTimer); schedulerTimer = null;
|
||
for (const m of meters) m.currentStep = -1;
|
||
syncStartBtn();
|
||
}
|
||
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) {
|
||
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, color: laneColor(id),
|
||
beatsOn: beatsOn ? beatsOn.slice() : [], // per-STEP on/off mask (one entry per pad = beats × subdivision)
|
||
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 — also sets how many pads each beat splits into">
|
||
<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>
|
||
</select>
|
||
<select class="cmp" id="m${m.id}_sound" title="sound">${VOICES.map(([v, n]) => `<option value="${v}">${n}</option>`).join("")}</select>
|
||
<div class="strip" id="m${m.id}_strip"></div>
|
||
<span class="bar" id="m${m.id}_bar">—</span>
|
||
<label class="mini-check" title="polyrhythm: fit these beats evenly into lane 1's bar"><input type="checkbox" id="m${m.id}_poly"> poly</label>
|
||
<button class="x" id="m${m.id}_remove" title="remove lane">✕</button>
|
||
</div>`;
|
||
document.getElementById("meters").appendChild(card);
|
||
m.el = card;
|
||
m.stripEl = card.querySelector(`#m${m.id}_strip`);
|
||
m.barEl = card.querySelector(`#m${m.id}_bar`);
|
||
m.titleEl = card.querySelector(`#m${m.id}_title`);
|
||
|
||
// wire controls
|
||
const $c = (sel) => card.querySelector(sel);
|
||
$c(`#m${m.id}_group`).addEventListener("input", (e) => { m.groupsStr = e.target.value; recomputeLane(m); });
|
||
const sub = $c(`#m${m.id}_sub`); sub.value = String(m.stepsPerBeat);
|
||
sub.addEventListener("change", (e) => { m.stepsPerBeat = +e.target.value; recomputeLane(m); });
|
||
const sel = $c(`#m${m.id}_sound`); sel.value = m.sound;
|
||
sel.addEventListener("change", (e) => m.sound = e.target.value);
|
||
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);
|
||
}
|
||
|
||
function recomputeLane(m) {
|
||
const p = parseGroups(m.groupsStr);
|
||
m.groups = p.groups; m.beatsPerBar = p.beatsPerBar; m.groupStarts = p.groupStarts;
|
||
// Remap the on/off mask to step resolution (beats × subdivision = one entry per pad),
|
||
// preserving the old pattern where it lines up and defaulting new pads to ON.
|
||
const spb = m.stepsPerBeat;
|
||
const prev = m.beatsOn || [], oldBpb = m._maskBpb || 0, oldSpb = m._maskSpb || 1;
|
||
const next = [];
|
||
for (let b = 0; b < m.beatsPerBar; b++) {
|
||
for (let s = 0; s < spb; s++) {
|
||
let val = true;
|
||
if (b < oldBpb) { // this beat existed before
|
||
const oi = (oldSpb === spb) ? b * oldSpb + s // same resolution → step-for-step
|
||
: b * oldSpb; // resolution changed → use the beat's downbeat
|
||
if (oi < prev.length) val = !!prev[oi];
|
||
}
|
||
next.push(val);
|
||
}
|
||
}
|
||
m.beatsOn = next; m._maskBpb = m.beatsPerBar; m._maskSpb = spb;
|
||
m.el.querySelector(`#m${m.id}_sum`).textContent = "=" + m.beatsPerBar;
|
||
buildLaneStrip(m);
|
||
}
|
||
|
||
function buildLaneStrip(m) { // one pad per STEP (beats × subdivision)
|
||
m.stripEl.innerHTML = "";
|
||
const spb = m.stepsPerBeat, total = m.beatsPerBar * spb;
|
||
for (let i = 0; i < total; i++) {
|
||
const b = Math.floor(i / spb), s = i % spb;
|
||
const cell = document.createElement("div");
|
||
cell.className = "led";
|
||
cell.textContent = (s === 0) ? (b + 1) : ""; // label downbeats; subdivisions blank
|
||
cell.style.cursor = "pointer";
|
||
cell.title = (s === 0) ? ("toggle beat " + (b + 1)) : ("toggle beat " + (b + 1) + " · sub " + (s + 1));
|
||
cell.addEventListener("click", () => { m.beatsOn[i] = !m.beatsOn[i]; renderLaneStrip(m); });
|
||
m.stripEl.appendChild(cell);
|
||
}
|
||
}
|
||
|
||
function renderLaneStrip(m) {
|
||
const cells = m.stripEl.children, spb = m.stepsPerBeat;
|
||
for (let i = 0; i < cells.length; i++) {
|
||
const cell = cells[i];
|
||
const b = Math.floor(i / spb), s = i % spb, onBeat = (s === 0);
|
||
const on = m.beatsOn[i], gs = onBeat && m.groupStarts.has(b);
|
||
let cls = "led";
|
||
if (!onBeat) cls += " sub"; // subdivision pad (smaller/dimmer)
|
||
else if (i > 0 && !gs) cls += " beatstart"; // gap between beats within a group
|
||
if (on) cls += " on";
|
||
if (gs) cls += " groupstart";
|
||
if (on && gs) cls += " accent";
|
||
cell.className = cls;
|
||
cell.style.setProperty("--lc", m.color);
|
||
if (state.running && i === m.currentStep) cell.classList.add("playhead");
|
||
}
|
||
if (m.barEl) m.barEl.textContent = state.running ? "bar " + (m.currentBar + 1) : "—";
|
||
}
|
||
|
||
/* =========================================================================
|
||
PRESETS (localStorage)
|
||
========================================================================= */
|
||
const LS = { presets: "metronome.presets", setlists: "metronome.setlists", logs: "metronome.logs", seeded: "metronome.seeded", continue: "metronome.continue", timers: "metronome.timers" };
|
||
function lsGet(k, fb) { try { const v = localStorage.getItem(k); return v ? JSON.parse(v) : fb; } catch (e) { return fb; } }
|
||
function lsSet(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); return true; } catch (e) { console.warn("localStorage unavailable", e); return false; } }
|
||
|
||
function snapshotLanes() { return meters.map((m) => ({ groupsStr: m.groupsStr, stepsPerBeat: m.stepsPerBeat, sound: m.sound, enabled: m.enabled, poly: m.poly, beatsOn: m.beatsOn.slice() })); }
|
||
function applyLanes(lanes) {
|
||
while (meters.length) removeMeter(meters[0].id);
|
||
for (const c of lanes) {
|
||
addMeter(c.groupsStr, c.stepsPerBeat, c.sound, c.beatsOn, c.poly);
|
||
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; // selected set list
|
||
let activeItem = -1; // selected / loaded item in the active set list
|
||
let nowPlaying = null; // { at, name } for duration logging
|
||
let historyName = null; // item whose past-session history is shown
|
||
let continueMode = lsGet(LS.continue, false); // auto-advance to next item when countdown ends
|
||
let timersOn = lsGet(LS.timers, true); // master switch for the elapsed/countdown timers
|
||
|
||
function currentSetup() { return { bpm: state.bpm, lanes: snapshotLanes(), trainer: { ...trainer }, ramp: { ...ramp }, countMs: timers.totalMs }; }
|
||
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 countdown
|
||
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) : "";
|
||
refreshFeatureBoxes(); renderTimers();
|
||
}
|
||
function refreshFeatureBoxes() {
|
||
$("trainerBox").classList.toggle("on", trainer.on);
|
||
$("rampBox").classList.toggle("on", ramp.on);
|
||
$("timerBox").classList.toggle("on", timersOn);
|
||
}
|
||
function fmtDur(sec) { sec = Math.round(sec); const m = Math.floor(sec / 60); return m + ":" + String(sec % 60).padStart(2, "0"); }
|
||
function getSL() { return setlists[activeSL]; }
|
||
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; activeItem = -1; saveSetlists(); renderSetlists();
|
||
}
|
||
function deleteSetlist() {
|
||
if (!setlists.length || !confirm("Delete this set list?")) return;
|
||
setlists.splice(activeSL, 1); activeSL = Math.max(0, activeSL - 1); activeItem = -1; saveSetlists(); renderSetlists();
|
||
}
|
||
function addItem(name) {
|
||
const sl = getSL(); if (!sl) return;
|
||
sl.items.push({ name: name || ("Item " + (sl.items.length + 1)), ...currentSetup() });
|
||
activeItem = sl.items.length - 1; saveSetlists(); renderItems();
|
||
}
|
||
function removeItem(i) {
|
||
const sl = getSL(); if (!sl) return;
|
||
sl.items.splice(i, 1);
|
||
if (activeItem === i) activeItem = -1; else if (activeItem > i) activeItem--;
|
||
saveSetlists(); renderItems();
|
||
}
|
||
function moveItem(i, d) { const sl = getSL(); const j = i + d; if (j < 0 || j >= sl.items.length) return; [sl.items[i], sl.items[j]] = [sl.items[j], sl.items[i]]; saveSetlists(); }
|
||
function moveActiveItem(d) { // keyboard reorder of the selected item (Alt+↑/↓)
|
||
const sl = getSL(); if (!sl || activeItem < 0) return;
|
||
const j = activeItem + d; if (j < 0 || j >= sl.items.length) return;
|
||
moveItem(activeItem, d); activeItem = j; renderItems();
|
||
}
|
||
|
||
// --- select / advance: clicking an item LOADS it; the transport is the only play/stop ---
|
||
function loadItem(i) {
|
||
const sl = getSL(); if (!sl || !sl.items[i]) return;
|
||
const wasRunning = state.running;
|
||
if (wasRunning) logFinalize(); // close out the previous segment
|
||
applySetup(sl.items[i]);
|
||
activeItem = i; historyName = sl.items[i].name;
|
||
if (wasRunning) { stop(); start(); nowPlaying = { at: Date.now(), name: sl.items[i].name }; } // keep playing the new item
|
||
renderItems(); renderLog();
|
||
}
|
||
function nextItem() { const sl = getSL(); if (sl && activeItem + 1 < sl.items.length) loadItem(activeItem + 1); }
|
||
function updateItem(i) { // overwrite item with current settings (keeps its name)
|
||
const sl = getSL(); if (!sl || !sl.items[i]) return;
|
||
sl.items[i] = { name: sl.items[i].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 sl = getSL(); if (activeItem >= 0 && sl && sl.items[activeItem]) nowPlaying = { at: Date.now(), name: sl.items[activeItem].name }; }
|
||
renderItems();
|
||
}
|
||
|
||
// --- now-playing info on the main screen (replaces the old preset dropdown) ---
|
||
function renderNowPlaying() {
|
||
const sl = getSL(); const it = (sl && activeItem >= 0) ? sl.items[activeItem] : null;
|
||
$("saveItemBtn").disabled = !it; // single save button targets the loaded item
|
||
if (!it) {
|
||
$("npName").textContent = "Free play";
|
||
$("npSub").textContent = "No set-list item loaded — edit the lanes freely.";
|
||
$("npDesc").textContent = (sl && sl.description) ? "“" + sl.title + "” — " + sl.description : "";
|
||
return;
|
||
}
|
||
$("npName").textContent = (activeItem + 1) + ". " + it.name;
|
||
$("npSub").textContent = it.bpm + " BPM · " + it.lanes.map((l) => l.sound + " " + l.groupsStr + (l.poly ? "~" : "") + (l.enabled === false ? " (off)" : "")).join(" · ");
|
||
$("npDesc").textContent = (sl.title || "") + (sl.description ? " — " + sl.description : "");
|
||
}
|
||
|
||
// --- render ---
|
||
function renderSetlists() {
|
||
const sel = $("setlistSelect"); sel.innerHTML = "";
|
||
const has = setlists.length > 0;
|
||
$("slTitle").disabled = $("slDesc").disabled = $("addItemBtn").disabled = $("delSetlistBtn").disabled = !has;
|
||
if (!has) { sel.innerHTML = '<option>— no set lists —</option>'; $("slTitle").value = ""; $("slDesc").value = ""; renderItems(); return; }
|
||
if (activeSL >= setlists.length) activeSL = setlists.length - 1;
|
||
setlists.forEach((sl, i) => { const o = document.createElement("option"); o.value = i; o.textContent = sl.title || ("Set list " + (i + 1)); sel.appendChild(o); });
|
||
sel.value = activeSL;
|
||
const sl = getSL(); $("slTitle").value = sl.title || ""; $("slDesc").value = sl.description || "";
|
||
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" + (i === activeItem ? " active" : "");
|
||
row.title = "Click to load into the player · Alt+↑/↓ to reorder";
|
||
row.innerHTML = `<span class="nm">${i + 1}. ${it.name}</span>
|
||
<span class="meta">${it.bpm} · ${it.lanes.map((l) => l.groupsStr).join("/")}</span>
|
||
<span class="row-actions">
|
||
<button class="x iconbtn" data-act="del" title="remove this item">✕</button>
|
||
</span>`;
|
||
row.onclick = () => loadItem(i);
|
||
row.querySelector('[data-act=del]').onclick = (e) => { e.stopPropagation(); removeItem(i); };
|
||
box.appendChild(row);
|
||
});
|
||
renderNowPlaying();
|
||
}
|
||
|
||
// --- practice log (flat entries, one per played item) ---
|
||
function logFinalize() {
|
||
if (!nowPlaying) return;
|
||
const logs = lsGet(LS.logs, []);
|
||
logs.unshift({ at: nowPlaying.at, name: nowPlaying.name, durationSec: (Date.now() - nowPlaying.at) / 1000, bpm: state.bpm, lanes: snapshotLanes() });
|
||
lsSet(LS.logs, logs); nowPlaying = null; renderLog();
|
||
}
|
||
// Show history for the item being (or last) played, so the user can compare
|
||
// today's BPM/duration against previous days for that specific task.
|
||
function renderLog() {
|
||
const box = $("logView"); box.innerHTML = "";
|
||
if (!historyName) { box.innerHTML = '<div class="hint">Play a set-list item to see its history — compare BPM & duration across days.</div>'; return; }
|
||
const entries = lsGet(LS.logs, []).filter((e) => e.name === historyName);
|
||
|
||
const head = document.createElement("div"); head.className = "log-head";
|
||
head.innerHTML = `<span class="log-head-nm">History — ${historyName}</span>`;
|
||
if (entries.length) {
|
||
const clr = document.createElement("button"); clr.className = "iconbtn"; clr.textContent = "Clear all";
|
||
clr.title = "delete all history for this item";
|
||
clr.onclick = () => clearItemHistory();
|
||
head.appendChild(clr);
|
||
}
|
||
box.appendChild(head);
|
||
|
||
if (!entries.length) {
|
||
const h = document.createElement("div"); h.className = "hint"; h.textContent = "No past sessions for this item yet.";
|
||
box.appendChild(h); return;
|
||
}
|
||
entries.forEach((e) => {
|
||
const row = document.createElement("div"); row.className = "hist-row";
|
||
const txt = document.createElement("span"); txt.className = "hist-txt";
|
||
txt.textContent = `${new Date(e.at).toLocaleString()} · ${fmtDur(e.durationSec)} @ ${e.bpm}bpm`;
|
||
const del = document.createElement("button"); del.className = "hist-del"; del.textContent = "✕";
|
||
del.title = "delete this entry";
|
||
del.onclick = () => deleteHistoryEntry(e.at);
|
||
row.appendChild(txt); row.appendChild(del); box.appendChild(row);
|
||
});
|
||
}
|
||
function deleteHistoryEntry(at) { // remove one session by its timestamp
|
||
const logs = lsGet(LS.logs, []).filter((e) => !(e.at === at && e.name === historyName));
|
||
lsSet(LS.logs, logs); renderLog();
|
||
}
|
||
function clearItemHistory() { // clear every session for the current item
|
||
if (!historyName) return;
|
||
if (!confirm("Clear all history for “" + historyName + "”? (other items, set lists & presets are kept)")) return;
|
||
const logs = lsGet(LS.logs, []).filter((e) => e.name !== historyName);
|
||
lsSet(LS.logs, logs); renderLog();
|
||
}
|
||
function clearLog() { if (confirm("Clear the practice log? (set lists & presets are kept)")) { lsSet(LS.logs, []); renderLog(); } }
|
||
function resetAll() {
|
||
if (!confirm("Reset EVERYTHING?\n\nThis permanently deletes all saved data on this device — presets, set lists, practice log and theme — and reloads the app to first-run state (demos restored). This cannot be undone.")) return;
|
||
try { localStorage.clear(); } catch (e) {}
|
||
location.replace(location.origin + location.pathname); // reload clean, no hash
|
||
}
|
||
|
||
// --- backup: export / import everything (presets + set lists + logs) ---
|
||
function exportAll() {
|
||
const data = { version: 2, exported: new Date().toISOString(), presets: lsGet(LS.presets, {}), setlists: lsGet(LS.setlists, []), logs: lsGet(LS.logs, []) };
|
||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||
const a = document.createElement("a");
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = "metronome-backup-" + new Date().toISOString().slice(0, 10) + ".json";
|
||
a.click(); URL.revokeObjectURL(a.href);
|
||
}
|
||
function importAll(file) {
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
try {
|
||
const d = JSON.parse(reader.result);
|
||
if (d.presets) lsSet(LS.presets, d.presets);
|
||
if (d.setlists) { lsSet(LS.setlists, d.setlists); setlists = d.setlists; activeSL = 0; activeItem = -1; }
|
||
if (d.logs) lsSet(LS.logs, d.logs);
|
||
renderSetlists(); renderLog();
|
||
alert("Imported " + Object.keys(d.presets || {}).length + " presets, " + (d.setlists || []).length + " set lists, " + (d.logs || []).length + " log entries.");
|
||
} catch (e) { alert("Import failed: " + e.message); }
|
||
};
|
||
reader.readAsText(file);
|
||
}
|
||
|
||
/* =========================================================================
|
||
SHARE LANGUAGE (compact, human-readable; encodes settings/set lists in URLs)
|
||
Patch: v1;t<bpm>;vol<pct>;<lane>;…[;tr<play>/<mute>][;rmp<start>/<step>/<every>]
|
||
Lane: <sound>:<grouping>[/<sub>][=<pattern x/.>][~ poly][! disabled]
|
||
========================================================================= */
|
||
function laneCfgToStr(c) {
|
||
let s = c.sound + ":" + c.groupsStr;
|
||
if ((c.stepsPerBeat || 1) !== 1) s += "/" + c.stepsPerBeat;
|
||
const on = c.beatsOn || []; // per-step mask; one char per pad
|
||
if (on.length && !on.every(Boolean)) s += "=" + on.map((v) => (v ? "x" : ".")).join("");
|
||
if (c.poly) s += "~";
|
||
if (c.enabled === false) s += "!"; // "!" = silenced / disabled
|
||
return s;
|
||
}
|
||
function laneStrToCfg(tok) {
|
||
let poly = false, disabled = false;
|
||
while (/[~!]$/.test(tok)) { if (tok.endsWith("!")) disabled = true; else poly = true; tok = tok.slice(0, -1); }
|
||
const ci = tok.indexOf(":"); if (ci < 0) return null;
|
||
let sound = tok.slice(0, ci), rest = tok.slice(ci + 1), pattern = null;
|
||
const eq = rest.indexOf("="); if (eq >= 0) { pattern = rest.slice(eq + 1); rest = rest.slice(0, eq); }
|
||
let groupsStr = rest, sub = 1; const sl = rest.indexOf("/");
|
||
if (sl >= 0) { groupsStr = rest.slice(0, sl); sub = parseInt(rest.slice(sl + 1), 10) || 1; }
|
||
const bpb = parseGroups(groupsStr).beatsPerBar;
|
||
const beatsOn = pattern ? pattern.split("").map((ch) => ch === "x" || ch === "X" || ch === "1")
|
||
: new Array(bpb).fill(true);
|
||
if (!DRUMS[sound]) sound = "beep";
|
||
return { groupsStr, stepsPerBeat: sub, sound, beatsOn, poly, enabled: !disabled };
|
||
}
|
||
function setupToPatch(s) {
|
||
const parts = ["v1", "t" + s.bpm];
|
||
if (s.volume != null) parts.push("vol" + Math.round(s.volume * 100));
|
||
if (s.countMs > 0) parts.push("cd" + Math.round(s.countMs / 1000));
|
||
(s.lanes || []).forEach((c) => parts.push(laneCfgToStr(c)));
|
||
if (s.trainer && s.trainer.on) parts.push("tr" + s.trainer.playBars + "/" + s.trainer.muteBars);
|
||
if (s.ramp && s.ramp.on) parts.push("rmp" + s.ramp.startBpm + "/" + s.ramp.amount + "/" + s.ramp.everyBars);
|
||
return parts.join(";");
|
||
}
|
||
function patchToSetup(str) {
|
||
const s = { bpm: 120, volume: null, countMs: 0, lanes: [], trainer: { on: false, playBars: 2, muteBars: 2 }, ramp: { on: false, startBpm: 80, amount: 5, everyBars: 4 } };
|
||
for (let tok of String(str).split(";")) {
|
||
tok = tok.trim(); if (!tok || tok === "v1") continue;
|
||
if (tok.includes(":")) { const c = laneStrToCfg(tok); if (c) s.lanes.push(c); }
|
||
else if (tok.startsWith("vol")) s.volume = (parseInt(tok.slice(3), 10) || 0) / 100;
|
||
else if (tok.startsWith("cd")) s.countMs = (parseInt(tok.slice(2), 10) || 0) * 1000;
|
||
else if (tok.startsWith("tr")) { const [p, m] = tok.slice(2).split("/"); s.trainer = { on: true, playBars: +p || 1, muteBars: +m || 0 }; }
|
||
else if (tok.startsWith("rmp")) { const [a, b, c] = tok.slice(3).split("/"); s.ramp = { on: true, startBpm: +a || 80, amount: +b || 0, everyBars: +c || 1 }; }
|
||
else if (tok.startsWith("t")) s.bpm = parseInt(tok.slice(1), 10) || 120;
|
||
}
|
||
return s;
|
||
}
|
||
function currentPatch() { return setupToPatch({ bpm: state.bpm, volume: state.volume, lanes: snapshotLanes(), trainer, ramp }); }
|
||
function setVolume(pct) {
|
||
state.volume = Math.max(0, Math.min(1, pct / 100));
|
||
$("vol").value = Math.round(state.volume * 100); volVal.textContent = Math.round(state.volume * 100) + "%";
|
||
if (masterGain && audioCtx) masterGain.gain.setTargetAtTime(state.volume, audioCtx.currentTime, 0.01);
|
||
}
|
||
function applyPatch(str) { const s = patchToSetup(str); if (s.volume != null) setVolume(s.volume * 100); applySetup(s); }
|
||
|
||
// base64url(JSON) for set lists — safely carries free-text titles/names
|
||
function b64u(str) { return btoa(unescape(encodeURIComponent(str))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); }
|
||
function unb64u(s) { s = s.replace(/-/g, "+").replace(/_/g, "/"); return decodeURIComponent(escape(atob(s))); }
|
||
function setlistToCode(sl) { return b64u(JSON.stringify({ t: sl.title, d: sl.description, i: sl.items.map((it) => ({ n: it.name, p: setupToPatch(it) })) })); }
|
||
function codeToSetlist(code) {
|
||
const o = JSON.parse(unb64u(code));
|
||
return { title: o.t || "Shared set list", description: o.d || "", items: (o.i || []).map((x) => ({ name: x.n || "Item", ...patchToSetup(x.p) })) };
|
||
}
|
||
|
||
function shareLink(hashPart) { return location.origin + location.pathname + "#" + hashPart; }
|
||
function openShare(title, url, note) {
|
||
$("shareTitle").textContent = title;
|
||
$("shareUrl").value = url;
|
||
$("shareNote").textContent = note || "";
|
||
$("shareExtBanner").hidden = true; // reset the external-QR warning
|
||
$("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).");
|
||
}
|
||
|
||
// Apply a shared link on load. Returns true if it set the metronome state.
|
||
function applyHashShare() {
|
||
const h = location.hash || "";
|
||
try {
|
||
if (h.startsWith("#p=")) { applyPatch(decodeURIComponent(h.slice(3))); history.replaceState(null, "", location.pathname); return true; }
|
||
if (h.startsWith("#sl=")) {
|
||
const sl = codeToSetlist(decodeURIComponent(h.slice(4)));
|
||
setlists.push(sl); activeSL = setlists.length - 1; saveSetlists(); renderSetlists();
|
||
if (sl.items[0]) { applySetup(sl.items[0]); activeItem = 0; historyName = sl.items[0].name; }
|
||
history.replaceState(null, "", location.pathname);
|
||
alert("Imported set list: " + sl.title + " (" + sl.items.length + " items)");
|
||
return true;
|
||
}
|
||
} catch (e) { console.warn("ignored bad share link", e); }
|
||
return false;
|
||
}
|
||
|
||
// Demo set list (each item authored in the share language — also exercises the parser).
|
||
const DEMOS = [
|
||
["Four-on-the-floor", "t120;kick:4;snare:4=.x.x;hatClosed:4/2"],
|
||
["5 over 4 polyrhythm", "t100;kick:4;claves:5~"],
|
||
["3 over 2 hemiola", "t96;woodblock:2;cowbell:3~"],
|
||
["2 & 4 & 3 over one bar", "t100;kick:3;cowbell:2~;claves:4~"],
|
||
["7/8 (2+2+3)", "t130;kick:2+2+3=x..x..x;hatClosed:2+2+3/2"],
|
||
["6/8 groove", "t100;kick:3+3=x..x..;snare:3+3=...x..;hatClosed:3+3/2"],
|
||
["5/4 (3+2)", "t112;kick:3+2=x..x.;snare:3+2=..x..;hatClosed:3+2/2"],
|
||
["Triplet hats", "t100;kick:4;snare:4=.x.x;hatClosed:4/3"],
|
||
["Tempo builder 80↑", "t80;woodblock:4;rmp80/4/4"],
|
||
["Gap trainer (play 2 / rest 2)", "t100;kick:4;hatClosed:4/2;tr2/2"],
|
||
];
|
||
|
||
/* =========================================================================
|
||
VISUALS
|
||
========================================================================= */
|
||
function drawLoop() {
|
||
if (audioCtx) {
|
||
const now = audioCtx.currentTime;
|
||
for (const m of meters) {
|
||
while (m.vqPtr < m.vq.length && m.vq[m.vqPtr].time <= now) { m.currentStep = m.vq[m.vqPtr].step; m.currentBar = m.vq[m.vqPtr].bar; m.vqPtr++; }
|
||
if (m.vqPtr > 512) { m.vq = m.vq.slice(m.vqPtr); m.vqPtr = 0; }
|
||
}
|
||
updateStatus(now);
|
||
}
|
||
for (const m of meters) renderLaneStrip(m);
|
||
tickTimers();
|
||
requestAnimationFrame(drawLoop);
|
||
}
|
||
|
||
/* =========================================================================
|
||
PRACTICE TIMERS — advance only while the metronome is running
|
||
========================================================================= */
|
||
const timers = { elapsedMs: 0, totalMs: 0, remainingMs: 0, last: 0 }; // countdown off by default
|
||
function fmtClock(ms) { const neg = ms < 0; const s = Math.round(Math.abs(ms) / 1000); return (neg ? "-" : "") + Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0"); }
|
||
// Parse a countdown duration: blank = off; "h:mm:ss" / "m:ss" (seconds-last); a plain number = minutes.
|
||
function parseTime(str) {
|
||
str = (str || "").trim(); if (!str) return 0;
|
||
if (!str.includes(":")) { const m = parseFloat(str); return isFinite(m) && m > 0 ? Math.round(m * 60000) : 0; }
|
||
const p = str.split(":").map((x) => parseInt(x, 10) || 0);
|
||
let h = 0, m = 0, s = 0;
|
||
if (p.length >= 3) { h = p[0]; m = p[1]; s = p[2]; } else { m = p[0]; s = p[1]; }
|
||
const ms = ((h * 60 + m) * 60 + s) * 1000;
|
||
return ms > 0 ? ms : 0;
|
||
}
|
||
function tickTimers() {
|
||
const now = Date.now();
|
||
const dt = timers.last ? Math.min(now - timers.last, 1000) : 0; // clamp so backgrounded gaps don't jump
|
||
timers.last = now;
|
||
if (timersOn && state.running) {
|
||
timers.elapsedMs += dt;
|
||
if (timers.totalMs > 0) {
|
||
const before = timers.remainingMs;
|
||
timers.remainingMs -= dt;
|
||
if (before > 0 && timers.remainingMs <= 0 && continueMode) { // countdown hit 0 → auto-advance
|
||
const sl = getSL();
|
||
if (sl && activeItem >= 0 && activeItem + 1 < sl.items.length) loadItem(activeItem + 1);
|
||
}
|
||
// otherwise it keeps counting past 0 into negative (overtime); never stops the metronome
|
||
}
|
||
}
|
||
renderTimers();
|
||
}
|
||
function renderTimers() {
|
||
$("dtimers").hidden = !timersOn;
|
||
if (!timersOn) return;
|
||
$("elapsedVal").textContent = fmtClock(timers.elapsedMs);
|
||
const off = timers.totalMs <= 0;
|
||
$("countWrap").hidden = off; // hide countdown when off
|
||
if (off) return;
|
||
const cd = $("countVal");
|
||
cd.textContent = fmtClock(timers.remainingMs);
|
||
cd.classList.toggle("over", timers.remainingMs <= 0); // overtime
|
||
cd.classList.toggle("low", timers.remainingMs > 0 && timers.remainingMs <= 10000); // almost up
|
||
}
|
||
|
||
// 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";
|
||
ctxDisplay.textContent = s;
|
||
ctxDisplay.classList.toggle("muted-cue", muted);
|
||
}
|
||
function updateCtx() { updateStatus(); }
|
||
|
||
/* =========================================================================
|
||
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; }
|
||
const THEMES = ["system", "light", "dark"];
|
||
function effectiveTheme(pref) { return pref === "system" ? (matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark") : pref; }
|
||
function themePref() { try { const p = localStorage.getItem("metronome.theme"); return (p === "light" || p === "dark" || p === "system") ? p : "system"; } catch (e) { return "system"; } }
|
||
function applyTheme(pref) {
|
||
try { localStorage.setItem("metronome.theme", pref); } catch (e) {}
|
||
document.documentElement.dataset.theme = effectiveTheme(pref);
|
||
$("themeBtn").textContent = pref === "system" ? "🖥" : pref === "light" ? "☀" : "🌙";
|
||
$("themeBtn").title = "Theme: " + pref + " (click to cycle: system → light → dark)";
|
||
}
|
||
$("themeBtn").addEventListener("click", () => applyTheme(THEMES[(THEMES.indexOf(themePref()) + 1) % THEMES.length]));
|
||
matchMedia("(prefers-color-scheme: light)").addEventListener("change", () => { if (themePref() === "system") applyTheme("system"); });
|
||
applyTheme(themePref());
|
||
$("startBtn").addEventListener("click", () => toggleTransport());
|
||
let _taps = [];
|
||
function tapTempo() {
|
||
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(activeItem);
|
||
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(); });
|
||
$("playBars").addEventListener("input", (e) => trainer.playBars = +e.target.value);
|
||
$("muteBars").addEventListener("input", (e) => trainer.muteBars = +e.target.value);
|
||
$("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(); });
|
||
$("continueMode").addEventListener("change", (e) => { continueMode = e.target.checked; lsSet(LS.continue, continueMode); });
|
||
$("timersOn").addEventListener("change", (e) => { timersOn = e.target.checked; lsSet(LS.timers, timersOn); refreshFeatureBoxes(); renderTimers(); });
|
||
$("trayMenuBtn").addEventListener("click", (e) => { e.stopPropagation(); $("trayMenu").hidden = !$("trayMenu").hidden; });
|
||
document.addEventListener("click", (e) => { const m = $("trayMenu"); if (m && !m.hidden && !m.contains(e.target) && e.target.id !== "trayMenuBtn") m.hidden = true; });
|
||
$("newSetlistBtn").addEventListener("click", newSetlist);
|
||
$("delSetlistBtn").addEventListener("click", deleteSetlist);
|
||
$("setlistSelect").addEventListener("change", (e) => { activeSL = +e.target.value; activeItem = -1; renderSetlists(); });
|
||
$("slTitle").addEventListener("input", (e) => { const sl = getSL(); if (sl) { sl.title = e.target.value; saveSetlists(); const o = $("setlistSelect").options[activeSL]; if (o) o.textContent = sl.title || ("Set list " + (activeSL + 1)); } });
|
||
$("slDesc").addEventListener("input", (e) => { const sl = getSL(); if (sl) { sl.description = e.target.value; saveSetlists(); } });
|
||
$("addItemBtn").addEventListener("click", () => { addItem($("itemName").value.trim()); $("itemName").value = ""; });
|
||
$("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 = ""; });
|
||
$("clearLogBtn").addEventListener("click", () => { $("trayMenu").hidden = true; clearLog(); });
|
||
$("resetAllBtn").addEventListener("click", () => { $("trayMenu").hidden = true; resetAll(); });
|
||
$("shareSettingsBtn").addEventListener("click", () => { $("trayMenu").hidden = true; shareSettings(); });
|
||
$("shareSetlistBtn").addEventListener("click", () => { $("trayMenu").hidden = true; shareSetlist(); });
|
||
$("shareClose").addEventListener("click", () => $("shareOverlay").hidden = true);
|
||
$("shareOverlay").addEventListener("click", (e) => { if (e.target.id === "shareOverlay") $("shareOverlay").hidden = true; });
|
||
$("shareCopy").addEventListener("click", async () => { try { await navigator.clipboard.writeText($("shareUrl").value); const b = $("shareCopy"); b.textContent = "Copied!"; setTimeout(() => b.textContent = "Copy link", 1200); } catch (e) { $("shareUrl").select(); } });
|
||
$("shareOpen").addEventListener("click", () => window.open($("shareUrl").value, "_blank"));
|
||
$("shareQrExt").addEventListener("click", () => {
|
||
$("shareExtBanner").hidden = false; // warn that we're handing the link to a third party
|
||
window.open("https://api.qrserver.com/v1/create-qr-code/?size=320x320&data=" + encodeURIComponent($("shareUrl").value), "_blank", "noopener");
|
||
});
|
||
window.addEventListener("keydown", (e) => {
|
||
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(); moveActiveItem(k === "ArrowUp" ? -1 : 1); return; } // reorder selected item
|
||
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; }
|
||
// Leave arrow keys to a focused slider / menu so they still adjust it.
|
||
const arrowCtrl = tag === "SELECT" || (tag === "INPUT" && type === "range");
|
||
if (k === "ArrowUp") { if (arrowCtrl) return; e.preventDefault(); setBpm(state.bpm + (e.shiftKey ? 10 : 1)); return; }
|
||
if (k === "ArrowDown") { if (arrowCtrl) return; e.preventDefault(); setBpm(state.bpm - (e.shiftKey ? 10 : 1)); return; }
|
||
if (k === "t" || k === "T") { tapTempo(); return; }
|
||
if (k === "a" || k === "A") { addMeter("4", 1, "claves"); return; }
|
||
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); return; }
|
||
if (k >= "1" && k <= "9") { const m = meters[+k - 1]; if (m) setLaneEnabled(m, !m.enabled); }
|
||
});
|
||
|
||
/* init */
|
||
// seed the demo set list once (first run only; not re-added if the user deletes it)
|
||
if (!lsGet(LS.seeded, false)) {
|
||
if (!setlists.length) {
|
||
setlists.push({ title: "✨ Demos", description: "Click an item to load it, then press Space — meters, polyrhythms, odd time, subdivisions & practice tools.", items: DEMOS.map(([n, p]) => ({ name: n, ...patchToSetup(p) })) });
|
||
activeSL = 0; saveSetlists();
|
||
}
|
||
lsSet(LS.seeded, true);
|
||
}
|
||
// a shared link (#p=… settings / #sl=… set list) sets the state; otherwise default lanes
|
||
if (!applyHashShare()) {
|
||
addMeter("4", 1, "kick"); // reference bar
|
||
addMeter("5", 1, "claves", null, true); // 5 fit into the bar → true 5:4 polyrhythm
|
||
}
|
||
renderSetlists();
|
||
renderLog();
|
||
updateCtx();
|
||
refreshFeatureBoxes();
|
||
$("continueMode").checked = continueMode;
|
||
$("timersOn").checked = timersOn;
|
||
$("appVersion").textContent = "v" + APP_VERSION;
|
||
requestAnimationFrame(drawLoop);
|
||
</script>
|
||
</body>
|
||
</html>
|