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