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:
parent
0dc9daf54f
commit
9701f49913
5 changed files with 89 additions and 30 deletions
|
|
@ -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<start>/<amt>/<every>`** tempo ramp: every `<every>` bars, change tempo by `<amt>`
|
||||
(may be negative), starting from `<start>`.
|
||||
- **`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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
15
tests/fixtures/track-format.json
vendored
15
tests/fixtures/track-format.json
vendored
|
|
@ -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] } ] }
|
||||
|
|
|
|||
Loading…
Reference in a new issue