@m4l-jweb/build 1.3.1 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -2
- package/src/chains.mjs +18 -3
- package/src/controls.mjs +209 -0
- package/src/index.mjs +260 -25
- package/src/surface.mjs +21 -5
- package/src/target.mjs +116 -0
- package/templates/starter/package.json +3 -3
- package/templates/starter/scripts/devices.mjs +20 -3
- package/templates/starter/tsconfig.app.json +9 -1
- package/templates/starter/tsconfig.test.json +12 -1
- package/templates/starter/vitest.config.ts +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m4l-jweb/build",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "m4l-jweb: the CLI that builds and packages a device repo into installable Max for Live devices.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
"exports": {
|
|
19
19
|
".": "./src/index.mjs",
|
|
20
20
|
"./chains": "./src/chains.mjs",
|
|
21
|
+
"./controls": "./src/controls.mjs",
|
|
22
|
+
"./target": "./src/target.mjs",
|
|
21
23
|
"./surface": "./src/surface.mjs",
|
|
22
24
|
"./watch": "./src/watch.mjs",
|
|
23
25
|
"./files": "./src/files.mjs",
|
|
@@ -35,6 +37,6 @@
|
|
|
35
37
|
"archiver": "^7.0.1",
|
|
36
38
|
"esbuild": "^0.25.0",
|
|
37
39
|
"typescript": "^5.7.0",
|
|
38
|
-
"@m4l-jweb/wrapper": "1.
|
|
40
|
+
"@m4l-jweb/wrapper": "1.6.0"
|
|
39
41
|
}
|
|
40
42
|
}
|
package/src/chains.mjs
CHANGED
|
@@ -100,7 +100,10 @@ export function claimAppMessages(ctx, routeId, unmatchedOutlet) {
|
|
|
100
100
|
|
|
101
101
|
removeLine(ctx.lines, srcId, ctx.unmatchedId);
|
|
102
102
|
ctx.lines.push(line(srcId, srcOutlet, routeId, 0));
|
|
103
|
-
|
|
103
|
+
// A HEADLESS device has nowhere to pass the tail on TO: `[js]` is already the far
|
|
104
|
+
// end of this stream, so the last route's unmatched outlet is left unconnected
|
|
105
|
+
// rather than wired back into the box the messages came from.
|
|
106
|
+
if (ctx.unmatchedId) ctx.lines.push(line(routeId, unmatchedOutlet, ctx.unmatchedId, 0));
|
|
104
107
|
ctx.appOut = [routeId, unmatchedOutlet];
|
|
105
108
|
}
|
|
106
109
|
|
|
@@ -232,7 +235,7 @@ export function assertUniqueBoxIds(boxes, deviceName, scope = "the patcher") {
|
|
|
232
235
|
* Also CUTS the template's direct midiin -> midiout thru cord: a device that
|
|
233
236
|
* transforms notes must not also leak the untransformed ones.
|
|
234
237
|
*/
|
|
235
|
-
function midiInChain({ boxes, lines,
|
|
238
|
+
function midiInChain({ boxes, lines, appIn }) {
|
|
236
239
|
removeLine(lines, "obj-midiin", "obj-midiout");
|
|
237
240
|
boxes.push(
|
|
238
241
|
box("obj-midiparse", "midiparse", {
|
|
@@ -244,7 +247,10 @@ function midiInChain({ boxes, lines, jwebId }) {
|
|
|
244
247
|
boxes.push(box("obj-noteinmsg", "prepend notein"));
|
|
245
248
|
lines.push(line("obj-midiin", 0, "obj-midiparse", 0));
|
|
246
249
|
lines.push(line("obj-midiparse", 0, "obj-noteinmsg", 0)); // outlet 0 = note: pitch, velocity
|
|
247
|
-
|
|
250
|
+
// The APP endpoint - `[jweb]` under the default target, `[js]` under headless. A
|
|
251
|
+
// played note reaches a React `onNote()` or a `function notein()` in [js], and this
|
|
252
|
+
// chain does not know or care which.
|
|
253
|
+
lines.push(line("obj-noteinmsg", 0, appIn, 0));
|
|
248
254
|
}
|
|
249
255
|
|
|
250
256
|
/**
|
|
@@ -854,6 +860,15 @@ function reverbChain(ctx) {
|
|
|
854
860
|
*/
|
|
855
861
|
function webaudioChain(ctx) {
|
|
856
862
|
const { boxes, lines, jwebId } = ctx;
|
|
863
|
+
// The signal comes out of the PAGE's own outlets. There is no page in a headless
|
|
864
|
+
// device, so this is not a stage that can be claimed - say so here rather than
|
|
865
|
+
// emit a cord from a box that is not in the patcher.
|
|
866
|
+
if (!jwebId) {
|
|
867
|
+
throw new Error(
|
|
868
|
+
`chain "webaudio" on device "${ctx.device?.name}" takes its signal from the page's [jweb~] outlets, ` +
|
|
869
|
+
`and target "headless" has no page. Write the audio in MSP (a chain), or drop \`target: "headless"\`.`,
|
|
870
|
+
);
|
|
871
|
+
}
|
|
857
872
|
|
|
858
873
|
for (const ch of [0, 1]) {
|
|
859
874
|
const [srcId, srcOut] = ctx.audioIn(ch);
|
package/src/controls.mjs
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* controls.mjs - the build side of defineControls().
|
|
3
|
+
*
|
|
4
|
+
* The fourth of the same pipeline (surface.mjs, watch.mjs, files.mjs), and like
|
|
5
|
+
* files.mjs it produces BOTH kinds of output: a patcher CHAIN and a data banner.
|
|
6
|
+
* The declaration itself rides on the Surface - `defineSurface({ controls })` - so
|
|
7
|
+
* there is nothing to import here that `loadSurface()` has not already loaded.
|
|
8
|
+
*
|
|
9
|
+
* ------------------------------------------------------------------------------
|
|
10
|
+
* WHERE THE WORK LIVES, and why it is split where it is.
|
|
11
|
+
*
|
|
12
|
+
* doc/TODO.md asked for discovery, grab, release and the value observer in
|
|
13
|
+
* the chain, with the frame diff and `send_value` in the wrapper. The line moved,
|
|
14
|
+
* and it moved for one reason: EVERY MEASURED FACT ABOUT THIS API WAS MEASURED
|
|
15
|
+
* THROUGH `[js]`. `push-probe` resolved the surface, grabbed by name, painted and
|
|
16
|
+
* observed from LiveAPI in the wrapper, and doc/MAX-FACTS.md's numbers are that
|
|
17
|
+
* path's numbers. A patcher rewrite of the same steps is unmeasured code in an API
|
|
18
|
+
* that reports NOTHING when it is wrong - so the parts that only [js] can do at all
|
|
19
|
+
* stayed in [js], and the part the constraint is actually about moved out of it:
|
|
20
|
+
*
|
|
21
|
+
* the chain THE INPUT PATH. One `[live.observer value]` per declared control,
|
|
22
|
+
* straight into `[jweb]`. A press crosses no [js] at all. Plus the
|
|
23
|
+
* two takeover parameters, tapped into [js] so the grab follows the
|
|
24
|
+
* dial even with the page closed or dead.
|
|
25
|
+
*
|
|
26
|
+
* the wrapper discovery (walking `live_app`'s `control_surfaces` is a LOOP, and
|
|
27
|
+
* a loop in a patcher is [uzi] + [zl] that nothing can test without
|
|
28
|
+
* the hardware), resolving each role against `get_control_names`,
|
|
29
|
+
* grab, release, the focus policy, the frame buffer and its per-cell
|
|
30
|
+
* diff, and `send_value`.
|
|
31
|
+
*
|
|
32
|
+
* The constraint that carries a failure mode - a grabbed pad must not wait on
|
|
33
|
+
* anything the page is doing - is met. The constraint that was a preference is not,
|
|
34
|
+
* and this comment is the record of the trade rather than a silent divergence.
|
|
35
|
+
* ------------------------------------------------------------------------------
|
|
36
|
+
*/
|
|
37
|
+
import { box, fanParamInto, line } from "./chains.mjs";
|
|
38
|
+
|
|
39
|
+
/** Must match FOCUS_OPTIONS in @m4l-jweb/surface - the menu's value is its index. */
|
|
40
|
+
const FOCUS_OPTIONS = ["Device", "Track", "Always"];
|
|
41
|
+
|
|
42
|
+
/** The chain that owns the observers. Every device that declares controls needs it. */
|
|
43
|
+
export const CONTROLS_CHAIN = "takeover";
|
|
44
|
+
|
|
45
|
+
/** The parameters defineControls() contributes. Must match TAKEOVER_PARAM / FOCUS_PARAM in @m4l-jweb/surface. */
|
|
46
|
+
export const TAKEOVER_PARAM = "takeover";
|
|
47
|
+
export const FOCUS_PARAM = "focus";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The chain list this device is actually built with.
|
|
51
|
+
*
|
|
52
|
+
* A device that declares controls gets `takeover` whether or not the manifest asked
|
|
53
|
+
* for it, appended LAST for the same reason `download` is: it claims no stage of the
|
|
54
|
+
* signal path, but chain order IS the signal path and inserting anywhere else would
|
|
55
|
+
* silently re-route a device that merely started using the pads.
|
|
56
|
+
*
|
|
57
|
+
* Idempotent - a manifest that lists it keeps exactly one, since running the chain
|
|
58
|
+
* twice emits the same box ids twice and assertUniqueBoxIds rejects that.
|
|
59
|
+
*/
|
|
60
|
+
export function withControlsChain(chains, surface) {
|
|
61
|
+
const declared = chains ?? [];
|
|
62
|
+
if (!surface?.controls) return declared;
|
|
63
|
+
return declared.includes(CONTROLS_CHAIN) ? declared : [...declared, CONTROLS_CHAIN];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The `var CONTROLS_SPEC = {...}` banner prepended to a device's wrapper.js.
|
|
68
|
+
*
|
|
69
|
+
* Only what the wrapper ACTS on travels: the key (which is the selector suffix and
|
|
70
|
+
* the argument the chain routes on), the kind, the size of the frame buffer, and the
|
|
71
|
+
* CANDIDATE NAMES to resolve the role against. The role name itself rides along
|
|
72
|
+
* because it is what a console line has to say when a role does not resolve, and
|
|
73
|
+
* "matrix" is what the device author wrote.
|
|
74
|
+
*
|
|
75
|
+
* A device with no declaration gets no banner ("") - `typeof CONTROLS_SPEC ===
|
|
76
|
+
* "undefined"` is exactly the guard the wrapper checks.
|
|
77
|
+
*/
|
|
78
|
+
export function controlsSpecBanner(surface) {
|
|
79
|
+
const controls = surface?.controls;
|
|
80
|
+
if (!controls) return "";
|
|
81
|
+
const spec = {
|
|
82
|
+
surface: controls.surface,
|
|
83
|
+
/**
|
|
84
|
+
* The declared `focus` default, as the menu INDEX Max stores it as.
|
|
85
|
+
*
|
|
86
|
+
* The wrapper needs it because a `live.menu` does not necessarily announce its
|
|
87
|
+
* value at load - and a wrapper that assumed 0 would treat a device declared
|
|
88
|
+
* `Track` as `Device` until somebody touched the menu, which is a takeover that
|
|
89
|
+
* silently does not happen on a track the user has selected.
|
|
90
|
+
*/
|
|
91
|
+
focus: FOCUS_OPTIONS.indexOf(controls.defaultFocus),
|
|
92
|
+
controls: controls.keys.map((key) => {
|
|
93
|
+
const c = controls.controls[key];
|
|
94
|
+
return {
|
|
95
|
+
key,
|
|
96
|
+
kind: c.kind,
|
|
97
|
+
role: c.role,
|
|
98
|
+
rows: c.kind === "grid" ? c.rows : 1,
|
|
99
|
+
cols: c.kind === "grid" ? c.cols : 1,
|
|
100
|
+
// Stamped onto the spec by defineControls() from its own ROLE_NAMES table,
|
|
101
|
+
// so this file never reimplements it. See ControlBase in controls.ts.
|
|
102
|
+
names: c.names ?? [],
|
|
103
|
+
};
|
|
104
|
+
}),
|
|
105
|
+
};
|
|
106
|
+
return `var CONTROLS_SPEC = ${JSON.stringify(spec)};\n`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The per-key selector the wrapper addresses the chain with: `tk_<key> id <n>`. */
|
|
110
|
+
export const controlIdSelector = (key) => `tk_${key}`;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* "takeover" - the pads' INPUT PATH, and the two parameters that switch it on.
|
|
114
|
+
*
|
|
115
|
+
* ```
|
|
116
|
+
* [js] outlet 1 -> [route tk_pads ...] -> id <n> -> right inlet of
|
|
117
|
+
* [live.observer value]
|
|
118
|
+
* |
|
|
119
|
+
* [prepend pad_pads] -+
|
|
120
|
+
* |
|
|
121
|
+
* [jweb]
|
|
122
|
+
* ```
|
|
123
|
+
*
|
|
124
|
+
* THE OBSERVER IS THE WHOLE POINT OF THE CHAIN. A press reaches the page through
|
|
125
|
+
* `[live.observer]` -> `[prepend]` -> `[jweb]` and touches no [js] on the way, so the
|
|
126
|
+
* one thing that must never queue behind anything else does not.
|
|
127
|
+
*
|
|
128
|
+
* `live.observer`'s LEFT OUTLET CARRIES THE VALUE AND NOTHING ELSE - read off Max's
|
|
129
|
+
* own reference on disk (`refpages/m4l-ref/live.observer.maxref.xml`: "The left
|
|
130
|
+
* outlet is reserved for value messages, all other output is sent to the right
|
|
131
|
+
* outlet"), which is NOT the shape [js] sees. A [js] callback is handed
|
|
132
|
+
* `["value", ...]`; this is the atoms alone. The page is written against the atoms.
|
|
133
|
+
*
|
|
134
|
+
* ...and it fires ONCE ON ATTACH, with the property's current value, which for a
|
|
135
|
+
* control nobody has touched is not a press. That reaches the page as a `pad_<key>`
|
|
136
|
+
* with no arguments and `padStore` drops it on arity - the same trap the probe hit
|
|
137
|
+
* as a press at (undefined, undefined).
|
|
138
|
+
*
|
|
139
|
+
* THE TWO PARAMETERS ARE TAPPED INTO `[js]`, not read out of the page. `takeover`
|
|
140
|
+
* decides whether this device holds the hardware, and a device whose grid dies
|
|
141
|
+
* because its Chromium view is closed is a device that fails exactly when the user
|
|
142
|
+
* is looking at the Push instead of the screen. `fanParamInto` wires BOTH of a
|
|
143
|
+
* parameter's sources - the object's own outlet (a knob turn, an automation lane,
|
|
144
|
+
* Push) and the route outlet carrying what the app wrote - because the app's write
|
|
145
|
+
* reaches the object as `set`, which updates it without producing output.
|
|
146
|
+
*/
|
|
147
|
+
export function takeoverChain(ctx) {
|
|
148
|
+
const { boxes, lines, appIn, surface, device } = ctx;
|
|
149
|
+
// The wrapper's own box, not `ctx.unmatchedId` - which is null under the headless
|
|
150
|
+
// target, where there is no separate far end to pass a tail on to. The id for the
|
|
151
|
+
// observer comes off [js]'s AUX outlet either way.
|
|
152
|
+
const jsId = "obj-js";
|
|
153
|
+
const controls = surface?.controls;
|
|
154
|
+
if (!controls) {
|
|
155
|
+
throw new Error(
|
|
156
|
+
`chain "${CONTROLS_CHAIN}" on device "${device?.name}" needs a defineControls() declaration - ` +
|
|
157
|
+
`pass it to defineSurface({ ..., controls }) in src/app/${device?.ui ?? device?.name}/surface.ts.`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
for (const id of [TAKEOVER_PARAM, FOCUS_PARAM]) {
|
|
161
|
+
if (!surface.params?.[id]) {
|
|
162
|
+
throw new Error(
|
|
163
|
+
`chain "${CONTROLS_CHAIN}" on device "${device?.name}" expected the generated parameter "${id}". ` +
|
|
164
|
+
`defineSurface() adds it when a \`controls\` declaration is present - this surface has ${surface.ids.join(", ") || "none"}.`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const keys = controls.keys;
|
|
170
|
+
const routeId = "obj-tk-route";
|
|
171
|
+
|
|
172
|
+
// [js]'s AUX outlet, the same one the `download` chain takes [maxurl]'s requests
|
|
173
|
+
// from. Two routes hang off it in parallel and that is safe here where it is not
|
|
174
|
+
// safe on [jweb]'s outlet: neither of them forwards what it did not match, so a
|
|
175
|
+
// message is delivered once or dropped, never twice.
|
|
176
|
+
boxes.push(
|
|
177
|
+
box(routeId, `route ${keys.map(controlIdSelector).join(" ")}`, {
|
|
178
|
+
numoutlets: keys.length + 1,
|
|
179
|
+
outlettype: keys.map(() => "").concat(""),
|
|
180
|
+
}),
|
|
181
|
+
);
|
|
182
|
+
lines.push(line(jsId, 1, routeId, 0));
|
|
183
|
+
|
|
184
|
+
keys.forEach((key, i) => {
|
|
185
|
+
const obs = `obj-tk-obs-${key}`;
|
|
186
|
+
const tag = `obj-tk-pad-${key}`;
|
|
187
|
+
boxes.push(box(obs, "live.observer value", { numinlets: 2, numoutlets: 2, outlettype: ["", ""] }));
|
|
188
|
+
// The RIGHT inlet takes `id <n>` - the reference is explicit that the id goes
|
|
189
|
+
// there, and [route] has already stripped the `tk_<key>` word by the time it
|
|
190
|
+
// arrives, leaving exactly that message.
|
|
191
|
+
lines.push(line(routeId, i, obs, 1));
|
|
192
|
+
boxes.push(box(tag, `prepend pad_${key}`));
|
|
193
|
+
lines.push(line(obs, 0, tag, 0));
|
|
194
|
+
// The APP endpoint. Under the default target a press lands in the page with no
|
|
195
|
+
// [js] in the way; under headless the page is not there and it lands in [js],
|
|
196
|
+
// which is where the game already was.
|
|
197
|
+
lines.push(line(tag, 0, appIn, 0));
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
for (const [param, selector] of [
|
|
201
|
+
[TAKEOVER_PARAM, "controls_takeover"],
|
|
202
|
+
[FOCUS_PARAM, "controls_focus"],
|
|
203
|
+
]) {
|
|
204
|
+
const id = `obj-tk-${param}`;
|
|
205
|
+
boxes.push(box(id, `prepend ${selector}`));
|
|
206
|
+
lines.push(line(id, 0, jsId, 0));
|
|
207
|
+
fanParamInto(ctx, param, id, 0);
|
|
208
|
+
}
|
|
209
|
+
}
|
package/src/index.mjs
CHANGED
|
@@ -16,11 +16,18 @@ import path from "node:path";
|
|
|
16
16
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
17
17
|
|
|
18
18
|
import { AMXD_TYPES, assertES5, buildAmxd, extraPayloadsJs, payloadJs } from "./amxd.mjs";
|
|
19
|
-
import { CHAINS, assertUniqueBoxIds, closeAudio, openAudio, resetLayout } from "./chains.mjs";
|
|
19
|
+
import { CHAINS, assertUniqueBoxIds, closeAudio, openAudio, registerChain, resetLayout } from "./chains.mjs";
|
|
20
|
+
import { CONTROLS_CHAIN, controlsSpecBanner, takeoverChain, withControlsChain } from "./controls.mjs";
|
|
21
|
+
import { deviceTarget, isHeadless, openApp } from "./target.mjs";
|
|
20
22
|
import { applySurface, applyWindows, applyPersistence, loadSurface, parameterRegistry, surfaceContext } from "./surface.mjs";
|
|
21
23
|
import { effectiveChains, filesSpecBanner, loadFiles } from "./files.mjs";
|
|
22
24
|
import { loadWatch, watchSpecsBanner } from "./watch.mjs";
|
|
23
25
|
|
|
26
|
+
// The takeover chain lives in controls.mjs beside the rest of defineControls()'s
|
|
27
|
+
// build half, and joins the vocabulary here rather than in chains.mjs - which it
|
|
28
|
+
// imports for box()/line()/fanParamInto(), and which must not import it back.
|
|
29
|
+
registerChain(CONTROLS_CHAIN, takeoverChain);
|
|
30
|
+
|
|
24
31
|
const require = createRequire(import.meta.url);
|
|
25
32
|
const pkgDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
26
33
|
const templates = path.join(pkgDir, "templates");
|
|
@@ -113,6 +120,76 @@ export function buildWrapper(root) {
|
|
|
113
120
|
return out;
|
|
114
121
|
}
|
|
115
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Compile ONE headless device's own `[js]` logic to ES5.
|
|
125
|
+
*
|
|
126
|
+
* `src/app/<device>/headless.ts` is what `App.tsx` is for a jweb device: the thing the
|
|
127
|
+
* device actually does. It is concatenated after the packaged wrapper, so it sees the
|
|
128
|
+
* wrapper's globals (`post`, `outlet`, `Task`, `LiveAPI`, `MODE`) and typechecks
|
|
129
|
+
* against them - which is why the wrapper sources are in the program even though only
|
|
130
|
+
* this file's output is kept.
|
|
131
|
+
*
|
|
132
|
+
* ONE PROGRAM PER DEVICE, deliberately. Compiling every device's headless source
|
|
133
|
+
* together would put them all in one global scope, where two devices could not both
|
|
134
|
+
* declare a `var step` - a constraint invented by the build, paid by the author, for
|
|
135
|
+
* nothing. A tsc run is about a second.
|
|
136
|
+
*
|
|
137
|
+
* The ES5 gate is the same one the wrapper passes and for the same reason: Max's [js]
|
|
138
|
+
* is an ES5-era interpreter, and this file runs inside it.
|
|
139
|
+
*/
|
|
140
|
+
export function buildHeadless(root, uiDir) {
|
|
141
|
+
const src = path.join(root, "src", "app", uiDir, "headless.ts");
|
|
142
|
+
if (!existsSync(src)) return null;
|
|
143
|
+
|
|
144
|
+
const { sources, types } = require("@m4l-jweb/wrapper/sources");
|
|
145
|
+
const deviceExt = path.join(root, "wrapper", "device.ts");
|
|
146
|
+
const context = [...sources, ...(existsSync(deviceExt) ? [deviceExt] : [])];
|
|
147
|
+
|
|
148
|
+
const tmp = path.join(root, "dist", `.headless-${uiDir}`);
|
|
149
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
150
|
+
mkdirSync(tmp, { recursive: true });
|
|
151
|
+
|
|
152
|
+
// Staged flat, like buildWrapper: tsc derives its output layout from the common root
|
|
153
|
+
// of its inputs, and the packaged sources live in node_modules while this one lives
|
|
154
|
+
// in the repo.
|
|
155
|
+
const staged = context.map((f, i) => {
|
|
156
|
+
const dest = path.join(tmp, `${String(i).padStart(2, "0")}-${path.basename(f)}`);
|
|
157
|
+
writeFileSync(dest, readFileSync(f, "utf8"));
|
|
158
|
+
return dest;
|
|
159
|
+
});
|
|
160
|
+
const mine = path.join(tmp, "device-headless.ts");
|
|
161
|
+
writeFileSync(mine, readFileSync(src, "utf8"));
|
|
162
|
+
const stagedTypes = path.join(tmp, path.basename(types));
|
|
163
|
+
writeFileSync(stagedTypes, readFileSync(types, "utf8"));
|
|
164
|
+
|
|
165
|
+
const tsconfig = path.join(tmp, "tsconfig.json");
|
|
166
|
+
writeFileSync(
|
|
167
|
+
tsconfig,
|
|
168
|
+
JSON.stringify({
|
|
169
|
+
compilerOptions: {
|
|
170
|
+
target: "ES5",
|
|
171
|
+
lib: ["ES5"],
|
|
172
|
+
module: "none",
|
|
173
|
+
outDir: tmp,
|
|
174
|
+
strict: true,
|
|
175
|
+
noImplicitThis: false,
|
|
176
|
+
alwaysStrict: false,
|
|
177
|
+
noImplicitAny: false,
|
|
178
|
+
skipLibCheck: true,
|
|
179
|
+
types: [],
|
|
180
|
+
},
|
|
181
|
+
files: [stagedTypes, ...staged, mine],
|
|
182
|
+
}),
|
|
183
|
+
);
|
|
184
|
+
execFileSync(process.execPath, [require.resolve("typescript/bin/tsc"), "-p", tsconfig], { stdio: "inherit" });
|
|
185
|
+
|
|
186
|
+
const js = readFileSync(mine.replace(/\.ts$/, ".js"), "utf8");
|
|
187
|
+
assertES5(js, `headless logic for ${uiDir}`);
|
|
188
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
189
|
+
console.log(`m4l-jweb: headless logic for ${uiDir} (${js.length} bytes, ES5 verified)`);
|
|
190
|
+
return js;
|
|
191
|
+
}
|
|
192
|
+
|
|
116
193
|
/* ------------------------------------------------------------------ *
|
|
117
194
|
* Step 2: the patchers
|
|
118
195
|
* ------------------------------------------------------------------ */
|
|
@@ -143,6 +220,32 @@ async function readDocs(root) {
|
|
|
143
220
|
return Array.isArray(mod.docs) ? mod.docs : [];
|
|
144
221
|
}
|
|
145
222
|
|
|
223
|
+
/**
|
|
224
|
+
* Release BUNDLES: a zip for one device, or a few, on its own.
|
|
225
|
+
*
|
|
226
|
+
* The repo zip is for somebody installing the whole library. A bundle is for somebody who
|
|
227
|
+
* wants ONE device and has never heard of the library - a game, an instrument, a thing
|
|
228
|
+
* with its own name and its own audience. They get a zip with that device in it and
|
|
229
|
+
* nothing else.
|
|
230
|
+
*
|
|
231
|
+
* Declared as a named export beside the manifest:
|
|
232
|
+
*
|
|
233
|
+
* export const bundles = [
|
|
234
|
+
* { name: "push-snake", title: "Snake for Push", devices: ["push-snake"], readme: "doc/SNAKE.md" }
|
|
235
|
+
* ];
|
|
236
|
+
*
|
|
237
|
+
* `readme` is written into the zip as `README.md`, because that is the file a person
|
|
238
|
+
* opens. Everything the named devices need travels with them: their `looseFiles`, and the
|
|
239
|
+
* sidecar folder of any `site:` window. An `.amxd` that embeds its own assets - which is
|
|
240
|
+
* every device that does not declare those two things - needs nothing else at all.
|
|
241
|
+
*/
|
|
242
|
+
async function readBundles(root) {
|
|
243
|
+
const p = path.join(root, "patcher", "devices.mjs");
|
|
244
|
+
if (!existsSync(p)) return [];
|
|
245
|
+
const mod = await import(pathToFileURL(p).href);
|
|
246
|
+
return Array.isArray(mod.bundles) ? mod.bundles : [];
|
|
247
|
+
}
|
|
248
|
+
|
|
146
249
|
/** patcher/base.json in the device repo wins; otherwise the packaged template. */
|
|
147
250
|
function readBase(root) {
|
|
148
251
|
const local = path.join(root, "patcher", "base.json");
|
|
@@ -217,7 +320,29 @@ export function composePatcher(base, d, surface, files = null) {
|
|
|
217
320
|
*
|
|
218
321
|
* Unset keeps the object's default. jweb~ clamps to 3x the minimum.
|
|
219
322
|
*/
|
|
220
|
-
if (d.latency != null)
|
|
323
|
+
if (d.latency != null) {
|
|
324
|
+
// A headless device has no [jweb~] to buffer, and a `latency` on one is a setting
|
|
325
|
+
// for a thing that is not there - worth saying, not ignoring.
|
|
326
|
+
if (isHeadless(d)) {
|
|
327
|
+
throw new Error(`device "${d.name}" is target "headless" and sets \`latency\` - there is no [jweb~] to give a ring buffer to.`);
|
|
328
|
+
}
|
|
329
|
+
boxes.find((b) => b.box.id === "obj-jweb").box.latency = d.latency;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* `mpe: true` - ask Live to send this device MPE.
|
|
334
|
+
*
|
|
335
|
+
* `is_mpe` is a PATCHER attribute, not a box one, and it is a declaration rather
|
|
336
|
+
* than machinery: Max's own reference says "If enabled, a Max for Live device will
|
|
337
|
+
* receive MPE data from Live", and a shipping device that carries the MPE badge
|
|
338
|
+
* sets it to 1 while parsing the result with ordinary `midiin` / `midiparse` (see
|
|
339
|
+
* doc/MAX-FACTS.md). The template writes 0, so a device that wants it says so here
|
|
340
|
+
* and something still has to READ the stream - the `mpein` chain, or `midiin`.
|
|
341
|
+
*
|
|
342
|
+
* Off by default. A device declaring MPE it does not handle is a device Live sends
|
|
343
|
+
* per-note channels to for no reason, and the badge tells the user a lie.
|
|
344
|
+
*/
|
|
345
|
+
if (d.mpe) p.patcher.is_mpe = 1;
|
|
221
346
|
|
|
222
347
|
const unmatchedId = d.unmatchedTo === "js" ? "obj-js" : (d.unmatchedTo ?? "obj-js");
|
|
223
348
|
|
|
@@ -238,11 +363,21 @@ export function composePatcher(base, d, surface, files = null) {
|
|
|
238
363
|
// voice abstraction). A chain pushes { name, data } here; generatePatchers writes
|
|
239
364
|
// each next to the device patcher and packageDevices freezes it into the container.
|
|
240
365
|
const extras = [];
|
|
241
|
-
const ctx = { boxes, lines,
|
|
242
|
-
|
|
366
|
+
const ctx = { boxes, lines, unmatchedId, device: d, extras, ...surfaceContext(surface) };
|
|
367
|
+
|
|
368
|
+
// WHERE THE LOGIC RUNS, decided once and before anything else touches the graph.
|
|
369
|
+
// It sets `ctx.appIn` / `ctx.appOut` - the endpoint every chain reaches "the app"
|
|
370
|
+
// through - and, for a headless device, deletes [jweb] and both of its cords. A
|
|
371
|
+
// chain claims a stage in a stream it did not create and must not know which target
|
|
372
|
+
// created it (target.mjs).
|
|
373
|
+
openApp(ctx);
|
|
243
374
|
openAudio(ctx);
|
|
244
375
|
|
|
245
|
-
|
|
376
|
+
// A declared takeover contributes its chain the way a declared files.ts
|
|
377
|
+
// contributes `download`: derived from the declaration, never from the manifest
|
|
378
|
+
// remembering to ask. A device whose observers were missing would load, grab, and
|
|
379
|
+
// report every press to nobody.
|
|
380
|
+
for (const name of withControlsChain(effectiveChains(d.chains, files), surface)) {
|
|
246
381
|
const chain = CHAINS[name];
|
|
247
382
|
if (!chain) throw new Error(`unknown chain "${name}" for device "${d.name}" (known: ${Object.keys(CHAINS).join(", ")})`);
|
|
248
383
|
chain(ctx);
|
|
@@ -305,8 +440,9 @@ export async function generatePatchers(root) {
|
|
|
305
440
|
// The chains it was BUILT with, not the ones the manifest listed - a derived
|
|
306
441
|
// `download` that never appeared in the log would be the same invisible wiring
|
|
307
442
|
// this feature exists to end.
|
|
308
|
-
const chains = effectiveChains(d.chains, files).join(", ") || "none";
|
|
309
|
-
|
|
443
|
+
const chains = withControlsChain(effectiveChains(d.chains, files), surface).join(", ") || "none";
|
|
444
|
+
const target = deviceTarget(d);
|
|
445
|
+
console.log(`m4l-jweb: ${d.name}.json (${d.type}/${target}, chains: ${chains}, params: ${params || "none"})`);
|
|
310
446
|
}
|
|
311
447
|
return devices;
|
|
312
448
|
}
|
|
@@ -391,15 +527,17 @@ export async function packageDevices(root) {
|
|
|
391
527
|
* disk was stale. The symptom would be a device showing its sibling's
|
|
392
528
|
* interface.
|
|
393
529
|
*/
|
|
530
|
+
const headless = isHeadless(d);
|
|
394
531
|
const uiName = `${d.name}.html`;
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
532
|
+
if (!headless) {
|
|
533
|
+
const uiSrc = path.join(dist, "ui", d.ui ?? d.name, "index.html");
|
|
534
|
+
const legacy = path.join(dist, "index.html"); // single-UI repos (the starter template)
|
|
535
|
+
const uiFrom = existsSync(uiSrc) ? uiSrc : legacy;
|
|
536
|
+
if (!existsSync(uiFrom)) {
|
|
537
|
+
throw new Error(`no UI for "${d.name}" at ${uiSrc} - run \`pnpm build\` (scripts/build-ui.mjs) first`);
|
|
538
|
+
}
|
|
539
|
+
await copyFile(uiFrom, path.join(outDir, uiName));
|
|
400
540
|
}
|
|
401
|
-
const uiHtml = readFileSync(uiFrom);
|
|
402
|
-
await copyFile(uiFrom, path.join(outDir, uiName));
|
|
403
541
|
|
|
404
542
|
/**
|
|
405
543
|
* Payloads ride inside wrapper.js as base64 and are written to real files
|
|
@@ -413,19 +551,40 @@ export async function packageDevices(root) {
|
|
|
413
551
|
// ...and so do its declared files: FILES_SPEC is what tells the packaged wrapper
|
|
414
552
|
// this device writes to disk, and therefore to hand the page its device folder.
|
|
415
553
|
const files = await loadFiles(root, d.ui ?? d.name);
|
|
554
|
+
// ...and its declared CONTROLS: CONTROLS_SPEC is the role table the packaged
|
|
555
|
+
// wrapper resolves against the connected hardware's own get_control_names.
|
|
556
|
+
const deviceSurface = await loadSurface(root, d.ui ?? d.name);
|
|
416
557
|
let wrapperData =
|
|
417
|
-
banner +
|
|
418
|
-
|
|
558
|
+
banner +
|
|
559
|
+
// The one thing that tells the packaged wrapper there is no page: no [jweb] to
|
|
560
|
+
// point at a URL, no payload to extract, no window sizes to follow. Everything
|
|
561
|
+
// else about the wrapper is unchanged, which is the point of the target being a
|
|
562
|
+
// seam rather than a second wrapper.
|
|
563
|
+
(headless ? "var HEADLESS = 1;\n" : "") +
|
|
564
|
+
watchSpecsBanner(watch) +
|
|
565
|
+
filesSpecBanner(files) +
|
|
566
|
+
controlsSpecBanner(deviceSurface) +
|
|
567
|
+
siteWindowsBanner(root, outDir, d, deviceSurface) +
|
|
568
|
+
wrapperJs;
|
|
419
569
|
|
|
420
|
-
// Main UI payload
|
|
421
|
-
wrapperData += payloadJs("UI_PAYLOAD", uiName, readFileSync(path.join(dist, "ui", d.ui ?? d.name, "index.html")));
|
|
422
|
-
|
|
423
|
-
// Additional window payloads
|
|
424
|
-
const extraWindows = uiDirContent.filter((f) => f !== "index.html");
|
|
425
570
|
const payloads = (d.payloads ?? []).map((f) => ({ name: path.basename(f), data: readFileSync(path.join(root, f)) }));
|
|
426
571
|
|
|
427
|
-
|
|
428
|
-
|
|
572
|
+
if (headless) {
|
|
573
|
+
// The device's OWN [js], where a jweb device would have had a bundle. Nothing
|
|
574
|
+
// else is appended: a headless .amxd contains a patcher and one script, and that
|
|
575
|
+
// is the whole claim the target makes.
|
|
576
|
+
const own = buildHeadless(root, d.ui ?? d.name);
|
|
577
|
+
if (own) wrapperData += "\n" + own;
|
|
578
|
+
} else {
|
|
579
|
+
const uiDirContent = readdirSync(path.join(dist, "ui", d.ui ?? d.name)).filter((f) => f.endsWith(".html"));
|
|
580
|
+
|
|
581
|
+
// Main UI payload
|
|
582
|
+
wrapperData += payloadJs("UI_PAYLOAD", uiName, readFileSync(path.join(dist, "ui", d.ui ?? d.name, "index.html")));
|
|
583
|
+
|
|
584
|
+
// Additional window payloads
|
|
585
|
+
for (const winHtml of uiDirContent.filter((f) => f !== "index.html")) {
|
|
586
|
+
payloads.push({ name: `${d.name}_${winHtml}`, data: readFileSync(path.join(dist, "ui", d.ui ?? d.name, winHtml)) });
|
|
587
|
+
}
|
|
429
588
|
}
|
|
430
589
|
|
|
431
590
|
if (payloads.length) wrapperData += extraPayloadsJs(payloads);
|
|
@@ -446,7 +605,7 @@ export async function packageDevices(root) {
|
|
|
446
605
|
extras: [...(d.extraFiles ?? []).map((f) => ({ name: path.basename(f), data: readFileSync(path.join(root, f)) })), ...chainExtras],
|
|
447
606
|
});
|
|
448
607
|
writeFileSync(path.join(outDir, deviceName), amxd);
|
|
449
|
-
console.log(`m4l-jweb: ${deviceName} (${d.type}, ${amxd.length} bytes)`);
|
|
608
|
+
console.log(`m4l-jweb: ${deviceName} (${d.type}/${deviceTarget(d)}, ${amxd.length} bytes)`);
|
|
450
609
|
}
|
|
451
610
|
|
|
452
611
|
await copyFile(path.join(dist, "wrapper", "wrapper.js"), path.join(outDir, "wrapper.js"));
|
|
@@ -503,7 +662,10 @@ export async function packageDevices(root) {
|
|
|
503
662
|
archive.pipe(output);
|
|
504
663
|
const files = [
|
|
505
664
|
...devices.map((d) => `${d.name}.amxd`),
|
|
506
|
-
|
|
665
|
+
// Each device's own UI, for inspection - a HEADLESS device has none, and there
|
|
666
|
+
// is nothing missing: the .amxd holds the patcher and one script, which is the
|
|
667
|
+
// whole of it.
|
|
668
|
+
...devices.filter((d) => !isHeadless(d)).map((d) => `${d.name}.html`),
|
|
507
669
|
...loose.map((f) => path.basename(f)),
|
|
508
670
|
...presets,
|
|
509
671
|
...docs,
|
|
@@ -528,6 +690,79 @@ export async function packageDevices(root) {
|
|
|
528
690
|
|
|
529
691
|
const { size } = await stat(zipPath);
|
|
530
692
|
console.log(`m4l-jweb: dist/${name}.zip (${size} bytes)`);
|
|
693
|
+
|
|
694
|
+
await packageBundles(root, dist, outDir, devices);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* One zip per declared bundle, beside the repo zip.
|
|
699
|
+
*
|
|
700
|
+
* A bundle names devices that must exist - a typo here would otherwise produce a zip that
|
|
701
|
+
* is missing the very thing it is named after, and nothing would say so until somebody
|
|
702
|
+
* downloaded it.
|
|
703
|
+
*/
|
|
704
|
+
async function packageBundles(root, dist, outDir, devices) {
|
|
705
|
+
const bundles = await readBundles(root);
|
|
706
|
+
const known = new Set(devices.map((d) => d.name));
|
|
707
|
+
|
|
708
|
+
for (const bundle of bundles) {
|
|
709
|
+
const named = bundle.devices ?? [];
|
|
710
|
+
for (const d of named) {
|
|
711
|
+
if (!known.has(d)) {
|
|
712
|
+
throw new Error(`bundle "${bundle.name}" lists device "${d}", which patcher/devices.mjs does not declare (${[...known].join(", ")}).`);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
if (!named.length) throw new Error(`bundle "${bundle.name}" names no devices - a zip of nothing is not a release`);
|
|
716
|
+
|
|
717
|
+
const zipPath = path.join(dist, `${bundle.name}.zip`);
|
|
718
|
+
const entries = [];
|
|
719
|
+
|
|
720
|
+
await new Promise((resolve, reject) => {
|
|
721
|
+
const output = createWriteStream(zipPath);
|
|
722
|
+
const archive = archiver("zip", { zlib: { level: 9 } });
|
|
723
|
+
output.on("close", resolve);
|
|
724
|
+
archive.on("error", reject);
|
|
725
|
+
archive.pipe(output);
|
|
726
|
+
|
|
727
|
+
for (const d of named) {
|
|
728
|
+
const device = devices.find((x) => x.name === d);
|
|
729
|
+
archive.file(path.join(outDir, `${d}.amxd`), { name: `${bundle.name}/${d}.amxd` });
|
|
730
|
+
entries.push(`${d}.amxd`);
|
|
731
|
+
|
|
732
|
+
// Whatever that device cannot embed. A loose file is one a Max object resolves
|
|
733
|
+
// when it INSTANTIATES, before any code has run, so it has to be a real file next
|
|
734
|
+
// to the .amxd; a `site:` window's folder is too big to be a payload.
|
|
735
|
+
for (const f of device.looseFiles ?? []) {
|
|
736
|
+
archive.file(path.join(outDir, path.basename(f)), { name: `${bundle.name}/${path.basename(f)}` });
|
|
737
|
+
entries.push(path.basename(f));
|
|
738
|
+
}
|
|
739
|
+
const site = path.join(outDir, `${d}-site`);
|
|
740
|
+
if (existsSync(site)) {
|
|
741
|
+
archive.directory(site, `${bundle.name}/${d}-site`);
|
|
742
|
+
entries.push(`${d}-site/`);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// The file a person opens, under the name they will look for.
|
|
747
|
+
if (bundle.readme && existsSync(path.join(root, bundle.readme))) {
|
|
748
|
+
archive.file(path.join(root, bundle.readme), { name: `${bundle.name}/README.md` });
|
|
749
|
+
entries.push("README.md");
|
|
750
|
+
}
|
|
751
|
+
for (const f of bundle.docs ?? []) {
|
|
752
|
+
const from = path.join(root, f);
|
|
753
|
+
if (!existsSync(from)) {
|
|
754
|
+
console.warn(`m4l-jweb: bundle "${bundle.name}" doc ${f} is not there - skipped`);
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
archive.file(from, { name: `${bundle.name}/${path.basename(f)}` });
|
|
758
|
+
entries.push(path.basename(f));
|
|
759
|
+
}
|
|
760
|
+
archive.finalize();
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
const { size } = await stat(zipPath);
|
|
764
|
+
console.log(`m4l-jweb: dist/${bundle.name}.zip (${size} bytes: ${entries.join(", ")})`);
|
|
765
|
+
}
|
|
531
766
|
}
|
|
532
767
|
|
|
533
768
|
export async function buildAll(root) {
|
package/src/surface.mjs
CHANGED
|
@@ -389,7 +389,7 @@ function kindBoxAttrs(spec) {
|
|
|
389
389
|
* wrapper. Doing it last means no chain has to know the Surface exists.
|
|
390
390
|
*/
|
|
391
391
|
export function applySurface(ctx) {
|
|
392
|
-
const { boxes, lines, surface, jwebId } = ctx;
|
|
392
|
+
const { boxes, lines, surface, jwebId, appIn } = ctx;
|
|
393
393
|
if (!surface || surface.ids.length === 0) return;
|
|
394
394
|
|
|
395
395
|
// Where the native dials go, if any were declared. `slots` is empty for a
|
|
@@ -403,7 +403,8 @@ export function applySurface(ctx) {
|
|
|
403
403
|
// stays in one place when the app flips between the two views. It is excluded from
|
|
404
404
|
// `computeNativeSlots` (it is not in `native.params`), so give it a rect here.
|
|
405
405
|
const switchId = native?.switch;
|
|
406
|
-
|
|
406
|
+
// Null under the headless target: there is no page to lay out around.
|
|
407
|
+
const jwebBox = jwebId ? boxes.find((b) => b.box.id === jwebId)?.box : null;
|
|
407
408
|
const [, , jpw] = jwebBox?.presentation_rect ?? [0, 0, 420, DEVICE_H];
|
|
408
409
|
let switchRect = null;
|
|
409
410
|
if (switchId) {
|
|
@@ -435,16 +436,19 @@ export function applySurface(ctx) {
|
|
|
435
436
|
},
|
|
436
437
|
});
|
|
437
438
|
// Read direction: a knob turn reaches the app as `<id> <value>`. A parameter
|
|
438
|
-
// is just another inlet message
|
|
439
|
+
// is just another inlet message - and under the headless target the app IS the
|
|
440
|
+
// wrapper, so the same cord lands on a `function <id>()` in [js] instead of on
|
|
441
|
+
// a `useParam()` in React.
|
|
439
442
|
boxes.push(box(`obj-prepend-${id}`, `prepend ${id}`));
|
|
440
443
|
lines.push(line(paramObject(id), 0, `obj-prepend-${id}`, 0));
|
|
441
|
-
lines.push(line(`obj-prepend-${id}`, 0,
|
|
444
|
+
lines.push(line(`obj-prepend-${id}`, 0, appIn, 0));
|
|
442
445
|
x += 56;
|
|
443
446
|
}
|
|
444
447
|
|
|
445
448
|
// Position [jweb] for the native layout. WIDTH is preserved (React layouts were
|
|
446
449
|
// built for 420 px). A surface with no native content leaves [jweb] where the
|
|
447
|
-
// template put it
|
|
450
|
+
// template put it - and a HEADLESS device has no [jweb] at all, so the dials are
|
|
451
|
+
// the whole device view and nothing needs shifting out of their way.
|
|
448
452
|
if ((nativeW > 0 || switchId) && jwebBox) {
|
|
449
453
|
const [, py, pw, ph] = jwebBox.presentation_rect ?? [0, 0, 420, DEVICE_H];
|
|
450
454
|
if (native.panel) {
|
|
@@ -724,6 +728,18 @@ export function applyWindows(ctx) {
|
|
|
724
728
|
const windowIds = surface?.windows ? Object.keys(surface.windows) : [];
|
|
725
729
|
if (windowIds.length === 0) return;
|
|
726
730
|
|
|
731
|
+
// A window IS a second page - its own [jweb] in a subpatcher, its own bundle. There
|
|
732
|
+
// is no page under the headless target, so this is not a thing that can be built
|
|
733
|
+
// quietly smaller: say so, at the declaration, rather than emit a subpatcher whose
|
|
734
|
+
// whole contents are a box that target does not have.
|
|
735
|
+
if (ctx.target === "headless") {
|
|
736
|
+
throw new Error(
|
|
737
|
+
`device "${ctx.device?.name}" is target "headless" and declares window(s) ${windowIds.join(", ")} in its surface.ts. ` +
|
|
738
|
+
`A window is a second [jweb] page, which that target has no browser for. ` +
|
|
739
|
+
`Use layout.native for a device view, or drop \`target: "headless"\`.`,
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
|
|
727
743
|
const selectors = windowIds.flatMap((id) => [`window_${id}_open`, `window_${id}_close`]);
|
|
728
744
|
|
|
729
745
|
const routeId = "obj-windows-route";
|
package/src/target.mjs
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* target.mjs - WHERE a device's logic runs, and what the patcher therefore contains.
|
|
3
|
+
*
|
|
4
|
+
* Until now there was one answer and it was wired into everything: the logic is a
|
|
5
|
+
* React app inside `[jweb]`, so the patcher has a `[jweb]`, the `.amxd` carries a
|
|
6
|
+
* base64 HTML payload, and every chain that wants to reach "the app" sends to that
|
|
7
|
+
* box. A device could not opt out, and the library was named after the assumption.
|
|
8
|
+
*
|
|
9
|
+
* A TARGET is that assumption, made explicit and given a second value:
|
|
10
|
+
*
|
|
11
|
+
* jweb the device page runs in Max's embedded Chromium. React, Web Audio,
|
|
12
|
+
* Workers, a UI you can look at. What every device shipped before this.
|
|
13
|
+
* headless there is no browser at all. The device declares its interface in
|
|
14
|
+
* TypeScript exactly as it does today - `defineSurface`, `defineControls`,
|
|
15
|
+
* `defineWatch` - and the build emits ONLY the `[js]` wrapper and the
|
|
16
|
+
* patcher. The device's own logic is `src/app/<device>/headless.ts`,
|
|
17
|
+
* compiled to ES5 and concatenated after the wrapper.
|
|
18
|
+
*
|
|
19
|
+
* ------------------------------------------------------------------------------
|
|
20
|
+
* WHY HEADLESS IS WORTH A SEAM, and none of it is speculation (doc/TODO.md item 3):
|
|
21
|
+
*
|
|
22
|
+
* - THE PAD TAKEOVER NEEDS NO BROWSER. The grab, the paint and the value observer
|
|
23
|
+
* are `live.object` / `live.observer` and `[js]`; the page was never in that path.
|
|
24
|
+
* A grid device's logic is a control plane, and `[js]` plus `Task` is a BETTER
|
|
25
|
+
* clock than a Chromium page nobody is looking at - the Worker in `push-snake`
|
|
26
|
+
* exists to dodge throttling that only a hidden page suffers.
|
|
27
|
+
* - AUDIO WITHOUT `[jweb~]` is a path this repo has already walked: bytes to disk,
|
|
28
|
+
* played through `[buffer~]`/`[groove~]`. Retired for ergonomics, not because it
|
|
29
|
+
* failed - and for a device holding two decoded FLACs the trade inverts, because
|
|
30
|
+
* half a gigabyte inside Chromium inside Live is not a thing `[buffer~]` suffers.
|
|
31
|
+
*
|
|
32
|
+
* ------------------------------------------------------------------------------
|
|
33
|
+
* THE ONE THING THE SEAM IS: `ctx.appIn` and `ctx.appOut`.
|
|
34
|
+
*
|
|
35
|
+
* Every chain that reaches "the app" used to name `[jweb]`. Now it names the APP
|
|
36
|
+
* ENDPOINT, which is `[jweb]` under one target and `[js]` under the other - and that
|
|
37
|
+
* substitution is the whole port. A `[prepend notein]` feeds `ctx.appIn` either way; a
|
|
38
|
+
* `[route midinote]` claims from `ctx.appOut` either way. Nothing else in the chain
|
|
39
|
+
* vocabulary had to know.
|
|
40
|
+
*
|
|
41
|
+
* What headless GIVES UP is real and belongs here rather than in a surprise: no React
|
|
42
|
+
* device view (native `live.*` objects only, which `defineSurface` already generates),
|
|
43
|
+
* no Web Audio, no Workers, no floating windows, and ES5 in the emitted output.
|
|
44
|
+
*/
|
|
45
|
+
import { removeBox } from "./chains.mjs";
|
|
46
|
+
|
|
47
|
+
/** The `[jweb]` box the template ships. Only a jweb-target device has one. */
|
|
48
|
+
export const JWEB_ID = "obj-jweb";
|
|
49
|
+
/** The `[js]` box, which EVERY device has - it is the wrapper. */
|
|
50
|
+
export const JS_ID = "obj-js";
|
|
51
|
+
|
|
52
|
+
export const TARGETS = ["jweb", "headless"];
|
|
53
|
+
|
|
54
|
+
/** A device's target, defaulting to the one every device had before there was a choice. */
|
|
55
|
+
export function deviceTarget(d) {
|
|
56
|
+
const target = d?.target ?? "jweb";
|
|
57
|
+
if (!TARGETS.includes(target)) {
|
|
58
|
+
throw new Error(`device "${d?.name}" declares target "${target}" - known targets are ${TARGETS.join(", ")}`);
|
|
59
|
+
}
|
|
60
|
+
return target;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const isHeadless = (d) => deviceTarget(d) === "headless";
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Point the context's app endpoint at whatever this device's target is, and delete
|
|
67
|
+
* what the other one would have needed.
|
|
68
|
+
*
|
|
69
|
+
* Called by `composePatcher` BEFORE any chain, for the same reason `openAudio` is:
|
|
70
|
+
* a chain claims a stage in a stream it did not create, and it must not have to know
|
|
71
|
+
* which target created it.
|
|
72
|
+
*
|
|
73
|
+
* THE HEADLESS CASE REMOVES `[jweb]` OUTRIGHT, both cords with it - the template's
|
|
74
|
+
* `[js] -> [jweb]` and `[jweb] -> [js]`. A box left in place with nothing wired to it
|
|
75
|
+
* would still load Chromium, which is the entire cost the target exists to avoid.
|
|
76
|
+
*
|
|
77
|
+
* `appOut` is seeded rather than left null for headless, because `claimAppMessages`
|
|
78
|
+
* falls back to `[jweb]`'s outlet 2 when it is null - the one place the old assumption
|
|
79
|
+
* was a literal.
|
|
80
|
+
*/
|
|
81
|
+
export function openApp(ctx) {
|
|
82
|
+
const target = deviceTarget(ctx.device);
|
|
83
|
+
|
|
84
|
+
if (target === "jweb") {
|
|
85
|
+
ctx.target = "jweb";
|
|
86
|
+
ctx.jwebId = JWEB_ID;
|
|
87
|
+
ctx.appIn = JWEB_ID;
|
|
88
|
+
// Left null on purpose: claimAppMessages reads [jweb]'s outlet 2 as the head of
|
|
89
|
+
// the unclaimed stream, and asserts the template's cord to [js] is still there.
|
|
90
|
+
ctx.appOut = null;
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
ctx.target = "headless";
|
|
95
|
+
removeBox(ctx.boxes, ctx.lines, JWEB_ID);
|
|
96
|
+
// Nothing may reach for `[jweb]` from here on. It is null rather than absent so the
|
|
97
|
+
// few genuinely jweb-only things (the `webaudio` chain, a declared window, the
|
|
98
|
+
// presentation rect applySurface gives the page) fail on a name rather than wire a
|
|
99
|
+
// cord to a box that is not in the patcher.
|
|
100
|
+
ctx.jwebId = null;
|
|
101
|
+
ctx.appIn = JS_ID;
|
|
102
|
+
// The wrapper's outlet 0 IS the app's outlet here: what a page would have sent
|
|
103
|
+
// across the bridge, `[js]` sends out of the same port, into the same routes.
|
|
104
|
+
ctx.appOut = [JS_ID, 0];
|
|
105
|
+
// ...and there is nowhere to pass the unmatched tail ON to, because [js] is already
|
|
106
|
+
// the far end. A route's unmatched outlet is simply left unconnected.
|
|
107
|
+
ctx.unmatchedId = null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The message a jweb-only feature should refuse a headless device with. */
|
|
111
|
+
export function refuseHeadless(ctx, what, instead) {
|
|
112
|
+
return new Error(
|
|
113
|
+
`device "${ctx.device?.name}" is target "headless", and ${what} needs the browser. ` +
|
|
114
|
+
`${instead} Drop \`target: "headless"\` from patcher/devices.mjs, or drop ${what}.`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
@@ -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.6.0",
|
|
21
|
+
"@m4l-jweb/surface": "^1.6.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.6.0",
|
|
27
27
|
"@types/node": "^22.0.0",
|
|
28
28
|
"@types/react": "^19.0.0",
|
|
29
29
|
"@types/react-dom": "^19.0.0",
|
|
@@ -17,8 +17,15 @@ export const devices = (await import(pathToFileURL(path.join(root, "patcher/devi
|
|
|
17
17
|
/** The folder under src/app/ holding a device's UI. */
|
|
18
18
|
export const uiDir = (d) => d.ui ?? d.name;
|
|
19
19
|
|
|
20
|
-
/**
|
|
21
|
-
|
|
20
|
+
/**
|
|
21
|
+
* Every distinct UI that has to be built (two devices may share one).
|
|
22
|
+
*
|
|
23
|
+
* A `target: "headless"` device has none - no [jweb], no page, no bundle - so it is not
|
|
24
|
+
* in this list and `pnpm dev:<it>` has nothing to serve. Its interface is its
|
|
25
|
+
* `surface.ts` and its logic is `headless.ts`, both of which the .amxd build reads
|
|
26
|
+
* directly.
|
|
27
|
+
*/
|
|
28
|
+
export const uiDirs = [...new Set(devices.filter((d) => d.target !== "headless").map(uiDir))];
|
|
22
29
|
|
|
23
30
|
/** Validate a device name from the command line, with a useful error. */
|
|
24
31
|
export function resolveDevice(arg) {
|
|
@@ -27,7 +34,17 @@ export function resolveDevice(arg) {
|
|
|
27
34
|
if (!known.includes(name)) {
|
|
28
35
|
throw new Error(`unknown device "${name}" - patcher/devices.mjs declares: ${known.join(", ")}`);
|
|
29
36
|
}
|
|
30
|
-
const
|
|
37
|
+
const d = devices.find((x) => x.name === name);
|
|
38
|
+
// A HEADLESS device has no page to serve, and the harness exists to run one beside a
|
|
39
|
+
// mocked Live. Say that, rather than start a dev server on a folder with no App.tsx
|
|
40
|
+
// in it and fail somewhere inside vite.
|
|
41
|
+
if (d.target === "headless") {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`device "${name}" is target "headless" - it has no page, so there is nothing for the dev harness to serve. ` +
|
|
44
|
+
`Its logic is src/app/${uiDir(d)}/headless.ts, which runs in Max's [js]: build it and load the .amxd.`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
const dir = uiDir(d);
|
|
31
48
|
if (!existsSync(path.join(root, "src/app", dir))) {
|
|
32
49
|
throw new Error(`device "${name}" has no UI at src/app/${dir}/`);
|
|
33
50
|
}
|
|
@@ -24,5 +24,13 @@
|
|
|
24
24
|
"noUnusedParameters": true,
|
|
25
25
|
"noFallthroughCasesInSwitch": true
|
|
26
26
|
},
|
|
27
|
-
"include": ["src"]
|
|
27
|
+
"include": ["src"],
|
|
28
|
+
/*
|
|
29
|
+
* A HEADLESS device's `headless.ts` is NOT app code. It is a Max `[js]` script -
|
|
30
|
+
* ES5, no modules, and its globals are `post`/`outlet`/`Task`/`LiveAPI` from the
|
|
31
|
+
* wrapper's max.d.ts, none of which exist here. The build compiles it in its own
|
|
32
|
+
* program (buildHeadless in @m4l-jweb/build), which is where it is typechecked;
|
|
33
|
+
* leaving it in this one reports every Max global as an undefined name.
|
|
34
|
+
*/
|
|
35
|
+
"exclude": ["src/app/*/headless.ts"]
|
|
28
36
|
}
|
|
@@ -19,11 +19,22 @@
|
|
|
19
19
|
"isolatedModules": true,
|
|
20
20
|
"moduleDetection": "force",
|
|
21
21
|
"noEmit": true,
|
|
22
|
+
// A test that RENDERS a component reaches a .tsx module, and tsc refuses to
|
|
23
|
+
// resolve one without a jsx setting even when the test itself is plain .ts.
|
|
24
|
+
// Matches tsconfig.app.json, and vitest.config.ts sets the same automatic
|
|
25
|
+
// runtime for its own esbuild pass.
|
|
26
|
+
"jsx": "react-jsx",
|
|
22
27
|
"strict": true
|
|
23
28
|
},
|
|
24
29
|
// `vitest.config.ts` is listed alongside the tests, and not only to check it: a
|
|
25
30
|
// project whose include matches nothing at all is a tsc ERROR (TS18003), and a
|
|
26
31
|
// freshly scaffolded repo has no tests yet. One file that always exists keeps the
|
|
27
32
|
// project valid from the first minute.
|
|
28
|
-
|
|
33
|
+
// `src/vite-env.d.ts` is here for its `/// <reference types="vite/client" />`, not
|
|
34
|
+
// to be checked. A test that imports app code pulls that module into THIS program,
|
|
35
|
+
// and app code imports assets - `import track from "./x.ogg"` - whose ambient
|
|
36
|
+
// declarations live in vite's client types. Without them the test project reports
|
|
37
|
+
// "cannot find module ./x.ogg" for a file the app compiles fine, and only under
|
|
38
|
+
// `tsc -b`, which is the one that runs in CI.
|
|
39
|
+
"include": ["tests/**/*.ts", "vitest.config.ts", "src/vite-env.d.ts"]
|
|
29
40
|
}
|
|
@@ -5,6 +5,12 @@ export default defineConfig({
|
|
|
5
5
|
resolve: {
|
|
6
6
|
alias: [{ find: "@", replacement: fileURLToPath(new URL("./src", import.meta.url)) }],
|
|
7
7
|
},
|
|
8
|
+
// The app and the packages are compiled with tsconfig's "jsx": "react-jsx" - the
|
|
9
|
+
// AUTOMATIC runtime, where a component file imports no React at all. vitest's own
|
|
10
|
+
// esbuild pass defaults to the classic runtime, so a test that renders any component
|
|
11
|
+
// dies on "React is not defined" inside a file that is perfectly correct. Match the
|
|
12
|
+
// app.
|
|
13
|
+
esbuild: { jsx: "automatic" },
|
|
8
14
|
test: {
|
|
9
15
|
include: ["tests/**/*.test.{ts,mjs}", "packages/*/tests/**/*.test.{ts,mjs}", "src/**/*.test.{ts,tsx}"],
|
|
10
16
|
environment: "node",
|