@m4l-jweb/wrapper 1.0.0 → 1.2.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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/core.ts +279 -3
  3. package/src/max.d.ts +33 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m4l-jweb/wrapper",
3
- "version": "1.0.0",
3
+ "version": "1.2.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/src/core.ts CHANGED
@@ -51,6 +51,7 @@ 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
+ followWindowSizes(); // a resized window resizes its page
54
55
  // A device's own wrapper/device.ts hooks in here: this is the ONLY safe place
55
56
  // to create LiveAPI objects (see the loadbang trap above).
56
57
  if (typeof onDeviceReady === "function") onDeviceReady();
@@ -65,6 +66,7 @@ function loadbang(): void {
65
66
 
66
67
  /** Manual re-init, handy while developing. */
67
68
  function reload(): void {
69
+ sentWindowUrls = {}; // a manual reload MEANS re-fetch, dedupe or not
68
70
  loadWebview();
69
71
  setupTempoObserver();
70
72
  startTickPoll();
@@ -127,6 +129,38 @@ function ui_ready(): void {
127
129
  if (typeof onUiReady === "function") onUiReady();
128
130
  }
129
131
 
132
+ /**
133
+ * One page hands a message to another page's window.
134
+ *
135
+ * The two pages of a device are separate Chromium contexts and share nothing -
136
+ * no globals, no memory, no events. State slots already cross that gap, but a
137
+ * slot SAVES WITH THE SET, so it is the wrong home for anything continuous: a
138
+ * knob being turned would write the Live set sixty times a second.
139
+ *
140
+ * This is the other half - a message that crosses and is not remembered. The
141
+ * device view sends `window_send <winId> <selector> <value>` and the page in that
142
+ * window receives `<selector> <value>` on the inlet it bound, exactly as if the
143
+ * wrapper had sent it. One selector and one value, for the reason reply() gives.
144
+ */
145
+ function window_send(winId: string, selector: string, value: unknown): void {
146
+ messnamed("window-read-" + winId, selector, value);
147
+ }
148
+
149
+ /**
150
+ * How loud a sounding window is, on its way to the DEVICE VIEW's page.
151
+ *
152
+ * The patcher sends this, not a page: a `[peakamp~]` on the window's own outlets
153
+ * (see applyWindows). It exists because [jweb~] has no signal inlet - "Web browser
154
+ * with audio output" - so a page can never be handed audio, and a device view that
155
+ * wants to show what its window is playing has to be told in messages.
156
+ *
157
+ * Straight to outlet 0 and not through reply(): the destination is the device view
158
+ * by definition, whoever happens to be mid-dispatch.
159
+ */
160
+ function window_level(winId: string, left: number, right: number): void {
161
+ outlet(0, "level_" + winId, left, right);
162
+ }
163
+
130
164
  /* ------------------------------------------------------------------ *
131
165
  * State persistence
132
166
  *
@@ -254,6 +288,102 @@ function setNativeHidden(varname: string, hidden: number): void {
254
288
  }
255
289
  }
256
290
 
291
+ /* ------------------------------------------------------------------ *
292
+ * What a parameter IS, said at RUNTIME
293
+ *
294
+ * A surface declares a parameter's name, unit and range at build time, which is
295
+ * right whenever the device knows what its own controls do. It does not when the
296
+ * control is whatever the user's code just asked for - a pattern's `slider()`, a
297
+ * modulation slot pointed somewhere new - and an unnamed 0..1 dial then says
298
+ * nothing about the sound it is moving, on the panel or on Push.
299
+ *
300
+ * `describeParam()` in @m4l-jweb/bridge is the app side. What is measured, in Live:
301
+ *
302
+ * name reaches the DEVICE PANEL, and NOT Live's parameter registry or the Rack
303
+ * macro picker - a frozen device cannot rename a parameter there.
304
+ * unit takes: the unit STYLE, so a readout says "600 Hz" and not "600".
305
+ * range takes, AND CHANGES THE DOMAIN THE PARAMETER REPORTS. A page still
306
+ * normalizing 0..1 would then scale an already-scaled value and the
307
+ * control sticks at its minimum - which is exactly how the first attempt
308
+ * at this failed and was reverted. So it is ANSWERED, and the caller
309
+ * stops normalizing when the answer is yes.
310
+ * ------------------------------------------------------------------ */
311
+
312
+ /** Max's unit styles, in the order its own reference lists them. 9 = Custom. */
313
+ var UNITSTYLE: { [name: string]: number } = { int: 0, float: 1, ms: 2, Hz: 3, dB: 4, "%": 5, pan: 6, st: 7, midi: 8 };
314
+ var UNITSTYLE_CUSTOM = 9;
315
+
316
+ /** The live.* object a surface id compiles to. Mirrors applySurface's varname. */
317
+ function paramBox(id: string): Maxobj | null {
318
+ try {
319
+ var obj = this.patcher.getnamed("param-" + id);
320
+ if (!obj) post("m4l-jweb: param " + id + " -> getnamed('param-" + id + "') null\n");
321
+ return obj || null;
322
+ } catch (e) {
323
+ post("m4l-jweb: param " + id + " lookup error: " + (e as Error).message + "\n");
324
+ return null;
325
+ }
326
+ }
327
+
328
+ /** Write an attribute whichever way this Max build offers. */
329
+ function setAttr(obj: Maxobj, attr: string, a: unknown, b?: unknown): void {
330
+ if (typeof obj.setattr === "function") {
331
+ if (b === undefined) obj.setattr(attr, a);
332
+ else obj.setattr(attr, a, b);
333
+ } else {
334
+ if (b === undefined) obj.message(attr, a);
335
+ else obj.message(attr, a, b);
336
+ }
337
+ }
338
+
339
+ function param_label(id: string, ...rest: unknown[]): void {
340
+ // A name with a space in it arrives as several arguments; put it back together.
341
+ var label = Array.prototype.slice.call(arguments, 1).join(" ");
342
+ if (!id || !label) return;
343
+ var obj = paramBox(id);
344
+ if (!obj) return;
345
+ var before = obj.getattr("_parameter_shortname");
346
+ if (String(before) === label) return;
347
+ setAttr(obj, "_parameter_shortname", label);
348
+ var after = obj.getattr("_parameter_shortname");
349
+ // The device view is told as well: its own controls should show what the code
350
+ // called this, not the declared short name.
351
+ outlet(0, "param_desc", id, label);
352
+ post(
353
+ "m4l-jweb: param_label " + id + " '" + before + "' -> '" + after + "'" + (String(after) === label ? "" : " (did NOT take)") + "\n",
354
+ );
355
+ }
356
+
357
+ function param_unit(id: string, ...rest: unknown[]): void {
358
+ var unit = Array.prototype.slice.call(arguments, 1).join(" ");
359
+ if (!id || !unit) return;
360
+ var obj = paramBox(id);
361
+ if (!obj) return;
362
+ var known = UNITSTYLE[unit];
363
+ var style = known === undefined ? UNITSTYLE_CUSTOM : known;
364
+ setAttr(obj, "_parameter_unitstyle", style);
365
+ // A unit Max does not know still prints, as a custom suffix.
366
+ if (style === UNITSTYLE_CUSTOM) setAttr(obj, "_parameter_units", unit);
367
+ var after = Number(obj.getattr("_parameter_unitstyle"));
368
+ post("m4l-jweb: param_unit " + id + " '" + unit + "' -> style " + after + (after === style ? "" : " (did NOT take)") + "\n");
369
+ }
370
+
371
+ function param_range(id: string, lo: number, hi: number): void {
372
+ var min = Number(lo);
373
+ var max = Number(hi);
374
+ if (!id || !(max > min)) return;
375
+ var obj = paramBox(id);
376
+ if (!obj) return;
377
+ setAttr(obj, "_parameter_range", min, max);
378
+ var after = obj.getattr("_parameter_range");
379
+ var took = !!after && Number(after[0]) === min && Number(after[1]) === max;
380
+ // WHO SCALES. The answer goes back to whoever asked (a window included), and the
381
+ // device view is told too, because its own controls have the same question.
382
+ reply(took ? "param_range_ok" : "param_range_failed", id);
383
+ outlet(0, "param_desc_range", id, min, max, took ? 1 : 0);
384
+ post("m4l-jweb: param_range " + id + " -> " + String(after) + (took ? "" : " (did NOT take)") + "\n");
385
+ }
386
+
257
387
  /* ------------------------------------------------------------------ *
258
388
  * Parameter LOM ids - what the `remote` chain binds to
259
389
  *
@@ -385,6 +515,16 @@ function window(id: string): void {
385
515
  if (selector === "ui_ready") ui_ready();
386
516
  else if (selector === "get_state") get_state(String(args[0]));
387
517
  else if (selector === "sync_state") (sync_state as any).apply(null, args);
518
+ // A window's page owns the code in devices like this one, so it is the page
519
+ // that knows what a parameter now IS. Same handlers, and param_range's answer
520
+ // rides replyWindow back to the window that asked.
521
+ // PLAIN CALLS, with the arguments already flattened. `.apply(null, ...)` would
522
+ // hand these functions a `this` with no patcher on it - at [js] global scope the
523
+ // global object IS the jsthis, and only a plain call inherits it. A spread would
524
+ // compile to the same .apply, so the label is joined here instead.
525
+ else if (selector === "param_label") param_label(String(args[0]), args.slice(1).join(" "));
526
+ else if (selector === "param_unit") param_unit(String(args[0]), args.slice(1).join(" "));
527
+ else if (selector === "param_range") param_range(String(args[0]), Number(args[1]), Number(args[2]));
388
528
  else if (typeof onWindowMessage === "function") (onWindowMessage as any).apply(null, [id, selector].concat(args as any[]));
389
529
  else post("m4l-jweb: window " + id + " sent unhandled '" + selector + "'\n");
390
530
  } finally {
@@ -430,9 +570,137 @@ function loadWindows(): void {
430
570
  var prefix = windowPrefix();
431
571
  for (var i = 0; i < ids.length; i++) {
432
572
  var winId = ids[i];
433
- var winUrl = encodeURI("file:///" + folder + "/" + prefix + winId + ".html") + "?v=" + encodeURIComponent(buildStamp());
434
- messnamed("window-read-" + winId, "url", winUrl);
435
- post("m4l-jweb: window " + winId + " -> " + winUrl + "\n");
573
+ // listWindowIds() includes the `site:` windows, because everything else that
574
+ // walks the windows (the state fan-out) must see them. They have no extracted
575
+ // payload though, so pointing one at `<device>_<id>.html` would load a file
576
+ // that is not there - and then the real url below would load the page a second
577
+ // time.
578
+ if (isSiteWindow(winId)) continue;
579
+ sendWindowUrl(winId, folder + "/" + prefix + winId + ".html");
580
+ }
581
+
582
+ // A `site:` window's page is not an extracted payload - it is a folder that
583
+ // shipped alongside the .amxd, so its URL is built from SITE_WINDOWS instead.
584
+ if (typeof SITE_WINDOWS !== "undefined") {
585
+ for (var siteId in SITE_WINDOWS) {
586
+ if (!SITE_WINDOWS.hasOwnProperty(siteId)) continue;
587
+ var target = folder + "/" + SITE_WINDOWS[siteId];
588
+ if (!fileExists(target)) {
589
+ // Not fatal - the device plays on, the window just opens empty - but it
590
+ // is invisible without saying it: nothing else in Max reports a page that
591
+ // did not load.
592
+ post(
593
+ "m4l-jweb: window '" + siteId + "' is missing its sidecar folder - expected " + target +
594
+ ". Install the whole '<device>-site' folder NEXT TO the .amxd.\n",
595
+ );
596
+ }
597
+ sendWindowUrl(siteId, target);
598
+ }
599
+ }
600
+ }
601
+
602
+ /**
603
+ * Point one window's [jweb] at a file, ONCE per url.
604
+ *
605
+ * loadbang and live.thisdevice both call loadWebview (they must: either can be
606
+ * the one that runs in a given load), so without this every page is fetched
607
+ * twice. For a small window that is waste; for a `site:` window it is tens of MB
608
+ * and a visible second load.
609
+ */
610
+ function sendWindowUrl(winId: string, target: string): void {
611
+ var url = encodeURI("file:///" + target) + "?v=" + encodeURIComponent(buildStamp());
612
+ if (sentWindowUrls[winId] === url) return;
613
+ sentWindowUrls[winId] = url;
614
+ messnamed("window-read-" + winId, "url", url);
615
+ post("m4l-jweb: window " + winId + " -> " + url + "\n");
616
+ }
617
+
618
+ /** What each window was last pointed at, so a second load does not re-fetch it. */
619
+ var sentWindowUrls: { [id: string]: string } = {};
620
+
621
+ /* ------------------------------------------------------------------ *
622
+ * A window's page follows the window's size - SPIKE
623
+ *
624
+ * A floating window can be dragged bigger, and its [jweb] does not care: the box
625
+ * has the size the build gave it, so the page keeps its original width and the
626
+ * rest of the window is empty grey. For a reference card that is tolerable; for a
627
+ * window you WORK in (an editor, a whole REPL) it makes resizing pointless.
628
+ *
629
+ * Max has no resize NOTIFICATION - `window getsize` is a query, and [thispatcher]
630
+ * reports only what it is asked. So this polls, cheaply, and only while the window
631
+ * is actually open.
632
+ *
633
+ * WHY IT MAY NOT WORK, and what to watch. This repo has already measured that
634
+ * setting `presentation_rect` at runtime is accepted but NOT redrawn in a frozen
635
+ * M4L device (see setNativeHidden). That was the device view, which is Live's
636
+ * presentation; a floating subpatcher window is an ordinary patcher window, so the
637
+ * same call may well take here. It logs the first size it applies per window, so
638
+ * the Max console says whether it did.
639
+ * ------------------------------------------------------------------ */
640
+
641
+ var resizeTask: Task | null = null;
642
+ /** The last size applied per window, so the log is one line and not one per poll. */
643
+ var appliedSize: { [id: string]: string } = {};
644
+
645
+ function followWindowSizes(): void {
646
+ var ids = listWindowIds();
647
+ if (!ids.length) return;
648
+ if (resizeTask) resizeTask.cancel();
649
+ resizeTask = new Task(function () {
650
+ for (var i = 0; i < ids.length; i++) fitWindowPage(ids[i]);
651
+ }, this);
652
+ resizeTask.interval = 500;
653
+ resizeTask.repeat();
654
+ }
655
+
656
+ function fitWindowPage(winId: string): void {
657
+ try {
658
+ var box = this.patcher.getnamed("window-" + winId);
659
+ if (!box) return;
660
+ var sub = box.subpatcher();
661
+ if (!sub) return;
662
+ var wind = sub.wind;
663
+ // A closed window has nothing to fit, and reading a hidden one every half
664
+ // second for no reason is the kind of cost that only shows up in someone's CPU
665
+ // meter.
666
+ if (!wind || !wind.visible) return;
667
+
668
+ // Max reports [width, height] here, but be liberal: a 4-number rect would
669
+ // otherwise be applied as a nonsense size.
670
+ var size = wind.size;
671
+ if (!size || size.length < 2) return;
672
+ var w = size.length >= 4 ? size[2] - size[0] : size[0];
673
+ var h = size.length >= 4 ? size[3] - size[1] : size[1];
674
+ if (!(w > 0 && h > 0)) return;
675
+
676
+ var key = w + "x" + h;
677
+ if (appliedSize[winId] === key) return;
678
+ appliedSize[winId] = key;
679
+
680
+ var page = sub.getnamed("obj-jweb");
681
+ if (!page) return;
682
+ page.rect = [0, 0, w, h];
683
+ page.presentation_rect = [0, 0, w, h];
684
+ post("m4l-jweb: window " + winId + " page fitted to " + key + "\n");
685
+ } catch (e) {
686
+ post("m4l-jweb: window " + winId + " resize error: " + (e as Error).message + "\n");
687
+ }
688
+ }
689
+
690
+ /** Is this window's content a sidecar folder rather than an extracted payload? */
691
+ function isSiteWindow(winId: string): boolean {
692
+ return typeof SITE_WINDOWS !== "undefined" && SITE_WINDOWS.hasOwnProperty(winId);
693
+ }
694
+
695
+ /** Does a path exist on disk? Max's File opens what is there and nothing else. */
696
+ function fileExists(target: string): boolean {
697
+ try {
698
+ var f = new File(target, "read");
699
+ var open = f.isopen;
700
+ if (open) f.close();
701
+ return open;
702
+ } catch (e) {
703
+ return false;
436
704
  }
437
705
  }
438
706
 
@@ -449,6 +717,14 @@ function windowPrefix(): string {
449
717
  */
450
718
  function listWindowIds(): string[] {
451
719
  var ids: string[] = [];
720
+ // A `site:` window has no payload to be listed from, but it is a window like any
721
+ // other everywhere else - state fan-out included, which is how a page in one
722
+ // window learns what a page in another just wrote.
723
+ if (typeof SITE_WINDOWS !== "undefined") {
724
+ for (var siteId in SITE_WINDOWS) {
725
+ if (SITE_WINDOWS.hasOwnProperty(siteId)) ids.push(siteId);
726
+ }
727
+ }
452
728
  if (typeof EXTRA_PAYLOAD_NAMES === "undefined") return ids;
453
729
  var prefix = windowPrefix();
454
730
  for (var i = 0; i < EXTRA_PAYLOAD_NAMES.length; i++) {
package/src/max.d.ts CHANGED
@@ -40,6 +40,26 @@ declare let outlets: number;
40
40
  * disk. (Hence `noImplicitThis: false` in wrapper/tsconfig.json.)
41
41
  */
42
42
 
43
+ /**
44
+ * A box in the patcher, reached with `this.patcher.getnamed("<varname>")`.
45
+ *
46
+ * Only what the wrapper actually uses is declared. `setattr` is the documented
47
+ * writer and `message` the older form; both are present in the wild, so the
48
+ * wrapper tries one and falls back to the other.
49
+ */
50
+ declare interface Maxobj {
51
+ /** Hide or show in the device (presentation) view. Works at runtime; position does not. */
52
+ hidden: number;
53
+ /** Patching-canvas rect [left, top, right, bottom]. */
54
+ rect: number[];
55
+ /** Presentation rect. Accepted at runtime but never redrawn - see MAX-FACTS.md. */
56
+ presentation_rect: number[];
57
+ getattr(name: string): unknown;
58
+ setattr(name: string, ...values: unknown[]): void;
59
+ message(selector: string, ...args: unknown[]): void;
60
+ subpatcher(): { wind?: { visible: boolean; size: number[] }; getnamed(varname: string): Maxobj | null } | null;
61
+ }
62
+
43
63
  /** Max's scheduler. There is no `setTimeout` in [js]. */
44
64
  declare class Task {
45
65
  constructor(fn: () => void, ctx: unknown);
@@ -58,7 +78,7 @@ declare class Task {
58
78
  * something has to build that dict, and [js] is the only thing in the patcher
59
79
  * that can.
60
80
  *
61
- * VERIFIED in Live (doc/TODO.md - what the spikes found, 1.3): [js] built a maxurl request dict
81
+ * VERIFIED in Live (doc/MAX-FACTS.md): [js] built a maxurl request dict
62
82
  * and read the response dict back. `constructor`, `set`, `clear` and `stringify`
63
83
  * all behave as declared. `get`, `parse` and `freepeer` were not exercised.
64
84
  */
@@ -101,7 +121,7 @@ declare class File {
101
121
  * ASYNCHRONOUS: framecount() right after it still reads the old size. Come back
102
122
  * on a Task.
103
123
  *
104
- * VERIFIED in Live (doc/TODO.md - what the spikes found, 1.2): an empty buffer~ went to 124439
124
+ * VERIFIED in Live (doc/MAX-FACTS.md): an empty buffer~ went to 124439
105
125
  * frames, 1 channel, midsample -0.0319 after `send("replace", "jongly.aif")`.
106
126
  * `send`, `framecount`, `channelcount` and `peek` are all real and behave as
107
127
  * declared. `poke` is the one member here still taken on faith from the docs.
@@ -183,6 +203,17 @@ declare const EXTRA_PAYLOAD_B64: string[][] | undefined;
183
203
  */
184
204
  declare const WATCH_SPECS: { key: string; path: string; property: string }[] | undefined;
185
205
 
206
+ /**
207
+ * Injected by @m4l-jweb/build for every window declared with `site:` - window id
208
+ * -> the path of its index.html RELATIVE to the device folder.
209
+ *
210
+ * Such a window's content is a whole prebuilt site, far too big to ride inside
211
+ * wrapper.js as base64, so it ships as a folder next to the .amxd instead. That
212
+ * is the one case where a device is not self-contained: the folder must travel
213
+ * with the .amxd, and the wrapper says so if it does not.
214
+ */
215
+ declare const SITE_WINDOWS: { [id: string]: string } | undefined;
216
+
186
217
  /*
187
218
  * Device hooks.
188
219
  *