@m4l-jweb/build 0.4.0 → 0.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 +2 -2
- package/src/chains.mjs +399 -34
- package/src/index.mjs +97 -48
- package/src/surface.mjs +205 -0
- package/templates/starter/package.json +3 -3
- package/templates/starter/patcher/devices.mjs +13 -3
- package/templates/starter/scripts/build-ui.mjs +34 -3
- package/templates/starter/vite.config.ts +4 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m4l-jweb/build",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.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",
|
|
@@ -33,6 +33,6 @@
|
|
|
33
33
|
"archiver": "^7.0.1",
|
|
34
34
|
"esbuild": "^0.25.0",
|
|
35
35
|
"typescript": "^5.7.0",
|
|
36
|
-
"@m4l-jweb/wrapper": "0.
|
|
36
|
+
"@m4l-jweb/wrapper": "0.6.0"
|
|
37
37
|
}
|
|
38
38
|
}
|
package/src/chains.mjs
CHANGED
|
@@ -5,9 +5,17 @@
|
|
|
5
5
|
* `lines` (cords: [sourceBox, outlet] -> [destBox, inlet]). So we never draw
|
|
6
6
|
* one - we generate it. Patch cords become code review.
|
|
7
7
|
*
|
|
8
|
-
* A chain is a small function that claims
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* A chain is a small function that claims a STAGE and hands the rest on. There
|
|
9
|
+
* are two streams to claim a stage in, and both work the same way:
|
|
10
|
+
*
|
|
11
|
+
* the app's messages `claimAppMessages(ctx, myRoute, unmatchedOutlet)`
|
|
12
|
+
* the signal path `ctx.audioIn(ch)` / `ctx.setAudioOut(ch, id, outlet)`
|
|
13
|
+
*
|
|
14
|
+
* A chain never OWNS either stream. It takes what the stage before it left, and
|
|
15
|
+
* says what it leaves for the stage after it - which is what makes
|
|
16
|
+
* `chains: ["lowpass", "gain"]` a series rather than two devices fighting over one
|
|
17
|
+
* patcher. Add your own with `registerChain()`; keep them small and named after
|
|
18
|
+
* what they do.
|
|
11
19
|
*/
|
|
12
20
|
|
|
13
21
|
let y = 300; // stack generated objects below the hand-made ones
|
|
@@ -96,6 +104,116 @@ export function claimAppMessages(ctx, routeId, unmatchedOutlet) {
|
|
|
96
104
|
ctx.appOut = [routeId, unmatchedOutlet];
|
|
97
105
|
}
|
|
98
106
|
|
|
107
|
+
/* ------------------------------------------------------------------ *
|
|
108
|
+
* The signal path
|
|
109
|
+
* ------------------------------------------------------------------ */
|
|
110
|
+
|
|
111
|
+
/** The device's audio endpoints. Created by the BUILD, never by a chain. */
|
|
112
|
+
export const AUDIO_IN = "obj-plugin";
|
|
113
|
+
export const AUDIO_OUT = "obj-plugout";
|
|
114
|
+
|
|
115
|
+
/** plugin~ hands us a stereo pair, so every stage is a pair of signal objects. */
|
|
116
|
+
export const AUDIO_CHANNELS = 2;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Create the device's audio endpoints, once, before any chain runs.
|
|
120
|
+
*
|
|
121
|
+
* THIS IS THE FIX FOR A SILENT BUG. Every audio chain used to create `plugin~` and
|
|
122
|
+
* `plugout~` for itself and wire itself between them - so each one was a whole
|
|
123
|
+
* device, not a stage in one. `chains: ["lowpass", "gain"]` emitted two boxes
|
|
124
|
+
* sharing the id `obj-plugin`, two sharing `obj-plugout`, and FOUR sources summing
|
|
125
|
+
* into the output: the filtered pair and the unfiltered gain pair, in parallel. The
|
|
126
|
+
* effects did not stack, they mixed - with no error at build time and none in Live.
|
|
127
|
+
* It just sounded wrong in a way you would blame on your DSP.
|
|
128
|
+
*
|
|
129
|
+
* So the endpoints belong to the device, and a chain occupies a STAGE between them:
|
|
130
|
+
*
|
|
131
|
+
* [plugin~] -> [onepole~] -> [*~] -> [plugout~]
|
|
132
|
+
* "lowpass" "gain"
|
|
133
|
+
*
|
|
134
|
+
* `ctx.audioIn(ch)` is whatever the last stage left on that channel - `plugin~` if
|
|
135
|
+
* you are the first. `ctx.setAudioOut(ch, id, outlet)` says you are the tail now.
|
|
136
|
+
* `closeAudio()` wires the final tail into `plugout~` once every chain has run.
|
|
137
|
+
*
|
|
138
|
+
* It is the same shape as `claimAppMessages()` one layer down: several things want
|
|
139
|
+
* one stream, so they are chained in SERIES with an explicit hand-off rather than
|
|
140
|
+
* hung off the source in parallel.
|
|
141
|
+
*/
|
|
142
|
+
export function openAudio(ctx) {
|
|
143
|
+
const { boxes, lines, device } = ctx;
|
|
144
|
+
const type = device?.type;
|
|
145
|
+
|
|
146
|
+
if (type !== "audio" && type !== "instrument") {
|
|
147
|
+
// A MIDI effect has no signal path at all. Say so when a chain asks for one,
|
|
148
|
+
// rather than emitting a cord from a box that does not exist - which Max opens
|
|
149
|
+
// as a patcher with a missing object and no explanation.
|
|
150
|
+
const refuse = () => {
|
|
151
|
+
throw new Error(
|
|
152
|
+
`a chain on device "${device?.name}" asked for the signal path, but the device is type "${type}". ` +
|
|
153
|
+
`plugin~/plugout~ exist only in an audio-effect or instrument device: set \`type: "audio"\` in patcher/devices.mjs.`,
|
|
154
|
+
);
|
|
155
|
+
};
|
|
156
|
+
ctx.audioIn = refuse;
|
|
157
|
+
ctx.setAudioOut = refuse;
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// An audio effect has no MIDI ports. An instrument keeps them: MIDI in is how it
|
|
162
|
+
// is played.
|
|
163
|
+
if (type === "audio") {
|
|
164
|
+
removeBox(boxes, lines, "obj-midiin");
|
|
165
|
+
removeBox(boxes, lines, "obj-midiout");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
boxes.push(box(AUDIO_IN, "plugin~", { numinlets: 1, numoutlets: 2, outlettype: ["signal", "signal"] }));
|
|
169
|
+
boxes.push(box(AUDIO_OUT, "plugout~", { numinlets: 2, numoutlets: 0 }));
|
|
170
|
+
|
|
171
|
+
// The tail of the signal path, per channel. Nothing has claimed a stage yet, so
|
|
172
|
+
// it is the input: a device with no audio chain at all is a straight wire.
|
|
173
|
+
ctx.audioTail = Array.from({ length: AUDIO_CHANNELS }, (_, ch) => [AUDIO_IN, ch]);
|
|
174
|
+
ctx.audioIn = (ch) => ctx.audioTail[ch];
|
|
175
|
+
ctx.setAudioOut = (ch, id, outlet = 0) => {
|
|
176
|
+
ctx.audioTail[ch] = [id, outlet];
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Wire whatever the last stage left into `plugout~`. The build calls this last. */
|
|
181
|
+
export function closeAudio(ctx) {
|
|
182
|
+
if (!ctx.audioTail) return; // not an audio device
|
|
183
|
+
ctx.audioTail.forEach(([srcId, srcOut], ch) => ctx.lines.push(line(srcId, srcOut, AUDIO_OUT, ch)));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Two boxes with one id is a MALFORMED patcher, and Max resolves it however it
|
|
188
|
+
* likes - so the device loads, and the cords go somewhere nobody chose. That was
|
|
189
|
+
* the visible half of the audio-chain bug, and nothing rejected it. This does.
|
|
190
|
+
*
|
|
191
|
+
* The build calls it after every chain and the Surface have run, on the patcher
|
|
192
|
+
* that is about to be written, so it covers the canned chains, a device repo's own
|
|
193
|
+
* `patcher/chains.mjs`, and the template underneath both.
|
|
194
|
+
*/
|
|
195
|
+
export function assertUniqueBoxIds(boxes, deviceName, scope = "the patcher") {
|
|
196
|
+
const seen = new Set();
|
|
197
|
+
const dupes = new Set();
|
|
198
|
+
for (const { box: b } of boxes) (seen.has(b.id) ? dupes : seen).add(b.id);
|
|
199
|
+
if (dupes.size) {
|
|
200
|
+
throw new Error(
|
|
201
|
+
`device "${deviceName}" generated duplicate box ids in ${scope}: ${[...dupes].join(", ")}. ` +
|
|
202
|
+
`Two boxes with one id is a patcher Max will interpret however it likes. ` +
|
|
203
|
+
`A chain that creates a box another chain also creates is not a stage - it is claiming to be the whole device. ` +
|
|
204
|
+
`Take the stage before you (ctx.audioIn / ctx.appOut) and hand yours on, or name your boxes after your chain.`,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
// A SUBPATCHER is its own id namespace, so its boxes are checked against each
|
|
208
|
+
// other and not against ours - a floating window's [jweb] may be called
|
|
209
|
+
// `obj-jweb` even though the device's is too. It is checked, though: the window
|
|
210
|
+
// codegen emitted two inlets sharing one id and nothing said a word, because
|
|
211
|
+
// this only ever looked at the top level.
|
|
212
|
+
for (const { box: b } of boxes) {
|
|
213
|
+
if (b.patcher?.boxes) assertUniqueBoxIds(b.patcher.boxes, deviceName, `subpatcher [${b.text ?? b.id}]`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
99
217
|
/**
|
|
100
218
|
* "midiin" - feed incoming MIDI notes to the app as `notein <pitch> <velocity>`.
|
|
101
219
|
*
|
|
@@ -172,25 +290,23 @@ function midiOutChain(ctx) {
|
|
|
172
290
|
/**
|
|
173
291
|
* "passthrough" - an audio effect that passes its input through UNTOUCHED.
|
|
174
292
|
*
|
|
175
|
-
* It
|
|
293
|
+
* It claims no stage, so the signal path stays what the build left it:
|
|
294
|
+
* `plugin~ -> plugout~`, a straight wire. Removing the device from a track sounds
|
|
176
295
|
* identical, because it does nothing to the audio - it exists to prove that an
|
|
177
296
|
* audio-effect container builds and that the UI runs inside one.
|
|
178
297
|
*
|
|
179
|
-
* It is a scaffold, not a feature
|
|
180
|
-
*
|
|
298
|
+
* It is a scaffold, not a feature, and since the build now creates the endpoints it
|
|
299
|
+
* is also a NO-OP: `type: "audio"` with no chains at all does exactly this. It
|
|
300
|
+
* survives because `chains: ["passthrough"]` says out loud what an empty list only
|
|
301
|
+
* implies. If you want an audio effect that is audible, you want `gain` below, or
|
|
302
|
+
* your own chain shaped like it.
|
|
181
303
|
*/
|
|
182
|
-
function passthroughChain(
|
|
183
|
-
//
|
|
184
|
-
removeBox(boxes, lines, "obj-midiin");
|
|
185
|
-
removeBox(boxes, lines, "obj-midiout");
|
|
186
|
-
boxes.push(box("obj-plugin", "plugin~", { numinlets: 1, numoutlets: 2, outlettype: ["signal", "signal"] }));
|
|
187
|
-
boxes.push(box("obj-plugout", "plugout~", { numinlets: 2, numoutlets: 0 }));
|
|
188
|
-
lines.push(line("obj-plugin", 0, "obj-plugout", 0));
|
|
189
|
-
lines.push(line("obj-plugin", 1, "obj-plugout", 1));
|
|
304
|
+
function passthroughChain(ctx) {
|
|
305
|
+
ctx.audioIn(0); // ...and hand it straight on. Also asserts the device HAS audio.
|
|
190
306
|
}
|
|
191
307
|
|
|
192
308
|
/**
|
|
193
|
-
* "gain" - an audio effect that actually DOES something:
|
|
309
|
+
* "gain" - an audio effect that actually DOES something: `*~` in the signal path,
|
|
194
310
|
* with a Live parameter riding the multiplier. Turn the dial, hear the level move.
|
|
195
311
|
*
|
|
196
312
|
* The smallest honest example of the shape every audio effect has - your DSP goes
|
|
@@ -208,23 +324,18 @@ function passthroughChain({ boxes, lines }) {
|
|
|
208
324
|
*/
|
|
209
325
|
function gainChain(ctx) {
|
|
210
326
|
const { boxes, lines, device } = ctx;
|
|
211
|
-
removeBox(boxes, lines, "obj-midiin");
|
|
212
|
-
removeBox(boxes, lines, "obj-midiout");
|
|
213
|
-
|
|
214
327
|
const paramId = requireParam(ctx, "gain", device?.gainParam ?? "gain", "gainParam");
|
|
215
328
|
|
|
216
|
-
boxes.push(box("obj-plugin", "plugin~", { numinlets: 1, numoutlets: 2, outlettype: ["signal", "signal"] }));
|
|
217
|
-
boxes.push(box("obj-plugout", "plugout~", { numinlets: 2, numoutlets: 0 }));
|
|
218
|
-
|
|
219
329
|
// One *~ per channel: a signal object handles ONE signal, and plugin~ hands us
|
|
220
330
|
// a stereo pair. `1.` (a float, not an int) keeps the right inlet in float mode.
|
|
221
|
-
for (const [
|
|
331
|
+
for (const [ch, id] of [
|
|
222
332
|
[0, "obj-gain-l"],
|
|
223
333
|
[1, "obj-gain-r"],
|
|
224
334
|
]) {
|
|
335
|
+
const [srcId, srcOut] = ctx.audioIn(ch); // whatever the last stage left
|
|
225
336
|
boxes.push(box(id, "*~ 1.", { numinlets: 2, numoutlets: 1, outlettype: ["signal"] }));
|
|
226
|
-
lines.push(line(
|
|
227
|
-
|
|
337
|
+
lines.push(line(srcId, srcOut, id, 0));
|
|
338
|
+
ctx.setAudioOut(ch, id, 0); // we are the tail now
|
|
228
339
|
fanParamInto(ctx, paramId, id, 1);
|
|
229
340
|
}
|
|
230
341
|
}
|
|
@@ -243,6 +354,14 @@ function gainChain(ctx) {
|
|
|
243
354
|
*
|
|
244
355
|
* The boxes named here are created LATER, by applySurface(). A patchline may name a
|
|
245
356
|
* box further down the array: a patcher is a graph, not a script.
|
|
357
|
+
*
|
|
358
|
+
* THE VALUE ARRIVES IN REAL UNITS, AND A CHAIN DOES NO ARITHMETIC ON IT. The range,
|
|
359
|
+
* the unit and the curve live on the PARAMETER (`range: [40, 18000]`, `unit: "Hz"`,
|
|
360
|
+
* `exponent`) - which is where Live wants them: the automation lane reads Hz, Push
|
|
361
|
+
* reads "7.3 kHz", and the number drops straight into the DSP object. `lowpass`
|
|
362
|
+
* used to take a 0-1 parameter and map it with `[expr 40. * pow(450., $f1)]`; a
|
|
363
|
+
* chain that reintroduces a mapping like that DOUBLE-MAPS a parameter that already
|
|
364
|
+
* carries its own curve, and lies to every readout while it does it.
|
|
246
365
|
*/
|
|
247
366
|
function fanParamInto(ctx, paramId, dstId, dstInlet) {
|
|
248
367
|
const [objId, objOut] = ctx.paramObject(paramId);
|
|
@@ -273,7 +392,7 @@ function requireParam(ctx, chainName, paramId, overrideField) {
|
|
|
273
392
|
* parameter via `set_cutoff` - so moving it moves the dial, the automation lane
|
|
274
393
|
* and the filter together. It is one control, with two faces.
|
|
275
394
|
*
|
|
276
|
-
* `
|
|
395
|
+
* `onepole~` in the signal path, one filter per channel.
|
|
277
396
|
*
|
|
278
397
|
* WHY onepole~ and not lores~/svf~/biquad~: a one-pole is a 6 dB/octave slope -
|
|
279
398
|
* the gentlest filter there is. It cannot self-oscillate, cannot blow up, and
|
|
@@ -293,23 +412,18 @@ function requireParam(ctx, chainName, paramId, overrideField) {
|
|
|
293
412
|
*/
|
|
294
413
|
function lowpassChain(ctx) {
|
|
295
414
|
const { boxes, lines, device } = ctx;
|
|
296
|
-
removeBox(boxes, lines, "obj-midiin");
|
|
297
|
-
removeBox(boxes, lines, "obj-midiout");
|
|
298
|
-
|
|
299
415
|
const paramId = requireParam(ctx, "lowpass", device?.cutoffParam ?? "cutoff", "cutoffParam");
|
|
300
416
|
|
|
301
|
-
boxes.push(box("obj-plugin", "plugin~", { numinlets: 1, numoutlets: 2, outlettype: ["signal", "signal"] }));
|
|
302
|
-
boxes.push(box("obj-plugout", "plugout~", { numinlets: 2, numoutlets: 0 }));
|
|
303
|
-
|
|
304
417
|
// One filter per channel: a signal object handles ONE signal, and plugin~ hands
|
|
305
418
|
// us a stereo pair. Both take the same cutoff, so the image does not shift.
|
|
306
|
-
for (const [
|
|
419
|
+
for (const [ch, id] of [
|
|
307
420
|
[0, "obj-lpf-l"],
|
|
308
421
|
[1, "obj-lpf-r"],
|
|
309
422
|
]) {
|
|
423
|
+
const [srcId, srcOut] = ctx.audioIn(ch);
|
|
310
424
|
boxes.push(box(id, "onepole~ 18000.", { numinlets: 2, numoutlets: 1, outlettype: ["signal"] }));
|
|
311
|
-
lines.push(line(
|
|
312
|
-
|
|
425
|
+
lines.push(line(srcId, srcOut, id, 0));
|
|
426
|
+
ctx.setAudioOut(ch, id, 0);
|
|
313
427
|
// The cutoff, in Hz, into the RIGHT inlet - from both of its sources: the dial
|
|
314
428
|
// (a knob turn, an automation lane, a Push encoder) and the route (the app's
|
|
315
429
|
// write, which the dial will not re-emit because it arrives as `set`).
|
|
@@ -317,12 +431,263 @@ function lowpassChain(ctx) {
|
|
|
317
431
|
}
|
|
318
432
|
}
|
|
319
433
|
|
|
434
|
+
/**
|
|
435
|
+
* "drive" - soft-clipping distortion: an `overdrive~` in the signal path, with a
|
|
436
|
+
* Live parameter on the drive factor.
|
|
437
|
+
*
|
|
438
|
+
* THE CHAIN WHOSE PLACE IN THE LIST YOU CAN HEAR, and it is in the vocabulary for
|
|
439
|
+
* that reason as much as for the sound. `lowpass` and `gain` are both LINEAR, so
|
|
440
|
+
* they commute: `["lowpass", "gain"]` and `["gain", "lowpass"]` generate different
|
|
441
|
+
* patchers and produce identical audio. A composition built only from those two
|
|
442
|
+
* cannot be verified by ear - you would reorder them, hear nothing change, and
|
|
443
|
+
* reasonably conclude the build was broken.
|
|
444
|
+
*
|
|
445
|
+
* Distortion does not commute with a level change. `["gain", "drive"]` turns the
|
|
446
|
+
* signal down and THEN distorts it, so a quiet input barely clips; `["drive",
|
|
447
|
+
* "gain"]` distorts at full level and turns the result down, so it stays dirty and
|
|
448
|
+
* gets quieter. Same two chains, same parameters, unmistakably different sound -
|
|
449
|
+
* which is what makes the series real rather than a claim in a test.
|
|
450
|
+
*
|
|
451
|
+
* `overdrive~` limits to +/- 1 and takes its drive factor (1 = clean, 10 = filthy)
|
|
452
|
+
* in the RIGHT inlet, per Max's own reference. Below 1 it distorts violently; the
|
|
453
|
+
* parameter's range starts at 1, which is where a user would expect "off" to be.
|
|
454
|
+
*
|
|
455
|
+
* Requires a parameter named `drive` (or pass `device.driveParam`).
|
|
456
|
+
*/
|
|
457
|
+
function driveChain(ctx) {
|
|
458
|
+
const { boxes, lines, device } = ctx;
|
|
459
|
+
const paramId = requireParam(ctx, "drive", device?.driveParam ?? "drive", "driveParam");
|
|
460
|
+
|
|
461
|
+
for (const [ch, id] of [
|
|
462
|
+
[0, "obj-drive-l"],
|
|
463
|
+
[1, "obj-drive-r"],
|
|
464
|
+
]) {
|
|
465
|
+
const [srcId, srcOut] = ctx.audioIn(ch);
|
|
466
|
+
boxes.push(box(id, "overdrive~ 1.", { numinlets: 2, numoutlets: 1, outlettype: ["signal"] }));
|
|
467
|
+
lines.push(line(srcId, srcOut, id, 0));
|
|
468
|
+
ctx.setAudioOut(ch, id, 0);
|
|
469
|
+
fanParamInto(ctx, paramId, id, 1);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* "download" - `[maxurl]`, so the app can fetch a file to DISK.
|
|
475
|
+
*
|
|
476
|
+
* [js] out 1 -> [route maxurl] -> [maxurl] -> [prepend maxurl_done] -> [js] in 0
|
|
477
|
+
* -> [prepend maxurl_progress] -> [js] in 0
|
|
478
|
+
*
|
|
479
|
+
* The bytes never enter the Max message stream: [js] hands maxurl a request dict
|
|
480
|
+
* carrying `filename_out` and libcurl writes the file itself. What comes back here
|
|
481
|
+
* is only the RESULT - a response dict name, and progress. That is the whole point:
|
|
482
|
+
* [js] is a control plane, not a data plane. See the fetch section of core.ts for
|
|
483
|
+
* the request dict, which is where this feature was actually broken.
|
|
484
|
+
*
|
|
485
|
+
* The request leaves [js] on OUTLET 1 (the aux outlet) rather than outlet 0, which
|
|
486
|
+
* belongs to [jweb]: a `maxurl` message on outlet 0 would be delivered to the web
|
|
487
|
+
* view, which has no idea what to do with it. The [route maxurl] then strips the
|
|
488
|
+
* tag, so what reaches the object is the bare `dictionary <name>` it expects.
|
|
489
|
+
*
|
|
490
|
+
* maxurl has TWO outlets (per its reference page): 0 = the response dictionary,
|
|
491
|
+
* 1 = progress. Not three.
|
|
492
|
+
*/
|
|
493
|
+
function downloadChain(ctx) {
|
|
494
|
+
const { boxes, lines, unmatchedId } = ctx; // unmatchedId is the wrapper's [js]
|
|
495
|
+
|
|
496
|
+
boxes.push(box("obj-maxurl", "maxurl", { numinlets: 2, numoutlets: 2, outlettype: ["", ""] }));
|
|
497
|
+
|
|
498
|
+
boxes.push(box("obj-route-maxurl", "route maxurl", { numoutlets: 2, outlettype: ["", ""] }));
|
|
499
|
+
lines.push(line(unmatchedId, 1, "obj-route-maxurl", 0));
|
|
500
|
+
lines.push(line("obj-route-maxurl", 0, "obj-maxurl", 0));
|
|
501
|
+
|
|
502
|
+
// Tag both replies, so [js] dispatches them to a handler rather than to
|
|
503
|
+
// anything(), which would swallow them - they arrive as a bare `dictionary
|
|
504
|
+
// <name>` and a bare list otherwise.
|
|
505
|
+
boxes.push(box("obj-prepend-maxurl-done", "prepend maxurl_done"));
|
|
506
|
+
boxes.push(box("obj-prepend-maxurl-progress", "prepend maxurl_progress"));
|
|
507
|
+
|
|
508
|
+
lines.push(line("obj-maxurl", 0, "obj-prepend-maxurl-done", 0));
|
|
509
|
+
lines.push(line("obj-maxurl", 1, "obj-prepend-maxurl-progress", 0));
|
|
510
|
+
|
|
511
|
+
lines.push(line("obj-prepend-maxurl-done", 0, unmatchedId, 0));
|
|
512
|
+
lines.push(line("obj-prepend-maxurl-progress", 0, unmatchedId, 0));
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* "samples" - the first chain that ORIGINATES a sound: a named [buffer~] per slot,
|
|
517
|
+
* loaded from a file on disk, played back through [groove~] into the signal path.
|
|
518
|
+
*
|
|
519
|
+
* app -> buffer_load <slot> <path> -> [js] (resolves the path)
|
|
520
|
+
* -> buffer_replace -> [buffer~ <name>]
|
|
521
|
+
* buffer_play <slot> -> [groove~] set + play
|
|
522
|
+
* buffer_stop -> [groove~] stop
|
|
523
|
+
* app <- buffer_ready <slot> <sr> <ms> <chans> when the read actually completed
|
|
524
|
+
* buffer_error <slot> <msg> when there was no file to read
|
|
525
|
+
*
|
|
526
|
+
* THE BYTES NEVER CROSS THE BRIDGE. [buffer~] reads the file itself; what travels in
|
|
527
|
+
* Max messages is a path and, coming back, a description of what landed - the same
|
|
528
|
+
* rule the `download` chain follows, which is how you get the file there in the
|
|
529
|
+
* first place.
|
|
530
|
+
*
|
|
531
|
+
* WAV/AIFF/Next-Sun ONLY. [buffer~]'s `read`/`replace` does not take MP3 - that list
|
|
532
|
+
* (MP3, OGG, FLAC, M4A) is [sfplay~]'s, which streams from disk rather than filling a
|
|
533
|
+
* buffer, and is therefore a different chain. A format it cannot read is an error in
|
|
534
|
+
* the Max console and NO bang, so the app's promise times out rather than lying.
|
|
535
|
+
*
|
|
536
|
+
* WHAT LOADED IS NOT WHAT YOU ASKED FOR, so the chain reports what it GOT. `replace`
|
|
537
|
+
* resizes the buffer and adopts the FILE's channel count and sample rate, so a slot
|
|
538
|
+
* is not mono because you wanted it to be. [info~] is banged from the buffer's own
|
|
539
|
+
* "read completed" outlet (outlet 1 - outlet 0 is a mouse position in the editing
|
|
540
|
+
* window) and reports sample rate, duration and channels; the app derives frames from
|
|
541
|
+
* those. A frame count on its own is not proof of a read: a failed `replace` leaves
|
|
542
|
+
* the PREVIOUS contents in place, so "there are samples in there" says nothing. The
|
|
543
|
+
* bang does - it only fires when a read completed - and until it does the app's
|
|
544
|
+
* promise is still open (see loadSample() in @m4l-jweb/bridge, which is where the
|
|
545
|
+
* timeout lives).
|
|
546
|
+
*
|
|
547
|
+
* info~'s outlets fire right-to-left, so the LAST one to arrive is outlet 0, the
|
|
548
|
+
* sample rate - which is therefore the one wired to [pack]'s hot inlet. Wire it the
|
|
549
|
+
* other way round and the message goes out carrying the PREVIOUS load's numbers.
|
|
550
|
+
*
|
|
551
|
+
* ONE VOICE, DELIBERATELY. `groove~` takes `set <buffer-name>` to switch buffers, so
|
|
552
|
+
* one stereo player covers N slots: this is a PREVIEW - the sample browser's "let me
|
|
553
|
+
* hear it, through the track" - not a sampler. Polyphony is the `instrument` chain,
|
|
554
|
+
* and it is still open (doc/TODO.md item 2).
|
|
555
|
+
*
|
|
556
|
+
* It SUMS into the signal path rather than claiming it ([+~]), because it makes sound
|
|
557
|
+
* of its own: on an audio effect the preview plays over the track's audio, and on an
|
|
558
|
+
* instrument there is nothing at the input to add.
|
|
559
|
+
*
|
|
560
|
+
* The buffer NAMES are global to Max, and they are generated from the device name
|
|
561
|
+
* (`buf-<device>-<slot>`), so two INSTANCES of the same device on two tracks name
|
|
562
|
+
* their buffers alike - and Max hands the name to whichever loaded last. Not
|
|
563
|
+
* verified in Live yet; a preview device you drag onto one track is unaffected.
|
|
564
|
+
*
|
|
565
|
+
* Slots default to one, named "preview". `slots: ["kick", "snare"]` in the manifest
|
|
566
|
+
* gives you more.
|
|
567
|
+
*/
|
|
568
|
+
function samplesChain(ctx) {
|
|
569
|
+
const { boxes, lines, device, jwebId, unmatchedId } = ctx;
|
|
570
|
+
const slots = device?.slots ?? ["preview"];
|
|
571
|
+
const bufName = (slot) => `buf-${device?.name}-${slot}`;
|
|
572
|
+
|
|
573
|
+
// `buffer_load` is NOT claimed from [jweb]. It goes on to the wrapper, which
|
|
574
|
+
// resolves the path and hands it back on its AUX OUTLET as `buffer_replace <slot>
|
|
575
|
+
// <abs path>` - the same shape the `download` chain takes `maxurl` in.
|
|
576
|
+
//
|
|
577
|
+
// The detour is the whole fix. The app writes a path relative to the device's
|
|
578
|
+
// folder (that is where fetchToFile puts the file), and [buffer~] does not resolve
|
|
579
|
+
// it that way: a bare name is looked up in MAX'S SEARCH PATH, which the device's
|
|
580
|
+
// folder is not in, so a file that was downloaded correctly reports "can't open".
|
|
581
|
+
// The resolved path then contains SPACES on a normal Live install ("Ableton
|
|
582
|
+
// Library"), and a path travelling through the patcher as message text would split
|
|
583
|
+
// there into atoms. Out of [js] it stays one symbol.
|
|
584
|
+
boxes.push(box("obj-samples-route", "route buffer_play buffer_stop", { numoutlets: 3, outlettype: ["", "", ""] }));
|
|
585
|
+
claimAppMessages(ctx, "obj-samples-route", 2);
|
|
586
|
+
|
|
587
|
+
boxes.push(box("obj-samples-replaceroute", "route buffer_replace", { numoutlets: 2, outlettype: ["", ""] }));
|
|
588
|
+
lines.push(line(unmatchedId, 1, "obj-samples-replaceroute", 0));
|
|
589
|
+
|
|
590
|
+
// `route` strips the selector, so the slot name is now the first word of both
|
|
591
|
+
// remaining messages: a second route per stream dispatches on it.
|
|
592
|
+
const slotList = slots.join(" ");
|
|
593
|
+
const slotOutlets = { numoutlets: slots.length + 1, outlettype: slots.map(() => "").concat("") };
|
|
594
|
+
boxes.push(box("obj-samples-loadslot", `route ${slotList}`, slotOutlets));
|
|
595
|
+
boxes.push(box("obj-samples-playslot", `route ${slotList}`, slotOutlets));
|
|
596
|
+
lines.push(line("obj-samples-replaceroute", 0, "obj-samples-loadslot", 0));
|
|
597
|
+
lines.push(line("obj-samples-route", 0, "obj-samples-playslot", 0));
|
|
598
|
+
|
|
599
|
+
// The player. `groove~ <buffer> 2` = two signal outlets (plus a loop-sync outlet),
|
|
600
|
+
// and it MIXES a buffer with more channels down rather than dropping them.
|
|
601
|
+
// @loop 0 makes it a one-shot: a preview that loops forever is a preview you have
|
|
602
|
+
// to fight. [sig~ 1.] is the playback rate, in the left inlet, which is a SIGNAL
|
|
603
|
+
// inlet - a float there means something else entirely (a position, in ms).
|
|
604
|
+
boxes.push(box("obj-samples-rate", "sig~ 1.", { numinlets: 1, numoutlets: 1, outlettype: ["signal"] }));
|
|
605
|
+
boxes.push(
|
|
606
|
+
box("obj-samples-groove", `groove~ ${bufName(slots[0])} 2 @loop 0`, {
|
|
607
|
+
numinlets: 3,
|
|
608
|
+
numoutlets: 3,
|
|
609
|
+
outlettype: ["signal", "signal", "signal"],
|
|
610
|
+
}),
|
|
611
|
+
);
|
|
612
|
+
lines.push(line("obj-samples-rate", 0, "obj-samples-groove", 0));
|
|
613
|
+
|
|
614
|
+
// Stop: a bare `buffer_stop` arrives from [route] as a BANG - the word is gone -
|
|
615
|
+
// so re-materialize it in a message box, or groove~ hears nothing it knows.
|
|
616
|
+
boxes.push(box("obj-samples-stopmsg", "stop", { maxclass: "message", numinlets: 2, numoutlets: 1 }));
|
|
617
|
+
lines.push(line("obj-samples-route", 1, "obj-samples-stopmsg", 0));
|
|
618
|
+
lines.push(line("obj-samples-stopmsg", 0, "obj-samples-groove", 0));
|
|
619
|
+
|
|
620
|
+
slots.forEach((slot, i) => {
|
|
621
|
+
const buf = `obj-samples-buf-${slot}`;
|
|
622
|
+
const info = `obj-samples-info-${slot}`;
|
|
623
|
+
|
|
624
|
+
// buffer~: outlet 0 is a mouse position, outlet 1 is the bang on a completed
|
|
625
|
+
// read. Only the second one means anything here.
|
|
626
|
+
boxes.push(box(buf, `buffer~ ${bufName(slot)}`, { numinlets: 1, numoutlets: 2, outlettype: ["float", "bang"] }));
|
|
627
|
+
boxes.push(box(`obj-samples-replace-${slot}`, "prepend replace"));
|
|
628
|
+
lines.push(line("obj-samples-loadslot", i, `obj-samples-replace-${slot}`, 0));
|
|
629
|
+
lines.push(line(`obj-samples-replace-${slot}`, 0, buf, 0));
|
|
630
|
+
|
|
631
|
+
// What actually landed. info~'s outlets: 0 = sample rate, 6 = duration in ms,
|
|
632
|
+
// 8 = number of channels (per its reference page - do not count them from
|
|
633
|
+
// memory). They fire right-to-left, so 0 arrives last and is the hot one.
|
|
634
|
+
boxes.push(
|
|
635
|
+
box(info, `info~ ${bufName(slot)}`, {
|
|
636
|
+
numinlets: 1,
|
|
637
|
+
numoutlets: 10,
|
|
638
|
+
outlettype: ["float", "list", "float", "float", "float", "float", "float", "", "int", ""],
|
|
639
|
+
}),
|
|
640
|
+
);
|
|
641
|
+
boxes.push(box(`obj-samples-pack-${slot}`, "pack 0. 0. 0", { numinlets: 3, numoutlets: 1, outlettype: [""] }));
|
|
642
|
+
boxes.push(box(`obj-samples-ready-${slot}`, `prepend buffer_ready ${slot}`));
|
|
643
|
+
|
|
644
|
+
lines.push(line(buf, 1, info, 0)); // the read completed - ask what it was
|
|
645
|
+
lines.push(line(info, 8, `obj-samples-pack-${slot}`, 2)); // channels (fires first)
|
|
646
|
+
lines.push(line(info, 6, `obj-samples-pack-${slot}`, 1)); // duration, ms
|
|
647
|
+
lines.push(line(info, 0, `obj-samples-pack-${slot}`, 0)); // sample rate - hot, last
|
|
648
|
+
lines.push(line(`obj-samples-pack-${slot}`, 0, `obj-samples-ready-${slot}`, 0));
|
|
649
|
+
lines.push(line(`obj-samples-ready-${slot}`, 0, jwebId, 0));
|
|
650
|
+
|
|
651
|
+
// Play: pick the buffer, THEN start it. Two cords out of one outlet fire in an
|
|
652
|
+
// order Max chooses, so the sequence goes through a [t b b] - right outlet
|
|
653
|
+
// first - and never through a fan-out. Starting the OLD buffer and then
|
|
654
|
+
// switching is exactly the bug that looks like "the wrong sample previewed".
|
|
655
|
+
boxes.push(box(`obj-samples-trig-${slot}`, "t b b", { numinlets: 1, numoutlets: 2, outlettype: ["bang", "bang"] }));
|
|
656
|
+
boxes.push(box(`obj-samples-set-${slot}`, `set ${bufName(slot)}`, { maxclass: "message", numinlets: 2, numoutlets: 1 }));
|
|
657
|
+
// A float in groove~'s left inlet is a playback POSITION in ms, and 0 is "from
|
|
658
|
+
// the beginning" - which is also what starts it after a `stop`.
|
|
659
|
+
boxes.push(box(`obj-samples-start-${slot}`, "0", { maxclass: "message", numinlets: 2, numoutlets: 1 }));
|
|
660
|
+
|
|
661
|
+
lines.push(line("obj-samples-playslot", i, `obj-samples-trig-${slot}`, 0));
|
|
662
|
+
lines.push(line(`obj-samples-trig-${slot}`, 1, `obj-samples-set-${slot}`, 0)); // right first: set the buffer
|
|
663
|
+
lines.push(line(`obj-samples-trig-${slot}`, 0, `obj-samples-start-${slot}`, 0)); // ...then play it
|
|
664
|
+
lines.push(line(`obj-samples-set-${slot}`, 0, "obj-samples-groove", 0));
|
|
665
|
+
lines.push(line(`obj-samples-start-${slot}`, 0, "obj-samples-groove", 0));
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
// Sum into the signal path: this chain MAKES sound, it does not process what came
|
|
669
|
+
// before. Claiming the stage instead would silence the track the preview plays over.
|
|
670
|
+
for (const [ch, id] of [
|
|
671
|
+
[0, "obj-samples-mix-l"],
|
|
672
|
+
[1, "obj-samples-mix-r"],
|
|
673
|
+
]) {
|
|
674
|
+
const [srcId, srcOut] = ctx.audioIn(ch);
|
|
675
|
+
boxes.push(box(id, "+~", { numinlets: 2, numoutlets: 1, outlettype: ["signal"] }));
|
|
676
|
+
lines.push(line(srcId, srcOut, id, 0));
|
|
677
|
+
lines.push(line("obj-samples-groove", ch, id, 1));
|
|
678
|
+
ctx.setAudioOut(ch, id, 0);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
320
682
|
export const CHAINS = {
|
|
321
683
|
midiin: midiInChain,
|
|
684
|
+
samples: samplesChain,
|
|
322
685
|
midiout: midiOutChain,
|
|
323
686
|
passthrough: passthroughChain,
|
|
324
687
|
gain: gainChain,
|
|
325
688
|
lowpass: lowpassChain,
|
|
689
|
+
drive: driveChain,
|
|
690
|
+
download: downloadChain,
|
|
326
691
|
};
|
|
327
692
|
|
|
328
693
|
/** Add a chain to the vocabulary. Called before generatePatchers(). */
|
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 } from "node:fs";
|
|
11
|
+
import { 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";
|
|
@@ -16,8 +16,8 @@ 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, resetLayout } from "./chains.mjs";
|
|
20
|
-
import { applySurface, loadSurface, surfaceContext } from "./surface.mjs";
|
|
19
|
+
import { CHAINS, assertUniqueBoxIds, closeAudio, openAudio, resetLayout } from "./chains.mjs";
|
|
20
|
+
import { applySurface, applyWindows, applyPersistence, loadSurface, surfaceContext } from "./surface.mjs";
|
|
21
21
|
|
|
22
22
|
const require = createRequire(import.meta.url);
|
|
23
23
|
const pkgDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
@@ -80,6 +80,14 @@ export function buildWrapper(root) {
|
|
|
80
80
|
// At [js] global scope `this` IS the jsthis object - that is how
|
|
81
81
|
// `this.patcher.filepath` works.
|
|
82
82
|
noImplicitThis: false,
|
|
83
|
+
// ...and that is ONLY true in sloppy mode. `strict: true` makes tsc emit
|
|
84
|
+
// "use strict" at the top of every file, under which `this` inside a plainly
|
|
85
|
+
// called function is UNDEFINED - so `this.patcher` would throw. Max's [js] is
|
|
86
|
+
// an ES5-era interpreter that does not enforce strict semantics, so the
|
|
87
|
+
// device works anyway; it works by accident, on a technicality of a very old
|
|
88
|
+
// engine, and any [js] that ever grew a real strict mode would take every
|
|
89
|
+
// device down with it. Emit what we actually target.
|
|
90
|
+
alwaysStrict: false,
|
|
83
91
|
noImplicitAny: false,
|
|
84
92
|
skipLibCheck: true,
|
|
85
93
|
types: [],
|
|
@@ -138,6 +146,75 @@ async function loadDeviceChains(root) {
|
|
|
138
146
|
console.log("m4l-jweb: loaded device chains from patcher/chains.mjs");
|
|
139
147
|
}
|
|
140
148
|
|
|
149
|
+
/**
|
|
150
|
+
* One device, one patcher: the template, its chains, its Surface. Pure - it takes
|
|
151
|
+
* the base patcher and the device's declaration and returns the JSON to write.
|
|
152
|
+
*
|
|
153
|
+
* It is exported because the tests generate patchers too, and a test that
|
|
154
|
+
* assembled the pipeline itself could pass while the build wired something else.
|
|
155
|
+
* The ORDER below is the pipeline, and every step of it is load-bearing:
|
|
156
|
+
*
|
|
157
|
+
* openAudio the device's plugin~/plugout~, created ONCE, before any chain -
|
|
158
|
+
* so a chain is a stage in the signal path, not the owner of it.
|
|
159
|
+
* the chains in declaration order, each taking what the last one left.
|
|
160
|
+
* applySurface LAST of the message-stream claimants: it routes every `set_<id>`
|
|
161
|
+
* off the app's stream and passes on what nobody claimed
|
|
162
|
+
* (ui_ready, ...) to the wrapper. Doing it last means no chain has
|
|
163
|
+
* to know the Surface exists.
|
|
164
|
+
* closeAudio the final stage's output into plugout~.
|
|
165
|
+
* assertUnique two boxes with one id is a malformed patcher, and Max resolves
|
|
166
|
+
* it however it likes. Nothing else would report it.
|
|
167
|
+
*/
|
|
168
|
+
export function composePatcher(base, d, surface) {
|
|
169
|
+
const amxdtype = AMXD_TYPES[d.type];
|
|
170
|
+
if (!amxdtype) throw new Error(`unknown type "${d.type}" for device "${d.name}" (midi | audio | instrument)`);
|
|
171
|
+
|
|
172
|
+
const p = structuredClone(base);
|
|
173
|
+
const { boxes, lines } = p.patcher;
|
|
174
|
+
p.patcher.project.amxdtype = amxdtype;
|
|
175
|
+
resetLayout();
|
|
176
|
+
|
|
177
|
+
// The wrapper is mode-switched by its object-box argument. `mode` defaults
|
|
178
|
+
// to the device type, but they are not always the same thing: a sample
|
|
179
|
+
// player can be an audio-effect device ("type") that the wrapper must treat
|
|
180
|
+
// as a sampler ("mode").
|
|
181
|
+
//
|
|
182
|
+
// jsarguments[0] is the SCRIPT NAME, so the mode lands at jsarguments[1].
|
|
183
|
+
const mode = d.mode ?? d.type;
|
|
184
|
+
boxes.find((b) => b.box.id === "obj-js").box.text = `js wrapper.js ${mode}`;
|
|
185
|
+
|
|
186
|
+
const unmatchedId = d.unmatchedTo === "js" ? "obj-js" : (d.unmatchedTo ?? "obj-js");
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The device's parameters, declared once in src/app/<ui>/surface.ts. A chain
|
|
190
|
+
* that drives DSP from a parameter needs two things from it, and needs BOTH:
|
|
191
|
+
*
|
|
192
|
+
* paramObject(id) the live.* object's outlet - a knob turn, an automation
|
|
193
|
+
* lane, a Push encoder.
|
|
194
|
+
* paramValue(id) the route outlet carrying what the APP wrote. Not
|
|
195
|
+
* redundant: the app's write reaches the object as `set`,
|
|
196
|
+
* which updates it WITHOUT output, so the object would
|
|
197
|
+
* never pass that value on. See surface.mjs.
|
|
198
|
+
*/
|
|
199
|
+
const ctx = { boxes, lines, jwebId: "obj-jweb", unmatchedId, device: d, ...surfaceContext(surface) };
|
|
200
|
+
|
|
201
|
+
openAudio(ctx);
|
|
202
|
+
|
|
203
|
+
for (const name of d.chains ?? []) {
|
|
204
|
+
const chain = CHAINS[name];
|
|
205
|
+
if (!chain) throw new Error(`unknown chain "${name}" for device "${d.name}" (known: ${Object.keys(CHAINS).join(", ")})`);
|
|
206
|
+
chain(ctx);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
applySurface(ctx);
|
|
210
|
+
applyWindows(ctx);
|
|
211
|
+
applyPersistence(ctx);
|
|
212
|
+
closeAudio(ctx);
|
|
213
|
+
assertUniqueBoxIds(boxes, d.name);
|
|
214
|
+
|
|
215
|
+
return p;
|
|
216
|
+
}
|
|
217
|
+
|
|
141
218
|
export async function generatePatchers(root) {
|
|
142
219
|
const devices = await readManifest(root);
|
|
143
220
|
const base = readBase(root);
|
|
@@ -146,36 +223,6 @@ export async function generatePatchers(root) {
|
|
|
146
223
|
mkdirSync(outDir, { recursive: true });
|
|
147
224
|
|
|
148
225
|
for (const d of devices) {
|
|
149
|
-
const amxdtype = AMXD_TYPES[d.type];
|
|
150
|
-
if (!amxdtype) throw new Error(`unknown type "${d.type}" for device "${d.name}" (midi | audio | instrument)`);
|
|
151
|
-
|
|
152
|
-
const p = structuredClone(base);
|
|
153
|
-
const { boxes, lines } = p.patcher;
|
|
154
|
-
p.patcher.project.amxdtype = amxdtype;
|
|
155
|
-
resetLayout();
|
|
156
|
-
|
|
157
|
-
// The wrapper is mode-switched by its object-box argument. `mode` defaults
|
|
158
|
-
// to the device type, but they are not always the same thing: a sample
|
|
159
|
-
// player can be an audio-effect device ("type") that the wrapper must treat
|
|
160
|
-
// as a sampler ("mode").
|
|
161
|
-
//
|
|
162
|
-
// jsarguments[0] is the SCRIPT NAME, so the mode lands at jsarguments[1].
|
|
163
|
-
const mode = d.mode ?? d.type;
|
|
164
|
-
boxes.find((b) => b.box.id === "obj-js").box.text = `js wrapper.js ${mode}`;
|
|
165
|
-
|
|
166
|
-
const unmatchedId = d.unmatchedTo === "js" ? "obj-js" : (d.unmatchedTo ?? "obj-js");
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* The device's parameters, declared once in src/app/<ui>/surface.ts. A chain
|
|
170
|
-
* that drives DSP from a parameter needs two things from it, and needs BOTH:
|
|
171
|
-
*
|
|
172
|
-
* paramObject(id) the live.* object's outlet - a knob turn, an automation
|
|
173
|
-
* lane, a Push encoder.
|
|
174
|
-
* paramValue(id) the route outlet carrying what the APP wrote. Not
|
|
175
|
-
* redundant: the app's write reaches the object as `set`,
|
|
176
|
-
* which updates it WITHOUT output, so the object would
|
|
177
|
-
* never pass that value on. See surface.mjs.
|
|
178
|
-
*/
|
|
179
226
|
// The manifest carried `parameters` until 0.4.0. It is now declared in
|
|
180
227
|
// src/app/<ui>/surface.ts and generated from there - so a leftover field is not
|
|
181
228
|
// a harmless extra key, it is a device whose parameters have SILENTLY
|
|
@@ -185,22 +232,12 @@ export async function generatePatchers(root) {
|
|
|
185
232
|
`device "${d.name}" still declares \`parameters\` in patcher/devices.mjs. ` +
|
|
186
233
|
`That field is gone: declare them in src/app/${d.ui ?? d.name}/surface.ts with defineSurface(), ` +
|
|
187
234
|
`which generates the live.* objects, both wiring directions and the protocol selectors. ` +
|
|
188
|
-
`See doc/
|
|
235
|
+
`See doc/ARCHITECTURE.md - "Parameters: the Surface Push reads".`,
|
|
189
236
|
);
|
|
190
237
|
}
|
|
191
238
|
|
|
192
239
|
const surface = await loadSurface(root, d.ui ?? d.name);
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
for (const name of d.chains ?? []) {
|
|
196
|
-
const chain = CHAINS[name];
|
|
197
|
-
if (!chain) throw new Error(`unknown chain "${name}" for device "${d.name}" (known: ${Object.keys(CHAINS).join(", ")})`);
|
|
198
|
-
chain(ctx);
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// AFTER the chains: the Surface routes every `set_<id>` off the app's message
|
|
202
|
-
// stream, and passes on what nobody claimed (ui_ready, ...) to the wrapper.
|
|
203
|
-
applySurface(ctx);
|
|
240
|
+
const p = composePatcher(base, d, surface);
|
|
204
241
|
|
|
205
242
|
writeFileSync(path.join(outDir, `${d.name}.json`), JSON.stringify(p, null, "\t"));
|
|
206
243
|
const params = surface ? surface.ids.join(", ") : "none";
|
|
@@ -265,11 +302,23 @@ export async function packageDevices(root) {
|
|
|
265
302
|
/**
|
|
266
303
|
* Payloads ride inside wrapper.js as base64 and are written to real files
|
|
267
304
|
* next to the .amxd on first load, because Chromium and any external
|
|
268
|
-
* process are blind to Max's frozen virtual filesystem.
|
|
269
|
-
*
|
|
305
|
+
* process are blind to Max's frozen virtual filesystem.
|
|
306
|
+
* We embed ALL .html files found in the UI directory (including the main UI and any windows).
|
|
270
307
|
*/
|
|
271
|
-
let wrapperData = banner + wrapperJs
|
|
308
|
+
let wrapperData = banner + wrapperJs;
|
|
309
|
+
const uiDirContent = readdirSync(path.join(dist, "ui", d.ui ?? d.name)).filter((f) => f.endsWith(".html"));
|
|
310
|
+
|
|
311
|
+
// Main UI payload
|
|
312
|
+
wrapperData += payloadJs("UI_PAYLOAD", uiName, readFileSync(path.join(dist, "ui", d.ui ?? d.name, "index.html")));
|
|
313
|
+
|
|
314
|
+
// Additional window payloads
|
|
315
|
+
const extraWindows = uiDirContent.filter((f) => f !== "index.html");
|
|
272
316
|
const payloads = (d.payloads ?? []).map((f) => ({ name: path.basename(f), data: readFileSync(path.join(root, f)) }));
|
|
317
|
+
|
|
318
|
+
for (const winHtml of extraWindows) {
|
|
319
|
+
payloads.push({ name: `${d.name}_${winHtml}`, data: readFileSync(path.join(dist, "ui", d.ui ?? d.name, winHtml)) });
|
|
320
|
+
}
|
|
321
|
+
|
|
273
322
|
if (payloads.length) wrapperData += extraPayloadsJs(payloads);
|
|
274
323
|
|
|
275
324
|
const amxd = buildAmxd({
|
package/src/surface.mjs
CHANGED
|
@@ -294,3 +294,208 @@ export function applySurface(ctx) {
|
|
|
294
294
|
lines.push(line(`obj-set-${id}`, 0, paramObject(id), 0));
|
|
295
295
|
});
|
|
296
296
|
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Compile a declared `window` into the patcher: a subpatcher holding its own
|
|
300
|
+
* [jweb], and a [pcontrol] that opens it when the app asks.
|
|
301
|
+
*
|
|
302
|
+
* [jweb] -> [route window_x_open window_x_close] -> [t b] -> [open( -> [pcontrol] -> [p Title]
|
|
303
|
+
*
|
|
304
|
+
* ------------------------------------------------------------------------------
|
|
305
|
+
* A MAXCLASS IS NOT A NAME YOU INVENT, and that is what was broken here.
|
|
306
|
+
*
|
|
307
|
+
* This generated the open/close message boxes as `newobj` with the TEXT "open"
|
|
308
|
+
* and "wclose", and the [pcontrol] as a box with `maxclass: "pcontrol"`. Neither
|
|
309
|
+
* is a thing:
|
|
310
|
+
*
|
|
311
|
+
* - `newobj` means "an object box", and its text names the object. There is no
|
|
312
|
+
* Max object called `open`, so what got built was a BROKEN box - the dashed
|
|
313
|
+
* outline you would see instantly in the Max editor, and see nothing of here.
|
|
314
|
+
* A message box is `maxclass: "message"` with the message in `text`
|
|
315
|
+
* (chains.mjs's [flush( does this - copy that, do not reinvent it).
|
|
316
|
+
* - `pcontrol` is an object, not a box class: `maxclass: "newobj"`,
|
|
317
|
+
* `text: "pcontrol"`. A box with an unknown maxclass does not instantiate.
|
|
318
|
+
*
|
|
319
|
+
* A patcher full of broken boxes still LOADS, keeps its cords, and does nothing.
|
|
320
|
+
* That is why the message reached [jweb]'s outlet, the route matched, and the
|
|
321
|
+
* window never opened - and why the failure was misread as `[route]` refusing to
|
|
322
|
+
* match jweb's output. The route was fine. It was firing into a box that had
|
|
323
|
+
* failed to exist.
|
|
324
|
+
* ------------------------------------------------------------------------------
|
|
325
|
+
*/
|
|
326
|
+
export function applyWindows(ctx) {
|
|
327
|
+
const { boxes, lines, surface } = ctx;
|
|
328
|
+
const windowIds = surface?.windows ? Object.keys(surface.windows) : [];
|
|
329
|
+
if (windowIds.length === 0) return;
|
|
330
|
+
|
|
331
|
+
const selectors = windowIds.flatMap((id) => [`window_${id}_open`, `window_${id}_close`]);
|
|
332
|
+
|
|
333
|
+
const routeId = "obj-windows-route";
|
|
334
|
+
boxes.push(
|
|
335
|
+
box(routeId, `route ${selectors.join(" ")}`, {
|
|
336
|
+
numoutlets: selectors.length + 1,
|
|
337
|
+
outlettype: selectors.map(() => "").concat(""),
|
|
338
|
+
}),
|
|
339
|
+
);
|
|
340
|
+
// IN SERIES, like every other claimant of the app's message stream. Hanging this
|
|
341
|
+
// route off [jweb] in parallel (which it did) leaves two paths to [js], so the
|
|
342
|
+
// wrapper sees every unrouted message - `ui_ready` included - twice.
|
|
343
|
+
claimAppMessages(ctx, routeId, selectors.length);
|
|
344
|
+
|
|
345
|
+
windowIds.forEach((id, index) => {
|
|
346
|
+
const spec = surface.windows[id];
|
|
347
|
+
|
|
348
|
+
// `route` STRIPS the selector, so `window_x_open 1` emerges as a bare `1`. A
|
|
349
|
+
// message box would try to interpret that as its own argument, so bang it
|
|
350
|
+
// instead: [t b] makes the trigger unambiguous whatever the app sent.
|
|
351
|
+
//
|
|
352
|
+
// The words are [pcontrol]'s: `open` and `close`. NOT `wclose` - that is
|
|
353
|
+
// [thispatcher]'s vocabulary, and pcontrol says so out loud
|
|
354
|
+
// ("pcontrol: doesn't understand \"wclose\""), which is the one thing an
|
|
355
|
+
// unrecognised NAME usually does not do. See pcontrol.maxref.xml.
|
|
356
|
+
for (const [outlet, verb, tag] of [
|
|
357
|
+
[index * 2, "open", "open"],
|
|
358
|
+
[index * 2 + 1, "close", "close"],
|
|
359
|
+
]) {
|
|
360
|
+
const triggerId = `obj-window-${id}-t-${tag}`;
|
|
361
|
+
const msgId = `obj-window-${id}-${tag}msg`;
|
|
362
|
+
boxes.push(box(triggerId, "t b"));
|
|
363
|
+
// A MESSAGE box: maxclass "message", the message in `text`. Not a newobj.
|
|
364
|
+
boxes.push(box(msgId, verb, { maxclass: "message", numinlets: 2, numoutlets: 1 }));
|
|
365
|
+
lines.push(line(routeId, outlet, triggerId, 0));
|
|
366
|
+
lines.push(line(triggerId, 0, msgId, 0));
|
|
367
|
+
lines.push(line(msgId, 0, `obj-window-${id}-pcontrol`, 0));
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// [pcontrol] is the supported way to open a subpatcher's window from outside
|
|
371
|
+
// it. `open` shows it, `wclose` hides it.
|
|
372
|
+
const pcontrolId = `obj-window-${id}-pcontrol`;
|
|
373
|
+
boxes.push(box(pcontrolId, "pcontrol"));
|
|
374
|
+
|
|
375
|
+
const subpatcherId = `obj-window-${id}-sub`;
|
|
376
|
+
lines.push(line(pcontrolId, 0, subpatcherId, 0));
|
|
377
|
+
boxes.push({
|
|
378
|
+
box: {
|
|
379
|
+
id: subpatcherId,
|
|
380
|
+
maxclass: "newobj",
|
|
381
|
+
text: `p ${spec.title}`,
|
|
382
|
+
// The inlet is not decoration: Max will not connect a patch cord to a
|
|
383
|
+
// subpatcher that has no inlets, so [pcontrol] would end up wired to
|
|
384
|
+
// NOTHING - silently, in the saved file. That was attempt 1.
|
|
385
|
+
numinlets: 1,
|
|
386
|
+
numoutlets: 0,
|
|
387
|
+
patching_rect: [16, 620, 120, 22],
|
|
388
|
+
patcher: {
|
|
389
|
+
fileversion: 1,
|
|
390
|
+
appversion: { major: 8, minor: 0, revision: 0, architecture: "x64", modernui: 1 },
|
|
391
|
+
rect: [100, 100, spec.width, spec.height],
|
|
392
|
+
openinpresentation: 1,
|
|
393
|
+
boxes: [
|
|
394
|
+
{ box: { id: "obj-in", maxclass: "inlet", patching_rect: [16, 16, 30, 30], numinlets: 0, numoutlets: 1, outlettype: [""] } },
|
|
395
|
+
// The page's URL cannot be WIRED here - this [jweb] is inside a
|
|
396
|
+
// subpatcher and the wrapper's [js] is outside it. The wrapper reaches
|
|
397
|
+
// it by NAME instead (messnamed), once the payload is extracted.
|
|
398
|
+
{
|
|
399
|
+
box: {
|
|
400
|
+
id: "obj-recv",
|
|
401
|
+
maxclass: "newobj",
|
|
402
|
+
text: `r window-read-${id}`,
|
|
403
|
+
numinlets: 0,
|
|
404
|
+
numoutlets: 1,
|
|
405
|
+
outlettype: [""],
|
|
406
|
+
patching_rect: [16, 56, 160, 22],
|
|
407
|
+
},
|
|
408
|
+
},
|
|
409
|
+
{
|
|
410
|
+
box: {
|
|
411
|
+
id: "obj-jweb",
|
|
412
|
+
maxclass: "jweb",
|
|
413
|
+
numinlets: 1,
|
|
414
|
+
numoutlets: 2,
|
|
415
|
+
outlettype: ["", ""],
|
|
416
|
+
patching_rect: [16, 96, spec.width, spec.height],
|
|
417
|
+
presentation: 1,
|
|
418
|
+
presentation_rect: [0, 0, spec.width, spec.height],
|
|
419
|
+
},
|
|
420
|
+
},
|
|
421
|
+
],
|
|
422
|
+
lines: [{ patchline: { source: ["obj-recv", 0], destination: ["obj-jweb", 0] } }],
|
|
423
|
+
},
|
|
424
|
+
},
|
|
425
|
+
});
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Compile a declared `state` slot: a named [dict] the app reads and writes, and a
|
|
431
|
+
* [pattr] bound to it, which is what actually SAVES.
|
|
432
|
+
*
|
|
433
|
+
* ------------------------------------------------------------------------------
|
|
434
|
+
* WHAT MAKES LIVE SAVE IT IS `parameter_enable`, and nothing else.
|
|
435
|
+
*
|
|
436
|
+
* A pattr on its own persists in a PATCHER. A Max for Live device is not saved as a
|
|
437
|
+
* patcher - Live saves the SET - so the pattr's value goes with it only when the
|
|
438
|
+
* pattr is a Live parameter. Max's own pattr help says so in one line:
|
|
439
|
+
*
|
|
440
|
+
* "In Max for Live, if you activate the parameter_enable attribute, the pattr
|
|
441
|
+
* value will be saved with the Live set."
|
|
442
|
+
*
|
|
443
|
+
* The first version of this emitted `@save 1`, which is not a pattr attribute at
|
|
444
|
+
* all ("pattr: 'save' is not a valid attribute argument"), and `@autorestore 1`,
|
|
445
|
+
* which restores from the PATCHER and so does nothing here. The state survived a
|
|
446
|
+
* reload of the page and would not have survived a reload of the set.
|
|
447
|
+
*
|
|
448
|
+
* The recipe below is copied from a device Ableton ships - `pattr Delays` in
|
|
449
|
+
* "Max DelayTaps.amxd", which persists an array of tap times exactly this way:
|
|
450
|
+
*
|
|
451
|
+
* parameter_type 3 BLOB. Not a float, not an enum - an opaque value Live
|
|
452
|
+
* stores and hands back, which is the whole point: JSON is
|
|
453
|
+
* not a number and must never pretend to be one.
|
|
454
|
+
* parameter_invisible 1 ...so it stays out of the automation lane and off Push.
|
|
455
|
+
* A blob cannot be automated, and a slot that offered to be
|
|
456
|
+
* would be a lie in every Live UI that listed it.
|
|
457
|
+
* parameter_enable 1 in saved_object_attributes - the switch itself.
|
|
458
|
+
*
|
|
459
|
+
* `dict` is an OBJECT (`maxclass: "newobj"`, text `dict <name>`), not a box class.
|
|
460
|
+
* It was emitted as `maxclass: "dict"` - a box class Max does not have, so the box
|
|
461
|
+
* never instantiated and the pattr bound to nothing. Same mistake as the windows,
|
|
462
|
+
* same silence.
|
|
463
|
+
* ------------------------------------------------------------------------------
|
|
464
|
+
*/
|
|
465
|
+
export function applyPersistence(ctx) {
|
|
466
|
+
const { boxes, surface } = ctx;
|
|
467
|
+
const stateIds = surface?.state ? Object.keys(surface.state) : [];
|
|
468
|
+
|
|
469
|
+
for (const id of stateIds) {
|
|
470
|
+
const dictId = `obj-state-${id}`;
|
|
471
|
+
// `varname` is the SCRIPTING name, which is what [pattr]'s @bindto resolves -
|
|
472
|
+
// it binds to a NAMED object in the patcher, not to a box id (which is ours, and
|
|
473
|
+
// which Max is free to renumber). The dict's own name argument is what [js]
|
|
474
|
+
// addresses with `new Dict("obj-state-<id>")`, so all three agree.
|
|
475
|
+
boxes.push(box(dictId, `dict ${dictId}`, { varname: dictId, numinlets: 2, numoutlets: 4, outlettype: ["dictionary", "", "", ""] }));
|
|
476
|
+
|
|
477
|
+
const pattrId = `obj-pattr-${id}`;
|
|
478
|
+
boxes.push(
|
|
479
|
+
box(pattrId, `pattr ${pattrId} @bindto ${dictId}`, {
|
|
480
|
+
numinlets: 2,
|
|
481
|
+
numoutlets: 3,
|
|
482
|
+
outlettype: ["", "", ""],
|
|
483
|
+
varname: pattrId,
|
|
484
|
+
saved_attribute_attributes: {
|
|
485
|
+
valueof: {
|
|
486
|
+
parameter_longname: pattrId,
|
|
487
|
+
// Live truncates a short name at 8 characters (see the parameter
|
|
488
|
+
// compiler above), and it is never displayed for an invisible blob -
|
|
489
|
+
// but it must still be there and still be unique.
|
|
490
|
+
parameter_shortname: `st_${id}`.slice(0, 8),
|
|
491
|
+
parameter_type: 3, // blob
|
|
492
|
+
parameter_invisible: 1,
|
|
493
|
+
},
|
|
494
|
+
},
|
|
495
|
+
saved_object_attributes: {
|
|
496
|
+
parameter_enable: 1, // THE switch: no parameter, no save.
|
|
497
|
+
},
|
|
498
|
+
}),
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
@@ -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": "^0.
|
|
21
|
-
"@m4l-jweb/surface": "^0.
|
|
20
|
+
"@m4l-jweb/bridge": "^0.6.0",
|
|
21
|
+
"@m4l-jweb/surface": "^0.6.0",
|
|
22
22
|
"react": "^19.0.0",
|
|
23
23
|
"react-dom": "^19.0.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
|
-
"@m4l-jweb/build": "^0.
|
|
26
|
+
"@m4l-jweb/build": "^0.6.0",
|
|
27
27
|
"@types/node": "^22.0.0",
|
|
28
28
|
"@types/react": "^19.0.0",
|
|
29
29
|
"@types/react-dom": "^19.0.0",
|
|
@@ -15,10 +15,20 @@
|
|
|
15
15
|
* <chan> <delayMs>` (see sendNote()); pipe +
|
|
16
16
|
* makenote + midiformat place it on Max's
|
|
17
17
|
* scheduler. The app computes WHEN.
|
|
18
|
-
* "lowpass"
|
|
18
|
+
* "lowpass" onepole~ in the signal path, with a `cutoff`
|
|
19
19
|
* parameter. An audio effect you can hear.
|
|
20
|
-
* "
|
|
21
|
-
*
|
|
20
|
+
* "drive" overdrive~ in the signal path, with a `drive`
|
|
21
|
+
* parameter. Soft-clipping distortion.
|
|
22
|
+
* "gain" *~ in the signal path, with a `gain` parameter.
|
|
23
|
+
* "passthrough" nothing at all. Does not touch the audio.
|
|
24
|
+
*
|
|
25
|
+
* THE ORDER IS THE SIGNAL PATH. An audio device's plugin~/plugout~
|
|
26
|
+
* are created by the build; each audio chain claims one STAGE
|
|
27
|
+
* between them, so `["lowpass", "drive", "gain"]` is
|
|
28
|
+
* plugin~ -> onepole~ -> overdrive~ -> *~ -> plugout~. Reorder the
|
|
29
|
+
* list and the device is rewired - and with a nonlinear stage like
|
|
30
|
+
* `drive` in it, you can hear the difference. (Two LINEAR stages
|
|
31
|
+
* commute: swapping "lowpass" and "gain" sounds identical.)
|
|
22
32
|
* unmatchedTo where messages the chains did not consume go. "js" sends them to
|
|
23
33
|
* the wrapper (ui_ready, ...).
|
|
24
34
|
*
|
|
@@ -1,19 +1,50 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* build-ui.mjs - bundle one self-contained UI per device.
|
|
2
|
+
* build-ui.mjs - bundle one self-contained UI per device, plus one per WINDOW.
|
|
3
3
|
*
|
|
4
|
-
* Emits dist/ui/<device>/index.html
|
|
5
|
-
*
|
|
4
|
+
* Emits dist/ui/<device>/index.html (the device view) and, for every window the
|
|
5
|
+
* device declares in its surface.ts, dist/ui/<device>/<winId>.html. `m4l-jweb
|
|
6
|
+
* build` embeds all of them into the matching .amxd as base64 payloads.
|
|
6
7
|
*
|
|
7
8
|
* Sequential, not parallel: vite reads DEVICE from the environment, and two
|
|
8
9
|
* concurrent builds would race on it. The builds are ~500 ms each.
|
|
10
|
+
*
|
|
11
|
+
* THE RENAME DANCE around the window builds is load-bearing. Vite always writes
|
|
12
|
+
* its entry as `index.html`, so a window build lands on top of the device view we
|
|
13
|
+
* just produced - and `emptyOutDir` cannot be left on for it either, or the window
|
|
14
|
+
* build wipes the folder instead. So the device view is moved aside, the windows
|
|
15
|
+
* are built and renamed one by one, and it is moved back.
|
|
9
16
|
*/
|
|
10
17
|
import { build } from "vite";
|
|
11
18
|
import { uiDirs } from "./devices.mjs";
|
|
19
|
+
// The PACKAGE export, not a path into packages/ - this file is copied verbatim
|
|
20
|
+
// into the `m4l-jweb init` scaffold (tests/starter.test.mjs pins that), and a
|
|
21
|
+
// scaffolded repo has no packages/ directory to reach into.
|
|
22
|
+
import { loadSurface } from "@m4l-jweb/build/surface";
|
|
23
|
+
import { renameSync } from "node:fs";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
|
|
26
|
+
const root = process.cwd();
|
|
12
27
|
|
|
13
28
|
for (const dir of uiDirs) {
|
|
14
29
|
process.env.DEVICE = dir;
|
|
30
|
+
delete process.env.WINDOW; // ...so this build empties the out dir and is the device view
|
|
15
31
|
console.log(`\nm4l-jweb: bundling UI for ${dir}`);
|
|
16
32
|
await build();
|
|
33
|
+
|
|
34
|
+
const outDir = path.join(root, "dist", "ui", dir);
|
|
35
|
+
const surface = await loadSurface(root, dir);
|
|
36
|
+
const windows = surface?.windows ? Object.keys(surface.windows) : [];
|
|
37
|
+
if (!windows.length) continue;
|
|
38
|
+
|
|
39
|
+
renameSync(path.join(outDir, "index.html"), path.join(outDir, "_device.html"));
|
|
40
|
+
for (const winId of windows) {
|
|
41
|
+
process.env.WINDOW = winId;
|
|
42
|
+
process.env.WINDOW_ENTRY = surface.windows[winId].entry;
|
|
43
|
+
console.log(`\nm4l-jweb: bundling window ${winId} for ${dir}`);
|
|
44
|
+
await build();
|
|
45
|
+
renameSync(path.join(outDir, "index.html"), path.join(outDir, `${winId}.html`));
|
|
46
|
+
}
|
|
47
|
+
renameSync(path.join(outDir, "_device.html"), path.join(outDir, "index.html"));
|
|
17
48
|
}
|
|
18
49
|
|
|
19
50
|
console.log(`\nm4l-jweb: ${uiDirs.length} UI bundle(s) -> dist/ui/`);
|
|
@@ -33,11 +33,14 @@ export default defineConfig(() => {
|
|
|
33
33
|
// The device UI is bundled into ONE self-contained index.html (every script,
|
|
34
34
|
// style and asset inlined) so it can be embedded in the .amxd as a base64
|
|
35
35
|
// payload and extracted to a real file:// path that jweb (Chromium) reads.
|
|
36
|
+
const WINDOW_ENTRY = process.env.WINDOW_ENTRY;
|
|
37
|
+
|
|
36
38
|
const config: UserConfig = {
|
|
37
39
|
base: "./",
|
|
38
40
|
plugins: [react(), viteSingleFile()],
|
|
39
41
|
resolve: {
|
|
40
42
|
alias: [
|
|
43
|
+
{ find: "@device/App", replacement: fileURLToPath(new URL(`./src/app/${DEVICE}/${WINDOW_ENTRY ? WINDOW_ENTRY : "App"}`, import.meta.url)) },
|
|
41
44
|
{ find: "@device", replacement: fileURLToPath(new URL(`./src/app/${DEVICE}`, import.meta.url)) },
|
|
42
45
|
{ find: "@", replacement: fileURLToPath(new URL("./src", import.meta.url)) },
|
|
43
46
|
],
|
|
@@ -60,7 +63,7 @@ export default defineConfig(() => {
|
|
|
60
63
|
build: {
|
|
61
64
|
// dist/ui/<device>/index.html - one per device, picked up by `m4l-jweb build`.
|
|
62
65
|
outDir: `dist/ui/${DEVICE}`,
|
|
63
|
-
emptyOutDir:
|
|
66
|
+
emptyOutDir: !process.env.WINDOW,
|
|
64
67
|
},
|
|
65
68
|
};
|
|
66
69
|
return config;
|