metronome/index.html
Me Here 61c933e44f Header: VARASYS tagline lockup left of title; Save tooltip
- Move the VARASYS logo to the left of the title and switch to the
  tagline-bottom lockup, with theme-aware dark/light variants swapped
  via CSS (both inlined as data URIs).
- Right-justify the theme/help buttons; drop the shortcut legend to its
  own line so the buttons no longer wrap on medium widths.
- Save button: wrap it so the tooltip shows while disabled, and make the
  tooltip dynamic (why it's disabled vs. which item it overwrites).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 09:05:34 -05:00

1272 lines
108 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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; }
/* 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.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:center; justify-content:space-between; gap:14px; margin-bottom:6px">
<div style="display:flex; align-items:center; gap:14px; min-width:0">
<a class="brand" href="https://varasys.io" target="_blank" rel="noopener"
title="VARASYS — Simplifying Complexity (varasys.io)" style="display:inline-flex; align-items:center; flex:0 0 auto">
<img class="brand-logo brand-dark" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAATAAAABSCAYAAADTsIgWAAAKN2lDQ1BzUkdCIElFQzYxOTY2LTIuMQAAeJydlndUU9kWh8+9N71QkhCKlNBraFICSA29SJEuKjEJEErAkAAiNkRUcERRkaYIMijggKNDkbEiioUBUbHrBBlE1HFwFBuWSWStGd+8ee/Nm98f935rn73P3Wfvfda6AJD8gwXCTFgJgAyhWBTh58WIjYtnYAcBDPAAA2wA4HCzs0IW+EYCmQJ82IxsmRP4F726DiD5+yrTP4zBAP+flLlZIjEAUJiM5/L42VwZF8k4PVecJbdPyZi2NE3OMErOIlmCMlaTc/IsW3z2mWUPOfMyhDwZy3PO4mXw5Nwn4405Er6MkWAZF+cI+LkyviZjg3RJhkDGb+SxGXxONgAoktwu5nNTZGwtY5IoMoIt43kA4EjJX/DSL1jMzxPLD8XOzFouEiSniBkmXFOGjZMTi+HPz03ni8XMMA43jSPiMdiZGVkc4XIAZs/8WRR5bRmyIjvYODk4MG0tbb4o1H9d/JuS93aWXoR/7hlEH/jD9ld+mQ0AsKZltdn6h21pFQBd6wFQu/2HzWAvAIqyvnUOfXEeunxeUsTiLGcrq9zcXEsBn2spL+jv+p8Of0NffM9Svt3v5WF485M4knQxQ143bmZ6pkTEyM7icPkM5p+H+B8H/nUeFhH8JL6IL5RFRMumTCBMlrVbyBOIBZlChkD4n5r4D8P+pNm5lona+BHQllgCpSEaQH4eACgqESAJe2Qr0O99C8ZHA/nNi9GZmJ37z4L+fVe4TP7IFiR/jmNHRDK4ElHO7Jr8WgI0IABFQAPqQBvoAxPABLbAEbgAD+ADAkEoiARxYDHgghSQAUQgFxSAtaAYlIKtYCeoBnWgETSDNnAYdIFj4DQ4By6By2AE3AFSMA6egCnwCsxAEISFyBAVUod0IEPIHLKFWJAb5AMFQxFQHJQIJUNCSAIVQOugUqgcqobqoWboW+godBq6AA1Dt6BRaBL6FXoHIzAJpsFasBFsBbNgTzgIjoQXwcnwMjgfLoK3wJVwA3wQ7oRPw5fgEVgKP4GnEYAQETqiizARFsJGQpF4JAkRIauQEqQCaUDakB6kH7mKSJGnyFsUBkVFMVBMlAvKHxWF4qKWoVahNqOqUQdQnag+1FXUKGoK9RFNRmuizdHO6AB0LDoZnYsuRlegm9Ad6LPoEfQ4+hUGg6FjjDGOGH9MHCYVswKzGbMb0445hRnGjGGmsVisOtYc64oNxXKwYmwxtgp7EHsSewU7jn2DI+J0cLY4X1w8TogrxFXgWnAncFdwE7gZvBLeEO+MD8Xz8MvxZfhGfA9+CD+OnyEoE4wJroRIQiphLaGS0EY4S7hLeEEkEvWITsRwooC4hlhJPEQ8TxwlviVRSGYkNimBJCFtIe0nnSLdIr0gk8lGZA9yPFlM3kJuJp8h3ye/UaAqWCoEKPAUVivUKHQqXFF4pohXNFT0VFysmK9YoXhEcUjxqRJeyUiJrcRRWqVUo3RU6YbStDJV2UY5VDlDebNyi/IF5UcULMWI4kPhUYoo+yhnKGNUhKpPZVO51HXURupZ6jgNQzOmBdBSaaW0b2iDtCkVioqdSrRKnkqNynEVKR2hG9ED6On0Mvph+nX6O1UtVU9Vvuom1TbVK6qv1eaoeajx1UrU2tVG1N6pM9R91NPUt6l3qd/TQGmYaYRr5Grs0Tir8XQObY7LHO6ckjmH59zWhDXNNCM0V2ju0xzQnNbS1vLTytKq0jqj9VSbru2hnaq9Q/uE9qQOVcdNR6CzQ+ekzmOGCsOTkc6oZPQxpnQ1df11Jbr1uoO6M3rGelF6hXrtevf0Cfos/ST9Hfq9+lMGOgYhBgUGrQa3DfGGLMMUw12G/YavjYyNYow2GHUZPTJWMw4wzjduNb5rQjZxN1lm0mByzRRjyjJNM91tetkMNrM3SzGrMRsyh80dzAXmu82HLdAWThZCiwaLG0wS05OZw2xljlrSLYMtCy27LJ9ZGVjFW22z6rf6aG1vnW7daH3HhmITaFNo02Pzq62ZLde2xvbaXPJc37mr53bPfW5nbse322N3055qH2K/wb7X/oODo4PIoc1h0tHAMdGx1vEGi8YKY21mnXdCO3k5rXY65vTW2cFZ7HzY+RcXpkuaS4vLo3nG8/jzGueNueq5clzrXaVuDLdEt71uUnddd457g/sDD30PnkeTx4SnqWeq50HPZ17WXiKvDq/XbGf2SvYpb8Tbz7vEe9CH4hPlU+1z31fPN9m31XfKz95vhd8pf7R/kP82/xsBWgHcgOaAqUDHwJWBfUGkoAVB1UEPgs2CRcE9IXBIYMj2kLvzDecL53eFgtCA0O2h98KMw5aFfR+OCQ8Lrwl/GGETURDRv4C6YMmClgWvIr0iyyLvRJlESaJ6oxWjE6Kbo1/HeMeUx0hjrWJXxl6K04gTxHXHY+Oj45vipxf6LNy5cDzBPqE44foi40V5iy4s1licvvj4EsUlnCVHEtGJMYktie85oZwGzvTSgKW1S6e4bO4u7hOeB28Hb5Lvyi/nTyS5JpUnPUp2Td6ePJninlKR8lTAFlQLnqf6p9alvk4LTduf9ik9Jr09A5eRmHFUSBGmCfsytTPzMoezzLOKs6TLnJftXDYlChI1ZUPZi7K7xTTZz9SAxESyXjKa45ZTk/MmNzr3SJ5ynjBvYLnZ8k3LJ/J9879egVrBXdFboFuwtmB0pefK+lXQqqWrelfrry5aPb7Gb82BtYS1aWt/KLQuLC98uS5mXU+RVtGaorH1futbixWKRcU3NrhsqNuI2ijYOLhp7qaqTR9LeCUXS61LK0rfb+ZuvviVzVeVX33akrRlsMyhbM9WzFbh1uvb3LcdKFcuzy8f2x6yvXMHY0fJjpc7l+y8UGFXUbeLsEuyS1oZXNldZVC1tep9dUr1SI1XTXutZu2m2te7ebuv7PHY01anVVda926vYO/Ner/6zgajhop9mH05+x42Rjf2f836urlJo6m06cN+4X7pgYgDfc2Ozc0tmi1lrXCrpHXyYMLBy994f9Pdxmyrb6e3lx4ChySHHn+b+O31w0GHe4+wjrR9Z/hdbQe1o6QT6lzeOdWV0iXtjusePhp4tLfHpafje8vv9x/TPVZzXOV42QnCiaITn07mn5w+lXXq6enk02O9S3rvnIk9c60vvG/wbNDZ8+d8z53p9+w/ed71/LELzheOXmRd7LrkcKlzwH6g4wf7HzoGHQY7hxyHui87Xe4Znjd84or7ldNXva+euxZw7dLI/JHh61HXb95IuCG9ybv56Fb6ree3c27P3FlzF3235J7SvYr7mvcbfjT9sV3qID0+6j068GDBgztj3LEnP2X/9H686CH5YcWEzkTzI9tHxyZ9Jy8/Xvh4/EnWk5mnxT8r/1z7zOTZd794/DIwFTs1/lz0/NOvm1+ov9j/0u5l73TY9P1XGa9mXpe8UX9z4C3rbf+7mHcTM7nvse8rP5h+6PkY9PHup4xPn34D94Tz+49wZioAAAAJcEhZcwAALiMAAC4jAXilP3YAACAASURBVHic7X0JfBRF9n9V92Sme5KA4VZYQOG3q7gqh4quiqisZ0K41ltBRVzxwAtQLkUwLl7IoYCAgqLoGgg58FoVI64HCnjssq4iguCiKHJlpmeSzPT/+yY92Z6enqPnSMz++/v5TLpT3VX1uurVq/eqXlU5+LLtkrN1u57Mhg0bNloQag/8vNVBwkt0iF80NzE2bNiwYQWQXcc5mpsIGzZs2EgVtgCzYcNGi4UtwGzYsNFiYQswGzZstFjYAsyGDRstFrYAs2HDRouFLcBs2LDRYmELMBs2bLRY2ALMhg0bLRaWBJiqqn/hjHuiHnB2Ev4OzhRRKlPf5yp/1STczTm/J1P5xMxfDY5hQfXriEBROAvfPi3beTfSwNQZKIMtKUYWVR50cya0R930QEh//I7NKIGpQmVz1WCgTB/EudiRCWwl3TYTVYQgftWgby2uHyqq+i0T9u9ne2uDzFkgu2TengvikZwL/fB8ACgdiKsr7VxVVoq/n5k+4+wq/P1t2nkwtgf5zIvx7GTkU5SBPGrAs5X4oDeCdWxzbU7we7b9h0Ose67IfPm5zhyhM9puT87Zybieg/f7sAzUt1UN7FtPkfSUMTC3ytcVl4vwE9MlqAHBqZ6i3LeNoe5K7/VNweNo+Cd4iqXF+jChdNdHstTuJty2zToBhGBwnWdw7rpMJReqI5VdjY+7Gf92zFS6lsHZGd7i3HHGYNA3GpdBzUCRioa3nAXqZnqLW30T+UgO3/jw24ffV/i9jl+J8MIvraQ8+XIucPqWo1POnbNjlO27rgze3NNvfOQu97zHRfFtlj7T3452+4IxUCj9KU+W8j9PM+2D4KsHlEPehcHL2xyMfNS4xNqL30/4fYofCWzmXn3wKObMGQOl4Ab8e1iqmVsSYJCcN+ISJcA8hdJ3YMA3cHtBqoQ0QmXbfMX569CATfIXbkw7/WTA2Uih/MCUYHHr/eGg4IguirtSeaopNMBsgOoIl5lg2sclKe8+MM7t+F9oBlL65Fb6BqBBvasPDLLgHIEJTS3A9kIbvNQ7OPdNnbBKClpjXSiUli6WpAuvQqc3C/93SIGGY6XunSfjGqXdQ9C/465SnkddXZlCug1Q2StmwosA4fUgLkemnDZjH6PihnsGSzsZkyxF9A5rtQ2Xu4WVB2dJeTn3oF3dhv9zrBJgVQPrnVvh+wMIft/4IKiqSwTO0xdgjC0NBoOqMdBVqZzi4LxPBtJPBnmy6CKN4BF9oM/PnpAldhdLoaB/LQiOaF+Dy11yhfJ3QeBktqVvBlkHMWuEAPNvfPgVud9E0nAyYTIlg/2BQOAsX3FuWhsZBEeMCOCyDA2xXMrPWQhhc7HVNBDnbqnMu8o31B1lSvrU4F0yFwtZalpKDatnY80eUCeCjE2fJYkPFN+hczV+ShnBy1qRZjsB3/+86BBWkkZqJb7lQXxVUEkLihJg/h/+VSkf3utHlp55Uq/4oc6bALZp02hfGlRVvRm962yNQUMIjpC/R49YCoa7rClpyQaUwXIZmHg0GOa5Js+cs8HuqgNHegtbfxsOCt57bxAa7jz0xLHGajIKdLij0hVeEemhIUJrvwEdH2mRbSxGz0HjXSpMrz4leO+Z9RHpFuX+CJ6bDJ57wjJNqjpFGSrvMIYLVbvdMi9YylLXwH9WfMFh6QovPUh4y1XeO6GFv2IlnmUBhoL8k1Bac0dwRN5P+vDg9X3rUNDL8XyC1TQbobJXSUgYg4WqmnYyd1ju2dIBGlI3l3TRENyu0ocHAuxxh8iyLsA4F0+Tq5TWliOqTOVq0BPkbE+tf8eXwRG9amO9CtNiBUz/obgdlg6tKUBkqpPG4u7UB/r8Nctg1sxgaYyJJIm1SpFcbvYgNLaVL1+K2wHggcO14L0o1y/A/OuUjbPeJ2FrFlcSnQuYdeHVAM76yX37j8fdg8ZHPt/aRbJUeA1uT0w6PZVt8PvXzmdsRNQjmRU8wHQDVFaBzn1acIT7B7NnNLbFc5xX0PeAFQ+DPKjF9XvQs5Gxute8Ra22msWD8D8M5bfQKi2puFG4ZMlxLa6zjA/U+vql3JFDlZDSoGOQqUvNwuWg4xr0FdaM7AyAN5g6EQLMXyxvQKP/ALenZjnzGUIqxUhRuBiaTZGloxR0KpVBFrzfV5j7T7PXg/X1UwSHYyhr4hlACIfrhIq99wUHtz3USAt6dNC7FEx/Z7y46UINBGabhdMwhdzKTfV9RNRDzv5EF5i5O6G5zlf8h57UayDgiatA96VpEcbZvfKamjXKkLx/6YPJCnBVKWMdjH/IktOa6gIscL3eemiks8L3B6RwaxpU7vP59y5jrEvUA7lSGSc4nQ/h1kn/c42lQlfORlEwtR1oho/4i3PL9ENFJPzxXlerxKTqB3aDMH36w8aeSBmS/xUY8F0QcmYKae72b9qwlhVFRkU+ApjmhhTpTBZb0avsRqM6Qx+I7zjdVa70g9DaGPF2kM0BE2RXgGUGMo3JQJwNQaMbBY1rpfEFaiyos/fx3mlZoqEePfCLJgPRrV2CexSukSZjoHY+E13UcWRoRjsKB32fflLNig18tvJggZznrMBt+wTxfwPGmAVN8Q53pXeSrzjvGaliX3fOXPMzQJsLnclSobT0DKPw8RfKH6PxL2JJDKWgvB/yFeVGzS4Ky3dIctuOT7P0Jm/epAktY6C7ynuuwIXHk4h/qsD5KrncuwFC+Wbtu1IW/qkKsCNdfcafj2uUvcpVvgQVbFmAQYAsN9r/BKnv+HNx6ZEamckBmt880Pw9+okzjM9EMaSFXaUPUzZ/tEru138nI2ZuGXDi+5a6yw984C1uvT3qqcrexPOsCDDU6yrwxCQ0GWLQCH6DhnkLOqgn9B0h0QeGXoPb4dmgB9hixmdyvpMGyhMJLz06ci4slSu89F35+LXKEH2nwlwkDSlKS1QO1U4GnVQu8WY7/+3bu2cmY92iHkhtOtyPy+/Sok5l5m4XKr/Wkg7P2cnQKD9AXZPZfXWq5KTsiS8IIZeGKAGm+H9eJUvt5uK2wEJyUCXrlppNZXMmpDNTkgwO+IPeZ9jmfyhmQok0GKHMOyE41L07HEYNAAVPg6p/yTJtmYTMRCeNo9xrfIBvNDUvMwEe4HM8QyQyu9YgI+OAzP+5ek8g/8HKiNAANFwxawJsr1kgzJq20AxSSe+P6ZFjipnuyoOVxvEimiiACTgekt90oosA7evD4MhuPmM4LImTHSK/I13CVK7+YhYO6yUV/0jSsm9Oh550lhJdgB69u7FHJ/USjft5i4S9Yza4l1vm68Zy2IVp0JgMng6Nwww+k6aWF6CRlRieO6WckP9bhJ+O4qtdLLucU/F+bpbpyxggqPqahatq4GfOs2CxqWwDhNcHWuZkYkWNKAsNjqARAsxTLK1HXWykgeAs0GTK85ypXzbvQoAIuBnLWQwl4WyjS5EyxP2cVOG5DnU5wCwiwq9C+1viKZTeC4cJ87e65O5dyHRMu5LV2Gmg/JreETkdASYywUljU1GOnYG64BIxR0hegAWZ6eC9mqOSp262xkIIAbW2dn7YCU/h9Ytl5iBBFTFhQN7CwvIdJfqeLTii1S9oZOSC8Ocs0pdpuE1DVRbld5chNI6JoEFVo2GR+XF8xBucnS1Veo6PGrPh0MIYezbjFHHWySzYt+njN6GBZ39yJklAoxkorfFQ+4qYmSOBJlfU3MgFB3m1m/kjCqjNp4TSLb3DM9BS987E0xlZSsZVdrhZeLC2bq7gzKFxzmzPIEcgrcXcKORrId3vMy6DIJ8OMOvHuD0piWT2Kft+XGW02VEBTlk66rp06EsIlVVoHsEhBAvzfoZQIme6awxvdpDatL8c16f1gcFA/VzB4SAm+9V03fFAExWm4YLQJgsfsEv5YUspY/9V+lQWnMeZsNjwHhe5SGM+o/WBim/bS6h/muk2bTBp4BihdJdsHIimYQGhYu95Ms+didqkOm0OB98IcIHPQjt6RVtF0QhlcN4Wd5UyO6bLEmfHoOzuxt39NAkF0zF116aopM21eGVY/tdyVc2pAnNQx3NupvJLhHR3o+gA1ZTGKqKWKqCxLIGAS0aAPW9ms8uuoyjd7K7Za+jlIxFgi1EqRgFGy5jI1IkQYDSDBwajtXHnZ43GDALM95pZuMC5Je/npKCyJ8k3UB/kYwdekNWCx03M7iuE0pp79L6FpD1oJv39GabMKbvaFeP6ovGB5tIxDrTMlCXHlVBMh6HMTmHNt2tLK9KmmAl/+fYfvF86rNWlcVwPJkGzXeMQReLZzNHP2UDyy6TO3vhIKcwjM/I8qdxznCAKV4M2Kuf/y1jeJkj7w1DJNMgeJcB8qnclerNHcZsXLz6ZmzHSvZFnV7HZTGZNVL6C2jdGvse7qzxnewsjF5mranAOhFtLEGAfK5s+etHopkJAZzOIpzaAHQuKEqh7yrg+zslaHx1jzFCSXSFNdmZEIoHAItkhTmJWF9olxmRh8aZVRgEbhiZIaRZwNjlYukTnQIFcg1Q2CPT/PsO0xAdn57mrvKO8he5lETRe2dEjVyi3cYGtjhHTBc2WlmtZd4aOD5fMHFQnMScEtBUO5A86nlZcwNI9Gx9yJtoVTXiYmvCpIm0BRv5DZmMY1JtBO/krbq+NE/0Ts/VfJMFFUYxyacgo1GjtSxAELld4b4kVhTORtLAIAeYrzntdLvf+y+oarqYEBNQ7Pn/gYjP3AXf5wR5cdJoOCKeB54JD8qNm+8htImYMzsYKpVse0q8cCA7J3RPDpE8PEEJyp14PswZH5bjQFvSv0X6MGiRXXUORBq3GSN4zPg3A7H5MKPO+rp8JJ4SWg1X5aPufi2JEzbTwCuNWd6X3DW+R21Sj10NbLkZj3EvJpzOn74STRc6Ga35f0d6wFpER1VJkIs3SRTvY1bMlyCGmAIOWZap9QXhle2D8R2XHrheNqymkNTVku8fzkyl0Vx7sqZ8xpUFVd4UyFxrMgizRmgpIUNFSjw0gb4V/80PlsZa/MDGHTLRM7kqhBlhgrjEQZll7mGXxnBUPl11H0XKxFfpApPU4+CuzAozA2Tg0fqeyfdftZlvZxILWIB+jn1Tu7S2I/B5aXseyOw5aIDsEctuJWvKl1tbeyp1OaDgWt9NIDyKsjtW0BRKsGNOdLsyg8SCtJvhQKC292+W8aDB0hqmsYW+wlJAZ25izK4UXfplo3A+IptDxkeRjFD0DojKP75Cy0ljuQsXefFnIvSrq/QxCZWyBGdNyQYitITRA4MxJ70TsZ+U7ePA5+bDWtL4stXVwZjQGg+f79v0UZeImhWuO9EdMvw+Ocv0KQa5ShguZX5j+N7NlSzARr2eJTEEeKtcIAUaaPXiI9kU7K5NEarhR7tZlILTuu5TBbkuLiEO0FbtpJvASd4VnIRdEojt6CVKmwNnQ3ArfJZ7B0kv6YJqEgpZaQkvPspa3OajhPo+6+VMwWHe3Mjj/31YiaysNyqCVlcv9JpI5Su5LWd9OJxbypDyZhE7Uinltm50or2KVqy9Hb4AGDudumorNzxBdZvD71EDUolHSrDh3Jt4OCOaMULp/WnDEYQfCQTQe4a5SFqMnnpg5MtVas8mNpDDSXNnSA41ukCCI5AaSUc0BgjPaNJ9e7ZD79U9mN5ET0SBO1/swNaSpPo6eOhsCLDRjJ3BhLfL9IgiLwF8HvjSYaongHZy7DsLlFOixJGizt2qEs3nQZN82bqQAa+JhuXuXTO3eahVDBCGnCOX3GjSD5QrftzZYeLg32ciaVvaIXOX9p8AEMtOdVjLP2OwEF0LOnlECzB+of0525JDHesS0NBq7qe+Xtmli1kDr8miLkqh8WWh3hGRMqXxZksgsjhDKPMifQGxahPyrPmcgJEz69r8DGgMNmGd6X7N/+zc//JpR44PwItMnufEONaSFRQgwmMBV6KVpt9RsLik7DtroHDmHzyYXIPDJ6ywQXOer2/eR2do/I2hTP7miBiaR4xOWLXOOs/aSJFIHcbk+mKwJdEg3oU7/lpV8E4N8NS8CfRfJrMCL8nsbncEbqhqsrt30yD9iDl/ooBS6X3VXKOBLbmlNaSYb27GmO20Oyd8L7aQsYrGmyv6F994zJkC9Ly7HZZCmKEAjjNYQSnfJstRuVNKJqIz2CpurX3BLDIzvXJ3KhnZNAWHFj7kwc4dBmExhWeqp0ejnmjKrypJfJ8fZEFqB4RkqNe5jRWnKlco8aPLJLBZOF9SJ9Uc99meiOE0W29E2UR/j/9cD9YHVviG5/4gVMeSfVanMQSd8d7aIo73o5Erv80qRe60+nHaVRfuhLYKKs5V3kiBn6UJ0BoXkg46O5wDkwruqqr7iCwZX08RMrIgo2ydhztNQwwnJZpZRbQFmIWlP70Y9CASXgBn+K8B4pD9VY/wsu04g/WpfkXuzMRzCi5a4JD9jw9lRNACJu8jDKerRwBzsVyfAyBUAwot8dLLpV7fPt//g8qgxzTWH2kIDtzLL6VBzVNKGx+sD/ar3aZnnTmfZm1mLhRzw5B9w/YPoEKfTzh3g52ne4ty3TN8O1i5ioitrAowAesi/cq3Jo29NwpobrenQEAj1IlkQ50DAv4wymmK2qUBoQqxKecrK5o0ZFWDIeJhQ6u1k3OzMNzT/bbncu40aPv6tVeoDUUtEhDWeDrJDjN59LYNQdUtbDEg0eB8FLoRMnQgBFpq0qPRtoJX2qdCXDMAAy8EMsXYj2KoMdt9qXD9HrgDuKu9kzgTTWd9MgByXaSzQGA7hRR72lkwq8NF1QulP0/X7bWluOc+wJFwfsomQMBNF0nameAqlB4zPtd00SJCks9f8/yqc4N0rIOCL0E6KjNYaQa0PrOOO5MVSpsdrnLIk0PKfiIqlBgWCl2rhlWZqpCyKFM/SAJ4lqGyb37+20rie2FWh9HcISa0YiADteSZVevuYaHQkJJOeWk4B5IkavVeKBqnCQ5vhPWkM9xa6l6Jh0SRFNnZ5qAdjRo1dwMwWJddFN6bgJFsgOfNGMsOYqlpbO487Q7PA2VwfmyxmSJWeSrN9t4jXwCC2AIuNVrCGnhWmTz/KOOTgP7D3W7lt8oZCxgecQdgYMO5fjBuyKaq6TOZ8uqpGe96HGF26aEw2zUfa88tsh0pRSH07D4GHdlIYpQ+j9X/y4SEnyc6pppsOUIYPy2tq1hl39SQovtoxsuSkpTGZpU1la9CbfmcMJjObtuZOJUku8FvB4Asi9gojl4EqH+1cMSQNasOgdNPxf4MSLpBpHC3AOEs46P8/gLTKj/hC6ncb8UaE2Usz76jjpNPOuACjtVlgXPIMroggbLD8HxC20Lfp4TdYUeQslUu68ALE655pWnQ45Pf7n4kan6n0dJS5mPKYFU1MII2J+llNWp4CbfNJPIwyL5oIbsHhWCGUbjnVuB8+7aDhrvBcxQXxTZZZ51VT01wQ4njeJ8ZvzTbNVAOBOVwU0xdgamiMbQ/qidYapjZryJnp7BrM6bwML836dUFlnnqmDnJwPgr/pb5bciB6F5TQgSOsIGnezMqUv9DgUlFhDFd82+40m6USsr1pocqe0ftthSEzcQxLz2x1SSy0seN9+kCF1z8lM8cU1rTe0Xr0laQjycM+ajCZfJbcVcojaR2+EolPoH393RgIQXkWBGVavluCINB4V4QAo7MS0RGS2Z76EXsqWwyayZuelq19Joohf7ikZ77CqajBwDtmDyC8srqDcDMjCGvman+RHPKoR2ddDUFOpr6VDUwJ3/iGFuwwnv/qVFv1tGKIZctn6VzjkhuC2Qk5oVNMnM7zskRHKFs1WDvX6AQuLN6UA1Mv7b32wax/FuZvfVDv2a9ty7MCFXF9uumnTBfj493lnteowRuf+XzfTpVdR52TkQ0Dg+baF4RXWjttahgkVXmONXr2qyw4lzPhmRTTXAcz/6bwNj+08FiYXn2iq9/JYwXGaZFyUgMwKlNnktuEMTy0VpK5WspW49ahsilKkdy4gJzOWRBKa96UJQfNECc7ju2F8L/e7PxXQRXO+DUIMPBCDgmH8QnfdIbey+YJ0VXRR8ZDNerYi5wrMzEW1FHqfgQtx1mmDwzwwByRiTQD11y2hMBEYblQfuAE/QnjBOpI5IpDVwgsZ2OaO8r+R6nd9jJjvSICoSHRFi+Z8EfiohpaQD9GH+jb/p+Vcvcu5Bxt1S3ka8VXO8K4C4W2yH0uzJclktr6UgjHi7UzAsx2UvkE7W6WMlguNSWYubK6DK45AdP4WW+RHHXsm7YyYKxQppTIOZycvItYg4ZsnGwhBeaVQH1gaix/OigElvbHz5rXODSAa4TlO6bGWw6jnZISb7eKtAFJb6ohqIJ6c6YmDcDwtCHfMn0YaQ1oyDTWlI0905MCjUdqZxVGrXektWvuSu/toP2plDNQ2QIzrRqMTjOPmZkppHW2VTWT9PtPhTzPK5WFyMN8kWds7GUOMSbPa0tgyEfxado5Qep9W1fOXUeoQkAKqIK3jge2mu2DFYa2YL1Z3TyyCfDTfppwM5sMIwSHyrtwoaGL+6ltO9u264FOvEANBBxBxK3d/9OX8eSBXKHQ4m5LLkjZXPbSVi7oSAPkMbcFhvCiVfztskjD5zTmYwykXQREUTg9g/n0gbA607i/GGzXOQIXmk2AEWiiQdvVM+oEbm+RezGe0ZkDqQyKK4q/fpExUFvVkEnTWZbV0ELwiJ7fx4ILZCbSGJ+VnVNPkRzCR7lrfJc37tUfA9pY7XbtlxCh4//6TiRXIatjQS0HnN0qS4W/FUq918Q62DYMTVAZFvXHnpAWKpQjZMH6ji7ZXbcnMBqcj7eveXbXParRC4sJEF6ZGJ8xgnreCAHm3/Twq3K/iV+x5llkq8c8d/mB9Wbez0p93WjZkUN+cFbN6ReMi4oJkqsNaXupnFATG5zdJCze9Ije9KOZX3eVQudNjrSWFO8Orl9PWzL7fP6ZZpM7VtGwvnTiU+Rxnm5aLQDny5LwRW6l7y5l06znklnnmAia8HqDpbCbR7YXHveXqrx9fYXuTcYHmhaUzQMU9vh++emFqL32G5a2XB4jTjooogmJiD32UbmhvcIsLlDNAloz0fkc1P+BRvU/tFa13DOSiyIxULJjkbAKAqadA+dZ6Rw6y516kQdyxMG8tK5V5NYEmAYRguwu2SWNREN8RPHXLiEXk1QIk9fUHCP1O5n2+c/WwcC/RrSDsF6GznkczL4S/+YNa8w2y0wGcpUyRNO8UtqpNes7J4hqyM0gyqSA8Mr2rhOLTPfaF3NopiQb7g2i5iV+uz7QV1uzXJbyaeeHJj2txQg6ZRzqP5lcJstfct+CRvIYNeqkElPZ29q2wRHQFuOn7t4QH6ThRggwWgUBuqtTPAk+tLsD/s6SXc5pSKcS6ZQpaqDabLcSPWh8R2rTfiCk9dWCw0HDIL/qHUiyiD6CwF+W+/XfhbovRa9WnszuHTRWKLmEInR21wsNZw6kjOwXPGdXuCuVnlHBnPfPbrZ8Ego16sg32hc6i9nSLp8RGgiEF10s50lboyCtqGlmlFs6dTYDad5nmh+z4HnJ2VlIx2w/+ezNJnN2slmenBzi0087V9st5VKZi3Q+KC0F+gqCeic6wkO4p47Qif87oPx/K7ftSD5jzeXjRzwwFWUx2eRRNmfz44G2SroNWvxttHsHaKNVIFtRXrtVrtL62Hqu8lyU4xEI+70sOX6XKVqboueQ6Yy7JsjHiOZYL0dCIFNlmg36M0WfwJqnsTSNptOw6cBRVFoRM9W/Huf65ir/ZEB7zB0f+unLL1x2GS7D/19VXxs2bPwPwBZgNmzYaLGwBZgNGzZaLGwBZsOGjRYLW4DZsGGjxcIWYDZs2GixsAWYDRs2WixsAWbDho0WC4foEOl07DXNTYgNGzZsWAHJLoenMHQYw9DmJsaGDRs2rMI2IW3YsNFiYQswGzZstFjYAsyGDRstFrYAs2HDRouFLcBs2LDRYmELMBs2bLRY2ALMhg0bLRa2ALNhw0aLhS3AbNiw0WIRIcB8Pl9Xp9M5inNOhxY48ftBVdUPa2pqVrdq1WqfWQLBYPBivH+s2SnJgUCgUBCE/rW1tUtdLtf2RMTg/Yvw/il4/xm8v03/rL6+/jRRFM83xgF9fly+Be1vud3mh20i7h8Q9wJFUZ7EO7sT0REG6Dg6JyfnCsR/EdeIQzqRXzfQODqZdDwez5y8vIYTnVFeN6CsuiRDS11dXW+HwzEccd4F/X/Thf8e4ZfgugL19W99HL/ffxTCrkFZ/g3vvBsvfbwznOoOZX5/vPd2797t7tixI9Xz2fj3N/QZ+H2Psv/7/v37V7Zp0+ZggiKIC5RFZ5TlSKRPpxp1oE/EbxvSf2Pnzp0vdesW+zRnAsqhL76VDuU4kdERcowRPZvwfS8hfEO8uCjbici30w8//DD58MNDJ3PHBd6/B+8f8e67795+5pn/PUps+vTpwrRp08yOmgvgO/bg91FZWdk7I0xOtQZ/9UP9jsJ3LEDdbUlEg46WMaDluETvIe+tqONG2hDvGsTri/zmG/lHDzzvg/K7Fu+/A/pWGdKch98GpPuc7n3iyxuSpf/777+f0KVLFyUcD+l9hvSWxItTWloqDh8+nPg1Z/PmzZMbBRiIvAJMROfb0WESn+NHguEkJDg6Pz9/Np7fP2PGjMfuNRxkiYKgZUjD8IsSYIh7Hp7fjEI6DffnII2oU3bCQMPrjvdW4v18fMzbCIoQYCQI8WwKPpIYQNWFh75BluU6PHsCjDVez1j6uBBCVAlJCzBU2m8pHuJ/ygynDIPGLho9VB5xD/fEd63AJSTA8P42xFsAenvj35gHoe7atUvu3Lnzi7gtgLCcl5ubq6erF+WN64f4N4IBQdeR2rOxKNOTjB2BHvguyp+OBYspwCAEijt16rQAaR4O2ukg269Zw9EMFyL+VQUFBSWo17txvzhefqixbwAADuBJREFUGZiBGv3UqVMnSpI0DelLSJ9Os9nOGk7bpvQv79q16zTQcD2+521jfAjP1sBC1OuliEt1/hl+1InRGYPjUBZ3ILwMHfD14OG9ZjQg30vw64NvpG+6LR69+M5LQVMJ3aNu6Ai6Rj479ljqw/nNyK8W/+oFoQPhuYjH0fC+grC6AnR9ok8Xz4jPbsY3voJ/kxZgiHMRa+Ah02/Tvfc+Lo0CDAJjHXjyUZTbAPDZKSREjHG0sn0Zt050MNPRARvTpNO36NDqRgGG76DDka80JOXCuzLK5RDuI4Q3OnA6WUkpKSnZAuH/e7x3I8rnnyifmKemDxs27A68Nwl1cWvfvn3rQo0fmsbv8DFP4/ZDEHtpWDOgQscz0nweQKTRY8eOXYjgmniFZQbEPQuE0SndT5g9p3zApEtJeCVKC+mcCFo/Df+/detWV/fu3U8EjXcj/m0DBgygU1GycbiqKVAxN4L+p+K9A2ZpvCdNigQtMQD1hIj7jFkcNJAH8c7vUC5DIbz2WKULcdsg31XQnk5LRrMwg0Yf9YjfkDY9c+bMV8MdGAmfKVOmXIDnfyGGOnDgwMtg+P1W0gfTLkTc61Eeb6FRjdNruVu2bHEeffTRpJWVII9XIYx7QRh/E36uNTA6Cf14xH8cWm4JGlnjSeFer7cTOompuL0R4UcfOnTojFhCTMMt4K1VaDzrzR4i/Y5oF/OS+KznQfO1+oCDBw+2AQ2kwT5I9Y82dhxo25VEWsngANJtbyUCWUOoT6rbMvDZXGZybivKlni6K947K2w9JAK+7XVcCvRh4KHbQd9jSOcclO3H+mfQ3ENX4il00qNA1+dI4xkI1T5mQhU8cizSuB/1/Sau85F2gwlJ5ggycUJY3aA3azSN6T0w61njx4/v2L59e8vCi4AMNyP9WWDC1/RMGAYY589knuC9j3E9yUraPXv2JE3x76CxGA2CCvBGMMiDYJDvU6G1KQBzZSJ6/HPxrbNRcW9CA9mpf47KHgjmugXlsQwVmtJOIYhbivRHIB/qdK62Gl9T6ynuFmgwA6nxg9Ean2uCbC2Y7W3wRSerwgu8NRLfSMLrRWjNVxm15l69epEmsxjl8wYE8elGvkF+T9JQB9K5Duk8bdQQtOGEm/B8C57Px3NqkMPNaCETi5JEWT8NgX+CmcBHek/SO2Q2Id+TrXxrq1ahU78Xol63USNHfY/D/+OtpJFpgI5yEvz4lttRRtUooxXhZ/j/z/j/YlzvAg/8vSnoQZnsQH63UV1CqNLhy3fon2/atCmnT58+y3Hrgxy5NmzNhQQYPqItXcG0+/TaQhgasyZtehkBATUKBfER0l4KQXO23gwFMUci/CEU5jv4PWtVgOlpBJahAAYhPWKwslTpzTaogaBMrgYTvYeGuQQ0nx+ukL179+ajZyKtbOe+ffvGhXspq0B6LyHd71Ced+CexirmW4mP+qLTxB2g8/J4movWU35rJe3q6moHNOUZqO//7NmzZ7RReOlBjI3LDn0YjXlBW7sc8VcSw8fLC8+fwHvnoByG4Vv6Ex+avLaXxsJonAcCn0zECFNSMx2H4XovjV8iyJIACwPpv0HjYanGzzQ2b948EULhVBrSgPLyCdrNlyjbE1BGs0FnOa6PkZbTVCBrBPkW43Yc6qpMrw337t17EujsB3qu0nf4IQGGSJ/jIfUykyBgbjeOc6WL8vLyf8J2nQoCH546depNCJqnERwyHYkEFOA1YMqB6eSD7wirnc1xqK0lUEMCvWR+TUFljUHQIgqHwHoMl66oqHPSHRyHZjMRgqIvqfDIY3OyvSmZPBBaNL7yKurki3RoMMPpp58+ADT9hgRCx44dPVbj4zuuoCt451HcJ3wf783Ce0MhQCiemQAj4bIa9fE8M5iSYdMRzz5Zv359CcrzSav0GmB2onmzgMaQoOFegk50M+r5r/jWQfjWl/BoN0zua+KNWWcLMP3HgIYvyJT88ccfTyD+0CY5JqMOXtZrioRQ7W/btm1Fjx49yIy7FWbY2SD8WVT6myUlJZ9lSpjRBADSLqZxAGhdr5BJQONiND5Gsylkl6cr7fFxhXQF7Z/hgzNBdlaBHvB+9IAXogweQZm8gUZzDL7hOjyaDfrfSTd90mzAlJeAITYivZfBHP2SmYXNzc09ETQ5UB9vUseWaSDN/nRF+uvwvakkcQqYeT80hk3J8MwDDzzwMXjvAEug+aDR3gLBPVBvSmqmYx5ZEVSeyDcVekNAGiehjjtTQ0w5kWiINFse6yHN0qNtxdSQoc18h/ZC5nwFvvUfCGqF/0+P5XWQbdB4L/K/AXVQ1qFDh1lbt269E7KJTMefwctjjUMFIQFG40iosDOhPk/Cv2PwMQ8RY6HS96EAXkWCC1Dw76VDGAlCNNKRYLrPyJTE/XW4/oXSR9pL0hFeZB9DxaTBwlGk+iLdr9Oh1QqQJ5lCMcczwLQXo3fbbPaMekDgapqVAs00o0NH2m/57rvvJnfr1i0j9BFDgIY/gSGqZVl+CWV1DuUbLw65FWi3/zE+01xLnjOGA6TFX5cMTeFBZ9S5qdtLEiBXix+T1RCI91BHlFfbeO9RowWvjwbvv4K28CDS/1AzHccb3WisAnVwJurgWdDhQRnOhVBJJ7lG0MQXeOdfsZ4jP9Kgj4+XBuiqwnuvI63zadzVOEva1KBxX9BBQmsshBe1iV6ogyKzyYRG/VsbuJxSXV1932mnnXYqKu4Mze/nYnwQjTfMQ8Lj0lEraUqfmAFpk78LTe3WQoUdbSVN0EJCL9w7kHrQGlrMSTSOR7NZMH9GtW7dOlUSUwH1bjEFJmiKO/FBDQPfPxll8ihNwYPRCxP5PVkFyuxDmnZGHgtRVo8gaFyCKOH8c40PNL+77Ybgc/CrtUBSaJAc9KRaURTf0swbQDPcCWdzweOv4RvJJeRm8NRI3L+3evXq2SNGjEg2n0GIU6n7nyRVT3JvISFKDTGeRmQVSNOLX8yOgzTVRNYIhPZ5qItz8S4JCPJ7XEQ8kykaU8GBAwfGoR0PRB1cQPWBb1hr9l7UAII2oLpe+5WQcysKfBkSugXaAjlGlqZDGApmEQpsCNI7D5V5JbSCqF4+AXqw/zYwQmhPf6S5CsLgtaa221G4T8dzozCbFDEC5vXj0HZn4PadWNpaugCNi0Ar+cPdqg3qPx/rXZTlV6SB492onltrfH/Sh9HssRVa8P4WMk2RB5l0qfT2/0T8y4g3yQRK9DI5yuK9w3Eb5Utmhp9//vnOdu3a/RG3HWhs1sz5NA4k/Nrp/ieB/wHKfFYmnH5NUItyfDHVyFrZkEb9Me6Hoj1+BGHxIhSBvtrsabPgsMMOO4Ayewb1fB8E6hy0C9P3Eo6AEoPgw0jQfI+CuoClKcBIwCC90Uj3vniNKBbIHNL7gYVBvUxTzphkEpqJQ8RbaSiWAdN0bNeuXY8HUzyFzugfsQbAS0pKPodApY5lGEzOCYlMTqvweDyvwxzwk8lJ2rjVTgdltYYEGIQp+S9NTfQ+3iPPc5owKktmbJTchfDutYhyNOJutUIbUGX0AyOQwE51Rjlb0GaDV+LWAUEd8v9E+7qMxl/z8/NpRr+4OQbydQg1aK1tmCLEwRAoXdA7/BLL4XHv3r37OnfunLEP0Zz4klqGYyNzINMUGI5GuRHCi1YlmI7raC4p88DAD/bu3XsigmZmkg5yywBTLkGjvgkN5lam8xI3gpaODBkyhNwf3g+Hffrpp2UwhWkFwp0QxKvjaa1omMfg+UQaCyorKytP1hTUJlHeSfabWiIgvKheT0ddDw8v9aOZavxPHgMPom7IF+vR5qQxERw7duyQ0Cu/3qlTpzoQfB0+YKPxpSOOOIJmKDmticrGrJSNpgP5VUG7uIy821GXPWgMxey9bdu2ze7Ro8cIvDMd9e7TxoGiNESkdS7SIrM+5kCyGfbt2zepoKCA/LPI10hav379o0Z/MDJvhg8fTub5eeDNU8KDy6QR4v+REDLrEPY30HAljV2Z0DYQwos0DE5+dxZNwf9paOuUJ+CWxrYjfCZnzJjxEDRwcnUhIfZ+vKU9zQ0H9cpkn4NY+pANYGgaxHyLNcxA5dNYFTkBIuxtMPELFgYzf3VARdytreczBSprqZl5+r8GWs6COp9CDBrrHZqZrqmpuSA3N7ec/PcgSEYhzl/x6Etaj4q4PfErRlqn4n9aanRbMj5ZYdBYEATUIAjUNbQcCdoAeeWTLxZ53NP6uZPwjNbZ5iBsgnFmjPzotMX/L4GGV/HO+8S7ePQjfh0Q/48IH4Cw3TRI3RLqFd9CazcvjvUc5b8C3/SWIditTTrEwx6Ux+TwP+Fxbdxu/OabbyagriNeJg0cdT8Sdb9ZGw/r05zjYfEQ4jgU3LMej+c12MCkzl+M/y8Mv4DC2Ynf9J07d86K0YORI+KBGOkrNAtigZ5ael9bmGuEX0srlV40HPe8eC/hu2nHBz2j12nxzMaA6rVn/hToMQOVYVJOncg3Jl1UdtqzuLOCEACz0LBpQfmAWO/Q2sLq6uoBZ5xxBo0h0c4H5IkuaPlQPWxEoxr75ZdfLtWW/lgCjasi/VOR/nW01hZBdwqaYxjSpwazCp3KQ7FcGEgDQ0PrhYZGps7F+l01EP9r0HYfns9N4NNEHbWVMT4yf7fDNI0aUqFwpi3atwJyrdDi9tR+pkAZrTME0awq0T8oQRbbwzea8/gsopPGvbSleFGguqeF5yjjZTD5pyPoFgPNlGYy33qQ3tVmr61ivxY3Zv00dpnaguEp9Pvll19a4f82dXV1HvoQMhtj+SVpjBfrGamoE5LV2lC4L+DygllPTktCcHki1mxEgnTJGTGhB7VxgJd6dlwKzAZ+tSUpBZkyqbUlKklBU/lNB6RBFy1wNqVZD21w9tJEeWlmHfXwi2mZExCiEybgd+QlTd8P4ZUs6bHSp1UIi2jbHpiVbcF33vDypUT1rfkGkf/iJFrgDa2ttdfr3U8aHtEG4RU3Pt4ptEIv3r8Hl3uM36x17kdaSSsM1FUFLhVJ5G38P2oRdiJo9X4Z3SfyRdN4yfSbkHdS34q2RyttlqbYbmnFzrx4dP4/XznMM2+LQnYAAAAASUVORK5CYII=" alt="VARASYS — Simplifying Complexity" />
<img class="brand-logo brand-light" src="data:image/png;base64,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" alt="VARASYS — Simplifying Complexity" />
</a>
<h1 style="margin:0">Stackable Metronome <span class="lane-meta" id="appVersion" title="build version">v0.0.1-dev</span></h1>
</div>
<div style="display:flex; align-items:center; gap:10px">
<button id="themeBtn" title="toggle light / dark theme"></button>
<button id="helpBtn" title="keyboard shortcuts (?)">?</button>
</div>
</div>
<div class="kbd-legend" style="margin-bottom:12px">Space play · T tap · ↑↓ tempo (⇧×10) · A add · N next · ? help</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">&nbsp;</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>
</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 &amp; 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 &amp; 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 19</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 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 selected.
const saveTip = it
? "Save the current settings to “" + it.name + "” (set-list item " + (activeItem + 1) + ")"
: "Select a set-list item to enable Save — it overwrites that item with your current settings";
$("saveItemBtn").title = $("saveItemWrap").title = saveTip;
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 &amp; 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>