diff --git a/docs/track-format.md b/docs/track-format.md index ee6d15c..206d9c5 100644 --- a/docs/track-format.md +++ b/docs/track-format.md @@ -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`. - **`@`** — per-lane gain in dB. (Host-optional: applied in the browser; see §6.) diff --git a/pico-cp/app.py b/pico-cp/app.py index 0cdd05a..264942f 100644 --- a/pico-cp/app.py +++ b/pico-cp/app.py @@ -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} diff --git a/pico-explorer/app.py b/pico-explorer/app.py index 484e29a..5df79b4 100644 --- a/pico-explorer/app.py +++ b/pico-explorer/app.py @@ -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} diff --git a/src/engine.js b/src/engine.js index 49942f3..7f9b637 100644 --- a/src/engine.js +++ b/src/engine.js @@ -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 }; diff --git a/tests/fixtures/track-format.json b/tests/fixtures/track-format.json index 0ca56f1..05aa764 100644 --- a/tests/fixtures/track-format.json +++ b/tests/fixtures/track-format.json @@ -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",