@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 +2 -2
- package/src/chains.mjs +4 -4
- package/src/index.mjs +47 -2
- package/src/surface.mjs +235 -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.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.
|
|
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
|
|
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,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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
669
|
-
|
|
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
|
-
|
|
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
|
|
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.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.
|
|
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",
|