Per-step accent/normal/mute dynamics + swing

Pads are now 3-state instead of on/off: click cycles accent → normal →
mute. Default keeps the first step of each beat accented (the rest
normal), so existing grooves are unchanged in feel; legacy on/off masks
migrate (on-downbeats → accent, on-subs → normal).
- Audio gain is driven per step by level (accent 1.0 / normal 0.6);
  the old auto group-start accent is replaced by explicit per-step level.
- Swing: "swing 8th / swing 16th" subdivision options apply a triplet
  (2:1) long–short feel to even subdivisions (per-lane).
- Share language: pattern uses X (accent) / x (normal) / . (mute), and
  the sub token takes a trailing s for swing (e.g. ride:4/2s). The
  default-accent pattern is omitted; legacy x/. still parse.
- Demos: "Swing ride" and an accents example.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-05-25 11:13:53 -05:00
parent 7a6aa1d5ba
commit 766d5d40ae
2 changed files with 66 additions and 51 deletions

View file

@ -66,15 +66,17 @@ Tokens are joined with `;`. `tr` and `rmp` are omitted when off.
`tomLow`, `tomMid`, `tomHigh`, `tambourine`, `cowbell`, `woodblock`, `claves`,
`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.
`2+2+3`. Groups get a visual divider; accents are perstep (see `=pattern`).
- **`/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).
into. Append **`s`** for **swing** on even subdivisions — `2s` (swung eighths) or
`4s` (swung sixteenths) delay the offbeats to a triplet (2:1) feel. Omit for quarter.
- **`=pattern`** — per**step dynamics**, one char per pad: **`X`** accent, **`x`**
normal, **`.`** mute (rest). Length = beats per bar × `sub`. Omit to get the
default — the first step of **each beat** accented, the rest normal (click a pad in
the UI to cycle accent → normal → mute). e.g. `4=.X.X` accents the backbeat (2 & 4);
`4/2s` is swung eighths with the default accents. (Legacy `x`/`.` on/off patterns and
short beatcount patterns still parse.)
- **`~`** — polyrhythm: fit this lane's beats evenly into **lane 1's** bar.
- **`!`** — mute the lane.
@ -83,8 +85,9 @@ Tokens are joined with `;`. `tr` and `rmp` are omitted when off.
| Patch / lane | What it is |
|---|---|
| `kick:4` | kick on 4 quarter beats |
| `snare:4=.x.x` | snare backbeat (2 & 4) |
| `hatClosed:4/2` | eighthnote hihats |
| `snare:4=.X.X` | accented snare backbeat (2 & 4) |
| `hatClosed:4/2` | eighthnote hihats (downbeat of each beat accented) |
| `ride:4/2s` | **swung** eighthnote ride |
| `claves:5~` | 5 evenly across lane 1's bar (5over4 if lane 1 is `4`) |
| `kick:2+2+3=x..x..x` | 7/8, kick on each group start |
| `cowbell:3+2/2` | 5/4 grouped 3+2, eighth subdivision |

View file

@ -294,7 +294,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">Each beat splits into <i>subdivision</i> pads — click any 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 a pad to cycle <b>accent → normal → mute</b>. Pick a <i>swing</i> subdivision for a triplet feel.</span>
</div>
<div id="meters"></div>
<div style="margin-top:10px"><button class="add" id="addMeterBtn" title="add meter lane (A)">+</button></div>
@ -523,24 +523,24 @@ 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);
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[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
} else {
playInstrument(m.sound, time, 0.4); // subdivision — same voice, softer
}
const lvl = m.beatsOn[tickInBar] | 0; // per-step dynamics: 0 = rest, 1 = normal, 2 = accent
if (!lvl) return;
playInstrument(m.sound, time, lvl === 2 ? 1.0 : 0.6);
}
// 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
const SWING_RATIO = 2 / 3; // triplet swing: the off-beat lands on the last triplet
function laneStepDur(m, tick) {
if (m.poly) return refBarDur() / (m.beatsPerBar * m.stepsPerBeat); // true ratio polyrhythm (no swing)
const beat = 60 / state.bpm;
if (m.swing && m.stepsPerBeat % 2 === 0) { // swing even subdivisions (8ths, 16ths): longshort pairs
const pairDur = beat / (m.stepsPerBeat / 2);
return ((tick % m.stepsPerBeat) % 2) === 0 ? SWING_RATIO * pairDur : (1 - SWING_RATIO) * pairDur;
}
return beat / m.stepsPerBeat; // straight: shared even grid
}
function scheduler() {
@ -552,8 +552,8 @@ function scheduler() {
for (const m of meters) {
while (m.nextTime < cap) {
scheduleMeterTick(m, m.nextTime);
m.nextTime += laneStepDur(m, m.tick); // duration of the step just scheduled (swing makes pairs uneven)
m.tick++;
m.nextTime += laneStepDur(m);
}
}
// Boundary reached → swap to the new segment seamlessly (scheduler keeps running).
@ -624,13 +624,13 @@ function setBpm(v) {
========================================================================= */
function laneColor(id) { return `hsl(${(id * 67) % 360} 70% 62%)`; }
function addMeter(groupsStr = "4", stepsPerBeat = 4, sound = "beep", beatsOn = null, poly = false) {
function addMeter(groupsStr = "4", stepsPerBeat = 4, sound = "beep", beatsOn = null, poly = false, swing = false) {
const id = ++meterSeq;
const p = parseGroups(groupsStr);
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-STEP on/off mask (one entry per pad = beats × subdivision)
stepsPerBeat, sound, enabled: true, poly: !!poly, swing: !!swing, color: laneColor(id),
beatsOn: beatsOn ? beatsOn.slice() : [], // per-STEP dynamics mask (one entry per pad: 0 mute / 1 normal / 2 accent)
tick: 0, nextTime: 0, vq: [], vqPtr: 0, currentStep: -1, currentBar: 0,
el: null, stripEl: null, barEl: null,
};
@ -672,9 +672,10 @@ 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}_sub" title="subdivision — also sets how many pads each beat splits into">
<select class="cmp" id="m${m.id}_sub" title="subdivision — sets how many pads each beat splits into; “swing” delays the off-beats">
<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>
<option value="2s">♪ swing 8th</option><option value="4s">swing 16th</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>
@ -691,8 +692,8 @@ 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 sub = $c(`#m${m.id}_sub`); sub.value = String(m.stepsPerBeat);
sub.addEventListener("change", (e) => { m.stepsPerBeat = +e.target.value; recomputeLane(m); });
const sub = $c(`#m${m.id}_sub`); sub.value = m.swing ? (m.stepsPerBeat + "s") : String(m.stepsPerBeat);
sub.addEventListener("change", (e) => { const v = e.target.value; m.swing = /s$/.test(v); m.stepsPerBeat = parseInt(v, 10) || 1; 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;
@ -705,21 +706,27 @@ function buildLaneCard(m) {
recomputeLane(m);
}
const stepDefault = (s) => (s === 0 ? 2 : 1); // default dynamics: first step of each beat accented, rest normal
function normLevel(v, dflt) { // coerce a stored value to a 0/1/2 level
if (v === true) return dflt === 2 ? 2 : 1; // legacy boolean "on" → keep accent on downbeats
if (v === false) return 0;
if (v == null) return dflt;
const n = v | 0; return n >= 2 ? 2 : n >= 1 ? 1 : 0;
}
function recomputeLane(m) {
const p = parseGroups(m.groupsStr);
m.groups = p.groups; m.beatsPerBar = p.beatsPerBar; m.groupStarts = p.groupStarts;
// 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.
// Remap the dynamics mask to step resolution (beats × subdivision = one pad each),
// preserving levels where they line up and defaulting new pads (first-of-beat accent, rest normal).
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;
let val = stepDefault(s);
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];
if (oldSpb === spb) val = normLevel(prev[b * oldSpb + s], stepDefault(s)); // same resolution → step-for-step
else if (s === 0) val = normLevel(prev[b * oldSpb], 2); // resolution changed → keep the downbeat; new subs default
}
next.push(val);
}
@ -738,8 +745,8 @@ function buildLaneStrip(m) { // one pad per STEP (b
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); });
cell.title = "click: accent → normal → mute · beat " + (b + 1) + (s ? " · sub " + (s + 1) : "");
cell.addEventListener("click", () => { m.beatsOn[i] = ((m.beatsOn[i] | 0) + 2) % 3; renderLaneStrip(m); }); // 2→1→0→2
m.stripEl.appendChild(cell);
}
}
@ -749,13 +756,13 @@ function renderLaneStrip(m) {
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);
const lvl = m.beatsOn[i] | 0, 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";
if (lvl >= 1) cls += " on"; // normal or accent → lit
if (gs) cls += " groupstart"; // group divider (layout only)
if (lvl === 2) cls += " accent"; // accented step (▲)
cell.className = cls;
cell.style.setProperty("--lc", m.color);
if (state.running && i === m.currentStep) cell.classList.add("playhead");
@ -770,11 +777,11 @@ const LS = { presets: "metronome.presets", setlists: "metronome.setlists", 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, enabled: m.enabled, poly: m.poly, beatsOn: m.beatsOn.slice() })); }
function snapshotLanes() { return meters.map((m) => ({ groupsStr: m.groupsStr, stepsPerBeat: m.stepsPerBeat, sound: m.sound, enabled: m.enabled, poly: m.poly, swing: !!m.swing, 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);
addMeter(c.groupsStr, c.stepsPerBeat, c.sound, c.beatsOn, c.poly, c.swing);
const m = meters[meters.length - 1];
setLaneEnabled(m, c.enabled !== undefined ? !!c.enabled : (c.mute === undefined ? true : !c.mute)); // back-compat with old "mute"
}
@ -1080,9 +1087,11 @@ function importAll(file) {
========================================================================= */
function laneCfgToStr(c) {
let s = c.sound + ":" + c.groupsStr;
if ((c.stepsPerBeat || 1) !== 1) s += "/" + c.stepsPerBeat;
const on = c.beatsOn || []; // per-step mask; one char per pad
if (on.length && !on.every(Boolean)) s += "=" + on.map((v) => (v ? "x" : ".")).join("");
const spb = c.stepsPerBeat || 1;
if (spb !== 1 || c.swing) s += "/" + spb + (c.swing ? "s" : ""); // "/2s" = swung eighths
const on = c.beatsOn || []; // per-step dynamics: one char per pad (X accent / x normal / . mute)
const isDefault = on.length && on.every((v, i) => (v | 0) === ((i % spb) === 0 ? 2 : 1));
if (on.length && !isDefault) s += "=" + on.map((v) => (v >= 2 ? "X" : v >= 1 ? "x" : ".")).join("");
if (c.poly) s += "~";
if (c.enabled === false) s += "!"; // "!" = silenced / disabled
return s;
@ -1093,13 +1102,14 @@ function laneStrToCfg(tok) {
const ci = tok.indexOf(":"); if (ci < 0) return null;
let sound = tok.slice(0, ci), rest = tok.slice(ci + 1), pattern = null;
const eq = rest.indexOf("="); if (eq >= 0) { pattern = rest.slice(eq + 1); rest = rest.slice(0, eq); }
let groupsStr = rest, sub = 1; const sl = rest.indexOf("/");
if (sl >= 0) { groupsStr = rest.slice(0, sl); sub = parseInt(rest.slice(sl + 1), 10) || 1; }
let groupsStr = rest, sub = 1, swing = false; const sl = rest.indexOf("/");
if (sl >= 0) { groupsStr = rest.slice(0, sl); const sp = rest.slice(sl + 1); swing = /s$/i.test(sp); sub = parseInt(sp, 10) || 1; }
const bpb = parseGroups(groupsStr).beatsPerBar;
const beatsOn = pattern ? pattern.split("").map((ch) => ch === "x" || ch === "X" || ch === "1")
: new Array(bpb).fill(true);
// pattern levels: X=accent(2), x/1=normal(1), . / anything else = mute(0); no pattern → default (first of each beat accented)
const beatsOn = pattern ? pattern.split("").map((ch) => ch === "X" ? 2 : (ch === "x" || ch === "1") ? 1 : 0)
: Array.from({ length: bpb * sub }, (_, i) => ((i % sub) === 0 ? 2 : 1));
if (!DRUMS[sound]) sound = "beep";
return { groupsStr, stepsPerBeat: sub, sound, beatsOn, poly, enabled: !disabled };
return { groupsStr, stepsPerBeat: sub, sound, beatsOn, poly, swing, enabled: !disabled };
}
function setupToPatch(s) {
const parts = ["v1", "t" + s.bpm];
@ -1176,6 +1186,8 @@ function applyHashShare() {
// Demo set list (each item authored in the share language — also exercises the parser).
const DEMOS = [
["Four-on-the-floor", "t120;kick:4;snare:4=.x.x;hatClosed:4/2"],
["Swing ride", "t150;ride:4/2s;kick:4=X..x;snare:4=.x.x"],
["Accents (click a pad to cycle)", "t92;kick:4=X..X;snare:4=.X.X;hatClosed:4/2"],
["5 over 4 polyrhythm", "t100;kick:4;claves:5~"],
["3 over 2 hemiola", "t96;woodblock:2;cowbell:3~"],
["2 & 4 & 3 over one bar", "t100;kick:3;cowbell:2~;claves:4~"],