@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m4l-jweb/surface",
3
- "version": "1.3.0",
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.3.0"
28
+ "@m4l-jweb/bridge": "1.6.0"
28
29
  },
29
30
  "peerDependenciesMeta": {
30
31
  "react": {
@@ -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
  * ------------------------------------------------------------------ */