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 ;
|
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
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
15
tests/fixtures/track-format.json
vendored
15
tests/fixtures/track-format.json
vendored
|
|
@ -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] } ] }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue