Track format: unify default (no-pattern) groove across web + firmware
A lane with no =pattern produced different defaults on web vs device — a real, shipped divergence the new conformance suite caught (e.g. hatClosed:4/2 in "Four-on-the-floor" played steady 8ths in the browser but quarter-notes on the device). Adopt one rule everywhere: every subdivision sounds at normal level, accents fall ONLY on group starts (the grouping is the accent map). - pico-cp/app.py, pico-explorer/app.py: off-beat subdivisions sound at normal (1) instead of resting (0); group-start accenting was already correct. - src/engine.js: default beatsOn accents group starts only (was: every beat); laneCfgToStr isDefault check updated to match so round-trips stay idempotent. - docs + fixtures: document the rule; default-pattern vectors now pass on both. Audible effect (intended): device subdivided hat/ride lanes gain their off-beat strokes (now match the web); web stops over-accenting every beat. Lanes with an explicit =pattern are unchanged. Verified green via node tests/run.mjs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
47edf4eb2a
commit
bf74c860e5
5 changed files with 25 additions and 13 deletions
|
|
@ -92,7 +92,10 @@ pattern = *( "X" | "x" | "g" | "." | "-" | "_" ) ; (* per-step dynamics *)
|
|||
- **swing** — `/2s` lays the off-beat on the last triplet (≈ 2/3).
|
||||
- **pattern** — one char per step: `X`=accent (level 2), `x`/`1`=normal (1), `g`=ghost (3),
|
||||
`.`/`-`/`_`/anything else = rest (0). Short patterns are right-padded with rests to `steps`.
|
||||
With no pattern, the first step of each beat is accented and the rest are normal.
|
||||
**With no pattern (the default):** every step sounds at normal level and accents fall **only
|
||||
on group starts** — the grouping *is* the accent map. So `4` accents beat 1; `2+2` accents
|
||||
beats 1 & 3; `4/2` is a steady 8th lane with an accent on beat 1. To accent every beat,
|
||||
write the grouping (`1+1+1+1`) rather than relying on the default.
|
||||
- **euclid** `(k,n,rot)** — `k` hits spread as evenly as possible over `n` steps (rotated by
|
||||
`rot`), first hit accented. Replaces an explicit `=pattern`.
|
||||
- **`@<db>`** — per-lane gain in dB. (Host-optional: applied in the browser; see §6.)
|
||||
|
|
|
|||
|
|
@ -315,8 +315,8 @@ def _parse_lane(tok):
|
|||
else:
|
||||
levels = []
|
||||
for i in range(steps):
|
||||
if i % sub == 0: levels.append(2 if (i // sub) in starts else 1)
|
||||
else: levels.append(0)
|
||||
if i % sub == 0: levels.append(2 if (i // sub) in starts else 1) # beat: accent on group starts
|
||||
else: levels.append(1) # off-beat subdivisions sound at normal (grouping IS the accent map)
|
||||
return {'sound': sound, 'sub': sub, 'swing': swing, 'steps': steps, 'levels': levels,
|
||||
'poly': poly, 'mute': mute, 'groups': groups, 'gain': gain}
|
||||
|
||||
|
|
|
|||
|
|
@ -284,8 +284,8 @@ def _parse_lane(tok):
|
|||
else:
|
||||
levels = []
|
||||
for i in range(steps):
|
||||
if i % sub == 0: levels.append(2 if (i // sub) in starts else 1)
|
||||
else: levels.append(0)
|
||||
if i % sub == 0: levels.append(2 if (i // sub) in starts else 1) # beat: accent on group starts
|
||||
else: levels.append(1) # off-beat subdivisions sound at normal (grouping IS the accent map)
|
||||
return {'sound': sound, 'sub': sub, 'swing': swing, 'steps': steps, 'levels': levels,
|
||||
'poly': poly, 'mute': mute, 'groups': groups, 'gain': gain}
|
||||
|
||||
|
|
|
|||
|
|
@ -178,7 +178,8 @@ function laneCfgToStr(c) {
|
|||
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 / g ghost / . mute)
|
||||
const isDefault = on.length && on.every((v, i) => (v | 0) === ((i % spb) === 0 ? 2 : 1));
|
||||
const gs = parseGroups(c.groupsStr).groupStarts; // default = accent group starts only; everything else sounds at normal
|
||||
const isDefault = on.length && on.every((v, i) => (v | 0) === (((i % spb) === 0 && gs.has(i / spb)) ? 2 : 1));
|
||||
if (on.length && !isDefault) s += "=" + on.map((v) => (v === 3 ? "g" : v >= 2 ? "X" : v >= 1 ? "x" : ".")).join("");
|
||||
if (c.gainDb) s += "@" + c.gainDb; // per-lane gain in dB (e.g. @-3, @2)
|
||||
if (c.poly) s += "~";
|
||||
|
|
@ -199,7 +200,7 @@ function laneStrToCfg(tok) {
|
|||
const eq = rest.indexOf("="); if (eq >= 0) { pattern = rest.slice(eq + 1); rest = rest.slice(0, eq); }
|
||||
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; }
|
||||
let bpb = parseGroups(groupsStr).beatsPerBar;
|
||||
let { beatsPerBar: bpb, groupStarts } = parseGroups(groupsStr);
|
||||
let beatsOn;
|
||||
if (eucK != null) { // k hits spread evenly; first hit accented
|
||||
let n = eucN || (bpb * sub);
|
||||
|
|
@ -207,9 +208,10 @@ function laneStrToCfg(tok) {
|
|||
let first = true;
|
||||
beatsOn = euclid(eucK, n, eucRot).map((h) => h ? (first ? (first = false, 2) : 1) : 0);
|
||||
} else {
|
||||
// pattern levels: X=accent(2), g=ghost(3), x/1=normal(1), . - _ / anything else = mute(0); no pattern → first of each beat accented
|
||||
// pattern levels: X=accent(2), g=ghost(3), x/1=normal(1), . - _ / anything else = mute(0);
|
||||
// no pattern → every subdivision sounds at normal, accent on group starts (the grouping IS the accent map)
|
||||
beatsOn = pattern ? pattern.split("").map((ch) => ch === "X" ? 2 : ch === "g" ? 3 : (ch === "x" || ch === "1") ? 1 : 0)
|
||||
: Array.from({ length: bpb * sub }, (_, i) => ((i % sub) === 0 ? 2 : 1));
|
||||
: Array.from({ length: bpb * sub }, (_, i) => ((i % sub) === 0 && groupStarts.has(i / sub)) ? 2 : 1);
|
||||
}
|
||||
if (!DRUMS[sound]) sound = "beep";
|
||||
return { groupsStr, stepsPerBeat: sub, sound, beatsOn, poly, swing, enabled: !disabled, gainDb };
|
||||
|
|
|
|||
15
tests/fixtures/track-format.json
vendored
15
tests/fixtures/track-format.json
vendored
|
|
@ -99,12 +99,19 @@
|
|||
|
||||
{
|
||||
"id": "default-pattern",
|
||||
"status": "divergence",
|
||||
"expectFail": ["py"],
|
||||
"note": "DESIGN DECISION PENDING. A lane with no =pattern produces different defaults: web plays every subdivision (off-beats normal) and accents every beat -> [2,1,2,1...]; device rests off-beats -> [2,0,1,0...]. This affects shipped presets (e.g. 'Four-on-the-floor' hatClosed:4/2). Vector pins the web behavior as provisional reference; revise once the rule is chosen.",
|
||||
"status": "stable",
|
||||
"note": "No =pattern: every subdivision sounds at normal; accent only on group starts (the grouping is the accent map). One group of 4 ⇒ accent on beat 1 only, off-8ths sound at normal.",
|
||||
"in": "t120;hatClosed:4/2",
|
||||
"norm": { "bpm": 120, "bars": 0, "volume": null, "countMs": 0, "ramp": null, "trainer": null, "rep": null, "end": null,
|
||||
"lanes": [ { "sound": "hatClosed", "groups": [4], "sub": 2, "swing": false, "poly": false, "mute": false, "gainDb": 0, "levels": [2,1,2,1,2,1,2,1] } ] }
|
||||
"lanes": [ { "sound": "hatClosed", "groups": [4], "sub": 2, "swing": false, "poly": false, "mute": false, "gainDb": 0, "levels": [2,1,1,1,1,1,1,1] } ] }
|
||||
},
|
||||
{
|
||||
"id": "default-pattern-odd-meter",
|
||||
"status": "stable",
|
||||
"note": "No =pattern over a 2+2+3 grouping ⇒ accents land on group starts (beats 1,3,5); all 8ths sound.",
|
||||
"in": "t130;hatClosed:2+2+3/2",
|
||||
"norm": { "bpm": 130, "bars": 0, "volume": null, "countMs": 0, "ramp": null, "trainer": null, "rep": null, "end": null,
|
||||
"lanes": [ { "sound": "hatClosed", "groups": [2,2,3], "sub": 2, "swing": false, "poly": false, "mute": false, "gainDb": 0, "levels": [2,1,1,1,2,1,1,1,2,1,1,1,1,1] } ] }
|
||||
},
|
||||
{
|
||||
"id": "euclid",
|
||||
|
|
|
|||
Loading…
Reference in a new issue