@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 +4 -2
- package/src/chains.mjs +292 -207
- package/src/index.mjs +84 -27
- package/src/surface.mjs +296 -0
- package/templates/starter/README.md +1 -1
- package/templates/starter/package.json +3 -3
- package/templates/starter/patcher/devices.mjs +17 -9
- package/templates/starter/src/app/{{name}}/surface.ts +4 -4
- package/templates/starter/src/main.tsx +22 -1
- package/templates/starter/src/vite-env.d.ts +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m4l-jweb/build",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "m4l-jweb: the CLI that builds and packages a device repo into installable Max for Live devices.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -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.
|
|
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
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* A chain is a small function that claims a STAGE and hands the rest on. There
|
|
9
|
+
* are two streams to claim a stage in, and both work the same way:
|
|
10
|
+
*
|
|
11
|
+
* the app's messages `claimAppMessages(ctx, myRoute, unmatchedOutlet)`
|
|
12
|
+
* the signal path `ctx.audioIn(ch)` / `ctx.setAudioOut(ch, id, outlet)`
|
|
13
|
+
*
|
|
14
|
+
* A chain never OWNS either stream. It takes what the stage before it left, and
|
|
15
|
+
* says what it leaves for the stage after it - which is what makes
|
|
16
|
+
* `chains: ["lowpass", "gain"]` a series rather than two devices fighting over one
|
|
17
|
+
* patcher. Add your own with `registerChain()`; keep them small and named after
|
|
18
|
+
* what they do.
|
|
11
19
|
*/
|
|
12
20
|
|
|
13
21
|
let y = 300; // stack generated objects below the hand-made ones
|
|
@@ -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(
|
|
81
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
136
|
-
*
|
|
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(
|
|
139
|
-
//
|
|
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:
|
|
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
|
|
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
|
|
160
|
-
*
|
|
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
|
|
314
|
+
* Requires a parameter named `gain` in the device's surface.ts (or pass
|
|
315
|
+
* `device.gainParam`).
|
|
163
316
|
*/
|
|
164
|
-
function gainChain(
|
|
165
|
-
|
|
166
|
-
|
|
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 [
|
|
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(
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
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
|
|
236
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
* `
|
|
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
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
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
|
-
|
|
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 [
|
|
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(
|
|
337
|
-
|
|
338
|
-
//
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
|
|
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
|
-
*
|
|
360
|
-
*
|
|
361
|
-
*
|
|
362
|
-
*
|
|
363
|
-
*
|
|
364
|
-
*
|
|
365
|
-
*
|
|
366
|
-
*
|
|
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,
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/surface.mjs
ADDED
|
@@ -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
|
|
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.
|
|
21
|
-
"@m4l-jweb/surface": "^0.
|
|
20
|
+
"@m4l-jweb/bridge": "^0.5.0",
|
|
21
|
+
"@m4l-jweb/surface": "^0.5.0",
|
|
22
22
|
"react": "^19.0.0",
|
|
23
23
|
"react-dom": "^19.0.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
|
-
"@m4l-jweb/build": "^0.
|
|
26
|
+
"@m4l-jweb/build": "^0.5.0",
|
|
27
27
|
"@types/node": "^22.0.0",
|
|
28
28
|
"@types/react": "^19.0.0",
|
|
29
29
|
"@types/react-dom": "^19.0.0",
|
|
@@ -15,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"
|
|
18
|
+
* "lowpass" onepole~ in the signal path, with a `cutoff`
|
|
19
19
|
* parameter. An audio effect you can hear.
|
|
20
|
-
* "
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
-
*
|
|
27
|
-
*
|
|
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
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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;
|