@m4l-jweb/build 0.5.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m4l-jweb/build",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "m4l-jweb: the CLI that builds and packages a device repo into installable Max for Live devices.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -33,6 +33,6 @@
33
33
  "archiver": "^7.0.1",
34
34
  "esbuild": "^0.25.0",
35
35
  "typescript": "^5.7.0",
36
- "@m4l-jweb/wrapper": "0.5.0"
36
+ "@m4l-jweb/wrapper": "0.6.0"
37
37
  }
38
38
  }
package/src/chains.mjs CHANGED
@@ -192,18 +192,26 @@ export function closeAudio(ctx) {
192
192
  * that is about to be written, so it covers the canned chains, a device repo's own
193
193
  * `patcher/chains.mjs`, and the template underneath both.
194
194
  */
195
- export function assertUniqueBoxIds(boxes, deviceName) {
195
+ export function assertUniqueBoxIds(boxes, deviceName, scope = "the patcher") {
196
196
  const seen = new Set();
197
197
  const dupes = new Set();
198
198
  for (const { box: b } of boxes) (seen.has(b.id) ? dupes : seen).add(b.id);
199
199
  if (dupes.size) {
200
200
  throw new Error(
201
- `device "${deviceName}" generated duplicate box ids: ${[...dupes].join(", ")}. ` +
201
+ `device "${deviceName}" generated duplicate box ids in ${scope}: ${[...dupes].join(", ")}. ` +
202
202
  `Two boxes with one id is a patcher Max will interpret however it likes. ` +
203
203
  `A chain that creates a box another chain also creates is not a stage - it is claiming to be the whole device. ` +
204
204
  `Take the stage before you (ctx.audioIn / ctx.appOut) and hand yours on, or name your boxes after your chain.`,
205
205
  );
206
206
  }
207
+ // A SUBPATCHER is its own id namespace, so its boxes are checked against each
208
+ // other and not against ours - a floating window's [jweb] may be called
209
+ // `obj-jweb` even though the device's is too. It is checked, though: the window
210
+ // codegen emitted two inlets sharing one id and nothing said a word, because
211
+ // this only ever looked at the top level.
212
+ for (const { box: b } of boxes) {
213
+ if (b.patcher?.boxes) assertUniqueBoxIds(b.patcher.boxes, deviceName, `subpatcher [${b.text ?? b.id}]`);
214
+ }
207
215
  }
208
216
 
209
217
  /**
@@ -462,13 +470,224 @@ function driveChain(ctx) {
462
470
  }
463
471
  }
464
472
 
473
+ /**
474
+ * "download" - `[maxurl]`, so the app can fetch a file to DISK.
475
+ *
476
+ * [js] out 1 -> [route maxurl] -> [maxurl] -> [prepend maxurl_done] -> [js] in 0
477
+ * -> [prepend maxurl_progress] -> [js] in 0
478
+ *
479
+ * The bytes never enter the Max message stream: [js] hands maxurl a request dict
480
+ * carrying `filename_out` and libcurl writes the file itself. What comes back here
481
+ * is only the RESULT - a response dict name, and progress. That is the whole point:
482
+ * [js] is a control plane, not a data plane. See the fetch section of core.ts for
483
+ * the request dict, which is where this feature was actually broken.
484
+ *
485
+ * The request leaves [js] on OUTLET 1 (the aux outlet) rather than outlet 0, which
486
+ * belongs to [jweb]: a `maxurl` message on outlet 0 would be delivered to the web
487
+ * view, which has no idea what to do with it. The [route maxurl] then strips the
488
+ * tag, so what reaches the object is the bare `dictionary <name>` it expects.
489
+ *
490
+ * maxurl has TWO outlets (per its reference page): 0 = the response dictionary,
491
+ * 1 = progress. Not three.
492
+ */
493
+ function downloadChain(ctx) {
494
+ const { boxes, lines, unmatchedId } = ctx; // unmatchedId is the wrapper's [js]
495
+
496
+ boxes.push(box("obj-maxurl", "maxurl", { numinlets: 2, numoutlets: 2, outlettype: ["", ""] }));
497
+
498
+ boxes.push(box("obj-route-maxurl", "route maxurl", { numoutlets: 2, outlettype: ["", ""] }));
499
+ lines.push(line(unmatchedId, 1, "obj-route-maxurl", 0));
500
+ lines.push(line("obj-route-maxurl", 0, "obj-maxurl", 0));
501
+
502
+ // Tag both replies, so [js] dispatches them to a handler rather than to
503
+ // anything(), which would swallow them - they arrive as a bare `dictionary
504
+ // <name>` and a bare list otherwise.
505
+ boxes.push(box("obj-prepend-maxurl-done", "prepend maxurl_done"));
506
+ boxes.push(box("obj-prepend-maxurl-progress", "prepend maxurl_progress"));
507
+
508
+ lines.push(line("obj-maxurl", 0, "obj-prepend-maxurl-done", 0));
509
+ lines.push(line("obj-maxurl", 1, "obj-prepend-maxurl-progress", 0));
510
+
511
+ lines.push(line("obj-prepend-maxurl-done", 0, unmatchedId, 0));
512
+ lines.push(line("obj-prepend-maxurl-progress", 0, unmatchedId, 0));
513
+ }
514
+
515
+ /**
516
+ * "samples" - the first chain that ORIGINATES a sound: a named [buffer~] per slot,
517
+ * loaded from a file on disk, played back through [groove~] into the signal path.
518
+ *
519
+ * app -> buffer_load <slot> <path> -> [js] (resolves the path)
520
+ * -> buffer_replace -> [buffer~ <name>]
521
+ * buffer_play <slot> -> [groove~] set + play
522
+ * buffer_stop -> [groove~] stop
523
+ * app <- buffer_ready <slot> <sr> <ms> <chans> when the read actually completed
524
+ * buffer_error <slot> <msg> when there was no file to read
525
+ *
526
+ * THE BYTES NEVER CROSS THE BRIDGE. [buffer~] reads the file itself; what travels in
527
+ * Max messages is a path and, coming back, a description of what landed - the same
528
+ * rule the `download` chain follows, which is how you get the file there in the
529
+ * first place.
530
+ *
531
+ * WAV/AIFF/Next-Sun ONLY. [buffer~]'s `read`/`replace` does not take MP3 - that list
532
+ * (MP3, OGG, FLAC, M4A) is [sfplay~]'s, which streams from disk rather than filling a
533
+ * buffer, and is therefore a different chain. A format it cannot read is an error in
534
+ * the Max console and NO bang, so the app's promise times out rather than lying.
535
+ *
536
+ * WHAT LOADED IS NOT WHAT YOU ASKED FOR, so the chain reports what it GOT. `replace`
537
+ * resizes the buffer and adopts the FILE's channel count and sample rate, so a slot
538
+ * is not mono because you wanted it to be. [info~] is banged from the buffer's own
539
+ * "read completed" outlet (outlet 1 - outlet 0 is a mouse position in the editing
540
+ * window) and reports sample rate, duration and channels; the app derives frames from
541
+ * those. A frame count on its own is not proof of a read: a failed `replace` leaves
542
+ * the PREVIOUS contents in place, so "there are samples in there" says nothing. The
543
+ * bang does - it only fires when a read completed - and until it does the app's
544
+ * promise is still open (see loadSample() in @m4l-jweb/bridge, which is where the
545
+ * timeout lives).
546
+ *
547
+ * info~'s outlets fire right-to-left, so the LAST one to arrive is outlet 0, the
548
+ * sample rate - which is therefore the one wired to [pack]'s hot inlet. Wire it the
549
+ * other way round and the message goes out carrying the PREVIOUS load's numbers.
550
+ *
551
+ * ONE VOICE, DELIBERATELY. `groove~` takes `set <buffer-name>` to switch buffers, so
552
+ * one stereo player covers N slots: this is a PREVIEW - the sample browser's "let me
553
+ * hear it, through the track" - not a sampler. Polyphony is the `instrument` chain,
554
+ * and it is still open (doc/TODO.md item 2).
555
+ *
556
+ * It SUMS into the signal path rather than claiming it ([+~]), because it makes sound
557
+ * of its own: on an audio effect the preview plays over the track's audio, and on an
558
+ * instrument there is nothing at the input to add.
559
+ *
560
+ * The buffer NAMES are global to Max, and they are generated from the device name
561
+ * (`buf-<device>-<slot>`), so two INSTANCES of the same device on two tracks name
562
+ * their buffers alike - and Max hands the name to whichever loaded last. Not
563
+ * verified in Live yet; a preview device you drag onto one track is unaffected.
564
+ *
565
+ * Slots default to one, named "preview". `slots: ["kick", "snare"]` in the manifest
566
+ * gives you more.
567
+ */
568
+ function samplesChain(ctx) {
569
+ const { boxes, lines, device, jwebId, unmatchedId } = ctx;
570
+ const slots = device?.slots ?? ["preview"];
571
+ const bufName = (slot) => `buf-${device?.name}-${slot}`;
572
+
573
+ // `buffer_load` is NOT claimed from [jweb]. It goes on to the wrapper, which
574
+ // resolves the path and hands it back on its AUX OUTLET as `buffer_replace <slot>
575
+ // <abs path>` - the same shape the `download` chain takes `maxurl` in.
576
+ //
577
+ // The detour is the whole fix. The app writes a path relative to the device's
578
+ // folder (that is where fetchToFile puts the file), and [buffer~] does not resolve
579
+ // it that way: a bare name is looked up in MAX'S SEARCH PATH, which the device's
580
+ // folder is not in, so a file that was downloaded correctly reports "can't open".
581
+ // The resolved path then contains SPACES on a normal Live install ("Ableton
582
+ // Library"), and a path travelling through the patcher as message text would split
583
+ // there into atoms. Out of [js] it stays one symbol.
584
+ boxes.push(box("obj-samples-route", "route buffer_play buffer_stop", { numoutlets: 3, outlettype: ["", "", ""] }));
585
+ claimAppMessages(ctx, "obj-samples-route", 2);
586
+
587
+ boxes.push(box("obj-samples-replaceroute", "route buffer_replace", { numoutlets: 2, outlettype: ["", ""] }));
588
+ lines.push(line(unmatchedId, 1, "obj-samples-replaceroute", 0));
589
+
590
+ // `route` strips the selector, so the slot name is now the first word of both
591
+ // remaining messages: a second route per stream dispatches on it.
592
+ const slotList = slots.join(" ");
593
+ const slotOutlets = { numoutlets: slots.length + 1, outlettype: slots.map(() => "").concat("") };
594
+ boxes.push(box("obj-samples-loadslot", `route ${slotList}`, slotOutlets));
595
+ boxes.push(box("obj-samples-playslot", `route ${slotList}`, slotOutlets));
596
+ lines.push(line("obj-samples-replaceroute", 0, "obj-samples-loadslot", 0));
597
+ lines.push(line("obj-samples-route", 0, "obj-samples-playslot", 0));
598
+
599
+ // The player. `groove~ <buffer> 2` = two signal outlets (plus a loop-sync outlet),
600
+ // and it MIXES a buffer with more channels down rather than dropping them.
601
+ // @loop 0 makes it a one-shot: a preview that loops forever is a preview you have
602
+ // to fight. [sig~ 1.] is the playback rate, in the left inlet, which is a SIGNAL
603
+ // inlet - a float there means something else entirely (a position, in ms).
604
+ boxes.push(box("obj-samples-rate", "sig~ 1.", { numinlets: 1, numoutlets: 1, outlettype: ["signal"] }));
605
+ boxes.push(
606
+ box("obj-samples-groove", `groove~ ${bufName(slots[0])} 2 @loop 0`, {
607
+ numinlets: 3,
608
+ numoutlets: 3,
609
+ outlettype: ["signal", "signal", "signal"],
610
+ }),
611
+ );
612
+ lines.push(line("obj-samples-rate", 0, "obj-samples-groove", 0));
613
+
614
+ // Stop: a bare `buffer_stop` arrives from [route] as a BANG - the word is gone -
615
+ // so re-materialize it in a message box, or groove~ hears nothing it knows.
616
+ boxes.push(box("obj-samples-stopmsg", "stop", { maxclass: "message", numinlets: 2, numoutlets: 1 }));
617
+ lines.push(line("obj-samples-route", 1, "obj-samples-stopmsg", 0));
618
+ lines.push(line("obj-samples-stopmsg", 0, "obj-samples-groove", 0));
619
+
620
+ slots.forEach((slot, i) => {
621
+ const buf = `obj-samples-buf-${slot}`;
622
+ const info = `obj-samples-info-${slot}`;
623
+
624
+ // buffer~: outlet 0 is a mouse position, outlet 1 is the bang on a completed
625
+ // read. Only the second one means anything here.
626
+ boxes.push(box(buf, `buffer~ ${bufName(slot)}`, { numinlets: 1, numoutlets: 2, outlettype: ["float", "bang"] }));
627
+ boxes.push(box(`obj-samples-replace-${slot}`, "prepend replace"));
628
+ lines.push(line("obj-samples-loadslot", i, `obj-samples-replace-${slot}`, 0));
629
+ lines.push(line(`obj-samples-replace-${slot}`, 0, buf, 0));
630
+
631
+ // What actually landed. info~'s outlets: 0 = sample rate, 6 = duration in ms,
632
+ // 8 = number of channels (per its reference page - do not count them from
633
+ // memory). They fire right-to-left, so 0 arrives last and is the hot one.
634
+ boxes.push(
635
+ box(info, `info~ ${bufName(slot)}`, {
636
+ numinlets: 1,
637
+ numoutlets: 10,
638
+ outlettype: ["float", "list", "float", "float", "float", "float", "float", "", "int", ""],
639
+ }),
640
+ );
641
+ boxes.push(box(`obj-samples-pack-${slot}`, "pack 0. 0. 0", { numinlets: 3, numoutlets: 1, outlettype: [""] }));
642
+ boxes.push(box(`obj-samples-ready-${slot}`, `prepend buffer_ready ${slot}`));
643
+
644
+ lines.push(line(buf, 1, info, 0)); // the read completed - ask what it was
645
+ lines.push(line(info, 8, `obj-samples-pack-${slot}`, 2)); // channels (fires first)
646
+ lines.push(line(info, 6, `obj-samples-pack-${slot}`, 1)); // duration, ms
647
+ lines.push(line(info, 0, `obj-samples-pack-${slot}`, 0)); // sample rate - hot, last
648
+ lines.push(line(`obj-samples-pack-${slot}`, 0, `obj-samples-ready-${slot}`, 0));
649
+ lines.push(line(`obj-samples-ready-${slot}`, 0, jwebId, 0));
650
+
651
+ // Play: pick the buffer, THEN start it. Two cords out of one outlet fire in an
652
+ // order Max chooses, so the sequence goes through a [t b b] - right outlet
653
+ // first - and never through a fan-out. Starting the OLD buffer and then
654
+ // switching is exactly the bug that looks like "the wrong sample previewed".
655
+ boxes.push(box(`obj-samples-trig-${slot}`, "t b b", { numinlets: 1, numoutlets: 2, outlettype: ["bang", "bang"] }));
656
+ boxes.push(box(`obj-samples-set-${slot}`, `set ${bufName(slot)}`, { maxclass: "message", numinlets: 2, numoutlets: 1 }));
657
+ // A float in groove~'s left inlet is a playback POSITION in ms, and 0 is "from
658
+ // the beginning" - which is also what starts it after a `stop`.
659
+ boxes.push(box(`obj-samples-start-${slot}`, "0", { maxclass: "message", numinlets: 2, numoutlets: 1 }));
660
+
661
+ lines.push(line("obj-samples-playslot", i, `obj-samples-trig-${slot}`, 0));
662
+ lines.push(line(`obj-samples-trig-${slot}`, 1, `obj-samples-set-${slot}`, 0)); // right first: set the buffer
663
+ lines.push(line(`obj-samples-trig-${slot}`, 0, `obj-samples-start-${slot}`, 0)); // ...then play it
664
+ lines.push(line(`obj-samples-set-${slot}`, 0, "obj-samples-groove", 0));
665
+ lines.push(line(`obj-samples-start-${slot}`, 0, "obj-samples-groove", 0));
666
+ });
667
+
668
+ // Sum into the signal path: this chain MAKES sound, it does not process what came
669
+ // before. Claiming the stage instead would silence the track the preview plays over.
670
+ for (const [ch, id] of [
671
+ [0, "obj-samples-mix-l"],
672
+ [1, "obj-samples-mix-r"],
673
+ ]) {
674
+ const [srcId, srcOut] = ctx.audioIn(ch);
675
+ boxes.push(box(id, "+~", { numinlets: 2, numoutlets: 1, outlettype: ["signal"] }));
676
+ lines.push(line(srcId, srcOut, id, 0));
677
+ lines.push(line("obj-samples-groove", ch, id, 1));
678
+ ctx.setAudioOut(ch, id, 0);
679
+ }
680
+ }
681
+
465
682
  export const CHAINS = {
466
683
  midiin: midiInChain,
684
+ samples: samplesChain,
467
685
  midiout: midiOutChain,
468
686
  passthrough: passthroughChain,
469
687
  gain: gainChain,
470
688
  lowpass: lowpassChain,
471
689
  drive: driveChain,
690
+ download: downloadChain,
472
691
  };
473
692
 
474
693
  /** Add a chain to the vocabulary. Called before generatePatchers(). */
package/src/index.mjs CHANGED
@@ -8,7 +8,7 @@
8
8
  * wrapper/device.ts - extra [js] message handlers, concatenated last
9
9
  */
10
10
  import archiver from "archiver";
11
- import { createReadStream, createWriteStream, existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
11
+ import { createReadStream, createWriteStream, existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, readdirSync } from "node:fs";
12
12
  import { copyFile, rename, stat } from "node:fs/promises";
13
13
  import { execFileSync } from "node:child_process";
14
14
  import { createRequire } from "node:module";
@@ -17,7 +17,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
17
17
 
18
18
  import { AMXD_TYPES, assertES5, buildAmxd, extraPayloadsJs, payloadJs } from "./amxd.mjs";
19
19
  import { CHAINS, assertUniqueBoxIds, closeAudio, openAudio, resetLayout } from "./chains.mjs";
20
- import { applySurface, loadSurface, surfaceContext } from "./surface.mjs";
20
+ import { applySurface, applyWindows, applyPersistence, loadSurface, surfaceContext } from "./surface.mjs";
21
21
 
22
22
  const require = createRequire(import.meta.url);
23
23
  const pkgDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
@@ -80,6 +80,14 @@ export function buildWrapper(root) {
80
80
  // At [js] global scope `this` IS the jsthis object - that is how
81
81
  // `this.patcher.filepath` works.
82
82
  noImplicitThis: false,
83
+ // ...and that is ONLY true in sloppy mode. `strict: true` makes tsc emit
84
+ // "use strict" at the top of every file, under which `this` inside a plainly
85
+ // called function is UNDEFINED - so `this.patcher` would throw. Max's [js] is
86
+ // an ES5-era interpreter that does not enforce strict semantics, so the
87
+ // device works anyway; it works by accident, on a technicality of a very old
88
+ // engine, and any [js] that ever grew a real strict mode would take every
89
+ // device down with it. Emit what we actually target.
90
+ alwaysStrict: false,
83
91
  noImplicitAny: false,
84
92
  skipLibCheck: true,
85
93
  types: [],
@@ -199,6 +207,8 @@ export function composePatcher(base, d, surface) {
199
207
  }
200
208
 
201
209
  applySurface(ctx);
210
+ applyWindows(ctx);
211
+ applyPersistence(ctx);
202
212
  closeAudio(ctx);
203
213
  assertUniqueBoxIds(boxes, d.name);
204
214
 
@@ -292,11 +302,23 @@ export async function packageDevices(root) {
292
302
  /**
293
303
  * Payloads ride inside wrapper.js as base64 and are written to real files
294
304
  * next to the .amxd on first load, because Chromium and any external
295
- * process are blind to Max's frozen virtual filesystem. The UI is always
296
- * one; a device can declare more (`payloads: ["dist/foo.cjs"]`).
305
+ * process are blind to Max's frozen virtual filesystem.
306
+ * We embed ALL .html files found in the UI directory (including the main UI and any windows).
297
307
  */
298
- let wrapperData = banner + wrapperJs + payloadJs("UI_PAYLOAD", uiName, uiHtml);
308
+ let wrapperData = banner + wrapperJs;
309
+ const uiDirContent = readdirSync(path.join(dist, "ui", d.ui ?? d.name)).filter((f) => f.endsWith(".html"));
310
+
311
+ // Main UI payload
312
+ wrapperData += payloadJs("UI_PAYLOAD", uiName, readFileSync(path.join(dist, "ui", d.ui ?? d.name, "index.html")));
313
+
314
+ // Additional window payloads
315
+ const extraWindows = uiDirContent.filter((f) => f !== "index.html");
299
316
  const payloads = (d.payloads ?? []).map((f) => ({ name: path.basename(f), data: readFileSync(path.join(root, f)) }));
317
+
318
+ for (const winHtml of extraWindows) {
319
+ payloads.push({ name: `${d.name}_${winHtml}`, data: readFileSync(path.join(dist, "ui", d.ui ?? d.name, winHtml)) });
320
+ }
321
+
300
322
  if (payloads.length) wrapperData += extraPayloadsJs(payloads);
301
323
 
302
324
  const amxd = buildAmxd({
package/src/surface.mjs CHANGED
@@ -294,3 +294,208 @@ export function applySurface(ctx) {
294
294
  lines.push(line(`obj-set-${id}`, 0, paramObject(id), 0));
295
295
  });
296
296
  }
297
+
298
+ /**
299
+ * Compile a declared `window` into the patcher: a subpatcher holding its own
300
+ * [jweb], and a [pcontrol] that opens it when the app asks.
301
+ *
302
+ * [jweb] -> [route window_x_open window_x_close] -> [t b] -> [open( -> [pcontrol] -> [p Title]
303
+ *
304
+ * ------------------------------------------------------------------------------
305
+ * A MAXCLASS IS NOT A NAME YOU INVENT, and that is what was broken here.
306
+ *
307
+ * This generated the open/close message boxes as `newobj` with the TEXT "open"
308
+ * and "wclose", and the [pcontrol] as a box with `maxclass: "pcontrol"`. Neither
309
+ * is a thing:
310
+ *
311
+ * - `newobj` means "an object box", and its text names the object. There is no
312
+ * Max object called `open`, so what got built was a BROKEN box - the dashed
313
+ * outline you would see instantly in the Max editor, and see nothing of here.
314
+ * A message box is `maxclass: "message"` with the message in `text`
315
+ * (chains.mjs's [flush( does this - copy that, do not reinvent it).
316
+ * - `pcontrol` is an object, not a box class: `maxclass: "newobj"`,
317
+ * `text: "pcontrol"`. A box with an unknown maxclass does not instantiate.
318
+ *
319
+ * A patcher full of broken boxes still LOADS, keeps its cords, and does nothing.
320
+ * That is why the message reached [jweb]'s outlet, the route matched, and the
321
+ * window never opened - and why the failure was misread as `[route]` refusing to
322
+ * match jweb's output. The route was fine. It was firing into a box that had
323
+ * failed to exist.
324
+ * ------------------------------------------------------------------------------
325
+ */
326
+ export function applyWindows(ctx) {
327
+ const { boxes, lines, surface } = ctx;
328
+ const windowIds = surface?.windows ? Object.keys(surface.windows) : [];
329
+ if (windowIds.length === 0) return;
330
+
331
+ const selectors = windowIds.flatMap((id) => [`window_${id}_open`, `window_${id}_close`]);
332
+
333
+ const routeId = "obj-windows-route";
334
+ boxes.push(
335
+ box(routeId, `route ${selectors.join(" ")}`, {
336
+ numoutlets: selectors.length + 1,
337
+ outlettype: selectors.map(() => "").concat(""),
338
+ }),
339
+ );
340
+ // IN SERIES, like every other claimant of the app's message stream. Hanging this
341
+ // route off [jweb] in parallel (which it did) leaves two paths to [js], so the
342
+ // wrapper sees every unrouted message - `ui_ready` included - twice.
343
+ claimAppMessages(ctx, routeId, selectors.length);
344
+
345
+ windowIds.forEach((id, index) => {
346
+ const spec = surface.windows[id];
347
+
348
+ // `route` STRIPS the selector, so `window_x_open 1` emerges as a bare `1`. A
349
+ // message box would try to interpret that as its own argument, so bang it
350
+ // instead: [t b] makes the trigger unambiguous whatever the app sent.
351
+ //
352
+ // The words are [pcontrol]'s: `open` and `close`. NOT `wclose` - that is
353
+ // [thispatcher]'s vocabulary, and pcontrol says so out loud
354
+ // ("pcontrol: doesn't understand \"wclose\""), which is the one thing an
355
+ // unrecognised NAME usually does not do. See pcontrol.maxref.xml.
356
+ for (const [outlet, verb, tag] of [
357
+ [index * 2, "open", "open"],
358
+ [index * 2 + 1, "close", "close"],
359
+ ]) {
360
+ const triggerId = `obj-window-${id}-t-${tag}`;
361
+ const msgId = `obj-window-${id}-${tag}msg`;
362
+ boxes.push(box(triggerId, "t b"));
363
+ // A MESSAGE box: maxclass "message", the message in `text`. Not a newobj.
364
+ boxes.push(box(msgId, verb, { maxclass: "message", numinlets: 2, numoutlets: 1 }));
365
+ lines.push(line(routeId, outlet, triggerId, 0));
366
+ lines.push(line(triggerId, 0, msgId, 0));
367
+ lines.push(line(msgId, 0, `obj-window-${id}-pcontrol`, 0));
368
+ }
369
+
370
+ // [pcontrol] is the supported way to open a subpatcher's window from outside
371
+ // it. `open` shows it, `wclose` hides it.
372
+ const pcontrolId = `obj-window-${id}-pcontrol`;
373
+ boxes.push(box(pcontrolId, "pcontrol"));
374
+
375
+ const subpatcherId = `obj-window-${id}-sub`;
376
+ lines.push(line(pcontrolId, 0, subpatcherId, 0));
377
+ boxes.push({
378
+ box: {
379
+ id: subpatcherId,
380
+ maxclass: "newobj",
381
+ text: `p ${spec.title}`,
382
+ // The inlet is not decoration: Max will not connect a patch cord to a
383
+ // subpatcher that has no inlets, so [pcontrol] would end up wired to
384
+ // NOTHING - silently, in the saved file. That was attempt 1.
385
+ numinlets: 1,
386
+ numoutlets: 0,
387
+ patching_rect: [16, 620, 120, 22],
388
+ patcher: {
389
+ fileversion: 1,
390
+ appversion: { major: 8, minor: 0, revision: 0, architecture: "x64", modernui: 1 },
391
+ rect: [100, 100, spec.width, spec.height],
392
+ openinpresentation: 1,
393
+ boxes: [
394
+ { box: { id: "obj-in", maxclass: "inlet", patching_rect: [16, 16, 30, 30], numinlets: 0, numoutlets: 1, outlettype: [""] } },
395
+ // The page's URL cannot be WIRED here - this [jweb] is inside a
396
+ // subpatcher and the wrapper's [js] is outside it. The wrapper reaches
397
+ // it by NAME instead (messnamed), once the payload is extracted.
398
+ {
399
+ box: {
400
+ id: "obj-recv",
401
+ maxclass: "newobj",
402
+ text: `r window-read-${id}`,
403
+ numinlets: 0,
404
+ numoutlets: 1,
405
+ outlettype: [""],
406
+ patching_rect: [16, 56, 160, 22],
407
+ },
408
+ },
409
+ {
410
+ box: {
411
+ id: "obj-jweb",
412
+ maxclass: "jweb",
413
+ numinlets: 1,
414
+ numoutlets: 2,
415
+ outlettype: ["", ""],
416
+ patching_rect: [16, 96, spec.width, spec.height],
417
+ presentation: 1,
418
+ presentation_rect: [0, 0, spec.width, spec.height],
419
+ },
420
+ },
421
+ ],
422
+ lines: [{ patchline: { source: ["obj-recv", 0], destination: ["obj-jweb", 0] } }],
423
+ },
424
+ },
425
+ });
426
+ });
427
+ }
428
+
429
+ /**
430
+ * Compile a declared `state` slot: a named [dict] the app reads and writes, and a
431
+ * [pattr] bound to it, which is what actually SAVES.
432
+ *
433
+ * ------------------------------------------------------------------------------
434
+ * WHAT MAKES LIVE SAVE IT IS `parameter_enable`, and nothing else.
435
+ *
436
+ * A pattr on its own persists in a PATCHER. A Max for Live device is not saved as a
437
+ * patcher - Live saves the SET - so the pattr's value goes with it only when the
438
+ * pattr is a Live parameter. Max's own pattr help says so in one line:
439
+ *
440
+ * "In Max for Live, if you activate the parameter_enable attribute, the pattr
441
+ * value will be saved with the Live set."
442
+ *
443
+ * The first version of this emitted `@save 1`, which is not a pattr attribute at
444
+ * all ("pattr: 'save' is not a valid attribute argument"), and `@autorestore 1`,
445
+ * which restores from the PATCHER and so does nothing here. The state survived a
446
+ * reload of the page and would not have survived a reload of the set.
447
+ *
448
+ * The recipe below is copied from a device Ableton ships - `pattr Delays` in
449
+ * "Max DelayTaps.amxd", which persists an array of tap times exactly this way:
450
+ *
451
+ * parameter_type 3 BLOB. Not a float, not an enum - an opaque value Live
452
+ * stores and hands back, which is the whole point: JSON is
453
+ * not a number and must never pretend to be one.
454
+ * parameter_invisible 1 ...so it stays out of the automation lane and off Push.
455
+ * A blob cannot be automated, and a slot that offered to be
456
+ * would be a lie in every Live UI that listed it.
457
+ * parameter_enable 1 in saved_object_attributes - the switch itself.
458
+ *
459
+ * `dict` is an OBJECT (`maxclass: "newobj"`, text `dict <name>`), not a box class.
460
+ * It was emitted as `maxclass: "dict"` - a box class Max does not have, so the box
461
+ * never instantiated and the pattr bound to nothing. Same mistake as the windows,
462
+ * same silence.
463
+ * ------------------------------------------------------------------------------
464
+ */
465
+ export function applyPersistence(ctx) {
466
+ const { boxes, surface } = ctx;
467
+ const stateIds = surface?.state ? Object.keys(surface.state) : [];
468
+
469
+ for (const id of stateIds) {
470
+ const dictId = `obj-state-${id}`;
471
+ // `varname` is the SCRIPTING name, which is what [pattr]'s @bindto resolves -
472
+ // it binds to a NAMED object in the patcher, not to a box id (which is ours, and
473
+ // which Max is free to renumber). The dict's own name argument is what [js]
474
+ // addresses with `new Dict("obj-state-<id>")`, so all three agree.
475
+ boxes.push(box(dictId, `dict ${dictId}`, { varname: dictId, numinlets: 2, numoutlets: 4, outlettype: ["dictionary", "", "", ""] }));
476
+
477
+ const pattrId = `obj-pattr-${id}`;
478
+ boxes.push(
479
+ box(pattrId, `pattr ${pattrId} @bindto ${dictId}`, {
480
+ numinlets: 2,
481
+ numoutlets: 3,
482
+ outlettype: ["", "", ""],
483
+ varname: pattrId,
484
+ saved_attribute_attributes: {
485
+ valueof: {
486
+ parameter_longname: pattrId,
487
+ // Live truncates a short name at 8 characters (see the parameter
488
+ // compiler above), and it is never displayed for an invisible blob -
489
+ // but it must still be there and still be unique.
490
+ parameter_shortname: `st_${id}`.slice(0, 8),
491
+ parameter_type: 3, // blob
492
+ parameter_invisible: 1,
493
+ },
494
+ },
495
+ saved_object_attributes: {
496
+ parameter_enable: 1, // THE switch: no parameter, no save.
497
+ },
498
+ }),
499
+ );
500
+ }
501
+ }
@@ -17,13 +17,13 @@
17
17
  "format": "prettier --write \"src/**/*.{ts,tsx,css}\" \"scripts/*.mjs\" \"patcher/*.mjs\""
18
18
  },
19
19
  "dependencies": {
20
- "@m4l-jweb/bridge": "^0.5.0",
21
- "@m4l-jweb/surface": "^0.5.0",
20
+ "@m4l-jweb/bridge": "^0.6.0",
21
+ "@m4l-jweb/surface": "^0.6.0",
22
22
  "react": "^19.0.0",
23
23
  "react-dom": "^19.0.0"
24
24
  },
25
25
  "devDependencies": {
26
- "@m4l-jweb/build": "^0.5.0",
26
+ "@m4l-jweb/build": "^0.6.0",
27
27
  "@types/node": "^22.0.0",
28
28
  "@types/react": "^19.0.0",
29
29
  "@types/react-dom": "^19.0.0",
@@ -1,19 +1,50 @@
1
1
  /**
2
- * build-ui.mjs - bundle one self-contained UI per device.
2
+ * build-ui.mjs - bundle one self-contained UI per device, plus one per WINDOW.
3
3
  *
4
- * Emits dist/ui/<device>/index.html, which `m4l-jweb build` then embeds into the
5
- * matching .amxd as a base64 payload.
4
+ * Emits dist/ui/<device>/index.html (the device view) and, for every window the
5
+ * device declares in its surface.ts, dist/ui/<device>/<winId>.html. `m4l-jweb
6
+ * build` embeds all of them into the matching .amxd as base64 payloads.
6
7
  *
7
8
  * Sequential, not parallel: vite reads DEVICE from the environment, and two
8
9
  * concurrent builds would race on it. The builds are ~500 ms each.
10
+ *
11
+ * THE RENAME DANCE around the window builds is load-bearing. Vite always writes
12
+ * its entry as `index.html`, so a window build lands on top of the device view we
13
+ * just produced - and `emptyOutDir` cannot be left on for it either, or the window
14
+ * build wipes the folder instead. So the device view is moved aside, the windows
15
+ * are built and renamed one by one, and it is moved back.
9
16
  */
10
17
  import { build } from "vite";
11
18
  import { uiDirs } from "./devices.mjs";
19
+ // The PACKAGE export, not a path into packages/ - this file is copied verbatim
20
+ // into the `m4l-jweb init` scaffold (tests/starter.test.mjs pins that), and a
21
+ // scaffolded repo has no packages/ directory to reach into.
22
+ import { loadSurface } from "@m4l-jweb/build/surface";
23
+ import { renameSync } from "node:fs";
24
+ import path from "node:path";
25
+
26
+ const root = process.cwd();
12
27
 
13
28
  for (const dir of uiDirs) {
14
29
  process.env.DEVICE = dir;
30
+ delete process.env.WINDOW; // ...so this build empties the out dir and is the device view
15
31
  console.log(`\nm4l-jweb: bundling UI for ${dir}`);
16
32
  await build();
33
+
34
+ const outDir = path.join(root, "dist", "ui", dir);
35
+ const surface = await loadSurface(root, dir);
36
+ const windows = surface?.windows ? Object.keys(surface.windows) : [];
37
+ if (!windows.length) continue;
38
+
39
+ renameSync(path.join(outDir, "index.html"), path.join(outDir, "_device.html"));
40
+ for (const winId of windows) {
41
+ process.env.WINDOW = winId;
42
+ process.env.WINDOW_ENTRY = surface.windows[winId].entry;
43
+ console.log(`\nm4l-jweb: bundling window ${winId} for ${dir}`);
44
+ await build();
45
+ renameSync(path.join(outDir, "index.html"), path.join(outDir, `${winId}.html`));
46
+ }
47
+ renameSync(path.join(outDir, "_device.html"), path.join(outDir, "index.html"));
17
48
  }
18
49
 
19
50
  console.log(`\nm4l-jweb: ${uiDirs.length} UI bundle(s) -> dist/ui/`);
@@ -33,11 +33,14 @@ export default defineConfig(() => {
33
33
  // The device UI is bundled into ONE self-contained index.html (every script,
34
34
  // style and asset inlined) so it can be embedded in the .amxd as a base64
35
35
  // payload and extracted to a real file:// path that jweb (Chromium) reads.
36
+ const WINDOW_ENTRY = process.env.WINDOW_ENTRY;
37
+
36
38
  const config: UserConfig = {
37
39
  base: "./",
38
40
  plugins: [react(), viteSingleFile()],
39
41
  resolve: {
40
42
  alias: [
43
+ { find: "@device/App", replacement: fileURLToPath(new URL(`./src/app/${DEVICE}/${WINDOW_ENTRY ? WINDOW_ENTRY : "App"}`, import.meta.url)) },
41
44
  { find: "@device", replacement: fileURLToPath(new URL(`./src/app/${DEVICE}`, import.meta.url)) },
42
45
  { find: "@", replacement: fileURLToPath(new URL("./src", import.meta.url)) },
43
46
  ],
@@ -60,7 +63,7 @@ export default defineConfig(() => {
60
63
  build: {
61
64
  // dist/ui/<device>/index.html - one per device, picked up by `m4l-jweb build`.
62
65
  outDir: `dist/ui/${DEVICE}`,
63
- emptyOutDir: true,
66
+ emptyOutDir: !process.env.WINDOW,
64
67
  },
65
68
  };
66
69
  return config;