fractal-midi 0.1.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/LICENSE +200 -0
  2. package/NOTICE +28 -0
  3. package/README.md +147 -0
  4. package/dist/am4/applicability.d.ts +61 -0
  5. package/dist/am4/applicability.d.ts.map +1 -0
  6. package/dist/am4/applicability.js +285 -0
  7. package/dist/am4/blockTypes.d.ts +43 -0
  8. package/dist/am4/blockTypes.d.ts.map +1 -0
  9. package/dist/am4/blockTypes.js +48 -0
  10. package/dist/am4/cacheEnums.d.ts +46 -0
  11. package/dist/am4/cacheEnums.d.ts.map +1 -0
  12. package/dist/am4/cacheEnums.js +734 -0
  13. package/dist/am4/cacheParams.d.ts +3533 -0
  14. package/dist/am4/cacheParams.d.ts.map +1 -0
  15. package/dist/am4/cacheParams.js +1996 -0
  16. package/dist/am4/editorControlLabels.d.ts +45 -0
  17. package/dist/am4/editorControlLabels.d.ts.map +1 -0
  18. package/dist/am4/editorControlLabels.js +15894 -0
  19. package/dist/am4/index.d.ts +28 -0
  20. package/dist/am4/index.d.ts.map +1 -0
  21. package/dist/am4/index.js +31 -0
  22. package/dist/am4/ir/preset.d.ts +24 -0
  23. package/dist/am4/ir/preset.d.ts.map +1 -0
  24. package/dist/am4/ir/preset.js +12 -0
  25. package/dist/am4/ir/transpile.d.ts +9 -0
  26. package/dist/am4/ir/transpile.d.ts.map +1 -0
  27. package/dist/am4/ir/transpile.js +19 -0
  28. package/dist/am4/locations.d.ts +32 -0
  29. package/dist/am4/locations.d.ts.map +1 -0
  30. package/dist/am4/locations.js +58 -0
  31. package/dist/am4/paramNames.d.ts +55 -0
  32. package/dist/am4/paramNames.d.ts.map +1 -0
  33. package/dist/am4/paramNames.js +863 -0
  34. package/dist/am4/paramNamesGenerated.d.ts +41 -0
  35. package/dist/am4/paramNamesGenerated.d.ts.map +1 -0
  36. package/dist/am4/paramNamesGenerated.js +183 -0
  37. package/dist/am4/parameterBridge.d.ts +46 -0
  38. package/dist/am4/parameterBridge.d.ts.map +1 -0
  39. package/dist/am4/parameterBridge.js +300 -0
  40. package/dist/am4/params.d.ts +9577 -0
  41. package/dist/am4/params.d.ts.map +1 -0
  42. package/dist/am4/params.js +4537 -0
  43. package/dist/am4/setParam.d.ts +414 -0
  44. package/dist/am4/setParam.d.ts.map +1 -0
  45. package/dist/am4/setParam.js +819 -0
  46. package/dist/am4/shared/paramHelpers.d.ts +55 -0
  47. package/dist/am4/shared/paramHelpers.d.ts.map +1 -0
  48. package/dist/am4/shared/paramHelpers.js +146 -0
  49. package/dist/am4/symbolicIds.d.ts +11 -0
  50. package/dist/am4/symbolicIds.d.ts.map +1 -0
  51. package/dist/am4/symbolicIds.js +587 -0
  52. package/dist/am4/typeApplicability.d.ts +39 -0
  53. package/dist/am4/typeApplicability.d.ts.map +1 -0
  54. package/dist/am4/typeApplicability.js +466 -0
  55. package/dist/am4/variantResolverTables.d.ts +51 -0
  56. package/dist/am4/variantResolverTables.d.ts.map +1 -0
  57. package/dist/am4/variantResolverTables.js +3128 -0
  58. package/dist/axe-fx-ii/blockTypes.d.ts +45 -0
  59. package/dist/axe-fx-ii/blockTypes.d.ts.map +1 -0
  60. package/dist/axe-fx-ii/blockTypes.js +116 -0
  61. package/dist/axe-fx-ii/index.d.ts +5 -0
  62. package/dist/axe-fx-ii/index.d.ts.map +1 -0
  63. package/dist/axe-fx-ii/index.js +18 -0
  64. package/dist/axe-fx-ii/paramAliases.d.ts +54 -0
  65. package/dist/axe-fx-ii/paramAliases.d.ts.map +1 -0
  66. package/dist/axe-fx-ii/paramAliases.js +146 -0
  67. package/dist/axe-fx-ii/params.d.ts +11502 -0
  68. package/dist/axe-fx-ii/params.d.ts.map +1 -0
  69. package/dist/axe-fx-ii/params.js +2847 -0
  70. package/dist/axe-fx-ii/setParam.d.ts +560 -0
  71. package/dist/axe-fx-ii/setParam.d.ts.map +1 -0
  72. package/dist/axe-fx-ii/setParam.js +888 -0
  73. package/dist/axe-fx-iii/blockTypes.d.ts +87 -0
  74. package/dist/axe-fx-iii/blockTypes.d.ts.map +1 -0
  75. package/dist/axe-fx-iii/blockTypes.js +156 -0
  76. package/dist/axe-fx-iii/enumOverlay.d.ts +73 -0
  77. package/dist/axe-fx-iii/enumOverlay.d.ts.map +1 -0
  78. package/dist/axe-fx-iii/enumOverlay.js +236 -0
  79. package/dist/axe-fx-iii/index.d.ts +9 -0
  80. package/dist/axe-fx-iii/index.d.ts.map +1 -0
  81. package/dist/axe-fx-iii/index.js +20 -0
  82. package/dist/axe-fx-iii/params.d.ts +179 -0
  83. package/dist/axe-fx-iii/params.d.ts.map +1 -0
  84. package/dist/axe-fx-iii/params.js +6913 -0
  85. package/dist/axe-fx-iii/setParam.d.ts +460 -0
  86. package/dist/axe-fx-iii/setParam.d.ts.map +1 -0
  87. package/dist/axe-fx-iii/setParam.js +910 -0
  88. package/dist/index.d.ts +2 -0
  89. package/dist/index.d.ts.map +1 -0
  90. package/dist/index.js +12 -0
  91. package/dist/shared/checksum.d.ts +10 -0
  92. package/dist/shared/checksum.d.ts.map +1 -0
  93. package/dist/shared/checksum.js +14 -0
  94. package/dist/shared/device.d.ts +195 -0
  95. package/dist/shared/device.d.ts.map +1 -0
  96. package/dist/shared/device.js +27 -0
  97. package/dist/shared/index.d.ts +8 -0
  98. package/dist/shared/index.d.ts.map +1 -0
  99. package/dist/shared/index.js +11 -0
  100. package/dist/shared/lineage/amp-lineage.json +8313 -0
  101. package/dist/shared/lineage/axefx2-amp-lineage.json +5871 -0
  102. package/dist/shared/lineage/axefx2-delay-lineage.json +226 -0
  103. package/dist/shared/lineage/axefx2-drive-lineage.json +575 -0
  104. package/dist/shared/lineage/axefx2-reverb-lineage.json +467 -0
  105. package/dist/shared/lineage/cab-lineage.json +10777 -0
  106. package/dist/shared/lineage/chorus-lineage.json +173 -0
  107. package/dist/shared/lineage/compressor-lineage.json +338 -0
  108. package/dist/shared/lineage/delay-lineage.json +313 -0
  109. package/dist/shared/lineage/drive-lineage.json +1844 -0
  110. package/dist/shared/lineage/flanger-lineage.json +313 -0
  111. package/dist/shared/lineage/phaser-lineage.json +208 -0
  112. package/dist/shared/lineage/reverb-lineage.json +793 -0
  113. package/dist/shared/lineage/wah-lineage.json +117 -0
  114. package/dist/shared/lineageLookup.d.ts +69 -0
  115. package/dist/shared/lineageLookup.d.ts.map +1 -0
  116. package/dist/shared/lineageLookup.js +196 -0
  117. package/dist/shared/packValue.d.ts +40 -0
  118. package/dist/shared/packValue.d.ts.map +1 -0
  119. package/dist/shared/packValue.js +105 -0
  120. package/dist/shared/types.d.ts +23 -0
  121. package/dist/shared/types.d.ts.map +1 -0
  122. package/dist/shared/types.js +9 -0
  123. package/package.json +75 -0
@@ -0,0 +1,910 @@
1
+ /**
2
+ * Axe-Fx III SysEx wire builders.
3
+ *
4
+ * BEFORE EDITING THIS FILE, READ:
5
+ * - `docs/SYSEX-MAP-AXE-FX-III.md` (project spec summary + known bugs)
6
+ * - `docs/manuals/AxeFx3-MIDI-3rdParty.txt` (Fractal v1.4 PDF, extracted)
7
+ *
8
+ * The v1.4 PDF is the only public spec Fractal ships for the III's
9
+ * third-party MIDI surface. It IS in this repo as extracted text.
10
+ * Don't web-search or guess opcodes — grep the .txt first.
11
+ *
12
+ * Envelope: `F0 00 01 74 0x10 [function] [payload...] [checksum] F7`.
13
+ * Same modern Fractal family as AM4 (model 0x15), FM3 (0x11), FM9
14
+ * (0x12), VP4 (0x14) — III is 0x10.
15
+ *
16
+ * Function-byte map (all opcodes documented in the PDF):
17
+ * - 0x0A SET/GET BYPASS (id id dd)
18
+ * - 0x0B SET/GET CHANNEL (id id dd)
19
+ * - 0x0C SET/GET SCENE (dd)
20
+ * - 0x0D QUERY PATCH NAME (dd dd — preset number; returns nn nn + 32-char name)
21
+ * - 0x0E QUERY SCENE NAME (dd — scene index; returns nn + 32-char name)
22
+ * - 0x0F SET/GET LOOPER STATE (dd — button index; returns state bitfield)
23
+ * - 0x10 TEMPO TAP (no payload; also the "tempo down-beat" push frame)
24
+ * - 0x11 TUNER ON/OFF (dd; push variant carries note/string/cents)
25
+ * - 0x13 STATUS DUMP (no payload; returns id id dd triples)
26
+ * - 0x14 SET/GET TEMPO (dd dd — BPM)
27
+ *
28
+ * NOT documented in v1.4 (deliberately omitted by Fractal):
29
+ * - SET_PRESET / SWITCH_PRESET — use MIDI Program Change (CC0/CC32 + PC).
30
+ * - SET_PARAMETER_VALUE (0x02) — family inference only; param-IDs not public.
31
+ * - STORE_PRESET / SAVE — multi-frame envelope (0x77/0x78/0x79) per
32
+ * community RE; not in v1.4.
33
+ * - SET_PRESET_NAME / SET_SCENE_NAME — names are query-only.
34
+ */
35
+ import { fractalChecksum } from '../shared/checksum.js';
36
+ /** Axe-Fx III model byte. From Fractal's published spec. */
37
+ export const AXE_FX_III_MODEL_ID = 0x10;
38
+ /** SysEx framing bytes shared across the entire modern Fractal family. */
39
+ const SYSEX_START = 0xf0;
40
+ const SYSEX_END = 0xf7;
41
+ const FRACTAL_MFR_PREFIX = [0x00, 0x01, 0x74];
42
+ // ── Function-ID bytes from the Axe-Fx III spec v1.4 ────────────────
43
+ export const FN_SET_GET_BYPASS = 0x0a;
44
+ export const FN_SET_GET_CHANNEL = 0x0b;
45
+ export const FN_SET_GET_SCENE = 0x0c;
46
+ export const FN_QUERY_PATCH_NAME = 0x0d;
47
+ export const FN_QUERY_SCENE_NAME = 0x0e;
48
+ export const FN_SET_GET_LOOPER = 0x0f;
49
+ export const FN_TEMPO_TAP = 0x10;
50
+ export const FN_TUNER_ON_OFF = 0x11;
51
+ export const FN_STATUS_DUMP = 0x13;
52
+ export const FN_SET_GET_TEMPO = 0x14;
53
+ /**
54
+ * 0x64 MULTIPURPOSE_RESPONSE — the III's error channel.
55
+ *
56
+ * When the III receives a malformed SysEx or an unsupported function it
57
+ * replies with:
58
+ *
59
+ * `F0 00 01 74 10 64 [echoed_fn] [result_code] [cs] F7` (10 bytes)
60
+ *
61
+ * `echoed_fn` is the function byte the host sent that the device
62
+ * rejected; `result_code` is the device's reason byte (0x00 has been
63
+ * seen for "general / checksum error", 0x05 has been seen for "NACK"
64
+ * during preset-store experiments). Wire shape is documented in v1.4
65
+ * and confirmed against a 2018 community capture — see
66
+ * `docs/axefx3-fn01-decode.md`.
67
+ */
68
+ export const FN_MULTIPURPOSE_RESPONSE = 0x64;
69
+ /**
70
+ * 0x01 PARAMETER_SETGET — III parameter-write opcode (NOT the II's
71
+ * 0x02 opcode). **Not in the v1.4 III spec** (Fractal deliberately
72
+ * omits parameter writes), but the wire shape is byte-verified
73
+ * against 10 community-captured frames spanning two effect blocks
74
+ * and two sub-action codes — see `docs/axefx3-set-parameter-captures.md`.
75
+ *
76
+ * Evidence chain (Session 97 pivot, 2026-05-18):
77
+ * • FC-12 footswitch captures (4 frames, Session 79 era): Drive 1/2
78
+ * boost ON/OFF. Effect IDs 58/59 (`ID_DISTORT1` / `ID_DISTORT2`),
79
+ * paramId 40, sub-action `52 00` (mouse-drag). Already decoded
80
+ * into the field-layout table in `docs/axefx3-fn01-decode.md`.
81
+ * • Mountain Utilities forum captures (6 frames, gabbernutter
82
+ * 2019-03-13): AxeEdit III writing Delay 1 TIME. Effect ID 70
83
+ * (`ID_DELAY1`), paramId 2. Four frames sub-action `52 00`
84
+ * (mouse-drag, intermediate values mid-drag) + two frames
85
+ * sub-action `09 00` (typed-input, final value). All 10 frames
86
+ * are 23 bytes, checksums validate, fields decode cleanly.
87
+ * • Session 82 Ghidra mining: opcode 0x01 appears in the III
88
+ * message-builder caller list — firmware code path is present.
89
+ *
90
+ * Earlier sessions (85+86) shipped `FN_SET_PARAMETER = 0x02` as a
91
+ * II→III model-byte-swap port. That was WRONG — the III uses fn=0x01
92
+ * with a 2-byte sub-action discriminator, NOT fn=0x02. Session 97
93
+ * reverted to the byte-verified envelope.
94
+ *
95
+ * Sub-actions seen on the wire:
96
+ * • `09 00` — typed-input SET (clean envelope, drag-context bytes
97
+ * zero). This is what we ship for `buildSetParameter`.
98
+ * • `52 00` — mouse-drag SET (drag-context bytes at pos 12-14
99
+ * carry cursor delta). Identical semantically; we don't emit
100
+ * this shape — the device accepts either.
101
+ * • `04 01` — STATE_BROADCAST (device→host, unsolicited state
102
+ * stream emitted on parameter change). NOT a sync SET response
103
+ * — the III appears to have no documented synchronous response
104
+ * to fn=0x01 SET.
105
+ *
106
+ * Status: 🟢 SET verified against 10 public captures, ready to ship.
107
+ * GET shape still 🟡 — no captured GET frames exist on the open web
108
+ * as of Session 97; the implementation uses `09 00` with value=0 as
109
+ * a hypothesis, matching the SET shape with an empty value field.
110
+ */
111
+ export const FN_PARAMETER_SETGET = 0x01;
112
+ /** III parameter SETGET sub-action codes (pos 6-7 of the envelope). */
113
+ const SUB_ACTION_SET_TYPED = [0x09, 0x00];
114
+ const SUB_ACTION_STATE_BROADCAST = [0x04, 0x01];
115
+ /** Query sentinel — when this is the value byte, the device responds with current state. */
116
+ export const QUERY_SENTINEL = 0x7f;
117
+ // ── Encoding helpers ───────────────────────────────────────────────
118
+ /**
119
+ * Encode a 14-bit value as a 2-byte septet pair (low 7 bits, then high
120
+ * 7 bits — little-endian). Preset numbers, BPMs, and effect IDs across
121
+ * the Fractal family use this.
122
+ */
123
+ function encode14(n) {
124
+ if (!Number.isInteger(n) || n < 0 || n > 0x3fff) {
125
+ throw new Error(`encode14: ${n} out of range (0..16383)`);
126
+ }
127
+ return [n & 0x7f, (n >> 7) & 0x7f];
128
+ }
129
+ /** Decode a 2-byte septet pair (low 7 bits then high 7 bits) into a 14-bit integer. */
130
+ function decode14(lo, hi) {
131
+ return (lo & 0x7f) | ((hi & 0x7f) << 7);
132
+ }
133
+ /**
134
+ * Build an envelope: `F0 00 01 74 [model] [function] [payload...]
135
+ * [checksum] F7`. Checksum covers everything from `F0` through the
136
+ * last payload byte (XOR-7bit).
137
+ */
138
+ function buildEnvelope(fn, payload) {
139
+ const body = [SYSEX_START, ...FRACTAL_MFR_PREFIX, AXE_FX_III_MODEL_ID, fn, ...payload];
140
+ const checksum = fractalChecksum(body);
141
+ return [...body, checksum, SYSEX_END];
142
+ }
143
+ // ── 0x01 PARAMETER_SETGET ─────────────────────────────────────────
144
+ //
145
+ // 23-byte envelope, byte-verified against 10 community captures
146
+ // (4 FC-12 + 6 Mountain Utilities forum). See FN_PARAMETER_SETGET
147
+ // doc-comment above for the evidence chain and
148
+ // `docs/axefx3-set-parameter-captures.md` for the captured frames.
149
+ //
150
+ // Wire layout (verified):
151
+ // pos 0-5: F0 00 01 74 10 01 (envelope + fn=0x01)
152
+ // pos 6-7: sub-action (09 00 typed, 52 00 mouse-drag)
153
+ // pos 8-9: effect ID (LS-first septet pair, 14-bit)
154
+ // pos 10-11: paramId (LS-first septet pair, 14-bit)
155
+ // pos 12-14: drag-context bytes (zero for typed-input SET)
156
+ // pos 15-17: value (packValue16 — 3 septets, supports 16-bit
157
+ // values; observed III params use ≤14-bit
158
+ // so pos 17 is zero in every public capture)
159
+ // pos 18-20: reserved zeros
160
+ // pos 21: checksum
161
+ // pos 22: F7
162
+ /**
163
+ * Pack a 16-bit unsigned value into the wire's three 7-bit septets.
164
+ *
165
+ * septet 0 = bits 6..0 (lowest seven bits)
166
+ * septet 1 = bits 13..7 (next seven bits)
167
+ * septet 2 = bits 15..14 (top two bits, zero-padded into a 7-bit byte)
168
+ *
169
+ * Valid input range 0..65534 (16-bit minus one — II wiki convention,
170
+ * carried forward to the III on the assumption param-value ranges
171
+ * scaled with firmware). All observed III captures use 14-bit values
172
+ * (pos 17 always zero); the 16-bit slot exists in the envelope shape
173
+ * but isn't exercised by any public capture yet.
174
+ */
175
+ export function packValue16(value) {
176
+ if (!Number.isInteger(value) || value < 0 || value > 0xffff) {
177
+ throw new Error(`packValue16 input out of range: ${value}`);
178
+ }
179
+ return [
180
+ value & 0x7f,
181
+ (value >> 7) & 0x7f,
182
+ (value >> 14) & 0x03,
183
+ ];
184
+ }
185
+ /** Inverse of `packValue16`. Inputs may have unused upper bits — masked. */
186
+ export function unpackValue16(b0, b1, b2) {
187
+ return ((b0 & 0x7f)) | ((b1 & 0x7f) << 7) | ((b2 & 0x03) << 14);
188
+ }
189
+ /**
190
+ * SET PARAMETER (function 0x01, sub-action 0x09 0x00 — typed input).
191
+ *
192
+ * `F0 00 01 74 10 01 09 00 [id_lo id_hi] [pid_lo pid_hi]
193
+ * 00 00 00 [v0 v1 v2] 00 00 00 [cs] F7`
194
+ *
195
+ * 23 bytes. Byte-verified against the typed-input gabbernutter
196
+ * captures (Mountain Utilities forum, 2019). The mouse-drag form
197
+ * (sub-action `52 00`) carries non-zero context at pos 12-14 — the
198
+ * device accepts either, but typed-input is the clean shape
199
+ * appropriate for programmatic writes.
200
+ *
201
+ * 🟢 Outbound wire shape verified across 10 public captures spanning
202
+ * two effect blocks (Drive 1/2, Delay 1) and two paramIds (40 boost,
203
+ * 2 TIME). The device's RESPONSE shape (sync echo or async
204
+ * STATE_BROADCAST `04 01`) is not in any public capture — wrap with
205
+ * `sendAndWatchForError` to surface 0x64 rejects, but don't expect a
206
+ * synchronous SET echo.
207
+ */
208
+ export function buildSetParameter(effectId, paramId, value) {
209
+ return buildEnvelope(FN_PARAMETER_SETGET, [
210
+ ...SUB_ACTION_SET_TYPED,
211
+ ...encode14(effectId),
212
+ ...encode14(paramId),
213
+ 0x00, 0x00, 0x00,
214
+ ...packValue16(value),
215
+ 0x00, 0x00, 0x00,
216
+ ]);
217
+ }
218
+ /**
219
+ * GET PARAMETER (function 0x01, sub-action 0x09 0x00 with value=0).
220
+ *
221
+ * 🟡 Hypothesis only — no public GET capture exists. The send shape
222
+ * mirrors SET with the value field zeroed, on the theory that the III
223
+ * either echoes the param's current value or emits a `04 01`
224
+ * STATE_BROADCAST asynchronously. Callers should treat a missing
225
+ * response within ~250 ms as "GET not supported on this firmware,"
226
+ * not as a tool error, and fall back to 0x13 STATUS_DUMP or
227
+ * STATE_BROADCAST listening.
228
+ */
229
+ export function buildGetParameter(effectId, paramId) {
230
+ return buildEnvelope(FN_PARAMETER_SETGET, [
231
+ ...SUB_ACTION_SET_TYPED,
232
+ ...encode14(effectId),
233
+ ...encode14(paramId),
234
+ 0x00, 0x00, 0x00,
235
+ 0x00, 0x00, 0x00,
236
+ 0x00, 0x00, 0x00,
237
+ ]);
238
+ }
239
+ /**
240
+ * Block-bypass via PARAMETER_SETGET (paramId 255 is the bypass
241
+ * register per Axe-Fx II wiki — III binding unverified). The III
242
+ * v1.4 spec exposes a separate 0x0A SET_BYPASS opcode — prefer that
243
+ * one for production bypass writes. This builder exists as a
244
+ * fallback for the 0x02-port era and is kept compatible with the
245
+ * pivoted fn=0x01 envelope.
246
+ *
247
+ * 🟡 III-untested specifically for paramId=255 binding.
248
+ */
249
+ export function buildSetParameterBypass(effectId, bypassed) {
250
+ return buildSetParameter(effectId, 255, bypassed ? 1 : 0);
251
+ }
252
+ /**
253
+ * Predicate: is this an inbound fn=0x01 PARAMETER frame? Accepts any
254
+ * sub-action — `52 00` (echo of host SET, observed in passive sniffs),
255
+ * `04 01` (STATE_BROADCAST), or `09 00` (theoretically a host
256
+ * typed-input echo). The parser disambiguates by sub-action.
257
+ */
258
+ export function isSetGetParameterResponse(bytes) {
259
+ return isAxeFxIIIFrame(bytes, FN_PARAMETER_SETGET);
260
+ }
261
+ /**
262
+ * Parse an inbound fn=0x01 PARAMETER frame. Returns
263
+ * `{ kind, effectId, paramId, value, subAction }`.
264
+ *
265
+ * Two response shapes seen in captures:
266
+ * • Sub-action `52 00` (23 bytes): host-SET echo. effId at pos 2-3
267
+ * of payload, paramId at 4-5, value at 9-11 (packValue16). Round-
268
+ * trip self-consistent with `buildSetParameter`.
269
+ * • Sub-action `04 01` (23 bytes): STATE_BROADCAST. effId at
270
+ * pos 2-3, paramId field is zero (the broadcast doesn't carry
271
+ * it), value at 6-7 as a 2-septet LS-first pair.
272
+ *
273
+ * For `04 01` STATE_BROADCAST frames we return `paramId: 0` to
274
+ * signal the caller that paramId is unknown — they should track
275
+ * which param was last SET to attribute the broadcast value.
276
+ *
277
+ * For consumers that prefer an explicit broadcast handler, see
278
+ * `parseStateBroadcast`, which throws on non-broadcast frames.
279
+ */
280
+ export function parseSetGetParameterResponse(bytes) {
281
+ if (!isSetGetParameterResponse(bytes)) {
282
+ throw new Error(`parseSetGetParameterResponse: not a fn=0x01 frame (len=${bytes.length})`);
283
+ }
284
+ const payload = bytes.slice(6, -2);
285
+ if (payload.length < 15) {
286
+ throw new Error(`parseSetGetParameterResponse: payload too short (${payload.length}B; expected ≥15)`);
287
+ }
288
+ const subAction = (payload[0] & 0x7f) | ((payload[1] & 0x7f) << 7);
289
+ if (payload[0] === 0x04 && payload[1] === 0x01) {
290
+ // STATE_BROADCAST — different field layout (no paramId slot, value
291
+ // at pos 6-7 as a 2-septet pair, optional flag at pos 8).
292
+ return {
293
+ kind: 'state_broadcast',
294
+ effectId: decode14(payload[2], payload[3]),
295
+ paramId: 0,
296
+ value: decode14(payload[6], payload[7]),
297
+ subAction,
298
+ };
299
+ }
300
+ // SET / SET-echo layout (sub-action `09 00` or `52 00`).
301
+ return {
302
+ kind: 'set_echo',
303
+ effectId: decode14(payload[2], payload[3]),
304
+ paramId: decode14(payload[4], payload[5]),
305
+ value: unpackValue16(payload[9], payload[10], payload[11]),
306
+ subAction,
307
+ };
308
+ }
309
+ /**
310
+ * Parse the async `04 01` STATE_BROADCAST sub-action specifically.
311
+ * Throws on any other sub-action.
312
+ *
313
+ * Use this when listening for the III's unsolicited state-change push
314
+ * (the closest thing the III has to a GET response — the device emits
315
+ * a broadcast whenever a parameter changes, whether the change was
316
+ * driven by the host, by the front panel, or by another editor).
317
+ *
318
+ * Caller must track which paramId was last SET on this effectId to
319
+ * attribute the broadcast value — the broadcast frame does NOT echo
320
+ * the paramId.
321
+ */
322
+ export function parseStateBroadcast(bytes) {
323
+ const parsed = parseSetGetParameterResponse(bytes);
324
+ if (parsed.kind !== 'state_broadcast') {
325
+ throw new Error(`parseStateBroadcast: frame is sub-action 0x${parsed.subAction.toString(16).padStart(4, '0')}, not 0x0104 STATE_BROADCAST`);
326
+ }
327
+ return { effectId: parsed.effectId, value: parsed.value };
328
+ }
329
+ // ── 0x05 SET_GRID_CELL ─────────────────────────────────────────────
330
+ //
331
+ // 🟡 NOT in v1.4 III spec. Wire shape ported from the Axe-Fx II's
332
+ // hardware-verified encoder. The II uses 0x05 to place a block at a
333
+ // grid cell (or clear it with blockId=0); whether III firmware honors
334
+ // this opcode is unverified. Rejections arrive as 0x64
335
+ // MULTIPURPOSE_RESPONSE with result_code 0x04 (msg not recognized).
336
+ const FN_SET_GRID_CELL = 0x05;
337
+ /**
338
+ * SET_GRID_CELL (function 0x05). Places `blockId` at cell (row, col).
339
+ *
340
+ * `F0 00 01 74 10 05 [blockId_lo blockId_hi] [cell_idx] [0x00] [cs] F7`
341
+ *
342
+ * cell_idx = (col - 1) * rows + (row - 1) — column-major. The II uses
343
+ * 4-row grids so cell_idx = (col-1)*4 + (row-1). The III runs a 4×14
344
+ * grid in Mark II firmware; the cell index shape is the same.
345
+ *
346
+ * 🟡 III-untested. The 8-byte payload was rejected by II firmware as
347
+ * "payload too short" — the II's encoder always sends 4 payload bytes
348
+ * (blockId_lo, blockId_hi, cell_idx, reserved=0). We mirror that.
349
+ */
350
+ export function buildSetGridCell(opts) {
351
+ const { row, col, blockId } = opts;
352
+ if (!Number.isInteger(row) || row < 1 || row > 4) {
353
+ throw new Error(`buildSetGridCell: row out of range (1..4): ${row}`);
354
+ }
355
+ if (!Number.isInteger(col) || col < 1 || col > 14) {
356
+ throw new Error(`buildSetGridCell: col out of range (1..14): ${col}`);
357
+ }
358
+ if (!Number.isInteger(blockId) || blockId < 0 || blockId > 0x3fff) {
359
+ throw new Error(`buildSetGridCell: blockId out of range (0..16383): ${blockId}`);
360
+ }
361
+ const cellIdx = (col - 1) * 4 + (row - 1);
362
+ return buildEnvelope(FN_SET_GRID_CELL, [
363
+ blockId & 0x7f,
364
+ (blockId >> 7) & 0x7f,
365
+ cellIdx & 0x7f,
366
+ 0x00, // reserved per II convention
367
+ ]);
368
+ }
369
+ // ── 0x09 SET_PRESET_NAME ───────────────────────────────────────────
370
+ //
371
+ // 🟡 NOT in v1.4 III spec — names are query-only there. Wire shape
372
+ // ported from the Axe-Fx II (function 0x09 takes 32 ASCII chars of
373
+ // the new working-buffer preset name). The III may honor it because
374
+ // the same firmware family handles 0x0D QUERY_PATCH_NAME; we test
375
+ // here and surface rejections.
376
+ const FN_SET_PRESET_NAME = 0x09;
377
+ /**
378
+ * SET_PRESET_NAME (function 0x09) — set the working-buffer preset name.
379
+ * Name is padded to 32 ASCII-printable chars (space-padded). The II
380
+ * uses this for the working buffer only; pairing with 0x1D STORE_PRESET
381
+ * is what persists the rename to flash.
382
+ *
383
+ * 🟡 III-untested.
384
+ */
385
+ export function buildSetPresetName(name) {
386
+ if (name.length > 32) {
387
+ throw new Error(`buildSetPresetName: name too long (max 32): "${name}" (${name.length})`);
388
+ }
389
+ for (let i = 0; i < name.length; i++) {
390
+ const c = name.charCodeAt(i);
391
+ if (c < 0x20 || c > 0x7e) {
392
+ throw new Error(`buildSetPresetName: non-printable char at position ${i}: 0x${c.toString(16)}`);
393
+ }
394
+ }
395
+ const padded = name.padEnd(32, ' ');
396
+ return buildEnvelope(FN_SET_PRESET_NAME, [
397
+ ...Array.from(padded, (c) => c.charCodeAt(0)),
398
+ ]);
399
+ }
400
+ // ── 0x1D STORE_PRESET ──────────────────────────────────────────────
401
+ //
402
+ // 🟡 NOT in v1.4 III spec — Fractal's published III save envelope is
403
+ // the multi-frame 0x77/0x78/0x79 chain (community RE, hypothesis-only;
404
+ // requires Huffman-compressed preset content). The II's 0x1D STORE
405
+ // command is a much simpler 10-byte envelope: "persist the current
406
+ // working buffer to slot N" with no preset payload — the device just
407
+ // commits whatever's in the working buffer.
408
+ //
409
+ // We try the 0x1D shape here because: (a) it's safe — wrong opcode
410
+ // emits a 0x64 rejection, no flash impact; (b) the III's firmware
411
+ // family probably still has the 0x1D code path; (c) if III honors it,
412
+ // users get save-to-slot without the Huffman work.
413
+ //
414
+ // Wire envelope (matches II):
415
+ //
416
+ // `F0 00 01 74 10 1D [preset_high] [preset_low] [cs] F7`
417
+ //
418
+ // preset_high = (n >> 7) & 0x7F, preset_low = n & 0x7F. MSB-first
419
+ // byte ordering per II convention (and per the III's own 0x14
420
+ // GET_TEMPO response, which uses MSB-first).
421
+ const FN_STORE_PRESET = 0x1d;
422
+ /** STORE_PRESET (function 0x1D). 🟡 III-untested. */
423
+ export function buildStorePreset(presetNumber) {
424
+ if (!Number.isInteger(presetNumber) || presetNumber < 0 || presetNumber > 0x3fff) {
425
+ throw new Error(`buildStorePreset: preset out of range (0..16383): ${presetNumber}`);
426
+ }
427
+ const high = (presetNumber >> 7) & 0x7f;
428
+ const low = presetNumber & 0x7f;
429
+ return buildEnvelope(FN_STORE_PRESET, [high, low]);
430
+ }
431
+ // ── MIDI Program Change (preset switch via standard MIDI) ──────────
432
+ //
433
+ // The III v1.4 spec says: "To CHANGE the active preset on the III via
434
+ // MIDI, use standard Program Change messages (with CC 0 + CC 32 Bank
435
+ // Select for slots > 127)." This is NOT a SysEx envelope — it's
436
+ // 3 short MIDI messages back-to-back. The III is documented to honor
437
+ // these without any firmware-version caveats.
438
+ /**
439
+ * Build the short-MIDI byte sequence to switch the III to preset
440
+ * `presetNumber` (0..1023). Returns 9 bytes:
441
+ *
442
+ * `B0 00 bankMsb` (Control Change 0 = Bank Select MSB)
443
+ * `B0 20 bankLsb` (Control Change 32 = Bank Select LSB)
444
+ * `C0 programNumber` (Program Change on channel 1)
445
+ *
446
+ * Default MIDI channel is 1 (0x0 in the channel nibble). The III
447
+ * listens on its globally-configured MIDI channel — users with a
448
+ * non-default channel will need to call `axefx3_switch_preset` with
449
+ * a `channel` arg (1..16) on a future iteration. For now we default
450
+ * to channel 1, which matches Fractal's factory setting.
451
+ *
452
+ * Per the III v1.4 PDF: 1024 presets are addressed across 8 banks of
453
+ * 128 each. presetNumber 0..127 = bank 0 PC 0..127, presetNumber
454
+ * 128..255 = bank 1 PC 0..127, etc. CC0 carries the bank's MSB and
455
+ * CC32 carries the LSB; both are 7-bit values, so bank = (CC0 << 7)
456
+ * | CC32. The III ignores CC0 when bank fits in CC32 (just CC32 + PC
457
+ * is sufficient for presets 0..16383), but spec-correct usage sends
458
+ * both — we do.
459
+ */
460
+ export function buildSwitchPresetPC(presetNumber, channel = 1) {
461
+ if (!Number.isInteger(presetNumber) || presetNumber < 0 || presetNumber > 1023) {
462
+ throw new Error(`buildSwitchPresetPC: presetNumber ${presetNumber} out of range (0..1023).`);
463
+ }
464
+ if (!Number.isInteger(channel) || channel < 1 || channel > 16) {
465
+ throw new Error(`buildSwitchPresetPC: channel ${channel} out of range (1..16).`);
466
+ }
467
+ const ch0 = (channel - 1) & 0x0f;
468
+ const bank = Math.floor(presetNumber / 128);
469
+ const pc = presetNumber % 128;
470
+ return [
471
+ 0xb0 | ch0, 0x00, (bank >> 7) & 0x7f, // CC 0 = Bank MSB
472
+ 0xb0 | ch0, 0x20, bank & 0x7f, // CC 32 = Bank LSB
473
+ 0xc0 | ch0, pc & 0x7f, // Program Change
474
+ ];
475
+ }
476
+ // ── 0x0A SET/GET BYPASS ────────────────────────────────────────────
477
+ /**
478
+ * SET BYPASS (function 0x0A). Targets the active scene only — per
479
+ * spec the III's bypass writes don't carry a scene argument.
480
+ *
481
+ * `F0 00 01 74 10 0A [id_lo] [id_hi] [dd] [cs] F7`
482
+ *
483
+ * `dd=0` engaged, `dd=1` bypassed.
484
+ */
485
+ export function buildSetBypass(effectId, bypassed) {
486
+ return buildEnvelope(FN_SET_GET_BYPASS, [
487
+ ...encode14(effectId),
488
+ bypassed ? 1 : 0,
489
+ ]);
490
+ }
491
+ /** GET BYPASS (function 0x0A with `dd=0x7F`). Device responds with same envelope shape. */
492
+ export function buildGetBypass(effectId) {
493
+ return buildEnvelope(FN_SET_GET_BYPASS, [
494
+ ...encode14(effectId),
495
+ QUERY_SENTINEL,
496
+ ]);
497
+ }
498
+ // ── 0x0B SET/GET CHANNEL ───────────────────────────────────────────
499
+ /**
500
+ * SET CHANNEL (function 0x0B). Targets the active scene only.
501
+ * `channel` is 0..3 mapping to A..D.
502
+ *
503
+ * `F0 00 01 74 10 0B [id_lo] [id_hi] [channel] [cs] F7`
504
+ */
505
+ export function buildSetChannel(effectId, channel) {
506
+ if (!Number.isInteger(channel) || channel < 0 || channel > 3) {
507
+ throw new Error(`buildSetChannel: channel ${channel} out of range (0..3 = A..D)`);
508
+ }
509
+ return buildEnvelope(FN_SET_GET_CHANNEL, [
510
+ ...encode14(effectId),
511
+ channel,
512
+ ]);
513
+ }
514
+ /** GET CHANNEL (function 0x0B with `dd=0x7F`). */
515
+ export function buildGetChannel(effectId) {
516
+ return buildEnvelope(FN_SET_GET_CHANNEL, [
517
+ ...encode14(effectId),
518
+ QUERY_SENTINEL,
519
+ ]);
520
+ }
521
+ // ── 0x0C SET/GET SCENE ─────────────────────────────────────────────
522
+ /**
523
+ * SET SCENE (function 0x0C). `sceneIndex` is 0..7. Spec also says
524
+ * "Returns: ... where dd is the current scene" — so SET also echoes.
525
+ */
526
+ export function buildSetScene(sceneIndex) {
527
+ if (!Number.isInteger(sceneIndex) || sceneIndex < 0 || sceneIndex > 7) {
528
+ throw new Error(`buildSetScene: sceneIndex ${sceneIndex} out of range (0..7)`);
529
+ }
530
+ return buildEnvelope(FN_SET_GET_SCENE, [sceneIndex & 0x7f]);
531
+ }
532
+ /** GET SCENE (function 0x0C with `dd=0x7F`). */
533
+ export function buildGetScene() {
534
+ return buildEnvelope(FN_SET_GET_SCENE, [QUERY_SENTINEL]);
535
+ }
536
+ // ── 0x0D QUERY PATCH NAME ──────────────────────────────────────────
537
+ /**
538
+ * QUERY PATCH NAME (function 0x0D).
539
+ *
540
+ * Request: `F0 00 01 74 10 0D [dd dd preset#] [cs] F7`
541
+ * Current: `F0 00 01 74 10 0D 7F 7F [cs] F7`
542
+ * Response: `F0 00 01 74 10 0D [nn nn preset#] [dd*32 name] [cs] F7`
543
+ *
544
+ * Pass a preset number 0..1023 (Mark II) / 0..511 (Mark I) to look
545
+ * up that preset's name, or `'current'` to query the active preset.
546
+ * Response contains BOTH the preset number AND the name — there's no
547
+ * separate "get preset number" function in the v1.4 spec.
548
+ *
549
+ * NB: this is NOT a preset-switching command. To CHANGE the active
550
+ * preset on the III via MIDI, use standard Program Change messages
551
+ * (with CC 0 + CC 32 Bank Select for slots > 127). The III has no
552
+ * SysEx preset-switch in the v1.4 public spec.
553
+ */
554
+ export function buildQueryPatchName(presetNumber) {
555
+ if (presetNumber === 'current') {
556
+ return buildEnvelope(FN_QUERY_PATCH_NAME, [QUERY_SENTINEL, QUERY_SENTINEL]);
557
+ }
558
+ if (!Number.isInteger(presetNumber) || presetNumber < 0 || presetNumber > 1023) {
559
+ throw new Error(`buildQueryPatchName: presetNumber ${presetNumber} out of range (0..1023).`);
560
+ }
561
+ return buildEnvelope(FN_QUERY_PATCH_NAME, encode14(presetNumber));
562
+ }
563
+ // ── 0x0E QUERY SCENE NAME ──────────────────────────────────────────
564
+ /**
565
+ * QUERY SCENE NAME (function 0x0E).
566
+ *
567
+ * Request: `F0 00 01 74 10 0E [dd scene] [cs] F7`
568
+ * Current: `F0 00 01 74 10 0E 7F [cs] F7`
569
+ * Response: `F0 00 01 74 10 0E [nn scene] [dd*32 name] [cs] F7`
570
+ *
571
+ * No SET variant in the spec.
572
+ */
573
+ export function buildQuerySceneName(sceneIndex) {
574
+ if (sceneIndex === 'current') {
575
+ return buildEnvelope(FN_QUERY_SCENE_NAME, [QUERY_SENTINEL]);
576
+ }
577
+ if (!Number.isInteger(sceneIndex) || sceneIndex < 0 || sceneIndex > 7) {
578
+ throw new Error(`buildQuerySceneName: sceneIndex ${sceneIndex} out of range (0..7).`);
579
+ }
580
+ return buildEnvelope(FN_QUERY_SCENE_NAME, [sceneIndex & 0x7f]);
581
+ }
582
+ const LOOPER_ACTION_VALUES = {
583
+ record: 0,
584
+ play: 1,
585
+ undo: 2,
586
+ once: 3,
587
+ reverse: 4,
588
+ half_speed: 5,
589
+ };
590
+ /**
591
+ * SET LOOPER (function 0x0F). Triggers a looper "button press":
592
+ *
593
+ * `F0 00 01 74 10 0F [dd button] [cs] F7`
594
+ *
595
+ * Buttons per spec: 0=Record, 1=Play, 2=Undo, 3=Once, 4=Reverse,
596
+ * 5=Half-speed.
597
+ */
598
+ export function buildSetLooper(action) {
599
+ return buildEnvelope(FN_SET_GET_LOOPER, [LOOPER_ACTION_VALUES[action]]);
600
+ }
601
+ /**
602
+ * GET LOOPER STATE (function 0x0F with `dd=0x7F`). Returns a state
603
+ * bitfield: bit 0=Record, 1=Play, 2=Overdub, 3=Once, 4=Reverse,
604
+ * 5=Half-speed.
605
+ */
606
+ export function buildGetLooperState() {
607
+ return buildEnvelope(FN_SET_GET_LOOPER, [QUERY_SENTINEL]);
608
+ }
609
+ // ── 0x10 TEMPO TAP ─────────────────────────────────────────────────
610
+ /**
611
+ * TEMPO TAP (function 0x10). Single-shot, no payload. Each call
612
+ * counts as one tap-tempo press; the III computes BPM from the
613
+ * inter-tap interval the same way as the front-panel TAP button.
614
+ */
615
+ export function buildTempoTap() {
616
+ return buildEnvelope(FN_TEMPO_TAP, []);
617
+ }
618
+ // ── 0x11 TUNER ON/OFF ──────────────────────────────────────────────
619
+ /** TUNER ON/OFF (function 0x11). */
620
+ export function buildSetTuner(on) {
621
+ return buildEnvelope(FN_TUNER_ON_OFF, [on ? 1 : 0]);
622
+ }
623
+ // ── 0x13 STATUS DUMP ───────────────────────────────────────────────
624
+ /**
625
+ * STATUS DUMP (function 0x13). One-shot snapshot of the current
626
+ * scene's state across all effect blocks in the preset. Response is
627
+ * a sequence of `id id dd` triples — see `parseStatusDumpResponse`.
628
+ */
629
+ export function buildStatusDump() {
630
+ return buildEnvelope(FN_STATUS_DUMP, []);
631
+ }
632
+ // ── 0x14 SET/GET TEMPO ─────────────────────────────────────────────
633
+ /**
634
+ * SET TEMPO (function 0x14). BPM as a 14-bit value (LS-first septet
635
+ * pair). Range per spec is implicitly 0..16383; in practice the III
636
+ * accepts ~30..250 BPM (front-panel range).
637
+ */
638
+ export function buildSetTempo(bpm) {
639
+ if (!Number.isInteger(bpm) || bpm < 0 || bpm > 0x3fff) {
640
+ throw new Error(`buildSetTempo: bpm ${bpm} out of range (0..16383)`);
641
+ }
642
+ return buildEnvelope(FN_SET_GET_TEMPO, encode14(bpm));
643
+ }
644
+ /** GET TEMPO (function 0x14 with `dd dd = 7F 7F`). */
645
+ export function buildGetTempo() {
646
+ return buildEnvelope(FN_SET_GET_TEMPO, [QUERY_SENTINEL, QUERY_SENTINEL]);
647
+ }
648
+ // ── Response predicates + parsers ──────────────────────────────────
649
+ function isAxeFxIIIFrame(bytes, fn) {
650
+ if (bytes.length < 7)
651
+ return false;
652
+ if (bytes[0] !== SYSEX_START)
653
+ return false;
654
+ if (bytes[1] !== FRACTAL_MFR_PREFIX[0])
655
+ return false;
656
+ if (bytes[2] !== FRACTAL_MFR_PREFIX[1])
657
+ return false;
658
+ if (bytes[3] !== FRACTAL_MFR_PREFIX[2])
659
+ return false;
660
+ if (bytes[4] !== AXE_FX_III_MODEL_ID)
661
+ return false;
662
+ if (bytes[5] !== fn)
663
+ return false;
664
+ if (bytes[bytes.length - 1] !== SYSEX_END)
665
+ return false;
666
+ return true;
667
+ }
668
+ /**
669
+ * Decode an ASCII payload that's space- or null-padded. III name
670
+ * responses are 32-char ASCII fields padded with spaces.
671
+ */
672
+ function decodeName(bytes) {
673
+ let end = bytes.length;
674
+ while (end > 0) {
675
+ const b = bytes[end - 1];
676
+ if (b !== 0x00 && b !== 0x20)
677
+ break;
678
+ end -= 1;
679
+ }
680
+ return String.fromCharCode(...bytes.slice(0, end));
681
+ }
682
+ export function isSetGetBypassResponse(bytes) {
683
+ return isAxeFxIIIFrame(bytes, FN_SET_GET_BYPASS);
684
+ }
685
+ export function isSetGetChannelResponse(bytes) {
686
+ return isAxeFxIIIFrame(bytes, FN_SET_GET_CHANNEL);
687
+ }
688
+ export function isSetGetSceneResponse(bytes) {
689
+ return isAxeFxIIIFrame(bytes, FN_SET_GET_SCENE);
690
+ }
691
+ export function isQueryPatchNameResponse(bytes) {
692
+ return isAxeFxIIIFrame(bytes, FN_QUERY_PATCH_NAME);
693
+ }
694
+ export function isQuerySceneNameResponse(bytes) {
695
+ return isAxeFxIIIFrame(bytes, FN_QUERY_SCENE_NAME);
696
+ }
697
+ export function isSetGetLooperResponse(bytes) {
698
+ return isAxeFxIIIFrame(bytes, FN_SET_GET_LOOPER);
699
+ }
700
+ export function isStatusDumpResponse(bytes) {
701
+ return isAxeFxIIIFrame(bytes, FN_STATUS_DUMP);
702
+ }
703
+ export function isSetGetTempoResponse(bytes) {
704
+ return isAxeFxIIIFrame(bytes, FN_SET_GET_TEMPO);
705
+ }
706
+ export function isMultipurposeResponse(bytes) {
707
+ return isAxeFxIIIFrame(bytes, FN_MULTIPURPOSE_RESPONSE);
708
+ }
709
+ /**
710
+ * Parse a 0x0A SET/GET BYPASS response. Payload is `[id_lo, id_hi, dd]`.
711
+ */
712
+ export function parseBypassResponse(bytes) {
713
+ if (!isSetGetBypassResponse(bytes)) {
714
+ throw new Error(`parseBypassResponse: not a 0x0A frame (len=${bytes.length})`);
715
+ }
716
+ const payload = bytes.slice(6, -2);
717
+ if (payload.length < 3)
718
+ throw new Error(`parseBypassResponse: payload too short`);
719
+ return {
720
+ effectId: decode14(payload[0], payload[1]),
721
+ bypassed: (payload[2] & 0x01) !== 0,
722
+ };
723
+ }
724
+ /** Parse a 0x0B SET/GET CHANNEL response. */
725
+ export function parseChannelResponse(bytes) {
726
+ if (!isSetGetChannelResponse(bytes)) {
727
+ throw new Error(`parseChannelResponse: not a 0x0B frame`);
728
+ }
729
+ const payload = bytes.slice(6, -2);
730
+ if (payload.length < 3)
731
+ throw new Error(`parseChannelResponse: payload too short`);
732
+ return {
733
+ effectId: decode14(payload[0], payload[1]),
734
+ channel: payload[2] & 0x07,
735
+ };
736
+ }
737
+ /** Parse a 0x0C SET/GET SCENE response. Payload is `[scene]`. */
738
+ export function parseSceneResponse(bytes) {
739
+ if (!isSetGetSceneResponse(bytes)) {
740
+ throw new Error(`parseSceneResponse: not a 0x0C frame`);
741
+ }
742
+ const payload = bytes.slice(6, -2);
743
+ if (payload.length < 1)
744
+ throw new Error('parseSceneResponse: empty payload');
745
+ return { scene: payload[0] & 0x07 };
746
+ }
747
+ /**
748
+ * Parse a 0x0D QUERY PATCH NAME response.
749
+ *
750
+ * `F0 00 01 74 10 0D [nn nn preset#] [dd*32 name] [cs] F7`
751
+ *
752
+ * Returns both the preset number AND the 32-char name (trimmed).
753
+ */
754
+ export function parseQueryPatchNameResponse(bytes) {
755
+ if (!isQueryPatchNameResponse(bytes)) {
756
+ throw new Error(`parseQueryPatchNameResponse: not a 0x0D frame (len=${bytes.length})`);
757
+ }
758
+ const payload = bytes.slice(6, -2);
759
+ if (payload.length < 2) {
760
+ throw new Error(`parseQueryPatchNameResponse: payload too short (${payload.length}B)`);
761
+ }
762
+ const presetNumber = decode14(payload[0], payload[1]);
763
+ const name = decodeName(payload.slice(2));
764
+ return { presetNumber, name };
765
+ }
766
+ /**
767
+ * Parse a 0x0E QUERY SCENE NAME response.
768
+ *
769
+ * `F0 00 01 74 10 0E [nn scene] [dd*32 name] [cs] F7`
770
+ */
771
+ export function parseQuerySceneNameResponse(bytes) {
772
+ if (!isQuerySceneNameResponse(bytes)) {
773
+ throw new Error(`parseQuerySceneNameResponse: not a 0x0E frame`);
774
+ }
775
+ const payload = bytes.slice(6, -2);
776
+ if (payload.length === 0)
777
+ throw new Error('parseQuerySceneNameResponse: empty payload');
778
+ const scene = payload[0] & 0x07;
779
+ const name = decodeName(payload.slice(1));
780
+ return { scene, name };
781
+ }
782
+ export function parseLooperStateResponse(bytes) {
783
+ if (!isSetGetLooperResponse(bytes)) {
784
+ throw new Error(`parseLooperStateResponse: not a 0x0F frame`);
785
+ }
786
+ const payload = bytes.slice(6, -2);
787
+ if (payload.length === 0)
788
+ throw new Error('parseLooperStateResponse: empty payload');
789
+ const dd = payload[0] & 0x7f;
790
+ return {
791
+ recording: (dd & 0x01) !== 0,
792
+ playing: (dd & 0x02) !== 0,
793
+ overdubbing: (dd & 0x04) !== 0,
794
+ once: (dd & 0x08) !== 0,
795
+ reverse: (dd & 0x10) !== 0,
796
+ halfSpeed: (dd & 0x20) !== 0,
797
+ raw: dd,
798
+ };
799
+ }
800
+ /** Parse a 0x14 SET/GET TEMPO response. Payload is the BPM as a septet pair. */
801
+ export function parseTempoResponse(bytes) {
802
+ if (!isSetGetTempoResponse(bytes)) {
803
+ throw new Error(`parseTempoResponse: not a 0x14 frame`);
804
+ }
805
+ const payload = bytes.slice(6, -2);
806
+ if (payload.length < 2)
807
+ throw new Error('parseTempoResponse: payload too short');
808
+ return { bpm: decode14(payload[0], payload[1]) };
809
+ }
810
+ /**
811
+ * Parse a 0x64 MULTIPURPOSE_RESPONSE frame. Payload is `[echoed_fn, result_code]`.
812
+ *
813
+ * `F0 00 01 74 10 64 [echoed_fn] [result_code] [cs] F7`
814
+ *
815
+ * Known `result_code` meanings (incomplete — Fractal doesn't publish a
816
+ * full table):
817
+ * - `0x00` — general / checksum error
818
+ * - `0x05` — NACK (seen during preset-store experiments)
819
+ *
820
+ * Anything else surfaces as the raw byte. Callers convert this to a
821
+ * warning string in their tool response.
822
+ */
823
+ export function parseMultipurposeResponse(bytes) {
824
+ if (!isMultipurposeResponse(bytes)) {
825
+ throw new Error(`parseMultipurposeResponse: not a 0x64 frame (len=${bytes.length})`);
826
+ }
827
+ const payload = bytes.slice(6, -2);
828
+ if (payload.length < 2) {
829
+ throw new Error(`parseMultipurposeResponse: payload too short (${payload.length}B)`);
830
+ }
831
+ return { echoedFn: payload[0] & 0x7f, resultCode: payload[1] & 0x7f };
832
+ }
833
+ /**
834
+ * Human-readable label for a known `result_code` byte. Returns
835
+ * `undefined` for codes not yet documented; callers fall back to the
836
+ * raw hex value.
837
+ *
838
+ * Source: AxeEdit III 1.14.31 release binary contains a contiguous
839
+ * 8-byte-aligned `MIDI_ERROR_*` string table at `.rdata` offset
840
+ * 0x597108 onward. Entries are accessed by result_code as index.
841
+ * Index 0 = `MIDI_ERROR_BAD_CHKSUM` matches the empirically-verified
842
+ * 0x64 frame whose host-side trigger was a malformed checksum, so the
843
+ * index → result_code mapping is high-confidence. Codes 0x00..0x1B
844
+ * are populated; anything ≥ 0x1C returns undefined.
845
+ *
846
+ * See `docs/axefx3-fn01-decode.md` "0x64 result codes" for the full
847
+ * decode + index-table evidence.
848
+ */
849
+ export function describeMultipurposeResultCode(code) {
850
+ switch (code & 0x7f) {
851
+ case 0x00: return 'bad checksum (MIDI_ERROR_BAD_CHKSUM)';
852
+ case 0x01: return 'wrong SysEx manufacturer ID (MIDI_ERROR_WRONG_SYSEX_ID)';
853
+ case 0x02: return 'wrong model number (MIDI_ERROR_WRONG_MODEL_NUM)';
854
+ case 0x03: return 'bad argument (MIDI_ERROR_BAD_ARGUMENT)';
855
+ case 0x04: return 'message not recognized (MIDI_ERROR_MSG_NOT_RECOGNIZED)';
856
+ case 0x05: return 'invalid effect ID (MIDI_ERROR_INVALID_FXID)';
857
+ case 0x06: return 'invalid parameter ID (MIDI_ERROR_INVALID_PARAMID)';
858
+ case 0x07: return 'effect not in use in this preset (MIDI_ERROR_FX_NOT_IN_USE)';
859
+ case 0x08: return 'no modifier slots left (MIDI_ERROR_NO_MODIFIERS_LEFT)';
860
+ case 0x09: return 'wrong count (MIDI_ERROR_WRONG_COUNT)';
861
+ case 0x0a: return 'effect not routable here (MIDI_ERROR_FX_NOT_ROUTABLE)';
862
+ case 0x0b: return 'bad grid position (MIDI_ERROR_BAD_GRID_POS)';
863
+ case 0x0c: return 'DSP overload (MIDI_ERROR_DSP_OVERLOAD)';
864
+ case 0x0d: return 'function failed (MIDI_ERROR_FUNCTION_FAIL)';
865
+ case 0x0e: return 'invalid patch number (MIDI_ERROR_INVALID_PATCHNUM)';
866
+ case 0x0f: return 'illegal message (MIDI_ERROR_ILLEGAL_MSG)';
867
+ case 0x10: return 'bad message length (MIDI_ERROR_BAD_MSG_LENGTH)';
868
+ case 0x11: return 'image size incorrect (MIDI_ERROR_IMAGE_SIZE_INCORRECT)';
869
+ case 0x12: return 'bad image checksum (MIDI_ERROR_BAD_IMAGE_CHKSUM)';
870
+ case 0x13: return 'not ready for firmware update (MIDI_ERROR_NOT_RDY_FOR_FW_UPD)';
871
+ case 0x14: return 'buffer overrun (MIDI_ERROR_BUFFER_OVERRUN)';
872
+ case 0x15: return 'invalid cab number (MIDI_ERROR_INVALID_CABNUM)';
873
+ case 0x16: return 'invalid modifier ID (MIDI_ERROR_INVALID_MODIFIERID)';
874
+ case 0x17: return 'invalid bank number (MIDI_ERROR_INVALID_BANKNUM)';
875
+ case 0x18: return 'firmware already current (MIDI_ERROR_FIRMWARE_ALREADY_CURRENT)';
876
+ case 0x19: return 'command not supported (MIDI_ERROR_CMD_NOT_SUPPORTED)';
877
+ case 0x1a: return 'null data (MIDI_ERROR_NULL_DATA)';
878
+ case 0x1b: return 'flash write failed (MIDI_ERROR_FLASH_WRITE_FAILED)';
879
+ default: return undefined;
880
+ }
881
+ }
882
+ /**
883
+ * Parse a 0x13 STATUS_DUMP response into a list of per-block entries.
884
+ *
885
+ * Wire shape per v1.4 PDF:
886
+ * `F0 00 01 74 10 13 [id id dd]* [cs] F7`
887
+ */
888
+ export function parseStatusDumpResponse(bytes) {
889
+ if (!isStatusDumpResponse(bytes)) {
890
+ throw new Error(`parseStatusDumpResponse: not a valid 0x13 frame (len=${bytes.length})`);
891
+ }
892
+ const payload = bytes.slice(6, -2);
893
+ if (payload.length % 3 !== 0) {
894
+ throw new Error(`parseStatusDumpResponse: payload length ${payload.length} not a ` +
895
+ 'multiple of 3 — STATUS_DUMP frames are id-id-dd triples.');
896
+ }
897
+ const entries = [];
898
+ for (let i = 0; i < payload.length; i += 3) {
899
+ const idLo = payload[i] & 0x7f;
900
+ const idHi = payload[i + 1] & 0x7f;
901
+ const dd = payload[i + 2] & 0x7f;
902
+ entries.push({
903
+ effectId: decode14(idLo, idHi),
904
+ bypassed: (dd & 0x01) !== 0,
905
+ channel: (dd >> 1) & 0x07,
906
+ channelCount: (dd >> 4) & 0x07,
907
+ });
908
+ }
909
+ return entries;
910
+ }