@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 +41 -0
- package/package.json +1 -1
- package/src/core.ts +305 -7
- package/src/max.d.ts +33 -2
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
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
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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 =
|
|
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(
|
|
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:///" +
|
|
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(
|
|
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/
|
|
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/
|
|
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
|
*
|