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.
- package/LICENSE +200 -0
- package/NOTICE +28 -0
- package/README.md +147 -0
- package/dist/am4/applicability.d.ts +61 -0
- package/dist/am4/applicability.d.ts.map +1 -0
- package/dist/am4/applicability.js +285 -0
- package/dist/am4/blockTypes.d.ts +43 -0
- package/dist/am4/blockTypes.d.ts.map +1 -0
- package/dist/am4/blockTypes.js +48 -0
- package/dist/am4/cacheEnums.d.ts +46 -0
- package/dist/am4/cacheEnums.d.ts.map +1 -0
- package/dist/am4/cacheEnums.js +734 -0
- package/dist/am4/cacheParams.d.ts +3533 -0
- package/dist/am4/cacheParams.d.ts.map +1 -0
- package/dist/am4/cacheParams.js +1996 -0
- package/dist/am4/editorControlLabels.d.ts +45 -0
- package/dist/am4/editorControlLabels.d.ts.map +1 -0
- package/dist/am4/editorControlLabels.js +15894 -0
- package/dist/am4/index.d.ts +28 -0
- package/dist/am4/index.d.ts.map +1 -0
- package/dist/am4/index.js +31 -0
- package/dist/am4/ir/preset.d.ts +24 -0
- package/dist/am4/ir/preset.d.ts.map +1 -0
- package/dist/am4/ir/preset.js +12 -0
- package/dist/am4/ir/transpile.d.ts +9 -0
- package/dist/am4/ir/transpile.d.ts.map +1 -0
- package/dist/am4/ir/transpile.js +19 -0
- package/dist/am4/locations.d.ts +32 -0
- package/dist/am4/locations.d.ts.map +1 -0
- package/dist/am4/locations.js +58 -0
- package/dist/am4/paramNames.d.ts +55 -0
- package/dist/am4/paramNames.d.ts.map +1 -0
- package/dist/am4/paramNames.js +863 -0
- package/dist/am4/paramNamesGenerated.d.ts +41 -0
- package/dist/am4/paramNamesGenerated.d.ts.map +1 -0
- package/dist/am4/paramNamesGenerated.js +183 -0
- package/dist/am4/parameterBridge.d.ts +46 -0
- package/dist/am4/parameterBridge.d.ts.map +1 -0
- package/dist/am4/parameterBridge.js +300 -0
- package/dist/am4/params.d.ts +9577 -0
- package/dist/am4/params.d.ts.map +1 -0
- package/dist/am4/params.js +4537 -0
- package/dist/am4/setParam.d.ts +414 -0
- package/dist/am4/setParam.d.ts.map +1 -0
- package/dist/am4/setParam.js +819 -0
- package/dist/am4/shared/paramHelpers.d.ts +55 -0
- package/dist/am4/shared/paramHelpers.d.ts.map +1 -0
- package/dist/am4/shared/paramHelpers.js +146 -0
- package/dist/am4/symbolicIds.d.ts +11 -0
- package/dist/am4/symbolicIds.d.ts.map +1 -0
- package/dist/am4/symbolicIds.js +587 -0
- package/dist/am4/typeApplicability.d.ts +39 -0
- package/dist/am4/typeApplicability.d.ts.map +1 -0
- package/dist/am4/typeApplicability.js +466 -0
- package/dist/am4/variantResolverTables.d.ts +51 -0
- package/dist/am4/variantResolverTables.d.ts.map +1 -0
- package/dist/am4/variantResolverTables.js +3128 -0
- package/dist/axe-fx-ii/blockTypes.d.ts +45 -0
- package/dist/axe-fx-ii/blockTypes.d.ts.map +1 -0
- package/dist/axe-fx-ii/blockTypes.js +116 -0
- package/dist/axe-fx-ii/index.d.ts +5 -0
- package/dist/axe-fx-ii/index.d.ts.map +1 -0
- package/dist/axe-fx-ii/index.js +18 -0
- package/dist/axe-fx-ii/paramAliases.d.ts +54 -0
- package/dist/axe-fx-ii/paramAliases.d.ts.map +1 -0
- package/dist/axe-fx-ii/paramAliases.js +146 -0
- package/dist/axe-fx-ii/params.d.ts +11502 -0
- package/dist/axe-fx-ii/params.d.ts.map +1 -0
- package/dist/axe-fx-ii/params.js +2847 -0
- package/dist/axe-fx-ii/setParam.d.ts +560 -0
- package/dist/axe-fx-ii/setParam.d.ts.map +1 -0
- package/dist/axe-fx-ii/setParam.js +888 -0
- package/dist/axe-fx-iii/blockTypes.d.ts +87 -0
- package/dist/axe-fx-iii/blockTypes.d.ts.map +1 -0
- package/dist/axe-fx-iii/blockTypes.js +156 -0
- package/dist/axe-fx-iii/enumOverlay.d.ts +73 -0
- package/dist/axe-fx-iii/enumOverlay.d.ts.map +1 -0
- package/dist/axe-fx-iii/enumOverlay.js +236 -0
- package/dist/axe-fx-iii/index.d.ts +9 -0
- package/dist/axe-fx-iii/index.d.ts.map +1 -0
- package/dist/axe-fx-iii/index.js +20 -0
- package/dist/axe-fx-iii/params.d.ts +179 -0
- package/dist/axe-fx-iii/params.d.ts.map +1 -0
- package/dist/axe-fx-iii/params.js +6913 -0
- package/dist/axe-fx-iii/setParam.d.ts +460 -0
- package/dist/axe-fx-iii/setParam.d.ts.map +1 -0
- package/dist/axe-fx-iii/setParam.js +910 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/shared/checksum.d.ts +10 -0
- package/dist/shared/checksum.d.ts.map +1 -0
- package/dist/shared/checksum.js +14 -0
- package/dist/shared/device.d.ts +195 -0
- package/dist/shared/device.d.ts.map +1 -0
- package/dist/shared/device.js +27 -0
- package/dist/shared/index.d.ts +8 -0
- package/dist/shared/index.d.ts.map +1 -0
- package/dist/shared/index.js +11 -0
- package/dist/shared/lineage/amp-lineage.json +8313 -0
- package/dist/shared/lineage/axefx2-amp-lineage.json +5871 -0
- package/dist/shared/lineage/axefx2-delay-lineage.json +226 -0
- package/dist/shared/lineage/axefx2-drive-lineage.json +575 -0
- package/dist/shared/lineage/axefx2-reverb-lineage.json +467 -0
- package/dist/shared/lineage/cab-lineage.json +10777 -0
- package/dist/shared/lineage/chorus-lineage.json +173 -0
- package/dist/shared/lineage/compressor-lineage.json +338 -0
- package/dist/shared/lineage/delay-lineage.json +313 -0
- package/dist/shared/lineage/drive-lineage.json +1844 -0
- package/dist/shared/lineage/flanger-lineage.json +313 -0
- package/dist/shared/lineage/phaser-lineage.json +208 -0
- package/dist/shared/lineage/reverb-lineage.json +793 -0
- package/dist/shared/lineage/wah-lineage.json +117 -0
- package/dist/shared/lineageLookup.d.ts +69 -0
- package/dist/shared/lineageLookup.d.ts.map +1 -0
- package/dist/shared/lineageLookup.js +196 -0
- package/dist/shared/packValue.d.ts +40 -0
- package/dist/shared/packValue.d.ts.map +1 -0
- package/dist/shared/packValue.js +105 -0
- package/dist/shared/types.d.ts +23 -0
- package/dist/shared/types.d.ts.map +1 -0
- package/dist/shared/types.js +9 -0
- 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
|
+
}
|