@m4l-jweb/wrapper 0.9.9 → 1.1.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/README.md ADDED
@@ -0,0 +1,41 @@
1
+ # @m4l-jweb/wrapper
2
+
3
+ The Max-side glue: the ES5 `[js]` script that owns a device's lifecycle, its LiveAPI work, transport polling, clip I/O and file writes. You do not usually import this - `@m4l-jweb/build` compiles it into every device it produces.
4
+
5
+ Part of **[m4l-jweb](https://github.com/alienmind/m4l-jweb)** - build Ableton Live devices (`.amxd`) from a TypeScript repo: React UI, LiveAPI glue, CI builds, no Max editor.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pnpm add @m4l-jweb/wrapper
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```ts
16
+ // Only a device repo extending the wrapper needs this: wrapper/device.ts is
17
+ // compiled together with the packaged sources into one ES5 script.
18
+ //
19
+ // Everything here must be ES5 - Max's [js] is not a modern JavaScript engine, and the
20
+ // build proves it with acorn before packaging.
21
+ ```
22
+
23
+ ## Notes
24
+
25
+ - `[js]` runs even inside a frozen device, and it is the only place LiveAPI exists - which is why the lifecycle lives here rather than in the browser.
26
+ - This package exists mainly so the build can find the wrapper sources (`@m4l-jweb/wrapper/sources`). It has no browser-facing API of its own.
27
+ - **`[node.script]` is never used.** It proved unstable in the field - silent non-start, then a full Live crash.
28
+
29
+ ## Requirements
30
+
31
+ Ableton Live 12 with Max 9. Devices are built on `[jweb~]`, the browser view with signal outlets; older hosts are unverified.
32
+
33
+ ## Links
34
+
35
+ - [Repository and full README](https://github.com/alienmind/m4l-jweb)
36
+ - [Architecture](https://github.com/alienmind/m4l-jweb/blob/main/doc/ARCHITECTURE.md)
37
+ - [What Max actually does: the measured facts](https://github.com/alienmind/m4l-jweb/blob/main/doc/MAX-FACTS.md)
38
+
39
+ ## License
40
+
41
+ MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m4l-jweb/wrapper",
3
- "version": "0.9.9",
3
+ "version": "1.1.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++) {
@@ -690,6 +966,28 @@ function partPath(destPath: string): string {
690
966
  return destPath + ".part";
691
967
  }
692
968
 
969
+ /**
970
+ * Where a SAVE lands before it has earned its destination - and deliberately NOT
971
+ * `<dest>.part`.
972
+ *
973
+ * `[js]` cannot delete a file, so a spent `.part` is truncated to zero bytes and left.
974
+ * For a download that is harmless: the destination is derived from the URL, so the same
975
+ * sample re-fetched reuses (and overwrites) the same scratch file. A SAVE has no such
976
+ * guarantee - an audio export names its file after the moment it was rendered, so every
977
+ * bounce would strand another 0-byte `superdough-export-<n>.wav.part` next to the real
978
+ * one, forever.
979
+ *
980
+ * One fixed scratch name per device folder instead: reused, overwritten, and never more
981
+ * than a single stray zero-byte file however many exports are made. Safe because saves
982
+ * are strictly one at a time (`activeSave`), and separate from the fetch scratch so a
983
+ * download in flight cannot collide with a save.
984
+ */
985
+ function savePartPath(destPath: string): string {
986
+ var cut = destPath.lastIndexOf("/");
987
+ var dir = cut < 0 ? "" : destPath.slice(0, cut + 1);
988
+ return dir + "m4l-jweb-save.part";
989
+ }
990
+
693
991
  interface ActiveFetch {
694
992
  requestId: string;
695
993
  url: string;
@@ -786,7 +1084,7 @@ function save_begin(requestId: string, destPath: string, byteCount: number): voi
786
1084
  if (activeSave && activeSave.file && activeSave.file.isopen) activeSave.file.close();
787
1085
 
788
1086
  var resolved = resolveFetchPath(destPath);
789
- var target = partPath(resolved);
1087
+ var target = savePartPath(resolved);
790
1088
  var f: File | null = null;
791
1089
  try {
792
1090
  f = new File(target, "write");
@@ -820,7 +1118,7 @@ function save_end(requestId: string): void {
820
1118
  var save = activeSave;
821
1119
  if (save.file && save.file.isopen) save.file.close();
822
1120
 
823
- var onDisk = fileSize(partPath(save.destPath));
1121
+ var onDisk = fileSize(savePartPath(save.destPath));
824
1122
  if (onDisk !== save.expect) {
825
1123
  outlet(0, "save_error", requestId, "size mismatch: wrote " + onDisk + " bytes, expected " + save.expect);
826
1124
  activeSave = null;
@@ -830,7 +1128,7 @@ function save_end(requestId: string): void {
830
1128
  // destPath must be a FLAT filename in the device folder - maxurl (libcurl) and Max's
831
1129
  // [js] File resolve a subdirectory differently, so `sub/x.wav` writes the .part where
832
1130
  // File agrees but maxurl cannot reach it, and the place returns -1. Keep saves flat.
833
- var placeUrl = encodeURI("file:///" + partPath(save.destPath));
1131
+ var placeUrl = encodeURI("file:///" + savePartPath(save.destPath));
834
1132
  post("m4l-jweb: save place " + placeUrl + " -> " + save.destPath + "\n");
835
1133
  var reqDict = new Dict();
836
1134
  reqDict.set("url", placeUrl);
@@ -851,7 +1149,7 @@ function finishSavePlace(): void {
851
1149
  activeSave = null;
852
1150
  return;
853
1151
  }
854
- truncate(partPath(save.destPath)); // [js] cannot delete; zero it
1152
+ truncate(savePartPath(save.destPath)); // [js] cannot delete; zero it, and reuse it
855
1153
  post("m4l-jweb: saved " + placed + " bytes to " + save.destPath + "\n");
856
1154
  outlet(0, "save_done", save.requestId, placed);
857
1155
  activeSave = null;
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
  *