From 9701f4991392968aa461bb217c8e75785e82bd8d Mon Sep 17 00:00:00 2001 From: Me Here Date: Sun, 31 May 2026 00:15:25 -0500 Subject: [PATCH] Firmware: parse euclid, GM note-numbers, and unknown-sound fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close three real parser divergences the conformance suite flagged on the device side (pico-cp + pico-explorer) — cases where the firmware produced a different groove/sound than the web for the same patch: - Euclidean (k,n,rot) shorthand (e.g. kick:4(3,8)) — was silently dropped to a plain bar; now expands to the same hits as engine.js (added _euclid + parsing). - GM note-number lane sounds (e.g. 36:4) — now resolve to the voice name (GM_NUM). - Unknown sound names fall back to beep, matching the web. vol/cd are NOT carried by the firmware by design: they are web-authoring fields (the device has a hardware volume knob and no count-in). Documented as an intentional, permanent host difference rather than a bug; the vol-and-countin vector stays as expectFail[py] to mark the boundary. tests/adapters/py_adapter.py: extract the new SOUND_GM/GM_NUM/_euclid nodes. fixtures: euclid/unknown-sound/gm-note-number now pass on both engines. docs §6 updated. node tests/run.mjs: 33 pass / 9 known, round-trips stable. pico-explorer parser spot-checked identical to pico-cp. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/track-format.md | 38 ++++++++++++++++++-------------- pico-cp/app.py | 32 +++++++++++++++++++++++++-- pico-explorer/app.py | 32 +++++++++++++++++++++++++-- tests/adapters/py_adapter.py | 2 +- tests/fixtures/track-format.json | 15 +++++-------- 5 files changed, 89 insertions(+), 30 deletions(-) diff --git a/docs/track-format.md b/docs/track-format.md index 206d9c5..6ff3dc0 100644 --- a/docs/track-format.md +++ b/docs/track-format.md @@ -66,8 +66,8 @@ patch = [ "v1" ";" ] directive *( ";" directive ) ; directive = tempo | volume | countin | bars | trainer | ramp | rep | end | lane ; tempo = "t" int ; (* beats per minute, clamped to 5..300 *) -volume = "vol" int ; (* 0..100, host-optional — device ignores *) -countin = "cd" int ; (* count-in seconds, host-optional *) +volume = "vol" int ; (* 0..100; web-authoring only — device has a hardware volume knob *) +countin = "cd" int ; (* count-in seconds; web-authoring only — device has no count-in *) bars = "b" int ; (* cycle length in bars; drives Continue/rep *) trainer = "tr" int "/" int ; (* playBars "/" muteBars (gap trainer) *) ramp = "rmp" int "/" signed "/" int ; (* startBpm "/" amount "/" everyBars *) @@ -110,7 +110,8 @@ pattern = *( "X" | "x" | "g" | "." | "-" | "_" ) ; (* per-step dynamics *) - **`rmp//`** tempo ramp: every `` bars, change tempo by `` (may be negative), starting from ``. - **`tr/`** gap trainer: play `` bars, silence `` bars, repeat. -- **`vol`, `cd`** host-optional (the device has its own volume and no count-in). +- **`vol`, `cd`** web-authoring only — the device has a hardware volume knob and no count-in, so + it parses past these and does not carry them (see §6). --- @@ -189,21 +190,26 @@ the real audible payload, and the most important thing two implementations must --- -## 6. Known divergences (today) — the work list +## 6. Divergences — status -Surfaced by the runner; each has a `divergence` vector: +Surfaced by the runner. **Resolved** (now identical on web + firmware, verified by the suite): -1. **Euclid `(k,n,rot)`** — parsed by `engine.js`, **not** by firmware (`_parse_lane` can't - strip `(...)`, so it falls back to a plain unaccented bar). → Implement on device. -2. **`vol` / `cd`** — round-tripped by `engine.js`, **dropped** by firmware. → Firmware should - preserve them on serialize even though it ignores them at runtime. -3. **Unknown sound name** — `engine.js` falls back to `beep`; firmware keeps the raw name. - → Spec: unknown sound ⇒ `beep` everywhere. -4. **Tempo clamp** — firmware clamps `t` to `[5,300]`; `engine.js` does not. → Clamp everywhere. -5. **Empty patch** — firmware injects a default `beep:4` lane; `engine.js` returns zero lanes - (the editor guards emptiness separately). → Spec: ≥1 lane; empty ⇒ `beep:4`. -6. **`@db` gain** — parsed on both sides but **applied** only in the browser (runtime, not a - parse divergence; not a fixture failure — listed for completeness). +- **Default (no-pattern) groove** — every subdivision sounds; accent only on group starts. +- **Euclid `(k,n,rot)`** — now parsed by both engines (`kick:4(3,8)` etc.). +- **Unknown sound name** — falls back to `beep` on both. +- **GM note-number aliases** — `36:4` resolves to the voice name on both. + +**Intentional / permanent host differences** (not bugs — the device is a host that lacks these): + +- **`vol` (master volume) / `cd` (count-in)** — web-authoring fields. The device has a hardware + volume knob and no count-in, so it parses past them and does not carry them. (Contrast `@db` + gain, which the device *does* round-trip as a per-lane field even though it doesn't apply it.) + +**Still open — web side only** (`engine.js`, flagged `expectFail: ["js"]`): + +- **Tempo clamp** — firmware clamps `t` to `[5,300]`; `engine.js` does not yet. +- **Empty patch** — firmware injects a default `beep:4`; `engine.js` returns zero lanes (the + editor guards emptiness separately, so this may stay as-is). The capability/version handshake (the device already replies with its firmware version over SysEx) should gate features so the editor can warn when a track uses something the connected diff --git a/pico-cp/app.py b/pico-cp/app.py index 264942f..cdf6c04 100644 --- a/pico-cp/app.py +++ b/pico-cp/app.py @@ -263,6 +263,14 @@ gc.collect() # ============================== POLYMETER ENGINE (same semantics as the web/MicroPython) ============================== PAT = {'X': 2, 'x': 1, 'g': 3, '.': 0, '-': 0, '_': 0} PRIO = {2: 3, 1: 2, 3: 1} +# General-MIDI percussion note numbers -> voice names (so a lane can be typed as "36:4"); matches the web GM_NUM +GM_NUM = {35: "kick", 36: "kick", 37: "rim", 38: "snare", 39: "clap", 40: "snare", 41: "tomLow", 42: "hatClosed", + 43: "tomLow", 44: "hatClosed", 45: "tomMid", 46: "hatOpen", 47: "tomMid", 48: "tomHigh", 49: "crash", + 50: "tomHigh", 51: "ride", 53: "ride", 54: "tambourine", 56: "cowbell", 75: "claves", 76: "woodblock", 77: "woodblock"} + +def _euclid(k, n, rot): # even distribution: k hits over n steps, rotated (matches web euclid()) + n = max(1, n); k = max(0, min(n, k)); rot = ((rot % n) + n) % n + return [1 if ((((i + rot) % n) * k) % n) < k else 0 for i in range(n)] def parse_program(s): bpm = 120; lanes = []; bars = 0; ramp = None; trainer = None @@ -297,6 +305,15 @@ def _parse_lane(tok): gain = '' if '@' in tok: tok, _, g = tok.partition('@'); gain = '@' + g # preserve @db for round-trip (engine ignores it) sound, _, rest = tok.partition(':') + if sound.isdigit(): sound = GM_NUM.get(int(sound), sound) # GM note-number alias (e.g. 36 -> kick) + euc = None # euclidean (k,n,rot) shorthand - pulled before the =/ splits + lp = rest.find('(') + if lp >= 0: + rp = rest.find(')', lp) + if rp > lp: + nums = [int(x) for x in rest[lp + 1:rp].split(',') if x.strip().isdigit()] + rest = rest[:lp] + rest[rp + 1:] + if nums: euc = nums pattern = None if '=' in rest: rest, _, pattern = rest.partition('=') sub = 1; swing = False @@ -307,16 +324,27 @@ def _parse_lane(tok): groups = [int(g) for g in rest.split('+') if g.isdigit()] or [4] beats = sum(groups); starts = set(); acc = 0 for gp in groups: starts.add(acc); acc += gp - steps = beats * sub - if pattern: + if euc: # euclidean: k hits over n steps, first hit accented + k = euc[0]; n = euc[1] if len(euc) > 1 else beats * sub; rot = euc[2] if len(euc) > 2 else 0 + if len(euc) > 1: + if n % beats == 0: sub = n // beats + else: groups = [n]; sub = 1 + steps = n; levels = []; first = True + for h in _euclid(k, n, rot): + if h: levels.append(2 if first else 1); first = False + else: levels.append(0) + elif pattern: + steps = beats * sub levels = [PAT.get(ch, 0) for ch in pattern] if len(levels) < steps: levels += [0] * (steps - len(levels)) steps = len(levels) else: + steps = beats * sub levels = [] for i in range(steps): 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) + if sound not in SOUND_GM: sound = "beep" # unknown sound -> beep (match web) 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 5df79b4..8abc586 100644 --- a/pico-explorer/app.py +++ b/pico-explorer/app.py @@ -232,6 +232,14 @@ gc.collect() # ============================== POLYMETER ENGINE ============================== PAT = {'X': 2, 'x': 1, 'g': 3, '.': 0, '-': 0, '_': 0} PRIO = {2: 3, 1: 2, 3: 1} +# General-MIDI percussion note numbers -> voice names (so a lane can be typed as "36:4"); matches the web GM_NUM +GM_NUM = {35: "kick", 36: "kick", 37: "rim", 38: "snare", 39: "clap", 40: "snare", 41: "tomLow", 42: "hatClosed", + 43: "tomLow", 44: "hatClosed", 45: "tomMid", 46: "hatOpen", 47: "tomMid", 48: "tomHigh", 49: "crash", + 50: "tomHigh", 51: "ride", 53: "ride", 54: "tambourine", 56: "cowbell", 75: "claves", 76: "woodblock", 77: "woodblock"} + +def _euclid(k, n, rot): # even distribution: k hits over n steps, rotated (matches web euclid()) + n = max(1, n); k = max(0, min(n, k)); rot = ((rot % n) + n) % n + return [1 if ((((i + rot) % n) * k) % n) < k else 0 for i in range(n)] def parse_program(s): bpm = 120; lanes = []; bars = 0; ramp = None; trainer = None @@ -266,6 +274,15 @@ def _parse_lane(tok): gain = '' if '@' in tok: tok, _, g = tok.partition('@'); gain = '@' + g sound, _, rest = tok.partition(':') + if sound.isdigit(): sound = GM_NUM.get(int(sound), sound) # GM note-number alias (e.g. 36 -> kick) + euc = None # euclidean (k,n,rot) shorthand - pulled before the =/ splits + lp = rest.find('(') + if lp >= 0: + rp = rest.find(')', lp) + if rp > lp: + nums = [int(x) for x in rest[lp + 1:rp].split(',') if x.strip().isdigit()] + rest = rest[:lp] + rest[rp + 1:] + if nums: euc = nums pattern = None if '=' in rest: rest, _, pattern = rest.partition('=') sub = 1; swing = False @@ -276,16 +293,27 @@ def _parse_lane(tok): groups = [int(g) for g in rest.split('+') if g.isdigit()] or [4] beats = sum(groups); starts = set(); acc = 0 for gp in groups: starts.add(acc); acc += gp - steps = beats * sub - if pattern: + if euc: # euclidean: k hits over n steps, first hit accented + k = euc[0]; n = euc[1] if len(euc) > 1 else beats * sub; rot = euc[2] if len(euc) > 2 else 0 + if len(euc) > 1: + if n % beats == 0: sub = n // beats + else: groups = [n]; sub = 1 + steps = n; levels = []; first = True + for h in _euclid(k, n, rot): + if h: levels.append(2 if first else 1); first = False + else: levels.append(0) + elif pattern: + steps = beats * sub levels = [PAT.get(ch, 0) for ch in pattern] if len(levels) < steps: levels += [0] * (steps - len(levels)) steps = len(levels) else: + steps = beats * sub levels = [] for i in range(steps): 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) + if sound not in SOUND_GM: sound = "beep" # unknown sound -> beep (match web) return {'sound': sound, 'sub': sub, 'swing': swing, 'steps': steps, 'levels': levels, 'poly': poly, 'mute': mute, 'groups': groups, 'gain': gain} diff --git a/tests/adapters/py_adapter.py b/tests/adapters/py_adapter.py index 3ef8989..8917fc1 100644 --- a/tests/adapters/py_adapter.py +++ b/tests/adapters/py_adapter.py @@ -12,7 +12,7 @@ import os import sys APP = os.path.join(os.path.dirname(__file__), "..", "..", "pico-cp", "app.py") -WANT = {"PAT", "PRIO", "PAT_CH", "parse_program", "_parse_lane", "lane_to_str"} +WANT = {"PAT", "PRIO", "PAT_CH", "SOUND_GM", "GM_NUM", "_euclid", "parse_program", "_parse_lane", "lane_to_str"} with open(APP) as f: _src = f.read() diff --git a/tests/fixtures/track-format.json b/tests/fixtures/track-format.json index 05aa764..6cc0069 100644 --- a/tests/fixtures/track-format.json +++ b/tests/fixtures/track-format.json @@ -91,8 +91,7 @@ "id": "gm-note-number-alias", "status": "stable", "in": "t120;36:4=Xxxx", - "note": "GM note 36 = kick. engine.js maps it; firmware keeps the numeric sound but plays it via SOUND_GM. Names should resolve the same.", - "expectFail": ["py"], + "note": "GM note 36 = kick. Both engines resolve the number to the voice name.", "norm": { "bpm": 120, "bars": 0, "volume": null, "countMs": 0, "ramp": null, "trainer": null, "rep": null, "end": null, "lanes": [ { "sound": "kick", "groups": [4], "sub": 1, "swing": false, "poly": false, "mute": false, "gainDb": 0, "levels": [2,1,1,1] } ] } }, @@ -115,9 +114,8 @@ }, { "id": "euclid", - "status": "divergence", - "expectFail": ["py"], - "note": "Euclid (k,n) parsed by engine.js; firmware falls back to a plain bar. Spec = engine behavior.", + "status": "stable", + "note": "Euclid (k,n) shorthand: 3 hits spread over 8 steps, first accented. Parsed identically on both engines.", "in": "t120;kick:4(3,8)", "norm": { "bpm": 120, "bars": 0, "volume": null, "countMs": 0, "ramp": null, "trainer": null, "rep": null, "end": null, "lanes": [ { "sound": "kick", "groups": [4], "sub": 2, "swing": false, "poly": false, "mute": false, "gainDb": 0, "levels": [2,0,0,1,0,0,1,0] } ] } @@ -126,16 +124,15 @@ "id": "vol-and-countin", "status": "divergence", "expectFail": ["py"], - "note": "engine.js round-trips vol/cd; firmware drops them. Spec = preserve them.", + "note": "INTENTIONAL host difference (permanent, not a bug): vol (master volume) and cd (count-in) are web-authoring fields. The device has a hardware volume knob and no count-in, so it omits them. Kept as a vector to document the boundary.", "in": "t120;vol80;cd2;kick:4=Xxxx", "norm": { "bpm": 120, "bars": 0, "volume": 0.8, "countMs": 2000, "ramp": null, "trainer": null, "rep": null, "end": null, "lanes": [ { "sound": "kick", "groups": [4], "sub": 1, "swing": false, "poly": false, "mute": false, "gainDb": 0, "levels": [2,1,1,1] } ] } }, { "id": "unknown-sound", - "status": "divergence", - "expectFail": ["py"], - "note": "engine.js maps unknown sounds to beep; firmware keeps the raw name. Spec = beep.", + "status": "stable", + "note": "Unknown sound name falls back to beep on both engines.", "in": "t120;blorp:4=Xxxx", "norm": { "bpm": 120, "bars": 0, "volume": null, "countMs": 0, "ramp": null, "trainer": null, "rep": null, "end": null, "lanes": [ { "sound": "beep", "groups": [4], "sub": 1, "swing": false, "poly": false, "mute": false, "gainDb": 0, "levels": [2,1,1,1] } ] }