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,819 @@
1
+ /**
2
+ * AM4 0x01 SET_PARAM (write) and READ message builders.
3
+ *
4
+ * Message layout (after envelope F0 00 01 74 15 01):
5
+ * [hdr0_lo hdr0_hi] [hdr1_lo hdr1_hi] [hdr2_lo hdr2_hi]
6
+ * [hdr3_lo hdr3_hi] [hdr4_lo hdr4_hi]
7
+ * [packed_value_bytes...]
8
+ * [cs] F7
9
+ *
10
+ * Each header field is a 14-bit little-endian integer split into two 7-bit
11
+ * septets. See docs/SYSEX-MAP.md §6a for field meanings.
12
+ */
13
+ import { fractalChecksum } from '../shared/checksum.js';
14
+ import { packFloat32LE, packValue, packValueChunked, unpackValue, unpackValueChunked } from '../shared/packValue.js';
15
+ import { KNOWN_PARAMS, encode } from './params.js';
16
+ export const AM4_MODEL_ID = 0x15;
17
+ const SYSEX_START = 0xf0;
18
+ const SYSEX_END = 0xf7;
19
+ const FRACTAL_MFR = [0x00, 0x01, 0x74];
20
+ const FUNC_PARAM_RW = 0x01;
21
+ const ACTION_WRITE = 0x0001;
22
+ const ACTION_SAVE_TO_LOCATION = 0x001b;
23
+ const ACTION_RENAME = 0x000c;
24
+ const ACTION_READ_PRESET_NAME = 0x0012;
25
+ const PRESET_NAME_BYTES = 32;
26
+ const RENAME_PID_LOW = 0x00ce;
27
+ const RENAME_PRESET_PID_HIGH = 0x000b;
28
+ const SCENE_SWITCH_PID_LOW = 0x00ce;
29
+ const SCENE_SWITCH_PID_HIGH = 0x000d;
30
+ const PRESET_SWITCH_PID_LOW = 0x00ce;
31
+ const PRESET_SWITCH_PID_HIGH = 0x000a;
32
+ const SCENE_RENAME_PID_LOW = 0x00ce;
33
+ const SCENE_RENAME_PID_HIGH_BASE = 0x0037;
34
+ const SCENE_NAME_BYTES = 32;
35
+ function encode14(n) {
36
+ if (n < 0 || n > 0x3fff)
37
+ throw new Error(`14-bit value out of range: ${n}`);
38
+ return [n & 0x7f, (n >> 7) & 0x7f];
39
+ }
40
+ /** Build a 0x01 WRITE message setting `param` to a 32-bit float `value`. */
41
+ export function buildSetFloatParam(param, value) {
42
+ const valueBytes = Array.from(packFloat32LE(value));
43
+ const body = [
44
+ AM4_MODEL_ID,
45
+ FUNC_PARAM_RW,
46
+ ...encode14(param.pidLow),
47
+ ...encode14(param.pidHigh),
48
+ ...encode14(ACTION_WRITE),
49
+ ...encode14(0x0000), // hdr3 reserved
50
+ ...encode14(valueBytes.length - 1), // hdr4 = raw byte count (= 4 for float32)
51
+ ...valueBytes,
52
+ ];
53
+ const head = [SYSEX_START, ...FRACTAL_MFR, ...body];
54
+ const cs = fractalChecksum(head);
55
+ return [...head, cs, SYSEX_END];
56
+ }
57
+ /**
58
+ * High-level write: look up `key` in the parameter registry, convert
59
+ * `displayValue` to its internal float via the param's unit scale, and
60
+ * build the SET_PARAM message.
61
+ *
62
+ * Example: `buildSetParam('amp.gain', 7.5)` → internal float 0.75.
63
+ */
64
+ export function buildSetParam(key, displayValue) {
65
+ const param = KNOWN_PARAMS[key];
66
+ return buildSetFloatParam(param, encode(param, displayValue));
67
+ }
68
+ /**
69
+ * Predicate for `receiveSysExMatching` that accepts the AM4's wire-level
70
+ * acknowledgement of a WRITE we just sent — a 64-byte frame carrying the
71
+ * same pidLow/pidHigh, action `0x0001`, and `hdr4 = 0x0028` (40-byte
72
+ * param descriptor).
73
+ *
74
+ * This matches the shape of the ack but does NOT tell apply from absorb.
75
+ * Session 19 hardware testing proved the AM4 emits this same 64-byte ack
76
+ * for writes to absent blocks (write had no audible effect) as well as
77
+ * for writes to placed blocks (write landed). The 40-byte payload likely
78
+ * contains a discriminator we haven't decoded — future work.
79
+ *
80
+ * A separate 23-byte frame byte-identical to our outgoing write also
81
+ * appears on the input port (USB-MIDI receipt-echo or driver loopback);
82
+ * the `hdr4 = 0x0028` check here filters that receipt-echo out so the
83
+ * predicate matches the genuine device-originated ack.
84
+ */
85
+ export function isWriteEcho(write, response) {
86
+ // Header runs bytes 0..15 (envelope + func + 5 × 14-bit fields).
87
+ if (response.length < 16)
88
+ return false;
89
+ // Envelope + function byte (bytes 0..5 of the write) must match exactly.
90
+ for (let i = 0; i < 6; i++)
91
+ if (response[i] !== write[i])
92
+ return false;
93
+ // pidLow (bytes 6..7) and pidHigh (bytes 8..9) septets must match.
94
+ for (let i = 6; i < 10; i++)
95
+ if (response[i] !== write[i])
96
+ return false;
97
+ // Action must be WRITE (0x0001) — 0x0026 is AM4-Edit's status poll.
98
+ if (response[10] !== 0x01 || response[11] !== 0x00)
99
+ return false;
100
+ // hdr4 must be 0x0028 (40-byte param descriptor payload). A 0x0004 here
101
+ // is our own write reflected back (loopback/receipt-echo), not an apply.
102
+ if (response[14] !== 0x28 || response[15] !== 0x00)
103
+ return false;
104
+ return true;
105
+ }
106
+ /**
107
+ * Predicate for `receiveSysExMatching` that accepts the AM4's "command ack"
108
+ * — the 18-byte frame returned after successful addressing-only commands
109
+ * (`save_to_location`, `set_preset_name`, `set_scene_name`). Shape
110
+ * confirmed 2026-04-19 across both save and rename on hardware:
111
+ *
112
+ * F0 00 01 74 15 01 <pidLow septets> <pidHigh septets>
113
+ * <action septets> 00 00 00 00 <cksum> F7
114
+ *
115
+ * Addressing bytes (pidLow/pidHigh/action) echo the outgoing command
116
+ * verbatim. hdr4 (bytes 12..13) is zero — no payload; the remaining two
117
+ * bytes at 14..15 are also zero. This is a distinct shape from the
118
+ * 64-byte SET_PARAM write-echo (hdr4 = 0x0028, 40-byte payload) and the
119
+ * 23-byte USB-MIDI receipt-echo of our own bytes.
120
+ *
121
+ * Used by save/rename tools to report a clean "ack received" status
122
+ * instead of dumping the raw frame to Claude for hex inspection.
123
+ */
124
+ export function isCommandAck(write, response) {
125
+ if (response.length !== 18)
126
+ return false;
127
+ if (response[0] !== SYSEX_START || response[17] !== SYSEX_END)
128
+ return false;
129
+ // Envelope + function byte (bytes 0..5) must match the outgoing write.
130
+ for (let i = 0; i < 6; i++)
131
+ if (response[i] !== write[i])
132
+ return false;
133
+ // pidLow (6..7), pidHigh (8..9), action (10..11) echo the outgoing write.
134
+ for (let i = 6; i < 12; i++)
135
+ if (response[i] !== write[i])
136
+ return false;
137
+ // hdr4 + trailing zero pad (12..15) all zero — 0-byte payload.
138
+ for (let i = 12; i < 16; i++)
139
+ if (response[i] !== 0x00)
140
+ return false;
141
+ return true;
142
+ }
143
+ /**
144
+ * Block-placement register: pidLow that addresses the "which block occupies
145
+ * slot N" state. The AM4 exposes 4 slots (positions 1..4 in the signal
146
+ * chain) at pidHigh = 0x000F, 0x0010, 0x0011, 0x0012 respectively. Writing
147
+ * a block's own pidLow as the float32 value places that block in the slot;
148
+ * writing 0 clears the slot to "none" (empty). pidHigh = 0x0013 is NOT a
149
+ * valid slot — the AM4 emits a structurally different ack and may produce
150
+ * side effects on unrelated slots (observed Session 19 hardware test).
151
+ *
152
+ * Decoded Session 19 from Session 18 captures — see SYSEX-MAP.md §6c.
153
+ */
154
+ export const BLOCK_SLOT_PID_LOW = 0x00ce;
155
+ export const BLOCK_SLOT_PID_HIGH_BASE = 0x000f;
156
+ /**
157
+ * Build a WRITE that places `blockTypeValue` into slot `position` (1..4).
158
+ * `blockTypeValue` is the target block's own pidLow (see `blockTypes.ts`);
159
+ * pass 0 to clear the slot.
160
+ *
161
+ * Hardware-mapped Session 19: sending pidHigh 0x10/0x11/0x12 landed on
162
+ * device slots 2/3/4, and pidHigh 0x13 produced an invalid-ack with
163
+ * side effects on an unrelated slot — hence the base 0x000F so that
164
+ * position 1..4 map to pidHigh 0x0F..0x12. Position 1 (pidHigh 0x000F)
165
+ * isn't exercised by any capture on disk, but fits the linear pattern;
166
+ * expected to land on device slot 1, pending independent hardware
167
+ * confirmation after the base-address fix.
168
+ */
169
+ export function buildSetBlockType(position, blockTypeValue) {
170
+ if (position < 1 || position > 4 || !Number.isInteger(position)) {
171
+ throw new Error(`Block position must be an integer 1..4, got ${position}`);
172
+ }
173
+ return buildSetFloatParam({
174
+ pidLow: BLOCK_SLOT_PID_LOW,
175
+ pidHigh: BLOCK_SLOT_PID_HIGH_BASE + (position - 1),
176
+ }, blockTypeValue);
177
+ }
178
+ /**
179
+ * Build a SAVE-TO-LOCATION command that persists the AM4's current working
180
+ * buffer to preset location `locationIndex` (0..103, A01..Z04). The command
181
+ * uses the PARAM_RW function (0x01) with a fresh action byte — 0x001B —
182
+ * which appears only in save captures. pidLow/pidHigh are both 0x0000
183
+ * (not a block/param address; the "target" is the location itself,
184
+ * carried in the payload).
185
+ *
186
+ * Payload = 4-byte uint32 LE location index (Z04 = 103 = 0x67 →
187
+ * `67 00 00 00` raw, `33 40 00 00 00` after the 8-to-7 septet pack).
188
+ *
189
+ * Decoded Session 19 from `session-18-save-preset-z04.pcapng`. Byte-exact
190
+ * golden lives in `verify-msg`.
191
+ *
192
+ * WRITE SAFETY: overwrites the target location. Only Z04 is designated
193
+ * scratch during RE — callers are responsible for gating this.
194
+ */
195
+ export function buildSaveToLocation(locationIndex) {
196
+ if (!Number.isInteger(locationIndex) || locationIndex < 0 || locationIndex > 103) {
197
+ throw new Error(`Preset location index must be integer 0..103, got ${locationIndex}.`);
198
+ }
199
+ const raw = new Uint8Array(4);
200
+ new DataView(raw.buffer).setUint32(0, locationIndex, true);
201
+ const packed = Array.from(packValue(raw));
202
+ const body = [
203
+ AM4_MODEL_ID,
204
+ FUNC_PARAM_RW,
205
+ ...encode14(0x0000), // pidLow = 0 (no block/param — save is a global action)
206
+ ...encode14(0x0000), // pidHigh = 0
207
+ ...encode14(ACTION_SAVE_TO_LOCATION), // action = 0x001B
208
+ ...encode14(0x0000), // hdr3
209
+ ...encode14(raw.length), // hdr4 = 4 (raw byte count, pre-pack)
210
+ ...packed,
211
+ ];
212
+ const head = [SYSEX_START, ...FRACTAL_MFR, ...body];
213
+ return [...head, fractalChecksum(head), SYSEX_END];
214
+ }
215
+ /**
216
+ * Build a RENAME-PRESET command that sets the name of the preset stored
217
+ * at preset location `locationIndex`. Shares the block-slot register
218
+ * (pidLow=0x00CE) but with pidHigh=0x000B and a new action byte
219
+ * (0x000C).
220
+ *
221
+ * Payload is 36 raw bytes:
222
+ * [0..3] uint32 LE preset location index (same encoding as
223
+ * save-to-location)
224
+ * [4..35] 32-byte ASCII name, space-padded. Names longer than 32
225
+ * chars throw; shorter names are space-padded to 32.
226
+ *
227
+ * Decoded Session 19 from `session-20-rename-preset.pcapng` — see
228
+ * SYSEX-MAP §6e. Byte-exact golden in `verify-msg`.
229
+ *
230
+ * WRITE SAFETY: like save-to-location, this writes to a specific preset
231
+ * location and can clobber user presets. Callers should gate to Z04
232
+ * during RE.
233
+ */
234
+ export function buildSetPresetName(locationIndex, name) {
235
+ if (!Number.isInteger(locationIndex) || locationIndex < 0 || locationIndex > 103) {
236
+ throw new Error(`Preset location index must be integer 0..103, got ${locationIndex}.`);
237
+ }
238
+ if (name.length > PRESET_NAME_BYTES) {
239
+ throw new Error(`Preset name must be ≤ ${PRESET_NAME_BYTES} ASCII chars, got ${name.length}: "${name}".`);
240
+ }
241
+ // ASCII-only guard — the AM4 displays a limited character set; being
242
+ // strict here surfaces problems early instead of writing unrenderable
243
+ // codepoints to the device.
244
+ for (let i = 0; i < name.length; i++) {
245
+ const c = name.charCodeAt(i);
246
+ if (c < 0x20 || c > 0x7e) {
247
+ throw new Error(`Preset name contains non-ASCII-printable char 0x${c.toString(16)} at position ${i}: "${name}".`);
248
+ }
249
+ }
250
+ const raw = new Uint8Array(4 + PRESET_NAME_BYTES);
251
+ new DataView(raw.buffer).setUint32(0, locationIndex, true);
252
+ // AM4 names are space-padded (0x20), not null-padded. Confirmed by
253
+ // decoding session-20-rename-preset (raw bytes 4+N..35 were all 0x20
254
+ // after the "boston" prefix).
255
+ for (let i = 0; i < PRESET_NAME_BYTES; i++) {
256
+ raw[4 + i] = i < name.length ? name.charCodeAt(i) : 0x20;
257
+ }
258
+ // 36-byte payloads need chunked (7-at-a-time) packing — see packValue.ts
259
+ // comment. Single-chunk packing only works up to 7 raw bytes.
260
+ const packed = Array.from(packValueChunked(raw));
261
+ const body = [
262
+ AM4_MODEL_ID,
263
+ FUNC_PARAM_RW,
264
+ ...encode14(RENAME_PID_LOW),
265
+ ...encode14(RENAME_PRESET_PID_HIGH),
266
+ ...encode14(ACTION_RENAME),
267
+ ...encode14(0x0000), // hdr3
268
+ ...encode14(raw.length), // hdr4 = 36 (raw byte count, pre-pack)
269
+ ...packed,
270
+ ];
271
+ const head = [SYSEX_START, ...FRACTAL_MFR, ...body];
272
+ return [...head, fractalChecksum(head), SYSEX_END];
273
+ }
274
+ /**
275
+ * Build a SWITCH-SCENE command that sets the AM4's active scene to
276
+ * `sceneIndex` (0..3, corresponding to scenes 1..4 in the UI). Same
277
+ * preset-level register family as block placement and preset rename
278
+ * (pidLow=0x00CE), with pidHigh=0x000D and a standard WRITE action.
279
+ * Payload = 4-byte uint32 LE scene index — NOT a float32, to match
280
+ * the integer semantics of save-to-slot.
281
+ *
282
+ * Decoded Session 21 from `session-21-switch-scene-1-3-4.pcapng`
283
+ * (combined with `session-18-switch-scene.pcapng`). All four scene
284
+ * indices confirmed: 0→scene 1, 1→scene 2, 2→scene 3, 3→scene 4.
285
+ * pidHigh stays fixed at 0x000D; only the u32 value changes. Byte-
286
+ * exact goldens for all four scenes live in `verify-msg`.
287
+ */
288
+ export function buildSwitchScene(sceneIndex) {
289
+ if (!Number.isInteger(sceneIndex) || sceneIndex < 0 || sceneIndex > 3) {
290
+ throw new Error(`Scene index must be integer 0..3, got ${sceneIndex}.`);
291
+ }
292
+ const raw = new Uint8Array(4);
293
+ new DataView(raw.buffer).setUint32(0, sceneIndex, true);
294
+ const packed = Array.from(packValue(raw));
295
+ const body = [
296
+ AM4_MODEL_ID,
297
+ FUNC_PARAM_RW,
298
+ ...encode14(SCENE_SWITCH_PID_LOW),
299
+ ...encode14(SCENE_SWITCH_PID_HIGH),
300
+ ...encode14(ACTION_WRITE),
301
+ ...encode14(0x0000),
302
+ ...encode14(raw.length),
303
+ ...packed,
304
+ ];
305
+ const head = [SYSEX_START, ...FRACTAL_MFR, ...body];
306
+ return [...head, fractalChecksum(head), SYSEX_END];
307
+ }
308
+ /**
309
+ * Per-block bypass register: pidHigh = 0x0003 on the block's own pidLow
310
+ * (amp = 0x003A, drive = 0x0076, reverb = 0x0042, etc.). Value is a
311
+ * float32: 1.0 = bypassed (silent), 0.0 = active.
312
+ *
313
+ * Scene-scoping is implicit. The AM4 is stateful — bypass writes land on
314
+ * whichever scene is active right now. To change scene N's bypass for a
315
+ * block, the caller switches scene first (via `buildSwitchScene(n)`),
316
+ * then emits this write. Same stateful-scoping rule that applies to
317
+ * channel switches and SET_PARAM writes (see HW-009 / Session 23).
318
+ *
319
+ * Decoded Session 27 from four session-23 captures: amp/drive/reverb
320
+ * bypass-on (float 1.0) and amp bypass-off (float 0.0). Byte-exact
321
+ * goldens in `verify-msg`.
322
+ */
323
+ const BLOCK_BYPASS_PID_HIGH = 0x0003;
324
+ /**
325
+ * Build a SET-BYPASS command that silences (`bypassed=true`) or activates
326
+ * (`bypassed=false`) the block whose pidLow is `blockPidLow` on the
327
+ * AM4's currently-active scene. `blockPidLow` is the block's own pidLow
328
+ * (see `BLOCK_TYPE_VALUES` in `blockTypes.ts`) — NOT a slot number.
329
+ * `0x0000` (the "none" value) is rejected since bypass has no meaning
330
+ * on an empty slot.
331
+ *
332
+ * Callers targeting a specific scene are responsible for issuing
333
+ * `buildSwitchScene(sceneIndex)` first; this function writes the block-
334
+ * level bypass register and inherits whichever scene the device is on.
335
+ */
336
+ export function buildSetBlockBypass(blockPidLow, bypassed) {
337
+ if (!Number.isInteger(blockPidLow) || blockPidLow <= 0 || blockPidLow > 0x3fff) {
338
+ throw new Error(`Block pidLow must be a positive 14-bit integer, got ${blockPidLow}.`);
339
+ }
340
+ return buildSetFloatParam({ pidLow: blockPidLow, pidHigh: BLOCK_BYPASS_PID_HIGH }, bypassed ? 1.0 : 0.0);
341
+ }
342
+ /**
343
+ * Build a SWITCH-PRESET command that loads preset location
344
+ * `locationIndex` (0..103, A01..Z04) into the AM4's working buffer.
345
+ * Same register family as the other preset-level commands
346
+ * (pidLow=0x00CE) with pidHigh=0x000A and a standard WRITE action.
347
+ *
348
+ * Value encoding: **float32** (IEEE 754 LE) representing the location
349
+ * index — e.g. index 1 → float 1.0 → raw bytes `00 00 80 3f`. This is
350
+ * DIFFERENT from scene-switch (u32 LE) and save-to-slot (u32 LE); both
351
+ * encodings coexist in the same register. Decoded Session 21 from
352
+ * `session-22-switch-preset-via-ui.pcapng`, which captured the user
353
+ * clicking A01 → A02 → A01 in AM4-Edit. Two unique writes: float 1.0
354
+ * (A02) and float 0.0 (A01). Byte-exact goldens in `verify-msg`.
355
+ *
356
+ * UX note: this is "load this preset into the working buffer", not
357
+ * "save to this location." Calling this on an unsaved working buffer
358
+ * discards edits — upstream MCP tool should confirm before issuing.
359
+ */
360
+ export function buildSwitchPreset(locationIndex) {
361
+ if (!Number.isInteger(locationIndex) || locationIndex < 0 || locationIndex > 103) {
362
+ throw new Error(`Preset location index must be integer 0..103, got ${locationIndex}.`);
363
+ }
364
+ return buildSetFloatParam({ pidLow: PRESET_SWITCH_PID_LOW, pidHigh: PRESET_SWITCH_PID_HIGH }, locationIndex);
365
+ }
366
+ /**
367
+ * Build a RENAME-SCENE command that sets the name of scene `sceneIndex`
368
+ * (0..3) in the current working buffer. Same envelope / action / payload
369
+ * structure as `buildSetPresetName`, with two differences:
370
+ * - pidHigh varies per scene: `0x0037 + sceneIndex` (scenes 1..4 →
371
+ * 0x0037 / 0x0038 / 0x0039 / 0x003A).
372
+ * - The 4-byte slot-index field at the head of the payload is zeroed
373
+ * — scene names are scoped to the working buffer, not a preset
374
+ * location.
375
+ *
376
+ * Decoded Session 21 from `session-20-rename-scene.pcapng` (scene 1)
377
+ * plus `session-22-rename-scene-{2,3,4}.pcapng` (scenes 2/3/4).
378
+ * Byte-exact goldens in `verify-msg` for scenes 2/3/4 with names
379
+ * "clean" / "chorus" / "lead"; scene 1 was the initial Session 19g
380
+ * capture confirming pidHigh=0x0037.
381
+ *
382
+ * Scope caveat: writes to the working buffer only. To persist scene
383
+ * names to a preset location, callers must still issue a
384
+ * `buildSaveToLocation` afterward.
385
+ */
386
+ export function buildSetSceneName(sceneIndex, name) {
387
+ if (!Number.isInteger(sceneIndex) || sceneIndex < 0 || sceneIndex > 3) {
388
+ throw new Error(`Scene index must be integer 0..3, got ${sceneIndex}.`);
389
+ }
390
+ if (name.length > SCENE_NAME_BYTES) {
391
+ throw new Error(`Scene name must be ≤ ${SCENE_NAME_BYTES} ASCII chars, got ${name.length}: "${name}".`);
392
+ }
393
+ for (let i = 0; i < name.length; i++) {
394
+ const c = name.charCodeAt(i);
395
+ if (c < 0x20 || c > 0x7e) {
396
+ throw new Error(`Scene name contains non-ASCII-printable char 0x${c.toString(16)} at position ${i}: "${name}".`);
397
+ }
398
+ }
399
+ const raw = new Uint8Array(4 + SCENE_NAME_BYTES);
400
+ // Bytes 0..3 stay zero (working-buffer scope, no slot index).
401
+ for (let i = 0; i < SCENE_NAME_BYTES; i++) {
402
+ raw[4 + i] = i < name.length ? name.charCodeAt(i) : 0x20;
403
+ }
404
+ const packed = Array.from(packValueChunked(raw));
405
+ const body = [
406
+ AM4_MODEL_ID,
407
+ FUNC_PARAM_RW,
408
+ ...encode14(SCENE_RENAME_PID_LOW),
409
+ ...encode14(SCENE_RENAME_PID_HIGH_BASE + sceneIndex),
410
+ ...encode14(ACTION_RENAME),
411
+ ...encode14(0x0000),
412
+ ...encode14(raw.length),
413
+ ...packed,
414
+ ];
415
+ const head = [SYSEX_START, ...FRACTAL_MFR, ...body];
416
+ return [...head, fractalChecksum(head), SYSEX_END];
417
+ }
418
+ /**
419
+ * Function byte for the request-active-buffer-dump command. Distinct from
420
+ * the 0x01 PARAM_RW family; the device replies with a 6-message
421
+ * 0x77 / 0x78 / 0x79 stream (see `presetDump.ts` for the response decoder).
422
+ */
423
+ const FUNC_REQUEST_DUMP = 0x03;
424
+ /**
425
+ * Active-buffer sentinel byte. Same convention as the 0x77 response header's
426
+ * bank field: `0x7F` means "the working buffer", not a stored bank/sub pair.
427
+ */
428
+ const ACTIVE_BUFFER_SENTINEL = 0x7f;
429
+ /**
430
+ * Build a REQUEST_ACTIVE_BUFFER_DUMP message that asks the AM4 to emit its
431
+ * current working buffer as a 6-message preset-dump stream
432
+ * (0x77 header + 4x 0x78 chunks + 0x79 footer, 12,352 bytes total).
433
+ *
434
+ * Wire shape (decoded HW-045 / Session 51 byte-exact from
435
+ * `samples/captured/session-51-export-preset.tshark.txt`; AM4-Edit's
436
+ * File -> Export Preset action against the working buffer):
437
+ *
438
+ * F0 00 01 74 15 03 7F 7F 00 [cs] F7 (11 bytes total)
439
+ *
440
+ * - **function** `0x03` (NEW — distinct from the 0x01 PARAM_RW family;
441
+ * shares wire space with the dump response stream's reply functions
442
+ * 0x77 / 0x78 / 0x79).
443
+ * - **payload** `7F 7F 00`: byte 0 = active-buffer sentinel, byte 1 = same
444
+ * sentinel (the response header carries `bank=0x7F sub=0x00` for an
445
+ * active-buffer dump; the request mirrors the bank sentinel into both
446
+ * addressing slots), byte 2 = constant `0x00`.
447
+ * - **checksum** XOR of all preceding bytes (computed via
448
+ * `fractalChecksum`, NOT hardcoded) — 0x13 in the captured frame.
449
+ *
450
+ * Non-destructive: no working-buffer mutation, no audible side effect, no
451
+ * change to the active stored location pointer. The device responds with
452
+ * the same byte stream `parsePresetBank` consumes, except the 0x77 header
453
+ * carries `bank=0x7F` (active sentinel) instead of a stored bank/sub pair.
454
+ *
455
+ * STORED-PRESET variant (request a specific stored location's dump
456
+ * without affecting the working buffer) is queued for v0.1.x; see
457
+ * `docs/preset-dump-request-research.md` and HW-045 for the follow-up
458
+ * capture needed to disambiguate the bank/sub encoding for stored
459
+ * locations.
460
+ */
461
+ export function buildRequestActiveBufferDump() {
462
+ const body = [
463
+ AM4_MODEL_ID,
464
+ FUNC_REQUEST_DUMP,
465
+ ACTIVE_BUFFER_SENTINEL,
466
+ ACTIVE_BUFFER_SENTINEL,
467
+ 0x00,
468
+ ];
469
+ const head = [SYSEX_START, ...FRACTAL_MFR, ...body];
470
+ return [...head, fractalChecksum(head), SYSEX_END];
471
+ }
472
+ /**
473
+ * Build a 0x01 READ request for `param`. `readType` selects the response
474
+ * shape — see docs/SYSEX-MAP.md §6a (use 0x0E for short parameter reads).
475
+ */
476
+ export function buildReadParam(param, readType = 0x0e) {
477
+ const body = [
478
+ AM4_MODEL_ID,
479
+ FUNC_PARAM_RW,
480
+ ...encode14(param.pidLow),
481
+ ...encode14(param.pidHigh),
482
+ ...encode14(readType),
483
+ ...encode14(0x0000),
484
+ ...encode14(0x0000), // hdr4 = 0 (no payload on a read)
485
+ ];
486
+ const head = [SYSEX_START, ...FRACTAL_MFR, ...body];
487
+ const cs = fractalChecksum(head);
488
+ return [...head, cs, SYSEX_END];
489
+ }
490
+ const READ_TYPE_SHORT = 0x0e;
491
+ const READ_RESPONSE_PAYLOAD_RAW_BYTES = 4;
492
+ /**
493
+ * Long-form param-descriptor read action. Empirically pinned HW-066
494
+ * (Session 48) from `samples/captured/session-46-front-panel-dly-rev-bypass.pcapng`:
495
+ * AM4-Edit polls bypass state with `action=0x0d`, getting a 64-byte
496
+ * response with `hdr4=0x0028` (40 raw payload bytes). The short read
497
+ * (`action=0x0e`, hdr4=4) returns a static value that doesn't track
498
+ * bypass writes; the long read tracks live state. Wire byte 22 of the
499
+ * long response is the bypass flag (1=bypassed, 0=active) — confirmed
500
+ * across 8 (block × scene) cases on hardware.
501
+ */
502
+ export const READ_TYPE_LONG = 0x0d;
503
+ const LONG_READ_RESPONSE_TOTAL_BYTES = 64;
504
+ const LONG_READ_RESPONSE_HDR4 = 0x0028;
505
+ /** Wire-byte offset of the bypass flag in a long-read response. */
506
+ export const LONG_READ_BYPASS_FLAG_BYTE = 22;
507
+ /**
508
+ * Denominator the AM4 uses to encode internal floats into the u32 read-
509
+ * response field. **Empirically pinned at 65534 (= 0xFFFE = 2¹⁶ - 2)
510
+ * by HW-044 (Session 42) + HW-046 (Session 43)** across 4 byte-exact
511
+ * data points:
512
+ *
513
+ * display | u32 | predicted u32 = round(internal × 65534)
514
+ * --------|-------|------------------------------------------
515
+ * 3.00 | 19660 | 19660 ✓
516
+ * 5.00 | 32767 | 32767 ✓
517
+ * 6.00 | 39320 | 39320 ✓ (mid + treble, two captures)
518
+ *
519
+ * /65536 was eliminated by bass=5.00 (predicted 32768, observed 32767).
520
+ * /65535 was eliminated by mid/treble=6.00 (predicted 39321, observed
521
+ * 39320). Only /65534 with round-to-nearest fits all four samples.
522
+ *
523
+ * Why 65534 rather than the cleaner 65535 or 65536 is unconfirmed but
524
+ * plausibly because the AM4 stores values internally in signed Q15
525
+ * fixed-point (range -32767..+32767, with -32768 reserved as a sentinel)
526
+ * and the read response shifts the magnitude left by 1 to fill a 16-bit
527
+ * unsigned span. We didn't reverse-engineer the firmware past the
528
+ * empirical match — the wire goldens in `verify-msg` are the ground truth.
529
+ */
530
+ export const READ_VALUE_DENOMINATOR = 65534;
531
+ /**
532
+ * Predicate for `receiveSysExMatching` that accepts the AM4's response
533
+ * to a 0x01 READ we just sent. Shape decoded HW-044 (Session 42) — see
534
+ * SYSEX-MAP.md §6a:
535
+ *
536
+ * F0 00 01 74 15 01 <pidLow septets> <pidHigh septets>
537
+ * <readType septets> 00 00 04 00 <5 packed bytes> <cs> F7
538
+ *
539
+ * The response is byte-identical to the outgoing request through the
540
+ * readType field, then `hdr4 = 0x0004` (4-byte payload follows) and 5
541
+ * packed-septet bytes encoding those 4 bytes via the same `packValue`
542
+ * scheme writes use. Distinct from `isWriteEcho` (hdr4 = 0x0028,
543
+ * 64-byte ack) and `isCommandAck` (hdr4 = 0x0000, 18-byte ack).
544
+ */
545
+ export function isReadResponse(read, response) {
546
+ if (response.length !== 23)
547
+ return false;
548
+ if (response[0] !== SYSEX_START || response[22] !== SYSEX_END)
549
+ return false;
550
+ // Envelope + function byte (bytes 0..5) must match the outgoing read.
551
+ for (let i = 0; i < 6; i++)
552
+ if (response[i] !== read[i])
553
+ return false;
554
+ // pidLow (6..7), pidHigh (8..9), readType (10..11) echo the outgoing read.
555
+ for (let i = 6; i < 12; i++)
556
+ if (response[i] !== read[i])
557
+ return false;
558
+ // hdr3 (12..13) zero, hdr4 (14..15) = 0x0004 (4-byte payload follows).
559
+ if (response[12] !== 0x00 || response[13] !== 0x00)
560
+ return false;
561
+ if (response[14] !== 0x04 || response[15] !== 0x00)
562
+ return false;
563
+ return true;
564
+ }
565
+ /**
566
+ * Predicate for the long-form (action=0x0d) READ response. Same envelope
567
+ * + echoed-fields shape as `isReadResponse`, but with `hdr4=0x0028` and
568
+ * a 64-byte total length. Used by `am4_get_block_bypass` to read the
569
+ * live bypass register (HW-066 / Session 48).
570
+ */
571
+ export function isReadResponseLong(read, response) {
572
+ if (response.length !== LONG_READ_RESPONSE_TOTAL_BYTES)
573
+ return false;
574
+ if (response[0] !== SYSEX_START || response[response.length - 1] !== SYSEX_END)
575
+ return false;
576
+ for (let i = 0; i < 6; i++)
577
+ if (response[i] !== read[i])
578
+ return false;
579
+ for (let i = 6; i < 12; i++)
580
+ if (response[i] !== read[i])
581
+ return false;
582
+ if (response[12] !== 0x00 || response[13] !== 0x00)
583
+ return false;
584
+ if (response[14] !== (LONG_READ_RESPONSE_HDR4 & 0x7f))
585
+ return false;
586
+ if (response[15] !== ((LONG_READ_RESPONSE_HDR4 >> 7) & 0x7f))
587
+ return false;
588
+ return true;
589
+ }
590
+ /**
591
+ * Extract the bypass flag from a long-form READ response (action=0x0d).
592
+ * Returns true if the block is currently bypassed, false if active.
593
+ *
594
+ * Validates the envelope, echoed fields, and checksum. Throws on any
595
+ * mismatch — pair with `isReadResponseLong` as the matcher predicate
596
+ * before calling.
597
+ */
598
+ export function parseLongReadBypassFlag(bytes) {
599
+ if (bytes.length !== LONG_READ_RESPONSE_TOTAL_BYTES) {
600
+ throw new Error(`Long read response must be ${LONG_READ_RESPONSE_TOTAL_BYTES} bytes, got ${bytes.length}.`);
601
+ }
602
+ if (bytes[0] !== SYSEX_START || bytes[LONG_READ_RESPONSE_TOTAL_BYTES - 1] !== SYSEX_END) {
603
+ throw new Error('Long read response missing F0/F7 envelope.');
604
+ }
605
+ for (let i = 0; i < FRACTAL_MFR.length; i++) {
606
+ if (bytes[1 + i] !== FRACTAL_MFR[i]) {
607
+ throw new Error('Long read response Fractal manufacturer ID mismatch.');
608
+ }
609
+ }
610
+ if (bytes[4] !== AM4_MODEL_ID) {
611
+ throw new Error(`Long read response device ID 0x${bytes[4].toString(16)} != AM4 (0x15).`);
612
+ }
613
+ if (bytes[5] !== FUNC_PARAM_RW) {
614
+ throw new Error(`Long read response function byte 0x${bytes[5].toString(16)} != 0x01.`);
615
+ }
616
+ const csIdx = LONG_READ_RESPONSE_TOTAL_BYTES - 2;
617
+ const expectedCs = fractalChecksum(bytes.slice(0, csIdx));
618
+ if (bytes[csIdx] !== expectedCs) {
619
+ throw new Error(`Long read response checksum mismatch: got 0x${bytes[csIdx].toString(16)}, expected 0x${expectedCs.toString(16)}.`);
620
+ }
621
+ return bytes[LONG_READ_BYPASS_FLAG_BYTE] === 0x01;
622
+ }
623
+ /**
624
+ * Parse a 0x01 READ response into its pidLow, pidHigh, and 4 raw payload
625
+ * bytes. Validates the envelope (F0 / mfr / device id / function / F7),
626
+ * checksum, action = readType (0x0E), and hdr4 = 0x0004. Throws on any
627
+ * mismatch — callers should check `isReadResponse` first when matching
628
+ * against a specific outgoing read, or feed validated bytes here.
629
+ *
630
+ * The 5-byte packed payload is unpacked via the same `unpackValue` scheme
631
+ * as writes. The resulting 4 raw bytes are returned for the caller to
632
+ * interpret per param type — see SYSEX-MAP.md §6a's "Decode rule" note.
633
+ *
634
+ * Decoded HW-044 (Session 42) from `samples/captured/session-42-readprobe.pcapng`.
635
+ */
636
+ export function parseReadResponse(bytes) {
637
+ if (bytes.length !== 23) {
638
+ throw new Error(`Read response must be 23 bytes, got ${bytes.length}.`);
639
+ }
640
+ if (bytes[0] !== SYSEX_START || bytes[22] !== SYSEX_END) {
641
+ throw new Error('Read response missing F0/F7 envelope.');
642
+ }
643
+ for (let i = 0; i < FRACTAL_MFR.length; i++) {
644
+ if (bytes[1 + i] !== FRACTAL_MFR[i]) {
645
+ throw new Error('Read response Fractal manufacturer ID mismatch.');
646
+ }
647
+ }
648
+ if (bytes[4] !== AM4_MODEL_ID) {
649
+ throw new Error(`Read response device ID 0x${bytes[4].toString(16)} != AM4 (0x15).`);
650
+ }
651
+ if (bytes[5] !== FUNC_PARAM_RW) {
652
+ throw new Error(`Read response function byte 0x${bytes[5].toString(16)} != 0x01.`);
653
+ }
654
+ const expectedCs = fractalChecksum(bytes.slice(0, 21));
655
+ if (bytes[21] !== expectedCs) {
656
+ throw new Error(`Read response checksum mismatch: got 0x${bytes[21].toString(16)}, expected 0x${expectedCs.toString(16)}.`);
657
+ }
658
+ const pidLow = bytes[6] | (bytes[7] << 7);
659
+ const pidHigh = bytes[8] | (bytes[9] << 7);
660
+ const action = bytes[10] | (bytes[11] << 7);
661
+ if (action !== READ_TYPE_SHORT) {
662
+ throw new Error(`Read response action 0x${action.toString(16).padStart(4, '0')} != 0x000E (short read).`);
663
+ }
664
+ const hdr4 = bytes[14] | (bytes[15] << 7);
665
+ if (hdr4 !== READ_RESPONSE_PAYLOAD_RAW_BYTES) {
666
+ throw new Error(`Read response hdr4 0x${hdr4.toString(16).padStart(4, '0')} != 0x0004.`);
667
+ }
668
+ const wire = new Uint8Array(bytes.slice(16, 21));
669
+ const rawValue = unpackValue(wire, READ_RESPONSE_PAYLOAD_RAW_BYTES);
670
+ return {
671
+ pidLow,
672
+ pidHigh,
673
+ rawValue,
674
+ asUInt32LE() {
675
+ return new DataView(rawValue.buffer, rawValue.byteOffset, 4).getUint32(0, true);
676
+ },
677
+ asInternalFloat() {
678
+ return this.asUInt32LE() / READ_VALUE_DENOMINATOR;
679
+ },
680
+ };
681
+ }
682
+ /**
683
+ * Sentinel string the AM4 returns in the 32-byte name buffer when a preset
684
+ * location is empty. C-string, NUL-terminated within the buffer (the
685
+ * trailing bytes after the NUL are uninitialised — typically 0x20 spaces
686
+ * with a final 0x00). `parseGetPresetNameResponse` cuts the buffer at the
687
+ * first NUL before comparing.
688
+ */
689
+ export const PRESET_NAME_EMPTY_SENTINEL = '<EMPTY>';
690
+ const READ_PRESET_NAME_RESPONSE_TOTAL_BYTES = 55;
691
+ const READ_PRESET_NAME_RESPONSE_HDR4 = PRESET_NAME_BYTES; // 32 raw payload bytes
692
+ // 32 raw = 4 full 7-byte chunks + 1 partial 4-byte chunk → 4*8 + 5 = 37 packed wire bytes.
693
+ const READ_PRESET_NAME_RESPONSE_PACKED_BYTES = 37;
694
+ /**
695
+ * Build a READ_PRESET_NAME request that reads the stored preset name at
696
+ * preset location `locationIndex` (0..103, A01..Z04) WITHOUT loading the
697
+ * preset into the working buffer.
698
+ *
699
+ * Wire shape (decoded HW-070 / Session 50 from
700
+ * `samples/captured/session-46-am4edit-launch-device-connected.midi-events.txt`):
701
+ *
702
+ * F0 00 01 74 15 01 [4E 01] [0B 00] [12 00] [00 00] [04 00]
703
+ * [5 packed bytes — u32 LE location index] <cs> F7
704
+ *
705
+ * - **function** `0x01` (PARAM_RW)
706
+ * - **pidLow** `0x00CE`, **pidHigh** `0x000B` — same register family as
707
+ * the rename WRITE (action `0x000C`); this is its read-direction sibling.
708
+ * - **action** `0x0012` — READ_PRESET_NAME (new with this decode).
709
+ * - **hdr4** `0x0004` — 4 raw payload bytes follow.
710
+ * - **payload** — uint32 LE location index (0..103), sliding-window packed
711
+ * to 5 wire septets via `packValue` (§6b).
712
+ *
713
+ * Total wire size: 23 bytes. Non-destructive: working-buffer state is
714
+ * preserved. AM4-Edit's "Refresh Preset Names" UI button issues this
715
+ * exact command in a 104-iteration loop (~350 ms for a full sweep).
716
+ *
717
+ * Byte-exact goldens for locations 0, 1, 103 in `verify-msg`.
718
+ */
719
+ export function buildGetPresetName(locationIndex) {
720
+ if (!Number.isInteger(locationIndex) || locationIndex < 0 || locationIndex > 103) {
721
+ throw new Error(`Preset location index must be integer 0..103, got ${locationIndex}.`);
722
+ }
723
+ const raw = new Uint8Array(4);
724
+ new DataView(raw.buffer).setUint32(0, locationIndex, true);
725
+ const packed = Array.from(packValue(raw));
726
+ const body = [
727
+ AM4_MODEL_ID,
728
+ FUNC_PARAM_RW,
729
+ ...encode14(RENAME_PID_LOW), // 0x00CE — shared with rename
730
+ ...encode14(RENAME_PRESET_PID_HIGH), // 0x000B — shared with rename
731
+ ...encode14(ACTION_READ_PRESET_NAME), // 0x0012 — read variant
732
+ ...encode14(0x0000), // hdr3 reserved
733
+ ...encode14(raw.length), // hdr4 = 4 (raw byte count)
734
+ ...packed,
735
+ ];
736
+ const head = [SYSEX_START, ...FRACTAL_MFR, ...body];
737
+ return [...head, fractalChecksum(head), SYSEX_END];
738
+ }
739
+ /**
740
+ * Parse a READ_PRESET_NAME response (action `0x0012`) into its name.
741
+ *
742
+ * Wire shape (56 bytes total):
743
+ *
744
+ * F0 00 01 74 15 01 [4E 01] [0B 00] [12 00] [00 00] [20 00]
745
+ * [37 packed bytes — 32 ASCII chars] <cs> F7
746
+ *
747
+ * The 32 raw bytes decode to a C-style ASCII name padded with 0x20
748
+ * (spaces) and terminated by 0x00 within the 32-byte buffer. Empty
749
+ * locations return the literal string `<EMPTY>` followed by 0x00 then
750
+ * uninitialised buffer bytes.
751
+ *
752
+ * Validates envelope (F0/F7), Fractal manufacturer ID, AM4 model byte
753
+ * (`0x15`), function byte (`0x01`), action (`0x0012`), hdr4 (`0x0020`),
754
+ * and the XOR checksum. Throws clear errors on any mismatch with the
755
+ * specific field that didn't match.
756
+ *
757
+ * Decoded HW-070 / Session 50 from
758
+ * `samples/captured/session-46-am4edit-launch-device-connected.midi-events.txt`.
759
+ * Byte-exact goldens for one populated location (A01 → "AM4 Gig Rig")
760
+ * and one empty location (X02 → `<EMPTY>`) in `verify-msg`.
761
+ */
762
+ export function parseGetPresetNameResponse(bytes, expectedLocation) {
763
+ const arr = Array.isArray(bytes) ? bytes : Array.from(bytes);
764
+ if (!Number.isInteger(expectedLocation) || expectedLocation < 0 || expectedLocation > 103) {
765
+ throw new Error(`Expected location must be integer 0..103, got ${expectedLocation}.`);
766
+ }
767
+ if (arr.length !== READ_PRESET_NAME_RESPONSE_TOTAL_BYTES) {
768
+ throw new Error(`READ_PRESET_NAME response must be ${READ_PRESET_NAME_RESPONSE_TOTAL_BYTES} bytes, got ${arr.length}.`);
769
+ }
770
+ if (arr[0] !== SYSEX_START || arr[arr.length - 1] !== SYSEX_END) {
771
+ throw new Error('READ_PRESET_NAME response missing F0/F7 envelope.');
772
+ }
773
+ for (let i = 0; i < FRACTAL_MFR.length; i++) {
774
+ if (arr[1 + i] !== FRACTAL_MFR[i]) {
775
+ throw new Error('READ_PRESET_NAME response Fractal manufacturer ID mismatch.');
776
+ }
777
+ }
778
+ if (arr[4] !== AM4_MODEL_ID) {
779
+ throw new Error(`READ_PRESET_NAME response device ID 0x${arr[4].toString(16)} != AM4 (0x15).`);
780
+ }
781
+ if (arr[5] !== FUNC_PARAM_RW) {
782
+ throw new Error(`READ_PRESET_NAME response function byte 0x${arr[5].toString(16)} != 0x01.`);
783
+ }
784
+ const pidLow = arr[6] | (arr[7] << 7);
785
+ const pidHigh = arr[8] | (arr[9] << 7);
786
+ const action = arr[10] | (arr[11] << 7);
787
+ if (pidLow !== RENAME_PID_LOW) {
788
+ throw new Error(`READ_PRESET_NAME response pidLow 0x${pidLow.toString(16).padStart(4, '0')} != 0x00CE.`);
789
+ }
790
+ if (pidHigh !== RENAME_PRESET_PID_HIGH) {
791
+ throw new Error(`READ_PRESET_NAME response pidHigh 0x${pidHigh.toString(16).padStart(4, '0')} != 0x000B.`);
792
+ }
793
+ if (action !== ACTION_READ_PRESET_NAME) {
794
+ throw new Error(`READ_PRESET_NAME response action 0x${action.toString(16).padStart(4, '0')} != 0x0012.`);
795
+ }
796
+ const hdr4 = arr[14] | (arr[15] << 7);
797
+ if (hdr4 !== READ_PRESET_NAME_RESPONSE_HDR4) {
798
+ throw new Error(`READ_PRESET_NAME response hdr4 0x${hdr4.toString(16).padStart(4, '0')} != 0x0020 (32 raw bytes).`);
799
+ }
800
+ const csIdx = arr.length - 2;
801
+ const expectedCs = fractalChecksum(arr.slice(0, csIdx));
802
+ if (arr[csIdx] !== expectedCs) {
803
+ throw new Error(`READ_PRESET_NAME response checksum mismatch: got 0x${arr[csIdx].toString(16)}, expected 0x${expectedCs.toString(16)}.`);
804
+ }
805
+ const packed = new Uint8Array(arr.slice(16, 16 + READ_PRESET_NAME_RESPONSE_PACKED_BYTES));
806
+ const raw = unpackValueChunked(packed, PRESET_NAME_BYTES);
807
+ // C-string semantics: cut at first NUL terminator inside the 32-byte
808
+ // buffer, then strip trailing 0x20 padding. Empirically the AM4 NUL-
809
+ // terminates within the 32-byte buffer; bytes after the NUL are
810
+ // uninitialised (often 0x20 with a trailing 0x00).
811
+ const ascii = String.fromCharCode(...Array.from(raw));
812
+ const nulIdx = ascii.indexOf('\0');
813
+ const name = (nulIdx >= 0 ? ascii.slice(0, nulIdx) : ascii).trimEnd();
814
+ return {
815
+ location: expectedLocation,
816
+ name,
817
+ isEmpty: name === PRESET_NAME_EMPTY_SENTINEL,
818
+ };
819
+ }