@m4l-jweb/build 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m4l-jweb/build",
3
- "version": "1.0.0",
3
+ "version": "1.2.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",
@@ -34,6 +34,6 @@
34
34
  "archiver": "^7.0.1",
35
35
  "esbuild": "^0.25.0",
36
36
  "typescript": "^5.7.0",
37
- "@m4l-jweb/wrapper": "1.0.0"
37
+ "@m4l-jweb/wrapper": "1.2.0"
38
38
  }
39
39
  }
package/src/chains.mjs CHANGED
@@ -126,8 +126,8 @@ export const AUDIO_OUT = "obj-plugout";
126
126
  * buffer takes its name from its creation argument and there is no documented runtime
127
127
  * rename), so the scoping has to be a load-time substitution Max itself performs.
128
128
  *
129
- * `#0` WAS TRIED AND DOES NOT WORK (spike, doc/TODO.md item 0, run 2026-07-17 in
130
- * Live). `#0` is documented for abstractions, and an .amxd device patcher turned out
129
+ * `#0` WAS TRIED AND DOES NOT WORK (spike run 2026-07-17 in Live; see
130
+ * doc/MAX-FACTS.md). `#0` is documented for abstractions, and an .amxd device patcher turned out
131
131
  * not to count as one: the token stayed literal in every instance, so writer and
132
132
  * reader still agreed on one global name and the collision survived, silently.
133
133
  *
@@ -645,8 +645,8 @@ function crushChain(ctx) {
645
645
  * drives - the app resolves that (our own filter, or an Auto Filter the user placed by
646
646
  * hand) and says so. That is what makes this bigger than an LFO on our own DSP: a slot
647
647
  * can point at any parameter in the set. **LOM ids are not stable across set reloads**,
648
- * so the app must re-bind on load and must never persist a raw id - see the diff rules
649
- * in doc/TODO.md item 1.
648
+ * so the app must re-bind on load and must never persist a raw id - see bindRemote() in
649
+ * @m4l-jweb/bridge for the rule.
650
650
  *
651
651
  * NOT IN THE SIGNAL PATH. It touches no audio: `ctx.audioIn`/`setAudioOut` are never
652
652
  * called, so `remote` composes with any chain list without taking a stage. It has no
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, readdirSync } from "node:fs";
11
+ import { cpSync, 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";
@@ -283,6 +283,43 @@ export async function generatePatchers(root) {
283
283
  * wrapper.js. The loose ui.html/wrapper.js are for inspection, not a runtime
284
284
  * requirement.
285
285
  */
286
+ /**
287
+ * Deliver a `site:` window's content as a SIDECAR FOLDER, and tell the wrapper
288
+ * where it is.
289
+ *
290
+ * Every other window rides inside wrapper.js as base64 and is written to a real
291
+ * file on first load. That works because a window is one self-contained HTML file
292
+ * of a few hundred kB. A whole prebuilt site is tens of MB across hundreds of
293
+ * files, and base64 is 4 bytes per 3 - so it ships as a plain folder next to the
294
+ * .amxd instead, and the wrapper points the window's [jweb] at
295
+ * `file:///<device folder>/<device>-site/<window>/index.html`.
296
+ *
297
+ * The cost is honest and documented: the .amxd is no longer self-contained, and
298
+ * the folder has to travel with it. The wrapper says so out loud when the folder
299
+ * is missing rather than opening a blank window.
300
+ */
301
+ function siteWindowsBanner(root, outDir, d, surface) {
302
+ const windows = surface?.windows ?? {};
303
+ const ids = Object.keys(windows).filter((id) => windows[id].site);
304
+ if (!ids.length) return "";
305
+
306
+ const map = {};
307
+ for (const id of ids) {
308
+ const from = path.join(root, windows[id].site);
309
+ if (!existsSync(path.join(from, "index.html"))) {
310
+ throw new Error(
311
+ `window "${id}" of "${d.name}" declares site "${windows[id].site}", but there is no index.html there - ` +
312
+ `build the site first (this repo: \`pnpm build:repl\`)`,
313
+ );
314
+ }
315
+ const rel = `${d.name}-site/${id}`;
316
+ cpSync(from, path.join(outDir, rel), { recursive: true });
317
+ map[id] = `${rel}/index.html`;
318
+ console.log(`m4l-jweb: ${windows[id].site} -> dist/${path.basename(outDir)}/${rel}/ (sidecar)`);
319
+ }
320
+ return `var SITE_WINDOWS = ${JSON.stringify(map)};\n`;
321
+ }
322
+
286
323
  export async function packageDevices(root) {
287
324
  const dist = path.join(root, "dist");
288
325
  const { name, version } = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8"));
@@ -333,7 +370,7 @@ export async function packageDevices(root) {
333
370
  // The device's declared watches ride in as a data banner, like the build stamp:
334
371
  // WATCH_SPECS is what the packaged wrapper's setupWatches() attaches observers from.
335
372
  const watch = await loadWatch(root, d.ui ?? d.name);
336
- let wrapperData = banner + watchSpecsBanner(watch) + wrapperJs;
373
+ let wrapperData = banner + watchSpecsBanner(watch) + siteWindowsBanner(root, outDir, d, await loadSurface(root, d.ui ?? d.name)) + wrapperJs;
337
374
  const uiDirContent = readdirSync(path.join(dist, "ui", d.ui ?? d.name)).filter((f) => f.endsWith(".html"));
338
375
 
339
376
  // Main UI payload
@@ -420,6 +457,14 @@ export async function packageDevices(root) {
420
457
  for (const f of installers) {
421
458
  archive.file(path.join(templates, f), { name: f, mode: 0o755 });
422
459
  }
460
+ // A `site:` window's sidecar folder is part of the release, not an extra: the
461
+ // .amxd alone opens that window empty. It is a directory rather than a listed
462
+ // file, so it is added as a tree.
463
+ for (const d of readdirSync(outDir, { withFileTypes: true })) {
464
+ if (d.isDirectory() && d.name.slice(-5) === "-site") {
465
+ archive.directory(path.join(outDir, d.name), `${name}/${d.name}`);
466
+ }
467
+ }
423
468
  archive.finalize();
424
469
  });
425
470
 
package/src/surface.mjs CHANGED
@@ -202,8 +202,44 @@ const PITCH_Y = 56;
202
202
  export function computeNativeSlots(surface) {
203
203
  const native = surface?.layout?.native;
204
204
  if (!native || native.params.length === 0) return { slots: new Map(), width: 0 };
205
- const rows = native.rows ?? 3;
206
205
  const slots = new Map();
206
+
207
+ // ROW SIZES, given explicitly: `rows: [1, 4, 4]` is one control on the first row
208
+ // and four on each of the next two. Column-major filling cannot express that -
209
+ // a transport button above two banks of dials came out interleaved with them -
210
+ // and it is what a panel actually wants to say.
211
+ if (Array.isArray(native.rows)) {
212
+ let i = 0;
213
+ let y = MARGIN;
214
+ let width = 0;
215
+ for (let r = 0; r < native.rows.length; r++) {
216
+ let x = MARGIN;
217
+ let rowH = 0;
218
+ for (let c = 0; c < native.rows[r] && i < native.params.length; c++, i++) {
219
+ const id = native.params[i];
220
+ const [w, h] = NATIVE_SIZE[surface.params[id].kind];
221
+ slots.set(id, [x, y, w, h]);
222
+ x += w + MARGIN;
223
+ rowH = Math.max(rowH, h);
224
+ }
225
+ width = Math.max(width, x);
226
+ y += Math.max(rowH, PITCH_Y - MARGIN) + MARGIN;
227
+ }
228
+ // Anything the rows did not account for still needs a rect, or it would have no
229
+ // presentation and simply not appear. Put it on one more row rather than
230
+ // silently dropping it.
231
+ let x = MARGIN;
232
+ for (; i < native.params.length; i++) {
233
+ const id = native.params[i];
234
+ const [w, h] = NATIVE_SIZE[surface.params[id].kind];
235
+ slots.set(id, [x, y, w, h]);
236
+ x += w + MARGIN;
237
+ width = Math.max(width, x);
238
+ }
239
+ return { slots, width };
240
+ }
241
+
242
+ const rows = native.rows ?? 3;
207
243
  let row = 0;
208
244
  let colW = 0;
209
245
  let x = MARGIN;
@@ -528,9 +564,11 @@ export function applySurface(ctx) {
528
564
  */
529
565
  const FLOAT_MSG = "window flags grow close title float, window exec";
530
566
 
531
- function floatBoxes(spec) {
567
+ function floatBoxes(spec, audio = false) {
532
568
  if (!spec.alwaysOnTop) return [];
533
- const y = 96 + spec.height + 80;
569
+ // A sounding window shows its patching canvas, so anything that is not the page
570
+ // is parked above the origin, out of sight (see FITTED WINDOWS).
571
+ const y = audio ? -140 : 96 + spec.height + 80;
534
572
  return [
535
573
  { box: { id: "obj-float-loadbang", maxclass: "newobj", text: "loadbang", numinlets: 1, numoutlets: 1, outlettype: ["bang"], patching_rect: [220, y, 60, 22] } },
536
574
  // A MESSAGE box - the comma is what makes it two messages, which is the point.
@@ -547,6 +585,140 @@ function floatLines(spec) {
547
585
  ];
548
586
  }
549
587
 
588
+ /**
589
+ * The `[jweb~]` a sounding window holds, and the pair of `[outlet]`s its L and R
590
+ * leave the subpatcher on.
591
+ *
592
+ * ------------------------------------------------------------------------------
593
+ * WHY A SUBPATCHER CAN CARRY A SIGNAL AT ALL
594
+ *
595
+ * An `[outlet]` inside a `[p]` is typeless until something is wired to it; wire a
596
+ * signal to it and the corresponding outlet on the parent `[p]` box IS a signal
597
+ * outlet. What decides WHICH outlet is which is the x position of the outlet boxes
598
+ * in the subpatcher - left to right - which is why L sits at x=16, R at x=56 and
599
+ * the message outlet far right at x=200. The `[p]` box declares the result in
600
+ * `outlettype` so the saved file already says signal, signal, message.
601
+ *
602
+ * The device view's own `[jweb~]` (templates/base.json) has exactly this shape:
603
+ * three outlets, L, R and the page's messages. A window is the same object in a
604
+ * subpatcher, so the `webaudio` chain's mix stage applies unchanged.
605
+ */
606
+ function audioWindowBoxes(id, spec) {
607
+ return [
608
+ {
609
+ box: {
610
+ id: "obj-jweb",
611
+ maxclass: "jweb~",
612
+ numinlets: 1,
613
+ numoutlets: 3,
614
+ outlettype: ["signal", "signal", ""],
615
+ rendermode: spec.rendermode ?? 1,
616
+ // The ring buffer between Chromium's audio thread and MSP. Unset keeps the
617
+ // object's default; the surface spec documents the documented range.
618
+ ...(spec.latency != null ? { latency: spec.latency } : {}),
619
+ // On the PATCHING canvas at the origin, filling the window. See FITTED
620
+ // WINDOWS below for why this one is not in presentation like the others.
621
+ patching_rect: [0, 0, spec.width, spec.height],
622
+ varname: "obj-jweb",
623
+ },
624
+ },
625
+ { box: { id: "obj-out-l", maxclass: "outlet", patching_rect: [16, HELPER_Y + 80, 30, 30], numinlets: 1, numoutlets: 0, comment: "signal L" } },
626
+ { box: { id: "obj-out-r", maxclass: "outlet", patching_rect: [56, HELPER_Y + 80, 30, 30], numinlets: 1, numoutlets: 0, comment: "signal R" } },
627
+ ];
628
+ }
629
+
630
+ /**
631
+ * FITTED WINDOWS - where the plumbing goes so the page can own the window.
632
+ *
633
+ * A window that is worth resizing (an editor, a whole REPL) has to have its page
634
+ * grow with it, and the wrapper does that by writing the [jweb]'s rect at runtime.
635
+ * MEASURED, in this repo: a runtime rect change is accepted but never redrawn when
636
+ * the object is shown in PRESENTATION - which is how every window here was laid
637
+ * out. On the patching canvas it does redraw, and that is the whole reason a
638
+ * sounding window is built differently: `openinpresentation` is off, the page sits
639
+ * at the canvas origin, and everything else - the inlet, the receive, the tag, the
640
+ * outlets - is moved ABOVE the origin, off the top of what the window shows.
641
+ *
642
+ * The plain window is untouched by this and stays in presentation. It is a fixed
643
+ * reference card; nothing about it wants to be dragged bigger.
644
+ */
645
+ const HELPER_Y = -260;
646
+
647
+ /**
648
+ * Sum a sounding window into the device's audio path, and make sure its page is
649
+ * actually LOADED.
650
+ *
651
+ * The mix is the `webaudio` chain's stage shape (chains.mjs), claimed here because
652
+ * `applyWindows` runs before `closeAudio` - the window is just another stage in the
653
+ * series, and `ctx.audioIn`/`ctx.setAudioOut` are the hand-off.
654
+ *
655
+ * The keepalive is the part that is NOT obvious. A `[jweb]` in a subpatcher whose
656
+ * window has never been opened may never load its page, and a page that never
657
+ * loaded has no AudioContext and makes no sound - a device that is silent until the
658
+ * user happens to open a window would be a trap. So `loadbang` pulses the window
659
+ * open and closes it again a moment later: the page loads, the audio starts, and
660
+ * the window is out of the way. It is deliberately a PULSE and not a hidden open -
661
+ * `[pcontrol]`'s `close` is the same message the app sends, so nothing special
662
+ * needs to be true about the window afterwards.
663
+ */
664
+ function mixAudioWindow(ctx, id, subpatcherId, pcontrolId, spec = {}) {
665
+ const { boxes, lines, device } = ctx;
666
+
667
+ // openAudio() leaves ctx.audioIn throwing on a MIDI device, but its message talks
668
+ // about chains. Say what is actually wrong here.
669
+ if (!ctx.audioTail) {
670
+ throw new Error(
671
+ `window "${id}" is declared \`audio: true\`, but device "${device?.name}" is type "${device?.type}". ` +
672
+ `A sounding window needs the signal path: set \`type: "audio"\` or \`type: "instrument"\` in patcher/devices.mjs.`,
673
+ );
674
+ }
675
+
676
+ for (const ch of [0, 1]) {
677
+ const [srcId, srcOut] = ctx.audioIn(ch);
678
+ const mixId = `obj-window-${id}-mix-${ch === 0 ? "l" : "r"}`;
679
+ boxes.push(box(mixId, "+~", { numinlets: 2, numoutlets: 1, outlettype: ["signal"] }));
680
+ lines.push(line(srcId, srcOut, mixId, 0)); // whatever the last stage left
681
+ lines.push(line(subpatcherId, ch, mixId, 1)); // the window's page
682
+ ctx.setAudioOut(ch, mixId, 0);
683
+ }
684
+
685
+ // The LEVEL of what this window is playing, as messages, for a page to draw.
686
+ //
687
+ // It has to be messages: [jweb~] is "Web browser with audio output" - one control
688
+ // inlet, no signal inlet - so no page can be handed a signal, and the device view
689
+ // cannot see the window's audio any other way. [peakamp~] reports the peak since
690
+ // the last report, which is what a meter wants and costs a float per channel per
691
+ // interval rather than a stream of samples.
692
+ const tap = (suffix) => `obj-window-${id}-peak-${suffix}`;
693
+ // Default 10 ms - fast enough that the trace reads as the shape of the sound rather
694
+ // than a meter twitching. It is still an ENVELOPE and not a waveform: what crosses is
695
+ // one float per channel per report, not samples, because no page can be handed audio.
696
+ const levelMs = spec.levelInterval ?? 10;
697
+ boxes.push(box(tap("l"), `peakamp~ ${levelMs}`, { numinlets: 1, numoutlets: 1, outlettype: ["float"] }));
698
+ boxes.push(box(tap("r"), `peakamp~ ${levelMs}`, { numinlets: 1, numoutlets: 1, outlettype: ["float"] }));
699
+ // `pak` and not `pack`: both inlets hot, so a change on either channel reports
700
+ // rather than waiting for the left one to move.
701
+ boxes.push(box(tap("pak"), "pak f f", { numinlets: 2, numoutlets: 1, outlettype: [""] }));
702
+ boxes.push(box(tap("pre"), `prepend window_level ${id}`, { numinlets: 1, numoutlets: 1, outlettype: [""] }));
703
+ lines.push(line(subpatcherId, 0, tap("l"), 0));
704
+ lines.push(line(subpatcherId, 1, tap("r"), 0));
705
+ lines.push(line(tap("l"), 0, tap("pak"), 0));
706
+ lines.push(line(tap("r"), 0, tap("pak"), 1));
707
+ lines.push(line(tap("pak"), 0, tap("pre"), 0));
708
+ lines.push(line(tap("pre"), 0, ctx.unmatchedId, 0));
709
+
710
+ const ka = (suffix) => `obj-window-${id}-ka-${suffix}`;
711
+ boxes.push(box(ka("loadbang"), "loadbang", { numinlets: 1, numoutlets: 1, outlettype: ["bang"] }));
712
+ boxes.push(box(ka("open"), "open", { maxclass: "message", numinlets: 2, numoutlets: 1 }));
713
+ boxes.push(box(ka("delay"), "delay 1500", { numinlets: 2, numoutlets: 1, outlettype: ["bang"] }));
714
+ boxes.push(box(ka("close"), "close", { maxclass: "message", numinlets: 2, numoutlets: 1 }));
715
+ lines.push(line(ka("loadbang"), 0, ka("open"), 0));
716
+ lines.push(line(ka("loadbang"), 0, ka("delay"), 0));
717
+ lines.push(line(ka("delay"), 0, ka("close"), 0));
718
+ lines.push(line(ka("open"), 0, pcontrolId, 0));
719
+ lines.push(line(ka("close"), 0, pcontrolId, 0));
720
+ }
721
+
550
722
  export function applyWindows(ctx) {
551
723
  const { boxes, lines, surface, unmatchedId } = ctx;
552
724
  const windowIds = surface?.windows ? Object.keys(surface.windows) : [];
@@ -596,6 +768,11 @@ export function applyWindows(ctx) {
596
768
  const pcontrolId = `obj-window-${id}-pcontrol`;
597
769
  boxes.push(box(pcontrolId, "pcontrol"));
598
770
 
771
+ // A SOUNDING window: [jweb~] with two signal outlets ahead of the message one.
772
+ // Everything above this point - the route, the triggers, [pcontrol] - is the
773
+ // same for both kinds, because opening a window is opening a window.
774
+ const audio = !!spec.audio;
775
+
599
776
  const subpatcherId = `obj-window-${id}-sub`;
600
777
  lines.push(line(pcontrolId, 0, subpatcherId, 0));
601
778
  // The window's [jweb] can now TALK BACK. Its output leaves the subpatcher on an
@@ -603,7 +780,10 @@ export function applyWindows(ctx) {
603
780
  // device view feeds. It is tagged `window <id>` inside (below), so the wrapper
604
781
  // tells the two apart and can answer the right one. Without this the window's
605
782
  // page could display but never send a message: the [jweb] outlet went nowhere.
606
- lines.push(line(subpatcherId, 0, unmatchedId, 0));
783
+ //
784
+ // On a sounding window the messages are the LAST outlet: 0 and 1 are the page's
785
+ // L and R.
786
+ lines.push(line(subpatcherId, audio ? 2 : 0, unmatchedId, 0));
607
787
  boxes.push({
608
788
  box: {
609
789
  id: subpatcherId,
@@ -613,16 +793,22 @@ export function applyWindows(ctx) {
613
793
  // subpatcher that has no inlets, so [pcontrol] would end up wired to
614
794
  // NOTHING - silently, in the saved file. That was attempt 1.
615
795
  numinlets: 1,
616
- numoutlets: 1,
617
- outlettype: [""],
796
+ numoutlets: audio ? 3 : 1,
797
+ outlettype: audio ? ["signal", "signal", ""] : [""],
798
+ // A scripting name, so [js] can reach INTO this subpatcher at runtime
799
+ // (getnamed -> subpatcher()). That is how the window's page is resized to
800
+ // follow the window - see followWindowSizes() in the wrapper.
801
+ varname: `window-${id}`,
618
802
  patching_rect: [16, 620, 120, 22],
619
803
  patcher: {
620
804
  fileversion: 1,
621
805
  appversion: { major: 8, minor: 0, revision: 0, architecture: "x64", modernui: 1 },
622
806
  rect: [100, 100, spec.width, spec.height],
623
- openinpresentation: 1,
807
+ // A sounding window shows its PATCHING canvas, so the page can be resized
808
+ // at runtime - see FITTED WINDOWS above.
809
+ openinpresentation: audio ? 0 : 1,
624
810
  boxes: [
625
- { box: { id: "obj-in", maxclass: "inlet", patching_rect: [16, 16, 30, 30], numinlets: 0, numoutlets: 1, outlettype: [""] } },
811
+ { box: { id: "obj-in", maxclass: "inlet", patching_rect: [16, audio ? HELPER_Y : 16, 30, 30], numinlets: 0, numoutlets: 1, outlettype: [""] } },
626
812
  // The page's URL cannot be WIRED here - this [jweb] is inside a
627
813
  // subpatcher and the wrapper's [js] is outside it. The wrapper reaches
628
814
  // it by NAME instead (messnamed), once the payload is extracted.
@@ -634,21 +820,26 @@ export function applyWindows(ctx) {
634
820
  numinlets: 0,
635
821
  numoutlets: 1,
636
822
  outlettype: [""],
637
- patching_rect: [16, 56, 160, 22],
638
- },
639
- },
640
- {
641
- box: {
642
- id: "obj-jweb",
643
- maxclass: "jweb",
644
- numinlets: 1,
645
- numoutlets: 2,
646
- outlettype: ["", ""],
647
- patching_rect: [16, 96, spec.width, spec.height],
648
- presentation: 1,
649
- presentation_rect: [0, 0, spec.width, spec.height],
823
+ patching_rect: [16, audio ? HELPER_Y + 40 : 56, 160, 22],
650
824
  },
651
825
  },
826
+ ...(audio
827
+ ? audioWindowBoxes(id, spec)
828
+ : [
829
+ {
830
+ box: {
831
+ id: "obj-jweb",
832
+ maxclass: "jweb",
833
+ numinlets: 1,
834
+ numoutlets: 2,
835
+ outlettype: ["", ""],
836
+ patching_rect: [16, 96, spec.width, spec.height],
837
+ presentation: 1,
838
+ presentation_rect: [0, 0, spec.width, spec.height],
839
+ varname: "obj-jweb",
840
+ },
841
+ },
842
+ ]),
652
843
  // TAG the window's messages with which window they are, so the wrapper's
653
844
  // `window()` can answer THIS window (its [jweb] has no cord from [js], so
654
845
  // a reply goes back by name). The page emits bare selectors; the tag is
@@ -662,22 +853,41 @@ export function applyWindows(ctx) {
662
853
  numinlets: 1,
663
854
  numoutlets: 1,
664
855
  outlettype: [""],
665
- patching_rect: [16, 96 + spec.height + 16, 160, 22],
856
+ // On a sounding window the signal outlets own the left of the row,
857
+ // and outlet ORDER is x order - so the messages move right.
858
+ patching_rect: [audio ? 200 : 16, audio ? HELPER_Y + 40 : 96 + spec.height + 16, 160, 22],
666
859
  },
667
860
  },
668
- { box: { id: "obj-out", maxclass: "outlet", patching_rect: [16, 96 + spec.height + 48, 30, 30], numinlets: 1, numoutlets: 0 } },
669
- ...floatBoxes(spec),
861
+ {
862
+ box: {
863
+ id: "obj-out",
864
+ maxclass: "outlet",
865
+ patching_rect: [audio ? 200 : 16, audio ? HELPER_Y + 80 : 96 + spec.height + 48, 30, 30],
866
+ numinlets: 1,
867
+ numoutlets: 0,
868
+ },
869
+ },
870
+ ...floatBoxes(spec, audio),
670
871
  ],
671
872
  lines: [
672
873
  { patchline: { source: ["obj-recv", 0], destination: ["obj-jweb", 0] } },
673
874
  // [jweb] outlet 0 is the page's messages; tag them and send them out.
674
- { patchline: { source: ["obj-jweb", 0], destination: ["obj-tag", 0] } },
875
+ // On [jweb~] that is outlet 2 - 0 and 1 carry the sound.
876
+ { patchline: { source: ["obj-jweb", audio ? 2 : 0], destination: ["obj-tag", 0] } },
675
877
  { patchline: { source: ["obj-tag", 0], destination: ["obj-out", 0] } },
878
+ ...(audio
879
+ ? [
880
+ { patchline: { source: ["obj-jweb", 0], destination: ["obj-out-l", 0] } },
881
+ { patchline: { source: ["obj-jweb", 1], destination: ["obj-out-r", 0] } },
882
+ ]
883
+ : []),
676
884
  ...floatLines(spec),
677
885
  ],
678
886
  },
679
887
  },
680
888
  });
889
+
890
+ if (audio) mixAudioWindow(ctx, id, subpatcherId, pcontrolId, spec);
681
891
  });
682
892
  }
683
893
 
@@ -61,6 +61,16 @@ for f in "$src"/*.adg "$src"/*.adv; do
61
61
  echo " installed $(basename "$f") (preset)"
62
62
  done
63
63
 
64
+ # A `site:` window's content is a whole prebuilt site - too big to ride inside the
65
+ # .amxd as a payload - so it ships as a folder NEXT TO the device and has to be
66
+ # installed with it. Without the folder the device still plays; that window opens
67
+ # empty, and the wrapper says so in the Max console.
68
+ for d in "$src"/*-site; do
69
+ [ -d "$d" ] || continue
70
+ cp -R "$d" "$dest/"
71
+ echo " installed $(basename "$d")/ (site sidecar)"
72
+ done
73
+
64
74
  echo "Installed to $dest"
65
75
  echo "In Live: User Library > Max For Live > $device_name"
66
76
  echo "NOTE: Live embeds a copy of the device in the set. Instances already"
@@ -65,6 +65,15 @@ foreach ($f in @(Get-ChildItem (Join-Path $src "*.adg") -ErrorAction SilentlyCon
65
65
  Write-Host " installed $($f.Name) (preset)"
66
66
  }
67
67
 
68
+ # A `site:` window's content is a whole prebuilt site - too big to ride inside the
69
+ # .amxd as a payload - so it ships as a folder NEXT TO the device and has to be
70
+ # installed with it. Without the folder the device still plays; that window opens
71
+ # empty, and the wrapper says so in the Max console.
72
+ foreach ($d in @(Get-ChildItem (Join-Path $src "*-site") -Directory -ErrorAction SilentlyContinue)) {
73
+ Copy-Item $d.FullName $dest -Recurse -Force
74
+ Write-Host " installed $($d.Name)/ (site sidecar)"
75
+ }
76
+
68
77
  Write-Host "Installed to $dest"
69
78
  Write-Host "In Live: User Library > Max For Live > $deviceName"
70
79
  Write-Host "NOTE: Live embeds a copy of the device in the set. Instances already"
@@ -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": "^1.0.0",
21
- "@m4l-jweb/surface": "^1.0.0",
20
+ "@m4l-jweb/bridge": "^1.2.0",
21
+ "@m4l-jweb/surface": "^1.2.0",
22
22
  "react": "^19.0.0",
23
23
  "react-dom": "^19.0.0"
24
24
  },
25
25
  "devDependencies": {
26
- "@m4l-jweb/build": "^1.0.0",
26
+ "@m4l-jweb/build": "^1.2.0",
27
27
  "@types/node": "^22.0.0",
28
28
  "@types/react": "^19.0.0",
29
29
  "@types/react-dom": "^19.0.0",