metronome/index.html
Me Here c0b6628488 Layout: dock the set-list panel beside the metronome (push, not overlay)
- App is now a flex shell (#app): the set-list panel docks as a sticky side
  column that pushes the metronome instead of floating over it.
- Collapses via the header 'Set Lists' toggle; falls back to an overlay drawer
  below 820px. Defaults open on desktop, closed on mobile.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 17:17:44 -05:00

908 lines
51 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>
<!--
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:11px; 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:40px; color:#ffd166; 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 var(--ring); 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 { background:#2c3a4d; border-color:#3b5168; color:#cfe3ff; font-weight:600; }
/* docked side panel: pushes the metronome (no overlap); sticky so it stays in view */
#routineTray { display:none; 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); }
#routineTray.open { display:block; }
/* narrow screens: fall back to an overlay drawer */
@media (max-width: 820px) {
#app { display:block; }
#routineTray { position:fixed; top:0; right:0; height:100%; max-height:none; width:min(360px,92vw);
border-radius:0; border:none; border-left:1px solid var(--edge); box-shadow:-12px 0 40px rgba(0,0,0,.45); z-index:60; }
}
.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; }
#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); }
.kbd-table { width:100%; border-collapse:collapse; font-size:13px; }
.kbd-table td { padding:6px 4px; border-bottom:1px solid var(--edge); }
.kbd-table tr:last-child td { border-bottom:none; }
.kbd-table td:first-child { width:100px; white-space:nowrap; }
kbd { background:var(--panel); border:1px solid var(--edge); border-bottom-width:2px; border-radius:5px; padding:2px 7px; font-family:"Courier New",monospace; font-size:12px; color:var(--txt); }
.setlist-fields textarea { width:100%; background:var(--panel); color:var(--txt); border:1px solid var(--edge); border-radius:8px; padding:8px; font-size:12px; resize:vertical; min-height:40px; }
.play { background:#2e7d32; border-color:#2e7d32; color:#fff; padding:3px 10px; }
.stop { background:#c0392b; border-color:#c0392b; color:#fff; padding:3px 10px; }
.menu { position:absolute; top:36px; right:0; background:var(--panel-2); border:1px solid var(--edge); border-radius:10px; padding:6px; display:flex; flex-direction:column; gap:4px; box-shadow:0 12px 30px rgba(0,0,0,.5); z-index:70; min-width:150px; }
.menu[hidden] { display:none; }
.menu button { text-align:left; }
</style>
</head>
<body>
<div id="app">
<div class="device">
<div class="row" style="align-items:baseline; justify-content:space-between; gap:14px; margin-bottom:12px">
<h1 style="margin:0">Stackable Metronome <span class="lane-meta" id="appVersion" title="build version">v0.0.1-dev</span></h1>
<div style="display:flex; align-items:center; gap:10px">
<span class="kbd-legend">Space play · T tap · ↑↓ tempo (⇧×10) · A add · R set&nbsp;lists · N next · ? help</span>
<button id="routineToggle" title="show / hide set lists (R)">☰ Set Lists</button>
<button id="themeBtn" title="toggle light / dark theme"></button>
<button id="helpBtn" title="keyboard shortcuts (?)">?</button>
</div>
</div>
<!-- Transport: display + preset/tempo/volume + practice, in three columns -->
<div class="row">
<div class="card" style="flex:1">
<div class="row" style="gap:22px; align-items:flex-start">
<div style="flex:0 0 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 class="practice-col" style="flex:1; min-width:215px">
<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: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>
</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>
</div>
<div id="meters"></div>
<div style="margin-top:10px"><button class="add" id="addMeterBtn">+ Add meter</button></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>
<!-- 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>
<button class="x" id="routineClose" title="close" style="margin-left:0"></button>
<div id="trayMenu" class="menu" hidden>
<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>
</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 · <kbd>N</kbd> advances · 💾 saves current settings back 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>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";
// 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);
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;
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.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] = currentSetup();
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();
applySetup(p);
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
let historyName = null; // name of the item whose past-session history is shown
function currentSetup() { return { bpm: state.bpm, lanes: snapshotLanes(), trainer: { ...trainer }, ramp: { ...ramp } }; }
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);
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;
}
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) stop(); // clean restart so ramp start-BPM + bar counter reset
start();
playingItem = i;
historyName = sl.items[i].name;
nowPlaying = { at: Date.now(), name: sl.items[i].name };
renderItems(); renderLog();
}
function nextItem() { const sl = getSL(); if (sl && playingItem + 1 < sl.items.length) playItem(playingItem + 1); }
function updateItem(i) { // load → adjust → save back to the item
const sl = getSL(); if (!sl || !sl.items[i]) return;
const nm = sl.items[i].name;
sl.items[i] = { name: nm, ...currentSetup() };
saveSetlists(); renderItems();
}
// 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";
const playing = (i === playingItem && state.running);
if (i === playingItem) row.style.borderColor = "#2e7d32";
row.innerHTML = `<button class="${playing ? "stop" : "play"} iconbtn" data-act="play" title="${playing ? "stop" : "load & start"}">${playing ? "⏹" : "▶"}</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="save" title="save current settings to this item">💾</button>
<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 = () => (i === playingItem && state.running) ? toggleTransport() : playItem(i);
row.querySelector('[data-act=save]').onclick = () => updateItem(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();
}
// 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 logs = lsGet(LS.logs, []).filter((e) => e.name === historyName);
let html = `<div class="log-head" style="margin-bottom:5px">History — ${historyName}</div>`;
if (!logs.length) html += '<div class="hint">No past sessions for this item yet.</div>';
else html += logs.map((e) => `<div class="log-seg">${new Date(e.at).toLocaleString()} · ${fmtDur(e.durationSec)} @ ${e.bpm}bpm</div>`).join("");
box.innerHTML = html;
}
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; }
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);
$("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);
$("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"));
$("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"));
$("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; 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", () => { $("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(); });
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();
$("appVersion").textContent = "v" + APP_VERSION;
if (window.innerWidth > 820) $("routineTray").classList.add("open"); // docked-open on desktop, closed on mobile
requestAnimationFrame(drawLoop);
</script>
</body>
</html>