@m4l-jweb/build 0.3.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.3.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",
@@ -18,6 +18,7 @@
18
18
  "exports": {
19
19
  ".": "./src/index.mjs",
20
20
  "./chains": "./src/chains.mjs",
21
+ "./surface": "./src/surface.mjs",
21
22
  "./amxd": "./src/amxd.mjs",
22
23
  "./init": "./src/init.mjs"
23
24
  },
@@ -30,7 +31,8 @@
30
31
  "dependencies": {
31
32
  "acorn": "^8.17.0",
32
33
  "archiver": "^7.0.1",
34
+ "esbuild": "^0.25.0",
33
35
  "typescript": "^5.7.0",
34
- "@m4l-jweb/wrapper": "0.3.0"
36
+ "@m4l-jweb/wrapper": "0.5.0"
35
37
  }
36
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
@@ -52,6 +60,152 @@ export function removeLine(lines, srcId, dstId) {
52
60
  }
53
61
  }
54
62
 
63
+ /**
64
+ * Splice a `route` into the app's message stream and hand the rest on.
65
+ *
66
+ * More than one thing routes [jweb]'s output - `midiout` claims `midinote` and
67
+ * `flush`, the Surface claims every `set_<id>`, and whatever neither wanted must
68
+ * still reach the wrapper. They cannot hang off [jweb]'s outlet in parallel: each
69
+ * would pass the unmatched messages on to [js], and the wrapper would see
70
+ * `ui_ready` once per route. So they are chained in SERIES, each feeding the next
71
+ * from its unmatched outlet:
72
+ *
73
+ * [jweb] -> [route midinote flush] -> [route set_density] -> [js]
74
+ * unmatched unmatched
75
+ *
76
+ * `ctx.appOut` names the tail of that chain - the outlet currently carrying
77
+ * everything nobody has claimed. Claim from THAT, never from `jwebId` directly,
78
+ * or you steal the messages the chain before you was passing on.
79
+ *
80
+ * Do not go looking for the cord to cut by searching for whatever feeds [js]
81
+ * either: `live.thisdevice` feeds it too, and cutting that one is invisible here
82
+ * and fatal in Live - it is the bang every LiveAPI observer is created from.
83
+ */
84
+ export function claimAppMessages(ctx, routeId, unmatchedOutlet) {
85
+ const [srcId, srcOutlet] = ctx.appOut ?? [ctx.jwebId, 0];
86
+
87
+ // Nobody has claimed the stream yet, and yet [jweb] no longer reaches the
88
+ // wrapper: a chain cut that cord by hand (the old `removeLine(jwebId,
89
+ // unmatchedId)` idiom) without saying where it put the messages. We cannot know
90
+ // - and guessing produces a patcher that WORKS while delivering every unrouted
91
+ // message twice, which is not a failure anyone would look for. Say so instead.
92
+ if (!ctx.appOut && !ctx.lines.some((l) => l.patchline.source[0] === ctx.jwebId && l.patchline.destination[0] === ctx.unmatchedId)) {
93
+ throw new Error(
94
+ `a chain on device "${ctx.device?.name}" took [jweb]'s outlet without claimAppMessages(). ` +
95
+ `Routes are chained in series, so each one must hand the next what it did not match. ` +
96
+ `Replace "removeLine(lines, jwebId, unmatchedId); lines.push(line(jwebId, 0, myRoute, 0)); ` +
97
+ `lines.push(line(myRoute, <last>, unmatchedId, 0));" with "claimAppMessages(ctx, myRoute, <last>)".`,
98
+ );
99
+ }
100
+
101
+ removeLine(ctx.lines, srcId, ctx.unmatchedId);
102
+ ctx.lines.push(line(srcId, srcOutlet, routeId, 0));
103
+ ctx.lines.push(line(routeId, unmatchedOutlet, ctx.unmatchedId, 0));
104
+ ctx.appOut = [routeId, unmatchedOutlet];
105
+ }
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
+
55
209
  /**
56
210
  * "midiin" - feed incoming MIDI notes to the app as `notein <pitch> <velocity>`.
57
211
  *
@@ -77,10 +231,8 @@ function midiInChain({ boxes, lines, jwebId }) {
77
231
  * "midiout" - the app emits `midinote <pitch> <vel> <durMs> <chan> <delayMs>`
78
232
  * and `flush`. Compute WHEN in your app; let Max place the note precisely.
79
233
  */
80
- function midiOutChain({ boxes, lines, jwebId, unmatchedId }) {
81
- // This chain consumes jweb's output, so the template's direct jweb -> js cord
82
- // is replaced by the route's unmatched outlet.
83
- removeLine(lines, jwebId, unmatchedId);
234
+ function midiOutChain(ctx) {
235
+ const { boxes, lines } = ctx;
84
236
 
85
237
  boxes.push(box("obj-route", "route midinote flush", { numoutlets: 3, outlettype: ["", "", ""] }));
86
238
  // Explicit unpack instead of letting pipe spread the list: unpack fires
@@ -108,7 +260,11 @@ function midiOutChain({ boxes, lines, jwebId, unmatchedId }) {
108
260
  // makenote actually releases hanging notes.
109
261
  boxes.push(box("obj-flushmsg", "flush", { maxclass: "message", numinlets: 2, numoutlets: 1 }));
110
262
 
111
- lines.push(line(jwebId, 0, "obj-route", 0));
263
+ // Take the app's messages, and pass on what is not a note (ui_ready, set_<id>,
264
+ // ...) from outlet 2 - to the Surface's route if the device has parameters, and
265
+ // to the wrapper in the end.
266
+ claimAppMessages(ctx, "obj-route", 2);
267
+
112
268
  lines.push(line("obj-route", 0, "obj-unpack", 0));
113
269
  lines.push(line("obj-route", 1, "obj-flushmsg", 0));
114
270
  lines.push(line("obj-flushmsg", 0, "obj-makenote", 0));
@@ -121,32 +277,28 @@ function midiOutChain({ boxes, lines, jwebId, unmatchedId }) {
121
277
  lines.push(line("obj-makenote", 1, "obj-packnote", 1));
122
278
  lines.push(line("obj-packnote", 0, "obj-fmt", 0));
123
279
  lines.push(line("obj-fmt", 0, "obj-midiout", 0));
124
- // Unmatched selectors (ui_ready, write_clip, read_notes...) carry on.
125
- lines.push(line("obj-route", 2, unmatchedId, 0));
126
280
  }
127
281
 
128
282
  /**
129
283
  * "passthrough" - an audio effect that passes its input through UNTOUCHED.
130
284
  *
131
- * 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
132
287
  * identical, because it does nothing to the audio - it exists to prove that an
133
288
  * audio-effect container builds and that the UI runs inside one.
134
289
  *
135
- * It is a scaffold, not a feature. If you want an audio effect that is audible,
136
- * 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.
137
295
  */
138
- function passthroughChain({ boxes, lines }) {
139
- // An audio effect has no MIDI ports.
140
- removeBox(boxes, lines, "obj-midiin");
141
- removeBox(boxes, lines, "obj-midiout");
142
- boxes.push(box("obj-plugin", "plugin~", { numinlets: 1, numoutlets: 2, outlettype: ["signal", "signal"] }));
143
- boxes.push(box("obj-plugout", "plugout~", { numinlets: 2, numoutlets: 0 }));
144
- lines.push(line("obj-plugin", 0, "obj-plugout", 0));
145
- 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.
146
298
  }
147
299
 
148
300
  /**
149
- * "gain" - an audio effect that actually DOES something: `plugin~ -> *~ -> plugout~`,
301
+ * "gain" - an audio effect that actually DOES something: `*~` in the signal path,
150
302
  * with a Live parameter riding the multiplier. Turn the dial, hear the level move.
151
303
  *
152
304
  * The smallest honest example of the shape every audio effect has - your DSP goes
@@ -154,112 +306,73 @@ function passthroughChain({ boxes, lines }) {
154
306
  * SIGNAL domain, not just the app.
155
307
  *
156
308
  * Note what does NOT happen here: the value does not travel through [jweb] and
157
- * back. The dial is wired straight into the `*~` right inlet, in the patcher, so
158
- * the audio path does not depend on the browser being alive or keeping up. The
159
- * app gets its own copy of the value (via addParameters) purely to DISPLAY it.
160
- * Audio is Max's job; the UI is a view of it.
309
+ * back. The parameter is wired straight into the `*~` right inlet, in the patcher,
310
+ * so the audio path does not depend on the browser being alive or keeping up. The
311
+ * app gets its own copy of the value purely to DISPLAY it. Audio is Max's job; the
312
+ * UI is a view of it.
161
313
  *
162
- * Requires a parameter named `gain` (or pass `device.gainParam`).
314
+ * Requires a parameter named `gain` in the device's surface.ts (or pass
315
+ * `device.gainParam`).
163
316
  */
164
- function gainChain({ boxes, lines, device }) {
165
- removeBox(boxes, lines, "obj-midiin");
166
- removeBox(boxes, lines, "obj-midiout");
167
-
168
- const paramId = device?.gainParam ?? "gain";
169
- const declared = (device?.parameters ?? []).some((p) => p.id === paramId);
170
- if (!declared) {
171
- throw new Error(`chain "gain" on device "${device?.name}" needs a parameter with id "${paramId}" (or set gainParam)`);
172
- }
173
-
174
- boxes.push(box("obj-plugin", "plugin~", { numinlets: 1, numoutlets: 2, outlettype: ["signal", "signal"] }));
175
- boxes.push(box("obj-plugout", "plugout~", { numinlets: 2, numoutlets: 0 }));
317
+ function gainChain(ctx) {
318
+ const { boxes, lines, device } = ctx;
319
+ const paramId = requireParam(ctx, "gain", device?.gainParam ?? "gain", "gainParam");
176
320
 
177
321
  // One *~ per channel: a signal object handles ONE signal, and plugin~ hands us
178
322
  // a stereo pair. `1.` (a float, not an int) keeps the right inlet in float mode.
179
- for (const [i, id] of [
323
+ for (const [ch, id] of [
180
324
  [0, "obj-gain-l"],
181
325
  [1, "obj-gain-r"],
182
326
  ]) {
327
+ const [srcId, srcOut] = ctx.audioIn(ch); // whatever the last stage left
183
328
  boxes.push(box(id, "*~ 1.", { numinlets: 2, numoutlets: 1, outlettype: ["signal"] }));
184
- lines.push(line("obj-plugin", i, id, 0));
185
- lines.push(line(id, 0, "obj-plugout", i));
186
- // The live.dial box is created LATER, by addParameters(). A patchline may name
187
- // a box that appears further down the boxes array - the patcher is a graph, not
188
- // a script - so this cord is valid as long as the parameter is declared, which
189
- // is what the check above guarantees.
190
- lines.push(line(`obj-param-${paramId}`, 0, id, 1));
329
+ lines.push(line(srcId, srcOut, id, 0));
330
+ ctx.setAudioOut(ch, id, 0); // we are the tail now
331
+ fanParamInto(ctx, paramId, id, 1);
191
332
  }
192
333
  }
193
334
 
194
335
  /**
195
- * Let the APP write to a Live parameter: `set_<id> <value>` from [jweb] lands on
196
- * the live.* object's inlet, and from there in the signal path and in Live's
197
- * automation, exactly as if the user had turned the dial.
198
- *
199
- * This is the missing half of the parameter story. Reading one has always worked
200
- * (addParameters wires the object out to the app). Writing one did not exist, so
201
- * a control in the web UI could only ever be a readout of a knob you had to turn
202
- * somewhere else - useless.
203
- *
204
- * TWO TRAPS, and the whole design of this helper is about them:
205
- *
206
- * 1. FEEDBACK. Sending a bare value into a live.dial's inlet SETS IT AND MAKES
207
- * IT OUTPUT, which sends it straight back to the app - which could set it
208
- * again. `set <value>` is the documented message that updates the value
209
- * WITHOUT producing outlet output, so the loop never starts. (Spike 1.1 in
210
- * doc/SPIKES.md confirms the behaviour properly; the field evidence is
211
- * below.)
212
- *
213
- * 2. `set` SUPPRESSING THE OUTPUT IS NOT FREE. It silences the dial for
214
- * EVERYONE, not just for the app - including whatever the dial drives inside
215
- * the patcher. The first version of the lowpass chain fed its filter from the
216
- * dial's OUTLET, so writing the parameter with `set` moved the dial and told
217
- * the filter nothing: the slider appeared dead and the cutoff never budged.
218
- *
219
- * So the value is FANNED OUT, not chained. The route outlet is the source of
220
- * truth for the app's write, and the caller taps it directly (see
221
- * `valueOutlet` below) to drive whatever the parameter controls. The dial is
222
- * updated in parallel, so Live's automation, MIDI mapping and Push all stay
223
- * correct - but nothing downstream *depends* on the dial re-emitting.
224
- *
225
- * The dial's own outlet still feeds the same destination, because that is the
226
- * path a real knob-turn, an automation lane or a Push encoder travels.
227
- *
228
- * Returns `{ valueOutlet(id) }`: the [route] outlet carrying the raw value the
229
- * app wrote, for the chain to wire wherever the parameter actually acts.
230
- *
231
- * This is a hand-rolled sliver of what the Surface will generate for every
232
- * parameter at once (Stage 2 of doc/TODO.md). It is here because "a slider in
233
- * the device window that actually does something" should not have to wait.
336
+ * Wire a parameter into the thing it controls, from BOTH of its sources:
337
+ *
338
+ * the OBJECT's outlet - a knob turn, an automation lane, a Push encoder.
339
+ * the ROUTE's outlet - the value the app wrote.
340
+ *
341
+ * The second is not redundant, and leaving it out is the bug this helper exists to
342
+ * make unrepeatable. The app's write reaches the object as `set <value>`, which
343
+ * updates it WITHOUT producing output - so the object never passes the app's value
344
+ * on, and whatever it drives sits where it was while the UI's slider appears dead.
345
+ * It did exactly that. See packages/build/src/surface.mjs.
346
+ *
347
+ * The boxes named here are created LATER, by applySurface(). A patchline may name a
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.
234
357
  */
235
- function writableParams({ boxes, lines, jwebId, unmatchedId }, ids) {
236
- removeLine(lines, jwebId, unmatchedId);
358
+ function fanParamInto(ctx, paramId, dstId, dstInlet) {
359
+ const [objId, objOut] = ctx.paramObject(paramId);
360
+ const [routeId, routeOut] = ctx.paramValue(paramId);
361
+ ctx.lines.push(line(objId, objOut, dstId, dstInlet));
362
+ ctx.lines.push(line(routeId, routeOut, dstId, dstInlet));
363
+ }
237
364
 
238
- const selectors = ids.map((id) => `set_${id}`);
239
- boxes.push(
240
- box("obj-setparam-route", `route ${selectors.join(" ")}`, {
241
- numoutlets: ids.length + 1,
242
- outlettype: ids.map(() => "").concat(""),
243
- }),
244
- );
245
- lines.push(line(jwebId, 0, "obj-setparam-route", 0));
246
-
247
- ids.forEach((id, i) => {
248
- // `route` STRIPS the selector, so what emerges is the bare value. Re-wrap it
249
- // as `set <value>` - the set-without-output message - and feed the object, so
250
- // the dial, the automation lane and Push all follow the app's slider.
251
- boxes.push(box(`obj-set-${id}`, "prepend set"));
252
- lines.push(line("obj-setparam-route", i, `obj-set-${id}`, 0));
253
- lines.push(line(`obj-set-${id}`, 0, `obj-param-${id}`, 0));
254
- });
255
-
256
- // Unmatched (ui_ready, and anything the wrapper handles) carries on.
257
- lines.push(line("obj-setparam-route", ids.length, unmatchedId, 0));
258
-
259
- return {
260
- /** The outlet carrying the value the APP wrote. Wire it where the parameter acts. */
261
- valueOutlet: (id) => ["obj-setparam-route", ids.indexOf(id)],
262
- };
365
+ /** A chain that drives DSP from a parameter is broken without it - say so loudly. */
366
+ function requireParam(ctx, chainName, paramId, overrideField) {
367
+ if (!ctx.surface?.params?.[paramId]) {
368
+ const declared = ctx.surface ? ctx.surface.ids.join(", ") || "none" : "no surface.ts at all";
369
+ throw new Error(
370
+ `chain "${chainName}" on device "${ctx.device?.name}" needs a parameter "${paramId}" in ` +
371
+ `src/app/${ctx.device?.ui ?? ctx.device?.name}/surface.ts (declared: ${declared}). ` +
372
+ `Rename it, or point the chain at another one with \`${overrideField}\`.`,
373
+ );
374
+ }
375
+ return paramId;
263
376
  }
264
377
 
265
378
  /**
@@ -271,7 +384,7 @@ function writableParams({ boxes, lines, jwebId, unmatchedId }, ids) {
271
384
  * parameter via `set_cutoff` - so moving it moves the dial, the automation lane
272
385
  * and the filter together. It is one control, with two faces.
273
386
  *
274
- * `plugin~ -> onepole~ -> plugout~`, one filter per channel.
387
+ * `onepole~` in the signal path, one filter per channel.
275
388
  *
276
389
  * WHY onepole~ and not lores~/svf~/biquad~: a one-pole is a 6 dB/octave slope -
277
390
  * the gentlest filter there is. It cannot self-oscillate, cannot blow up, and
@@ -279,66 +392,73 @@ function writableParams({ boxes, lines, jwebId, unmatchedId }, ids) {
279
392
  * effect is unmistakable when you sweep it, and there is no way to configure it
280
393
  * into silence or into a scream. Swap in `svf~` when you want a real filter.
281
394
  *
282
- * THE CUTOFF MAPPING is the interesting part. The dial is 0-1 (a Live parameter
283
- * wants a bounded, automatable range), but pitch is logarithmic: a linear sweep
284
- * from 20 Hz to 18 kHz spends almost all its travel in the top octave, where you
285
- * can hear nothing happening, and races through the bottom, where everything
286
- * happens. So the value goes through [expr] first:
287
- *
288
- * 40 * 450^x x = 0 -> 40 Hz, x = 0.5 -> ~850 Hz, x = 1 -> 18 kHz
289
- *
290
- * which spreads the audible action evenly across the knob. This is exactly the
291
- * curve a filter knob on real hardware has.
395
+ * THE CUTOFF IS IN HERTZ, and no arithmetic happens here. The chain used to take a
396
+ * 0-1 parameter and map it through `[expr 40. * pow(450., $f1)]`, because pitch is
397
+ * logarithmic and a linear knob is useless on a filter. That mapping now lives on
398
+ * the PARAMETER (`range: [40, 18000]`, `unit: "Hz"`, `exponent`), which is where
399
+ * Live wants it: the automation lane reads Hz, Push reads "7.3 kHz", the app reads
400
+ * Hz, and the value drops straight into onepole~. A normalised parameter with the
401
+ * curve hidden in a chain lies to every one of those readouts.
292
402
  *
293
- * Requires a parameter named `cutoff` (or pass `device.cutoffParam`).
403
+ * Requires a parameter named `cutoff` (or pass `device.cutoffParam`), in Hz.
294
404
  */
295
405
  function lowpassChain(ctx) {
296
406
  const { boxes, lines, device } = ctx;
297
- removeBox(boxes, lines, "obj-midiin");
298
- removeBox(boxes, lines, "obj-midiout");
299
-
300
- const paramId = device?.cutoffParam ?? "cutoff";
301
- const declared = (device?.parameters ?? []).some((p) => p.id === paramId);
302
- if (!declared) {
303
- throw new Error(`chain "lowpass" on device "${device?.name}" needs a parameter with id "${paramId}" (or set cutoffParam)`);
304
- }
305
-
306
- // The slider in the device window writes the parameter: `set_cutoff <0-1>`.
307
- const { valueOutlet } = writableParams(ctx, [paramId]);
308
- const [routeId, routeOut] = valueOutlet(paramId);
309
-
310
- boxes.push(box("obj-plugin", "plugin~", { numinlets: 1, numoutlets: 2, outlettype: ["signal", "signal"] }));
311
- boxes.push(box("obj-plugout", "plugout~", { numinlets: 2, numoutlets: 0 }));
312
-
313
- // 0-1 -> 40..18000 Hz, logarithmically. Floats, not ints: `40.` and `450.` keep
314
- // expr in float mode, and an int cutoff would quantise the sweep into steps.
315
- boxes.push(box("obj-cutoff-hz", "expr 40. * pow(450., $f1)", { numinlets: 1, numoutlets: 1, outlettype: [""] }));
316
-
317
- // TWO sources feed the filter, and it needs both:
318
- //
319
- // the DIAL's outlet - a knob turn, an automation lane, a Push encoder.
320
- // the ROUTE's outlet - the app's slider.
321
- //
322
- // The second is not redundant. The app writes the dial with `set`, which
323
- // updates it WITHOUT producing output - so the dial would never pass the app's
324
- // value on, and the filter would sit wherever it was while the slider appeared
325
- // to do nothing. (It did exactly that.) Tap the value where it enters.
326
- lines.push(line(`obj-param-${paramId}`, 0, "obj-cutoff-hz", 0));
327
- lines.push(line(routeId, routeOut, "obj-cutoff-hz", 0));
407
+ const paramId = requireParam(ctx, "lowpass", device?.cutoffParam ?? "cutoff", "cutoffParam");
328
408
 
329
409
  // One filter per channel: a signal object handles ONE signal, and plugin~ hands
330
410
  // us a stereo pair. Both take the same cutoff, so the image does not shift.
331
- for (const [i, id] of [
411
+ for (const [ch, id] of [
332
412
  [0, "obj-lpf-l"],
333
413
  [1, "obj-lpf-r"],
334
414
  ]) {
415
+ const [srcId, srcOut] = ctx.audioIn(ch);
335
416
  boxes.push(box(id, "onepole~ 18000.", { numinlets: 2, numoutlets: 1, outlettype: ["signal"] }));
336
- lines.push(line("obj-plugin", i, id, 0));
337
- lines.push(line(id, 0, "obj-plugout", i));
338
- // Cutoff into the RIGHT inlet. The dial box itself is created LATER, by
339
- // addParameters() - a patchline may name a box further down the array,
340
- // because a patcher is a graph, not a script.
341
- lines.push(line("obj-cutoff-hz", 0, id, 1));
417
+ lines.push(line(srcId, srcOut, id, 0));
418
+ ctx.setAudioOut(ch, id, 0);
419
+ // The cutoff, in Hz, into the RIGHT inlet - from both of its sources: the dial
420
+ // (a knob turn, an automation lane, a Push encoder) and the route (the app's
421
+ // write, which the dial will not re-emit because it arrives as `set`).
422
+ fanParamInto(ctx, paramId, id, 1);
423
+ }
424
+ }
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);
342
462
  }
343
463
  }
344
464
 
@@ -348,6 +468,7 @@ export const CHAINS = {
348
468
  passthrough: passthroughChain,
349
469
  gain: gainChain,
350
470
  lowpass: lowpassChain,
471
+ drive: driveChain,
351
472
  };
352
473
 
353
474
  /** Add a chain to the vocabulary. Called before generatePatchers(). */
@@ -356,48 +477,12 @@ export function registerChain(name, fn) {
356
477
  }
357
478
 
358
479
  /**
359
- * Real Live parameters: automatable, MIDI-mappable, and the ONLY thing Push can
360
- * display. Each becomes a live.* object wired into the UI as `<id> <value>`, so
361
- * a parameter change is just another inlet message to your app.
362
- *
363
- * `default` is not optional in practice, whatever the type says. A live.* object
364
- * with no initial value loads at the BOTTOM of its range, and for a great many
365
- * parameters the bottom of the range is a broken device: a filter cutoff of 0
366
- * loads as a device that eats the signal, and it looks exactly like a bug in your
367
- * DSP. Declare `default` and Max stores it as the object's initial value, which
368
- * Live restores on load and on "reset to default".
480
+ * Parameters used to be declared in the manifest and generated here, by
481
+ * `addParameters()`, in ONE direction: object -> app. Writing one back was a
482
+ * per-chain hand-roll (`writableParams()`).
483
+ *
484
+ * Both are gone. A device's parameters are declared in `src/app/<ui>/surface.ts`
485
+ * and compiled by `applySurface()` in surface.mjs - objects, both directions, and
486
+ * the fan-out that the `set` behaviour forces. A chain reaches them through
487
+ * `fanParamInto()` above.
369
488
  */
370
- export function addParameters(boxes, lines, params, dstId) {
371
- let x = 480;
372
- for (const p of params) {
373
- const objId = `obj-param-${p.id}`;
374
- const prependId = `obj-prepend-${p.id}`;
375
- boxes.push({
376
- box: {
377
- id: objId,
378
- maxclass: p.object, // live.dial | live.toggle | live.menu
379
- numinlets: 1,
380
- numoutlets: 1,
381
- outlettype: [""],
382
- parameter_enable: 1,
383
- patching_rect: [x, 300, 44, 48],
384
- saved_attribute_attributes: {
385
- valueof: {
386
- parameter_longname: p.id,
387
- parameter_shortname: p.id.slice(0, 8), // Push shows short names
388
- parameter_type: p.object === "live.toggle" ? 2 : 0, // 2 = enum, 0 = float
389
- ...(p.range ? { parameter_range: p.range } : {}),
390
- // parameter_initial is a LIST, and it is inert without
391
- // parameter_initial_enable - setting one without the other silently
392
- // does nothing, which is the worst way for this to fail.
393
- ...(p.default !== undefined ? { parameter_initial_enable: 1, parameter_initial: [p.default] } : {}),
394
- },
395
- },
396
- },
397
- });
398
- boxes.push(box(prependId, `prepend ${p.id}`));
399
- lines.push(line(objId, 0, prependId, 0));
400
- lines.push(line(prependId, 0, dstId, 0));
401
- x += 56;
402
- }
403
- }
package/src/index.mjs CHANGED
@@ -16,7 +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, addParameters, resetLayout } from "./chains.mjs";
19
+ import { CHAINS, assertUniqueBoxIds, closeAudio, openAudio, resetLayout } from "./chains.mjs";
20
+ import { applySurface, loadSurface, surfaceContext } from "./surface.mjs";
20
21
 
21
22
  const require = createRequire(import.meta.url);
22
23
  const pkgDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
@@ -137,6 +138,73 @@ async function loadDeviceChains(root) {
137
138
  console.log("m4l-jweb: loaded device chains from patcher/chains.mjs");
138
139
  }
139
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
+
140
208
  export async function generatePatchers(root) {
141
209
  const devices = await readManifest(root);
142
210
  const base = readBase(root);
@@ -145,36 +213,25 @@ export async function generatePatchers(root) {
145
213
  mkdirSync(outDir, { recursive: true });
146
214
 
147
215
  for (const d of devices) {
148
- const amxdtype = AMXD_TYPES[d.type];
149
- if (!amxdtype) throw new Error(`unknown type "${d.type}" for device "${d.name}" (midi | audio | instrument)`);
150
-
151
- const p = structuredClone(base);
152
- const { boxes, lines } = p.patcher;
153
- p.patcher.project.amxdtype = amxdtype;
154
- resetLayout();
155
-
156
- // The wrapper is mode-switched by its object-box argument. `mode` defaults
157
- // to the device type, but they are not always the same thing: a sample
158
- // player can be an audio-effect device ("type") that the wrapper must treat
159
- // as a sampler ("mode").
160
- //
161
- // jsarguments[0] is the SCRIPT NAME, so the mode lands at jsarguments[1].
162
- const mode = d.mode ?? d.type;
163
- boxes.find((b) => b.box.id === "obj-js").box.text = `js wrapper.js ${mode}`;
164
-
165
- const unmatchedId = d.unmatchedTo === "js" ? "obj-js" : (d.unmatchedTo ?? "obj-js");
166
-
167
- for (const name of d.chains ?? []) {
168
- const chain = CHAINS[name];
169
- if (!chain) throw new Error(`unknown chain "${name}" for device "${d.name}" (known: ${Object.keys(CHAINS).join(", ")})`);
170
- chain({ boxes, lines, jwebId: "obj-jweb", unmatchedId, device: d });
216
+ // The manifest carried `parameters` until 0.4.0. It is now declared in
217
+ // src/app/<ui>/surface.ts and generated from there - so a leftover field is not
218
+ // a harmless extra key, it is a device whose parameters have SILENTLY
219
+ // disappeared. Fail the build and say where they went.
220
+ if (d.parameters) {
221
+ throw new Error(
222
+ `device "${d.name}" still declares \`parameters\` in patcher/devices.mjs. ` +
223
+ `That field is gone: declare them in src/app/${d.ui ?? d.name}/surface.ts with defineSurface(), ` +
224
+ `which generates the live.* objects, both wiring directions and the protocol selectors. ` +
225
+ `See doc/ARCHITECTURE.md - "Parameters: the Surface Push reads".`,
226
+ );
171
227
  }
172
228
 
173
- // Parameters feed the UI: a knob move arrives as just another inlet message.
174
- addParameters(boxes, lines, d.parameters ?? [], "obj-jweb");
229
+ const surface = await loadSurface(root, d.ui ?? d.name);
230
+ const p = composePatcher(base, d, surface);
175
231
 
176
232
  writeFileSync(path.join(outDir, `${d.name}.json`), JSON.stringify(p, null, "\t"));
177
- console.log(`m4l-jweb: ${d.name}.json (${d.type}, chains: ${(d.chains ?? []).join(", ") || "none"})`);
233
+ const params = surface ? surface.ids.join(", ") : "none";
234
+ console.log(`m4l-jweb: ${d.name}.json (${d.type}, chains: ${(d.chains ?? []).join(", ") || "none"}, params: ${params || "none"})`);
178
235
  }
179
236
  return devices;
180
237
  }
@@ -0,0 +1,296 @@
1
+ /**
2
+ * surface.mjs - the Surface compiler.
3
+ *
4
+ * One declaration (`src/app/<device>/surface.ts`) becomes the whole Max side of a
5
+ * parameter: the `live.*` object, its wiring in BOTH directions, and the protocol
6
+ * selectors the lint then checks for free. It replaces `addParameters()` (which
7
+ * only ever did the read direction) and `writableParams()` (which did the write
8
+ * direction, by hand, for one parameter at a time).
9
+ *
10
+ * ------------------------------------------------------------------------------
11
+ * THE TRAP THIS FILE EXISTS TO NOT REPRODUCE
12
+ *
13
+ * The app writes a parameter by sending `set_<id> <value>`, and the patcher feeds
14
+ * the object a `set <value>` message. `set` updates the object WITHOUT making it
15
+ * output - which is what stops the app feeding itself back in a loop.
16
+ *
17
+ * But `set` does not suppress the outlet for the app only. It suppresses it for
18
+ * EVERYONE, including whatever that object drives inside the patcher. The first
19
+ * `lowpass` chain fed its filter from the dial's outlet, and the app wrote the
20
+ * dial with `set`: the dial moved, and the filter never heard a thing. The slider
21
+ * looked dead.
22
+ *
23
+ * So a parameter's value is FANNED OUT, never chained:
24
+ *
25
+ * [jweb] --set_cutoff--> [route] --+--> [prepend set] --> [live.dial] --+
26
+ * | |
27
+ * +-------------> the DSP <------------+
28
+ * (or whatever it drives)
29
+ *
30
+ * The object is updated in parallel, so automation, MIDI mapping and Push all stay
31
+ * correct - but nothing downstream DEPENDS on it re-emitting. The object's own
32
+ * outlet still reaches the same destination, because that is the path a knob turn,
33
+ * an automation lane or a Push encoder travels.
34
+ *
35
+ * `paramValue()` below is the route outlet a chain taps for the app's write;
36
+ * `paramObject()` is the object's own outlet. A chain that drives DSP from a
37
+ * parameter must wire BOTH. `tests/surface.test.mjs` asserts it.
38
+ * ------------------------------------------------------------------------------
39
+ */
40
+ import { existsSync, mkdtempSync, rmSync } from "node:fs";
41
+ import { tmpdir } from "node:os";
42
+ import path from "node:path";
43
+ import { pathToFileURL } from "node:url";
44
+
45
+ import { box, claimAppMessages, line } from "./chains.mjs";
46
+
47
+ /** The one route that dispatches every `set_<id>` the app sends. */
48
+ export const SURFACE_ROUTE = "obj-surface-route";
49
+
50
+ /** The `live.*` object for a parameter. Its outlet is a knob turn / automation. */
51
+ export const paramObject = (id) => `obj-param-${id}`;
52
+
53
+ /**
54
+ * The route outlet carrying the value the APP wrote - the fan-out tap.
55
+ *
56
+ * Deterministic from the declaration, so a chain can wire it before the route box
57
+ * exists: a patcher is a graph, not a script, and a cord may name a box that
58
+ * appears later in the array.
59
+ */
60
+ export const paramValue = (surface, id) => [SURFACE_ROUTE, surface.ids.indexOf(id)];
61
+
62
+ /**
63
+ * What a chain is handed to reach the parameters: `surface` (to check a parameter
64
+ * it needs exists) and the two outlets it must fan a value out of. Spread into the
65
+ * chain context by the build - and by the codegen test, so the test drives the
66
+ * chains through the same seam the build does.
67
+ */
68
+ export function surfaceContext(surface) {
69
+ return {
70
+ surface,
71
+ paramObject: (id) => [paramObject(id), 0],
72
+ paramValue: (id) => paramValue(surface, id),
73
+ };
74
+ }
75
+
76
+ /* ------------------------------------------------------------------ *
77
+ * Reading the declaration
78
+ * ------------------------------------------------------------------ */
79
+
80
+ /**
81
+ * Evaluate `src/app/<ui>/surface.ts` in Node.
82
+ *
83
+ * It is TypeScript, and it imports @m4l-jweb/surface, whose entry point is also
84
+ * TypeScript - so it has to be bundled before it can be imported. esbuild does
85
+ * that in milliseconds. `defineSurface()` returns plain serializable data, so
86
+ * nothing exotic crosses the boundary.
87
+ *
88
+ * `format` is the exception: it is a FUNCTION, and functions do not serialize
89
+ * into a patcher. It survives the import (this is a real module, not JSON) and is
90
+ * used app-side only - by the dev harness and the Push preview. Do not try to
91
+ * ship it into [js].
92
+ */
93
+ export async function loadSurface(root, uiDir) {
94
+ const src = path.join(root, "src", "app", uiDir, "surface.ts");
95
+ if (!existsSync(src)) return null;
96
+
97
+ const { build } = await import("esbuild");
98
+ const tmp = mkdtempSync(path.join(tmpdir(), "m4l-surface-"));
99
+ const out = path.join(tmp, "surface.mjs");
100
+ try {
101
+ await build({
102
+ entryPoints: [src],
103
+ outfile: out,
104
+ bundle: true,
105
+ format: "esm",
106
+ platform: "node",
107
+ logLevel: "silent",
108
+ // React is not imported by a surface declaration, and bundling it here would
109
+ // be both slow and pointless.
110
+ external: ["react", "react-dom"],
111
+ });
112
+ const mod = await import(pathToFileURL(out).href);
113
+ const surface = mod.default;
114
+ if (!surface?.ids) {
115
+ throw new Error(`${src} must \`export default defineSurface({...})\``);
116
+ }
117
+ return surface;
118
+ } finally {
119
+ rmSync(tmp, { recursive: true, force: true });
120
+ }
121
+ }
122
+
123
+ /* ------------------------------------------------------------------ *
124
+ * Generating the objects
125
+ * ------------------------------------------------------------------ */
126
+
127
+ const MAXCLASS = { dial: "live.dial", toggle: "live.toggle", menu: "live.menu" };
128
+
129
+ /**
130
+ * Max's `parameter_type`: 0 = float, 1 = int, 2 = enum.
131
+ *
132
+ * A dial with `step: 1` is an INTEGER parameter - which matters to Live, not just
133
+ * to us: an int parameter quantises automation and shows whole numbers on Push,
134
+ * where a float one would read "2.4 of [off 1/4 1/8 ...]".
135
+ */
136
+ function parameterType(spec) {
137
+ if (spec.kind === "menu" || spec.kind === "toggle") return 2;
138
+ return spec.step === 1 ? 1 : 0;
139
+ }
140
+
141
+ /**
142
+ * `parameter_unitstyle` - HOW LIVE PRINTS THE VALUE, and the reason a perfectly
143
+ * good float parameter can read "0" and "1" on a Push.
144
+ *
145
+ * The unit style is a display concern with no effect on the value, which is
146
+ * exactly what makes it easy to get wrong and hard to notice: the dial sweeps
147
+ * continuously, the DSP hears every intermediate value, and Push rounds the
148
+ * readout to an integer because THAT is what unit style 0 means. Declare the unit
149
+ * and the same knob reads "7.3 kHz".
150
+ *
151
+ * The order below is the order the unit styles are listed in Max's own reference
152
+ * (docs/refpages/m4l-ref/parameters.maxref.xml), and 3 = Hertz is confirmed
153
+ * against the factory devices that ship with Live: every parameter named
154
+ * "Frequency" / "Master Freq" carries `parameter_unitstyle: 3`.
155
+ */
156
+ const UNITSTYLE = {
157
+ int: 0,
158
+ float: 1,
159
+ ms: 2,
160
+ Hz: 3,
161
+ dB: 4,
162
+ "%": 5,
163
+ pan: 6,
164
+ st: 7,
165
+ midi: 8,
166
+ // 9 = Custom (takes parameter_units), 10 = Native.
167
+ };
168
+ const UNITSTYLE_CUSTOM = 9;
169
+
170
+ /**
171
+ * A dial's unit. No `unit` means "just a number": integer if the parameter is an
172
+ * integer, float otherwise - because the default, 0, prints a float as a rounded
173
+ * integer.
174
+ */
175
+ function unitAttrs(spec) {
176
+ if (!spec.unit) return { parameter_unitstyle: spec.step === 1 ? UNITSTYLE.int : UNITSTYLE.float };
177
+ const known = UNITSTYLE[spec.unit];
178
+ if (known !== undefined) return { parameter_unitstyle: known };
179
+ // Anything else is a custom unit: Live prints the number and appends the string
180
+ // (or honours a sprintf pattern, e.g. "%0.2f Bogons").
181
+ return { parameter_unitstyle: UNITSTYLE_CUSTOM, parameter_units: spec.unit };
182
+ }
183
+
184
+ /** The parameter's value as MAX stores it: numbers, always. */
185
+ function initialValue(spec) {
186
+ if (spec.kind === "toggle") return spec.default ? 1 : 0;
187
+ if (spec.kind === "menu") return spec.options.indexOf(spec.default);
188
+ return spec.default;
189
+ }
190
+
191
+ /**
192
+ * The `saved_attribute_attributes.valueof` block, in the shape Max itself writes.
193
+ *
194
+ * THE RANGE IS `parameter_mmin` / `parameter_mmax`, NOT `parameter_range`. This
195
+ * cost a device: we emitted `parameter_range: [0, 1]` for a long time, and it is
196
+ * not a key Max uses for a continuous parameter - so the range was whatever the
197
+ * object defaulted to, silently. `parameter_range` appears in exactly zero of the
198
+ * patchers Ableton ships. An enum's options are `parameter_enum`, with
199
+ * `parameter_mmax` holding the highest index.
200
+ */
201
+ function parameterAttrs(id, spec) {
202
+ const attrs = {
203
+ parameter_longname: id,
204
+ parameter_shortname: spec.short,
205
+ parameter_type: parameterType(spec),
206
+ // `parameter_initial` is a LIST, and it is INERT without
207
+ // parameter_initial_enable - setting one without the other silently does
208
+ // nothing, which is the worst way for this to fail. A live.* object with no
209
+ // initial value loads at the BOTTOM of its range, and for a filter cutoff the
210
+ // bottom of the range is a device that eats the signal on load.
211
+ parameter_initial_enable: 1,
212
+ parameter_initial: [initialValue(spec)],
213
+ };
214
+
215
+ if (spec.kind === "dial") {
216
+ const [min, max] = spec.range;
217
+ attrs.parameter_mmin = min;
218
+ attrs.parameter_mmax = max;
219
+ Object.assign(attrs, unitAttrs(spec));
220
+ // `parameter_exponent` bends the knob's travel: > 1 gives the bottom of the
221
+ // range more of the sweep, which is what a frequency or a time wants, because
222
+ // hearing is logarithmic and a linear sweep spends its travel where nothing
223
+ // happens. The VALUE is unaffected - only how the dial's rotation maps onto it.
224
+ if (spec.exponent !== undefined && spec.exponent !== 1) attrs.parameter_exponent = spec.exponent;
225
+ // `parameter_steps` quantises a continuous range into N settings.
226
+ if (spec.steps !== undefined) attrs.parameter_steps = spec.steps;
227
+ }
228
+
229
+ if (spec.kind === "toggle") {
230
+ attrs.parameter_mmax = 1;
231
+ attrs.parameter_enum = ["off", "on"];
232
+ }
233
+
234
+ if (spec.kind === "menu") {
235
+ attrs.parameter_enum = [...spec.options];
236
+ attrs.parameter_mmax = spec.options.length - 1;
237
+ }
238
+
239
+ return attrs;
240
+ }
241
+
242
+ /**
243
+ * Compile the Surface into the patcher.
244
+ *
245
+ * Runs AFTER the chains, and claims what they did not want: the app's `set_<id>`
246
+ * messages are picked off the stream, and everything else carries on to the
247
+ * wrapper. Doing it last means no chain has to know the Surface exists.
248
+ */
249
+ export function applySurface(ctx) {
250
+ const { boxes, lines, surface, jwebId } = ctx;
251
+ if (!surface || surface.ids.length === 0) return;
252
+
253
+ let x = 480;
254
+ for (const id of surface.ids) {
255
+ const spec = surface.params[id];
256
+ boxes.push({
257
+ box: {
258
+ id: paramObject(id),
259
+ maxclass: MAXCLASS[spec.kind],
260
+ numinlets: 1,
261
+ numoutlets: 1,
262
+ outlettype: [""],
263
+ parameter_enable: 1,
264
+ patching_rect: [x, 300, 44, 48],
265
+ saved_attribute_attributes: { valueof: parameterAttrs(id, spec) },
266
+ },
267
+ });
268
+ // Read direction: a knob turn reaches the app as `<id> <value>`. A parameter
269
+ // is just another inlet message.
270
+ boxes.push(box(`obj-prepend-${id}`, `prepend ${id}`));
271
+ lines.push(line(paramObject(id), 0, `obj-prepend-${id}`, 0));
272
+ lines.push(line(`obj-prepend-${id}`, 0, jwebId, 0));
273
+ x += 56;
274
+ }
275
+
276
+ // Write direction: one route for every `set_<id>` the app can send. It goes at
277
+ // the END of the chain of routes (see claimAppMessages), so a chain that already
278
+ // took [jweb]'s outlet keeps it and hands us what it did not match.
279
+ const selectors = surface.ids.map((id) => `set_${id}`);
280
+ boxes.push(
281
+ box(SURFACE_ROUTE, `route ${selectors.join(" ")}`, {
282
+ numoutlets: surface.ids.length + 1,
283
+ outlettype: surface.ids.map(() => "").concat(""),
284
+ }),
285
+ );
286
+ claimAppMessages(ctx, SURFACE_ROUTE, surface.ids.length);
287
+
288
+ surface.ids.forEach((id, i) => {
289
+ // `route` STRIPS the selector, so what emerges is the bare value. Re-wrap it as
290
+ // `set <value>` - the set-WITHOUT-output message - so the object, the automation
291
+ // lane and Push all follow the app's control without echoing back at it.
292
+ boxes.push(box(`obj-set-${id}`, "prepend set"));
293
+ lines.push(line(SURFACE_ROUTE, i, `obj-set-${id}`, 0));
294
+ lines.push(line(`obj-set-${id}`, 0, paramObject(id), 0));
295
+ });
296
+ }
@@ -19,7 +19,7 @@ track.
19
19
  | `src/app/{{name}}/App.tsx` | The UI, and the device's logic. A React app. |
20
20
  | `src/app/{{name}}/protocol.ts` | Every selector crossing the bridge. Both sides read it. |
21
21
  | `src/app/{{name}}/surface.ts` | The Live parameters (automatable, MIDI-mappable, visible to Push). |
22
- | `patcher/devices.mjs` | The manifest: name, type, chains, parameters. The patcher is generated from it. |
22
+ | `patcher/devices.mjs` | The manifest: name, type, chains. The patcher is generated from it. |
23
23
 
24
24
  `src/app/shared/` and `scripts/` are infrastructure. You should rarely need to
25
25
  touch them.
@@ -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.3.0",
21
- "@m4l-jweb/surface": "^0.3.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.3.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,19 +15,28 @@
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.
22
- * parameters real Live parameters: automatable, MIDI-mappable, and what Push
23
- * reads. Each becomes a live.* object, and reaches the app as
24
- * `<id> <value>`.
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.
25
24
  *
26
- * Set `default`. Without it the object loads at the BOTTOM of its
27
- * range, which for many parameters is a broken device.
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.)
28
32
  * unmatchedTo where messages the chains did not consume go. "js" sends them to
29
33
  * the wrapper (ui_ready, ...).
30
34
  *
35
+ * Parameters are NOT here: they are declared in src/app/<ui>/surface.ts, and the
36
+ * build generates the live.* objects and their wiring from that one declaration.
37
+ * A chain that names a parameter (`lowpass` wants `cutoff`) fails the build if the
38
+ * surface does not declare it.
39
+ *
31
40
  * Add a second device by adding an entry here and a folder at src/app/<name>/.
32
41
  */
33
42
  export default [
@@ -35,7 +44,6 @@ export default [
35
44
  name: "{{name}}",
36
45
  type: "midi",
37
46
  chains: ["midiin", "midiout"],
38
- parameters: [{ id: "density", object: "live.dial", range: [0, 1], default: 0.5 }],
39
47
  unmatchedTo: "js",
40
48
  },
41
49
  ];
@@ -4,10 +4,10 @@
4
4
  * Push reads Live parameters, not your UI, so anything musically meaningful has
5
5
  * to exist here as well as in the app.
6
6
  *
7
- * Not wired up yet: this declaration is typechecked and validated, but the
8
- * codegen that turns it into live.* objects is not built. For now the parameters
9
- * Live sees come from `parameters` in patcher/devices.mjs, and the two have to
10
- * be kept in step by hand.
7
+ * This is the ONLY place they are declared. The build imports this file and
8
+ * generates the live.* objects from it, wired in both directions: a knob turn
9
+ * reaches the app as `<id> <value>`, and the app writes the parameter back with
10
+ * `set_<id> <value>` - which moves the dial, the automation lane and Push.
11
11
  */
12
12
  import { defineSurface, dial } from "@m4l-jweb/surface";
13
13
 
@@ -27,11 +27,32 @@ import App from "@device/App";
27
27
  */
28
28
  const DevHarness = import.meta.env.DEV ? (await import("@m4l-jweb/surface/dev")).DevHarness : null;
29
29
 
30
+ /**
31
+ * The device's parameter surface, for the harness to render - the same
32
+ * declaration the Max objects are generated from, so the panel and the Push
33
+ * preview cannot drift from what Live will show.
34
+ *
35
+ * A GLOB rather than `import "@device/surface"`, because surface.ts is OPTIONAL:
36
+ * a device with no parameters has no such file, and a static import of a missing
37
+ * module is a build error, not an undefined.
38
+ *
39
+ * The glob sits INSIDE the `import.meta.env.DEV` branch, and that placement is
40
+ * load-bearing. A glob resolves to every match, so hoisting it to a `const` would
41
+ * put EVERY device's declaration in EVERY bundle - one device shipping its
42
+ * siblings' parameters - and the single-file build inlines dynamic chunks, so
43
+ * being lazy is not enough on its own. Written here, the whole expression is dead
44
+ * code once DEV is replaced by `false`, and rollup drops all of it.
45
+ * `tests/bundle.test.mjs` asserts a device carries no sibling's parameters.
46
+ */
47
+ const surface = import.meta.env.DEV
48
+ ? (((await import.meta.glob("./app/*/surface.ts", { import: "default" })[`./app/${__DEVICE__}/surface.ts`]?.()) as never) ?? null)
49
+ : null;
50
+
30
51
  createRoot(document.getElementById("root")!).render(
31
52
  <StrictMode>
32
53
  {DevHarness ? (
33
54
  <div className="dev-layout">
34
- <DevHarness />
55
+ <DevHarness surface={surface} />
35
56
  <App />
36
57
  </div>
37
58
  ) : (
@@ -3,6 +3,11 @@
3
3
  // Injected by vite's `define` (see vite.config.ts) - the UI's own build stamp.
4
4
  declare const __APP_VERSION__: string;
5
5
 
6
+ // Injected by vite's `define` - which device this bundle IS. Used to pick the
7
+ // device's surface.ts out of a glob, since a static import cannot name a file
8
+ // that some devices do not have.
9
+ declare const __DEVICE__: string;
10
+
6
11
  declare module "*?worker&inline" {
7
12
  const workerConstructor: new () => Worker;
8
13
  export default workerConstructor;