Add stackable metronome mockup and Caddy deploy script

index.html: single-file browser mockup of the polymetric groove trainer —
stackable meter lanes, subdivisions, odd-meter grouping, per-beat patterns,
GM percussion voices, true ratio polyrhythm (poly toggle), presets, set
lists with a recordable practice log, and keyboard shortcuts.

deploy.sh: copies index.html into the Caddy web root that serves
https://metronome.varasys.io (no restart needed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-05-24 14:25:42 -05:00
commit 49c8584c8c
2 changed files with 845 additions and 0 deletions

35
deploy.sh Executable file
View file

@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Deploy the metronome mockup to the Caddy web root that serves
# https://metronome.varasys.io
#
# Caddy config: /var/lib/caddy/Caddyfile (metronome.varasys.io:8443 block)
# Bind-mount: /etc/containers/systemd/caddy.container
#
# The web root is bind-mounted read-only into the Caddy container and
# served by file_server, which picks up changes immediately — so a plain
# file copy is all that's needed (no container restart).
set -euo pipefail
SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DEST_DIR="/var/lib/caddy/www/metronome"
[[ -f "$SRC_DIR/index.html" ]] || { echo "error: $SRC_DIR/index.html not found" >&2; exit 1; }
[[ -d "$DEST_DIR" ]] || { echo "error: web root $DEST_DIR is missing — is Caddy set up?" >&2; exit 1; }
cp "$SRC_DIR/index.html" "$DEST_DIR/index.html"
echo "deployed index.html ($(stat -c '%s' "$DEST_DIR/index.html") bytes) -> $DEST_DIR"
# If real audio samples are added later (see the plan's GM-sample note),
# sync that directory too.
if [[ -d "$SRC_DIR/samples" ]]; then
rsync -a --delete "$SRC_DIR/samples/" "$DEST_DIR/samples/"
echo "synced samples/ -> $DEST_DIR/samples"
fi
# Smoke test: Caddy serves on :8443 with tls internal; resolve the host
# to localhost so SNI matches the site block.
if command -v curl >/dev/null 2>&1; then
code=$(curl -sk --resolve metronome.varasys.io:8443:127.0.0.1 \
https://metronome.varasys.io:8443/ -o /dev/null -w '%{http_code}' || echo "??")
echo "smoke test: metronome.varasys.io -> HTTP $code"
fi

810
index.html Normal file
View file

@ -0,0 +1,810 @@
<!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>
<!--
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; --panel:#1d222b; --panel-2:#242b36; --edge:#333d4b;
--txt:#c7d0db; --muted:#7f8b9a; --hot:#ffd166; --led-off:#2b323d;
}
* { box-sizing: border-box; }
body {
margin:0; padding:24px;
background: radial-gradient(circle at 50% -10%, #1b212b, 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:11px; font-family:"Courier New",monospace; text-align:right; }
.device { max-width:1000px; margin:0 auto; background:linear-gradient(180deg,var(--panel),#171c24);
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:40px; color:var(--hot); letter-spacing:3px; text-shadow:0 0 12px rgba(255,209,102,.5); }
.display .ctx { font-family:"Courier New",monospace; font-size:12px; color:#4dd0e1; height:15px; }
.knob { margin-bottom:10px; }
.knob label { display:flex; justify-content:space-between; font-size:12px; margin-bottom:5px; }
.knob label b { color:#fff; font-variant-numeric:tabular-nums; }
input[type=range] { width:100%; accent-color:var(--hot); }
.btnrow { display:flex; gap:10px; flex-wrap:wrap; }
button { background:var(--panel); color:var(--txt); border:1px solid var(--edge); border-radius:8px; padding:9px 14px; font-size:13px; cursor:pointer; transition:.12s; }
button:hover { border-color:var(--muted); }
button.primary { background:#2e7d32; border-color:#2e7d32; color:#fff; font-weight:600; }
button.primary.on { background:#c0392b; border-color:#c0392b; }
button.add { background:#2c3a4d; border-color:#3b5168; color:#cfe3ff; font-weight:600; }
.seg { display:inline-flex; border:1px solid var(--edge); border-radius:8px; overflow:hidden; }
.seg button { border:none; border-radius:0; padding:8px 10px; }
.seg button.active { background:var(--hot); color:#000; }
input[type=text].txt { background:var(--panel); color:var(--txt); border:1px solid var(--edge); border-radius:8px; padding:8px; font-size:13px; font-family:"Courier New",monospace; }
.checkrow, .mini-check { display:flex; align-items:center; gap:8px; font-size:13px; }
.mini-check { color:var(--muted); }
/* LED strip */
.strip { display:flex; gap:6px; flex-wrap:wrap; }
.led { width:30px; height:30px; border-radius:7px; background:var(--led-off); border:1px solid #000; position:relative;
display:flex; align-items:center; justify-content:center; font-size:9px; color:#4a5562; cursor:default; transition:background .04s, box-shadow .04s; }
.led.on { background:var(--lc,#888); box-shadow:0 0 8px var(--lc); color:rgba(0,0,0,.55); }
.led.accent { box-shadow:0 0 4px #fff, 0 0 14px var(--lc); }
.led.accent::after { content:"▲"; position:absolute; top:-1px; font-size:7px; color:#fff; }
.led.playhead { outline:2px solid #fff; outline-offset:1px; }
.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; }
.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); }
.ex-item .nm { flex:1; }
.ex-item .meta { color:var(--muted); font-family:"Courier New",monospace; font-size:11px; }
.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:3px; }
.log-seg { font-size:12px; color:var(--muted); margin:2px 0 0 12px; font-family:"Courier New",monospace; }
.practice { border-top:1px solid var(--edge); margin-top:16px; padding-top:4px; }
#routineToggle { position:fixed; top:16px; right:16px; z-index:40; background:#2c3a4d; border-color:#3b5168; color:#cfe3ff; font-weight:600; }
#routineTray { position:fixed; top:0; right:0; height:100%; width:380px; max-width:92vw; background:linear-gradient(180deg,var(--panel),#171c24); border-left:1px solid var(--edge); box-shadow:-12px 0 40px rgba(0,0,0,.55); transform:translateX(102%); transition:transform .25s ease; z-index:60; padding:18px; overflow:auto; }
#routineTray.open { transform:translateX(0); }
.tray-head { display:flex; align-items:center; justify-content:space-between; margin-bottom:14px; }
.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); }
.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:#fff; }
.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; }
</style>
</head>
<body>
<div class="device">
<div class="row" style="align-items:baseline; justify-content:space-between; gap:14px; margin-bottom:12px">
<h1 style="margin:0">Stackable Metronome <span class="lane-meta">mockup</span></h1>
<div style="display:flex; align-items:center; gap:10px">
<span class="kbd-legend">Space play · T tap · ↑↓ tempo (⇧×10) · A add · R set&nbsp;lists · N next · ? help</span>
<button id="helpBtn" title="keyboard shortcuts (?)">?</button>
</div>
</div>
<!-- Transport: display + preset/tempo/volume + practice, in three columns -->
<div class="row">
<div class="card" style="flex:1">
<div class="row" style="gap:22px; align-items:flex-start">
<div style="flex:0 0 190px; min-width:170px">
<div class="display">
<div class="big" id="bpmDisplay">120</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></div>
</div>
<div style="flex:1; min-width:200px">
<div class="knob" style="margin-bottom:10px">
<label>Preset</label>
<div style="display:flex; gap:6px">
<select class="cmp" id="presetSelect" style="flex:1"></select>
<button class="x" id="delPresetBtn" title="delete selected preset" style="margin-left:0"></button>
</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 style="flex:1; min-width:215px; border-left:1px solid var(--edge); padding-left:18px">
<div class="checkrow" style="margin-bottom:8px"><input type="checkbox" id="trainerOn"><label for="trainerOn">Gap / mute trainer</label></div>
<div class="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 class="checkrow" style="margin:10px 0 8px"><input type="checkbox" id="rampOn"><label for="rampOn">Tempo ramp</label></div>
<div class="row" style="gap:14px; align-items:center">
<label style="font-size:12px"><input type="number" class="num" id="rampAmt" min="-10" max="10" value="2"> 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>
</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">Click a beat pad to toggle it (rest) — e.g. snare on 2 &amp; 4</span>
<button class="add" id="addMeterBtn">+ Add meter</button>
</div>
<div id="meters"></div>
<!-- (presets moved into the Transport card; set lists live in the slide-out tray) -->
<div style="height:14px"></div>
<div class="sub" id="statusLine">Stopped.</div>
</div>
<!-- Routine slide-out tray (from the right) -->
<button id="routineToggle">☰ Routine &amp; Log</button>
<aside id="routineTray">
<div class="tray-head"><h2 style="margin:0">Set Lists</h2><button class="x" id="routineClose" style="margin-left:0"></button></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>
<div id="itemList"></div>
<div class="hint" style="margin-top:6px">▶ loads &amp; starts an item (one click) · press <kbd>N</kbd> to advance to the next.</div>
<div class="tray-head" style="margin-top:22px"><h2 style="margin:0">Log &amp; Backup</h2></div>
<div class="lane-row" style="margin-bottom:8px; flex-wrap:wrap">
<button id="exportBtn">⭳ Export all</button>
<button id="importBtn">⭱ Import…</button>
<input type="file" id="importFile" accept="application/json" style="display:none">
<button id="clearLogBtn">Clear log</button>
</div>
<div class="hint" style="margin:0 0 8px">Export/Import covers presets, set lists &amp; logs.</div>
<div id="logView"></div>
</aside>
<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>Start / stop</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>R</kbd></td><td>Set lists tray</td></tr>
<tr><td><kbd>N</kbd></td><td>Next set-list item</td></tr>
<tr><td><kbd>1</kbd><kbd>9</kbd></td><td>Mute 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>
</div>
<script>
"use strict";
/* =========================================================================
STATE
========================================================================= */
const state = { bpm: 120, volume: 0.7, running: false };
const trainer = { on: false, playBars: 2, muteBars: 2 };
const ramp = { on: false, amount: 2, 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);
if (onBeat) m.vq.push({ time, beat: beatIndex, bar: Math.floor(m.tick / barLen) }); // playhead + measure (advance even when muted)
if (m.mute || isMutedAt(time)) return;
if (!m.beatsOn[beatIndex]) return; // beat toggled off → rest (incl. its subdivisions)
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;
const t0 = audioCtx.currentTime + 0.08;
for (const m of meters) { m.tick = 0; m.nextTime = t0; m.vq = []; m.vqPtr = 0; m.currentBeat = -1; m.currentBar = 0; }
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.currentBeat = -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, mute: false, poly: !!poly, color: laneColor(id),
beatsOn: beatsOn ? beatsOn.slice() : [], // per-beat on/off mask (rests)
tick: 0, nextTime: 0, vq: [], vqPtr: 0, currentBeat: -1, currentBar: 0,
el: null, stripEl: null, barEl: null,
};
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 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="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}_preset" title="time-signature presets">
<option value="">sig…</option>
<option value="4">4/4</option><option value="3">3/4</option><option value="3+3">6/8</option>
<option value="2+3">5/8</option><option value="2+2+3">7/8</option><option value="3+2+2">7/8b</option>
</select>
<select class="cmp" id="m${m.id}_sub" title="subdivision (clicks per beat)">
<option value="1">♩ quarter</option><option value="2">♪ eighth</option>
<option value="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>
<label class="mini-check"><input type="checkbox" id="m${m.id}_mute"> mute</label>
<button class="x" id="m${m.id}_remove" title="remove lane"></button>
</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 preset = $c(`#m${m.id}_preset`);
preset.addEventListener("change", (e) => {
if (!e.target.value) return;
m.groupsStr = e.target.value; $c(`#m${m.id}_group`).value = e.target.value; e.target.value = ""; recomputeLane(m);
});
const sub = $c(`#m${m.id}_sub`); sub.value = String(m.stepsPerBeat);
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);
$c(`#m${m.id}_mute`).addEventListener("change", (e) => m.mute = e.target.checked);
$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;
const prev = m.beatsOn || []; // resize mask, preserve, default new beats on
m.beatsOn = [];
for (let b = 0; b < m.beatsPerBar; b++) m.beatsOn[b] = (b < prev.length) ? !!prev[b] : true;
m.el.querySelector(`#m${m.id}_sum`).textContent = "=" + m.beatsPerBar;
buildLaneStrip(m);
}
function buildLaneStrip(m) {
m.stripEl.innerHTML = "";
for (let b = 0; b < m.beatsPerBar; b++) {
const cell = document.createElement("div");
cell.className = "led"; cell.textContent = b + 1;
cell.style.cursor = "pointer"; cell.title = "toggle beat " + (b + 1);
cell.addEventListener("click", () => { m.beatsOn[b] = !m.beatsOn[b]; renderLaneStrip(m); });
m.stripEl.appendChild(cell);
}
}
function renderLaneStrip(m) {
const cells = m.stripEl.children;
for (let b = 0; b < cells.length; b++) {
const cell = cells[b];
const on = m.beatsOn[b], gs = m.groupStarts.has(b);
let cls = "led"; 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 && b === m.currentBeat) 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" };
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, mute: m.mute, 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];
m.mute = !!c.mute;
const cb = m.el.querySelector(`#m${m.id}_mute`); if (cb) cb.checked = m.mute;
}
}
function savePreset(name) {
const all = lsGet(LS.presets, {});
all[name] = { bpm: state.bpm, lanes: snapshotLanes() };
lsSet(LS.presets, all); refreshPresetList(name);
}
function loadPreset(name) {
const all = lsGet(LS.presets, {}); const p = all[name]; if (!p) return;
const wasRunning = state.running; if (wasRunning) stop();
setBpm(p.bpm); applyLanes(p.lanes); updateCtx();
if (wasRunning) start();
}
function deletePreset(name) { const all = lsGet(LS.presets, {}); delete all[name]; lsSet(LS.presets, all); refreshPresetList(); }
function refreshPresetList(sel) {
const all = lsGet(LS.presets, {}); const list = $("presetSelect");
list.innerHTML = '<option value="">— preset —</option><option value="__save__"> Save current as…</option>';
Object.keys(all).sort().forEach((n) => { const o = document.createElement("option"); o.value = n; o.textContent = n; list.appendChild(o); });
list.value = sel || "";
}
/* =========================================================================
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; // index of the selected set list
let playingItem = -1; // index of the item currently playing in the active set list
let nowPlaying = null; // { at, name } for duration logging
function currentSetup() { return { bpm: state.bpm, lanes: snapshotLanes() }; }
function applySetup(s) { setBpm(s.bpm); applyLanes(s.lanes); updateCtx(); }
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; playingItem = -1; saveSetlists(); renderSetlists();
}
function deleteSetlist() {
if (!setlists.length || !confirm("Delete this set list?")) return;
setlists.splice(activeSL, 1); activeSL = Math.max(0, activeSL - 1); playingItem = -1; saveSetlists(); renderSetlists();
}
function addItem(name) {
const sl = getSL(); if (!sl) return;
sl.items.push({ name: name || ("Item " + (sl.items.length + 1)), ...currentSetup() });
saveSetlists(); renderItems();
}
function removeItem(i) { const sl = getSL(); sl.items.splice(i, 1); if (playingItem >= sl.items.length) playingItem = -1; 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(); renderItems(); }
// --- play / advance ---
function playItem(i) {
const sl = getSL(); if (!sl || !sl.items[i]) return;
logFinalize(); // close out the previously-playing item
applySetup(sl.items[i]);
if (!state.running) start();
playingItem = i;
nowPlaying = { at: Date.now(), name: sl.items[i].name };
renderItems();
}
function nextItem() { const sl = getSL(); if (sl && playingItem + 1 < sl.items.length) playItem(playingItem + 1); }
// Start/stop button + Space route through here so internal restarts don't log.
function toggleTransport() {
if (state.running) { logFinalize(); playingItem = -1; renderItems(); stop(); }
else start();
}
// --- 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 the current settings as items.</div>'; return; }
if (!sl.items.length) { box.innerHTML = '<div class="hint">No items yet — set up the metronome and “Add current settings”.</div>'; return; }
sl.items.forEach((it, i) => {
const row = document.createElement("div"); row.className = "ex-item";
if (i === playingItem) row.style.borderColor = "#2e7d32";
row.innerHTML = `<button class="play iconbtn" data-act="play" title="load &amp; start"></button>
<span class="nm">${i + 1}. ${it.name}</span>
<span class="meta">${it.bpm}bpm · ${it.lanes.map((l) => l.groupsStr).join("/")}</span>
<button class="iconbtn" data-act="up" title="up"></button>
<button class="iconbtn" data-act="down" title="down"></button>
<button class="x iconbtn" data-act="del" title="remove"></button>`;
row.querySelector('[data-act=play]').onclick = () => playItem(i);
row.querySelector('[data-act=up]').onclick = () => moveItem(i, -1);
row.querySelector('[data-act=down]').onclick = () => moveItem(i, 1);
row.querySelector('[data-act=del]').onclick = () => removeItem(i);
box.appendChild(row);
});
}
// --- 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();
}
function renderLog() {
const box = $("logView"); const logs = lsGet(LS.logs, []); box.innerHTML = "";
if (!logs.length) { box.innerHTML = '<div class="hint">No practice logged yet — play set-list items to record what you do.</div>'; return; }
logs.forEach((e) => {
const div = document.createElement("div"); div.className = "log-item";
div.innerHTML = `<div class="log-head">${new Date(e.at).toLocaleString()} — ${e.name}</div>
<div class="log-seg">${fmtDur(e.durationSec)} @ ${e.bpm}bpm · ${(e.lanes || []).map((l) => l.groupsStr + "/" + l.sound).join(", ")}</div>`;
box.appendChild(div);
});
}
function clearLog() { if (confirm("Clear the practice log? (set lists & presets are kept)")) { lsSet(LS.logs, []); renderLog(); } }
// --- 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; playingItem = -1; }
if (d.logs) lsSet(LS.logs, d.logs);
refreshPresetList(); 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);
}
/* =========================================================================
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.currentBeat = m.vq[m.vqPtr].beat; 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);
requestAnimationFrame(drawLoop);
}
function updateCtx() {
ctxDisplay.textContent = meters.length ? `${meters.length} meter${meters.length > 1 ? "s" : ""}` : "no meters";
}
function updateStatus() {
if (!state.running) { statusLine.textContent = meters.length ? "Stopped." : "Add a meter to begin."; return; }
const mbpb = masterBeatsPerBar();
const barIndex = Math.floor(Math.max(0, masterBeat - 1) / mbpb);
let s = `Running · bar ${barIndex + 1} · ${meters.map((m) => m.groups.join("+")).join(" vs ")}`;
if (trainer.on) {
const muted = isMutedAt(audioCtx.currentTime);
s += muted ? " — TRAINER: muted (count!)" : " — TRAINER: playing";
}
if (ramp.on) s += ` — ramp ${ramp.amount >= 0 ? "+" : ""}${ramp.amount}/${ramp.everyBars} bars`;
statusLine.textContent = s;
}
/* =========================================================================
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; }
$("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);
$("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);
$("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);
$("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"));
$("presetSelect").addEventListener("change", (e) => {
const v = e.target.value;
if (v === "__save__") { const n = (prompt("Save current settings as preset:") || "").trim(); if (n) savePreset(n); else refreshPresetList(); }
else if (v) loadPreset(v);
});
$("delPresetBtn").addEventListener("click", () => { const n = $("presetSelect").value; if (n && n !== "__save__" && confirm('Delete preset "' + n + '"?')) deletePreset(n); });
$("routineToggle").addEventListener("click", () => $("routineTray").classList.toggle("open"));
$("routineClose").addEventListener("click", () => $("routineTray").classList.remove("open"));
$("newSetlistBtn").addEventListener("click", newSetlist);
$("delSetlistBtn").addEventListener("click", deleteSetlist);
$("setlistSelect").addEventListener("change", (e) => { activeSL = +e.target.value; playingItem = -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", exportAll);
$("importBtn").addEventListener("click", () => $("importFile").click());
$("importFile").addEventListener("change", (e) => { if (e.target.files[0]) importAll(e.target.files[0]); e.target.value = ""; });
$("clearLogBtn").addEventListener("click", clearLog);
window.addEventListener("keydown", (e) => {
const t = e.target;
if (t && (t.tagName === "INPUT" || t.tagName === "SELECT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
const k = e.key;
if (e.code === "Space") { e.preventDefault(); toggleTransport(); }
else if (k === "t" || k === "T") { tapTempo(); }
else if (k === "ArrowUp") { e.preventDefault(); setBpm(state.bpm + (e.shiftKey ? 10 : 1)); }
else if (k === "ArrowDown") { e.preventDefault(); setBpm(state.bpm - (e.shiftKey ? 10 : 1)); }
else if (k === "a" || k === "A") { addMeter("4", 1, "claves"); }
else if (k === "r" || k === "R") { $("routineTray").classList.toggle("open"); }
else if (k === "n" || k === "N") { nextItem(); }
else if (k === "?") { toggleShortcuts(true); }
else if (k === "Escape") { if (!$("shortcutsOverlay").hidden) toggleShortcuts(false); else $("routineTray").classList.remove("open"); }
else if (k >= "1" && k <= "9") {
const m = meters[+k - 1];
if (m) { m.mute = !m.mute; const cb = m.el.querySelector(`#m${m.id}_mute`); if (cb) cb.checked = m.mute; }
}
});
/* init: two example lanes (4/4 vs 3/4) to show polymeter immediately */
addMeter("4", 1, "kick"); // reference bar
addMeter("5", 1, "claves", null, true); // 5 fit into the bar → true 5:4 polyrhythm
refreshPresetList();
renderSetlists();
renderLog();
updateCtx();
requestAnimationFrame(drawLoop);
</script>
</body>
</html>