@m4l-jweb/build 0.4.0 → 0.5.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 +180 -34
- package/src/index.mjs +70 -43
- package/templates/starter/package.json +3 -3
- package/templates/starter/patcher/devices.mjs +13 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m4l-jweb/build",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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.5.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,108 @@ 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) {
|
|
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: ${[...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
|
+
}
|
|
208
|
+
|
|
99
209
|
/**
|
|
100
210
|
* "midiin" - feed incoming MIDI notes to the app as `notein <pitch> <velocity>`.
|
|
101
211
|
*
|
|
@@ -172,25 +282,23 @@ function midiOutChain(ctx) {
|
|
|
172
282
|
/**
|
|
173
283
|
* "passthrough" - an audio effect that passes its input through UNTOUCHED.
|
|
174
284
|
*
|
|
175
|
-
* It
|
|
285
|
+
* It claims no stage, so the signal path stays what the build left it:
|
|
286
|
+
* `plugin~ -> plugout~`, a straight wire. Removing the device from a track sounds
|
|
176
287
|
* identical, because it does nothing to the audio - it exists to prove that an
|
|
177
288
|
* audio-effect container builds and that the UI runs inside one.
|
|
178
289
|
*
|
|
179
|
-
* It is a scaffold, not a feature
|
|
180
|
-
*
|
|
290
|
+
* It is a scaffold, not a feature, and since the build now creates the endpoints it
|
|
291
|
+
* is also a NO-OP: `type: "audio"` with no chains at all does exactly this. It
|
|
292
|
+
* survives because `chains: ["passthrough"]` says out loud what an empty list only
|
|
293
|
+
* implies. If you want an audio effect that is audible, you want `gain` below, or
|
|
294
|
+
* your own chain shaped like it.
|
|
181
295
|
*/
|
|
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));
|
|
296
|
+
function passthroughChain(ctx) {
|
|
297
|
+
ctx.audioIn(0); // ...and hand it straight on. Also asserts the device HAS audio.
|
|
190
298
|
}
|
|
191
299
|
|
|
192
300
|
/**
|
|
193
|
-
* "gain" - an audio effect that actually DOES something:
|
|
301
|
+
* "gain" - an audio effect that actually DOES something: `*~` in the signal path,
|
|
194
302
|
* with a Live parameter riding the multiplier. Turn the dial, hear the level move.
|
|
195
303
|
*
|
|
196
304
|
* The smallest honest example of the shape every audio effect has - your DSP goes
|
|
@@ -208,23 +316,18 @@ function passthroughChain({ boxes, lines }) {
|
|
|
208
316
|
*/
|
|
209
317
|
function gainChain(ctx) {
|
|
210
318
|
const { boxes, lines, device } = ctx;
|
|
211
|
-
removeBox(boxes, lines, "obj-midiin");
|
|
212
|
-
removeBox(boxes, lines, "obj-midiout");
|
|
213
|
-
|
|
214
319
|
const paramId = requireParam(ctx, "gain", device?.gainParam ?? "gain", "gainParam");
|
|
215
320
|
|
|
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
321
|
// One *~ per channel: a signal object handles ONE signal, and plugin~ hands us
|
|
220
322
|
// a stereo pair. `1.` (a float, not an int) keeps the right inlet in float mode.
|
|
221
|
-
for (const [
|
|
323
|
+
for (const [ch, id] of [
|
|
222
324
|
[0, "obj-gain-l"],
|
|
223
325
|
[1, "obj-gain-r"],
|
|
224
326
|
]) {
|
|
327
|
+
const [srcId, srcOut] = ctx.audioIn(ch); // whatever the last stage left
|
|
225
328
|
boxes.push(box(id, "*~ 1.", { numinlets: 2, numoutlets: 1, outlettype: ["signal"] }));
|
|
226
|
-
lines.push(line(
|
|
227
|
-
|
|
329
|
+
lines.push(line(srcId, srcOut, id, 0));
|
|
330
|
+
ctx.setAudioOut(ch, id, 0); // we are the tail now
|
|
228
331
|
fanParamInto(ctx, paramId, id, 1);
|
|
229
332
|
}
|
|
230
333
|
}
|
|
@@ -243,6 +346,14 @@ function gainChain(ctx) {
|
|
|
243
346
|
*
|
|
244
347
|
* The boxes named here are created LATER, by applySurface(). A patchline may name a
|
|
245
348
|
* box further down the array: a patcher is a graph, not a script.
|
|
349
|
+
*
|
|
350
|
+
* THE VALUE ARRIVES IN REAL UNITS, AND A CHAIN DOES NO ARITHMETIC ON IT. The range,
|
|
351
|
+
* the unit and the curve live on the PARAMETER (`range: [40, 18000]`, `unit: "Hz"`,
|
|
352
|
+
* `exponent`) - which is where Live wants them: the automation lane reads Hz, Push
|
|
353
|
+
* reads "7.3 kHz", and the number drops straight into the DSP object. `lowpass`
|
|
354
|
+
* used to take a 0-1 parameter and map it with `[expr 40. * pow(450., $f1)]`; a
|
|
355
|
+
* chain that reintroduces a mapping like that DOUBLE-MAPS a parameter that already
|
|
356
|
+
* carries its own curve, and lies to every readout while it does it.
|
|
246
357
|
*/
|
|
247
358
|
function fanParamInto(ctx, paramId, dstId, dstInlet) {
|
|
248
359
|
const [objId, objOut] = ctx.paramObject(paramId);
|
|
@@ -273,7 +384,7 @@ function requireParam(ctx, chainName, paramId, overrideField) {
|
|
|
273
384
|
* parameter via `set_cutoff` - so moving it moves the dial, the automation lane
|
|
274
385
|
* and the filter together. It is one control, with two faces.
|
|
275
386
|
*
|
|
276
|
-
* `
|
|
387
|
+
* `onepole~` in the signal path, one filter per channel.
|
|
277
388
|
*
|
|
278
389
|
* WHY onepole~ and not lores~/svf~/biquad~: a one-pole is a 6 dB/octave slope -
|
|
279
390
|
* the gentlest filter there is. It cannot self-oscillate, cannot blow up, and
|
|
@@ -293,23 +404,18 @@ function requireParam(ctx, chainName, paramId, overrideField) {
|
|
|
293
404
|
*/
|
|
294
405
|
function lowpassChain(ctx) {
|
|
295
406
|
const { boxes, lines, device } = ctx;
|
|
296
|
-
removeBox(boxes, lines, "obj-midiin");
|
|
297
|
-
removeBox(boxes, lines, "obj-midiout");
|
|
298
|
-
|
|
299
407
|
const paramId = requireParam(ctx, "lowpass", device?.cutoffParam ?? "cutoff", "cutoffParam");
|
|
300
408
|
|
|
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
409
|
// One filter per channel: a signal object handles ONE signal, and plugin~ hands
|
|
305
410
|
// us a stereo pair. Both take the same cutoff, so the image does not shift.
|
|
306
|
-
for (const [
|
|
411
|
+
for (const [ch, id] of [
|
|
307
412
|
[0, "obj-lpf-l"],
|
|
308
413
|
[1, "obj-lpf-r"],
|
|
309
414
|
]) {
|
|
415
|
+
const [srcId, srcOut] = ctx.audioIn(ch);
|
|
310
416
|
boxes.push(box(id, "onepole~ 18000.", { numinlets: 2, numoutlets: 1, outlettype: ["signal"] }));
|
|
311
|
-
lines.push(line(
|
|
312
|
-
|
|
417
|
+
lines.push(line(srcId, srcOut, id, 0));
|
|
418
|
+
ctx.setAudioOut(ch, id, 0);
|
|
313
419
|
// The cutoff, in Hz, into the RIGHT inlet - from both of its sources: the dial
|
|
314
420
|
// (a knob turn, an automation lane, a Push encoder) and the route (the app's
|
|
315
421
|
// write, which the dial will not re-emit because it arrives as `set`).
|
|
@@ -317,12 +423,52 @@ function lowpassChain(ctx) {
|
|
|
317
423
|
}
|
|
318
424
|
}
|
|
319
425
|
|
|
426
|
+
/**
|
|
427
|
+
* "drive" - soft-clipping distortion: an `overdrive~` in the signal path, with a
|
|
428
|
+
* Live parameter on the drive factor.
|
|
429
|
+
*
|
|
430
|
+
* THE CHAIN WHOSE PLACE IN THE LIST YOU CAN HEAR, and it is in the vocabulary for
|
|
431
|
+
* that reason as much as for the sound. `lowpass` and `gain` are both LINEAR, so
|
|
432
|
+
* they commute: `["lowpass", "gain"]` and `["gain", "lowpass"]` generate different
|
|
433
|
+
* patchers and produce identical audio. A composition built only from those two
|
|
434
|
+
* cannot be verified by ear - you would reorder them, hear nothing change, and
|
|
435
|
+
* reasonably conclude the build was broken.
|
|
436
|
+
*
|
|
437
|
+
* Distortion does not commute with a level change. `["gain", "drive"]` turns the
|
|
438
|
+
* signal down and THEN distorts it, so a quiet input barely clips; `["drive",
|
|
439
|
+
* "gain"]` distorts at full level and turns the result down, so it stays dirty and
|
|
440
|
+
* gets quieter. Same two chains, same parameters, unmistakably different sound -
|
|
441
|
+
* which is what makes the series real rather than a claim in a test.
|
|
442
|
+
*
|
|
443
|
+
* `overdrive~` limits to +/- 1 and takes its drive factor (1 = clean, 10 = filthy)
|
|
444
|
+
* in the RIGHT inlet, per Max's own reference. Below 1 it distorts violently; the
|
|
445
|
+
* parameter's range starts at 1, which is where a user would expect "off" to be.
|
|
446
|
+
*
|
|
447
|
+
* Requires a parameter named `drive` (or pass `device.driveParam`).
|
|
448
|
+
*/
|
|
449
|
+
function driveChain(ctx) {
|
|
450
|
+
const { boxes, lines, device } = ctx;
|
|
451
|
+
const paramId = requireParam(ctx, "drive", device?.driveParam ?? "drive", "driveParam");
|
|
452
|
+
|
|
453
|
+
for (const [ch, id] of [
|
|
454
|
+
[0, "obj-drive-l"],
|
|
455
|
+
[1, "obj-drive-r"],
|
|
456
|
+
]) {
|
|
457
|
+
const [srcId, srcOut] = ctx.audioIn(ch);
|
|
458
|
+
boxes.push(box(id, "overdrive~ 1.", { numinlets: 2, numoutlets: 1, outlettype: ["signal"] }));
|
|
459
|
+
lines.push(line(srcId, srcOut, id, 0));
|
|
460
|
+
ctx.setAudioOut(ch, id, 0);
|
|
461
|
+
fanParamInto(ctx, paramId, id, 1);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
320
465
|
export const CHAINS = {
|
|
321
466
|
midiin: midiInChain,
|
|
322
467
|
midiout: midiOutChain,
|
|
323
468
|
passthrough: passthroughChain,
|
|
324
469
|
gain: gainChain,
|
|
325
470
|
lowpass: lowpassChain,
|
|
471
|
+
drive: driveChain,
|
|
326
472
|
};
|
|
327
473
|
|
|
328
474
|
/** Add a chain to the vocabulary. Called before generatePatchers(). */
|
package/src/index.mjs
CHANGED
|
@@ -16,7 +16,7 @@ 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";
|
|
19
|
+
import { CHAINS, assertUniqueBoxIds, closeAudio, openAudio, resetLayout } from "./chains.mjs";
|
|
20
20
|
import { applySurface, loadSurface, surfaceContext } from "./surface.mjs";
|
|
21
21
|
|
|
22
22
|
const require = createRequire(import.meta.url);
|
|
@@ -138,6 +138,73 @@ async function loadDeviceChains(root) {
|
|
|
138
138
|
console.log("m4l-jweb: loaded device chains from patcher/chains.mjs");
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
/**
|
|
142
|
+
* One device, one patcher: the template, its chains, its Surface. Pure - it takes
|
|
143
|
+
* the base patcher and the device's declaration and returns the JSON to write.
|
|
144
|
+
*
|
|
145
|
+
* It is exported because the tests generate patchers too, and a test that
|
|
146
|
+
* assembled the pipeline itself could pass while the build wired something else.
|
|
147
|
+
* The ORDER below is the pipeline, and every step of it is load-bearing:
|
|
148
|
+
*
|
|
149
|
+
* openAudio the device's plugin~/plugout~, created ONCE, before any chain -
|
|
150
|
+
* so a chain is a stage in the signal path, not the owner of it.
|
|
151
|
+
* the chains in declaration order, each taking what the last one left.
|
|
152
|
+
* applySurface LAST of the message-stream claimants: it routes every `set_<id>`
|
|
153
|
+
* off the app's stream and passes on what nobody claimed
|
|
154
|
+
* (ui_ready, ...) to the wrapper. Doing it last means no chain has
|
|
155
|
+
* to know the Surface exists.
|
|
156
|
+
* closeAudio the final stage's output into plugout~.
|
|
157
|
+
* assertUnique two boxes with one id is a malformed patcher, and Max resolves
|
|
158
|
+
* it however it likes. Nothing else would report it.
|
|
159
|
+
*/
|
|
160
|
+
export function composePatcher(base, d, surface) {
|
|
161
|
+
const amxdtype = AMXD_TYPES[d.type];
|
|
162
|
+
if (!amxdtype) throw new Error(`unknown type "${d.type}" for device "${d.name}" (midi | audio | instrument)`);
|
|
163
|
+
|
|
164
|
+
const p = structuredClone(base);
|
|
165
|
+
const { boxes, lines } = p.patcher;
|
|
166
|
+
p.patcher.project.amxdtype = amxdtype;
|
|
167
|
+
resetLayout();
|
|
168
|
+
|
|
169
|
+
// The wrapper is mode-switched by its object-box argument. `mode` defaults
|
|
170
|
+
// to the device type, but they are not always the same thing: a sample
|
|
171
|
+
// player can be an audio-effect device ("type") that the wrapper must treat
|
|
172
|
+
// as a sampler ("mode").
|
|
173
|
+
//
|
|
174
|
+
// jsarguments[0] is the SCRIPT NAME, so the mode lands at jsarguments[1].
|
|
175
|
+
const mode = d.mode ?? d.type;
|
|
176
|
+
boxes.find((b) => b.box.id === "obj-js").box.text = `js wrapper.js ${mode}`;
|
|
177
|
+
|
|
178
|
+
const unmatchedId = d.unmatchedTo === "js" ? "obj-js" : (d.unmatchedTo ?? "obj-js");
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The device's parameters, declared once in src/app/<ui>/surface.ts. A chain
|
|
182
|
+
* that drives DSP from a parameter needs two things from it, and needs BOTH:
|
|
183
|
+
*
|
|
184
|
+
* paramObject(id) the live.* object's outlet - a knob turn, an automation
|
|
185
|
+
* lane, a Push encoder.
|
|
186
|
+
* paramValue(id) the route outlet carrying what the APP wrote. Not
|
|
187
|
+
* redundant: the app's write reaches the object as `set`,
|
|
188
|
+
* which updates it WITHOUT output, so the object would
|
|
189
|
+
* never pass that value on. See surface.mjs.
|
|
190
|
+
*/
|
|
191
|
+
const ctx = { boxes, lines, jwebId: "obj-jweb", unmatchedId, device: d, ...surfaceContext(surface) };
|
|
192
|
+
|
|
193
|
+
openAudio(ctx);
|
|
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
|
+
applySurface(ctx);
|
|
202
|
+
closeAudio(ctx);
|
|
203
|
+
assertUniqueBoxIds(boxes, d.name);
|
|
204
|
+
|
|
205
|
+
return p;
|
|
206
|
+
}
|
|
207
|
+
|
|
141
208
|
export async function generatePatchers(root) {
|
|
142
209
|
const devices = await readManifest(root);
|
|
143
210
|
const base = readBase(root);
|
|
@@ -146,36 +213,6 @@ export async function generatePatchers(root) {
|
|
|
146
213
|
mkdirSync(outDir, { recursive: true });
|
|
147
214
|
|
|
148
215
|
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
216
|
// The manifest carried `parameters` until 0.4.0. It is now declared in
|
|
180
217
|
// src/app/<ui>/surface.ts and generated from there - so a leftover field is not
|
|
181
218
|
// a harmless extra key, it is a device whose parameters have SILENTLY
|
|
@@ -185,22 +222,12 @@ export async function generatePatchers(root) {
|
|
|
185
222
|
`device "${d.name}" still declares \`parameters\` in patcher/devices.mjs. ` +
|
|
186
223
|
`That field is gone: declare them in src/app/${d.ui ?? d.name}/surface.ts with defineSurface(), ` +
|
|
187
224
|
`which generates the live.* objects, both wiring directions and the protocol selectors. ` +
|
|
188
|
-
`See doc/
|
|
225
|
+
`See doc/ARCHITECTURE.md - "Parameters: the Surface Push reads".`,
|
|
189
226
|
);
|
|
190
227
|
}
|
|
191
228
|
|
|
192
229
|
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);
|
|
230
|
+
const p = composePatcher(base, d, surface);
|
|
204
231
|
|
|
205
232
|
writeFileSync(path.join(outDir, `${d.name}.json`), JSON.stringify(p, null, "\t"));
|
|
206
233
|
const params = surface ? surface.ids.join(", ") : "none";
|
|
@@ -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.5.0",
|
|
21
|
+
"@m4l-jweb/surface": "^0.5.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.5.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
|
*
|