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