Firmware: parse euclid, GM note-numbers, and unknown-sound fallback

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) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-05-31 00:15:25 -05:00
parent 0dc9daf54f
commit 9701f49913
5 changed files with 89 additions and 30 deletions

View file

@ -66,8 +66,8 @@ patch = [ "v1" ";" ] directive *( ";" directive ) ;
directive = tempo | volume | countin | bars | trainer | ramp | rep | end | lane ; directive = tempo | volume | countin | bars | trainer | ramp | rep | end | lane ;
tempo = "t" int ; (* beats per minute, clamped to 5..300 *) tempo = "t" int ; (* beats per minute, clamped to 5..300 *)
volume = "vol" int ; (* 0..100, host-optional — device ignores *) volume = "vol" int ; (* 0..100; web-authoring only — device has a hardware volume knob *)
countin = "cd" int ; (* count-in seconds, host-optional *) countin = "cd" int ; (* count-in seconds; web-authoring only — device has no count-in *)
bars = "b" int ; (* cycle length in bars; drives Continue/rep *) bars = "b" int ; (* cycle length in bars; drives Continue/rep *)
trainer = "tr" int "/" int ; (* playBars "/" muteBars (gap trainer) *) trainer = "tr" int "/" int ; (* playBars "/" muteBars (gap trainer) *)
ramp = "rmp" int "/" signed "/" int ; (* startBpm "/" amount "/" everyBars *) ramp = "rmp" int "/" signed "/" int ; (* startBpm "/" amount "/" everyBars *)
@ -110,7 +110,8 @@ pattern = *( "X" | "x" | "g" | "." | "-" | "_" ) ; (* per-step dynamics *)
- **`rmp<start>/<amt>/<every>`** tempo ramp: every `<every>` bars, change tempo by `<amt>` - **`rmp<start>/<amt>/<every>`** tempo ramp: every `<every>` bars, change tempo by `<amt>`
(may be negative), starting from `<start>`. (may be negative), starting from `<start>`.
- **`tr<play>/<mute>`** gap trainer: play `<play>` bars, silence `<mute>` bars, repeat. - **`tr<play>/<mute>`** gap trainer: play `<play>` bars, silence `<mute>` 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 - **Default (no-pattern) groove** — every subdivision sounds; accent only on group starts.
strip `(...)`, so it falls back to a plain unaccented bar). → Implement on device. - **Euclid `(k,n,rot)`** — now parsed by both engines (`kick:4(3,8)` etc.).
2. **`vol` / `cd`** — round-tripped by `engine.js`, **dropped** by firmware. → Firmware should - **Unknown sound name** — falls back to `beep` on both.
preserve them on serialize even though it ignores them at runtime. - **GM note-number aliases**`36:4` resolves to the voice name on both.
3. **Unknown sound name**`engine.js` falls back to `beep`; firmware keeps the raw name.
→ Spec: unknown sound ⇒ `beep` everywhere. **Intentional / permanent host differences** (not bugs — the device is a host that lacks these):
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 - **`vol` (master volume) / `cd` (count-in)** — web-authoring fields. The device has a hardware
(the editor guards emptiness separately). → Spec: ≥1 lane; empty ⇒ `beep:4`. volume knob and no count-in, so it parses past them and does not carry them. (Contrast `@db`
6. **`@db` gain** — parsed on both sides but **applied** only in the browser (runtime, not a gain, which the device *does* round-trip as a per-lane field even though it doesn't apply it.)
parse divergence; not a fixture failure — listed for completeness).
**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 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 SysEx) should gate features so the editor can warn when a track uses something the connected

View file

@ -263,6 +263,14 @@ gc.collect()
# ============================== POLYMETER ENGINE (same semantics as the web/MicroPython) ============================== # ============================== POLYMETER ENGINE (same semantics as the web/MicroPython) ==============================
PAT = {'X': 2, 'x': 1, 'g': 3, '.': 0, '-': 0, '_': 0} PAT = {'X': 2, 'x': 1, 'g': 3, '.': 0, '-': 0, '_': 0}
PRIO = {2: 3, 1: 2, 3: 1} 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): def parse_program(s):
bpm = 120; lanes = []; bars = 0; ramp = None; trainer = None bpm = 120; lanes = []; bars = 0; ramp = None; trainer = None
@ -297,6 +305,15 @@ def _parse_lane(tok):
gain = '' gain = ''
if '@' in tok: tok, _, g = tok.partition('@'); gain = '@' + g # preserve @db for round-trip (engine ignores it) if '@' in tok: tok, _, g = tok.partition('@'); gain = '@' + g # preserve @db for round-trip (engine ignores it)
sound, _, rest = tok.partition(':') 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 pattern = None
if '=' in rest: rest, _, pattern = rest.partition('=') if '=' in rest: rest, _, pattern = rest.partition('=')
sub = 1; swing = False 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] groups = [int(g) for g in rest.split('+') if g.isdigit()] or [4]
beats = sum(groups); starts = set(); acc = 0 beats = sum(groups); starts = set(); acc = 0
for gp in groups: starts.add(acc); acc += gp for gp in groups: starts.add(acc); acc += gp
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 steps = beats * sub
if pattern:
levels = [PAT.get(ch, 0) for ch in pattern] levels = [PAT.get(ch, 0) for ch in pattern]
if len(levels) < steps: levels += [0] * (steps - len(levels)) if len(levels) < steps: levels += [0] * (steps - len(levels))
steps = len(levels) steps = len(levels)
else: else:
steps = beats * sub
levels = [] levels = []
for i in range(steps): for i in range(steps):
if i % sub == 0: levels.append(2 if (i // sub) in starts else 1) # beat: accent on group starts 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) 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, return {'sound': sound, 'sub': sub, 'swing': swing, 'steps': steps, 'levels': levels,
'poly': poly, 'mute': mute, 'groups': groups, 'gain': gain} 'poly': poly, 'mute': mute, 'groups': groups, 'gain': gain}

View file

@ -232,6 +232,14 @@ gc.collect()
# ============================== POLYMETER ENGINE ============================== # ============================== POLYMETER ENGINE ==============================
PAT = {'X': 2, 'x': 1, 'g': 3, '.': 0, '-': 0, '_': 0} PAT = {'X': 2, 'x': 1, 'g': 3, '.': 0, '-': 0, '_': 0}
PRIO = {2: 3, 1: 2, 3: 1} 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): def parse_program(s):
bpm = 120; lanes = []; bars = 0; ramp = None; trainer = None bpm = 120; lanes = []; bars = 0; ramp = None; trainer = None
@ -266,6 +274,15 @@ def _parse_lane(tok):
gain = '' gain = ''
if '@' in tok: tok, _, g = tok.partition('@'); gain = '@' + g if '@' in tok: tok, _, g = tok.partition('@'); gain = '@' + g
sound, _, rest = tok.partition(':') 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 pattern = None
if '=' in rest: rest, _, pattern = rest.partition('=') if '=' in rest: rest, _, pattern = rest.partition('=')
sub = 1; swing = False 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] groups = [int(g) for g in rest.split('+') if g.isdigit()] or [4]
beats = sum(groups); starts = set(); acc = 0 beats = sum(groups); starts = set(); acc = 0
for gp in groups: starts.add(acc); acc += gp for gp in groups: starts.add(acc); acc += gp
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 steps = beats * sub
if pattern:
levels = [PAT.get(ch, 0) for ch in pattern] levels = [PAT.get(ch, 0) for ch in pattern]
if len(levels) < steps: levels += [0] * (steps - len(levels)) if len(levels) < steps: levels += [0] * (steps - len(levels))
steps = len(levels) steps = len(levels)
else: else:
steps = beats * sub
levels = [] levels = []
for i in range(steps): for i in range(steps):
if i % sub == 0: levels.append(2 if (i // sub) in starts else 1) # beat: accent on group starts 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) 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, return {'sound': sound, 'sub': sub, 'swing': swing, 'steps': steps, 'levels': levels,
'poly': poly, 'mute': mute, 'groups': groups, 'gain': gain} 'poly': poly, 'mute': mute, 'groups': groups, 'gain': gain}

View file

@ -12,7 +12,7 @@ import os
import sys import sys
APP = os.path.join(os.path.dirname(__file__), "..", "..", "pico-cp", "app.py") 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: with open(APP) as f:
_src = f.read() _src = f.read()

View file

@ -91,8 +91,7 @@
"id": "gm-note-number-alias", "id": "gm-note-number-alias",
"status": "stable", "status": "stable",
"in": "t120;36:4=Xxxx", "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.", "note": "GM note 36 = kick. Both engines resolve the number to the voice name.",
"expectFail": ["py"],
"norm": { "bpm": 120, "bars": 0, "volume": null, "countMs": 0, "ramp": null, "trainer": null, "rep": null, "end": null, "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] } ] } "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", "id": "euclid",
"status": "divergence", "status": "stable",
"expectFail": ["py"], "note": "Euclid (k,n) shorthand: 3 hits spread over 8 steps, first accented. Parsed identically on both engines.",
"note": "Euclid (k,n) parsed by engine.js; firmware falls back to a plain bar. Spec = engine behavior.",
"in": "t120;kick:4(3,8)", "in": "t120;kick:4(3,8)",
"norm": { "bpm": 120, "bars": 0, "volume": null, "countMs": 0, "ramp": null, "trainer": null, "rep": null, "end": null, "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] } ] } "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", "id": "vol-and-countin",
"status": "divergence", "status": "divergence",
"expectFail": ["py"], "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", "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, "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] } ] } "lanes": [ { "sound": "kick", "groups": [4], "sub": 1, "swing": false, "poly": false, "mute": false, "gainDb": 0, "levels": [2,1,1,1] } ] }
}, },
{ {
"id": "unknown-sound", "id": "unknown-sound",
"status": "divergence", "status": "stable",
"expectFail": ["py"], "note": "Unknown sound name falls back to beep on both engines.",
"note": "engine.js maps unknown sounds to beep; firmware keeps the raw name. Spec = beep.",
"in": "t120;blorp:4=Xxxx", "in": "t120;blorp:4=Xxxx",
"norm": { "bpm": 120, "bars": 0, "volume": null, "countMs": 0, "ramp": null, "trainer": null, "rep": null, "end": null, "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] } ] } "lanes": [ { "sound": "beep", "groups": [4], "sub": 1, "swing": false, "poly": false, "mute": false, "gainDb": 0, "levels": [2,1,1,1] } ] }