Add stackable metronome mockup and Caddy deploy script
index.html: single-file browser mockup of the polymetric groove trainer — stackable meter lanes, subdivisions, odd-meter grouping, per-beat patterns, GM percussion voices, true ratio polyrhythm (poly toggle), presets, set lists with a recordable practice log, and keyboard shortcuts. deploy.sh: copies index.html into the Caddy web root that serves https://metronome.varasys.io (no restart needed). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
49c8584c8c
2 changed files with 845 additions and 0 deletions
35
deploy.sh
Executable file
35
deploy.sh
Executable file
|
|
@ -0,0 +1,35 @@
|
|||
#!/usr/bin/env bash
|
||||
# Deploy the metronome mockup to the Caddy web root that serves
|
||||
# https://metronome.varasys.io
|
||||
#
|
||||
# Caddy config: /var/lib/caddy/Caddyfile (metronome.varasys.io:8443 block)
|
||||
# Bind-mount: /etc/containers/systemd/caddy.container
|
||||
#
|
||||
# The web root is bind-mounted read-only into the Caddy container and
|
||||
# served by file_server, which picks up changes immediately — so a plain
|
||||
# file copy is all that's needed (no container restart).
|
||||
set -euo pipefail
|
||||
|
||||
SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
DEST_DIR="/var/lib/caddy/www/metronome"
|
||||
|
||||
[[ -f "$SRC_DIR/index.html" ]] || { echo "error: $SRC_DIR/index.html not found" >&2; exit 1; }
|
||||
[[ -d "$DEST_DIR" ]] || { echo "error: web root $DEST_DIR is missing — is Caddy set up?" >&2; exit 1; }
|
||||
|
||||
cp "$SRC_DIR/index.html" "$DEST_DIR/index.html"
|
||||
echo "deployed index.html ($(stat -c '%s' "$DEST_DIR/index.html") bytes) -> $DEST_DIR"
|
||||
|
||||
# If real audio samples are added later (see the plan's GM-sample note),
|
||||
# sync that directory too.
|
||||
if [[ -d "$SRC_DIR/samples" ]]; then
|
||||
rsync -a --delete "$SRC_DIR/samples/" "$DEST_DIR/samples/"
|
||||
echo "synced samples/ -> $DEST_DIR/samples"
|
||||
fi
|
||||
|
||||
# Smoke test: Caddy serves on :8443 with tls internal; resolve the host
|
||||
# to localhost so SNI matches the site block.
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
code=$(curl -sk --resolve metronome.varasys.io:8443:127.0.0.1 \
|
||||
https://metronome.varasys.io:8443/ -o /dev/null -w '%{http_code}' || echo "??")
|
||||
echo "smoke test: metronome.varasys.io -> HTTP $code"
|
||||
fi
|
||||
810
index.html
Normal file
810
index.html
Normal file
|
|
@ -0,0 +1,810 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Stackable Metronome — Mockup</title>
|
||||
<!--
|
||||
Browser mockup / simulator for the Pi Pico metronome.
|
||||
|
||||
DESIGN: a basic metronome (tempo / volume / start-stop) PLUS an arbitrary
|
||||
number of "meter lanes". Each lane is its own little metronome with a
|
||||
grouping (e.g. 2+2+3), a subdivision, and a sound. All lanes share the global
|
||||
tempo; layering lanes with different groupings/subdivisions is what creates
|
||||
polymeter / polyrhythm — no special "voice" or ratio mode required.
|
||||
|
||||
Functions marked PORTS TO FIRMWARE carry over to the RP2040 with little change.
|
||||
Web Audio's look-ahead scheduler stands in for the hardware timer.
|
||||
-->
|
||||
<style>
|
||||
:root {
|
||||
--bg:#14171c; --panel:#1d222b; --panel-2:#242b36; --edge:#333d4b;
|
||||
--txt:#c7d0db; --muted:#7f8b9a; --hot:#ffd166; --led-off:#2b323d;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin:0; padding:24px;
|
||||
background: radial-gradient(circle at 50% -10%, #1b212b, var(--bg));
|
||||
color: var(--txt); font-family: "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
h1 { font-size:18px; font-weight:600; letter-spacing:.5px; margin:0 0 2px; }
|
||||
.sub { color:var(--muted); font-size:12px; margin-bottom:18px; }
|
||||
.kbd-legend { color:var(--muted); font-size:11px; font-family:"Courier New",monospace; text-align:right; }
|
||||
.device { max-width:1000px; margin:0 auto; background:linear-gradient(180deg,var(--panel),#171c24);
|
||||
border:1px solid var(--edge); border-radius:16px; padding:18px; box-shadow:0 18px 50px rgba(0,0,0,.5); }
|
||||
.row { display:flex; gap:18px; flex-wrap:wrap; }
|
||||
.card { background:var(--panel-2); border:1px solid var(--edge); border-radius:12px; padding:13px; flex:1; min-width:240px; }
|
||||
.card h2 { font-size:11px; text-transform:uppercase; letter-spacing:1.4px; color:var(--muted); margin:0 0 14px; }
|
||||
.display { background:#0a0d11; border:1px solid #000; border-radius:8px; padding:8px 14px; text-align:center; box-shadow:inset 0 2px 10px rgba(0,0,0,.7); }
|
||||
.display .big { font-family:"Courier New",monospace; font-weight:700; font-size:40px; color:var(--hot); letter-spacing:3px; text-shadow:0 0 12px rgba(255,209,102,.5); }
|
||||
.display .ctx { font-family:"Courier New",monospace; font-size:12px; color:#4dd0e1; height:15px; }
|
||||
.knob { margin-bottom:10px; }
|
||||
.knob label { display:flex; justify-content:space-between; font-size:12px; margin-bottom:5px; }
|
||||
.knob label b { color:#fff; font-variant-numeric:tabular-nums; }
|
||||
input[type=range] { width:100%; accent-color:var(--hot); }
|
||||
.btnrow { display:flex; gap:10px; flex-wrap:wrap; }
|
||||
button { background:var(--panel); color:var(--txt); border:1px solid var(--edge); border-radius:8px; padding:9px 14px; font-size:13px; cursor:pointer; transition:.12s; }
|
||||
button:hover { border-color:var(--muted); }
|
||||
button.primary { background:#2e7d32; border-color:#2e7d32; color:#fff; font-weight:600; }
|
||||
button.primary.on { background:#c0392b; border-color:#c0392b; }
|
||||
button.add { background:#2c3a4d; border-color:#3b5168; color:#cfe3ff; font-weight:600; }
|
||||
.seg { display:inline-flex; border:1px solid var(--edge); border-radius:8px; overflow:hidden; }
|
||||
.seg button { border:none; border-radius:0; padding:8px 10px; }
|
||||
.seg button.active { background:var(--hot); color:#000; }
|
||||
input[type=text].txt { background:var(--panel); color:var(--txt); border:1px solid var(--edge); border-radius:8px; padding:8px; font-size:13px; font-family:"Courier New",monospace; }
|
||||
.checkrow, .mini-check { display:flex; align-items:center; gap:8px; font-size:13px; }
|
||||
.mini-check { color:var(--muted); }
|
||||
/* LED strip */
|
||||
.strip { display:flex; gap:6px; flex-wrap:wrap; }
|
||||
.led { width:30px; height:30px; border-radius:7px; background:var(--led-off); border:1px solid #000; position:relative;
|
||||
display:flex; align-items:center; justify-content:center; font-size:9px; color:#4a5562; cursor:default; transition:background .04s, box-shadow .04s; }
|
||||
.led.on { background:var(--lc,#888); box-shadow:0 0 8px var(--lc); color:rgba(0,0,0,.55); }
|
||||
.led.accent { box-shadow:0 0 4px #fff, 0 0 14px var(--lc); }
|
||||
.led.accent::after { content:"▲"; position:absolute; top:-1px; font-size:7px; color:#fff; }
|
||||
.led.playhead { outline:2px solid #fff; outline-offset:1px; }
|
||||
.led.groupstart { margin-left:16px; }
|
||||
.led.groupstart::before { content:""; position:absolute; left:-9px; top:4px; bottom:4px; width:2px; background:var(--muted); }
|
||||
/* meter lanes — compact single-row controls + strip */
|
||||
#meters { display:flex; flex-direction:column; gap:10px; }
|
||||
.meter-card { background:var(--panel-2); border:1px solid var(--edge); border-radius:12px; padding:9px 14px; }
|
||||
.lane-row { display:flex; gap:9px; align-items:center; flex-wrap:wrap; margin-bottom:0; }
|
||||
.lane-row .strip { margin-left:4px; }
|
||||
.lane-title { font-weight:700; font-size:13px; min-width:28px; }
|
||||
.txt.grp { width:80px; text-align:center; }
|
||||
.sum { font-family:"Courier New",monospace; font-size:12px; color:var(--muted); min-width:24px; }
|
||||
.bar { font-family:"Courier New",monospace; font-size:12px; color:var(--hot); min-width:46px; text-align:right; }
|
||||
.cmp { background:var(--panel); color:var(--txt); border:1px solid var(--edge); border-radius:8px; padding:7px 8px; font-size:12px; }
|
||||
.meter-card .led { width:26px; height:26px; border-radius:6px; }
|
||||
.x { background:transparent; border:1px solid var(--edge); color:var(--muted); padding:4px 9px; border-radius:7px; margin-left:auto; }
|
||||
.x:hover { color:#ff9a8a; border-color:#c0392b; }
|
||||
.hint { font-size:11px; color:var(--muted); margin-top:8px; }
|
||||
code { background:#0d1014; padding:1px 5px; border-radius:4px; color:#cfe3ff; }
|
||||
.ex-item { display:flex; gap:8px; align-items:center; padding:7px 9px; border:1px solid var(--edge); border-radius:8px; margin-bottom:6px; font-size:13px; background:var(--panel); }
|
||||
.ex-item .nm { flex:1; }
|
||||
.ex-item .meta { color:var(--muted); font-family:"Courier New",monospace; font-size:11px; }
|
||||
.iconbtn { padding:3px 8px; font-size:12px; }
|
||||
.log-item { padding:8px 10px; border:1px solid var(--edge); border-radius:8px; margin-bottom:6px; background:var(--panel); }
|
||||
.log-head { font-weight:600; font-size:13px; margin-bottom:3px; }
|
||||
.log-seg { font-size:12px; color:var(--muted); margin:2px 0 0 12px; font-family:"Courier New",monospace; }
|
||||
.practice { border-top:1px solid var(--edge); margin-top:16px; padding-top:4px; }
|
||||
#routineToggle { position:fixed; top:16px; right:16px; z-index:40; background:#2c3a4d; border-color:#3b5168; color:#cfe3ff; font-weight:600; }
|
||||
#routineTray { position:fixed; top:0; right:0; height:100%; width:380px; max-width:92vw; background:linear-gradient(180deg,var(--panel),#171c24); border-left:1px solid var(--edge); box-shadow:-12px 0 40px rgba(0,0,0,.55); transform:translateX(102%); transition:transform .25s ease; z-index:60; padding:18px; overflow:auto; }
|
||||
#routineTray.open { transform:translateX(0); }
|
||||
.tray-head { display:flex; align-items:center; justify-content:space-between; margin-bottom:14px; }
|
||||
.num { width:54px; background:var(--panel); color:var(--txt); border:1px solid var(--edge); border-radius:8px; padding:6px 6px; font-size:13px; text-align:center; }
|
||||
#helpBtn { padding:4px 11px; }
|
||||
.overlay { position:fixed; inset:0; background:rgba(0,0,0,.55); display:flex; align-items:center; justify-content:center; z-index:80; }
|
||||
.overlay[hidden] { display:none; }
|
||||
.overlay-box { background:var(--panel-2); border:1px solid var(--edge); border-radius:14px; padding:16px 20px; width:380px; max-width:92vw; box-shadow:0 20px 60px rgba(0,0,0,.6); }
|
||||
.kbd-table { width:100%; border-collapse:collapse; font-size:13px; }
|
||||
.kbd-table td { padding:6px 4px; border-bottom:1px solid var(--edge); }
|
||||
.kbd-table tr:last-child td { border-bottom:none; }
|
||||
.kbd-table td:first-child { width:100px; white-space:nowrap; }
|
||||
kbd { background:var(--panel); border:1px solid var(--edge); border-bottom-width:2px; border-radius:5px; padding:2px 7px; font-family:"Courier New",monospace; font-size:12px; color:#fff; }
|
||||
.setlist-fields textarea { width:100%; background:var(--panel); color:var(--txt); border:1px solid var(--edge); border-radius:8px; padding:8px; font-size:12px; resize:vertical; min-height:40px; }
|
||||
.play { background:#2e7d32; border-color:#2e7d32; color:#fff; padding:3px 10px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="device">
|
||||
<div class="row" style="align-items:baseline; justify-content:space-between; gap:14px; margin-bottom:12px">
|
||||
<h1 style="margin:0">Stackable Metronome <span class="lane-meta">mockup</span></h1>
|
||||
<div style="display:flex; align-items:center; gap:10px">
|
||||
<span class="kbd-legend">Space play · T tap · ↑↓ tempo (⇧×10) · A add · R set 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"> </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 & 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 & 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 & 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 & 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 & 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 1–9</td></tr>
|
||||
<tr><td><kbd>?</kbd></td><td>This help</td></tr>
|
||||
<tr><td><kbd>Esc</kbd></td><td>Close tray / help</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</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 & 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>
|
||||
Loading…
Reference in a new issue