@m4l-jweb/surface 1.3.0 → 1.6.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/package.json +3 -2
- package/src/controls.ts +495 -0
- package/src/dev.tsx +108 -2
- package/src/index.ts +113 -0
- package/src/pads.ts +267 -0
- package/src/react.tsx +123 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m4l-jweb/surface",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "m4l-jweb: declare a device's Live parameters as code - the surface Push actually sees - plus a mocked-Live dev harness.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"exports": {
|
|
16
16
|
".": "./src/index.ts",
|
|
17
17
|
"./store": "./src/store.ts",
|
|
18
|
+
"./pads": "./src/pads.ts",
|
|
18
19
|
"./react": "./src/react.tsx",
|
|
19
20
|
"./dev": "./src/dev.tsx"
|
|
20
21
|
},
|
|
@@ -24,7 +25,7 @@
|
|
|
24
25
|
],
|
|
25
26
|
"peerDependencies": {
|
|
26
27
|
"react": ">=18",
|
|
27
|
-
"@m4l-jweb/bridge": "1.
|
|
28
|
+
"@m4l-jweb/bridge": "1.6.0"
|
|
28
29
|
},
|
|
29
30
|
"peerDependenciesMeta": {
|
|
30
31
|
"react": {
|
package/src/controls.ts
ADDED
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* controls.ts - declare what a device claims on the CONTROL SURFACE, once, as code.
|
|
3
|
+
*
|
|
4
|
+
* The fourth sibling of `defineSurface`, `defineWatch` and `defineFiles`, and the
|
|
5
|
+
* one that reaches the sixty-four pads. `defineSurface` already gives a device the
|
|
6
|
+
* eight Push encoders - declare a parameter and it is on the hardware, labelled,
|
|
7
|
+
* banked, automatable. It gives you nothing on the grid, and this closes that:
|
|
8
|
+
*
|
|
9
|
+
* grid.draw() a frame buffer you paint
|
|
10
|
+
* grid.onPad() an event stream you handle
|
|
11
|
+
*
|
|
12
|
+
* ------------------------------------------------------------------------------
|
|
13
|
+
* WHAT THE HARDWARE ACTUALLY DOES, because five of these contradict the obvious
|
|
14
|
+
* guess and every one of them fails silently. All measured on a Push 3 with the
|
|
15
|
+
* `push-probe` device; the evidence is doc/MAX-FACTS.md, "Grabbing a Push control".
|
|
16
|
+
*
|
|
17
|
+
* 1. A control is grabbed BY NAME. A bare LOM id is rejected; the two-atom
|
|
18
|
+
* `id <n>` works and buys nothing over the name. The id is wanted for exactly
|
|
19
|
+
* one thing - building the observer.
|
|
20
|
+
* 2. A REJECTED CALL REPORTS NOTHING. `LiveAPI.call` does not throw when Live
|
|
21
|
+
* refuses it: it posts a console line and returns normally. There is no success
|
|
22
|
+
* to branch on, so nothing here can verify its own grab. `controls_role` reports
|
|
23
|
+
* the one failure that IS visible - a role this hardware does not have.
|
|
24
|
+
* 3. Y COUNTS FROM THE TOP on the wire and from the BOTTOM in this API, and the
|
|
25
|
+
* flip lives in ONE place (`usePadGrid`). Get it wrong anywhere else and every
|
|
26
|
+
* device on the grid is mirrored, with nothing to report it.
|
|
27
|
+
* 4. THE FIRST FRAME AFTER A GRAB IS LOST. Live's own surface script repaints the
|
|
28
|
+
* matrix just after handing it over, so the wrapper defers its first paint and
|
|
29
|
+
* treats the hardware as unknown until then.
|
|
30
|
+
* 5. ROLES RESOLVE AT RUNTIME. A Push 3 answers `get_control_names` with 176 names
|
|
31
|
+
* and they are not the Push 2 set, so the role table below is CANDIDATES, tried
|
|
32
|
+
* in order against that answer. A candidate that is not on the connected
|
|
33
|
+
* hardware is never called - which is what turns rule 2's silence into a
|
|
34
|
+
* reportable `controls_role <key> 0`.
|
|
35
|
+
* 6. THE ENCODERS ARE GRABBABLE, and grabbing one takes it: with `Track_Controls`
|
|
36
|
+
* held, the dials stop moving their parameters entirely. That costs automation,
|
|
37
|
+
* MIDI mapping and the automation lane - everything the parameter path exists
|
|
38
|
+
* for - so those roles are REFUSED here, at declaration time.
|
|
39
|
+
* 7. CLAIMING THE MATRIX TAKES THE PADS OFF THE NOTE PATH. No MIDI notes and no
|
|
40
|
+
* MPE while `Button_Matrix` is held, even on a device that declares `mpe`.
|
|
41
|
+
* Claiming a non-matrix control (the scene column, the jog wheel) costs nothing
|
|
42
|
+
* on the note path, which is why a mixed declaration is allowed rather than
|
|
43
|
+
* refused.
|
|
44
|
+
* 8. A CONTROL IS NOT ALWAYS A GRID OR A BUTTON. A jog wheel is a STREAM, so there
|
|
45
|
+
* is a third shape here rather than a fourth one bolted on later.
|
|
46
|
+
* ------------------------------------------------------------------------------
|
|
47
|
+
*
|
|
48
|
+
* Like its three siblings the checks run HERE, at call time, and throw - the build
|
|
49
|
+
* imports this declaration to emit the chain and the wrapper's spec, so a bad
|
|
50
|
+
* declaration fails `pnpm build` and CI rather than the hardware going quiet.
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
/* ------------------------------------------------------------------ *
|
|
54
|
+
* The roles, and the names they resolve to
|
|
55
|
+
* ------------------------------------------------------------------ */
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The role vocabulary. A device names a ROLE, never a Max name: the name is a
|
|
59
|
+
* per-generation fact the library owns, and CLAUDE.md's first hard rule is that a
|
|
60
|
+
* name Max looks up and does not recognise is not an error - it is a feature that
|
|
61
|
+
* silently does nothing.
|
|
62
|
+
*/
|
|
63
|
+
export type ControlRole =
|
|
64
|
+
// Grids
|
|
65
|
+
| "matrix"
|
|
66
|
+
| "scene_launch"
|
|
67
|
+
| "track_state"
|
|
68
|
+
// Buttons
|
|
69
|
+
| "shift"
|
|
70
|
+
| "select"
|
|
71
|
+
| "delete"
|
|
72
|
+
| "duplicate"
|
|
73
|
+
| "new"
|
|
74
|
+
| "undo"
|
|
75
|
+
| "capture"
|
|
76
|
+
| "record"
|
|
77
|
+
| "play"
|
|
78
|
+
| "loop"
|
|
79
|
+
| "left"
|
|
80
|
+
| "right"
|
|
81
|
+
| "up"
|
|
82
|
+
| "down"
|
|
83
|
+
| "octave_up"
|
|
84
|
+
| "octave_down"
|
|
85
|
+
| "layout"
|
|
86
|
+
| "note_mode"
|
|
87
|
+
| "session_mode"
|
|
88
|
+
// Streams
|
|
89
|
+
| "jogwheel"
|
|
90
|
+
| "touch_strip";
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Role -> the control names to TRY, in order, on whatever is plugged in.
|
|
94
|
+
*
|
|
95
|
+
* Two sources, and neither is complete: the 176 names a Push 3 returned from
|
|
96
|
+
* `get_control_names`, and the names a shipping third-party takeover device
|
|
97
|
+
* addresses (a Push 2-era set - several of them do not exist on a Push 3, which has
|
|
98
|
+
* `Left_Arrow`/`Right_Arrow` and `Layout` where the older set has `Left_Button`,
|
|
99
|
+
* `Right_Button` and `Layout_Button`). Both are in doc/MAX-FACTS.md.
|
|
100
|
+
*
|
|
101
|
+
* A candidate is only ever used if it appears in the connected hardware's own
|
|
102
|
+
* `get_control_names` answer, so a wrong guess here costs a `controls_role <key> 0`
|
|
103
|
+
* and not a silent no-op. That is the whole reason resolution is a runtime list
|
|
104
|
+
* rather than a compile-time name.
|
|
105
|
+
*/
|
|
106
|
+
export const ROLE_NAMES: Record<ControlRole, readonly string[]> = {
|
|
107
|
+
matrix: ["Button_Matrix"],
|
|
108
|
+
scene_launch: ["Scene_Launch_Buttons"],
|
|
109
|
+
track_state: ["Track_State_Buttons"],
|
|
110
|
+
shift: ["Shift_Button"],
|
|
111
|
+
select: ["Select_Button"],
|
|
112
|
+
delete: ["Delete_Button"],
|
|
113
|
+
duplicate: ["Duplicate_Button"],
|
|
114
|
+
new: ["New_Button"],
|
|
115
|
+
undo: ["Undo_Button"],
|
|
116
|
+
capture: ["Capture_Button"],
|
|
117
|
+
record: ["Record_Button"],
|
|
118
|
+
play: ["Play_Button"],
|
|
119
|
+
loop: ["Loop_Button"],
|
|
120
|
+
left: ["Left_Arrow", "Left_Button"],
|
|
121
|
+
right: ["Right_Arrow", "Right_Button"],
|
|
122
|
+
up: ["Up_Arrow", "Up_Button"],
|
|
123
|
+
down: ["Down_Arrow", "Down_Button"],
|
|
124
|
+
octave_up: ["Octave_Up_Button"],
|
|
125
|
+
octave_down: ["Octave_Down_Button"],
|
|
126
|
+
layout: ["Layout", "Layout_Button"],
|
|
127
|
+
note_mode: ["Note_Mode_Button"],
|
|
128
|
+
session_mode: ["Session_Mode_Button"],
|
|
129
|
+
jogwheel: ["Jogwheel"],
|
|
130
|
+
touch_strip: ["Touch_Strip_Control"],
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The roles a declaration may NOT claim, and why - the message is what the author
|
|
135
|
+
* reads, so it says what grabbing one costs rather than "not allowed".
|
|
136
|
+
*
|
|
137
|
+
* They are perfectly grabbable. That is the problem: measured, with two dials
|
|
138
|
+
* declared so Push had something to map, the encoders moved their parameters
|
|
139
|
+
* normally and stopped dead the moment `Track_Controls` was grabbed.
|
|
140
|
+
*/
|
|
141
|
+
export const REFUSED_ROLES: Record<string, string> = {
|
|
142
|
+
Track_Controls:
|
|
143
|
+
"the eight encoders are grabbable, and grabbing them TAKES them: the dials stop moving their parameters, " +
|
|
144
|
+
"which costs automation, MIDI mapping and the automation lane. Declare the values as parameters in surface.ts instead.",
|
|
145
|
+
Global_Param_Controls: "same as Track_Controls - an encoder belongs to the parameter path, not to a takeover.",
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/** Which roles are grids, and the dimensions each one is. */
|
|
149
|
+
const GRID_SHAPE: Partial<Record<ControlRole, { rows: number; cols: number }>> = {
|
|
150
|
+
// The only one measured: 8x8, y from the top, one control and one id - not 64.
|
|
151
|
+
matrix: { rows: 8, cols: 8 },
|
|
152
|
+
// UNMEASURED SHAPES. Both resolve on a Push 3 and both are grabbable (the scene
|
|
153
|
+
// column was grabbed and released during the spike, and the MPE stream survived
|
|
154
|
+
// it), but what their `value` carries and what `send_value` wants have not been
|
|
155
|
+
// read off the hardware. Declaring one is allowed and reports its resolution;
|
|
156
|
+
// trusting its payload without looking is not.
|
|
157
|
+
scene_launch: { rows: 8, cols: 1 },
|
|
158
|
+
track_state: { rows: 1, cols: 8 },
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
/** The roles that are a single button. */
|
|
162
|
+
const BUTTON_ROLES: ControlRole[] = [
|
|
163
|
+
"shift",
|
|
164
|
+
"select",
|
|
165
|
+
"delete",
|
|
166
|
+
"duplicate",
|
|
167
|
+
"new",
|
|
168
|
+
"undo",
|
|
169
|
+
"capture",
|
|
170
|
+
"record",
|
|
171
|
+
"play",
|
|
172
|
+
"loop",
|
|
173
|
+
"left",
|
|
174
|
+
"right",
|
|
175
|
+
"up",
|
|
176
|
+
"down",
|
|
177
|
+
"octave_up",
|
|
178
|
+
"octave_down",
|
|
179
|
+
"layout",
|
|
180
|
+
"note_mode",
|
|
181
|
+
"session_mode",
|
|
182
|
+
];
|
|
183
|
+
|
|
184
|
+
/** The roles that are a continuous stream rather than a gate. */
|
|
185
|
+
const STREAM_ROLES: ControlRole[] = ["jogwheel", "touch_strip"];
|
|
186
|
+
|
|
187
|
+
/* ------------------------------------------------------------------ *
|
|
188
|
+
* The control kinds
|
|
189
|
+
* ------------------------------------------------------------------ */
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* What every control kind carries.
|
|
193
|
+
*
|
|
194
|
+
* `names` is FILLED IN BY `defineControls()`, from ROLE_NAMES above - it is not
|
|
195
|
+
* something a device writes. The build reads the declaration as plain data (it
|
|
196
|
+
* cannot call back into this module: `packages/build` is .mjs and this is the
|
|
197
|
+
* bundled TypeScript it imported), so the candidate list has to travel ON the
|
|
198
|
+
* declaration rather than be looked up again on the other side. Two lookups of one
|
|
199
|
+
* table is exactly how a role table drifts.
|
|
200
|
+
*/
|
|
201
|
+
export interface ControlBase {
|
|
202
|
+
role: ControlRole;
|
|
203
|
+
/** The control names to try, in order. Written by defineControls; do not set it. */
|
|
204
|
+
names?: readonly string[];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** A rectangle of pads you paint and read. */
|
|
208
|
+
export interface GridControlSpec extends ControlBase {
|
|
209
|
+
kind: "grid";
|
|
210
|
+
rows: number;
|
|
211
|
+
cols: number;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** One button: a boolean, live. The degenerate case of a grid. */
|
|
215
|
+
export interface ButtonControlSpec extends ControlBase {
|
|
216
|
+
kind: "button";
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* A continuous control - the jog wheel, the touch strip.
|
|
221
|
+
*
|
|
222
|
+
* NOTHING HERE IS MEASURED YET. Whether either emits a usable stream under a grab,
|
|
223
|
+
* and whether the jog wheel reports a DELTA or an absolute position, is doc/TODO.md
|
|
224
|
+
* doc/TODO.md - one button press in `push-probe`, and it gates the DJ surface in
|
|
225
|
+
* PUSH-USECASES.md. The shape exists so that answering the question does not force
|
|
226
|
+
* an API change; it does not claim the answer.
|
|
227
|
+
*/
|
|
228
|
+
export interface StreamControlSpec extends ControlBase {
|
|
229
|
+
kind: "stream";
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export type ControlSpec = GridControlSpec | ButtonControlSpec | StreamControlSpec;
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* A rectangle of pads. `rows` and `cols` are stated by the caller and checked
|
|
236
|
+
* against the role, rather than defaulted from it: a device that writes
|
|
237
|
+
* `rows: 8, cols: 8` is a device whose layout code you can read without knowing the
|
|
238
|
+
* role table.
|
|
239
|
+
*/
|
|
240
|
+
export const grid = (spec: { role: ControlRole; rows: number; cols: number }): GridControlSpec => ({ kind: "grid", ...spec });
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* One button.
|
|
244
|
+
*
|
|
245
|
+
* NOT `button()` - that name already belongs to `defineSurface`'s `live.text`
|
|
246
|
+
* parameter, and a device declaring both would import two different things called
|
|
247
|
+
* the same word. PUSH-USECASES.md writes `button({ role: "shift" })`; this is that,
|
|
248
|
+
* renamed for the collision.
|
|
249
|
+
*/
|
|
250
|
+
export const padButton = (spec: { role: ControlRole }): ButtonControlSpec => ({ kind: "button", ...spec });
|
|
251
|
+
|
|
252
|
+
/** One continuous control. See StreamControlSpec - the hardware question is open. */
|
|
253
|
+
export const padStream = (spec: { role: ControlRole }): StreamControlSpec => ({ kind: "stream", ...spec });
|
|
254
|
+
|
|
255
|
+
/* ------------------------------------------------------------------ *
|
|
256
|
+
* The palette
|
|
257
|
+
* ------------------------------------------------------------------ */
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The 128 pad colours, as Live itself holds them.
|
|
261
|
+
*
|
|
262
|
+
* READ OUT OF LIVE, not off a photograph. Live 12 drives Push from
|
|
263
|
+
* `Program/Push/python/Push2/colors.pyc`, whose `COLOR_TABLE` is 128 entries and whose
|
|
264
|
+
* `push_color_index_to_pad_rgb(i)` returns `COLOR_TABLE[i]` split into bytes. These are
|
|
265
|
+
* those values: `PUSH_PAD_RGB[i]` is `0xRRGGBB` for palette index `i`, so what a device
|
|
266
|
+
* draws on screen and what the pad shows come from one number.
|
|
267
|
+
*
|
|
268
|
+
* It agrees with the hardware. Painting a known pattern on a Push 3 and photographing it
|
|
269
|
+
* matched this table across the greys, the pastels and the primaries, and it explains the
|
|
270
|
+
* two errors the photographs had made - see doc/MAX-FACTS.md, "Live holds the pad palette,
|
|
271
|
+
* and the photographs were a page upside down".
|
|
272
|
+
*
|
|
273
|
+
* The greys and the pure primaries are at the END: 118 grey, 119 near-black, 120 white,
|
|
274
|
+
* 122 light grey, 123 dark grey, 125 blue, 126 green, 127 red. Indices 65 to 117 are the
|
|
275
|
+
* dim and shaded variants, which is why so much of that range photographs as mud.
|
|
276
|
+
*/
|
|
277
|
+
export const PUSH_PAD_RGB: readonly number[] = [
|
|
278
|
+
0x000000, 0xff4032, 0x800400, 0xc93c00, 0xac1f00, 0x8c5018, 0x491804, 0xfadc3b, // 0-7
|
|
279
|
+
0xffc516, 0xb6ff0e, 0x79ff18, 0x34c216, 0x4f8a04, 0x62ff55, 0x297d53, 0x269e72, // 8-15
|
|
280
|
+
0x31adff, 0x3663fc, 0x1a34ff, 0x1c0ce6, 0x153999, 0x3937ff, 0x5722ff, 0x972bff, // 16-23
|
|
281
|
+
0x852178, 0xff1032, 0xff2bd4, 0xa63421, 0x995628, 0x876700, 0x90821f, 0x4a8700, // 24-31
|
|
282
|
+
0x007f12, 0x1853b2, 0x624bad, 0x733a67, 0xf8bcaf, 0xff9b76, 0xffbf5f, 0xd9af71, // 32-39
|
|
283
|
+
0xfff480, 0xbfba69, 0xbccc88, 0xaeff99, 0x7cdd9f, 0x89b47d, 0x80f3ff, 0x7acefc, // 40-47
|
|
284
|
+
0x68a1d3, 0x858fc2, 0xbbaaf2, 0xcdbbe4, 0xef8bb0, 0x859d8c, 0x6b756e, 0x84909b, // 48-55
|
|
285
|
+
0x6a7075, 0x88859d, 0x6c6a75, 0x9d859c, 0x746a74, 0x9c9d85, 0x74756a, 0x9d8484, // 56-63
|
|
286
|
+
0x756a6a, 0x661914, 0x210806, 0x460300, 0x280000, 0x5d1700, 0x200d00, 0x470c00, // 64-71
|
|
287
|
+
0x1c0800, 0x3b2b14, 0x1c130a, 0x250e05, 0x0d0602, 0x645817, 0x201c07, 0x664e08, // 72-79
|
|
288
|
+
0x211902, 0x486605, 0x172101, 0x306609, 0x0f2103, 0x144d08, 0x061902, 0x1f3701, // 80-87
|
|
289
|
+
0x0a1100, 0x276622, 0x0c210b, 0x143e29, 0x081910, 0x004d36, 0x00180e, 0x134566, // 88-95
|
|
290
|
+
0x061621, 0x152764, 0x070c20, 0x0a1466, 0x030621, 0x0b045c, 0x03011d, 0x0a1c4c, // 96-103
|
|
291
|
+
0x040b1e, 0x161666, 0x070721, 0x220d66, 0x0b0421, 0x3c1166, 0x130521, 0x350d30, // 104-111
|
|
292
|
+
0x11040f, 0x660614, 0x210206, 0x661154, 0x21051b, 0x000000, 0x595959, 0x1a1a1a, // 112-119
|
|
293
|
+
0xffffff, 0x595959, 0xcccccc, 0x404040, 0x141414, 0x0000ff, 0x00ff00, 0xff0000, // 120-127
|
|
294
|
+
];
|
|
295
|
+
|
|
296
|
+
/** `0xRRGGBB` as a CSS colour, so a screen can mirror a pad exactly. */
|
|
297
|
+
export function rgbCss(value: number): string {
|
|
298
|
+
return `rgb(${(value >> 16) & 255}, ${(value >> 8) & 255}, ${value & 255})`;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Colour NAMES, resolved to hardware palette indices by the library.
|
|
303
|
+
*
|
|
304
|
+
* A device that writes `36` is a device nobody can read. Every index below was chosen
|
|
305
|
+
* because `PUSH_PAD_RGB` says it IS that colour, so the name and the light agree.
|
|
306
|
+
*
|
|
307
|
+
* These are a legible subset, not the whole palette. A device that wants an index the
|
|
308
|
+
* names do not cover reads `PUSH_PAD_RGB` and picks one.
|
|
309
|
+
*/
|
|
310
|
+
export const PUSH_PALETTE = {
|
|
311
|
+
black: 0,
|
|
312
|
+
/** 0xff0000. The pure primaries live at the top of the table. */
|
|
313
|
+
red: 127,
|
|
314
|
+
orange: 3,
|
|
315
|
+
amber: 8,
|
|
316
|
+
yellow: 7,
|
|
317
|
+
lime: 9,
|
|
318
|
+
green: 11,
|
|
319
|
+
spring: 13,
|
|
320
|
+
turquoise: 15,
|
|
321
|
+
mint: 44,
|
|
322
|
+
sky: 16,
|
|
323
|
+
cyan: 46,
|
|
324
|
+
ocean: 33,
|
|
325
|
+
blue: 18,
|
|
326
|
+
violet: 22,
|
|
327
|
+
purple: 23,
|
|
328
|
+
magenta: 26,
|
|
329
|
+
rose: 25,
|
|
330
|
+
pink: 52,
|
|
331
|
+
peach: 36,
|
|
332
|
+
tan: 39,
|
|
333
|
+
gold: 38,
|
|
334
|
+
pale_blue: 47,
|
|
335
|
+
pale_pink: 51,
|
|
336
|
+
/** The greys, which the photographs never found. */
|
|
337
|
+
grey: 118,
|
|
338
|
+
dark_grey: 123,
|
|
339
|
+
light_grey: 122,
|
|
340
|
+
white: 120,
|
|
341
|
+
} as const;
|
|
342
|
+
|
|
343
|
+
/** A colour a device may name. */
|
|
344
|
+
export type PadColour = keyof typeof PUSH_PALETTE;
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* What each name looks like on a screen mirroring the hardware.
|
|
348
|
+
*
|
|
349
|
+
* DERIVED, never typed by hand: one table, so a name cannot drift from its light.
|
|
350
|
+
*/
|
|
351
|
+
export const PALETTE_CSS: Record<PadColour, string> = Object.fromEntries(
|
|
352
|
+
(Object.keys(PUSH_PALETTE) as PadColour[]).map((name) => [name, rgbCss(PUSH_PAD_RGB[PUSH_PALETTE[name]]!)]),
|
|
353
|
+
) as Record<PadColour, string>;
|
|
354
|
+
|
|
355
|
+
/** The index the hardware wants for a named colour. An unknown name is OFF, not a guess. */
|
|
356
|
+
export function paletteIndex(colour: string): number {
|
|
357
|
+
const i = (PUSH_PALETTE as Record<string, number>)[colour];
|
|
358
|
+
return typeof i === "number" ? i : 0;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** ...and back, for a screen mirroring the hardware. Any of the 128, named or not. */
|
|
362
|
+
export function cssForPaletteIndex(index: number): string {
|
|
363
|
+
const rgb = PUSH_PAD_RGB[index];
|
|
364
|
+
return rgb === undefined ? rgbCss(0) : rgbCss(rgb);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
/* ------------------------------------------------------------------ *
|
|
369
|
+
* The declaration
|
|
370
|
+
* ------------------------------------------------------------------ */
|
|
371
|
+
|
|
372
|
+
/** When an enabled device actually HOLDS the controls it declared. */
|
|
373
|
+
export type FocusPolicy = "Device" | "Track" | "Always";
|
|
374
|
+
|
|
375
|
+
export const FOCUS_OPTIONS: readonly FocusPolicy[] = ["Device", "Track", "Always"];
|
|
376
|
+
|
|
377
|
+
export interface ControlsDef<C extends Record<string, ControlSpec>> {
|
|
378
|
+
/** Which hardware family the roles are resolved against. */
|
|
379
|
+
surface: "push";
|
|
380
|
+
controls: C;
|
|
381
|
+
/**
|
|
382
|
+
* What `focus` starts at. `Track` is the default because two of these devices in
|
|
383
|
+
* one set is the normal case, not the edge: `Always` means the last one loaded
|
|
384
|
+
* wins the grid forever, which reads as the first one being broken.
|
|
385
|
+
*/
|
|
386
|
+
defaultFocus?: FocusPolicy;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export interface Controls<C extends Record<string, ControlSpec> = Record<string, ControlSpec>> extends ControlsDef<C> {
|
|
390
|
+
/**
|
|
391
|
+
* Declaration order - the order the build emits CONTROLS_SPEC and the chain wires
|
|
392
|
+
* observers.
|
|
393
|
+
*
|
|
394
|
+
* Deliberately `string[]` and not `Extract<keyof C, string>[]`. Naming the keys
|
|
395
|
+
* here would make `Controls<C>` INVARIANT in C, and a `Controls<{ pads: ... }>`
|
|
396
|
+
* would then not be assignable to the `Controls` a surface declares - which reads,
|
|
397
|
+
* at the call site, as "property 'pads' is missing in Record<string, ControlSpec>".
|
|
398
|
+
* The precise key union is where it is useful anyway: on `usePadGrid`, which takes
|
|
399
|
+
* it from `keyof C` directly.
|
|
400
|
+
*/
|
|
401
|
+
readonly keys: readonly string[];
|
|
402
|
+
readonly defaultFocus: FocusPolicy;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* The parameter ids `defineControls` contributes to a device's Surface.
|
|
407
|
+
*
|
|
408
|
+
* They are REAL Live parameters, so the user can switch takeover off, automate it,
|
|
409
|
+
* put it on an encoder and see it in the device view. `takeover` defaults OFF: a
|
|
410
|
+
* device that seizes the pads of every set it lands in is a device people uninstall.
|
|
411
|
+
*/
|
|
412
|
+
export const TAKEOVER_PARAM = "takeover";
|
|
413
|
+
export const FOCUS_PARAM = "focus";
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Declare what this device claims on the control surface.
|
|
417
|
+
*
|
|
418
|
+
* ```ts
|
|
419
|
+
* export default defineControls({
|
|
420
|
+
* surface: "push",
|
|
421
|
+
* controls: { pads: grid({ role: "matrix", rows: 8, cols: 8 }) },
|
|
422
|
+
* });
|
|
423
|
+
* ```
|
|
424
|
+
*/
|
|
425
|
+
export function defineControls<const C extends Record<string, ControlSpec>>(def: ControlsDef<C>): Controls<C> {
|
|
426
|
+
if (def.surface !== "push") {
|
|
427
|
+
throw new Error(`controls: surface "${def.surface}" is not a family this library resolves roles for (push)`);
|
|
428
|
+
}
|
|
429
|
+
const keys = Object.keys(def.controls) as Extract<keyof C, string>[];
|
|
430
|
+
if (!keys.length) throw new Error(`controls: declare at least one control - a device that claims nothing should not declare controls at all`);
|
|
431
|
+
|
|
432
|
+
const claimed = new Map<ControlRole, string>();
|
|
433
|
+
for (const key of keys) {
|
|
434
|
+
// The key becomes the selector `pad_<key>`, and Max splits a message on
|
|
435
|
+
// whitespace: a key with a space in it would arrive as two atoms and dispatch to
|
|
436
|
+
// a handler nobody wrote.
|
|
437
|
+
if (/\s/.test(key)) throw new Error(`controls: key "${key}" has whitespace - it becomes the selector pad_${key}, which Max would split`);
|
|
438
|
+
const spec = def.controls[key];
|
|
439
|
+
const role = spec.role as ControlRole;
|
|
440
|
+
|
|
441
|
+
if (!(role in ROLE_NAMES)) {
|
|
442
|
+
throw new Error(`controls: "${key}" claims role "${role}", which is not in the vocabulary (${Object.keys(ROLE_NAMES).join(", ")})`);
|
|
443
|
+
}
|
|
444
|
+
for (const name of ROLE_NAMES[role]) {
|
|
445
|
+
if (REFUSED_ROLES[name]) throw new Error(`controls: "${key}" claims "${role}", which this library refuses - ${REFUSED_ROLES[name]}`);
|
|
446
|
+
}
|
|
447
|
+
const already = claimed.get(role);
|
|
448
|
+
if (already) throw new Error(`controls: "${key}" and "${already}" both claim role "${role}" - one control cannot be grabbed twice`);
|
|
449
|
+
claimed.set(role, key);
|
|
450
|
+
|
|
451
|
+
if (spec.kind === "grid") {
|
|
452
|
+
const shape = GRID_SHAPE[role];
|
|
453
|
+
if (!shape) {
|
|
454
|
+
throw new Error(`controls: "${key}" declares role "${role}" as a grid, but that role is not a grid (it is a ${describeRole(role)})`);
|
|
455
|
+
}
|
|
456
|
+
if (spec.rows !== shape.rows || spec.cols !== shape.cols) {
|
|
457
|
+
throw new Error(
|
|
458
|
+
`controls: "${key}" declares role "${role}" as ${spec.rows}x${spec.cols}; on this hardware it is ${shape.rows}x${shape.cols}`,
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
if (spec.kind === "button" && !BUTTON_ROLES.includes(role)) {
|
|
463
|
+
throw new Error(`controls: "${key}" declares role "${role}" as a button, but that role is a ${describeRole(role)}`);
|
|
464
|
+
}
|
|
465
|
+
if (spec.kind === "stream" && !STREAM_ROLES.includes(role)) {
|
|
466
|
+
throw new Error(`controls: "${key}" declares role "${role}" as a stream, but that role is a ${describeRole(role)}`);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Stamp the candidate names onto each control, so the build reads one table
|
|
471
|
+
// rather than reimplementing it. See ControlBase.
|
|
472
|
+
const resolved = {} as C;
|
|
473
|
+
for (const key of keys) {
|
|
474
|
+
const spec = def.controls[key];
|
|
475
|
+
(resolved as Record<string, ControlSpec>)[key] = { ...spec, names: ROLE_NAMES[spec.role as ControlRole] };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
return { ...def, controls: resolved, keys, defaultFocus: def.defaultFocus ?? "Track" };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function describeRole(role: ControlRole): string {
|
|
482
|
+
if (GRID_SHAPE[role]) return "grid";
|
|
483
|
+
if (STREAM_ROLES.includes(role)) return "stream";
|
|
484
|
+
return "button";
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** How many cells one declared control carries. A button and a stream are one. */
|
|
488
|
+
export function controlSize(spec: ControlSpec): number {
|
|
489
|
+
return spec.kind === "grid" ? spec.rows * spec.cols : 1;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** The candidate names one declared control resolves against, in order. */
|
|
493
|
+
export function controlNames(spec: ControlSpec): readonly string[] {
|
|
494
|
+
return ROLE_NAMES[spec.role as ControlRole] ?? [];
|
|
495
|
+
}
|
package/src/dev.tsx
CHANGED
|
@@ -26,8 +26,8 @@
|
|
|
26
26
|
* tests/bundle.test.mjs asserts HARNESS_MARKER is absent from the built ui.html.
|
|
27
27
|
*/
|
|
28
28
|
import { useEffect, useRef, useState } from "react";
|
|
29
|
-
import { simulate, tapMessages, type BridgeMessage } from "@m4l-jweb/bridge";
|
|
30
|
-
import { BANK_SIZE, formatValue, type ParamSpec, type Surface } from "./index";
|
|
29
|
+
import { CONTROLS_IN, CONTROLS_OUT, padSelector, simulate, tapMessages, type BridgeMessage } from "@m4l-jweb/bridge";
|
|
30
|
+
import { BANK_SIZE, cssForPaletteIndex, formatValue, TAKEOVER_PARAM, type ControlSpec, type Controls, type ParamSpec, type Surface } from "./index";
|
|
31
31
|
import { useSurface } from "./react";
|
|
32
32
|
|
|
33
33
|
/**
|
|
@@ -113,6 +113,8 @@ export function DevHarness({ surface }: { surface?: AnySurface | null }) {
|
|
|
113
113
|
</>
|
|
114
114
|
)}
|
|
115
115
|
|
|
116
|
+
{surface?.controls && <PadMock surface={surface} controls={surface.controls} />}
|
|
117
|
+
|
|
116
118
|
<section style={S.section}>
|
|
117
119
|
<div style={S.row}>
|
|
118
120
|
<h2 style={S.h2}>messages</h2>
|
|
@@ -198,6 +200,107 @@ function Params({ surface }: { surface: AnySurface }) {
|
|
|
198
200
|
);
|
|
199
201
|
}
|
|
200
202
|
|
|
203
|
+
/**
|
|
204
|
+
* THE PADS, MOCKED - the 8x8 grid, before there is any hardware in the room.
|
|
205
|
+
*
|
|
206
|
+
* WHY IT EXISTS AT ALL, and why it was built before the first real device: without
|
|
207
|
+
* it, every iteration on a grid device is a rebuild, a reinstall, a re-drag (Live
|
|
208
|
+
* embeds a COPY of a device in the set, so instances already on tracks do not
|
|
209
|
+
* update) and a squint at sixty-four LEDs to work out whether a cell is one column
|
|
210
|
+
* off. Here it is a browser tab.
|
|
211
|
+
*
|
|
212
|
+
* It is driven by the REAL contract, in both directions, and that is the whole
|
|
213
|
+
* point of putting it on the bridge tap rather than on the store:
|
|
214
|
+
*
|
|
215
|
+
* paint every `controls_frame <key> <64 indices>` the app sends is rendered,
|
|
216
|
+
* palette index by palette index, in the same hardware order the wrapper
|
|
217
|
+
* would receive - so a y flip that is wrong shows up here as an upside-down
|
|
218
|
+
* picture, which is exactly what it would be on the hardware.
|
|
219
|
+
* press a click fakes `pad_<key> <velocity> <x> <yFromTop>` inbound, the shape a
|
|
220
|
+
* `[live.observer]` actually emits, and a release on mouse-up.
|
|
221
|
+
*
|
|
222
|
+
* THE HONEST LIMIT is the usual one: it is the message-level contract, not the
|
|
223
|
+
* hardware. It cannot tell you about pad latency, about a grab Live rejected without
|
|
224
|
+
* saying so, or about what a palette index really looks like under a camera. Keep
|
|
225
|
+
* "look at the Push" for those.
|
|
226
|
+
*
|
|
227
|
+
* `controls_held` is driven from the `takeover` parameter alone. The real answer
|
|
228
|
+
* also depends on `focus` and on what Live has selected, which a mock has no way to
|
|
229
|
+
* know - so this deliberately answers the easy half and a device should still be
|
|
230
|
+
* tried with two instances in one set.
|
|
231
|
+
*/
|
|
232
|
+
function PadMock({ surface, controls }: { surface: AnySurface; controls: Controls }) {
|
|
233
|
+
const [values] = useSurface(surface);
|
|
234
|
+
const keys = controls.keys as readonly string[];
|
|
235
|
+
const specs = controls.controls as Record<string, ControlSpec>;
|
|
236
|
+
const [frames, setFrames] = useState<Record<string, number[]>>({});
|
|
237
|
+
|
|
238
|
+
// Every declared role "resolves" on a mock. Sent once, so a device that renders
|
|
239
|
+
// usePadRoles() shows the same thing it would with the hardware plugged in.
|
|
240
|
+
useEffect(() => {
|
|
241
|
+
for (const key of keys) simulate(CONTROLS_IN.controls_role, key, 1);
|
|
242
|
+
// The declaration does not change at runtime.
|
|
243
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
244
|
+
}, []);
|
|
245
|
+
|
|
246
|
+
const held = Boolean(values[TAKEOVER_PARAM]);
|
|
247
|
+
// The reason travels with it, as it does from the wrapper - the mock only ever has
|
|
248
|
+
// the two easy answers, because it cannot know what Live has selected.
|
|
249
|
+
useEffect(() => simulate(CONTROLS_IN.controls_held, held ? 1 : 0, held ? "held" : "off"), [held]);
|
|
250
|
+
|
|
251
|
+
useEffect(
|
|
252
|
+
() =>
|
|
253
|
+
tapMessages((m) => {
|
|
254
|
+
if (m.direction !== "out") return;
|
|
255
|
+
if (m.selector === CONTROLS_OUT.controls_refresh) return setFrames({});
|
|
256
|
+
if (m.selector !== CONTROLS_OUT.controls_frame) return;
|
|
257
|
+
const [key, ...cells] = m.args;
|
|
258
|
+
setFrames((prev) => ({ ...prev, [String(key)]: cells.map(Number) }));
|
|
259
|
+
}),
|
|
260
|
+
[],
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
return (
|
|
264
|
+
<section style={S.section}>
|
|
265
|
+
<div style={S.row}>
|
|
266
|
+
<h2 style={S.h2}>pads (mocked)</h2>
|
|
267
|
+
<span style={S.bankName}>{held ? "held" : "released"}</span>
|
|
268
|
+
</div>
|
|
269
|
+
{keys.map((key) => {
|
|
270
|
+
const spec = specs[key];
|
|
271
|
+
const rows = spec.kind === "grid" ? spec.rows : 1;
|
|
272
|
+
const cols = spec.kind === "grid" ? spec.cols : 1;
|
|
273
|
+
const cells = frames[key] ?? [];
|
|
274
|
+
return (
|
|
275
|
+
<div key={key}>
|
|
276
|
+
<div style={S.padLabel}>
|
|
277
|
+
{key} · {spec.role}
|
|
278
|
+
</div>
|
|
279
|
+
<div style={{ ...S.padGrid, gridTemplateColumns: `repeat(${cols}, 1fr)` }}>
|
|
280
|
+
{Array.from({ length: rows * cols }, (_, i) => {
|
|
281
|
+
// Row 0 is the TOP, on the wire and here - the mock renders exactly
|
|
282
|
+
// what was sent, so a device that flipped y twice looks wrong.
|
|
283
|
+
const x = i % cols;
|
|
284
|
+
const y = Math.floor(i / cols);
|
|
285
|
+
return (
|
|
286
|
+
<button
|
|
287
|
+
key={i}
|
|
288
|
+
title={`x ${x} y ${rows - 1 - y}`}
|
|
289
|
+
style={{ ...S.pad, background: cssForPaletteIndex(cells[i] ?? 0), opacity: held ? 1 : 0.35 }}
|
|
290
|
+
onMouseDown={() => simulate(padSelector(key), 100, x, y, 1)}
|
|
291
|
+
onMouseUp={() => simulate(padSelector(key), 0, x, y, 1)}
|
|
292
|
+
onMouseLeave={(e) => e.buttons === 1 && simulate(padSelector(key), 0, x, y, 1)}
|
|
293
|
+
/>
|
|
294
|
+
);
|
|
295
|
+
})}
|
|
296
|
+
</div>
|
|
297
|
+
</div>
|
|
298
|
+
);
|
|
299
|
+
})}
|
|
300
|
+
</section>
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
201
304
|
/**
|
|
202
305
|
* THE PUSH PREVIEW - what a performer will actually be looking at.
|
|
203
306
|
*
|
|
@@ -308,6 +411,9 @@ const S: Record<string, React.CSSProperties> = {
|
|
|
308
411
|
},
|
|
309
412
|
bankName: { color: "#7d8694", marginLeft: "auto" },
|
|
310
413
|
push: { display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 2 },
|
|
414
|
+
padLabel: { color: "#7d8694", margin: "4px 0 3px" },
|
|
415
|
+
padGrid: { display: "grid", gap: 3 },
|
|
416
|
+
pad: { aspectRatio: "1", border: "1px solid #262a31", borderRadius: 3, padding: 0, cursor: "pointer" },
|
|
311
417
|
cell: { background: "#0e1013", border: "1px solid #262a31", borderRadius: 2, padding: "4px 5px", overflow: "hidden" },
|
|
312
418
|
cellName: { color: "#7d8694", fontSize: 10, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" },
|
|
313
419
|
cellValue: { color: "#8fd0a4", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" },
|
package/src/index.ts
CHANGED
|
@@ -30,6 +30,16 @@
|
|
|
30
30
|
* and Push shows every parameter.
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
|
+
import { FOCUS_OPTIONS, type Controls, type FocusPolicy } from "./controls";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The CONTROL SURFACE declaration lives in its own file and is re-exported here, so
|
|
37
|
+
* a device imports `defineSurface` and `defineControls` from one place. See
|
|
38
|
+
* controls.ts - the hardware behaviour it is shaped around is measured, and five
|
|
39
|
+
* parts of it contradict the obvious guess.
|
|
40
|
+
*/
|
|
41
|
+
export * from "./controls";
|
|
42
|
+
|
|
33
43
|
/* ------------------------------------------------------------------ *
|
|
34
44
|
* Parameter kinds
|
|
35
45
|
* ------------------------------------------------------------------ */
|
|
@@ -400,6 +410,17 @@ export interface SurfaceDef<
|
|
|
400
410
|
state?: S;
|
|
401
411
|
/** Which parameters render as native Max objects in the device view. */
|
|
402
412
|
layout?: { native?: NativeLayout<Extract<keyof P, string>> };
|
|
413
|
+
/**
|
|
414
|
+
* What this device claims on the CONTROL SURFACE - `defineControls()`.
|
|
415
|
+
*
|
|
416
|
+
* It lives on the Surface rather than beside it because it CONTRIBUTES two real
|
|
417
|
+
* Live parameters (`takeover`, `focus`): the user must be able to switch a
|
|
418
|
+
* takeover off, automate it and see it on Push, and the only thing Push sees is a
|
|
419
|
+
* Live parameter. So the declaration that claims the pads and the declaration that
|
|
420
|
+
* generates the dials are one declaration, and the two parameters are typed into
|
|
421
|
+
* this surface exactly as if they had been written out by hand.
|
|
422
|
+
*/
|
|
423
|
+
controls?: Controls;
|
|
403
424
|
}
|
|
404
425
|
|
|
405
426
|
export interface Surface<
|
|
@@ -411,6 +432,41 @@ export interface Surface<
|
|
|
411
432
|
readonly ids: readonly Extract<keyof P, string>[];
|
|
412
433
|
}
|
|
413
434
|
|
|
435
|
+
/**
|
|
436
|
+
* The two parameters `defineControls()` adds to the Surface that carries it.
|
|
437
|
+
*
|
|
438
|
+
* `takeover` is off by default and `focus` decides WHEN an enabled device actually
|
|
439
|
+
* holds what it declared - see controls.ts. They are spelled out as a type so that
|
|
440
|
+
* `useParam(surface, "takeover")` is typed like any other declared parameter, with
|
|
441
|
+
* nothing to cast and nothing to remember.
|
|
442
|
+
*/
|
|
443
|
+
export interface ControlParams {
|
|
444
|
+
takeover: ToggleSpec;
|
|
445
|
+
focus: MenuSpec<FocusPolicy>;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* `P` with the takeover pair folded in, FLATTENED.
|
|
450
|
+
*
|
|
451
|
+
* A plain `P & ControlParams` is an intersection, and an intersection of two object
|
|
452
|
+
* types has no index signature even when both halves would satisfy one - so
|
|
453
|
+
* `useParam(surface, ...)` fails to infer `P extends Record<string, ParamSpec>` and
|
|
454
|
+
* the device sees a wall of "index signature is missing". Mapping over the keys
|
|
455
|
+
* produces an ordinary object type again, which infers exactly as a hand-written
|
|
456
|
+
* params block does.
|
|
457
|
+
*/
|
|
458
|
+
export type WithControlParams<P extends Record<string, ParamSpec>> = {
|
|
459
|
+
[K in keyof (P & ControlParams)]: (P & ControlParams)[K];
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
/** The two parameter specs themselves, so the build and the app agree on one object. */
|
|
463
|
+
export function controlParams(controls: Controls): ControlParams {
|
|
464
|
+
return {
|
|
465
|
+
takeover: toggle({ default: false, short: "Takeovr" }),
|
|
466
|
+
focus: menu({ options: FOCUS_OPTIONS, default: controls.defaultFocus, short: "Focus" }),
|
|
467
|
+
} as ControlParams;
|
|
468
|
+
}
|
|
469
|
+
|
|
414
470
|
/** Push has eight encoders per page. A ninth parameter in a bank is not an error in Max - it just never appears. */
|
|
415
471
|
export const BANK_SIZE = 8;
|
|
416
472
|
|
|
@@ -426,11 +482,28 @@ export const BANK_SIZE = 8;
|
|
|
426
482
|
* imports this module to generate the patcher, so a violation fails `pnpm
|
|
427
483
|
* build` and fails CI. It is only a less pretty error message.
|
|
428
484
|
*/
|
|
485
|
+
export function defineSurface<
|
|
486
|
+
const P extends Record<string, ParamSpec>,
|
|
487
|
+
const S extends Record<string, StateSpec>,
|
|
488
|
+
const W extends Record<string, WindowSpec>,
|
|
489
|
+
>(def: SurfaceDef<P, S, W> & { controls: Controls }): Surface<WithControlParams<P>, S, W>;
|
|
490
|
+
export function defineSurface<
|
|
491
|
+
const P extends Record<string, ParamSpec>,
|
|
492
|
+
const S extends Record<string, StateSpec>,
|
|
493
|
+
const W extends Record<string, WindowSpec>,
|
|
494
|
+
>(def: SurfaceDef<P, S, W>): Surface<P, S, W>;
|
|
429
495
|
export function defineSurface<
|
|
430
496
|
const P extends Record<string, ParamSpec>,
|
|
431
497
|
const S extends Record<string, StateSpec>,
|
|
432
498
|
const W extends Record<string, WindowSpec>,
|
|
433
499
|
>(def: SurfaceDef<P, S, W>): Surface<P, S, W> {
|
|
500
|
+
// A declared takeover contributes two REAL Live parameters, and it does so HERE
|
|
501
|
+
// rather than in the build: they have to be in `ids` for the codegen to emit the
|
|
502
|
+
// objects, in `banks` for Push to page them, and in the app's store for
|
|
503
|
+
// `useParam(surface, "takeover")` to work. One merge, before every check below
|
|
504
|
+
// runs, so the generated pair is validated exactly like a hand-written one.
|
|
505
|
+
if (def.controls) def = mergeControlParams(def);
|
|
506
|
+
|
|
434
507
|
const ids = Object.keys(def.params) as Extract<keyof P, string>[];
|
|
435
508
|
|
|
436
509
|
for (const id of ids) {
|
|
@@ -503,6 +576,46 @@ export function defineSurface<
|
|
|
503
576
|
return { ...def, ids };
|
|
504
577
|
}
|
|
505
578
|
|
|
579
|
+
/**
|
|
580
|
+
* Fold `defineControls()`'s two parameters into the declaration.
|
|
581
|
+
*
|
|
582
|
+
* THE BANK MATTERS AS MUCH AS THE PARAMETER. Push shows a device eight encoders per
|
|
583
|
+
* page and nothing else, so a `takeover` toggle that exists but is in no bank is a
|
|
584
|
+
* switch the user can only reach by opening the device view - which is the one place
|
|
585
|
+
* a Push user is not looking. A surface that declares banks therefore gets the pair
|
|
586
|
+
* appended to the first page with room, and a page of its own only if every declared
|
|
587
|
+
* bank is full.
|
|
588
|
+
*
|
|
589
|
+
* A surface with NO banks is left alone: Live falls back to declaration order, and
|
|
590
|
+
* the two land at the end of it either way.
|
|
591
|
+
*/
|
|
592
|
+
function mergeControlParams<
|
|
593
|
+
P extends Record<string, ParamSpec>,
|
|
594
|
+
S extends Record<string, StateSpec>,
|
|
595
|
+
W extends Record<string, WindowSpec>,
|
|
596
|
+
>(def: SurfaceDef<P, S, W>): SurfaceDef<P, S, W> {
|
|
597
|
+
const extra = controlParams(def.controls!) as unknown as P;
|
|
598
|
+
for (const id of Object.keys(extra)) {
|
|
599
|
+
if (def.params[id]) {
|
|
600
|
+
throw new Error(
|
|
601
|
+
`surface: "${id}" is generated by defineControls() - remove it from params, or the takeover would have two dials and one name`,
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const params = { ...def.params, ...extra };
|
|
607
|
+
if (!def.banks?.length) return { ...def, params };
|
|
608
|
+
|
|
609
|
+
const ids = Object.keys(extra) as Extract<keyof P, string>[];
|
|
610
|
+
const banks = def.banks.map((b) => ({ name: b.name, params: [...b.params] as Extract<keyof P, string>[] }));
|
|
611
|
+
for (const id of ids) {
|
|
612
|
+
const room = banks.find((b) => b.params.length < BANK_SIZE);
|
|
613
|
+
if (room) room.params.push(id);
|
|
614
|
+
else banks.push({ name: "Takeover", params: [id] });
|
|
615
|
+
}
|
|
616
|
+
return { ...def, params, banks };
|
|
617
|
+
}
|
|
618
|
+
|
|
506
619
|
/**
|
|
507
620
|
* Does this parameter render as a native Max object? App code uses it to stop
|
|
508
621
|
* drawing an HTML control the device view now owns. Cheap and honest: a parameter
|
package/src/pads.ts
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pads.ts - the state behind usePadGrid(), with no React in it.
|
|
3
|
+
*
|
|
4
|
+
* The twin of store.ts, and separate from it for the same reason store.ts is
|
|
5
|
+
* separate from react.tsx: everything subtle here is testable without a DOM. What
|
|
6
|
+
* is subtle is not the binding - it is the two things this file owns and nothing
|
|
7
|
+
* else may:
|
|
8
|
+
*
|
|
9
|
+
* ------------------------------------------------------------------------------
|
|
10
|
+
* 1. THE Y FLIP, IN ONE PLACE.
|
|
11
|
+
*
|
|
12
|
+
* The hardware counts rows from the TOP (a press on the bottom-left pad reports
|
|
13
|
+
* y = 7) and `defineControls`' API counts them from the BOTTOM, which is the
|
|
14
|
+
* orientation every layout in PUSH-USECASES.md is drawn in. Both directions are
|
|
15
|
+
* converted HERE, on the way out of `draw()` and on the way in from `pad_<key>`,
|
|
16
|
+
* and nowhere else. Do it twice and it cancels; do it in a device and every OTHER
|
|
17
|
+
* device on the grid is mirrored vertically with nothing to report it. That misread
|
|
18
|
+
* cost two rounds of the spike, because a wrong reading of the same payload
|
|
19
|
+
* collapsed four corners onto two cells and looked like a hardware fault.
|
|
20
|
+
*
|
|
21
|
+
* ------------------------------------------------------------------------------
|
|
22
|
+
* 2. THE FRAME IS WHOLE, AND SENT ONLY WHEN IT CHANGED.
|
|
23
|
+
*
|
|
24
|
+
* `draw()` takes a FRAME, not a pad: a device describes the entire grid every time
|
|
25
|
+
* and this works out whether anything moved. The callback fills an off-screen
|
|
26
|
+
* buffer of palette indices; if it is identical to the last buffer sent, nothing
|
|
27
|
+
* crosses the bridge at all. So a device may redraw on every tick and every state
|
|
28
|
+
* change - which is what makes `pads.draw(frame)` on a worker message a reasonable
|
|
29
|
+
* thing to write - and a still grid costs nothing.
|
|
30
|
+
*
|
|
31
|
+
* The wrapper diffs a second time, per CELL, against what the HARDWARE last
|
|
32
|
+
* received. That is not the same buffer: Live repaints the matrix as it hands it
|
|
33
|
+
* over, so after a grab the hardware's state is unknown and the wrapper has to
|
|
34
|
+
* repaint everything even though the app's frame did not change. Two diffs, two
|
|
35
|
+
* questions: this one is "did the device change its mind", the wrapper's is "does
|
|
36
|
+
* the pad already show this".
|
|
37
|
+
*/
|
|
38
|
+
import { bindInlet, outlet, padSelector, CONTROLS_IN, CONTROLS_OUT } from "@m4l-jweb/bridge";
|
|
39
|
+
import { controlSize, paletteIndex, type ControlSpec, type Controls } from "./controls";
|
|
40
|
+
|
|
41
|
+
/** One event off a grabbed control. */
|
|
42
|
+
export interface PadEvent {
|
|
43
|
+
/** 0 .. cols-1, left to right. */
|
|
44
|
+
x: number;
|
|
45
|
+
/** 0 .. rows-1, BOTTOM to top - flipped from the wire here and nowhere else. */
|
|
46
|
+
y: number;
|
|
47
|
+
/** The raw value: a velocity on a press, 0 on a release. */
|
|
48
|
+
value: number;
|
|
49
|
+
/** `value > 0` - the check nine handlers in ten want. */
|
|
50
|
+
down: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* The wire's fourth atom, passed through unnamed.
|
|
53
|
+
*
|
|
54
|
+
* It has been `1` on every event observed on a Push 3, press and release alike,
|
|
55
|
+
* and naming a field after a guess is how a guess becomes a fact by repetition.
|
|
56
|
+
*/
|
|
57
|
+
extra: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The painting surface `draw()` hands you. Coordinates are bottom-up. */
|
|
61
|
+
export interface PadFrame {
|
|
62
|
+
readonly rows: number;
|
|
63
|
+
readonly cols: number;
|
|
64
|
+
/** Fill every cell. */
|
|
65
|
+
clear(colour: string): void;
|
|
66
|
+
/** One cell. Out-of-range coordinates are ignored rather than thrown - a game's cursor may walk off the edge. */
|
|
67
|
+
set(x: number, y: number, colour: string): void;
|
|
68
|
+
/** A whole row, bottom-up. */
|
|
69
|
+
row(y: number, colour: string): void;
|
|
70
|
+
/** A whole column. */
|
|
71
|
+
col(x: number, colour: string): void;
|
|
72
|
+
/** A rectangle, from its bottom-left corner. */
|
|
73
|
+
rect(x: number, y: number, w: number, h: number, colour: string): void;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Why this device is or is not holding what it declared.
|
|
78
|
+
*
|
|
79
|
+
* `held` is the only one that is not a complaint. The other four are why the pads are
|
|
80
|
+
* dark, and they are worth distinguishing precisely because the hardware cannot: a
|
|
81
|
+
* Push with `takeover` off, a Push that is not plugged in, and a Push another instance
|
|
82
|
+
* of the device is holding all look identical.
|
|
83
|
+
*/
|
|
84
|
+
export type PadHoldReason = "held" | "off" | "no_surface" | "unresolved" | "not_focused";
|
|
85
|
+
|
|
86
|
+
export type PadHandler = (e: PadEvent) => void;
|
|
87
|
+
/** A `stream` control's raw atoms, exactly as the hardware sent them. Nothing is measured about their meaning yet. */
|
|
88
|
+
export type StreamHandler = (atoms: number[]) => void;
|
|
89
|
+
|
|
90
|
+
export interface PadStore {
|
|
91
|
+
/** Paint the whole of one control. Nothing crosses the bridge if the frame is unchanged. */
|
|
92
|
+
draw(key: string, fill: (f: PadFrame) => void): void;
|
|
93
|
+
/** Force the next `draw` to be sent even if it is identical, and tell the wrapper to repaint the hardware. */
|
|
94
|
+
refresh(): void;
|
|
95
|
+
onPad(key: string, fn: PadHandler): () => void;
|
|
96
|
+
onStream(key: string, fn: StreamHandler): () => void;
|
|
97
|
+
/** Do we hold the declared controls right now? */
|
|
98
|
+
held(): boolean;
|
|
99
|
+
/**
|
|
100
|
+
* WHY, when we do not - `off`, `no_surface`, `unresolved`, `not_focused`, `held`.
|
|
101
|
+
*
|
|
102
|
+
* On the hardware all four look the same: a dark Push. And a rejected LiveAPI call
|
|
103
|
+
* reports nothing, so the wrapper cannot say whether Live accepted a grab - only
|
|
104
|
+
* what it DECIDED, which is this.
|
|
105
|
+
*/
|
|
106
|
+
reason(): PadHoldReason;
|
|
107
|
+
/** Which declared roles resolved on the connected hardware. A key absent from this map has not been answered for yet. */
|
|
108
|
+
roles(): Record<string, boolean>;
|
|
109
|
+
subscribe(fn: () => void): () => void;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const stores = new WeakMap<object, PadStore>();
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The store for one `defineControls()` declaration - created once, never torn down.
|
|
116
|
+
*
|
|
117
|
+
* Same rule as paramStore: the bridge holds ONE handler per selector, so a second
|
|
118
|
+
* `bindInlet("pad_pads", ...)` would silently replace the first and one of the two
|
|
119
|
+
* components would never see another press. Bind once, fan out.
|
|
120
|
+
*/
|
|
121
|
+
export function padStore<C extends Record<string, ControlSpec>>(controls: Controls<C>): PadStore {
|
|
122
|
+
const existing = stores.get(controls);
|
|
123
|
+
if (existing) return existing;
|
|
124
|
+
|
|
125
|
+
const keys = controls.keys as readonly string[];
|
|
126
|
+
const specs = controls.controls as Record<string, ControlSpec>;
|
|
127
|
+
|
|
128
|
+
/** The last frame SENT for each control, in hardware order. Null means "nothing sent yet". */
|
|
129
|
+
const sent = new Map<string, number[]>();
|
|
130
|
+
const padHandlers = new Map<string, Set<PadHandler>>();
|
|
131
|
+
const streamHandlers = new Map<string, Set<StreamHandler>>();
|
|
132
|
+
const listeners = new Set<() => void>();
|
|
133
|
+
|
|
134
|
+
let heldNow = false;
|
|
135
|
+
let reasonNow: PadHoldReason = "off";
|
|
136
|
+
let roleMap: Record<string, boolean> = {};
|
|
137
|
+
|
|
138
|
+
const notify = () => {
|
|
139
|
+
for (const fn of listeners) fn();
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
for (const key of keys) {
|
|
143
|
+
const spec = specs[key];
|
|
144
|
+
const rows = spec.kind === "grid" ? spec.rows : 1;
|
|
145
|
+
const cols = spec.kind === "grid" ? spec.cols : 1;
|
|
146
|
+
|
|
147
|
+
bindInlet(padSelector(key), (...args) => {
|
|
148
|
+
const atoms = args.map(Number);
|
|
149
|
+
|
|
150
|
+
// `live.observer` emits the property's CURRENT value the moment it is pointed
|
|
151
|
+
// at an object - for a control nobody has touched that is a bang, which
|
|
152
|
+
// arrives here as no arguments at all. It is an attach notification, not a
|
|
153
|
+
// press, and forwarding it is a press at (undefined, undefined).
|
|
154
|
+
if (!atoms.length) return;
|
|
155
|
+
|
|
156
|
+
const streams = streamHandlers.get(key);
|
|
157
|
+
if (streams) for (const fn of streams) fn(atoms);
|
|
158
|
+
if (spec.kind === "stream") return;
|
|
159
|
+
|
|
160
|
+
const handlers = padHandlers.get(key);
|
|
161
|
+
if (!handlers?.size) return;
|
|
162
|
+
|
|
163
|
+
// MEASURED on a Push 3: `<velocity> <x> <yFromTop> <1>`. A button carries one
|
|
164
|
+
// atom (unmeasured - no button but the matrix has been grabbed), so its
|
|
165
|
+
// coordinates are the only cell it has.
|
|
166
|
+
const value = atoms[0];
|
|
167
|
+
const hx = atoms.length >= 3 ? atoms[1] : 0;
|
|
168
|
+
const hy = atoms.length >= 3 ? atoms[2] : 0;
|
|
169
|
+
const e: PadEvent = { x: hx, y: rows - 1 - hy, value, down: value > 0, extra: atoms.length >= 4 ? atoms[3] : 0 };
|
|
170
|
+
if (e.x < 0 || e.x >= cols || e.y < 0 || e.y >= rows) return;
|
|
171
|
+
for (const fn of handlers) fn(e);
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
bindInlet(CONTROLS_IN.controls_held, (v, why) => {
|
|
176
|
+
const next = Number(v) === 1;
|
|
177
|
+
const nextReason = (why === undefined ? (next ? "held" : "off") : String(why)) as PadHoldReason;
|
|
178
|
+
if (next === heldNow && nextReason === reasonNow) return;
|
|
179
|
+
reasonNow = nextReason;
|
|
180
|
+
if (next === heldNow) {
|
|
181
|
+
// The reason moved without the grab moving - `off` to `not_focused`, say. The
|
|
182
|
+
// page still wants to know, and the frame cache is still valid.
|
|
183
|
+
notify();
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
heldNow = next;
|
|
187
|
+
// The hardware is repainted by Live as it hands the controls over, so what we
|
|
188
|
+
// last sent is no longer what is lit. Forget it either way, and the next draw
|
|
189
|
+
// is a whole frame rather than a no-op against a stale cache.
|
|
190
|
+
sent.clear();
|
|
191
|
+
notify();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
bindInlet(CONTROLS_IN.controls_role, (key, ok) => {
|
|
195
|
+
const next = { ...roleMap, [String(key)]: Number(ok) === 1 };
|
|
196
|
+
roleMap = next;
|
|
197
|
+
notify();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const store: PadStore = {
|
|
201
|
+
draw(key, fill) {
|
|
202
|
+
const spec = specs[key];
|
|
203
|
+
if (!spec) return;
|
|
204
|
+
const rows = spec.kind === "grid" ? spec.rows : 1;
|
|
205
|
+
const cols = spec.kind === "grid" ? spec.cols : 1;
|
|
206
|
+
const cells = new Array<number>(controlSize(spec)).fill(0);
|
|
207
|
+
|
|
208
|
+
// Hardware order: row 0 is the TOP. The flip is here, and only here.
|
|
209
|
+
const at = (x: number, y: number) => (rows - 1 - y) * cols + x;
|
|
210
|
+
const put = (x: number, y: number, colour: string) => {
|
|
211
|
+
if (x < 0 || x >= cols || y < 0 || y >= rows) return;
|
|
212
|
+
cells[at(x, y)] = paletteIndex(colour);
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const frame: PadFrame = {
|
|
216
|
+
rows,
|
|
217
|
+
cols,
|
|
218
|
+
clear: (colour) => cells.fill(paletteIndex(colour)),
|
|
219
|
+
set: put,
|
|
220
|
+
row: (y, colour) => {
|
|
221
|
+
for (let x = 0; x < cols; x++) put(x, y, colour);
|
|
222
|
+
},
|
|
223
|
+
col: (x, colour) => {
|
|
224
|
+
for (let y = 0; y < rows; y++) put(x, y, colour);
|
|
225
|
+
},
|
|
226
|
+
rect: (x, y, w, h, colour) => {
|
|
227
|
+
for (let dx = 0; dx < w; dx++) for (let dy = 0; dy < h; dy++) put(x + dx, y + dy, colour);
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
fill(frame);
|
|
231
|
+
|
|
232
|
+
const last = sent.get(key);
|
|
233
|
+
if (last && last.length === cells.length && last.every((c, i) => c === cells[i])) return;
|
|
234
|
+
sent.set(key, cells);
|
|
235
|
+
// ONE message carrying the whole grid, not one per cell: [js] is a control
|
|
236
|
+
// plane, and sixty-four messages a frame is a data plane. The per-cell diff
|
|
237
|
+
// that decides what the hardware is actually told happens in the wrapper.
|
|
238
|
+
outlet(CONTROLS_OUT.controls_frame, key, ...cells);
|
|
239
|
+
},
|
|
240
|
+
refresh() {
|
|
241
|
+
sent.clear();
|
|
242
|
+
outlet(CONTROLS_OUT.controls_refresh);
|
|
243
|
+
},
|
|
244
|
+
onPad(key, fn) {
|
|
245
|
+
let set = padHandlers.get(key);
|
|
246
|
+
if (!set) padHandlers.set(key, (set = new Set()));
|
|
247
|
+
set.add(fn);
|
|
248
|
+
return () => set!.delete(fn);
|
|
249
|
+
},
|
|
250
|
+
onStream(key, fn) {
|
|
251
|
+
let set = streamHandlers.get(key);
|
|
252
|
+
if (!set) streamHandlers.set(key, (set = new Set()));
|
|
253
|
+
set.add(fn);
|
|
254
|
+
return () => set!.delete(fn);
|
|
255
|
+
},
|
|
256
|
+
held: () => heldNow,
|
|
257
|
+
reason: () => reasonNow,
|
|
258
|
+
roles: () => roleMap,
|
|
259
|
+
subscribe(fn) {
|
|
260
|
+
listeners.add(fn);
|
|
261
|
+
return () => listeners.delete(fn);
|
|
262
|
+
},
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
stores.set(controls, store);
|
|
266
|
+
return store;
|
|
267
|
+
}
|
package/src/react.tsx
CHANGED
|
@@ -18,8 +18,9 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
20
20
|
import { describeParam, onParamRange, outlet } from "@m4l-jweb/bridge";
|
|
21
|
-
import type { ParamSpec, ParamValue, StateSpec, StateValue, Surface, Watch, WatchSpec, WatchValue, WindowSpec } from "./index";
|
|
21
|
+
import type { ControlSpec, Controls, ParamSpec, ParamValue, StateSpec, StateValue, Surface, Watch, WatchSpec, WatchValue, WindowSpec } from "./index";
|
|
22
22
|
import { JWEB_VARNAME } from "./index";
|
|
23
|
+
import { padStore, type PadFrame, type PadHandler, type PadHoldReason } from "./pads";
|
|
23
24
|
import { paramStore, stateStore, watchStore } from "./store";
|
|
24
25
|
|
|
25
26
|
/**
|
|
@@ -159,6 +160,127 @@ export function useStateSync<
|
|
|
159
160
|
return [values[id] as StateValue<S[K]>, set];
|
|
160
161
|
}
|
|
161
162
|
|
|
163
|
+
/* ------------------------------------------------------------------ *
|
|
164
|
+
* The control surface - the pads, declared with defineControls()
|
|
165
|
+
* ------------------------------------------------------------------ */
|
|
166
|
+
|
|
167
|
+
/** One declared control, bound to React. */
|
|
168
|
+
export interface PadGrid {
|
|
169
|
+
/** Rows of the control, as declared. */
|
|
170
|
+
rows: number;
|
|
171
|
+
/** Columns of the control, as declared. */
|
|
172
|
+
cols: number;
|
|
173
|
+
/**
|
|
174
|
+
* Paint the WHOLE control. Coordinates are bottom-up.
|
|
175
|
+
*
|
|
176
|
+
* Redraw as freely as you like - on every worker frame, on every state change.
|
|
177
|
+
* The frame is diffed against the last one sent and an unchanged grid crosses the
|
|
178
|
+
* bridge not at all, so the cost of a still picture is one array comparison.
|
|
179
|
+
*/
|
|
180
|
+
draw: (fill: (f: PadFrame) => void) => void;
|
|
181
|
+
/**
|
|
182
|
+
* Handle presses and releases. Returns an unsubscribe function, so it is written
|
|
183
|
+
* as the body of an effect: `useEffect(() => pads.onPad(fn), [pads])`.
|
|
184
|
+
*/
|
|
185
|
+
onPad: (fn: PadHandler) => () => void;
|
|
186
|
+
/** Repaint the hardware from scratch, discarding what we believe is lit. */
|
|
187
|
+
refresh: () => void;
|
|
188
|
+
/** Did this control's role resolve on the connected hardware? `false` until the wrapper has answered. */
|
|
189
|
+
resolved: boolean;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* A declared control, as a frame buffer and an event stream.
|
|
194
|
+
*
|
|
195
|
+
* ```tsx
|
|
196
|
+
* const pads = usePadGrid(controls, "pads");
|
|
197
|
+
* pads.draw((f) => { f.clear("black"); f.set(3, 4, "green"); });
|
|
198
|
+
* useEffect(() => pads.onPad((e) => e.down && turn()), [pads]);
|
|
199
|
+
* ```
|
|
200
|
+
*
|
|
201
|
+
* `x` is 0..cols-1 left to right and `y` is 0..rows-1 BOTTOM to top, in both
|
|
202
|
+
* directions - the Push's own orientation and the one every layout in
|
|
203
|
+
* PUSH-USECASES.md is written in. The wire counts y from the top; the flip lives in
|
|
204
|
+
* pads.ts and nowhere else.
|
|
205
|
+
*/
|
|
206
|
+
export function usePadGrid<C extends Record<string, ControlSpec>, K extends Extract<keyof C, string>>(controls: Controls<C>, key: K): PadGrid {
|
|
207
|
+
const store = useMemo(() => padStore(controls), [controls]);
|
|
208
|
+
const roles = useSyncExternalStore(store.subscribe, store.roles, store.roles);
|
|
209
|
+
const spec = controls.controls[key] as ControlSpec;
|
|
210
|
+
const rows = spec.kind === "grid" ? spec.rows : 1;
|
|
211
|
+
const cols = spec.kind === "grid" ? spec.cols : 1;
|
|
212
|
+
|
|
213
|
+
const draw = useCallback((fill: (f: PadFrame) => void) => store.draw(key, fill), [store, key]);
|
|
214
|
+
const onPad = useCallback((fn: PadHandler) => store.onPad(key, fn), [store, key]);
|
|
215
|
+
const refresh = useCallback(() => store.refresh(), [store]);
|
|
216
|
+
|
|
217
|
+
return useMemo(() => ({ rows, cols, draw, onPad, refresh, resolved: roles[key] === true }), [rows, cols, draw, onPad, refresh, roles, key]);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* A declared BUTTON, as a live boolean.
|
|
222
|
+
*
|
|
223
|
+
* The degenerate grid: one cell, so `usePadGrid` would work and read badly.
|
|
224
|
+
* UNMEASURED - the only control grabbed on hardware so far is `Button_Matrix`, and
|
|
225
|
+
* what a single button's `value` carries has not been read off a Push. The first
|
|
226
|
+
* atom is taken as the value, which is what the matrix does.
|
|
227
|
+
*/
|
|
228
|
+
export function usePadButton<C extends Record<string, ControlSpec>, K extends Extract<keyof C, string>>(controls: Controls<C>, key: K): boolean {
|
|
229
|
+
const store = useMemo(() => padStore(controls), [controls]);
|
|
230
|
+
const [down, setDown] = useState(false);
|
|
231
|
+
useEffect(() => store.onPad(key, (e) => setDown(e.down)), [store, key]);
|
|
232
|
+
return down;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* A declared STREAM's raw atoms - the jog wheel, the touch strip.
|
|
237
|
+
*
|
|
238
|
+
* Deliberately not decoded, and now for a measured reason (doc/MAX-FACTS.md). Both stream
|
|
239
|
+
* continuously under a grab, and NEITHER carries a position: the jog wheel sends a delta
|
|
240
|
+
* of one detent as a signed 7-bit step, and the touch strip sends a byte that counts in
|
|
241
|
+
* 64s and wraps. A hook that returned "the position" would be inventing one. This hands
|
|
242
|
+
* over what arrived, and the device decides what it means.
|
|
243
|
+
*/
|
|
244
|
+
export function usePadStream<C extends Record<string, ControlSpec>, K extends Extract<keyof C, string>>(controls: Controls<C>, key: K): number[] {
|
|
245
|
+
const store = useMemo(() => padStore(controls), [controls]);
|
|
246
|
+
const [atoms, setAtoms] = useState<number[]>([]);
|
|
247
|
+
useEffect(() => store.onStream(key, setAtoms), [store, key]);
|
|
248
|
+
return atoms;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Do we own the declared controls RIGHT NOW?
|
|
253
|
+
*
|
|
254
|
+
* Not the same question as "is `takeover` on": `focus` decides whether an enabled
|
|
255
|
+
* device holds them while another track or device is selected, and two of these in
|
|
256
|
+
* one set is the normal case.
|
|
257
|
+
*/
|
|
258
|
+
export function usePadsHeld<C extends Record<string, ControlSpec>>(controls: Controls<C>): boolean {
|
|
259
|
+
const store = useMemo(() => padStore(controls), [controls]);
|
|
260
|
+
return useSyncExternalStore(store.subscribe, store.held, store.held);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* ...and WHY NOT, when we do not.
|
|
265
|
+
*
|
|
266
|
+
* Worth rendering, because the hardware cannot show the difference: `takeover` off, no
|
|
267
|
+
* Push connected, a Push whose control names this role is not among, and another
|
|
268
|
+
* instance holding the grid are four different problems that all look like a dark
|
|
269
|
+
* Push. And they cannot be told apart by asking Live either - a rejected grab is a
|
|
270
|
+
* console line and a normal return - so this is the wrapper reporting what it DECIDED,
|
|
271
|
+
* which is the half it does know.
|
|
272
|
+
*/
|
|
273
|
+
export function usePadsReason<C extends Record<string, ControlSpec>>(controls: Controls<C>): PadHoldReason {
|
|
274
|
+
const store = useMemo(() => padStore(controls), [controls]);
|
|
275
|
+
return useSyncExternalStore(store.subscribe, store.reason, store.reason);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Which declared roles resolved on the connected hardware - the one failure this API can report. */
|
|
279
|
+
export function usePadRoles<C extends Record<string, ControlSpec>>(controls: Controls<C>): Record<string, boolean> {
|
|
280
|
+
const store = useMemo(() => padStore(controls), [controls]);
|
|
281
|
+
return useSyncExternalStore(store.subscribe, store.roles, store.roles);
|
|
282
|
+
}
|
|
283
|
+
|
|
162
284
|
/* ------------------------------------------------------------------ *
|
|
163
285
|
* Borrowing from a native knob pool
|
|
164
286
|
* ------------------------------------------------------------------ */
|