@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/LICENSE +21 -0
- package/package.json +6 -4
- package/src/amxd.mjs +93 -93
- package/src/chains.mjs +338 -119
- package/src/index.mjs +246 -228
- package/src/init.mjs +38 -33
- package/templates/starter/.prettierrc +5 -0
- package/templates/starter/README.md +38 -12
- package/templates/starter/index.html +1 -1
- package/templates/starter/package.json +40 -34
- package/templates/starter/patcher/devices.mjs +33 -24
- package/templates/starter/scripts/build-ui.mjs +19 -0
- package/templates/starter/scripts/dev.mjs +24 -0
- package/templates/starter/scripts/devices.d.mts +24 -0
- package/templates/starter/scripts/devices.mjs +35 -0
- package/templates/starter/src/app/shared/Frame.tsx +51 -0
- package/templates/starter/src/app/shared/device.ts +79 -0
- package/templates/starter/src/app/{worker.ts → shared/worker.ts} +8 -8
- package/templates/starter/src/app/{{name}}/App.tsx +62 -0
- package/templates/starter/src/app/{{name}}/protocol.ts +32 -0
- package/templates/starter/src/app/{{name}}/surface.ts +26 -0
- package/templates/starter/src/index.css +117 -56
- package/templates/starter/src/main.tsx +35 -4
- package/templates/starter/src/vite-env.d.ts +2 -2
- package/templates/starter/tsconfig.app.json +26 -22
- package/templates/starter/tsconfig.node.json +1 -1
- package/templates/starter/vite.config.ts +61 -28
- package/templates/starter/vitest.config.ts +1 -1
- package/templates/starter/src/app/App.tsx +0 -87
- package/templates/starter/src/app/protocol.ts +0 -32
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
|
-
|
|
17
|
+
y = 300;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
export const box = (id, text, extra = {}) => ({
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
}
|