From 766d5d40aeccb9291acd3dc02975db6667257c9f Mon Sep 17 00:00:00 2001 From: Me Here Date: Mon, 25 May 2026 11:13:53 -0500 Subject: [PATCH] Per-step accent/normal/mute dynamics + swing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- README.md | 21 +++++++----- index.html | 96 ++++++++++++++++++++++++++++++------------------------ 2 files changed, 66 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 40e8186..ca23a15 100644 --- a/README.md +++ b/README.md @@ -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 per‑step (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` individually‑toggleable 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 sixteenth‑grid pattern. A short pattern whose length - equals just the beat count is still accepted and expanded across each beat's - subdivisions (back‑compat). + into. Append **`s`** for **swing** on even subdivisions — `2s` (swung eighths) or + `4s` (swung sixteenths) delay the off‑beats 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 beat‑count 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` | eighth‑note hi‑hats | +| `snare:4=.X.X` | accented snare backbeat (2 & 4) | +| `hatClosed:4/2` | eighth‑note hi‑hats (downbeat of each beat accented) | +| `ride:4/2s` | **swung** eighth‑note ride | | `claves:5~` | 5 evenly across lane 1's bar (5‑over‑4 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 | diff --git a/index.html b/index.html index d155a8c..2891715 100644 --- a/index.html +++ b/index.html @@ -294,7 +294,7 @@

Meter lanes

- Each beat splits into subdivision pads — click any pad to toggle it (rest). e.g. snare on 2 & 4 + Each beat splits into subdivision pads — click a pad to cycle accent → normal → mute. Pick a swing subdivision for a triplet feel.
@@ -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): long–short 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) { - +
@@ -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~"],