@m4l-jweb/build 1.2.0 → 1.3.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 +3 -2
- package/src/chains.mjs +0 -36
- package/src/files.mjs +87 -0
- package/src/index.mjs +30 -6
- package/templates/install-mac.sh +6 -1
- package/templates/install-windows.ps1 +14 -3
- package/templates/starter/package.json +3 -3
- package/templates/starter/tsconfig.json +1 -1
- package/templates/starter/tsconfig.test.json +29 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m4l-jweb/build",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.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",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"./chains": "./src/chains.mjs",
|
|
21
21
|
"./surface": "./src/surface.mjs",
|
|
22
22
|
"./watch": "./src/watch.mjs",
|
|
23
|
+
"./files": "./src/files.mjs",
|
|
23
24
|
"./amxd": "./src/amxd.mjs",
|
|
24
25
|
"./init": "./src/init.mjs"
|
|
25
26
|
},
|
|
@@ -34,6 +35,6 @@
|
|
|
34
35
|
"archiver": "^7.0.1",
|
|
35
36
|
"esbuild": "^0.25.0",
|
|
36
37
|
"typescript": "^5.7.0",
|
|
37
|
-
"@m4l-jweb/wrapper": "1.
|
|
38
|
+
"@m4l-jweb/wrapper": "1.3.0"
|
|
38
39
|
}
|
|
39
40
|
}
|
package/src/chains.mjs
CHANGED
|
@@ -112,42 +112,6 @@ export function claimAppMessages(ctx, routeId, unmatchedOutlet) {
|
|
|
112
112
|
export const AUDIO_IN = "obj-plugin";
|
|
113
113
|
export const AUDIO_OUT = "obj-plugout";
|
|
114
114
|
|
|
115
|
-
/**
|
|
116
|
-
* A [buffer~] name that is unique PER DEVICE INSTANCE.
|
|
117
|
-
*
|
|
118
|
-
* THE BUG THIS EXISTS TO KILL. Buffer names are GLOBAL to Max, and they used to be
|
|
119
|
-
* generated from the device name alone (`buf-<device>-<slot>`) and frozen into the
|
|
120
|
-
* patcher at BUILD time. So two copies of one device - a drum rack on two tracks, which
|
|
121
|
-
* is the normal case, not an exotic one - named their buffers identically, and Max gave
|
|
122
|
-
* both to whichever loaded last. One rack's samples silently became the other's. No
|
|
123
|
-
* error, no console line: just the wrong sound.
|
|
124
|
-
*
|
|
125
|
-
* A name minted by the wrapper after load cannot reach a box frozen at build time (a
|
|
126
|
-
* buffer takes its name from its creation argument and there is no documented runtime
|
|
127
|
-
* rename), so the scoping has to be a load-time substitution Max itself performs.
|
|
128
|
-
*
|
|
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
|
-
* not to count as one: the token stayed literal in every instance, so writer and
|
|
132
|
-
* reader still agreed on one global name and the collision survived, silently.
|
|
133
|
-
*
|
|
134
|
-
* `---` IS THE MECHANISM BUILT FOR THIS. Max for Live replaces a leading `---` in a
|
|
135
|
-
* name with an id unique to the DEVICE instance - and the scope is the whole device,
|
|
136
|
-
* subpatchers and [poly~] voices included, not one patcher. That kills the `#0`/`#1`
|
|
137
|
-
* hand-off the first attempt needed: the voice spells the SAME name the device does,
|
|
138
|
-
* and no id has to travel through [poly~]'s arguments.
|
|
139
|
-
*
|
|
140
|
-
* OUTSIDE LIVE `---` stays literal (it is a Live-only substitution). Both writer and
|
|
141
|
-
* reader keep agreeing, so a patcher opened in standalone Max degrades to the old
|
|
142
|
-
* shared-name behavior instead of breaking - acceptable, since the devices only
|
|
143
|
-
* meaningfully run in Live.
|
|
144
|
-
*/
|
|
145
|
-
export const deviceBufName = (device, slot) => `---buf-${device?.name}-${slot}`;
|
|
146
|
-
|
|
147
|
-
/** The same buffer, as a [poly~] voice spells it: identical - `---` scopes per DEVICE,
|
|
148
|
-
* not per patcher, so the voice shares the expansion with the patcher that loaded it. */
|
|
149
|
-
export const voiceBufName = (device, slot) => deviceBufName(device, slot);
|
|
150
|
-
|
|
151
115
|
/**
|
|
152
116
|
* How long a `remote` slot takes to slide to each new value, in ms.
|
|
153
117
|
*
|
package/src/files.mjs
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* files.mjs - the build side of defineFiles().
|
|
3
|
+
*
|
|
4
|
+
* The third of the same pipeline: import a device's declaration, and turn it into
|
|
5
|
+
* the two things Max needs. Unlike watch.mjs it produces BOTH kinds of output -
|
|
6
|
+
* data (the FILES_SPEC banner the wrapper reads) and a patcher CHAIN - because a
|
|
7
|
+
* device that writes files needs [maxurl] in the box graph and a folder in the
|
|
8
|
+
* page, and those two travelling separately is the failure the declaration exists
|
|
9
|
+
* to make impossible.
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { pathToFileURL } from "node:url";
|
|
15
|
+
|
|
16
|
+
/** The chain that owns [maxurl]. Every file-writing device needs it - see defineFiles. */
|
|
17
|
+
export const FILES_CHAIN = "download";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Load a device's `src/app/<uiDir>/files.ts`, or null if it declares none.
|
|
21
|
+
*
|
|
22
|
+
* Bundled with esbuild exactly like loadSurface and loadWatch, for the same
|
|
23
|
+
* reason: the declaration is TypeScript importing @m4l-jweb/surface, and Node
|
|
24
|
+
* cannot import that directly.
|
|
25
|
+
*/
|
|
26
|
+
export async function loadFiles(root, uiDir) {
|
|
27
|
+
const src = path.join(root, "src", "app", uiDir, "files.ts");
|
|
28
|
+
if (!existsSync(src)) return null;
|
|
29
|
+
|
|
30
|
+
const { build } = await import("esbuild");
|
|
31
|
+
const tmp = mkdtempSync(path.join(tmpdir(), "m4l-files-"));
|
|
32
|
+
const out = path.join(tmp, "files.mjs");
|
|
33
|
+
try {
|
|
34
|
+
await build({
|
|
35
|
+
entryPoints: [src],
|
|
36
|
+
outfile: out,
|
|
37
|
+
bundle: true,
|
|
38
|
+
format: "esm",
|
|
39
|
+
platform: "node",
|
|
40
|
+
logLevel: "silent",
|
|
41
|
+
external: ["react", "react-dom"],
|
|
42
|
+
});
|
|
43
|
+
const mod = await import(pathToFileURL(out).href);
|
|
44
|
+
const files = mod.default;
|
|
45
|
+
if (!files || typeof files.saves !== "boolean" || typeof files.fetches !== "boolean") {
|
|
46
|
+
throw new Error(`${src} must \`export default defineFiles({...})\``);
|
|
47
|
+
}
|
|
48
|
+
return files;
|
|
49
|
+
} finally {
|
|
50
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The chain list this device is actually built with.
|
|
56
|
+
*
|
|
57
|
+
* A declared file device gets `download` whether or not the manifest asked for it,
|
|
58
|
+
* appended LAST so it cannot displace an audio stage: `download` claims no stage of
|
|
59
|
+
* the signal path, but chain order IS the signal path, and inserting anywhere else
|
|
60
|
+
* would be a silent re-routing of a device that merely started writing files.
|
|
61
|
+
*
|
|
62
|
+
* Idempotent. A manifest that still lists `download` keeps exactly one - running the
|
|
63
|
+
* chain twice would emit the same box ids twice, which assertUniqueBoxIds rejects.
|
|
64
|
+
*/
|
|
65
|
+
export function effectiveChains(chains, files) {
|
|
66
|
+
const declared = chains ?? [];
|
|
67
|
+
if (!files) return declared;
|
|
68
|
+
return declared.includes(FILES_CHAIN) ? declared : [...declared, FILES_CHAIN];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The `var FILES_SPEC = {...}` banner prepended to a device's wrapper.js.
|
|
73
|
+
*
|
|
74
|
+
* Only what the wrapper ACTS on travels. `tellPage` gates the `device_folder`
|
|
75
|
+
* message at ui_ready; `saves` and `fetches` ride along because a device that has
|
|
76
|
+
* never written anything still says so in the Max console, and a wrapper reporting
|
|
77
|
+
* "this device writes no files" next to a save error is the cheapest possible answer
|
|
78
|
+
* to "is it even wired for this".
|
|
79
|
+
*
|
|
80
|
+
* A device with no declaration gets no banner ("") - `typeof FILES_SPEC === "undefined"`
|
|
81
|
+
* is exactly the guard the wrapper checks.
|
|
82
|
+
*/
|
|
83
|
+
export function filesSpecBanner(files) {
|
|
84
|
+
if (!files) return "";
|
|
85
|
+
const spec = { saves: !!files.saves, fetches: !!files.fetches, tellPage: !!files.tellPage };
|
|
86
|
+
return `var FILES_SPEC = ${JSON.stringify(spec)};\n`;
|
|
87
|
+
}
|
package/src/index.mjs
CHANGED
|
@@ -18,6 +18,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
18
18
|
import { AMXD_TYPES, assertES5, buildAmxd, extraPayloadsJs, payloadJs } from "./amxd.mjs";
|
|
19
19
|
import { CHAINS, assertUniqueBoxIds, closeAudio, openAudio, resetLayout } from "./chains.mjs";
|
|
20
20
|
import { applySurface, applyWindows, applyPersistence, loadSurface, parameterRegistry, surfaceContext } from "./surface.mjs";
|
|
21
|
+
import { effectiveChains, filesSpecBanner, loadFiles } from "./files.mjs";
|
|
21
22
|
import { loadWatch, watchSpecsBanner } from "./watch.mjs";
|
|
22
23
|
|
|
23
24
|
const require = createRequire(import.meta.url);
|
|
@@ -157,7 +158,8 @@ async function loadDeviceChains(root) {
|
|
|
157
158
|
*
|
|
158
159
|
* openAudio the device's plugin~/plugout~, created ONCE, before any chain -
|
|
159
160
|
* so a chain is a stage in the signal path, not the owner of it.
|
|
160
|
-
* the chains in declaration order, each taking what the last one left
|
|
161
|
+
* the chains in declaration order, each taking what the last one left, plus
|
|
162
|
+
* the `download` chain a files.ts declaration derives (files.mjs).
|
|
161
163
|
* applySurface LAST of the message-stream claimants: it routes every `set_<id>`
|
|
162
164
|
* off the app's stream and passes on what nobody claimed
|
|
163
165
|
* (ui_ready, ...) to the wrapper. Doing it last means no chain has
|
|
@@ -166,7 +168,7 @@ async function loadDeviceChains(root) {
|
|
|
166
168
|
* assertUnique two boxes with one id is a malformed patcher, and Max resolves
|
|
167
169
|
* it however it likes. Nothing else would report it.
|
|
168
170
|
*/
|
|
169
|
-
export function composePatcher(base, d, surface) {
|
|
171
|
+
export function composePatcher(base, d, surface, files = null) {
|
|
170
172
|
const amxdtype = AMXD_TYPES[d.type];
|
|
171
173
|
if (!amxdtype) throw new Error(`unknown type "${d.type}" for device "${d.name}" (midi | audio | instrument)`);
|
|
172
174
|
|
|
@@ -184,6 +186,19 @@ export function composePatcher(base, d, surface) {
|
|
|
184
186
|
const mode = d.mode ?? d.type;
|
|
185
187
|
boxes.find((b) => b.box.id === "obj-js").box.text = `js wrapper.js ${mode}`;
|
|
186
188
|
|
|
189
|
+
/**
|
|
190
|
+
* The ring buffer between Chromium's audio thread and MSP, for the DEVICE PAGE's
|
|
191
|
+
* own `[jweb~]`.
|
|
192
|
+
*
|
|
193
|
+
* `window({ audio: true, latency })` has taken this since 1.1.0, and the device
|
|
194
|
+
* page could not - so a device that put its buffer at the documented maximum for
|
|
195
|
+
* its sounding WINDOW left its own page at the object default (~21 ms at 48 kHz)
|
|
196
|
+
* and went on dropping out. Two pages, one setting, and only one of them had it.
|
|
197
|
+
*
|
|
198
|
+
* Unset keeps the object's default. jweb~ clamps to 3x the minimum.
|
|
199
|
+
*/
|
|
200
|
+
if (d.latency != null) boxes.find((b) => b.box.id === "obj-jweb").box.latency = d.latency;
|
|
201
|
+
|
|
187
202
|
const unmatchedId = d.unmatchedTo === "js" ? "obj-js" : (d.unmatchedTo ?? "obj-js");
|
|
188
203
|
|
|
189
204
|
/**
|
|
@@ -207,7 +222,7 @@ export function composePatcher(base, d, surface) {
|
|
|
207
222
|
|
|
208
223
|
openAudio(ctx);
|
|
209
224
|
|
|
210
|
-
for (const name of d.chains
|
|
225
|
+
for (const name of effectiveChains(d.chains, files)) {
|
|
211
226
|
const chain = CHAINS[name];
|
|
212
227
|
if (!chain) throw new Error(`unknown chain "${name}" for device "${d.name}" (known: ${Object.keys(CHAINS).join(", ")})`);
|
|
213
228
|
chain(ctx);
|
|
@@ -253,7 +268,8 @@ export async function generatePatchers(root) {
|
|
|
253
268
|
}
|
|
254
269
|
|
|
255
270
|
const surface = await loadSurface(root, d.ui ?? d.name);
|
|
256
|
-
const
|
|
271
|
+
const files = await loadFiles(root, d.ui ?? d.name);
|
|
272
|
+
const p = composePatcher(base, d, surface, files);
|
|
257
273
|
|
|
258
274
|
// A chain's frozen dependencies (e.g. a [poly~] voice patch) are written beside
|
|
259
275
|
// the device patcher and their names recorded in a sidecar, so packageDevices -
|
|
@@ -266,7 +282,11 @@ export async function generatePatchers(root) {
|
|
|
266
282
|
|
|
267
283
|
writeFileSync(path.join(outDir, `${d.name}.json`), JSON.stringify(p, null, "\t"));
|
|
268
284
|
const params = surface ? surface.ids.join(", ") : "none";
|
|
269
|
-
|
|
285
|
+
// The chains it was BUILT with, not the ones the manifest listed - a derived
|
|
286
|
+
// `download` that never appeared in the log would be the same invisible wiring
|
|
287
|
+
// this feature exists to end.
|
|
288
|
+
const chains = effectiveChains(d.chains, files).join(", ") || "none";
|
|
289
|
+
console.log(`m4l-jweb: ${d.name}.json (${d.type}, chains: ${chains}, params: ${params || "none"})`);
|
|
270
290
|
}
|
|
271
291
|
return devices;
|
|
272
292
|
}
|
|
@@ -370,7 +390,11 @@ export async function packageDevices(root) {
|
|
|
370
390
|
// The device's declared watches ride in as a data banner, like the build stamp:
|
|
371
391
|
// WATCH_SPECS is what the packaged wrapper's setupWatches() attaches observers from.
|
|
372
392
|
const watch = await loadWatch(root, d.ui ?? d.name);
|
|
373
|
-
|
|
393
|
+
// ...and so do its declared files: FILES_SPEC is what tells the packaged wrapper
|
|
394
|
+
// this device writes to disk, and therefore to hand the page its device folder.
|
|
395
|
+
const files = await loadFiles(root, d.ui ?? d.name);
|
|
396
|
+
let wrapperData =
|
|
397
|
+
banner + watchSpecsBanner(watch) + filesSpecBanner(files) + siteWindowsBanner(root, outDir, d, await loadSurface(root, d.ui ?? d.name)) + wrapperJs;
|
|
374
398
|
const uiDirContent = readdirSync(path.join(dist, "ui", d.ui ?? d.name)).filter((f) => f.endsWith(".html"));
|
|
375
399
|
|
|
376
400
|
// Main UI payload
|
package/templates/install-mac.sh
CHANGED
|
@@ -44,7 +44,9 @@ if [ ! -d "$user_lib" ]; then
|
|
|
44
44
|
fi
|
|
45
45
|
|
|
46
46
|
dest="$user_lib/Max For Live/$device_name"
|
|
47
|
-
|
|
47
|
+
# The folder is NOT wiped first. It is the same folder the devices SAVE INTO - exports,
|
|
48
|
+
# downloaded samples, anything a user dragged out of it - so clearing it to get a clean
|
|
49
|
+
# install threw away their work. Overwrite what this build produces; leave the rest.
|
|
48
50
|
mkdir -p "$dest"
|
|
49
51
|
|
|
50
52
|
# Each .amxd is self-contained: the UI rides inside it as a payload in wrapper.js.
|
|
@@ -67,6 +69,9 @@ done
|
|
|
67
69
|
# empty, and the wrapper says so in the Max console.
|
|
68
70
|
for d in "$src"/*-site; do
|
|
69
71
|
[ -d "$d" ] || continue
|
|
72
|
+
# This one IS replaced wholesale: it is entirely build output, and a file dropped
|
|
73
|
+
# from the site between builds would otherwise linger and be served.
|
|
74
|
+
rm -rf "$dest/$(basename "$d")"
|
|
70
75
|
cp -R "$d" "$dest/"
|
|
71
76
|
echo " installed $(basename "$d")/ (site sidecar)"
|
|
72
77
|
done
|
|
@@ -49,13 +49,20 @@ if (-not (Test-Path $userLib)) {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
$dest = Join-Path $userLib "Max For Live\$deviceName"
|
|
52
|
-
|
|
52
|
+
# The folder is NOT wiped first. It is the same folder the devices SAVE INTO - exports,
|
|
53
|
+
# downloaded samples, anything a user dragged out of it - so clearing it to get a clean
|
|
54
|
+
# install threw away their work, and failed outright the moment Live held one of those
|
|
55
|
+
# files open. Overwrite what this build produces; leave everything else alone.
|
|
53
56
|
New-Item -ItemType Directory -Force $dest | Out-Null
|
|
54
57
|
|
|
55
58
|
# Each .amxd is self-contained: the UI rides inside it as a payload in wrapper.js.
|
|
56
59
|
foreach ($f in $devices) {
|
|
57
|
-
|
|
58
|
-
|
|
60
|
+
try {
|
|
61
|
+
Copy-Item $f.FullName $dest -Force -ErrorAction Stop
|
|
62
|
+
Write-Host " installed $($f.Name)"
|
|
63
|
+
} catch {
|
|
64
|
+
Write-Error "Could not replace $($f.Name) - it is open in Live. Close the set (or remove the device from the track) and run this again."
|
|
65
|
+
}
|
|
59
66
|
}
|
|
60
67
|
|
|
61
68
|
# Presets (hand-saved Live racks, packaged next to the devices by the build) go in
|
|
@@ -70,6 +77,10 @@ foreach ($f in @(Get-ChildItem (Join-Path $src "*.adg") -ErrorAction SilentlyCon
|
|
|
70
77
|
# installed with it. Without the folder the device still plays; that window opens
|
|
71
78
|
# empty, and the wrapper says so in the Max console.
|
|
72
79
|
foreach ($d in @(Get-ChildItem (Join-Path $src "*-site") -Directory -ErrorAction SilentlyContinue)) {
|
|
80
|
+
# This one IS replaced wholesale: it is entirely build output, and a file dropped
|
|
81
|
+
# from the site between builds would otherwise linger and be served.
|
|
82
|
+
$siteTarget = Join-Path $dest $d.Name
|
|
83
|
+
if (Test-Path $siteTarget) { Remove-Item $siteTarget -Recurse -Force }
|
|
73
84
|
Copy-Item $d.FullName $dest -Recurse -Force
|
|
74
85
|
Write-Host " installed $($d.Name)/ (site sidecar)"
|
|
75
86
|
}
|
|
@@ -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.3.0",
|
|
21
|
+
"@m4l-jweb/surface": "^1.3.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.3.0",
|
|
27
27
|
"@types/node": "^22.0.0",
|
|
28
28
|
"@types/react": "^19.0.0",
|
|
29
29
|
"@types/react-dom": "^19.0.0",
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
// The TESTS, which nothing typechecked before.
|
|
3
|
+
//
|
|
4
|
+
// `tsconfig.app.json` covers `src`, `tsconfig.node.json` covers the scripts and the
|
|
5
|
+
// patcher - and `tests/` fell between them. That is fine for the `.mjs` suites, but
|
|
6
|
+
// a `.ts` one carrying `expectTypeOf` assertions was checking nothing at all: the
|
|
7
|
+
// assertion is a TYPE error or it is nothing, so an unchecked type test passes for
|
|
8
|
+
// the same reason a deleted one does. Referenced from the root solution, so a plain
|
|
9
|
+
// `tsc -b` (which the build runs first) fails on it.
|
|
10
|
+
"compilerOptions": {
|
|
11
|
+
"target": "ES2022",
|
|
12
|
+
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
|
13
|
+
"module": "ESNext",
|
|
14
|
+
"skipLibCheck": true,
|
|
15
|
+
"moduleResolution": "bundler",
|
|
16
|
+
"allowImportingTsExtensions": true,
|
|
17
|
+
"resolveJsonModule": true,
|
|
18
|
+
"allowJs": true,
|
|
19
|
+
"isolatedModules": true,
|
|
20
|
+
"moduleDetection": "force",
|
|
21
|
+
"noEmit": true,
|
|
22
|
+
"strict": true
|
|
23
|
+
},
|
|
24
|
+
// `vitest.config.ts` is listed alongside the tests, and not only to check it: a
|
|
25
|
+
// project whose include matches nothing at all is a tsc ERROR (TS18003), and a
|
|
26
|
+
// freshly scaffolded repo has no tests yet. One file that always exists keeps the
|
|
27
|
+
// project valid from the first minute.
|
|
28
|
+
"include": ["tests/**/*.ts", "vitest.config.ts"]
|
|
29
|
+
}
|