@m4l-jweb/wrapper 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/wrapper",
3
- "version": "1.3.0",
3
+ "version": "1.6.0",
4
4
  "description": "m4l-jweb: the Max for Live glue layer connecting a device to LiveAPI.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/sources.mjs CHANGED
@@ -25,4 +25,5 @@ export const sources = [
25
25
  src("core.ts"), // build stamp, lifecycle, the anything() guard, payload extraction
26
26
  src("liveapi.ts"), // transport poll, tempo observer, clip I/O
27
27
  src("watch.ts"), // defineWatch() observers - after liveapi, it calls observeProperty()
28
+ src("controls.ts"), // defineControls() - the Push takeover: discovery, the grab, the frame diff
28
29
  ];
@@ -0,0 +1,715 @@
1
+ /**
2
+ * controls.ts - the CONTROL SURFACE half of the wrapper: discovery, the grab, the
3
+ * focus policy, and the frame buffer that decides what the hardware is told.
4
+ *
5
+ * Concatenated after liveapi.ts. What to claim is injected by the build as
6
+ * CONTROLS_SPEC, from the device's `defineControls()` - this file is generic and a
7
+ * device that declared no controls ships an undefined spec and does nothing.
8
+ *
9
+ * ------------------------------------------------------------------------------
10
+ * EVERY RULE BELOW IS A MEASUREMENT. doc/MAX-FACTS.md, "Grabbing a Push control",
11
+ * on a Push 3 in Live 12. Four of them contradict the obvious guess and all four
12
+ * fail SILENTLY, which is why they are restated at the code that depends on them:
13
+ *
14
+ * - A control is grabbed BY NAME. A bare id is rejected outright; the two-atom
15
+ * `id <n>` works and buys nothing. The id is wanted only to point the chain's
16
+ * `[live.observer]` at the control.
17
+ * - `LiveAPI.call` DOES NOT THROW when Live refuses. It posts a console line and
18
+ * returns normally, so `try/catch` catches nothing and there is no success to
19
+ * branch on. Nothing here may claim it verified a grab.
20
+ * - Y COUNTS FROM THE TOP in `send_value` and in the `value` payload. The flip to
21
+ * the API's bottom-up orientation lives in the PAGE (pads.ts), in one place;
22
+ * this file is entirely in hardware coordinates and never flips anything.
23
+ * - THE FIRST FRAME AFTER A GRAB IS LOST. Live's own surface script repaints the
24
+ * matrix just after handing it over, so the first paint is deferred and until it
25
+ * runs the hardware's state is treated as unknown.
26
+ *
27
+ * ...and one that makes the rest shippable: giving a control back is safe by three
28
+ * routes - an explicit release, deleting the device without one, and reinstalling
29
+ * the .amxd while an instance is loaded. A device cannot strand the hardware, so it
30
+ * only has to release when it stops WANTING the grid.
31
+ * ------------------------------------------------------------------------------
32
+ */
33
+
34
+ /** The control-surface classes this library knows how to talk to. */
35
+ var CONTROLS_SURFACE_TYPES = ["Push", "Push2", "Push3", "Move"];
36
+
37
+ /**
38
+ * How long to wait before the first paint after a grab, in ms.
39
+ *
40
+ * Measured: a paint issued in the grab's own message turn does not appear, and the
41
+ * same paint 400 ms later does. It is Live's surface script redrawing the matrix as
42
+ * it hands it over; there is nothing to wait ON, so this is a delay and not a
43
+ * handshake.
44
+ */
45
+ var CONTROLS_FIRST_FRAME_MS = 400;
46
+
47
+ /** The Push (or Move) we resolved, and what Live calls its class. */
48
+ var controlsSurface: LiveAPI | null = null;
49
+ var controlsSurfaceType = "";
50
+ /** `live_app`, observing `control_surfaces` - a Push can be plugged in mid-set. */
51
+ var controlsApp: LiveAPI | null = null;
52
+
53
+ /** Per declared key: the resolved control, its LOM id, and the name that resolved. */
54
+ var controlsObjects: { [key: string]: LiveAPI | null } = {};
55
+ var controlsIds: { [key: string]: number } = {};
56
+ var controlsNames: { [key: string]: string } = {};
57
+
58
+ /** Per key: the last frame the PAGE asked for, and the last frame the HARDWARE was told. */
59
+ var controlsWanted: { [key: string]: number[] } = {};
60
+ var controlsShown: { [key: string]: number[] | null } = {};
61
+
62
+ /**
63
+ * Has setupControls() finished?
64
+ *
65
+ * Two things fire before it does and neither may act on a half-built picture: an
66
+ * observer calls back once while it is being CREATED, and Live restores this device's
67
+ * saved parameters during patcher load - which is a context where LiveAPI objects are
68
+ * born dead (hard rule 4), so a grab decided there would be decided against nothing.
69
+ */
70
+ var controlsStarted = false;
71
+
72
+ /** Has the `focus` PARAMETER told us its value? If so it outranks the declared default. */
73
+ var controlsFocusSeen = false;
74
+
75
+ /** Are we holding the declared controls right now? */
76
+ var controlsHeld = false;
77
+ /** The `takeover` parameter, and the `focus` menu index. */
78
+ var controlsEnabled = false;
79
+ var controlsFocus = 0;
80
+ /** FOCUS_OPTIONS in @m4l-jweb/surface: the menu's value is its index. */
81
+ var CONTROLS_FOCUS_DEVICE = 0;
82
+ var CONTROLS_FOCUS_TRACK = 1;
83
+ var CONTROLS_FOCUS_ALWAYS = 2;
84
+
85
+ /** This device and its track, for the focus comparison. */
86
+ var controlsThisDeviceId = 0;
87
+ var controlsThisTrackId = 0;
88
+ /** What Live currently has selected. */
89
+ var controlsSelectedTrackId = 0;
90
+ var controlsSelectedDeviceId = 0;
91
+ /** The focus observers, held so they stay alive. Dropping the reference kills the observer. */
92
+ var controlsTrackObs: LiveAPI | null = null;
93
+ var controlsDeviceObs: LiveAPI | null = null;
94
+ /** The deferred first frame, and the deferred rebuild of the device observer. */
95
+ var controlsFirstFrameTask: Task | null = null;
96
+ var controlsDeviceObsTask: Task | null = null;
97
+
98
+ /**
99
+ * The retry that exists because resolving on load is a RACE.
100
+ *
101
+ * A device loads when Live loads the set, and Live's own Push script initialises on its
102
+ * own schedule. Ask too early and `control_surfaces` is empty, or the surface is there and
103
+ * `get_control` answers 0 for every name - so every role comes back unresolved and the
104
+ * device never grabs anything. Re-adding the device by hand fixes it, because the second
105
+ * load happens after Push is ready. That is exactly the shape of the bug reported against
106
+ * this: intermittent, unrelated to any other device, and cured by a re-drag.
107
+ *
108
+ * The `control_surfaces` observer does not cover it. It fires on CHANGE, and a Push that
109
+ * was plugged in all along never changes - it just was not ready yet.
110
+ *
111
+ * So: if nothing resolved, ask again, a few times, and stop as soon as something does.
112
+ */
113
+ var controlsRetryTask: Task | null = null;
114
+ var controlsRetriesLeft = 0;
115
+ var CONTROLS_RETRY_MS = 1000;
116
+ var CONTROLS_RETRIES = 12;
117
+
118
+ /** Is this build a device that declared controls at all? */
119
+ function controlsDeclared(): boolean {
120
+ return typeof CONTROLS_SPEC !== "undefined" && !!CONTROLS_SPEC && !!CONTROLS_SPEC.controls.length;
121
+ }
122
+
123
+ /**
124
+ * Attach everything. Called from bang(), never loadbang - a LiveAPI object created
125
+ * in a patcher-loading context is DEAD, and recreating unconditionally (no
126
+ * `if (obs) return` guard) is what stops that bug being permanent.
127
+ */
128
+ function setupControls(): void {
129
+ if (!controlsDeclared()) return;
130
+
131
+ // Nothing an ATTACHING observer fires may act. Every LiveAPI observer here calls
132
+ // back once, immediately, while it is being created - so `control_surfaces` was
133
+ // resolving the roles before this function had worked out which track the device is
134
+ // on, and announcing "not_focused - this track 0 vs selected 0" on the way past. The
135
+ // callbacks still RECORD what they are handed; they just do not decide anything
136
+ // until the picture is whole.
137
+ controlsStarted = false;
138
+
139
+ controlsObjects = {};
140
+ controlsIds = {};
141
+ controlsNames = {};
142
+ controlsShown = {};
143
+ controlsHeld = false;
144
+ controlsSurface = null;
145
+ controlsSurfaceType = "";
146
+
147
+ /**
148
+ * Seed `focus` from the DECLARATION - but only if the PARAMETER has not already
149
+ * spoken.
150
+ *
151
+ * Live restores a saved parameter during patcher load, so `controls_focus` arrives
152
+ * before this runs, carrying what the user actually chose. Overwriting it with the
153
+ * declared default is how a set saved with `Always` comes back as `Track` - the
154
+ * device works, differently, and nothing says why. The seed is for the case the
155
+ * parameter never announces itself at all, which is the one it exists for.
156
+ */
157
+ if (!controlsFocusSeen) controlsFocus = CONTROLS_SPEC!.focus;
158
+
159
+ // The device's own identity FIRST, before anything can observe and ask about it.
160
+ controlsIdentify();
161
+ controlsWatchSurfaces();
162
+ controlsWatchFocus();
163
+ controlsResolve();
164
+
165
+ // One line saying what was actually found, every load. Everything downstream of
166
+ // here is silent when it fails - Live refuses a grab by posting and returning
167
+ // normally - so this is the only place a person can check that the surface was
168
+ // seen at all, and that this instance knows which track it is on.
169
+ post(
170
+ "m4l-jweb: controls on " +
171
+ (controlsSurfaceType || "NO SURFACE") +
172
+ ", track " +
173
+ controlsThisTrackId +
174
+ ", device " +
175
+ controlsThisDeviceId +
176
+ ", focus " +
177
+ controlsFocus +
178
+ "\n",
179
+ );
180
+
181
+ // Announce unconditionally on setup: `controlsApply` only speaks when the reason
182
+ // CHANGES, and after a reload the reason is usually the same one as before.
183
+ controlsStarted = true;
184
+ controlsReason = "";
185
+ controlsApply();
186
+
187
+ // ...and if the answer was "nothing is there", it may simply be too early. See
188
+ // controlsRetryTask.
189
+ controlsScheduleRetry(CONTROLS_RETRIES);
190
+ }
191
+
192
+ /**
193
+ * Re-send what a freshly loaded page could not have heard.
194
+ *
195
+ * Called from ui_ready(). The page loads asynchronously and long after bang()
196
+ * resolved the roles, so every one of these messages was sent to nobody.
197
+ */
198
+ function resendControls(): void {
199
+ if (!controlsDeclared()) return;
200
+ var specs = CONTROLS_SPEC!.controls;
201
+ for (var i = 0; i < specs.length; i++) {
202
+ outlet(0, "controls_role", specs[i].key, controlsIds[specs[i].key] ? 1 : 0);
203
+ }
204
+ outlet(0, "controls_held", controlsHeld ? 1 : 0, controlsReason || controlsHoldReason());
205
+ }
206
+
207
+ /**
208
+ * Watch for a Push being plugged in or unplugged mid-set.
209
+ *
210
+ * `control_surfaces` is a flat list of `id N` pairs WITH EMPTY SLOTS (`id 0`) for
211
+ * every unconfigured control-surface slot in Live's preferences, so most of it is
212
+ * nothing. The callback only re-resolves; it does no LOM work of its own, because a
213
+ * notification is not a safe place to change the set.
214
+ */
215
+ function controlsWatchSurfaces(): void {
216
+ controlsApp = null;
217
+ try {
218
+ controlsApp = new LiveAPI(function (a: unknown[]) {
219
+ // The attach callback is not news - setupControls resolves right after this.
220
+ if (!controlsStarted) return;
221
+ if (a && a[0] == "control_surfaces") {
222
+ post("m4l-jweb: control surfaces changed - re-resolving\n");
223
+ controlsResolve();
224
+ controlsApply();
225
+ // A Push just arrived, or just left. Either way the retry's question is answered.
226
+ controlsScheduleRetry(CONTROLS_RETRIES);
227
+ }
228
+ }, "live_app");
229
+ controlsApp.property = "control_surfaces";
230
+ } catch (e) {
231
+ post("m4l-jweb: cannot observe control_surfaces - " + (e as Error).message + "\n");
232
+ }
233
+ }
234
+
235
+ /** The first Push or Move in Live's control-surface slots. */
236
+ function controlsFindSurface(): void {
237
+ controlsSurface = null;
238
+ controlsSurfaceType = "";
239
+ var app = new LiveAPI("live_app");
240
+ if (!app.id) return;
241
+ var flat = controlsAtoms(app.get("control_surfaces"));
242
+ for (var i = 0; i < flat.length; i++) {
243
+ if (String(flat[i]) === "id") continue; // the list alternates "id" and the number
244
+ var id = Number(flat[i]);
245
+ if (!id) continue; // an empty preferences slot
246
+ var cs = new LiveAPI("id " + id);
247
+ if (!cs.id) continue;
248
+ var type = String(cs.type);
249
+ for (var t = 0; t < CONTROLS_SURFACE_TYPES.length; t++) {
250
+ if (type === CONTROLS_SURFACE_TYPES[t]) {
251
+ controlsSurface = cs;
252
+ controlsSurfaceType = type;
253
+ return;
254
+ }
255
+ }
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Resolve every declared role against the hardware that is actually plugged in.
261
+ *
262
+ * A Push 3 answers `get_control_names` with 176 names and they are NOT the Push 2
263
+ * set, so a role is a CANDIDATE LIST and the first candidate the hardware admits to
264
+ * having is the one used. Checking the list first is what turns "Live rejects the
265
+ * call and says nothing" into a `controls_role <key> 0` the page can show: a name
266
+ * that is not there is never called.
267
+ */
268
+ function controlsResolve(): void {
269
+ var specs = CONTROLS_SPEC!.controls;
270
+ controlsFindSurface();
271
+
272
+ if (!controlsSurface) {
273
+ for (var n = 0; n < specs.length; n++) {
274
+ controlsObjects[specs[n].key] = null;
275
+ controlsIds[specs[n].key] = 0;
276
+ outlet(0, "controls_role", specs[n].key, 0);
277
+ outlet(1, "tk_" + specs[n].key, "id", 0); // id 0 = no object; the observer goes quiet
278
+ }
279
+ post("m4l-jweb: no Push or Move connected - the declared controls resolve to nothing\n");
280
+ return;
281
+ }
282
+
283
+ var available = controlsControlNames();
284
+ for (var i = 0; i < specs.length; i++) {
285
+ var spec = specs[i];
286
+ var name = "";
287
+ for (var c = 0; c < spec.names.length; c++) {
288
+ if (controlsHasName(available, spec.names[c])) {
289
+ name = spec.names[c];
290
+ break;
291
+ }
292
+ }
293
+
294
+ var id = 0;
295
+ if (name) id = controlsIdOf(controlsSurface.call("get_control", name));
296
+
297
+ controlsNames[spec.key] = name;
298
+ controlsIds[spec.key] = id;
299
+ controlsObjects[spec.key] = id ? new LiveAPI("id " + id) : null;
300
+ // The observer lives in the PATCHER (the `takeover` chain), so a press reaches
301
+ // the page without passing through [js]. All it needs from here is the id, and
302
+ // `[route tk_<key>]` strips the selector before it reaches the right inlet.
303
+ outlet(1, "tk_" + spec.key, "id", id);
304
+ outlet(0, "controls_role", spec.key, id ? 1 : 0);
305
+
306
+ if (!id) {
307
+ post(
308
+ 'm4l-jweb: role "' +
309
+ spec.role +
310
+ '" (' +
311
+ spec.key +
312
+ ") is not on this " +
313
+ (controlsSurfaceType || "surface") +
314
+ " - tried " +
315
+ spec.names.join(", ") +
316
+ "\n",
317
+ );
318
+ }
319
+ }
320
+ }
321
+
322
+ /**
323
+ * The connected surface's control vocabulary, as a lowercase lookup.
324
+ *
325
+ * The reply is MAX-FORMATTED: the selector, the count, then `control <name>` pairs,
326
+ * then `done`. Nothing here depends on that layout - every atom goes in the set and
327
+ * a name is looked up by value, so `control` and `done` are simply names no role
328
+ * asks for.
329
+ */
330
+ function controlsControlNames(): { [name: string]: boolean } {
331
+ var out: { [name: string]: boolean } = {};
332
+ try {
333
+ var atoms = controlsAtoms(controlsSurface!.call("get_control_names"));
334
+ for (var i = 0; i < atoms.length; i++) out[String(atoms[i]).toLowerCase()] = true;
335
+ } catch (e) {
336
+ post("m4l-jweb: get_control_names failed - " + (e as Error).message + "\n");
337
+ }
338
+ return out;
339
+ }
340
+
341
+ function controlsHasName(available: { [name: string]: boolean }, name: string): boolean {
342
+ return available[String(name).toLowerCase()] === true;
343
+ }
344
+
345
+ /** A LOM reply as a plain array, whatever shape Max handed over. */
346
+ function controlsAtoms(v: unknown): unknown[] {
347
+ if (v === null || typeof v === "undefined") return [];
348
+ if (typeof v === "string") return [v];
349
+ var list = v as { length?: number; [i: number]: unknown };
350
+ if (typeof list.length !== "number") return [v];
351
+ var out: unknown[] = [];
352
+ for (var i = 0; i < list.length; i++) out.push(list[i]);
353
+ return out;
354
+ }
355
+
356
+ /** An id, however the LOM chose to hand it over: a number, "id 5", or ["id", 5]. */
357
+ function controlsIdOf(v: unknown): number {
358
+ if (v === null || typeof v === "undefined") return 0;
359
+ if (typeof v === "number") return v;
360
+ if (typeof v === "string") {
361
+ var parts = v.split(" ");
362
+ return Number(parts[parts.length - 1]);
363
+ }
364
+ var list = v as { length?: number; [i: number]: unknown };
365
+ if (typeof list.length === "number" && list.length > 0) return Number(list[list.length - 1]);
366
+ return 0;
367
+ }
368
+
369
+ /* ------------------------------------------------------------------ *
370
+ * The focus policy
371
+ * ------------------------------------------------------------------ */
372
+
373
+ /**
374
+ * The id an OBSERVER callback carries, from the atoms after the property name.
375
+ *
376
+ * A property observer is handed `[<property>, ...value]`, and an OBJECT-valued
377
+ * property's value is the TWO atoms `id <n>` - so the callback is
378
+ * `["selected_track", "id", 5]` and `a[1]` is the symbol `id`, not a number.
379
+ *
380
+ * THAT IS THE BUG THIS EXISTS TO NAME. Reading `a[1]` gives `Number("id")` = NaN,
381
+ * which is not equal to anything, including itself - so the focus test could never
382
+ * be true, `takeover` looked switched on and nothing was ever grabbed, and there was
383
+ * no error anywhere because NaN is a perfectly ordinary number to compare. Take the
384
+ * LAST atom, which is the id in both the `id <n>` and the bare-number shapes.
385
+ */
386
+ function controlsIdFromCallback(a: unknown[]): number {
387
+ if (!a || a.length < 2) return 0;
388
+ return controlsIdOf(a[a.length - 1]);
389
+ }
390
+
391
+ /**
392
+ * Observe what Live has selected, so `focus` can decide whether THIS instance holds
393
+ * the hardware.
394
+ *
395
+ * Two of these devices in one set is the normal case, not the edge, and `Always`
396
+ * means the last one loaded wins the grid forever - which reads as the first one
397
+ * being broken. The selected TRACK is one observer; the selected DEVICE is an
398
+ * observer on the selected track's view, so it has to be rebuilt whenever the track
399
+ * changes.
400
+ */
401
+ function controlsIdentify(): void {
402
+ var me = new LiveAPI("this_device");
403
+ controlsThisDeviceId = me.id ? Number(me.id) : 0;
404
+ // ownTrack() (liveapi.ts) CLIMBS to the Track. `this_device canonical_parent` is
405
+ // the track only when the device sits directly on one; inside a Rack it is the
406
+ // CHAIN, whose id equals no selected_track ever - so a device in a rack would never
407
+ // hold the grid under Track focus, silently.
408
+ var track = ownTrack();
409
+ controlsThisTrackId = track && track.id ? Number(track.id) : 0;
410
+ }
411
+
412
+ function controlsWatchFocus(): void {
413
+ controlsTrackObs = null;
414
+ try {
415
+ controlsTrackObs = new LiveAPI(function (a: unknown[]) {
416
+ if (!a || a[0] != "selected_track") return;
417
+ controlsSelectedTrackId = controlsIdFromCallback(a);
418
+ // Rebuilding an observer from inside a notification is not something to do in
419
+ // the notification's own turn - the Live API is explicit that the set must not
420
+ // be modified there. A Task puts it in the next one.
421
+ if (controlsDeviceObsTask) controlsDeviceObsTask.cancel();
422
+ controlsDeviceObsTask = new Task(controlsWatchSelectedDevice, this);
423
+ controlsDeviceObsTask.schedule(0);
424
+ // The id above is RECORDED either way - it is the value this observer exists
425
+ // for, and the attach callback is the only place the current one arrives.
426
+ if (controlsStarted) controlsApply();
427
+ }, "live_set view");
428
+ controlsTrackObs.property = "selected_track";
429
+ } catch (e) {
430
+ post("m4l-jweb: cannot observe the selected track - " + (e as Error).message + "\n");
431
+ }
432
+
433
+ controlsWatchSelectedDevice();
434
+ }
435
+
436
+ /** ...and the selected DEVICE, on whichever track is selected now. */
437
+ function controlsWatchSelectedDevice(): void {
438
+ controlsDeviceObs = null;
439
+ try {
440
+ controlsDeviceObs = new LiveAPI(function (a: unknown[]) {
441
+ if (!a || a[0] != "selected_device") return;
442
+ controlsSelectedDeviceId = controlsIdFromCallback(a);
443
+ if (controlsStarted) controlsApply();
444
+ }, "live_set view selected_track view");
445
+ controlsDeviceObs.property = "selected_device";
446
+ } catch (e) {
447
+ // A device-focused takeover then behaves like a track-focused one. Say so: the
448
+ // alternative is a device that never grabs and never explains why.
449
+ post('m4l-jweb: cannot observe the selected device - focus "Device" will behave like "Track" - ' + (e as Error).message + "\n");
450
+ }
451
+ }
452
+
453
+ /**
454
+ * Should this instance be holding the hardware - and if not, WHY NOT?
455
+ *
456
+ * It answers a reason rather than a boolean because "the pads did not light up" has
457
+ * four completely different causes that look identical on a dark Push, and a rejected
458
+ * LiveAPI call reports nothing at all. `held` means take it; anything else is the
459
+ * reason, and it goes to the Max console and to the device view.
460
+ *
461
+ * off `takeover` is off. The default, and the commonest answer.
462
+ * no_surface no Push or Move in Live's control-surface slots.
463
+ * unresolved a surface is there, but no declared role resolved on it.
464
+ * not_focused `focus` says another track or device has it.
465
+ */
466
+ function controlsHoldReason(): string {
467
+ if (!controlsEnabled) return "off";
468
+ if (!controlsSurface) return "no_surface";
469
+ if (!controlsAnyResolved()) return "unresolved";
470
+ if (controlsFocus === CONTROLS_FOCUS_ALWAYS) return "held";
471
+
472
+ var byTrack = controlsThisTrackId !== 0 && controlsThisTrackId === controlsSelectedTrackId;
473
+ if (controlsFocus === CONTROLS_FOCUS_DEVICE) {
474
+ // With no reachable selected-device observer this degenerates to the track test,
475
+ // which is the conservative answer: it can be too generous, never too silent.
476
+ if (!controlsDeviceObs) return byTrack ? "held" : "not_focused";
477
+ return controlsThisDeviceId !== 0 && controlsThisDeviceId === controlsSelectedDeviceId ? "held" : "not_focused";
478
+ }
479
+ return byTrack ? "held" : "not_focused";
480
+ }
481
+
482
+ /**
483
+ * Ask again in a second, up to `tries` times, unless something has already resolved.
484
+ *
485
+ * Cheap: `get_control_names` on a surface that is not ready costs a rejected call and a
486
+ * console line, and twelve of them over twelve seconds is nothing next to a device that
487
+ * silently never works until the user re-drags it.
488
+ */
489
+ function controlsScheduleRetry(tries: number): void {
490
+ if (controlsRetryTask) controlsRetryTask.cancel();
491
+ controlsRetryTask = null;
492
+ if (tries <= 0) return;
493
+ if (controlsAnyResolved()) return;
494
+
495
+ controlsRetriesLeft = tries;
496
+ controlsRetryTask = new Task(controlsRetry, this);
497
+ controlsRetryTask.interval = CONTROLS_RETRY_MS;
498
+ controlsRetryTask.repeat(tries);
499
+ }
500
+
501
+ function controlsRetry(): void {
502
+ controlsRetriesLeft--;
503
+ if (controlsAnyResolved()) {
504
+ if (controlsRetryTask) controlsRetryTask.cancel();
505
+ controlsRetryTask = null;
506
+ return;
507
+ }
508
+ controlsResolve();
509
+ controlsApply();
510
+ if (controlsAnyResolved()) {
511
+ post("m4l-jweb: controls resolved on retry - Live's surface was not ready at device load\n");
512
+ if (controlsRetryTask) controlsRetryTask.cancel();
513
+ controlsRetryTask = null;
514
+ return;
515
+ }
516
+ if (controlsRetriesLeft <= 0) {
517
+ post(
518
+ "m4l-jweb: controls still unresolved after " +
519
+ CONTROLS_RETRIES +
520
+ " tries - plug a Push in, or delete and re-drag the device\n",
521
+ );
522
+ }
523
+ }
524
+
525
+ /** Did ANY declared role resolve? A surface with none of them is not a surface we can use. */
526
+ function controlsAnyResolved(): boolean {
527
+ var specs = CONTROLS_SPEC!.controls;
528
+ for (var i = 0; i < specs.length; i++) if (controlsIds[specs[i].key]) return true;
529
+ return false;
530
+ }
531
+
532
+ /* ------------------------------------------------------------------ *
533
+ * The grab
534
+ * ------------------------------------------------------------------ */
535
+
536
+ /** The last reason announced, so a re-check that changed nothing says nothing. */
537
+ var controlsReason = "";
538
+
539
+ /** Take or give back the declared controls, so that reality matches the policy. */
540
+ function controlsApply(): void {
541
+ if (!controlsDeclared() || !controlsStarted) return;
542
+ var reason = controlsHoldReason();
543
+ var want = reason === "held";
544
+ if (want !== controlsHeld) {
545
+ if (want) controlsGrab();
546
+ else controlsRelease();
547
+ }
548
+ if (reason === controlsReason) return;
549
+ controlsReason = reason;
550
+ // The one line that answers "I turned Takeovr on and nothing happened". There is no
551
+ // other channel: Live refuses a grab by posting and returning normally, so this is
552
+ // the wrapper saying what it DECIDED, which is the half it does know.
553
+ post("m4l-jweb: controls " + (want ? "HELD" : "not held") + " (" + reason + ")" + controlsFocusDetail(reason) + "\n");
554
+ outlet(0, "controls_held", want ? 1 : 0, reason);
555
+ }
556
+
557
+ /** The ids behind a `not_focused`, because that is the one whose cause is invisible. */
558
+ function controlsFocusDetail(reason: string): string {
559
+ if (reason !== "not_focused") return "";
560
+ return (
561
+ " - this track " +
562
+ controlsThisTrackId +
563
+ " vs selected " +
564
+ controlsSelectedTrackId +
565
+ ", this device " +
566
+ controlsThisDeviceId +
567
+ " vs selected " +
568
+ controlsSelectedDeviceId
569
+ );
570
+ }
571
+
572
+ function controlsGrab(): void {
573
+ var specs = CONTROLS_SPEC!.controls;
574
+ for (var i = 0; i < specs.length; i++) {
575
+ var name = controlsNames[specs[i].key];
576
+ if (!name || !controlsIds[specs[i].key]) continue;
577
+ // BY NAME, on the SURFACE. A bare id is rejected and the two-atom `id <n>` form
578
+ // buys nothing over this. There is no return value worth reading: a rejected
579
+ // call posts to the Max console and returns normally.
580
+ controlsSurface!.call("grab_control", name);
581
+ // What is lit is now unknown - Live repaints the matrix as it hands it over.
582
+ controlsShown[specs[i].key] = null;
583
+ }
584
+ controlsHeld = true;
585
+ // The announcement is controlsApply's, not this function's: it is the one that
586
+ // knows WHY, and a device view that only ever heard "held 0" could not tell the
587
+ // four reasons apart.
588
+
589
+ // THE FIRST FRAME AFTER A GRAB IS LOST, measured. Deferred, not handshaked: there
590
+ // is nothing to wait on. A device that painted once here and then only on change
591
+ // would come up blank and stay blank.
592
+ if (controlsFirstFrameTask) controlsFirstFrameTask.cancel();
593
+ controlsFirstFrameTask = new Task(controlsPaintAll, this);
594
+ controlsFirstFrameTask.schedule(CONTROLS_FIRST_FRAME_MS);
595
+ }
596
+
597
+ function controlsRelease(): void {
598
+ if (controlsFirstFrameTask) controlsFirstFrameTask.cancel();
599
+ var specs = CONTROLS_SPEC!.controls;
600
+ for (var i = 0; i < specs.length; i++) {
601
+ var name = controlsNames[specs[i].key];
602
+ // The surface can be GONE by now - a Push unplugged mid-set re-resolves to null,
603
+ // and a release is exactly what we then want to do and cannot. Nothing is stranded
604
+ // by skipping it: a grab belongs to the device context, and Live drops it.
605
+ if (!name || !controlsIds[specs[i].key] || !controlsSurface) continue;
606
+ controlsSurface.call("release_control", name);
607
+ controlsShown[specs[i].key] = null;
608
+ }
609
+ controlsHeld = false;
610
+ }
611
+
612
+ /** The deferred first frame: everything the page has asked for, with nothing assumed lit. */
613
+ function controlsPaintAll(): void {
614
+ if (!controlsHeld) return;
615
+ var specs = CONTROLS_SPEC!.controls;
616
+ for (var i = 0; i < specs.length; i++) {
617
+ var key = specs[i].key;
618
+ controlsShown[key] = null;
619
+ controlsPaint(key, controlsWanted[key] || controlsBlank(specs[i]));
620
+ }
621
+ }
622
+
623
+ /** A frame of nothing, so a control the page has not painted yet is dark rather than whatever Live left. */
624
+ function controlsBlank(spec: { rows: number; cols: number }): number[] {
625
+ var cells: number[] = [];
626
+ for (var i = 0; i < spec.rows * spec.cols; i++) cells.push(0);
627
+ return cells;
628
+ }
629
+
630
+ /**
631
+ * THE FRAME DIFF, and the only place `send_value` is called.
632
+ *
633
+ * The page sends the WHOLE grid; this decides what the hardware is actually told.
634
+ * It is not the same diff the page does. The page's question is "did the device
635
+ * change its mind"; this one is "does the pad already show this" - and after a grab
636
+ * the answer is unknown for every cell, so `controlsShown` is null and everything
637
+ * goes out.
638
+ *
639
+ * The budget is not a constraint: 64 `send_value` calls measured at ~2.6 ms, flat
640
+ * across fifty consecutive full-grid frames, with no stutter in Live's UI. The diff
641
+ * is here for the messages it does not send, not because the hardware could not keep
642
+ * up.
643
+ */
644
+ function controlsPaint(key: string, cells: number[]): void {
645
+ var obj = controlsObjects[key];
646
+ if (!obj || !controlsHeld) return;
647
+ var spec = controlsSpecFor(key);
648
+ if (!spec) return;
649
+
650
+ var shown = controlsShown[key];
651
+ var next: number[] = [];
652
+ for (var i = 0; i < cells.length; i++) {
653
+ var colour = Number(cells[i]) || 0;
654
+ next.push(colour);
655
+ if (shown && shown[i] === colour) continue;
656
+ // Hardware coordinates throughout: row 0 is the TOP, which is the order the page
657
+ // packed the frame in. Nothing in this file flips y.
658
+ obj.call("send_value", i % spec.cols, Math.floor(i / spec.cols), colour);
659
+ }
660
+ controlsShown[key] = next;
661
+ }
662
+
663
+ function controlsSpecFor(key: string): { key: string; rows: number; cols: number } | null {
664
+ var specs = CONTROLS_SPEC!.controls;
665
+ for (var i = 0; i < specs.length; i++) if (specs[i].key === key) return specs[i];
666
+ return null;
667
+ }
668
+
669
+ /* ------------------------------------------------------------------ *
670
+ * The message handlers
671
+ * ------------------------------------------------------------------ */
672
+
673
+ /**
674
+ * The `takeover` parameter, tapped straight off the `live.toggle` by the chain.
675
+ *
676
+ * It does NOT come from the page, deliberately: a device whose grid dies because its
677
+ * Chromium view was closed would fail exactly when the user is looking at the Push
678
+ * instead of the screen.
679
+ */
680
+ function controls_takeover(v: unknown): void {
681
+ controlsEnabled = Number(v) >= 0.5;
682
+ controlsApply();
683
+ }
684
+
685
+ /** The `focus` menu, as its index. See FOCUS_OPTIONS in @m4l-jweb/surface. */
686
+ function controls_focus(v: unknown): void {
687
+ controlsFocus = Math.round(Number(v));
688
+ controlsFocusSeen = true;
689
+ controlsApply();
690
+ }
691
+
692
+ /**
693
+ * `controls_frame <key> <c0> <c1> ...` - the whole grid, as palette indices, in
694
+ * hardware order.
695
+ *
696
+ * Variadic, so it reads `arguments` rather than a fixed signature. It is stored even
697
+ * when nothing is held: that is what makes the deferred first frame after a grab
698
+ * show the picture the device already wanted rather than a blank grid.
699
+ */
700
+ function controls_frame(): void {
701
+ if (!controlsDeclared()) return;
702
+ var args = arrayfromargs(arguments);
703
+ if (!args.length) return;
704
+ var key = String(args[0]);
705
+ var cells: number[] = [];
706
+ for (var i = 1; i < args.length; i++) cells.push(Number(args[i]) || 0);
707
+ controlsWanted[key] = cells;
708
+ controlsPaint(key, cells);
709
+ }
710
+
711
+ /** `controls_refresh` - forget what we believe is lit and repaint everything. */
712
+ function controls_refresh(): void {
713
+ if (!controlsDeclared()) return;
714
+ controlsPaintAll();
715
+ }
package/src/core.ts CHANGED
@@ -51,10 +51,17 @@ function bang(): void {
51
51
  setupTempoObserver(); // liveapi.ts
52
52
  startTickPoll(); // liveapi.ts
53
53
  setupWatches(); // watch.ts - the device's declared defineWatch() observers
54
+ setupControls(); // controls.ts - the device's declared defineControls() takeover
54
55
  followWindowSizes(); // a resized window resizes its page
55
56
  // A device's own wrapper/device.ts hooks in here: this is the ONLY safe place
56
57
  // to create LiveAPI objects (see the loadbang trap above).
57
58
  if (typeof onDeviceReady === "function") onDeviceReady();
59
+ // ...and a HEADLESS device's own logic hooks in HERE, under a name of its own.
60
+ // `wrapper/device.ts` is repo-wide and already owns `onDeviceReady`; everything is
61
+ // concatenated into one [js] script, so a headless device defining the same function
62
+ // would not extend that hook, it would REPLACE it - silently, and for every device in
63
+ // the repo that shares the script. Two hooks, two names, no collision.
64
+ if (typeof onHeadlessReady === "function") onHeadlessReady();
58
65
  }
59
66
 
60
67
  /** Patcher loaded. File work is safe here; LiveAPI is NOT. */
@@ -71,6 +78,7 @@ function reload(): void {
71
78
  setupTempoObserver();
72
79
  startTickPoll();
73
80
  setupWatches(); // watch.ts
81
+ setupControls(); // controls.ts
74
82
  }
75
83
 
76
84
  /**
@@ -126,11 +134,33 @@ function ui_ready(): void {
126
134
  resendWatches(); // watch.ts - the current value of every declared watch, for a late page
127
135
  sendDeviceFolder(); // where this device's files land, for a device that declares any
128
136
  sendTrackKind(); // liveapi.ts - audio | midi | none, which decides what clips it can make
137
+ resendControls(); // controls.ts - which declared roles resolved, and do we hold them
129
138
  // The device resends its own state here. The page loads asynchronously, so
130
139
  // anything sent before it was listening is simply gone.
131
140
  if (typeof onUiReady === "function") onUiReady();
132
141
  }
133
142
 
143
+ /**
144
+ * `open_url <url>` - hand a URL to the user's default web browser.
145
+ *
146
+ * `; max launchbrowser <url>` is the only door Max offers, and it is measured to reach the
147
+ * shell (doc/MAX-FACTS.md). What it cannot do is REVEAL A FOLDER - the same message with a
148
+ * correct `file://` path opens nothing at all - so this is deliberately for `http` and
149
+ * `https`, which is what it does work for. Anything else is refused rather than sent into
150
+ * a mechanism known not to answer.
151
+ *
152
+ * `messnamed` is a Max HOST function: fixed arity only, never `.apply` (it crashes Live).
153
+ */
154
+ function open_url(url: string): void {
155
+ var target = String(url);
156
+ if (target.indexOf("http://") !== 0 && target.indexOf("https://") !== 0) {
157
+ post("m4l-jweb: open_url refused " + target + " - only http and https are opened, and launchbrowser cannot reveal a folder\n");
158
+ return;
159
+ }
160
+ post("m4l-jweb: opening " + target + "\n");
161
+ messnamed("max", "launchbrowser", target);
162
+ }
163
+
134
164
  /**
135
165
  * One page hands a message to another page's window.
136
166
  *
@@ -351,9 +381,7 @@ function param_label(id: string, ...rest: unknown[]): void {
351
381
  // The device view is told as well: its own controls should show what the code
352
382
  // called this, not the declared short name.
353
383
  outlet(0, "param_desc", id, label);
354
- post(
355
- "m4l-jweb: param_label " + id + " '" + before + "' -> '" + after + "'" + (String(after) === label ? "" : " (did NOT take)") + "\n",
356
- );
384
+ post("m4l-jweb: param_label " + id + " '" + before + "' -> '" + after + "'" + (String(after) === label ? "" : " (did NOT take)") + "\n");
357
385
  }
358
386
 
359
387
  function param_unit(id: string, ...rest: unknown[]): void {
@@ -544,7 +572,23 @@ function window(id: string): void {
544
572
  * real file next to the .amxd on first load and point jweb at that file:// URL.
545
573
  * ------------------------------------------------------------------ */
546
574
 
575
+ /**
576
+ * Is this a HEADLESS device - no [jweb], no page, no payload?
577
+ *
578
+ * The build injects `HEADLESS` for a device whose manifest says `target: "headless"`.
579
+ * Everything else about the wrapper is unchanged; what changes is that there is
580
+ * nothing on outlet 0 that wants a URL, and nothing on disk to extract for it.
581
+ */
582
+ function headless(): boolean {
583
+ return typeof HEADLESS !== "undefined" && !!HEADLESS;
584
+ }
585
+
547
586
  function loadWebview(): void {
587
+ // Nothing to point at a page that is not in the patcher. Outlet 0 still carries the
588
+ // app's messages under this target - it is [js] talking to the routes the chains and
589
+ // the Surface put there - so sending a `url` down it would be a message the device's
590
+ // own logic has to learn to ignore, for a browser it does not have.
591
+ if (headless()) return;
548
592
  try {
549
593
  var url = resolveUiUrl();
550
594
  if (!url) return;
@@ -592,7 +636,10 @@ function loadWindows(): void {
592
636
  // is invisible without saying it: nothing else in Max reports a page that
593
637
  // did not load.
594
638
  post(
595
- "m4l-jweb: window '" + siteId + "' is missing its sidecar folder - expected " + target +
639
+ "m4l-jweb: window '" +
640
+ siteId +
641
+ "' is missing its sidecar folder - expected " +
642
+ target +
596
643
  ". Install the whole '<device>-site' folder NEXT TO the .amxd.\n",
597
644
  );
598
645
  }
@@ -645,6 +692,7 @@ var resizeTask: Task | null = null;
645
692
  var appliedSize: { [id: string]: string } = {};
646
693
 
647
694
  function followWindowSizes(): void {
695
+ if (headless()) return; // no windows without a browser to put one in
648
696
  var ids = listWindowIds();
649
697
  if (!ids.length) return;
650
698
  if (resizeTask) resizeTask.cancel();
package/src/max.d.ts CHANGED
@@ -211,6 +211,44 @@ declare const WATCH_SPECS: { key: string; path: string; property: string }[] | u
211
211
  */
212
212
  declare const FILES_SPEC: { saves: boolean; fetches: boolean; tellPage: boolean } | undefined;
213
213
 
214
+ /**
215
+ * Injected by @m4l-jweb/build for a device whose manifest says `target: "headless"`.
216
+ *
217
+ * There is no [jweb] in the patcher, no HTML payload in the .amxd and no page to hand
218
+ * a URL to: the device's own logic is this script, appended after the wrapper from
219
+ * `src/app/<device>/headless.ts`. Undefined for every other device.
220
+ */
221
+ declare const HEADLESS: number | undefined;
222
+
223
+ /**
224
+ * A HEADLESS device's own entry point, from `src/app/<device>/headless.ts`.
225
+ *
226
+ * Called from `bang()` - live.thisdevice, the one moment a LiveAPI object is not born
227
+ * dead. It is a separate name from `onDeviceReady` on purpose: that one belongs to the
228
+ * repo-wide `wrapper/device.ts`, everything is concatenated into ONE [js] script, and
229
+ * a second definition of a function does not extend it, it replaces it.
230
+ */
231
+ declare function onHeadlessReady(): void;
232
+
233
+ /**
234
+ * Injected by @m4l-jweb/build from the device's `defineControls()`: what this
235
+ * device claims on the control surface.
236
+ *
237
+ * `names` is the CANDIDATE list for the role, in order - a Push 3 answers
238
+ * `get_control_names` with 176 names and they are not the Push 2 set, so the
239
+ * wrapper resolves against that answer rather than calling a name it hopes exists.
240
+ * Undefined for a device that declares no controls, which is also a device with no
241
+ * `takeover` chain and therefore no observers.
242
+ */
243
+ declare const CONTROLS_SPEC:
244
+ | {
245
+ surface: string;
246
+ /** The declared `focus` default, as the menu index Max stores it as. */
247
+ focus: number;
248
+ controls: { key: string; kind: string; role: string; rows: number; cols: number; names: string[] }[];
249
+ }
250
+ | undefined;
251
+
214
252
  /**
215
253
  * Injected by @m4l-jweb/build for every window declared with `site:` - window id
216
254
  * -> the path of its index.html RELATIVE to the device folder.