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,888 @@
1
+ /**
2
+ * Axe-Fx II family GET/SET_BLOCK_PARAMETER_VALUE message builders.
3
+ *
4
+ * Reference: Fractal Audio Wiki "MIDI_SysEx" page (cached at
5
+ * `docs/_private/wiki-cache/axe-fx-ii-midi-sysex.html`), §"GET/SET_
6
+ * BLOCK_PARAMETER_VALUE" + §"obtaining parameter values".
7
+ *
8
+ * Wire envelope (function ID `0x02`):
9
+ *
10
+ * F0 00 01 74 [model] 02
11
+ * [effectId bits 6-0] [effectId bits 13-7]
12
+ * [paramId bits 6-0] [paramId bits 13-7]
13
+ * [value bits 6-0] [value bits 13-7] [value bits 14-15]
14
+ * [00=query, 01=set]
15
+ * [checksum] F7
16
+ *
17
+ * Value range: 0..65534 (16-bit). Packed across three 7-bit septets
18
+ * because SysEx bytes can't have bit 7 set. The high 2 bits of the
19
+ * 16-bit value land in the bottom 2 bits of the third septet (bits
20
+ * 6..2 of that byte are zero).
21
+ *
22
+ * Status: 🟢 hardware-verified on Quantum 8.02 (2026-05-10). HW-075
23
+ * landed both halves of function 0x02 SET: normal paramId (Amp 1 Bass
24
+ * 5.30 → 6.30, audibly warmer) and paramId=255 bypass (Reverb 1 tail
25
+ * dropped, front-panel LED disengaged). Wiki spec matches Q8.02
26
+ * firmware behavior — no drift detected. HW-077 covers the GET half
27
+ * (function 0x02 query). Byte-exact goldens in
28
+ * `scripts/verify-axe-fx-ii-encoding.ts` lock the encoder.
29
+ */
30
+ import { fractalChecksum } from '../shared/checksum.js';
31
+ const SYSEX_START = 0xf0;
32
+ const SYSEX_END = 0xf7;
33
+ const FRACTAL_MFR = [0x00, 0x01, 0x74];
34
+ const FUNC_BLOCK_PARAM = 0x02;
35
+ const FUNC_SET_GRID_CELL = 0x05;
36
+ const FUNC_SET_CELL_ROUTING = 0x06;
37
+ const FUNC_SET_PRESET_NAME = 0x09;
38
+ const FUNC_GET_PRESET_NAME = 0x0f;
39
+ const FUNC_BLOCK_CHANNEL = 0x11;
40
+ const FUNC_GET_PRESET_NUMBER = 0x14;
41
+ const FUNC_GET_GRID_LAYOUT = 0x20;
42
+ const FUNC_SCENE_NUMBER = 0x29;
43
+ const FUNC_STORE_PRESET = 0x1d;
44
+ const FUNC_MULTIPURPOSE_RESPONSE = 0x64;
45
+ const FUNC_SWITCH_PRESET = 0x3c;
46
+ const FUNC_STATE_DUMP_HEADER = 0x74;
47
+ const FUNC_STATE_DUMP_CHUNK = 0x75;
48
+ const FUNC_STATE_DUMP_FOOTER = 0x76;
49
+ /**
50
+ * Max items per 0x75 chunk observed in device captures. AxeEdit /
51
+ * firmware splits the state value list into chunks of up to 64 items
52
+ * each (each item = 3 wire bytes via `packValue16`). Capture corpus
53
+ * shows full chunks of 64 followed by a final short chunk holding
54
+ * the remainder; we mirror that shape for round-trip byte-exactness.
55
+ */
56
+ const STATE_DUMP_CHUNK_MAX_ITEMS = 64;
57
+ const ACTION_QUERY = 0x00;
58
+ const ACTION_SET = 0x01;
59
+ /** Sentinel scene value used by SET_SCENE_NUMBER to read the current scene. */
60
+ const SCENE_QUERY = 0x7f;
61
+ /** Default model byte for the founder's hardware (Axe-Fx II XL+). */
62
+ export const AXE_FX_II_XL_PLUS_MODEL_ID = 0x07;
63
+ /** Wire model byte for each Axe-Fx II family variant. */
64
+ export const MODEL_IDS = Object.freeze({
65
+ 'axe-fx-ii': 0x03, // Mark I / Mark II
66
+ 'axe-fx-ii-xl': 0x06,
67
+ 'axe-fx-ii-xl-plus': 0x07,
68
+ 'ax8': 0x08,
69
+ });
70
+ function encode14(n) {
71
+ if (!Number.isInteger(n) || n < 0 || n > 0x3fff) {
72
+ throw new Error(`14-bit value out of range: ${n}`);
73
+ }
74
+ return [n & 0x7f, (n >> 7) & 0x7f];
75
+ }
76
+ /**
77
+ * Pack a 16-bit unsigned value into the wire's three 7-bit septets.
78
+ *
79
+ * septet 0 = bits 6..0 (lowest seven bits)
80
+ * septet 1 = bits 13..7 (next seven bits)
81
+ * septet 2 = bits 15..14 (top two bits, zero-padded into a 7-bit byte)
82
+ *
83
+ * Per wiki, valid input range is 0..65534. We accept up to 65535 (the
84
+ * full 16-bit range) so callers can passes through without an extra
85
+ * clamp; firmware reportedly clamps 65535 → 65534 internally.
86
+ */
87
+ export function packValue16(value) {
88
+ if (!Number.isInteger(value) || value < 0 || value > 0xffff) {
89
+ throw new Error(`packValue16 input out of range: ${value}`);
90
+ }
91
+ return [
92
+ value & 0x7f,
93
+ (value >> 7) & 0x7f,
94
+ (value >> 14) & 0x03,
95
+ ];
96
+ }
97
+ /** Inverse of `packValue16`. Inputs may have unused upper bits — masked. */
98
+ export function unpackValue16(b0, b1, b2) {
99
+ return ((b0 & 0x7f)) | ((b1 & 0x7f) << 7) | ((b2 & 0x03) << 14);
100
+ }
101
+ export function displayToWire(display, opts) {
102
+ if (!Number.isFinite(display)) {
103
+ throw new Error(`displayToWire: display value must be finite, got ${display}`);
104
+ }
105
+ const { displayMin, displayMax, displayScale = 'linear' } = opts;
106
+ if (displayMin >= displayMax) {
107
+ throw new Error(`displayToWire: displayMin (${displayMin}) must be < displayMax (${displayMax})`);
108
+ }
109
+ if (displayScale === 'log10') {
110
+ if (displayMin <= 0 || displayMax <= 0) {
111
+ throw new Error(`displayToWire: log10 scale requires positive displayMin/displayMax, got ${displayMin}/${displayMax}`);
112
+ }
113
+ const clamped = Math.min(displayMax, Math.max(displayMin, display));
114
+ const ratio = Math.log10(clamped / displayMin) / Math.log10(displayMax / displayMin);
115
+ return Math.round(ratio * 65534);
116
+ }
117
+ // linear (default)
118
+ const clamped = Math.min(displayMax, Math.max(displayMin, display));
119
+ const ratio = (clamped - displayMin) / (displayMax - displayMin);
120
+ return Math.round(ratio * 65534);
121
+ }
122
+ /**
123
+ * Inverse of `displayToWire`. Wire 0..65534 → display value via the
124
+ * param's linear or log10 scale.
125
+ */
126
+ export function wireToDisplay(wire, opts) {
127
+ if (!Number.isInteger(wire) || wire < 0 || wire > 65534) {
128
+ throw new Error(`wireToDisplay: wire value out of range: ${wire}`);
129
+ }
130
+ const { displayMin, displayMax, displayScale = 'linear' } = opts;
131
+ if (displayMin >= displayMax) {
132
+ throw new Error(`wireToDisplay: displayMin (${displayMin}) must be < displayMax (${displayMax})`);
133
+ }
134
+ if (displayScale === 'log10') {
135
+ if (displayMin <= 0 || displayMax <= 0) {
136
+ throw new Error(`wireToDisplay: log10 scale requires positive displayMin/displayMax, got ${displayMin}/${displayMax}`);
137
+ }
138
+ return displayMin * Math.pow(displayMax / displayMin, wire / 65534);
139
+ }
140
+ return displayMin + (wire / 65534) * (displayMax - displayMin);
141
+ }
142
+ function buildEnvelope(modelId, body) {
143
+ const head = [SYSEX_START, ...FRACTAL_MFR, modelId, ...body];
144
+ return [...head, fractalChecksum(head), SYSEX_END];
145
+ }
146
+ /**
147
+ * Build a SET_BLOCK_PARAMETER_VALUE message (function `0x02`, action 1).
148
+ *
149
+ * `value` is the wire-level 16-bit integer (0..65534). Display ↔ wire
150
+ * conversion is the caller's responsibility — see KNOWN_PARAMS in
151
+ * `./params.ts` for per-param display ranges where the wiki documents
152
+ * them, and the §LOUDNESS BUDGET / display-anchor doc for params
153
+ * without populated ranges.
154
+ */
155
+ export function buildSetBlockParameterValue(param, value, opts = {}) {
156
+ const modelId = opts.modelId ?? AXE_FX_II_XL_PLUS_MODEL_ID;
157
+ return buildEnvelope(modelId, [
158
+ FUNC_BLOCK_PARAM,
159
+ ...encode14(param.effectId),
160
+ ...encode14(param.paramId),
161
+ ...packValue16(value),
162
+ ACTION_SET,
163
+ ]);
164
+ }
165
+ /**
166
+ * Build a GET_BLOCK_PARAMETER_VALUE request (function `0x02`, action 0).
167
+ *
168
+ * Per wiki: "When you are getting a parameter value you still have to
169
+ * include a parameter value with your message but this value can be 0."
170
+ * We send three zero septets for the value field.
171
+ */
172
+ export function buildGetBlockParameterValue(param, opts = {}) {
173
+ const modelId = opts.modelId ?? AXE_FX_II_XL_PLUS_MODEL_ID;
174
+ return buildEnvelope(modelId, [
175
+ FUNC_BLOCK_PARAM,
176
+ ...encode14(param.effectId),
177
+ ...encode14(param.paramId),
178
+ 0x00, 0x00, 0x00,
179
+ ACTION_QUERY,
180
+ ]);
181
+ }
182
+ /**
183
+ * Build a block-bypass toggle. Per wiki:
184
+ *
185
+ * "Bypassing/Engaging a Block is also done with this function with
186
+ * parameter 255. Send the value 0 to Engage, 1 to Bypass."
187
+ */
188
+ export function buildSetBlockBypass(effectId, bypassed, opts = {}) {
189
+ return buildSetBlockParameterValue({ effectId, paramId: 255 }, bypassed ? 1 : 0, opts);
190
+ }
191
+ /**
192
+ * Predicate: does `bytes` look like the response to our GET_BLOCK_PARAMETER_VALUE
193
+ * request for `(effectId, paramId)`? The device echoes the same
194
+ * envelope+function+effectId+paramId, then includes the actual wire
195
+ * value, then 5 unknown bytes, then a null-terminated label string.
196
+ *
197
+ * Per wiki §"GET/SET_BLOCK_PARAMETER_VALUE", response payload is:
198
+ * [eff7-0][eff13-7][param7-0][param13-7][val7-0][val13-7][val15-14]
199
+ * [unk1][unk2][unk3][unk4][unk5][label-bytes...][0x00][checksum] F7
200
+ *
201
+ * Distinguishing GET response from a SET echo: the GET response always
202
+ * carries a label string (>= 1 character + null terminator), so total
203
+ * length is well above the SET request's 14 bytes. We use the length
204
+ * cutoff as the discriminator.
205
+ */
206
+ export function isGetBlockParameterResponse(bytes, target, expectedModelId = AXE_FX_II_XL_PLUS_MODEL_ID) {
207
+ if (bytes.length < 17)
208
+ return false;
209
+ if (bytes[0] !== SYSEX_START)
210
+ return false;
211
+ if (bytes[1] !== FRACTAL_MFR[0] || bytes[2] !== FRACTAL_MFR[1] || bytes[3] !== FRACTAL_MFR[2])
212
+ return false;
213
+ if (bytes[4] !== expectedModelId)
214
+ return false;
215
+ if (bytes[5] !== FUNC_BLOCK_PARAM)
216
+ return false;
217
+ const eff = (bytes[6] & 0x7f) | ((bytes[7] & 0x7f) << 7);
218
+ const param = (bytes[8] & 0x7f) | ((bytes[9] & 0x7f) << 7);
219
+ return eff === target.effectId && param === target.paramId;
220
+ }
221
+ /**
222
+ * Parse a GET_BLOCK_PARAMETER_VALUE response. Caller must have already
223
+ * matched it via `isGetBlockParameterResponse` for the right target —
224
+ * this just decodes the value + label string from a known-shape buffer.
225
+ */
226
+ export function parseGetBlockParameterResponse(bytes) {
227
+ // bytes[10..12] = value 3 septets; bytes[13..17] = 5 unknown bytes;
228
+ // bytes[18..N-2] = label string + null; bytes[N-2] = checksum; bytes[N-1] = 0xF7
229
+ if (bytes.length < 17) {
230
+ throw new Error(`GET_BLOCK_PARAMETER response too short: ${bytes.length} bytes`);
231
+ }
232
+ const value = unpackValue16(bytes[10], bytes[11], bytes[12]);
233
+ // Find the null terminator starting at byte 18 (after 5 unknown bytes).
234
+ let nullIdx = -1;
235
+ for (let i = 18; i < bytes.length - 2; i++) {
236
+ if (bytes[i] === 0x00) {
237
+ nullIdx = i;
238
+ break;
239
+ }
240
+ }
241
+ const labelBytes = nullIdx > 18 ? bytes.slice(18, nullIdx) : [];
242
+ const label = String.fromCharCode(...labelBytes);
243
+ return { value, label };
244
+ }
245
+ /**
246
+ * Build a GET_PRESET_NAME request (function 0x0F). Empty body —
247
+ * envelope + function + checksum + F7.
248
+ */
249
+ export function buildGetPresetName(opts = {}) {
250
+ const modelId = opts.modelId ?? AXE_FX_II_XL_PLUS_MODEL_ID;
251
+ return buildEnvelope(modelId, [FUNC_GET_PRESET_NAME]);
252
+ }
253
+ export function isGetPresetNameResponse(bytes, expectedModelId = AXE_FX_II_XL_PLUS_MODEL_ID) {
254
+ if (bytes.length < 9)
255
+ return false;
256
+ if (bytes[0] !== SYSEX_START)
257
+ return false;
258
+ if (bytes[1] !== FRACTAL_MFR[0] || bytes[2] !== FRACTAL_MFR[1] || bytes[3] !== FRACTAL_MFR[2])
259
+ return false;
260
+ if (bytes[4] !== expectedModelId)
261
+ return false;
262
+ if (bytes[5] !== FUNC_GET_PRESET_NAME)
263
+ return false;
264
+ return true;
265
+ }
266
+ /**
267
+ * Parse a GET_PRESET_NAME response. Body is null-terminated ASCII
268
+ * starting at byte 6.
269
+ */
270
+ export function parseGetPresetNameResponse(bytes) {
271
+ if (!isGetPresetNameResponse(bytes)) {
272
+ throw new Error('Bytes do not match GET_PRESET_NAME response shape');
273
+ }
274
+ let nullIdx = -1;
275
+ for (let i = 6; i < bytes.length - 2; i++) {
276
+ if (bytes[i] === 0x00) {
277
+ nullIdx = i;
278
+ break;
279
+ }
280
+ }
281
+ const labelBytes = nullIdx > 6 ? bytes.slice(6, nullIdx) : [];
282
+ return String.fromCharCode(...labelBytes).trim();
283
+ }
284
+ /**
285
+ * Build a GET_GRID_LAYOUT_AND_ROUTING request (function 0x20). Empty
286
+ * body. Response carries 48× 4-byte grid cells (4 rows × 12 columns).
287
+ */
288
+ export function buildGetGridLayout(opts = {}) {
289
+ const modelId = opts.modelId ?? AXE_FX_II_XL_PLUS_MODEL_ID;
290
+ return buildEnvelope(modelId, [FUNC_GET_GRID_LAYOUT]);
291
+ }
292
+ export function isGetGridLayoutResponse(bytes, expectedModelId = AXE_FX_II_XL_PLUS_MODEL_ID) {
293
+ if (bytes.length < 8 + 48 * 4)
294
+ return false;
295
+ if (bytes[0] !== SYSEX_START)
296
+ return false;
297
+ if (bytes[1] !== FRACTAL_MFR[0] || bytes[2] !== FRACTAL_MFR[1] || bytes[3] !== FRACTAL_MFR[2])
298
+ return false;
299
+ if (bytes[4] !== expectedModelId)
300
+ return false;
301
+ if (bytes[5] !== FUNC_GET_GRID_LAYOUT)
302
+ return false;
303
+ return true;
304
+ }
305
+ /**
306
+ * Parse a GET_GRID_LAYOUT_AND_ROUTING response into 48 grid cells.
307
+ *
308
+ * Per wiki §"GET_GRID_LAYOUT_AND_ROUTING": cell order is column-major,
309
+ * top-to-bottom within each column. Cell 0 = (col=1, row=1); cell 1 =
310
+ * (col=1, row=2); ...; cell 4 = (col=2, row=1); etc.
311
+ *
312
+ * Per cell (4 bytes):
313
+ * bytes[0]: blockId bits 6-0
314
+ * bytes[1]: blockId bits 13-7
315
+ * bytes[2]: routing flags
316
+ * bytes[3]: unused (per wiki)
317
+ */
318
+ export function parseGetGridLayoutResponse(bytes) {
319
+ if (!isGetGridLayoutResponse(bytes)) {
320
+ throw new Error('Bytes do not match GET_GRID_LAYOUT response shape');
321
+ }
322
+ const cells = [];
323
+ let i = 6; // start after envelope + function byte
324
+ for (let cellIdx = 0; cellIdx < 48; cellIdx++) {
325
+ const col = Math.floor(cellIdx / 4) + 1;
326
+ const row = (cellIdx % 4) + 1;
327
+ const blockId = (bytes[i] & 0x7f) | ((bytes[i + 1] & 0x7f) << 7);
328
+ const routingFlags = bytes[i + 2] & 0x0f;
329
+ cells.push({ col, row, blockId, routingFlags });
330
+ i += 4;
331
+ }
332
+ return cells;
333
+ }
334
+ /**
335
+ * Build a SET_SCENE_NUMBER message (function 0x29). Pass scene 0..7
336
+ * to switch; pass undefined to query the current scene (sentinel 0x7F
337
+ * per wiki — response carries the actual scene number).
338
+ */
339
+ export function buildSetSceneNumber(scene, opts = {}) {
340
+ if (!Number.isInteger(scene) || scene < 0 || scene > 7) {
341
+ throw new Error(`Scene out of range: ${scene} (valid 0..7)`);
342
+ }
343
+ const modelId = opts.modelId ?? AXE_FX_II_XL_PLUS_MODEL_ID;
344
+ return buildEnvelope(modelId, [FUNC_SCENE_NUMBER, scene]);
345
+ }
346
+ export function buildGetSceneNumber(opts = {}) {
347
+ const modelId = opts.modelId ?? AXE_FX_II_XL_PLUS_MODEL_ID;
348
+ return buildEnvelope(modelId, [FUNC_SCENE_NUMBER, SCENE_QUERY]);
349
+ }
350
+ export function isSceneNumberResponse(bytes, expectedModelId = AXE_FX_II_XL_PLUS_MODEL_ID) {
351
+ if (bytes.length < 9)
352
+ return false;
353
+ if (bytes[0] !== SYSEX_START)
354
+ return false;
355
+ if (bytes[1] !== FRACTAL_MFR[0] || bytes[2] !== FRACTAL_MFR[1] || bytes[3] !== FRACTAL_MFR[2])
356
+ return false;
357
+ if (bytes[4] !== expectedModelId)
358
+ return false;
359
+ if (bytes[5] !== FUNC_SCENE_NUMBER)
360
+ return false;
361
+ return true;
362
+ }
363
+ export function parseSceneNumberResponse(bytes) {
364
+ if (!isSceneNumberResponse(bytes)) {
365
+ throw new Error('Bytes do not match SCENE_NUMBER response shape');
366
+ }
367
+ return bytes[6] & 0x7f;
368
+ }
369
+ export function channelToWire(c) {
370
+ if (c === 'X' || c === 0)
371
+ return 0;
372
+ if (c === 'Y' || c === 1)
373
+ return 1;
374
+ throw new Error(`channelToWire: expected 'X' / 'Y' / 0 / 1, got ${c}`);
375
+ }
376
+ export function wireToChannel(b) {
377
+ if (b === 0)
378
+ return 'X';
379
+ if (b === 1)
380
+ return 'Y';
381
+ throw new Error(`wireToChannel: expected 0 or 1, got ${b}`);
382
+ }
383
+ export function buildSetBlockChannel(effectId, channel, opts = {}) {
384
+ const modelId = opts.modelId ?? AXE_FX_II_XL_PLUS_MODEL_ID;
385
+ return buildEnvelope(modelId, [
386
+ FUNC_BLOCK_CHANNEL,
387
+ ...encode14(effectId),
388
+ channelToWire(channel),
389
+ 0x01, // action = set
390
+ ]);
391
+ }
392
+ export function buildGetBlockChannel(effectId, opts = {}) {
393
+ const modelId = opts.modelId ?? AXE_FX_II_XL_PLUS_MODEL_ID;
394
+ return buildEnvelope(modelId, [
395
+ FUNC_BLOCK_CHANNEL,
396
+ ...encode14(effectId),
397
+ 0x00, // chan field — ignored on get
398
+ 0x00, // action = get
399
+ ]);
400
+ }
401
+ export function isGetBlockChannelResponse(bytes, expectedEffectId, expectedModelId = AXE_FX_II_XL_PLUS_MODEL_ID) {
402
+ // Response is 11 bytes: F0 00 01 74 [model] 11 [eff_lo] [eff_hi] [chan] [cs] F7
403
+ if (bytes.length !== 11)
404
+ return false;
405
+ if (bytes[0] !== SYSEX_START)
406
+ return false;
407
+ if (bytes[1] !== FRACTAL_MFR[0] || bytes[2] !== FRACTAL_MFR[1] || bytes[3] !== FRACTAL_MFR[2])
408
+ return false;
409
+ if (bytes[4] !== expectedModelId)
410
+ return false;
411
+ if (bytes[5] !== FUNC_BLOCK_CHANNEL)
412
+ return false;
413
+ const effId = (bytes[6] & 0x7f) | ((bytes[7] & 0x7f) << 7);
414
+ return effId === expectedEffectId;
415
+ }
416
+ export function parseGetBlockChannelResponse(bytes) {
417
+ if (bytes.length !== 11) {
418
+ throw new Error(`Expected 11-byte BLOCK_CHANNEL response, got ${bytes.length}`);
419
+ }
420
+ return wireToChannel(bytes[8]);
421
+ }
422
+ /**
423
+ * Switch preset (function 0x3C) — load preset N into the working buffer.
424
+ * Wiki documents the envelope as `F0 00 01 74 [model] 3C [pn_lo]
425
+ * [pn_hi] [cs] F7` where `pn_lo`/`pn_hi` is a 14-bit septet pair
426
+ * encoding the preset number.
427
+ *
428
+ * Axe-Fx II preset numbering: 0-based linear index 0..N across the
429
+ * bank (each bank holds 128 presets typically; XL+ has bigger
430
+ * capacity). 0x14 GET_PRESET_NUMBER returns the same number space.
431
+ *
432
+ * Status: 🟢 hardware-verified on Q8.02 (2026-05-11, HW-100).
433
+ * `preset_number: 0` loaded "59 Bassguy" into the working buffer
434
+ * cleanly; front panel showed slot "1" (the MIDI 0-based index maps
435
+ * to the device's 1-based front-panel display — preset N appears as
436
+ * slot N+1 on the front panel).
437
+ */
438
+ export function buildSwitchPreset(presetNumber, opts = {}) {
439
+ if (!Number.isInteger(presetNumber) || presetNumber < 0 || presetNumber > 0x3fff) {
440
+ throw new Error(`buildSwitchPreset: preset number out of range (0..16383): ${presetNumber}`);
441
+ }
442
+ const modelId = opts.modelId ?? AXE_FX_II_XL_PLUS_MODEL_ID;
443
+ // **MSB-first** byte ordering — matches `buildStorePreset` (0x1D) and
444
+ // the device's own `GET_PRESET_NUMBER` (0x14) response, both of
445
+ // which were hardware-verified on Q8.02 (HW-100/HW-102, session-61).
446
+ //
447
+ // The wiki documents this envelope as LSB-first, but Session 68
448
+ // hardware testing on Q8.02 showed that LSB-first encoding silently
449
+ // fails for any preset ≥ 128 — the device receives the bytes,
450
+ // doesn't ACK or NACK, and stays on the previously-active slot
451
+ // (founder slot-705 test 2026-05-12; agent's "RockOfAges" build
452
+ // appeared at slot 683 instead of slot 702 because the per-entry
453
+ // switch never landed and the writes hit whatever working buffer
454
+ // was active). For preset < 128 both orderings produce identical
455
+ // bytes, which is why HW-100 (test on wire 0) didn't catch this.
456
+ //
457
+ // Closes HW-103 (verify buildSwitchPreset byte ordering for preset ≥ 128).
458
+ const presetHigh = (presetNumber >> 7) & 0x7f;
459
+ const presetLow = presetNumber & 0x7f;
460
+ return buildEnvelope(modelId, [
461
+ FUNC_SWITCH_PRESET,
462
+ presetHigh,
463
+ presetLow,
464
+ ]);
465
+ }
466
+ /**
467
+ * Set working-buffer preset name (function 0x09). Wiki documents the
468
+ * envelope as the function byte followed by ASCII characters. Preset
469
+ * names on Axe-Fx II are 32 chars, space-padded.
470
+ *
471
+ * Status: 🟢 hardware-verified on Q8.02 (2026-05-11, HW-100).
472
+ * `"HW-100 Test"` written to the working buffer; immediate
473
+ * `axefx2_get_preset_name` echoed it back; front panel showed the
474
+ * new name. Working-buffer scope confirmed — switching presets
475
+ * discards it (no persistent change to factory slot).
476
+ */
477
+ export function buildSetPresetName(name, opts = {}) {
478
+ if (name.length > 32) {
479
+ throw new Error(`buildSetPresetName: name too long (max 32 chars): "${name}" (${name.length})`);
480
+ }
481
+ // Validate ASCII-printable per project convention. Allow space + printable 0x20..0x7E.
482
+ for (let i = 0; i < name.length; i++) {
483
+ const c = name.charCodeAt(i);
484
+ if (c < 0x20 || c > 0x7e) {
485
+ throw new Error(`buildSetPresetName: non-ASCII-printable char at position ${i}: 0x${c.toString(16)}`);
486
+ }
487
+ }
488
+ // Right-pad with spaces to 32 chars (Axe-Fx II convention; matches
489
+ // GET_PRESET_NAME response shape).
490
+ const padded = name.padEnd(32, ' ');
491
+ const modelId = opts.modelId ?? AXE_FX_II_XL_PLUS_MODEL_ID;
492
+ return buildEnvelope(modelId, [
493
+ FUNC_SET_PRESET_NAME,
494
+ ...Array.from(padded, (c) => c.charCodeAt(0)),
495
+ ]);
496
+ }
497
+ /**
498
+ * STORE the working buffer to a user preset slot (function 0x1D).
499
+ *
500
+ * Wire envelope:
501
+ *
502
+ * F0 00 01 74 [model] 1D [preset_high] [preset_low] [cs] F7
503
+ * preset_high = (n >> 7) & 0x7F
504
+ * preset_low = n & 0x7F
505
+ *
506
+ * Note: this function uses **MSB-first** byte ordering for the 14-bit
507
+ * preset number, unlike `buildSwitchPreset` / `buildEnvelope(..., [0x02,
508
+ * ...effectId, ...paramId, ...])` which use LSB-first via `encode14()`.
509
+ * The MSB-first ordering for 0x1D is taken from bspaulding/axe-fx-midi
510
+ * (Rust) and matches the byte order the device uses for its own
511
+ * 0x14 GET_PRESET_NUMBER response (captured session-61 payload `05 3B`
512
+ * for preset 699 = display preset 700).
513
+ *
514
+ * Status: 🟢 hardware-verified on Q8.02 XL+ (HW-102, 2026-05-11).
515
+ * End-to-end round-trip from our encoder landed on the first attempt:
516
+ * `axefx2_save_preset({ preset_number: 699, name: "..." })` produced
517
+ * the captured save sequence, device returned `0x64 1D 00` (OK),
518
+ * working buffer persisted to slot 700 confirmed by founder front-
519
+ * panel inspection. Wire format derived from bspaulding/axe-fx-midi
520
+ * + session-61 passive capture (AxeEdit File → Save Preset); our
521
+ * encoder matches AxeEdit's behavior byte-for-byte.
522
+ *
523
+ * Cross-checks before shipping:
524
+ * - bspaulding's test case (Mark II, preset 217):
525
+ * `[F0 00 01 74 03 1D 01 59 43 F7]` — locked as a golden in
526
+ * `scripts/verify-axe-fx-ii-encoding.ts`.
527
+ * - XL+ encoding for preset 699:
528
+ * `[F0 00 01 74 07 1D 05 3B 21 F7]` — also locked, paired with the
529
+ * captured device-side 0x64 ACK pattern.
530
+ *
531
+ * 0-vs-1 indexing: same as `buildSwitchPreset` — wire is 0-based, the
532
+ * device's front panel displays 1-based. To save to what the user sees
533
+ * as "preset 700," pass `presetNumber: 699`. The tool layer surfaces
534
+ * this in `axefx2_save_preset`'s description so the agent translates.
535
+ */
536
+ export function buildStorePreset(presetNumber, opts = {}) {
537
+ if (!Number.isInteger(presetNumber) || presetNumber < 0 || presetNumber > 0x3fff) {
538
+ throw new Error(`buildStorePreset: preset number out of range (0..16383): ${presetNumber}`);
539
+ }
540
+ const modelId = opts.modelId ?? AXE_FX_II_XL_PLUS_MODEL_ID;
541
+ const high = (presetNumber >> 7) & 0x7f;
542
+ const low = presetNumber & 0x7f;
543
+ return buildEnvelope(modelId, [FUNC_STORE_PRESET, high, low]);
544
+ }
545
+ /**
546
+ * Match a MULTIPURPOSE_RESPONSE (function 0x64) acknowledging a
547
+ * STORE_PRESET (function 0x1D) request. The device's response format
548
+ * is `[echoed_fn, result_code]`:
549
+ *
550
+ * F0 00 01 74 [model] 64 1D [result_code] [cs] F7
551
+ * result_code 0x00 = OK
552
+ * result_code 0x05 = parsed but not honored (e.g. malformed payload)
553
+ *
554
+ * Captured session-61, 2026-05-11 from AxeEdit's save-to-slot operation
555
+ * on Q8.02 XL+: `F0 00 01 74 07 64 1D 00 7B F7` (result=OK).
556
+ */
557
+ export function isStorePresetResponse(bytes) {
558
+ if (bytes.length < 10)
559
+ return false;
560
+ if (bytes[0] !== SYSEX_START)
561
+ return false;
562
+ if (bytes[1] !== FRACTAL_MFR[0])
563
+ return false;
564
+ if (bytes[2] !== FRACTAL_MFR[1])
565
+ return false;
566
+ if (bytes[3] !== FRACTAL_MFR[2])
567
+ return false;
568
+ // bytes[4] = model byte — accept any (we may encounter the same response
569
+ // shape across II / XL / XL+ / AX8 if cross-revision support lands later).
570
+ if (bytes[5] !== FUNC_MULTIPURPOSE_RESPONSE)
571
+ return false;
572
+ if (bytes[6] !== FUNC_STORE_PRESET)
573
+ return false;
574
+ return bytes[bytes.length - 1] === SYSEX_END;
575
+ }
576
+ export function parseStorePresetResponse(bytes) {
577
+ if (!isStorePresetResponse(bytes)) {
578
+ throw new Error(`parseStorePresetResponse: not a STORE_PRESET MULTIPURPOSE_RESPONSE: ${bytes.map((b) => b.toString(16).padStart(2, '0')).join(' ')}`);
579
+ }
580
+ const resultCode = bytes[7];
581
+ return { resultCode, ok: resultCode === 0x00 };
582
+ }
583
+ /**
584
+ * GET the device's currently-active preset number (function 0x14).
585
+ *
586
+ * Wire envelope (request): F0 00 01 74 [model] 14 [cs] F7 (no payload)
587
+ * Wire envelope (response): F0 00 01 74 [model] 14 [hi] [lo] [cs] F7
588
+ *
589
+ * **Byte ordering is MSB-first** in the response — `[bits 13-7, bits 6-0]`
590
+ * — contrary to the wiki's "bits 6-0 first" documentation. Empirically
591
+ * disambiguated by session-61 passive capture: payload `05 3B` decodes
592
+ * to wire preset 699 (= front-panel display "slot 700") only under
593
+ * MSB-first, matching the founder's reported state at capture time.
594
+ * LSB-first decode would yield preset 7557, which is impossible (XL+
595
+ * user range is 0..767). See `docs/SYSEX-MAP-AXE-FX-II.md` § 6b.
596
+ *
597
+ * Status: 🟡 wire format from session-61 passive capture; will flip to
598
+ * 🟢 once axefx2_get_active_preset_number lands end-to-end on Q8.02.
599
+ */
600
+ export function buildGetPresetNumber(opts = {}) {
601
+ const modelId = opts.modelId ?? AXE_FX_II_XL_PLUS_MODEL_ID;
602
+ return buildEnvelope(modelId, [FUNC_GET_PRESET_NUMBER]);
603
+ }
604
+ export function isGetPresetNumberResponse(bytes) {
605
+ if (bytes.length < 10)
606
+ return false;
607
+ if (bytes[0] !== SYSEX_START)
608
+ return false;
609
+ if (bytes[1] !== FRACTAL_MFR[0])
610
+ return false;
611
+ if (bytes[2] !== FRACTAL_MFR[1])
612
+ return false;
613
+ if (bytes[3] !== FRACTAL_MFR[2])
614
+ return false;
615
+ if (bytes[5] !== FUNC_GET_PRESET_NUMBER)
616
+ return false;
617
+ if (bytes[bytes.length - 1] !== SYSEX_END)
618
+ return false;
619
+ // Distinguish from a bare request echo: a response must carry the
620
+ // two-byte preset-number payload (total length = 6 header + 2 payload
621
+ // + 1 cs + 1 end = 10 bytes).
622
+ return bytes.length === 10;
623
+ }
624
+ export function parseGetPresetNumberResponse(bytes) {
625
+ if (!isGetPresetNumberResponse(bytes)) {
626
+ throw new Error(`parseGetPresetNumberResponse: not a GET_PRESET_NUMBER response: ${bytes.map((b) => b.toString(16).padStart(2, '0')).join(' ')}`);
627
+ }
628
+ const high = bytes[6] & 0x7f;
629
+ const low = bytes[7] & 0x7f;
630
+ const presetNumber = (high << 7) | low;
631
+ return { presetNumber, displaySlot: presetNumber + 1 };
632
+ }
633
+ /**
634
+ * Set the block at a grid cell (function 0x05). Places a block at the
635
+ * specified cell, or clears the cell (blockId = 0). If the block was
636
+ * already on the grid elsewhere, the device MOVES it (clears its
637
+ * previous cell as a side effect).
638
+ *
639
+ * Wire envelope:
640
+ *
641
+ * F0 00 01 74 [model] 05
642
+ * [blockId_lo] [blockId_hi]
643
+ * [cell_idx]
644
+ * [reserved=0]
645
+ * [cs] F7
646
+ *
647
+ * cell_idx = (col_idx × 4) + row_idx
648
+ * col_idx ∈ 0..11 (column 1..12 minus 1)
649
+ * row_idx ∈ 0..3 (row 1..4 minus 1)
650
+ * col-major, top-to-bottom — same ordering as the 0x20
651
+ * GET_GRID_LAYOUT_AND_ROUTING response.
652
+ *
653
+ * blockId = 14-bit, LSB-first septet pair.
654
+ * 0 = empty cell (clears whatever was there)
655
+ * 100..170 = named blocks (see blockTypes.ts)
656
+ * 200..235 = shunts (pass-through cells)
657
+ *
658
+ * reserved = the device accepts 0x00; non-zero behavior is unknown.
659
+ * May be a routing-flag mask in some firmwares; on Q8.02 XL+
660
+ * we observed no effect on routing when set. The device
661
+ * auto-assigns a routing mask to newly-placed cells (e.g.
662
+ * :2 for row-2 placements) regardless of this byte.
663
+ *
664
+ * Decoding evidence (session-62 + session-63 probes, 2026-05-11):
665
+ *
666
+ * Probe (payload bytes after 0x05) Observed effect
667
+ * ─────────────────────────────────────────────────────────
668
+ * 64 00 00 00 → CPR1 at cell 0 (R1C1) ✓ matches
669
+ * 00 00 02 00 → empty at cell 2 (R3C1) ✓ matches (was already empty)
670
+ * 00 00 01 02 → empty at cell 1 (R2C1) ✓ matches (CPR1 cleared)
671
+ * 64 00 01 00 → CPR1 at cell 1 (R2C1) ✓ matches (CPR1 returned)
672
+ *
673
+ * Cell index 1 disambiguates col-major vs row-major: under col-major
674
+ * it points to (col 0, row 1) = R2C1, which IS where the change
675
+ * landed. Under row-major it would point to (row 0, col 1) = R1C2,
676
+ * which is empty and would show no change. Col-major confirmed.
677
+ *
678
+ * Probes with bare (no payload) and 1-byte payload were rejected by
679
+ * the device with non-OK result codes (0x06 / 0x0C) — the 4-byte
680
+ * payload is the minimum the device accepts.
681
+ *
682
+ * KNOWN LIMITATION: this write does NOT propagate routing/cabling.
683
+ * Moving a block out of a cell leaves the downstream block's input
684
+ * mask pointing at empty space (audio dead-end). The agent / user is
685
+ * responsible for re-wiring via a separate mechanism (TBD — likely
686
+ * either byte[3] of this function with the right value, or a sibling
687
+ * function byte like 0x06 which also fires during grid-move captures).
688
+ *
689
+ * Status: 🟢 wire format hardware-validated on Q8.02 XL+
690
+ * (session-63 probe sequence). Routing-propagation limitation
691
+ * documented above.
692
+ */
693
+ export function buildSetGridCell(opts) {
694
+ const { row, col, blockId } = opts;
695
+ if (!Number.isInteger(row) || row < 1 || row > 4) {
696
+ throw new Error(`buildSetGridCell: row out of range (1..4): ${row}`);
697
+ }
698
+ if (!Number.isInteger(col) || col < 1 || col > 12) {
699
+ throw new Error(`buildSetGridCell: col out of range (1..12): ${col}`);
700
+ }
701
+ if (!Number.isInteger(blockId) || blockId < 0 || blockId > 0x3fff) {
702
+ throw new Error(`buildSetGridCell: blockId out of range (0..16383): ${blockId}`);
703
+ }
704
+ const cellIdx = (col - 1) * 4 + (row - 1);
705
+ const modelId = opts.modelId ?? AXE_FX_II_XL_PLUS_MODEL_ID;
706
+ return buildEnvelope(modelId, [
707
+ FUNC_SET_GRID_CELL,
708
+ blockId & 0x7f, // blockId LSB septet
709
+ (blockId >> 7) & 0x7f, // blockId MSB septet
710
+ cellIdx & 0x7f, // cell index 0..47
711
+ 0x00, // reserved / routing-flag-mask (unused on Q8.02)
712
+ ]);
713
+ }
714
+ /**
715
+ * Match a MULTIPURPOSE_RESPONSE (function 0x64) acknowledging a
716
+ * SET_GRID_CELL (function 0x05) request.
717
+ *
718
+ * F0 00 01 74 [model] 64 05 [result_code] [cs] F7
719
+ *
720
+ * Result codes observed on Q8.02 XL+ during decode:
721
+ * 0x00 — OK, write applied
722
+ * 0x06 — payload too short (e.g. bare envelope)
723
+ * 0x0C — payload too short (e.g. 1-byte payload)
724
+ *
725
+ * Other values are unknown; treat any non-0x00 as a rejection.
726
+ */
727
+ export function isSetGridCellResponse(bytes) {
728
+ if (bytes.length < 10)
729
+ return false;
730
+ if (bytes[0] !== SYSEX_START)
731
+ return false;
732
+ if (bytes[1] !== FRACTAL_MFR[0])
733
+ return false;
734
+ if (bytes[2] !== FRACTAL_MFR[1])
735
+ return false;
736
+ if (bytes[3] !== FRACTAL_MFR[2])
737
+ return false;
738
+ if (bytes[5] !== FUNC_MULTIPURPOSE_RESPONSE)
739
+ return false;
740
+ if (bytes[6] !== FUNC_SET_GRID_CELL)
741
+ return false;
742
+ return bytes[bytes.length - 1] === SYSEX_END;
743
+ }
744
+ export function parseSetGridCellResponse(bytes) {
745
+ if (!isSetGridCellResponse(bytes)) {
746
+ throw new Error(`parseSetGridCellResponse: not a SET_GRID_CELL MULTIPURPOSE_RESPONSE: ${bytes.map((b) => b.toString(16).padStart(2, '0')).join(' ')}`);
747
+ }
748
+ const resultCode = bytes[7];
749
+ return { resultCode, ok: resultCode === 0x00 };
750
+ }
751
+ /**
752
+ * Build a SET_CELL_ROUTING message (function 0x06) — add or remove a
753
+ * cable between two adjacent-column grid cells. Companion to
754
+ * `buildSetGridCell` (function 0x05): 0x05 places the block, 0x06 wires
755
+ * its inputs.
756
+ *
757
+ * Wire envelope:
758
+ *
759
+ * F0 00 01 74 [model] 06
760
+ * [src_cell_idx] ← col-major linear index (col-1)*4 + (row-1)
761
+ * [dst_cell_idx] ← col-major linear index; MUST be src_col + 1
762
+ * [connect] ← 0x01 = add cable, 0x00 = remove cable
763
+ * [cs] F7
764
+ *
765
+ * Effect: the device updates `dst_cell.routing_mask` by setting
766
+ * (connect=1) or clearing (connect=0) the bit at index `src_row_0indexed`.
767
+ * The mask byte uses 4-bit input-mask encoding — bit N set means "feed
768
+ * from row N+1 of previous column" (see `parseGetGridLayoutResponse`).
769
+ * So a cable from row 2 → row 2 with src_col=2, dst_col=3 toggles
770
+ * `dst_cell.routing_mask`'s bit 1 (= 0x02) on or off.
771
+ *
772
+ * Status: 🟢 hardware-decoded on Q8.02 XL+ (Session 70, 2026-05-13).
773
+ * Captured AxeEdit's outbound fn 0x06 from a click-to-connect on
774
+ * Amp(R2C2) → Cab(R2C3):
775
+ *
776
+ * F0 00 01 74 07 06 05 09 01 09 F7
777
+ * src_cell = 5 = (2-1)*4 + (2-1) = R2C2
778
+ * dst_cell = 9 = (3-1)*4 + (2-1) = R2C3
779
+ * connect = 1 = add cable
780
+ *
781
+ * Replayed by our own probe with byte-exact match — device acked
782
+ * 0x00 OK and the grid-state read confirmed Cab's routing mask
783
+ * flipped 0x00 → 0x02 ("Cab now feeds from row 2 of col 2 = from Amp").
784
+ *
785
+ * Validates adjacency (`dstCol === srcCol + 1`); the device rejects
786
+ * non-adjacent cables. Cross-row cables (e.g. row 1 of col 5 → row 3
787
+ * of col 6) ARE allowed — that's how parallel paths are wired.
788
+ */
789
+ export function buildSetCellRouting(opts) {
790
+ const { srcRow, srcCol, dstRow, dstCol, connect = true } = opts;
791
+ if (!Number.isInteger(srcRow) || srcRow < 1 || srcRow > 4) {
792
+ throw new Error(`buildSetCellRouting: srcRow out of range (1..4): ${srcRow}`);
793
+ }
794
+ if (!Number.isInteger(srcCol) || srcCol < 1 || srcCol > 11) {
795
+ throw new Error(`buildSetCellRouting: srcCol out of range (1..11): ${srcCol}`);
796
+ }
797
+ if (!Number.isInteger(dstRow) || dstRow < 1 || dstRow > 4) {
798
+ throw new Error(`buildSetCellRouting: dstRow out of range (1..4): ${dstRow}`);
799
+ }
800
+ if (!Number.isInteger(dstCol) || dstCol < 2 || dstCol > 12) {
801
+ throw new Error(`buildSetCellRouting: dstCol out of range (2..12): ${dstCol}`);
802
+ }
803
+ if (dstCol !== srcCol + 1) {
804
+ throw new Error(`buildSetCellRouting: dstCol (${dstCol}) must equal srcCol + 1 (got src=${srcCol}, dst=${dstCol}). ` +
805
+ `Cables connect adjacent columns only — the device rejects off-column cables.`);
806
+ }
807
+ const srcCellIdx = (srcCol - 1) * 4 + (srcRow - 1);
808
+ const dstCellIdx = (dstCol - 1) * 4 + (dstRow - 1);
809
+ const modelId = opts.modelId ?? AXE_FX_II_XL_PLUS_MODEL_ID;
810
+ return buildEnvelope(modelId, [
811
+ FUNC_SET_CELL_ROUTING,
812
+ srcCellIdx & 0x7f,
813
+ dstCellIdx & 0x7f,
814
+ connect ? 0x01 : 0x00,
815
+ ]);
816
+ }
817
+ /**
818
+ * Match a MULTIPURPOSE_RESPONSE (function 0x64) acknowledging a
819
+ * SET_CELL_ROUTING (function 0x06) request.
820
+ *
821
+ * F0 00 01 74 [model] 64 06 [result_code] [cs] F7
822
+ *
823
+ * Result codes observed on Q8.02 XL+ during decode:
824
+ * 0x00 — OK, routing updated
825
+ * 0x01 — request rejected (e.g. non-adjacent columns, malformed shape)
826
+ * 0x0C — payload length too short
827
+ */
828
+ export function isSetCellRoutingResponse(bytes) {
829
+ if (bytes.length < 10)
830
+ return false;
831
+ if (bytes[0] !== SYSEX_START)
832
+ return false;
833
+ if (bytes[1] !== FRACTAL_MFR[0])
834
+ return false;
835
+ if (bytes[2] !== FRACTAL_MFR[1])
836
+ return false;
837
+ if (bytes[3] !== FRACTAL_MFR[2])
838
+ return false;
839
+ if (bytes[5] !== FUNC_MULTIPURPOSE_RESPONSE)
840
+ return false;
841
+ if (bytes[6] !== FUNC_SET_CELL_ROUTING)
842
+ return false;
843
+ return bytes[bytes.length - 1] === SYSEX_END;
844
+ }
845
+ export function parseSetCellRoutingResponse(bytes) {
846
+ if (!isSetCellRoutingResponse(bytes)) {
847
+ throw new Error(`parseSetCellRoutingResponse: not a SET_CELL_ROUTING MULTIPURPOSE_RESPONSE: ${bytes.map((b) => b.toString(16).padStart(2, '0')).join(' ')}`);
848
+ }
849
+ const resultCode = bytes[7];
850
+ return { resultCode, ok: resultCode === 0x00 };
851
+ }
852
+ export function buildStateBroadcastTripleMessages(targetId, values, opts = {}) {
853
+ if (!Number.isInteger(targetId) || targetId < 0 || targetId > 0x3fff) {
854
+ throw new Error(`State-broadcast targetId out of range: ${targetId}`);
855
+ }
856
+ const opFlag = opts.opFlag ?? 0x01;
857
+ if (!Number.isInteger(opFlag) || opFlag < 0 || opFlag > 0x7f) {
858
+ throw new Error(`State-broadcast opFlag out of range: ${opFlag}`);
859
+ }
860
+ const modelId = opts.modelId ?? AXE_FX_II_XL_PLUS_MODEL_ID;
861
+ const header = buildEnvelope(modelId, [
862
+ FUNC_STATE_DUMP_HEADER,
863
+ ...encode14(targetId),
864
+ ...encode14(values.length),
865
+ opFlag,
866
+ ]);
867
+ const chunks = [];
868
+ for (let start = 0; start < values.length; start += STATE_DUMP_CHUNK_MAX_ITEMS) {
869
+ const slice = values.slice(start, start + STATE_DUMP_CHUNK_MAX_ITEMS);
870
+ const body = [FUNC_STATE_DUMP_CHUNK, ...encode14(slice.length)];
871
+ for (const v of slice) {
872
+ const [b0, b1, b2] = packValue16(v);
873
+ body.push(b0, b1, b2);
874
+ }
875
+ chunks.push(buildEnvelope(modelId, body));
876
+ }
877
+ // Empty value list still emits a single zero-item chunk so the
878
+ // header + chunk + footer triple shape stays consistent.
879
+ if (chunks.length === 0) {
880
+ chunks.push(buildEnvelope(modelId, [FUNC_STATE_DUMP_CHUNK, ...encode14(0)]));
881
+ }
882
+ const footer = buildEnvelope(modelId, [FUNC_STATE_DUMP_FOOTER]);
883
+ return { header, chunks, footer };
884
+ }
885
+ export function buildStateBroadcastTriple(targetId, values, opts = {}) {
886
+ const { header, chunks, footer } = buildStateBroadcastTripleMessages(targetId, values, opts);
887
+ return [...header, ...chunks.flat(), ...footer];
888
+ }