metronome/index.html
Me Here 49c8584c8c 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>
2026-05-24 14:25:42 -05:00

810 lines
45 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>
<!--
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>