@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m4l-jweb/build",
3
- "version": "0.4.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.4.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 [jweb]'s outlet, routes the selectors
9
- * it owns, and passes everything else on to `unmatchedTo`. Add your own with
10
- * `registerChain()`; keep them small and named after what they do.
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 is a straight wire: `plugin~ -> plugout~`. Removing it from a track sounds
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. If you want an audio effect that is audible,
180
- * you want `gain` below, or your own chain shaped like it.
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({ boxes, lines }) {
183
- // An audio effect has no MIDI ports.
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: `plugin~ -> *~ -> plugout~`,
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 [i, id] of [
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("obj-plugin", i, id, 0));
227
- lines.push(line(id, 0, "obj-plugout", i));
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
- * `plugin~ -> onepole~ -> plugout~`, one filter per channel.
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 [i, id] of [
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("obj-plugin", i, id, 0));
312
- lines.push(line(id, 0, "obj-plugout", i));
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/SURFACE.md.`,
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 ctx = { boxes, lines, jwebId: "obj-jweb", unmatchedId, device: d, ...surfaceContext(surface) };
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.4.0",
21
- "@m4l-jweb/surface": "^0.4.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.4.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" plugin~ -> onepole~ -> plugout~, with a `cutoff`
18
+ * "lowpass" onepole~ in the signal path, with a `cutoff`
19
19
  * parameter. An audio effect you can hear.
20
- * "gain" plugin~ -> *~ -> plugout~, with a `gain` parameter.
21
- * "passthrough" plugin~ -> plugout~. Does nothing to the audio.
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
  *