@m4l-jweb/wrapper 0.4.0 → 0.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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/core.ts +407 -0
  3. package/src/max.d.ts +21 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m4l-jweb/wrapper",
3
- "version": "0.4.0",
3
+ "version": "0.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/src/core.ts CHANGED
@@ -91,6 +91,66 @@ function ui_ready(): void {
91
91
  if (typeof onUiReady === "function") onUiReady();
92
92
  }
93
93
 
94
+ /* ------------------------------------------------------------------ *
95
+ * State persistence
96
+ *
97
+ * A declared `state` slot is a named [dict] in the patcher with a [pattr] bound
98
+ * to it (see applyPersistence in surface.mjs). The pattr is what SAVES: Live
99
+ * stores a pattr's value in the set, and restores it before this script runs.
100
+ * The dict is where the app's JSON lives while the set is open.
101
+ *
102
+ * The app never touches the dict directly - it cannot; it is in the patcher. It
103
+ * asks for a slot (`get_state <id>`) and writes one (`sync_state <id> <json>`),
104
+ * and BOTH selectors carry the id as an ARGUMENT, not in the selector name.
105
+ * That matters: Max dispatches a message on its first word, so an app emitting
106
+ * `sync_state_config` looks for a `function sync_state_config()` that no device
107
+ * has, lands in anything(), and is silently swallowed. It did exactly that -
108
+ * every write was dropped, and the read path worked, so the state looked like it
109
+ * simply never persisted. The app side is @m4l-jweb/surface's stateStore().
110
+ * ------------------------------------------------------------------ */
111
+
112
+ /** The app asks for a slot. Reply on the inlet it binds: `state_<id> <json>`. */
113
+ function get_state(id: string): void {
114
+ try {
115
+ var d = new Dict("obj-state-" + id);
116
+ var json = d.stringify();
117
+ // Logged, because this is the ONLY moment that can tell you whether Live actually
118
+ // restored the slot: the app asks for it once the page is up, and what the dict
119
+ // hands back is what came out of the set. An empty "{}" here after a reopen means
120
+ // the [pattr] did not save - which is a failure with no other symptom.
121
+ post("m4l-jweb: get_state " + id + " -> " + json + "\n");
122
+ outlet(0, "state_" + id, json);
123
+ } catch (e) {
124
+ post("m4l-jweb: get_state error for " + id + " - " + (e as Error).message + "\n");
125
+ }
126
+ }
127
+
128
+ /**
129
+ * The app writes a slot: `sync_state <id> <json...>`.
130
+ *
131
+ * The JSON arrives SPLIT: Max parses a message into atoms on whitespace, so
132
+ * anything the app stringified with spaces in it reaches us as several
133
+ * arguments. Join them back before parsing.
134
+ */
135
+ function sync_state(id: string): void {
136
+ try {
137
+ var jsonParts: string[] = [];
138
+ for (var i = 1; i < arguments.length; i++) {
139
+ jsonParts.push(String(arguments[i]));
140
+ }
141
+ var json = jsonParts.join(" ");
142
+ var d = new Dict("obj-state-" + id);
143
+ d.parse(json);
144
+ // Read it straight back out. `parse` failing silently, or the dict box not
145
+ // existing at all (it was emitted with an invalid maxclass for a while, so [pattr]
146
+ // bound to nothing), both look exactly like a successful write from here. What the
147
+ // dict says it holds is the only evidence.
148
+ post("m4l-jweb: sync_state " + id + " <- " + json + " (dict now " + d.stringify() + ")\n");
149
+ } catch (e) {
150
+ post("m4l-jweb: sync_state error for " + id + " - " + (e as Error).message + "\n");
151
+ }
152
+ }
153
+
94
154
  /* ------------------------------------------------------------------ *
95
155
  * The self-extracting UI payload
96
156
  *
@@ -107,11 +167,43 @@ function loadWebview(): void {
107
167
  if (!url) return;
108
168
  outlet(0, "url", url);
109
169
  post("m4l-jweb: sent url " + url + "\n");
170
+
171
+ loadWindows();
110
172
  } catch (e) {
111
173
  post("m4l-jweb: loadWebview error " + (e as Error).message + "\n");
112
174
  }
113
175
  }
114
176
 
177
+ /**
178
+ * Point every floating window's [jweb] at its own extracted page.
179
+ *
180
+ * A window's [jweb] lives inside a subpatcher, so there is no cord from this
181
+ * [js] to it. `messnamed` reaches the [r window-read-<id>] the build put next to
182
+ * it - naming the receiver instead of the cord.
183
+ *
184
+ * The window payloads are the extra payloads whose name is `<device>_<id>.html`
185
+ * (packageDevices names them; the device's own UI is UI_PAYLOAD and is not in
186
+ * this list). Strip that prefix EXPLICITLY: a regex like /^.*_/ is greedy, so a
187
+ * window id with an underscore in it - `edit_grid` - would arrive as `grid`, and
188
+ * the page would then load into a receiver nobody is listening on.
189
+ */
190
+ function loadWindows(): void {
191
+ if (typeof EXTRA_PAYLOAD_NAMES === "undefined") return;
192
+ var folder = deviceFolder();
193
+ if (!folder) return;
194
+
195
+ var prefix = typeof UI_PAYLOAD_NAME !== "undefined" ? UI_PAYLOAD_NAME.replace(/\.html$/, "") + "_" : "";
196
+ for (var i = 0; i < EXTRA_PAYLOAD_NAMES.length; i++) {
197
+ var name = EXTRA_PAYLOAD_NAMES[i];
198
+ if (name.slice(-5) !== ".html") continue; // a sample, a preset, ...: not a window
199
+ if (prefix && name.slice(0, prefix.length) !== prefix) continue;
200
+ var winId = name.slice(prefix.length, name.length - 5);
201
+ var winUrl = encodeURI("file:///" + folder + "/" + name) + "?v=" + encodeURIComponent(buildStamp());
202
+ messnamed("window-read-" + winId, "url", winUrl);
203
+ post("m4l-jweb: window " + winId + " -> " + winUrl + "\n");
204
+ }
205
+ }
206
+
115
207
  function resolveUiUrl(): string | null {
116
208
  var folder = deviceFolder();
117
209
  if (!folder) {
@@ -265,3 +357,318 @@ function b64decode(s: string): number[] {
265
357
  }
266
358
  return out;
267
359
  }
360
+
361
+ /* ------------------------------------------------------------------ *
362
+ * Fetch-to-disk
363
+ *
364
+ * `fetchToFile(url, path)` in the app, [maxurl] in the patcher (the "download"
365
+ * chain), and this, orchestrating between them. Bulk data must never cross the
366
+ * Max message bridge, so the bytes go from libcurl STRAIGHT to disk and only the
367
+ * result comes back as a message.
368
+ *
369
+ * ------------------------------------------------------------------------------
370
+ * THE THREE THINGS THAT MADE THIS LOOK IMPOSSIBLE, all in maxurl's request dict:
371
+ *
372
+ * 1. THE KEY IS `filename_out`. It was `downloadfilename` here, which is not a
373
+ * key maxurl has - and an unknown key in a request dict is IGNORED, not
374
+ * rejected. So the request succeeded (HTTP 200, every time), the body went
375
+ * into the response dict, and nothing was ever written to disk. A perfect
376
+ * success and an empty folder. It is `filename_out` in Max's own reference
377
+ * (docs/refpages/max-ref/maxurl.maxref.xml, the `dictionary` message) and in
378
+ * the maxurl.maxhelp that ships with Live. Do not guess these names.
379
+ *
380
+ * 2. `overwrite_output_file` DEFAULTS TO 0. maxurl refuses to overwrite a file
381
+ * that already exists - so even with the right key, the download works once
382
+ * and then silently stops working. Set it to 1.
383
+ *
384
+ * 3. THE PATH IS AN OS PATH. maxurl is libcurl, not a Max file object: it does
385
+ * not know `~/`, and it does not know Max's `Desktop:/` style. It needs an
386
+ * absolute path, which is why a relative one is resolved against the device
387
+ * folder below.
388
+ *
389
+ * `response_dict` names the reply dict, so what comes back on maxurl's outlets is
390
+ * identifiable rather than a dict called "output" shared with anyone else's request.
391
+ *
392
+ * ------------------------------------------------------------------------------
393
+ * DOWNLOAD, THEN PLACE - why every fetch is TWO maxurl requests.
394
+ *
395
+ * A 404 is a RESPONSE, and maxurl writes it: point `filename_out` at a file you
396
+ * already rely on and a missing URL replaces it with the error page. Measured, not
397
+ * feared - see doc/ARCHITECTURE.md, where a 404 destroyed a good 1.2 MB cached .wav
398
+ * and reported status 404 while doing it. `overwrite_output_file` does not care what
399
+ * the status was.
400
+ *
401
+ * Everywhere else in computing the answer is "write to a temp path and move it into
402
+ * place on success". Max's [js] cannot: its `File` object has open, close, and the
403
+ * read/write family, and NOTHING ELSE. No rename. No delete. (Confirmed twice - in
404
+ * Cycling '74's reference, and by asking the live object what members it has.)
405
+ *
406
+ * So MAXURL DOES THE MOVE. It is libcurl, and libcurl speaks `file://`: a GET of
407
+ * `file:///<temp>` with `filename_out` set to the destination is a native, streaming
408
+ * file copy, on maxurl's own thread, with not one byte passing through [js]. Measured
409
+ * at 6 ms for 1 MB. The download therefore goes:
410
+ *
411
+ * 1. GET <url> -> filename_out <dest>.part (a bad response lands HERE)
412
+ * 2. validate status 2xx, no `error` key, bytes on disk > 0
413
+ * 3. GET file://<part> -> filename_out <dest> (only now is <dest> touched)
414
+ *
415
+ * A failure at 1 or 2 leaves the destination UNTOUCHED, which is the entire point.
416
+ *
417
+ * TWO TRAPS IN STEP 3, both of which look like success:
418
+ *
419
+ * - A file:// reply has NO HTTP STATUS. It comes back `status 0`, which the 2xx
420
+ * check in step 2 would reject as a failure. The copy is validated on BYTES - the
421
+ * destination is the same size as the part file - which is the honest check anyway
422
+ * and the only one that survives both schemes.
423
+ * - The .part file cannot be DELETED afterwards (no unlink, see above), so it is
424
+ * TRUNCATED to zero bytes instead. It costs an inode, not a megabyte, and the next
425
+ * fetch to the same destination overwrites it.
426
+ * ------------------------------------------------------------------ */
427
+
428
+ /** The reply dicts. Named, so nothing else can be mistaken for them - and so the two phases cannot be mistaken for each other. */
429
+ var FETCH_RESPONSE_DICT = "m4ljweb_fetch_response";
430
+ var PLACE_RESPONSE_DICT = "m4ljweb_place_response";
431
+
432
+ /** Where a download lands before it has earned its destination. */
433
+ function partPath(destPath: string): string {
434
+ return destPath + ".part";
435
+ }
436
+
437
+ interface ActiveFetch {
438
+ requestId: string;
439
+ url: string;
440
+ destPath: string;
441
+ /** Bytes in the .part file, carried from the download phase into the place phase. */
442
+ partBytes: number;
443
+ }
444
+
445
+ // One at a time: [maxurl] can run several, but a queue keeps `currentFetch`
446
+ // unambiguous, and a device downloading its samples wants them in order anyway.
447
+ var fetchQueue: ActiveFetch[] = [];
448
+ var currentFetch: ActiveFetch | null = null;
449
+
450
+ /**
451
+ * An absolute OS path for libcurl, from whatever the app asked for.
452
+ *
453
+ * A relative path is resolved against the DEVICE's folder - the one place a
454
+ * device can always write, and the same folder the UI payload is extracted into.
455
+ * An absolute path (POSIX `/...`, or Windows `C:/...`) is passed through.
456
+ */
457
+ function resolveFetchPath(destPath: string): string {
458
+ var isAbsolute = destPath.indexOf("/") === 0 || destPath.indexOf(":") === 1;
459
+ if (isAbsolute) return destPath;
460
+ var folder = deviceFolder();
461
+ return folder ? folder + "/" + destPath : destPath;
462
+ }
463
+
464
+ /** PHASE 1: download the URL to `<dest>.part`. Nothing touches `<dest>` yet. */
465
+ function processNextFetch(): void {
466
+ if (currentFetch || fetchQueue.length === 0) return;
467
+ var next = fetchQueue.shift();
468
+ if (!next) return;
469
+ currentFetch = next;
470
+ next.destPath = resolveFetchPath(next.destPath);
471
+
472
+ var reqDict = new Dict();
473
+ reqDict.set("url", next.url);
474
+ reqDict.set("http_method", "get");
475
+ reqDict.set("filename_out", partPath(next.destPath)); // NOT the destination - see above
476
+ reqDict.set("overwrite_output_file", 1); // ...or it downloads exactly once, ever
477
+ reqDict.set("response_dict", FETCH_RESPONSE_DICT);
478
+
479
+ post("m4l-jweb: fetch " + next.url + " -> " + next.destPath + "\n");
480
+ // Outlet 1 is the aux outlet; the "download" chain routes `maxurl` off it.
481
+ outlet(1, "maxurl", "dictionary", reqDict.name);
482
+ }
483
+
484
+ /**
485
+ * PHASE 3: ask libcurl to copy the validated .part file over the destination.
486
+ *
487
+ * This is the "move" that [js] cannot do. `file://` is a scheme libcurl handles, so
488
+ * maxurl streams the file on its own thread and nothing crosses the message bridge.
489
+ */
490
+ function placeFetch(fetched: ActiveFetch): void {
491
+ var reqDict = new Dict();
492
+ reqDict.set("url", encodeURI("file:///" + partPath(fetched.destPath)));
493
+ reqDict.set("http_method", "get");
494
+ reqDict.set("filename_out", fetched.destPath);
495
+ reqDict.set("overwrite_output_file", 1);
496
+ reqDict.set("response_dict", PLACE_RESPONSE_DICT);
497
+ outlet(1, "maxurl", "dictionary", reqDict.name);
498
+ }
499
+
500
+ /* ------------------------------------------------------------------ *
501
+ * Samples - the `samples` chain
502
+ * ------------------------------------------------------------------ */
503
+
504
+ /**
505
+ * The app: `buffer_load <slot> <path>` - read a file into that slot's [buffer~].
506
+ *
507
+ * WHY THIS GOES THROUGH [js] AT ALL, when the chain could route it straight to the
508
+ * buffer: because the path the app wrote is not a path [buffer~] can open, and it
509
+ * fails in the two ways this file exists to prevent.
510
+ *
511
+ * A RELATIVE path is resolved against the device's folder - the same resolution
512
+ * `fetch_to_file` does, and it has to be the same one or the app downloads a file to
513
+ * one place and loads it from another. [buffer~] does not resolve it that way: a bare
514
+ * name is looked up in MAX's SEARCH PATH, which does not contain the device's folder,
515
+ * so `preview.wav` - freshly downloaded, right there next to the .amxd - reports
516
+ * "can't open" and the promise times out.
517
+ *
518
+ * ...and the resolved path CONTAINS SPACES on a normal Live install ("Ableton
519
+ * Library", "Max For Live"). A message travelling through the patcher as text would
520
+ * split there into three atoms and [buffer~] would open the first one. Handed out of
521
+ * [js] as a string, it stays ONE symbol all the way to `replace`.
522
+ *
523
+ * The file is checked before the buffer is asked for it, because a missing file makes
524
+ * [buffer~] print to the Max console and stay silent - there is no failure bang to
525
+ * bind to, and the app would learn nothing until the timeout. `buffer_error` says so
526
+ * at once.
527
+ */
528
+ function buffer_load(slot: string, path: string): void {
529
+ var resolved = resolveFetchPath(path); // the same folder the download wrote to
530
+ var f: File | null = null;
531
+ try {
532
+ f = new File(resolved, "read");
533
+ } catch (e) {
534
+ f = null;
535
+ }
536
+ if (!f || !f.isopen) {
537
+ outlet(0, "buffer_error", slot, "no file at " + resolved);
538
+ return;
539
+ }
540
+ var bytes = f.eof;
541
+ f.close();
542
+ if (!bytes) {
543
+ outlet(0, "buffer_error", slot, "empty file at " + resolved);
544
+ return;
545
+ }
546
+
547
+ // Outlet 1 is the aux outlet; the `samples` chain routes `buffer_replace` off it,
548
+ // exactly as the `download` chain routes `maxurl`. Outlet 0 belongs to [jweb].
549
+ post("m4l-jweb: buffer_load " + slot + " -> " + resolved + "\n");
550
+ outlet(1, "buffer_replace", slot, resolved);
551
+ }
552
+
553
+ /** The app: `fetch_to_file <requestId> <url> <destPath>`. */
554
+ function fetch_to_file(requestId: string, url: string, destPath: string): void {
555
+ fetchQueue.push({ requestId: requestId, url: url, destPath: destPath, partBytes: 0 });
556
+ processNextFetch();
557
+ }
558
+
559
+ /**
560
+ * maxurl finished: `maxurl_done dictionary <name>` (outlet 0, via [prepend]).
561
+ *
562
+ * Both phases land here, told apart by the response dict they asked for.
563
+ */
564
+ function maxurl_done(msgType: string, dictName: string): void {
565
+ if (msgType !== "dictionary") return;
566
+ // A device's own wrapper/device.ts may drive [maxurl] itself (a cache, a spike) and
567
+ // its replies come back down this same cord. Offer it the reply first: without this
568
+ // hook, a request the device made lands here, finds no `currentFetch`, and is
569
+ // dropped in silence - which looks exactly like maxurl never answering.
570
+ if (typeof onMaxurlReply === "function" && onMaxurlReply(dictName)) return;
571
+ if (!currentFetch) return;
572
+ var fetched = currentFetch;
573
+
574
+ try {
575
+ if (dictName === PLACE_RESPONSE_DICT) finishPlace(fetched);
576
+ else finishDownload(fetched, dictName);
577
+ } catch (e) {
578
+ post("m4l-jweb: maxurl_done error - " + (e as Error).message + "\n");
579
+ failFetch(fetched, "wrapper error: " + (e as Error).message);
580
+ }
581
+ }
582
+
583
+ /**
584
+ * PHASE 2: did the download earn its destination?
585
+ *
586
+ * All three checks, and all three are load-bearing: a filesystem failure comes back as
587
+ * status 200 with an `error` key, and a `filename_out` maxurl ignored comes back as a
588
+ * clean 200 with nothing on disk. Only the file itself proves the file.
589
+ */
590
+ function finishDownload(fetched: ActiveFetch, dictName: string): void {
591
+ var d = new Dict(dictName);
592
+ var status = Number(d.get("status"));
593
+ var errorMsg = d.get("error");
594
+ var bytes = fileSize(partPath(fetched.destPath));
595
+
596
+ if (errorMsg) return failFetch(fetched, String(errorMsg));
597
+ if (status < 200 || status >= 300) return failFetch(fetched, "HTTP " + status);
598
+ if (bytes <= 0) return failFetch(fetched, "HTTP " + status + " but nothing was written to disk");
599
+
600
+ fetched.partBytes = bytes;
601
+ placeFetch(fetched); // ...and only now does the destination get touched
602
+ }
603
+
604
+ /**
605
+ * PHASE 3 (reply): validate the copy ON BYTES, not on status.
606
+ *
607
+ * A `file://` request has no HTTP status - it comes back as `status 0`, measured - so
608
+ * the 2xx check that guards the download would reject a perfectly good copy here. The
609
+ * size is the honest check for both schemes anyway.
610
+ */
611
+ function finishPlace(fetched: ActiveFetch): void {
612
+ var placed = fileSize(fetched.destPath);
613
+ if (placed !== fetched.partBytes) {
614
+ return failFetch(fetched, "could not place the download: " + placed + " bytes at the destination, expected " + fetched.partBytes);
615
+ }
616
+ truncate(partPath(fetched.destPath)); // [js] cannot DELETE it; zero it instead
617
+ post("m4l-jweb: fetched " + placed + " bytes to " + fetched.destPath + "\n");
618
+ outlet(0, "fetch_done", fetched.requestId, placed);
619
+ currentFetch = null;
620
+ processNextFetch();
621
+ }
622
+
623
+ /** The destination is untouched whenever this is called from phase 1 or 2. That is the point. */
624
+ function failFetch(fetched: ActiveFetch, message: string): void {
625
+ post("m4l-jweb: fetch failed - " + message + "\n");
626
+ outlet(0, "fetch_error", fetched.requestId, message);
627
+ currentFetch = null;
628
+ processNextFetch();
629
+ }
630
+
631
+ /** Bytes on disk, or -1 if there is no readable file there. */
632
+ function fileSize(p: string): number {
633
+ try {
634
+ var f = new File(p);
635
+ if (!f.isopen) return -1;
636
+ var n = f.eof;
637
+ f.close();
638
+ return n;
639
+ } catch (e) {
640
+ return -1;
641
+ }
642
+ }
643
+
644
+ /** Zero a file. The closest thing to `delete` that [js] has - it has no unlink. */
645
+ function truncate(p: string): void {
646
+ try {
647
+ var f = new File(p, "write");
648
+ if (!f.isopen) f.open();
649
+ if (!f.isopen) return;
650
+ f.eof = 0;
651
+ f.close();
652
+ } catch (e) {
653
+ /* a leftover .part is untidy, not a failure */
654
+ }
655
+ }
656
+
657
+ /**
658
+ * maxurl's progress outlet, via [prepend maxurl_progress]:
659
+ *
660
+ * maxurl_progress <response-dict-name> <dl total> <dl now> <ul total> <ul now>
661
+ *
662
+ * FIVE atoms, starting with the dict NAME - per the reference (outlet 1). This
663
+ * used to be read as `(downloaded, total, percent)`, so the "bytes downloaded"
664
+ * the UI showed was actually a symbol, and the total was the download total. And
665
+ * note the reference's warning: not every server sends a content length, so the
666
+ * TOTAL can legitimately be 0. Do not divide by it.
667
+ */
668
+ function maxurl_progress(dictName: string, dlTotal: number, dlNow: number): void {
669
+ if (!currentFetch) return;
670
+ // The local file:// copy reports progress too. It is not the download, and a UI that
671
+ // showed it would run its progress bar twice - the second time in a few milliseconds.
672
+ if (dictName !== FETCH_RESPONSE_DICT) return;
673
+ outlet(0, "fetch_progress", currentFetch.requestId, dlNow, dlTotal);
674
+ }
package/src/max.d.ts CHANGED
@@ -16,6 +16,16 @@ declare function outlet(n: number, ...args: unknown[]): void;
16
16
  /** Collect the `arguments` of a Max message handler into a real array. */
17
17
  declare function arrayfromargs(args: IArguments): unknown[];
18
18
 
19
+ /**
20
+ * Send a message to every [receive <name>] / [r <name>] in the patcher - the
21
+ * only way [js] can reach a box it has no patch cord to.
22
+ *
23
+ * The wrapper uses it for what CANNOT be wired: a floating window's [jweb] lives
24
+ * inside a subpatcher, so there is no cord from here to there. `messnamed` names
25
+ * the receiver instead of the cord.
26
+ */
27
+ declare function messnamed(receiveName: string, ...args: unknown[]): void;
28
+
19
29
  /** Arguments given to the object box, e.g. `js wrapper.js midi` -> ["midi"]. */
20
30
  declare const jsarguments: unknown[];
21
31
 
@@ -48,7 +58,7 @@ declare class Task {
48
58
  * something has to build that dict, and [js] is the only thing in the patcher
49
59
  * that can.
50
60
  *
51
- * VERIFIED in Live (doc/SPIKES.md spike 1.3): [js] built a maxurl request dict
61
+ * VERIFIED in Live (doc/TODO.md - what the spikes found, 1.3): [js] built a maxurl request dict
52
62
  * and read the response dict back. `constructor`, `set`, `clear` and `stringify`
53
63
  * all behave as declared. `get`, `parse` and `freepeer` were not exercised.
54
64
  */
@@ -91,7 +101,7 @@ declare class File {
91
101
  * ASYNCHRONOUS: framecount() right after it still reads the old size. Come back
92
102
  * on a Task.
93
103
  *
94
- * VERIFIED in Live (doc/SPIKES.md spike 1.2): an empty buffer~ went to 124439
104
+ * VERIFIED in Live (doc/TODO.md - what the spikes found, 1.2): an empty buffer~ went to 124439
95
105
  * frames, 1 channel, midsample -0.0319 after `send("replace", "jongly.aif")`.
96
106
  * `send`, `framecount`, `channelcount` and `peek` are all real and behave as
97
107
  * declared. `poke` is the one member here still taken on faith from the docs.
@@ -166,5 +176,14 @@ declare function onUiReady(): void;
166
176
  /** Every transport poll (20 Hz), after the packaged wrapper has sent its tick. */
167
177
  declare function onTick(playing: number, beats: number): void;
168
178
 
179
+ /**
180
+ * A reply from [maxurl] arrived. Return true if it was YOURS.
181
+ *
182
+ * Only for a device that drives [maxurl] itself - its replies travel the same cord as
183
+ * `fetchToFile()`'s, and the wrapper would otherwise drop them (no `currentFetch`).
184
+ * Match on the `response_dict` name you asked for.
185
+ */
186
+ declare function onMaxurlReply(responseDictName: string): boolean;
187
+
169
188
  /** Live's tempo changed (and once on attach). */
170
189
  declare function onTempoChange(bpm: number): void;