Per-step pad grid (beats × subdivision); drop confusing Sig dropdown; help: repo link + offline note

- Pad row now shows beatsPerBar × subdivision pads, each individually
  toggleable (the subdivision control sets pad resolution). Subdivision
  pads render smaller; downbeats labeled; group/beat gaps preserved.
- Mask (beatsOn) is now per-step; playhead tracks the current step.
  recomputeLane remaps on grouping/subdivision change and migrates legacy
  per-beat masks (saved data, short share patterns) by expanding across subs.
- Share language: =pattern is now per-step (len = beats × sub); short
  per-beat patterns still accepted and expanded. README updated.
- Removed the Sig time-signature preset dropdown (confusing vs subdivision).
- Help dialog: link to git.varasys.io/VARASYS/metronome + note that it's a
  single-page app you can save & run offline, but file:// won't auto-save
  the set list (export a backup).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-05-25 08:01:17 -05:00
parent 61b5dfb6de
commit 9cb7c2c193
2 changed files with 73 additions and 40 deletions

View file

@ -58,10 +58,14 @@ Tokens are joined with `;`. `tr` and `rmp` are omitted when off.
`jamblock` (unknown → `beep`).
- **grouping** — beats per bar, optionally grouped for odd meters: `4`, `3`,
`2+2+3`. The first beat of each group is accented.
- **`/sub`** — subdivision (clicks per beat): `1` quarter (default), `2` eighth,
`3` triplet, `4` sixteenth, `6` sextuplet. Omit for quarter.
- **`=pattern`** — perbeat on/off as `x`/`.`, length = beats per bar. Omit = all on.
e.g. `=.x.x` puts a backbeat on 2 & 4.
- **`/sub`** — subdivision: `1` quarter (default), `2` eighth, `3` triplet,
`4` sixteenth, `6` sextuplet. This also sets how many **pads** each beat splits
into (a beat becomes `sub` individuallytoggleable steps). Omit for quarter.
- **`=pattern`** — per**step** on/off as `x`/`.`, length = beats per bar × `sub`
(one char per pad). Omit = all on. e.g. `4=.x.x` is a backbeat on 2 & 4;
`4/4=x..x..x.x...x...` is a sixteenthgrid pattern. A short pattern whose length
equals just the beat count is still accepted and expanded across each beat's
subdivisions (backcompat).
- **`~`** — polyrhythm: fit this lane's beats evenly into **lane 1's** bar.
- **`!`** — mute the lane.

View file

@ -83,6 +83,8 @@
.led.accent { box-shadow:0 0 4px #fff, 0 0 14px var(--lc); }
.led.accent::after { content:"▲"; position:absolute; top:-1px; font-size:7px; color:#fff; }
.led.playhead { outline:2px solid var(--ring); outline-offset:1px; }
.led.sub { width:20px; height:20px; opacity:.9; } /* subdivision step — smaller than a downbeat */
.led.beatstart { margin-left:11px; } /* extra gap between beats within a group */
.led.groupstart { margin-left:16px; }
.led.groupstart::before { content:""; position:absolute; left:-9px; top:4px; bottom:4px; width:2px; background:var(--muted); }
/* meter lanes — compact single-row controls + strip */
@ -96,6 +98,7 @@
.bar { font-family:"Courier New",monospace; font-size:12px; color:var(--hot); min-width:46px; text-align:right; }
.cmp { background:var(--panel); color:var(--txt); border:1px solid var(--edge); border-radius:8px; padding:7px 8px; font-size:12px; }
.meter-card .led { width:26px; height:26px; border-radius:6px; }
.meter-card .led.sub { width:17px; height:17px; }
.x { background:transparent; border:1px solid var(--edge); color:var(--muted); padding:4px 9px; border-radius:7px; margin-left:auto; }
.x:hover { color:#ff9a8a; border-color:#c0392b; }
.hint { font-size:11px; color:var(--muted); margin-top:8px; }
@ -166,6 +169,10 @@
.ext-banner { font-size:11px; color:#3a2f10; background:#ffe2a8; border:1px solid #d9a441; border-radius:8px; padding:8px 10px; margin-top:10px; line-height:1.35; }
.ext-banner[hidden] { display:none; }
#shareUrl { width:100%; resize:vertical; background:var(--panel); color:var(--txt); border:1px solid var(--edge); border-radius:8px; padding:8px; font-family:"Courier New",monospace; font-size:12px; }
.help-about { margin-top:14px; padding-top:12px; border-top:1px solid var(--edge); font-size:12px; color:var(--muted); line-height:1.45; }
.help-about p { margin:0 0 8px; }
.help-about p:last-child { margin-bottom:0; }
.help-about a { color:#6cb6ff; }
.kbd-table { width:100%; border-collapse:collapse; font-size:13px; }
.kbd-table td { padding:6px 4px; border-bottom:1px solid var(--edge); }
.kbd-table tr:last-child td { border-bottom:none; }
@ -254,7 +261,7 @@
<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>
<span class="hint" style="margin:0; flex:1">Each beat splits into <i>subdivision</i> pads — click any pad to toggle it (rest). e.g. snare on 2 &amp; 4</span>
</div>
<div id="meters"></div>
<div style="margin-top:10px"><button class="add" id="addMeterBtn" title="add meter lane (A)">+</button></div>
@ -318,6 +325,10 @@
<tr><td><kbd>?</kbd></td><td>This help</td></tr>
<tr><td><kbd>Esc</kbd></td><td>Close tray / help</td></tr>
</table>
<div class="help-about">
<p>Source: <a href="https://git.varasys.io/VARASYS/metronome" target="_blank" rel="noopener">git.varasys.io/VARASYS/metronome</a></p>
<p>This is a single-page app — save this page (<kbd>Ctrl/⌘+S</kbd>) and open the file to run it fully offline, no server needed. One catch when running from a local <code>file://</code>: it <b>won't auto-save your set list</b> between sessions, so export a backup (set-list <b></b> menu → <b>Export all</b>) to keep your work.</p>
</div>
</div>
</div>
@ -473,9 +484,9 @@ function scheduleMeterTick(m, time) {
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)
m.vq.push({ time, step: tickInBar, bar: Math.floor(m.tick / barLen) }); // playhead (per step) + measure (advance even when muted)
if (!m.enabled || isMutedAt(time)) return;
if (!m.beatsOn[beatIndex]) return; // beat toggled off → rest (incl. its subdivisions)
if (!m.beatsOn[tickInBar]) return; // this step toggled off → rest
if (onBeat) {
const groupStart = m.groupStarts.has(beatIndex);
playInstrument(m.sound, time, groupStart ? 1.0 : 0.7); // beat — louder on group start
@ -511,7 +522,7 @@ function start() {
state.running = true;
if (ramp.on) setBpm(ramp.startBpm); // ramp begins from its start BPM
const t0 = audioCtx.currentTime + 0.08;
for (const m of meters) { m.tick = 0; m.nextTime = t0; m.vq = []; m.vqPtr = 0; m.currentBeat = -1; m.currentBar = 0; }
for (const m of meters) { m.tick = 0; m.nextTime = t0; m.vq = []; m.vqPtr = 0; m.currentStep = -1; m.currentBar = 0; }
masterBeat = 0; masterBeatTime = t0; muteWindows = [];
schedulerTimer = setInterval(scheduler, LOOKAHEAD_MS);
scheduler(); syncStartBtn();
@ -519,7 +530,7 @@ function start() {
function stop() {
state.running = false;
clearInterval(schedulerTimer); schedulerTimer = null;
for (const m of meters) m.currentBeat = -1;
for (const m of meters) m.currentStep = -1;
syncStartBtn();
}
function setBpm(v) {
@ -538,10 +549,15 @@ function addMeter(groupsStr = "4", stepsPerBeat = 4, sound = "beep", beatsOn = n
const m = {
id, groupsStr, groups: p.groups, beatsPerBar: p.beatsPerBar, groupStarts: p.groupStarts,
stepsPerBeat, sound, enabled: true, poly: !!poly, color: laneColor(id),
beatsOn: beatsOn ? beatsOn.slice() : [], // per-beat on/off mask (rests)
tick: 0, nextTime: 0, vq: [], vqPtr: 0, currentBeat: -1, currentBar: 0,
beatsOn: beatsOn ? beatsOn.slice() : [], // per-STEP on/off mask (one entry per pad = beats × subdivision)
tick: 0, nextTime: 0, vq: [], vqPtr: 0, currentStep: -1, currentBar: 0,
el: null, stripEl: null, barEl: null,
};
// Tell recomputeLane the resolution the incoming mask was authored at, so it can
// remap/expand it: matches steps → per-step (new), matches beats → legacy per-beat.
if (m.beatsOn.length === p.beatsPerBar * stepsPerBeat) { m._maskBpb = p.beatsPerBar; m._maskSpb = stepsPerBeat; }
else if (m.beatsOn.length === p.beatsPerBar) { m._maskBpb = p.beatsPerBar; m._maskSpb = 1; }
else { m._maskBpb = 0; m._maskSpb = 1; } // empty/unknown → recompute fills all-on
if (state.running) { m.nextTime = audioCtx.currentTime + 0.05; }
meters.push(m);
buildLaneCard(m);
@ -575,12 +591,7 @@ function buildLaneCard(m) {
<input type="checkbox" class="lane-enable" id="m${m.id}_enable" title="enable / silence this lane" checked>
<input type="text" class="txt grp" id="m${m.id}_group" value="${m.groupsStr}" spellcheck="false" title="grouping, e.g. 2+2+3">
<span class="sum" id="m${m.id}_sum"></span>
<select class="cmp" id="m${m.id}_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)">
<select class="cmp" id="m${m.id}_sub" title="subdivision — also sets how many pads each beat splits into">
<option value="1">♩ quarter</option><option value="2">♪ eighth</option>
<option value="3">3·triplet</option><option value="4">16th</option><option value="6">6·sext</option>
</select>
@ -599,11 +610,6 @@ function buildLaneCard(m) {
// 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;
@ -621,33 +627,57 @@ function buildLaneCard(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;
// Remap the on/off mask to step resolution (beats × subdivision = one entry per pad),
// preserving the old pattern where it lines up and defaulting new pads to ON.
const spb = m.stepsPerBeat;
const prev = m.beatsOn || [], oldBpb = m._maskBpb || 0, oldSpb = m._maskSpb || 1;
const next = [];
for (let b = 0; b < m.beatsPerBar; b++) {
for (let s = 0; s < spb; s++) {
let val = true;
if (b < oldBpb) { // this beat existed before
const oi = (oldSpb === spb) ? b * oldSpb + s // same resolution → step-for-step
: b * oldSpb; // resolution changed → use the beat's downbeat
if (oi < prev.length) val = !!prev[oi];
}
next.push(val);
}
}
m.beatsOn = next; m._maskBpb = m.beatsPerBar; m._maskSpb = spb;
m.el.querySelector(`#m${m.id}_sum`).textContent = "=" + m.beatsPerBar;
buildLaneStrip(m);
}
function buildLaneStrip(m) {
function buildLaneStrip(m) { // one pad per STEP (beats × subdivision)
m.stripEl.innerHTML = "";
for (let b = 0; b < m.beatsPerBar; b++) {
const spb = m.stepsPerBeat, total = m.beatsPerBar * spb;
for (let i = 0; i < total; i++) {
const b = Math.floor(i / spb), s = i % spb;
const cell = document.createElement("div");
cell.className = "led"; cell.textContent = b + 1;
cell.style.cursor = "pointer"; cell.title = "toggle beat " + (b + 1);
cell.addEventListener("click", () => { m.beatsOn[b] = !m.beatsOn[b]; renderLaneStrip(m); });
cell.className = "led";
cell.textContent = (s === 0) ? (b + 1) : ""; // label downbeats; subdivisions blank
cell.style.cursor = "pointer";
cell.title = (s === 0) ? ("toggle beat " + (b + 1)) : ("toggle beat " + (b + 1) + " · sub " + (s + 1));
cell.addEventListener("click", () => { m.beatsOn[i] = !m.beatsOn[i]; renderLaneStrip(m); });
m.stripEl.appendChild(cell);
}
}
function renderLaneStrip(m) {
const cells = m.stripEl.children;
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";
const cells = m.stripEl.children, spb = m.stepsPerBeat;
for (let i = 0; i < cells.length; i++) {
const cell = cells[i];
const b = Math.floor(i / spb), s = i % spb, onBeat = (s === 0);
const on = m.beatsOn[i], gs = onBeat && m.groupStarts.has(b);
let cls = "led";
if (!onBeat) cls += " sub"; // subdivision pad (smaller/dimmer)
else if (i > 0 && !gs) cls += " beatstart"; // gap between beats within a group
if (on) cls += " on";
if (gs) cls += " groupstart";
if (on && gs) cls += " accent";
cell.className = cls;
cell.style.setProperty("--lc", m.color);
if (state.running && b === m.currentBeat) cell.classList.add("playhead");
if (state.running && i === m.currentStep) cell.classList.add("playhead");
}
if (m.barEl) m.barEl.textContent = state.running ? "bar " + (m.currentBar + 1) : "—";
}
@ -890,11 +920,10 @@ function importAll(file) {
Lane: <sound>:<grouping>[/<sub>][=<pattern x/.>][~ poly][! disabled]
========================================================================= */
function laneCfgToStr(c) {
const bpb = parseGroups(c.groupsStr).beatsPerBar;
let s = c.sound + ":" + c.groupsStr;
if ((c.stepsPerBeat || 1) !== 1) s += "/" + c.stepsPerBeat;
const on = (c.beatsOn || []).slice(0, bpb);
if (on.length && !on.every(Boolean)) s += "=" + Array.from({ length: bpb }, (_, i) => (on[i] ? "x" : ".")).join("");
const on = c.beatsOn || []; // per-step mask; one char per pad
if (on.length && !on.every(Boolean)) s += "=" + on.map((v) => (v ? "x" : ".")).join("");
if (c.poly) s += "~";
if (c.enabled === false) s += "!"; // "!" = silenced / disabled
return s;
@ -1004,7 +1033,7 @@ 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++; }
while (m.vqPtr < m.vq.length && m.vq[m.vqPtr].time <= now) { m.currentStep = m.vq[m.vqPtr].step; m.currentBar = m.vq[m.vqPtr].bar; m.vqPtr++; }
if (m.vqPtr > 512) { m.vq = m.vq.slice(m.vqPtr); m.vqPtr = 0; }
}
updateStatus(now);