@m4l-jweb/build 1.0.0 → 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/package.json +2 -2
- package/src/chains.mjs +4 -4
- package/src/index.mjs +47 -2
- package/src/surface.mjs +231 -25
- package/templates/install-mac.sh +10 -0
- package/templates/install-windows.ps1 +9 -0
- package/templates/starter/package.json +3 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m4l-jweb/build",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.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.
|
|
37
|
+
"@m4l-jweb/wrapper": "1.1.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
|
|
130
|
-
*
|
|
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
|
|
649
|
-
*
|
|
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
|
-
|
|
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,136 @@ 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: 1,
|
|
616
|
+
// On the PATCHING canvas at the origin, filling the window. See FITTED
|
|
617
|
+
// WINDOWS below for why this one is not in presentation like the others.
|
|
618
|
+
patching_rect: [0, 0, spec.width, spec.height],
|
|
619
|
+
varname: "obj-jweb",
|
|
620
|
+
},
|
|
621
|
+
},
|
|
622
|
+
{ box: { id: "obj-out-l", maxclass: "outlet", patching_rect: [16, HELPER_Y + 80, 30, 30], numinlets: 1, numoutlets: 0, comment: "signal L" } },
|
|
623
|
+
{ box: { id: "obj-out-r", maxclass: "outlet", patching_rect: [56, HELPER_Y + 80, 30, 30], numinlets: 1, numoutlets: 0, comment: "signal R" } },
|
|
624
|
+
];
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* FITTED WINDOWS - where the plumbing goes so the page can own the window.
|
|
629
|
+
*
|
|
630
|
+
* A window that is worth resizing (an editor, a whole REPL) has to have its page
|
|
631
|
+
* grow with it, and the wrapper does that by writing the [jweb]'s rect at runtime.
|
|
632
|
+
* MEASURED, in this repo: a runtime rect change is accepted but never redrawn when
|
|
633
|
+
* the object is shown in PRESENTATION - which is how every window here was laid
|
|
634
|
+
* out. On the patching canvas it does redraw, and that is the whole reason a
|
|
635
|
+
* sounding window is built differently: `openinpresentation` is off, the page sits
|
|
636
|
+
* at the canvas origin, and everything else - the inlet, the receive, the tag, the
|
|
637
|
+
* outlets - is moved ABOVE the origin, off the top of what the window shows.
|
|
638
|
+
*
|
|
639
|
+
* The plain window is untouched by this and stays in presentation. It is a fixed
|
|
640
|
+
* reference card; nothing about it wants to be dragged bigger.
|
|
641
|
+
*/
|
|
642
|
+
const HELPER_Y = -260;
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Sum a sounding window into the device's audio path, and make sure its page is
|
|
646
|
+
* actually LOADED.
|
|
647
|
+
*
|
|
648
|
+
* The mix is the `webaudio` chain's stage shape (chains.mjs), claimed here because
|
|
649
|
+
* `applyWindows` runs before `closeAudio` - the window is just another stage in the
|
|
650
|
+
* series, and `ctx.audioIn`/`ctx.setAudioOut` are the hand-off.
|
|
651
|
+
*
|
|
652
|
+
* The keepalive is the part that is NOT obvious. A `[jweb]` in a subpatcher whose
|
|
653
|
+
* window has never been opened may never load its page, and a page that never
|
|
654
|
+
* loaded has no AudioContext and makes no sound - a device that is silent until the
|
|
655
|
+
* user happens to open a window would be a trap. So `loadbang` pulses the window
|
|
656
|
+
* open and closes it again a moment later: the page loads, the audio starts, and
|
|
657
|
+
* the window is out of the way. It is deliberately a PULSE and not a hidden open -
|
|
658
|
+
* `[pcontrol]`'s `close` is the same message the app sends, so nothing special
|
|
659
|
+
* needs to be true about the window afterwards.
|
|
660
|
+
*/
|
|
661
|
+
function mixAudioWindow(ctx, id, subpatcherId, pcontrolId) {
|
|
662
|
+
const { boxes, lines, device } = ctx;
|
|
663
|
+
|
|
664
|
+
// openAudio() leaves ctx.audioIn throwing on a MIDI device, but its message talks
|
|
665
|
+
// about chains. Say what is actually wrong here.
|
|
666
|
+
if (!ctx.audioTail) {
|
|
667
|
+
throw new Error(
|
|
668
|
+
`window "${id}" is declared \`audio: true\`, but device "${device?.name}" is type "${device?.type}". ` +
|
|
669
|
+
`A sounding window needs the signal path: set \`type: "audio"\` or \`type: "instrument"\` in patcher/devices.mjs.`,
|
|
670
|
+
);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
for (const ch of [0, 1]) {
|
|
674
|
+
const [srcId, srcOut] = ctx.audioIn(ch);
|
|
675
|
+
const mixId = `obj-window-${id}-mix-${ch === 0 ? "l" : "r"}`;
|
|
676
|
+
boxes.push(box(mixId, "+~", { numinlets: 2, numoutlets: 1, outlettype: ["signal"] }));
|
|
677
|
+
lines.push(line(srcId, srcOut, mixId, 0)); // whatever the last stage left
|
|
678
|
+
lines.push(line(subpatcherId, ch, mixId, 1)); // the window's page
|
|
679
|
+
ctx.setAudioOut(ch, mixId, 0);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// The LEVEL of what this window is playing, as messages, for a page to draw.
|
|
683
|
+
//
|
|
684
|
+
// It has to be messages: [jweb~] is "Web browser with audio output" - one control
|
|
685
|
+
// inlet, no signal inlet - so no page can be handed a signal, and the device view
|
|
686
|
+
// cannot see the window's audio any other way. [peakamp~] reports the peak since
|
|
687
|
+
// the last report, which is what a meter wants and costs a float per channel per
|
|
688
|
+
// interval rather than a stream of samples.
|
|
689
|
+
const tap = (suffix) => `obj-window-${id}-peak-${suffix}`;
|
|
690
|
+
// 10 ms - fast enough that the trace reads as the shape of the sound rather than a
|
|
691
|
+
// meter twitching. It is still an ENVELOPE and not a waveform: what crosses is one
|
|
692
|
+
// float per channel per report, not samples, because no page can be handed audio.
|
|
693
|
+
boxes.push(box(tap("l"), "peakamp~ 10", { numinlets: 1, numoutlets: 1, outlettype: ["float"] }));
|
|
694
|
+
boxes.push(box(tap("r"), "peakamp~ 10", { numinlets: 1, numoutlets: 1, outlettype: ["float"] }));
|
|
695
|
+
// `pak` and not `pack`: both inlets hot, so a change on either channel reports
|
|
696
|
+
// rather than waiting for the left one to move.
|
|
697
|
+
boxes.push(box(tap("pak"), "pak f f", { numinlets: 2, numoutlets: 1, outlettype: [""] }));
|
|
698
|
+
boxes.push(box(tap("pre"), `prepend window_level ${id}`, { numinlets: 1, numoutlets: 1, outlettype: [""] }));
|
|
699
|
+
lines.push(line(subpatcherId, 0, tap("l"), 0));
|
|
700
|
+
lines.push(line(subpatcherId, 1, tap("r"), 0));
|
|
701
|
+
lines.push(line(tap("l"), 0, tap("pak"), 0));
|
|
702
|
+
lines.push(line(tap("r"), 0, tap("pak"), 1));
|
|
703
|
+
lines.push(line(tap("pak"), 0, tap("pre"), 0));
|
|
704
|
+
lines.push(line(tap("pre"), 0, ctx.unmatchedId, 0));
|
|
705
|
+
|
|
706
|
+
const ka = (suffix) => `obj-window-${id}-ka-${suffix}`;
|
|
707
|
+
boxes.push(box(ka("loadbang"), "loadbang", { numinlets: 1, numoutlets: 1, outlettype: ["bang"] }));
|
|
708
|
+
boxes.push(box(ka("open"), "open", { maxclass: "message", numinlets: 2, numoutlets: 1 }));
|
|
709
|
+
boxes.push(box(ka("delay"), "delay 1500", { numinlets: 2, numoutlets: 1, outlettype: ["bang"] }));
|
|
710
|
+
boxes.push(box(ka("close"), "close", { maxclass: "message", numinlets: 2, numoutlets: 1 }));
|
|
711
|
+
lines.push(line(ka("loadbang"), 0, ka("open"), 0));
|
|
712
|
+
lines.push(line(ka("loadbang"), 0, ka("delay"), 0));
|
|
713
|
+
lines.push(line(ka("delay"), 0, ka("close"), 0));
|
|
714
|
+
lines.push(line(ka("open"), 0, pcontrolId, 0));
|
|
715
|
+
lines.push(line(ka("close"), 0, pcontrolId, 0));
|
|
716
|
+
}
|
|
717
|
+
|
|
550
718
|
export function applyWindows(ctx) {
|
|
551
719
|
const { boxes, lines, surface, unmatchedId } = ctx;
|
|
552
720
|
const windowIds = surface?.windows ? Object.keys(surface.windows) : [];
|
|
@@ -596,6 +764,11 @@ export function applyWindows(ctx) {
|
|
|
596
764
|
const pcontrolId = `obj-window-${id}-pcontrol`;
|
|
597
765
|
boxes.push(box(pcontrolId, "pcontrol"));
|
|
598
766
|
|
|
767
|
+
// A SOUNDING window: [jweb~] with two signal outlets ahead of the message one.
|
|
768
|
+
// Everything above this point - the route, the triggers, [pcontrol] - is the
|
|
769
|
+
// same for both kinds, because opening a window is opening a window.
|
|
770
|
+
const audio = !!spec.audio;
|
|
771
|
+
|
|
599
772
|
const subpatcherId = `obj-window-${id}-sub`;
|
|
600
773
|
lines.push(line(pcontrolId, 0, subpatcherId, 0));
|
|
601
774
|
// The window's [jweb] can now TALK BACK. Its output leaves the subpatcher on an
|
|
@@ -603,7 +776,10 @@ export function applyWindows(ctx) {
|
|
|
603
776
|
// device view feeds. It is tagged `window <id>` inside (below), so the wrapper
|
|
604
777
|
// tells the two apart and can answer the right one. Without this the window's
|
|
605
778
|
// page could display but never send a message: the [jweb] outlet went nowhere.
|
|
606
|
-
|
|
779
|
+
//
|
|
780
|
+
// On a sounding window the messages are the LAST outlet: 0 and 1 are the page's
|
|
781
|
+
// L and R.
|
|
782
|
+
lines.push(line(subpatcherId, audio ? 2 : 0, unmatchedId, 0));
|
|
607
783
|
boxes.push({
|
|
608
784
|
box: {
|
|
609
785
|
id: subpatcherId,
|
|
@@ -613,16 +789,22 @@ export function applyWindows(ctx) {
|
|
|
613
789
|
// subpatcher that has no inlets, so [pcontrol] would end up wired to
|
|
614
790
|
// NOTHING - silently, in the saved file. That was attempt 1.
|
|
615
791
|
numinlets: 1,
|
|
616
|
-
numoutlets: 1,
|
|
617
|
-
outlettype: [""],
|
|
792
|
+
numoutlets: audio ? 3 : 1,
|
|
793
|
+
outlettype: audio ? ["signal", "signal", ""] : [""],
|
|
794
|
+
// A scripting name, so [js] can reach INTO this subpatcher at runtime
|
|
795
|
+
// (getnamed -> subpatcher()). That is how the window's page is resized to
|
|
796
|
+
// follow the window - see followWindowSizes() in the wrapper.
|
|
797
|
+
varname: `window-${id}`,
|
|
618
798
|
patching_rect: [16, 620, 120, 22],
|
|
619
799
|
patcher: {
|
|
620
800
|
fileversion: 1,
|
|
621
801
|
appversion: { major: 8, minor: 0, revision: 0, architecture: "x64", modernui: 1 },
|
|
622
802
|
rect: [100, 100, spec.width, spec.height],
|
|
623
|
-
|
|
803
|
+
// A sounding window shows its PATCHING canvas, so the page can be resized
|
|
804
|
+
// at runtime - see FITTED WINDOWS above.
|
|
805
|
+
openinpresentation: audio ? 0 : 1,
|
|
624
806
|
boxes: [
|
|
625
|
-
{ box: { id: "obj-in", maxclass: "inlet", patching_rect: [16, 16, 30, 30], numinlets: 0, numoutlets: 1, outlettype: [""] } },
|
|
807
|
+
{ box: { id: "obj-in", maxclass: "inlet", patching_rect: [16, audio ? HELPER_Y : 16, 30, 30], numinlets: 0, numoutlets: 1, outlettype: [""] } },
|
|
626
808
|
// The page's URL cannot be WIRED here - this [jweb] is inside a
|
|
627
809
|
// subpatcher and the wrapper's [js] is outside it. The wrapper reaches
|
|
628
810
|
// it by NAME instead (messnamed), once the payload is extracted.
|
|
@@ -634,21 +816,26 @@ export function applyWindows(ctx) {
|
|
|
634
816
|
numinlets: 0,
|
|
635
817
|
numoutlets: 1,
|
|
636
818
|
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],
|
|
819
|
+
patching_rect: [16, audio ? HELPER_Y + 40 : 56, 160, 22],
|
|
650
820
|
},
|
|
651
821
|
},
|
|
822
|
+
...(audio
|
|
823
|
+
? audioWindowBoxes(id, spec)
|
|
824
|
+
: [
|
|
825
|
+
{
|
|
826
|
+
box: {
|
|
827
|
+
id: "obj-jweb",
|
|
828
|
+
maxclass: "jweb",
|
|
829
|
+
numinlets: 1,
|
|
830
|
+
numoutlets: 2,
|
|
831
|
+
outlettype: ["", ""],
|
|
832
|
+
patching_rect: [16, 96, spec.width, spec.height],
|
|
833
|
+
presentation: 1,
|
|
834
|
+
presentation_rect: [0, 0, spec.width, spec.height],
|
|
835
|
+
varname: "obj-jweb",
|
|
836
|
+
},
|
|
837
|
+
},
|
|
838
|
+
]),
|
|
652
839
|
// TAG the window's messages with which window they are, so the wrapper's
|
|
653
840
|
// `window()` can answer THIS window (its [jweb] has no cord from [js], so
|
|
654
841
|
// a reply goes back by name). The page emits bare selectors; the tag is
|
|
@@ -662,22 +849,41 @@ export function applyWindows(ctx) {
|
|
|
662
849
|
numinlets: 1,
|
|
663
850
|
numoutlets: 1,
|
|
664
851
|
outlettype: [""],
|
|
665
|
-
|
|
852
|
+
// On a sounding window the signal outlets own the left of the row,
|
|
853
|
+
// and outlet ORDER is x order - so the messages move right.
|
|
854
|
+
patching_rect: [audio ? 200 : 16, audio ? HELPER_Y + 40 : 96 + spec.height + 16, 160, 22],
|
|
666
855
|
},
|
|
667
856
|
},
|
|
668
|
-
{
|
|
669
|
-
|
|
857
|
+
{
|
|
858
|
+
box: {
|
|
859
|
+
id: "obj-out",
|
|
860
|
+
maxclass: "outlet",
|
|
861
|
+
patching_rect: [audio ? 200 : 16, audio ? HELPER_Y + 80 : 96 + spec.height + 48, 30, 30],
|
|
862
|
+
numinlets: 1,
|
|
863
|
+
numoutlets: 0,
|
|
864
|
+
},
|
|
865
|
+
},
|
|
866
|
+
...floatBoxes(spec, audio),
|
|
670
867
|
],
|
|
671
868
|
lines: [
|
|
672
869
|
{ patchline: { source: ["obj-recv", 0], destination: ["obj-jweb", 0] } },
|
|
673
870
|
// [jweb] outlet 0 is the page's messages; tag them and send them out.
|
|
674
|
-
|
|
871
|
+
// On [jweb~] that is outlet 2 - 0 and 1 carry the sound.
|
|
872
|
+
{ patchline: { source: ["obj-jweb", audio ? 2 : 0], destination: ["obj-tag", 0] } },
|
|
675
873
|
{ patchline: { source: ["obj-tag", 0], destination: ["obj-out", 0] } },
|
|
874
|
+
...(audio
|
|
875
|
+
? [
|
|
876
|
+
{ patchline: { source: ["obj-jweb", 0], destination: ["obj-out-l", 0] } },
|
|
877
|
+
{ patchline: { source: ["obj-jweb", 1], destination: ["obj-out-r", 0] } },
|
|
878
|
+
]
|
|
879
|
+
: []),
|
|
676
880
|
...floatLines(spec),
|
|
677
881
|
],
|
|
678
882
|
},
|
|
679
883
|
},
|
|
680
884
|
});
|
|
885
|
+
|
|
886
|
+
if (audio) mixAudioWindow(ctx, id, subpatcherId, pcontrolId);
|
|
681
887
|
});
|
|
682
888
|
}
|
|
683
889
|
|
package/templates/install-mac.sh
CHANGED
|
@@ -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.
|
|
21
|
-
"@m4l-jweb/surface": "^1.
|
|
20
|
+
"@m4l-jweb/bridge": "^1.1.0",
|
|
21
|
+
"@m4l-jweb/surface": "^1.1.0",
|
|
22
22
|
"react": "^19.0.0",
|
|
23
23
|
"react-dom": "^19.0.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
|
-
"@m4l-jweb/build": "^1.
|
|
26
|
+
"@m4l-jweb/build": "^1.1.0",
|
|
27
27
|
"@types/node": "^22.0.0",
|
|
28
28
|
"@types/react": "^19.0.0",
|
|
29
29
|
"@types/react-dom": "^19.0.0",
|