@m4l-jweb/build 0.1.0 → 0.2.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/src/chains.mjs CHANGED
@@ -14,42 +14,42 @@ let y = 300; // stack generated objects below the hand-made ones
14
14
 
15
15
  /** Reset the layout cursor. The build calls this once per device. */
16
16
  export function resetLayout() {
17
- y = 300;
17
+ y = 300;
18
18
  }
19
19
 
20
20
  export const box = (id, text, extra = {}) => ({
21
- box: {
22
- id,
23
- maxclass: "newobj",
24
- text,
25
- numinlets: 1,
26
- numoutlets: 1,
27
- outlettype: [""],
28
- patching_rect: [16, (y += 32), 220, 20],
29
- ...extra,
30
- },
21
+ box: {
22
+ id,
23
+ maxclass: "newobj",
24
+ text,
25
+ numinlets: 1,
26
+ numoutlets: 1,
27
+ outlettype: [""],
28
+ patching_rect: [16, (y += 32), 220, 20],
29
+ ...extra,
30
+ },
31
31
  });
32
32
 
33
33
  export const line = (srcId, srcOut, dstId, dstIn) => ({
34
- patchline: { source: [srcId, srcOut], destination: [dstId, dstIn] },
34
+ patchline: { source: [srcId, srcOut], destination: [dstId, dstIn] },
35
35
  });
36
36
 
37
37
  /** Drop every cord touching a box, then the box. */
38
38
  export function removeBox(boxes, lines, id) {
39
- const i = boxes.findIndex((b) => b.box.id === id);
40
- if (i >= 0) boxes.splice(i, 1);
41
- for (let j = lines.length - 1; j >= 0; j--) {
42
- const pl = lines[j].patchline;
43
- if (pl.source[0] === id || pl.destination[0] === id) lines.splice(j, 1);
44
- }
39
+ const i = boxes.findIndex((b) => b.box.id === id);
40
+ if (i >= 0) boxes.splice(i, 1);
41
+ for (let j = lines.length - 1; j >= 0; j--) {
42
+ const pl = lines[j].patchline;
43
+ if (pl.source[0] === id || pl.destination[0] === id) lines.splice(j, 1);
44
+ }
45
45
  }
46
46
 
47
47
  /** Drop a specific cord (used when a chain takes over jweb's output). */
48
48
  export function removeLine(lines, srcId, dstId) {
49
- for (let i = lines.length - 1; i >= 0; i--) {
50
- const pl = lines[i].patchline;
51
- if (pl.source[0] === srcId && pl.destination[0] === dstId) lines.splice(i, 1);
52
- }
49
+ for (let i = lines.length - 1; i >= 0; i--) {
50
+ const pl = lines[i].patchline;
51
+ if (pl.source[0] === srcId && pl.destination[0] === dstId) lines.splice(i, 1);
52
+ }
53
53
  }
54
54
 
55
55
  /**
@@ -59,18 +59,18 @@ export function removeLine(lines, srcId, dstId) {
59
59
  * transforms notes must not also leak the untransformed ones.
60
60
  */
61
61
  function midiInChain({ boxes, lines, jwebId }) {
62
- removeLine(lines, "obj-midiin", "obj-midiout");
63
- boxes.push(
64
- box("obj-midiparse", "midiparse", {
65
- numinlets: 1,
66
- numoutlets: 8,
67
- outlettype: ["list", "list", "int", "int", "int", "list", "int", ""],
68
- }),
69
- );
70
- boxes.push(box("obj-noteinmsg", "prepend notein"));
71
- lines.push(line("obj-midiin", 0, "obj-midiparse", 0));
72
- lines.push(line("obj-midiparse", 0, "obj-noteinmsg", 0)); // outlet 0 = note: pitch, velocity
73
- lines.push(line("obj-noteinmsg", 0, jwebId, 0));
62
+ removeLine(lines, "obj-midiin", "obj-midiout");
63
+ boxes.push(
64
+ box("obj-midiparse", "midiparse", {
65
+ numinlets: 1,
66
+ numoutlets: 8,
67
+ outlettype: ["list", "list", "int", "int", "int", "list", "int", ""],
68
+ }),
69
+ );
70
+ boxes.push(box("obj-noteinmsg", "prepend notein"));
71
+ lines.push(line("obj-midiin", 0, "obj-midiparse", 0));
72
+ lines.push(line("obj-midiparse", 0, "obj-noteinmsg", 0)); // outlet 0 = note: pitch, velocity
73
+ lines.push(line("obj-noteinmsg", 0, jwebId, 0));
74
74
  }
75
75
 
76
76
  /**
@@ -78,107 +78,326 @@ function midiInChain({ boxes, lines, jwebId }) {
78
78
  * and `flush`. Compute WHEN in your app; let Max place the note precisely.
79
79
  */
80
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);
84
-
85
- boxes.push(box("obj-route", "route midinote flush", { numoutlets: 3, outlettype: ["", "", ""] }));
86
- // Explicit unpack instead of letting pipe spread the list: unpack fires
87
- // right-to-left, so the delay (outlet 4) lands in pipe's delay inlet BEFORE
88
- // the pitch (outlet 0) hits the hot inlet.
89
- boxes.push(
90
- box("obj-unpack", "unpack 0 0 0 0 0", {
91
- numinlets: 1,
92
- numoutlets: 5,
93
- outlettype: ["int", "int", "int", "int", "int"],
94
- }),
95
- );
96
- boxes.push(
97
- box("obj-pipe", "pipe 0 0 0 0 0", {
98
- numinlets: 5, // 4 data inlets + delay
99
- numoutlets: 4,
100
- outlettype: ["int", "int", "int", "int"],
101
- }),
102
- );
103
- boxes.push(box("obj-makenote", "makenote 100 250", { numinlets: 3, numoutlets: 2, outlettype: ["int", "int"] }));
104
- boxes.push(box("obj-packnote", "pack 0 0", { numinlets: 2, numoutlets: 1, outlettype: [""] }));
105
- boxes.push(box("obj-fmt", "midiformat", { numinlets: 7, numoutlets: 1, outlettype: ["int"] }));
106
- // `route` STRIPS the selector: a bare "flush" emerges from outlet 1 as a bang,
107
- // which makenote ignores. Re-materialize the word with a message box so
108
- // makenote actually releases hanging notes.
109
- boxes.push(box("obj-flushmsg", "flush", { maxclass: "message", numinlets: 2, numoutlets: 1 }));
110
-
111
- lines.push(line(jwebId, 0, "obj-route", 0));
112
- lines.push(line("obj-route", 0, "obj-unpack", 0));
113
- lines.push(line("obj-route", 1, "obj-flushmsg", 0));
114
- lines.push(line("obj-flushmsg", 0, "obj-makenote", 0));
115
- for (let i = 0; i < 5; i++) lines.push(line("obj-unpack", i, "obj-pipe", i));
116
- lines.push(line("obj-pipe", 0, "obj-makenote", 0)); // pitch
117
- lines.push(line("obj-pipe", 1, "obj-makenote", 1)); // velocity
118
- lines.push(line("obj-pipe", 2, "obj-makenote", 2)); // duration ms
119
- lines.push(line("obj-pipe", 3, "obj-fmt", 6)); // channel
120
- lines.push(line("obj-makenote", 0, "obj-packnote", 0));
121
- lines.push(line("obj-makenote", 1, "obj-packnote", 1));
122
- lines.push(line("obj-packnote", 0, "obj-fmt", 0));
123
- 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));
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);
84
+
85
+ boxes.push(box("obj-route", "route midinote flush", { numoutlets: 3, outlettype: ["", "", ""] }));
86
+ // Explicit unpack instead of letting pipe spread the list: unpack fires
87
+ // right-to-left, so the delay (outlet 4) lands in pipe's delay inlet BEFORE
88
+ // the pitch (outlet 0) hits the hot inlet.
89
+ boxes.push(
90
+ box("obj-unpack", "unpack 0 0 0 0 0", {
91
+ numinlets: 1,
92
+ numoutlets: 5,
93
+ outlettype: ["int", "int", "int", "int", "int"],
94
+ }),
95
+ );
96
+ boxes.push(
97
+ box("obj-pipe", "pipe 0 0 0 0 0", {
98
+ numinlets: 5, // 4 data inlets + delay
99
+ numoutlets: 4,
100
+ outlettype: ["int", "int", "int", "int"],
101
+ }),
102
+ );
103
+ boxes.push(box("obj-makenote", "makenote 100 250", { numinlets: 3, numoutlets: 2, outlettype: ["int", "int"] }));
104
+ boxes.push(box("obj-packnote", "pack 0 0", { numinlets: 2, numoutlets: 1, outlettype: [""] }));
105
+ boxes.push(box("obj-fmt", "midiformat", { numinlets: 7, numoutlets: 1, outlettype: ["int"] }));
106
+ // `route` STRIPS the selector: a bare "flush" emerges from outlet 1 as a bang,
107
+ // which makenote ignores. Re-materialize the word with a message box so
108
+ // makenote actually releases hanging notes.
109
+ boxes.push(box("obj-flushmsg", "flush", { maxclass: "message", numinlets: 2, numoutlets: 1 }));
110
+
111
+ lines.push(line(jwebId, 0, "obj-route", 0));
112
+ lines.push(line("obj-route", 0, "obj-unpack", 0));
113
+ lines.push(line("obj-route", 1, "obj-flushmsg", 0));
114
+ lines.push(line("obj-flushmsg", 0, "obj-makenote", 0));
115
+ for (let i = 0; i < 5; i++) lines.push(line("obj-unpack", i, "obj-pipe", i));
116
+ lines.push(line("obj-pipe", 0, "obj-makenote", 0)); // pitch
117
+ lines.push(line("obj-pipe", 1, "obj-makenote", 1)); // velocity
118
+ lines.push(line("obj-pipe", 2, "obj-makenote", 2)); // duration ms
119
+ lines.push(line("obj-pipe", 3, "obj-fmt", 6)); // channel
120
+ lines.push(line("obj-makenote", 0, "obj-packnote", 0));
121
+ lines.push(line("obj-makenote", 1, "obj-packnote", 1));
122
+ lines.push(line("obj-packnote", 0, "obj-fmt", 0));
123
+ 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
126
  }
127
127
 
128
- /** "passthrough" - an audio effect that passes its input through untouched. */
128
+ /**
129
+ * "passthrough" - an audio effect that passes its input through UNTOUCHED.
130
+ *
131
+ * It is a straight wire: `plugin~ -> plugout~`. Removing it from a track sounds
132
+ * identical, because it does nothing to the audio - it exists to prove that an
133
+ * audio-effect container builds and that the UI runs inside one.
134
+ *
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.
137
+ */
129
138
  function passthroughChain({ boxes, lines }) {
130
- // An audio effect has no MIDI ports.
131
- removeBox(boxes, lines, "obj-midiin");
132
- removeBox(boxes, lines, "obj-midiout");
133
- boxes.push(box("obj-plugin", "plugin~", { numinlets: 1, numoutlets: 2, outlettype: ["signal", "signal"] }));
134
- boxes.push(box("obj-plugout", "plugout~", { numinlets: 2, numoutlets: 0 }));
135
- lines.push(line("obj-plugin", 0, "obj-plugout", 0));
136
- lines.push(line("obj-plugin", 1, "obj-plugout", 1));
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));
146
+ }
147
+
148
+ /**
149
+ * "gain" - an audio effect that actually DOES something: `plugin~ -> *~ -> plugout~`,
150
+ * with a Live parameter riding the multiplier. Turn the dial, hear the level move.
151
+ *
152
+ * The smallest honest example of the shape every audio effect has - your DSP goes
153
+ * where the `*~` is - and the smallest proof that a Live parameter reaches the
154
+ * SIGNAL domain, not just the app.
155
+ *
156
+ * 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.
161
+ *
162
+ * Requires a parameter named `gain` (or pass `device.gainParam`).
163
+ */
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 }));
176
+
177
+ // One *~ per channel: a signal object handles ONE signal, and plugin~ hands us
178
+ // a stereo pair. `1.` (a float, not an int) keeps the right inlet in float mode.
179
+ for (const [i, id] of [
180
+ [0, "obj-gain-l"],
181
+ [1, "obj-gain-r"],
182
+ ]) {
183
+ 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));
191
+ }
192
+ }
193
+
194
+ /**
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.
234
+ */
235
+ function writableParams({ boxes, lines, jwebId, unmatchedId }, ids) {
236
+ removeLine(lines, jwebId, unmatchedId);
237
+
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
+ };
263
+ }
264
+
265
+ /**
266
+ * "lowpass" - an audio effect you can actually HEAR: a resonance-free one-pole
267
+ * lowpass with a Live parameter on the cutoff. Sweep it down and the top end
268
+ * goes away. The smallest device in this repo that does something musical.
269
+ *
270
+ * The Cutoff slider lives in the DEVICE WINDOW (the jweb UI) and writes the Live
271
+ * parameter via `set_cutoff` - so moving it moves the dial, the automation lane
272
+ * and the filter together. It is one control, with two faces.
273
+ *
274
+ * `plugin~ -> onepole~ -> plugout~`, one filter per channel.
275
+ *
276
+ * WHY onepole~ and not lores~/svf~/biquad~: a one-pole is a 6 dB/octave slope -
277
+ * the gentlest filter there is. It cannot self-oscillate, cannot blow up, and
278
+ * has no resonance to set. That makes it the honest choice for a demo: the
279
+ * effect is unmistakable when you sweep it, and there is no way to configure it
280
+ * into silence or into a scream. Swap in `svf~` when you want a real filter.
281
+ *
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.
292
+ *
293
+ * Requires a parameter named `cutoff` (or pass `device.cutoffParam`).
294
+ */
295
+ function lowpassChain(ctx) {
296
+ 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));
328
+
329
+ // One filter per channel: a signal object handles ONE signal, and plugin~ hands
330
+ // us a stereo pair. Both take the same cutoff, so the image does not shift.
331
+ for (const [i, id] of [
332
+ [0, "obj-lpf-l"],
333
+ [1, "obj-lpf-r"],
334
+ ]) {
335
+ 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));
342
+ }
137
343
  }
138
344
 
139
345
  export const CHAINS = {
140
- midiin: midiInChain,
141
- midiout: midiOutChain,
142
- passthrough: passthroughChain,
346
+ midiin: midiInChain,
347
+ midiout: midiOutChain,
348
+ passthrough: passthroughChain,
349
+ gain: gainChain,
350
+ lowpass: lowpassChain,
143
351
  };
144
352
 
145
353
  /** Add a chain to the vocabulary. Called before generatePatchers(). */
146
354
  export function registerChain(name, fn) {
147
- CHAINS[name] = fn;
355
+ CHAINS[name] = fn;
148
356
  }
149
357
 
150
358
  /**
151
359
  * Real Live parameters: automatable, MIDI-mappable, and the ONLY thing Push can
152
360
  * display. Each becomes a live.* object wired into the UI as `<id> <value>`, so
153
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".
154
369
  */
155
370
  export function addParameters(boxes, lines, params, dstId) {
156
- let x = 480;
157
- for (const p of params) {
158
- const objId = `obj-param-${p.id}`;
159
- const prependId = `obj-prepend-${p.id}`;
160
- boxes.push({
161
- box: {
162
- id: objId,
163
- maxclass: p.object, // live.dial | live.toggle | live.menu
164
- numinlets: 1,
165
- numoutlets: 1,
166
- outlettype: [""],
167
- parameter_enable: 1,
168
- patching_rect: [x, 300, 44, 48],
169
- saved_attribute_attributes: {
170
- valueof: {
171
- parameter_longname: p.id,
172
- parameter_shortname: p.id.slice(0, 8), // Push shows short names
173
- parameter_type: p.object === "live.toggle" ? 2 : 0, // 2 = enum, 0 = float
174
- ...(p.range ? { parameter_range: p.range } : {}),
175
- },
176
- },
177
- },
178
- });
179
- boxes.push(box(prependId, `prepend ${p.id}`));
180
- lines.push(line(objId, 0, prependId, 0));
181
- lines.push(line(prependId, 0, dstId, 0));
182
- x += 56;
183
- }
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
+ }
184
403
  }