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:
parent
7a6aa1d5ba
commit
766d5d40ae
2 changed files with 66 additions and 51 deletions
21
README.md
21
README.md
|
|
@ -66,15 +66,17 @@ Tokens are joined with `;`. `tr` and `rmp` are omitted when off.
|
||||||
`tomLow`, `tomMid`, `tomHigh`, `tambourine`, `cowbell`, `woodblock`, `claves`,
|
`tomLow`, `tomMid`, `tomHigh`, `tambourine`, `cowbell`, `woodblock`, `claves`,
|
||||||
`jamblock` (unknown → `beep`).
|
`jamblock` (unknown → `beep`).
|
||||||
- **grouping** — beats per bar, optionally grouped for odd meters: `4`, `3`,
|
- **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,
|
- **`/sub`** — subdivision: `1` quarter (default), `2` eighth, `3` triplet,
|
||||||
`4` sixteenth, `6` sextuplet. This also sets how many **pads** each beat splits
|
`4` sixteenth, `6` sextuplet. This also sets how many **pads** each beat splits
|
||||||
into (a beat becomes `sub` individually‑toggleable steps). Omit for quarter.
|
into. Append **`s`** for **swing** on even subdivisions — `2s` (swung eighths) or
|
||||||
- **`=pattern`** — per‑**step** on/off as `x`/`.`, length = beats per bar × `sub`
|
`4s` (swung sixteenths) delay the off‑beats to a triplet (2:1) feel. Omit for quarter.
|
||||||
(one char per pad). Omit = all on. e.g. `4=.x.x` is a backbeat on 2 & 4;
|
- **`=pattern`** — per‑**step dynamics**, one char per pad: **`X`** accent, **`x`**
|
||||||
`4/4=x..x..x.x...x...` is a sixteenth‑grid pattern. A short pattern whose length
|
normal, **`.`** mute (rest). Length = beats per bar × `sub`. Omit to get the
|
||||||
equals just the beat count is still accepted and expanded across each beat's
|
default — the first step of **each beat** accented, the rest normal (click a pad in
|
||||||
subdivisions (back‑compat).
|
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.
|
- **`~`** — polyrhythm: fit this lane's beats evenly into **lane 1's** bar.
|
||||||
- **`!`** — mute the lane.
|
- **`!`** — mute the lane.
|
||||||
|
|
||||||
|
|
@ -83,8 +85,9 @@ Tokens are joined with `;`. `tr` and `rmp` are omitted when off.
|
||||||
| Patch / lane | What it is |
|
| Patch / lane | What it is |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `kick:4` | kick on 4 quarter beats |
|
| `kick:4` | kick on 4 quarter beats |
|
||||||
| `snare:4=.x.x` | snare backbeat (2 & 4) |
|
| `snare:4=.X.X` | accented snare backbeat (2 & 4) |
|
||||||
| `hatClosed:4/2` | eighth‑note hi‑hats |
|
| `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`) |
|
| `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 |
|
| `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 |
|
| `cowbell:3+2/2` | 5/4 grouped 3+2, eighth subdivision |
|
||||||
|
|
|
||||||
96
index.html
96
index.html
|
|
@ -294,7 +294,7 @@
|
||||||
|
|
||||||
<div class="row" style="align-items:center; gap:12px; margin:14px 0 8px">
|
<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>
|
<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 & 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>
|
||||||
<div id="meters"></div>
|
<div id="meters"></div>
|
||||||
<div style="margin-top:10px"><button class="add" id="addMeterBtn" title="add meter lane (A)">+</button></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 spb = m.stepsPerBeat;
|
||||||
const barLen = m.beatsPerBar * spb;
|
const barLen = m.beatsPerBar * spb;
|
||||||
const tickInBar = ((m.tick % barLen) + barLen) % barLen;
|
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)
|
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.enabled || isMutedAt(time)) return;
|
||||||
if (!m.beatsOn[tickInBar]) return; // this step toggled off → rest
|
const lvl = m.beatsOn[tickInBar] | 0; // per-step dynamics: 0 = rest, 1 = normal, 2 = accent
|
||||||
if (onBeat) {
|
if (!lvl) return;
|
||||||
const groupStart = m.groupStarts.has(beatIndex);
|
playInstrument(m.sound, time, lvl === 2 ? 1.0 : 0.6);
|
||||||
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.
|
// 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 refBarDur() { return (meters.length ? meters[0].beatsPerBar : 4) * (60 / state.bpm); }
|
||||||
function laneStepDur(m) {
|
const SWING_RATIO = 2 / 3; // triplet swing: the off-beat lands on the last triplet
|
||||||
if (m.poly) return refBarDur() / (m.beatsPerBar * m.stepsPerBeat); // true ratio polyrhythm
|
function laneStepDur(m, tick) {
|
||||||
return (60 / state.bpm) / m.stepsPerBeat; // normal: shared quarter-note grid
|
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() {
|
function scheduler() {
|
||||||
|
|
@ -552,8 +552,8 @@ function scheduler() {
|
||||||
for (const m of meters) {
|
for (const m of meters) {
|
||||||
while (m.nextTime < cap) {
|
while (m.nextTime < cap) {
|
||||||
scheduleMeterTick(m, m.nextTime);
|
scheduleMeterTick(m, m.nextTime);
|
||||||
|
m.nextTime += laneStepDur(m, m.tick); // duration of the step just scheduled (swing makes pairs uneven)
|
||||||
m.tick++;
|
m.tick++;
|
||||||
m.nextTime += laneStepDur(m);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Boundary reached → swap to the new segment seamlessly (scheduler keeps running).
|
// 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 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 id = ++meterSeq;
|
||||||
const p = parseGroups(groupsStr);
|
const p = parseGroups(groupsStr);
|
||||||
const m = {
|
const m = {
|
||||||
id, groupsStr, groups: p.groups, beatsPerBar: p.beatsPerBar, groupStarts: p.groupStarts,
|
id, groupsStr, groups: p.groups, beatsPerBar: p.beatsPerBar, groupStarts: p.groupStarts,
|
||||||
stepsPerBeat, sound, enabled: true, poly: !!poly, color: laneColor(id),
|
stepsPerBeat, sound, enabled: true, poly: !!poly, swing: !!swing, color: laneColor(id),
|
||||||
beatsOn: beatsOn ? beatsOn.slice() : [], // per-STEP on/off mask (one entry per pad = beats × subdivision)
|
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,
|
tick: 0, nextTime: 0, vq: [], vqPtr: 0, currentStep: -1, currentBar: 0,
|
||||||
el: null, stripEl: null, barEl: null,
|
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="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">
|
<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>
|
<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="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="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>
|
||||||
<select class="cmp" id="m${m.id}_sound" title="sound">${VOICES.map(([v, n]) => `<option value="${v}">${n}</option>`).join("")}</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>
|
<div class="strip" id="m${m.id}_strip"></div>
|
||||||
|
|
@ -691,8 +692,8 @@ function buildLaneCard(m) {
|
||||||
// wire controls
|
// wire controls
|
||||||
const $c = (sel) => card.querySelector(sel);
|
const $c = (sel) => card.querySelector(sel);
|
||||||
$c(`#m${m.id}_group`).addEventListener("input", (e) => { m.groupsStr = e.target.value; recomputeLane(m); });
|
$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);
|
const sub = $c(`#m${m.id}_sub`); sub.value = m.swing ? (m.stepsPerBeat + "s") : String(m.stepsPerBeat);
|
||||||
sub.addEventListener("change", (e) => { m.stepsPerBeat = +e.target.value; recomputeLane(m); });
|
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;
|
const sel = $c(`#m${m.id}_sound`); sel.value = m.sound;
|
||||||
sel.addEventListener("change", (e) => m.sound = e.target.value);
|
sel.addEventListener("change", (e) => m.sound = e.target.value);
|
||||||
const polyCb = $c(`#m${m.id}_poly`); polyCb.checked = m.poly;
|
const polyCb = $c(`#m${m.id}_poly`); polyCb.checked = m.poly;
|
||||||
|
|
@ -705,21 +706,27 @@ function buildLaneCard(m) {
|
||||||
recomputeLane(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) {
|
function recomputeLane(m) {
|
||||||
const p = parseGroups(m.groupsStr);
|
const p = parseGroups(m.groupsStr);
|
||||||
m.groups = p.groups; m.beatsPerBar = p.beatsPerBar; m.groupStarts = p.groupStarts;
|
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),
|
// Remap the dynamics mask to step resolution (beats × subdivision = one pad each),
|
||||||
// preserving the old pattern where it lines up and defaulting new pads to ON.
|
// preserving levels where they line up and defaulting new pads (first-of-beat accent, rest normal).
|
||||||
const spb = m.stepsPerBeat;
|
const spb = m.stepsPerBeat;
|
||||||
const prev = m.beatsOn || [], oldBpb = m._maskBpb || 0, oldSpb = m._maskSpb || 1;
|
const prev = m.beatsOn || [], oldBpb = m._maskBpb || 0, oldSpb = m._maskSpb || 1;
|
||||||
const next = [];
|
const next = [];
|
||||||
for (let b = 0; b < m.beatsPerBar; b++) {
|
for (let b = 0; b < m.beatsPerBar; b++) {
|
||||||
for (let s = 0; s < spb; s++) {
|
for (let s = 0; s < spb; s++) {
|
||||||
let val = true;
|
let val = stepDefault(s);
|
||||||
if (b < oldBpb) { // this beat existed before
|
if (b < oldBpb) { // this beat existed before
|
||||||
const oi = (oldSpb === spb) ? b * oldSpb + s // same resolution → step-for-step
|
if (oldSpb === spb) val = normLevel(prev[b * oldSpb + s], stepDefault(s)); // same resolution → step-for-step
|
||||||
: b * oldSpb; // resolution changed → use the beat's downbeat
|
else if (s === 0) val = normLevel(prev[b * oldSpb], 2); // resolution changed → keep the downbeat; new subs default
|
||||||
if (oi < prev.length) val = !!prev[oi];
|
|
||||||
}
|
}
|
||||||
next.push(val);
|
next.push(val);
|
||||||
}
|
}
|
||||||
|
|
@ -738,8 +745,8 @@ function buildLaneStrip(m) { // one pad per STEP (b
|
||||||
cell.className = "led";
|
cell.className = "led";
|
||||||
cell.textContent = (s === 0) ? (b + 1) : ""; // label downbeats; subdivisions blank
|
cell.textContent = (s === 0) ? (b + 1) : ""; // label downbeats; subdivisions blank
|
||||||
cell.style.cursor = "pointer";
|
cell.style.cursor = "pointer";
|
||||||
cell.title = (s === 0) ? ("toggle beat " + (b + 1)) : ("toggle beat " + (b + 1) + " · sub " + (s + 1));
|
cell.title = "click: accent → normal → mute · beat " + (b + 1) + (s ? " · sub " + (s + 1) : "");
|
||||||
cell.addEventListener("click", () => { m.beatsOn[i] = !m.beatsOn[i]; renderLaneStrip(m); });
|
cell.addEventListener("click", () => { m.beatsOn[i] = ((m.beatsOn[i] | 0) + 2) % 3; renderLaneStrip(m); }); // 2→1→0→2
|
||||||
m.stripEl.appendChild(cell);
|
m.stripEl.appendChild(cell);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -749,13 +756,13 @@ function renderLaneStrip(m) {
|
||||||
for (let i = 0; i < cells.length; i++) {
|
for (let i = 0; i < cells.length; i++) {
|
||||||
const cell = cells[i];
|
const cell = cells[i];
|
||||||
const b = Math.floor(i / spb), s = i % spb, onBeat = (s === 0);
|
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";
|
let cls = "led";
|
||||||
if (!onBeat) cls += " sub"; // subdivision pad (smaller/dimmer)
|
if (!onBeat) cls += " sub"; // subdivision pad (smaller/dimmer)
|
||||||
else if (i > 0 && !gs) cls += " beatstart"; // gap between beats within a group
|
else if (i > 0 && !gs) cls += " beatstart"; // gap between beats within a group
|
||||||
if (on) cls += " on";
|
if (lvl >= 1) cls += " on"; // normal or accent → lit
|
||||||
if (gs) cls += " groupstart";
|
if (gs) cls += " groupstart"; // group divider (layout only)
|
||||||
if (on && gs) cls += " accent";
|
if (lvl === 2) cls += " accent"; // accented step (▲)
|
||||||
cell.className = cls;
|
cell.className = cls;
|
||||||
cell.style.setProperty("--lc", m.color);
|
cell.style.setProperty("--lc", m.color);
|
||||||
if (state.running && i === m.currentStep) cell.classList.add("playhead");
|
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 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 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) {
|
function applyLanes(lanes) {
|
||||||
while (meters.length) removeMeter(meters[0].id);
|
while (meters.length) removeMeter(meters[0].id);
|
||||||
for (const c of lanes) {
|
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];
|
const m = meters[meters.length - 1];
|
||||||
setLaneEnabled(m, c.enabled !== undefined ? !!c.enabled : (c.mute === undefined ? true : !c.mute)); // back-compat with old "mute"
|
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) {
|
function laneCfgToStr(c) {
|
||||||
let s = c.sound + ":" + c.groupsStr;
|
let s = c.sound + ":" + c.groupsStr;
|
||||||
if ((c.stepsPerBeat || 1) !== 1) s += "/" + c.stepsPerBeat;
|
const spb = c.stepsPerBeat || 1;
|
||||||
const on = c.beatsOn || []; // per-step mask; one char per pad
|
if (spb !== 1 || c.swing) s += "/" + spb + (c.swing ? "s" : ""); // "/2s" = swung eighths
|
||||||
if (on.length && !on.every(Boolean)) s += "=" + on.map((v) => (v ? "x" : ".")).join("");
|
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.poly) s += "~";
|
||||||
if (c.enabled === false) s += "!"; // "!" = silenced / disabled
|
if (c.enabled === false) s += "!"; // "!" = silenced / disabled
|
||||||
return s;
|
return s;
|
||||||
|
|
@ -1093,13 +1102,14 @@ function laneStrToCfg(tok) {
|
||||||
const ci = tok.indexOf(":"); if (ci < 0) return null;
|
const ci = tok.indexOf(":"); if (ci < 0) return null;
|
||||||
let sound = tok.slice(0, ci), rest = tok.slice(ci + 1), pattern = 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); }
|
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("/");
|
let groupsStr = rest, sub = 1, swing = false; const sl = rest.indexOf("/");
|
||||||
if (sl >= 0) { groupsStr = rest.slice(0, sl); sub = parseInt(rest.slice(sl + 1), 10) || 1; }
|
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 bpb = parseGroups(groupsStr).beatsPerBar;
|
||||||
const beatsOn = pattern ? pattern.split("").map((ch) => ch === "x" || ch === "X" || ch === "1")
|
// pattern levels: X=accent(2), x/1=normal(1), . / anything else = mute(0); no pattern → default (first of each beat accented)
|
||||||
: new Array(bpb).fill(true);
|
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";
|
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) {
|
function setupToPatch(s) {
|
||||||
const parts = ["v1", "t" + s.bpm];
|
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).
|
// Demo set list (each item authored in the share language — also exercises the parser).
|
||||||
const DEMOS = [
|
const DEMOS = [
|
||||||
["Four-on-the-floor", "t120;kick:4;snare:4=.x.x;hatClosed:4/2"],
|
["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~"],
|
["5 over 4 polyrhythm", "t100;kick:4;claves:5~"],
|
||||||
["3 over 2 hemiola", "t96;woodblock:2;cowbell:3~"],
|
["3 over 2 hemiola", "t96;woodblock:2;cowbell:3~"],
|
||||||
["2 & 4 & 3 over one bar", "t100;kick:3;cowbell:2~;claves:4~"],
|
["2 & 4 & 3 over one bar", "t100;kick:3;cowbell:2~;claves:4~"],
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue