@m4l-jweb/build 0.2.0 → 0.4.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 +108 -169
- package/src/index.mjs +35 -5
- 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 +5 -7
- package/templates/starter/src/app/shared/Frame.tsx +18 -8
- package/templates/starter/src/app/{{name}}/surface.ts +4 -4
- package/templates/starter/src/index.css +17 -0
- 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.4.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.4.0"
|
|
35
37
|
}
|
|
36
38
|
}
|
package/src/chains.mjs
CHANGED
|
@@ -52,6 +52,50 @@ export function removeLine(lines, srcId, dstId) {
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Splice a `route` into the app's message stream and hand the rest on.
|
|
57
|
+
*
|
|
58
|
+
* More than one thing routes [jweb]'s output - `midiout` claims `midinote` and
|
|
59
|
+
* `flush`, the Surface claims every `set_<id>`, and whatever neither wanted must
|
|
60
|
+
* still reach the wrapper. They cannot hang off [jweb]'s outlet in parallel: each
|
|
61
|
+
* would pass the unmatched messages on to [js], and the wrapper would see
|
|
62
|
+
* `ui_ready` once per route. So they are chained in SERIES, each feeding the next
|
|
63
|
+
* from its unmatched outlet:
|
|
64
|
+
*
|
|
65
|
+
* [jweb] -> [route midinote flush] -> [route set_density] -> [js]
|
|
66
|
+
* unmatched unmatched
|
|
67
|
+
*
|
|
68
|
+
* `ctx.appOut` names the tail of that chain - the outlet currently carrying
|
|
69
|
+
* everything nobody has claimed. Claim from THAT, never from `jwebId` directly,
|
|
70
|
+
* or you steal the messages the chain before you was passing on.
|
|
71
|
+
*
|
|
72
|
+
* Do not go looking for the cord to cut by searching for whatever feeds [js]
|
|
73
|
+
* either: `live.thisdevice` feeds it too, and cutting that one is invisible here
|
|
74
|
+
* and fatal in Live - it is the bang every LiveAPI observer is created from.
|
|
75
|
+
*/
|
|
76
|
+
export function claimAppMessages(ctx, routeId, unmatchedOutlet) {
|
|
77
|
+
const [srcId, srcOutlet] = ctx.appOut ?? [ctx.jwebId, 0];
|
|
78
|
+
|
|
79
|
+
// Nobody has claimed the stream yet, and yet [jweb] no longer reaches the
|
|
80
|
+
// wrapper: a chain cut that cord by hand (the old `removeLine(jwebId,
|
|
81
|
+
// unmatchedId)` idiom) without saying where it put the messages. We cannot know
|
|
82
|
+
// - and guessing produces a patcher that WORKS while delivering every unrouted
|
|
83
|
+
// message twice, which is not a failure anyone would look for. Say so instead.
|
|
84
|
+
if (!ctx.appOut && !ctx.lines.some((l) => l.patchline.source[0] === ctx.jwebId && l.patchline.destination[0] === ctx.unmatchedId)) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
`a chain on device "${ctx.device?.name}" took [jweb]'s outlet without claimAppMessages(). ` +
|
|
87
|
+
`Routes are chained in series, so each one must hand the next what it did not match. ` +
|
|
88
|
+
`Replace "removeLine(lines, jwebId, unmatchedId); lines.push(line(jwebId, 0, myRoute, 0)); ` +
|
|
89
|
+
`lines.push(line(myRoute, <last>, unmatchedId, 0));" with "claimAppMessages(ctx, myRoute, <last>)".`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
removeLine(ctx.lines, srcId, ctx.unmatchedId);
|
|
94
|
+
ctx.lines.push(line(srcId, srcOutlet, routeId, 0));
|
|
95
|
+
ctx.lines.push(line(routeId, unmatchedOutlet, ctx.unmatchedId, 0));
|
|
96
|
+
ctx.appOut = [routeId, unmatchedOutlet];
|
|
97
|
+
}
|
|
98
|
+
|
|
55
99
|
/**
|
|
56
100
|
* "midiin" - feed incoming MIDI notes to the app as `notein <pitch> <velocity>`.
|
|
57
101
|
*
|
|
@@ -77,10 +121,8 @@ function midiInChain({ boxes, lines, jwebId }) {
|
|
|
77
121
|
* "midiout" - the app emits `midinote <pitch> <vel> <durMs> <chan> <delayMs>`
|
|
78
122
|
* and `flush`. Compute WHEN in your app; let Max place the note precisely.
|
|
79
123
|
*/
|
|
80
|
-
function midiOutChain(
|
|
81
|
-
|
|
82
|
-
// is replaced by the route's unmatched outlet.
|
|
83
|
-
removeLine(lines, jwebId, unmatchedId);
|
|
124
|
+
function midiOutChain(ctx) {
|
|
125
|
+
const { boxes, lines } = ctx;
|
|
84
126
|
|
|
85
127
|
boxes.push(box("obj-route", "route midinote flush", { numoutlets: 3, outlettype: ["", "", ""] }));
|
|
86
128
|
// Explicit unpack instead of letting pipe spread the list: unpack fires
|
|
@@ -108,7 +150,11 @@ function midiOutChain({ boxes, lines, jwebId, unmatchedId }) {
|
|
|
108
150
|
// makenote actually releases hanging notes.
|
|
109
151
|
boxes.push(box("obj-flushmsg", "flush", { maxclass: "message", numinlets: 2, numoutlets: 1 }));
|
|
110
152
|
|
|
111
|
-
|
|
153
|
+
// Take the app's messages, and pass on what is not a note (ui_ready, set_<id>,
|
|
154
|
+
// ...) from outlet 2 - to the Surface's route if the device has parameters, and
|
|
155
|
+
// to the wrapper in the end.
|
|
156
|
+
claimAppMessages(ctx, "obj-route", 2);
|
|
157
|
+
|
|
112
158
|
lines.push(line("obj-route", 0, "obj-unpack", 0));
|
|
113
159
|
lines.push(line("obj-route", 1, "obj-flushmsg", 0));
|
|
114
160
|
lines.push(line("obj-flushmsg", 0, "obj-makenote", 0));
|
|
@@ -121,8 +167,6 @@ function midiOutChain({ boxes, lines, jwebId, unmatchedId }) {
|
|
|
121
167
|
lines.push(line("obj-makenote", 1, "obj-packnote", 1));
|
|
122
168
|
lines.push(line("obj-packnote", 0, "obj-fmt", 0));
|
|
123
169
|
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
170
|
}
|
|
127
171
|
|
|
128
172
|
/**
|
|
@@ -154,22 +198,20 @@ function passthroughChain({ boxes, lines }) {
|
|
|
154
198
|
* SIGNAL domain, not just the app.
|
|
155
199
|
*
|
|
156
200
|
* 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
|
-
*
|
|
201
|
+
* back. The parameter is wired straight into the `*~` right inlet, in the patcher,
|
|
202
|
+
* so the audio path does not depend on the browser being alive or keeping up. The
|
|
203
|
+
* app gets its own copy of the value purely to DISPLAY it. Audio is Max's job; the
|
|
204
|
+
* UI is a view of it.
|
|
161
205
|
*
|
|
162
|
-
* Requires a parameter named `gain` (or pass
|
|
206
|
+
* Requires a parameter named `gain` in the device's surface.ts (or pass
|
|
207
|
+
* `device.gainParam`).
|
|
163
208
|
*/
|
|
164
|
-
function gainChain(
|
|
209
|
+
function gainChain(ctx) {
|
|
210
|
+
const { boxes, lines, device } = ctx;
|
|
165
211
|
removeBox(boxes, lines, "obj-midiin");
|
|
166
212
|
removeBox(boxes, lines, "obj-midiout");
|
|
167
213
|
|
|
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
|
-
}
|
|
214
|
+
const paramId = requireParam(ctx, "gain", device?.gainParam ?? "gain", "gainParam");
|
|
173
215
|
|
|
174
216
|
boxes.push(box("obj-plugin", "plugin~", { numinlets: 1, numoutlets: 2, outlettype: ["signal", "signal"] }));
|
|
175
217
|
boxes.push(box("obj-plugout", "plugout~", { numinlets: 2, numoutlets: 0 }));
|
|
@@ -183,83 +225,43 @@ function gainChain({ boxes, lines, device }) {
|
|
|
183
225
|
boxes.push(box(id, "*~ 1.", { numinlets: 2, numoutlets: 1, outlettype: ["signal"] }));
|
|
184
226
|
lines.push(line("obj-plugin", i, id, 0));
|
|
185
227
|
lines.push(line(id, 0, "obj-plugout", i));
|
|
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));
|
|
228
|
+
fanParamInto(ctx, paramId, id, 1);
|
|
191
229
|
}
|
|
192
230
|
}
|
|
193
231
|
|
|
194
232
|
/**
|
|
195
|
-
*
|
|
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.
|
|
233
|
+
* Wire a parameter into the thing it controls, from BOTH of its sources:
|
|
218
234
|
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
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.
|
|
235
|
+
* the OBJECT's outlet - a knob turn, an automation lane, a Push encoder.
|
|
236
|
+
* the ROUTE's outlet - the value the app wrote.
|
|
224
237
|
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
238
|
+
* The second is not redundant, and leaving it out is the bug this helper exists to
|
|
239
|
+
* make unrepeatable. The app's write reaches the object as `set <value>`, which
|
|
240
|
+
* updates it WITHOUT producing output - so the object never passes the app's value
|
|
241
|
+
* on, and whatever it drives sits where it was while the UI's slider appears dead.
|
|
242
|
+
* It did exactly that. See packages/build/src/surface.mjs.
|
|
227
243
|
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
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.
|
|
244
|
+
* The boxes named here are created LATER, by applySurface(). A patchline may name a
|
|
245
|
+
* box further down the array: a patcher is a graph, not a script.
|
|
234
246
|
*/
|
|
235
|
-
function
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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));
|
|
247
|
+
function fanParamInto(ctx, paramId, dstId, dstInlet) {
|
|
248
|
+
const [objId, objOut] = ctx.paramObject(paramId);
|
|
249
|
+
const [routeId, routeOut] = ctx.paramValue(paramId);
|
|
250
|
+
ctx.lines.push(line(objId, objOut, dstId, dstInlet));
|
|
251
|
+
ctx.lines.push(line(routeId, routeOut, dstId, dstInlet));
|
|
252
|
+
}
|
|
258
253
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
254
|
+
/** A chain that drives DSP from a parameter is broken without it - say so loudly. */
|
|
255
|
+
function requireParam(ctx, chainName, paramId, overrideField) {
|
|
256
|
+
if (!ctx.surface?.params?.[paramId]) {
|
|
257
|
+
const declared = ctx.surface ? ctx.surface.ids.join(", ") || "none" : "no surface.ts at all";
|
|
258
|
+
throw new Error(
|
|
259
|
+
`chain "${chainName}" on device "${ctx.device?.name}" needs a parameter "${paramId}" in ` +
|
|
260
|
+
`src/app/${ctx.device?.ui ?? ctx.device?.name}/surface.ts (declared: ${declared}). ` +
|
|
261
|
+
`Rename it, or point the chain at another one with \`${overrideField}\`.`,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
return paramId;
|
|
263
265
|
}
|
|
264
266
|
|
|
265
267
|
/**
|
|
@@ -279,53 +281,26 @@ function writableParams({ boxes, lines, jwebId, unmatchedId }, ids) {
|
|
|
279
281
|
* effect is unmistakable when you sweep it, and there is no way to configure it
|
|
280
282
|
* into silence or into a scream. Swap in `svf~` when you want a real filter.
|
|
281
283
|
*
|
|
282
|
-
* THE CUTOFF
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
*
|
|
286
|
-
*
|
|
284
|
+
* THE CUTOFF IS IN HERTZ, and no arithmetic happens here. The chain used to take a
|
|
285
|
+
* 0-1 parameter and map it through `[expr 40. * pow(450., $f1)]`, because pitch is
|
|
286
|
+
* logarithmic and a linear knob is useless on a filter. That mapping now lives on
|
|
287
|
+
* the PARAMETER (`range: [40, 18000]`, `unit: "Hz"`, `exponent`), which is where
|
|
288
|
+
* Live wants it: the automation lane reads Hz, Push reads "7.3 kHz", the app reads
|
|
289
|
+
* Hz, and the value drops straight into onepole~. A normalised parameter with the
|
|
290
|
+
* curve hidden in a chain lies to every one of those readouts.
|
|
287
291
|
*
|
|
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.
|
|
292
|
-
*
|
|
293
|
-
* Requires a parameter named `cutoff` (or pass `device.cutoffParam`).
|
|
292
|
+
* Requires a parameter named `cutoff` (or pass `device.cutoffParam`), in Hz.
|
|
294
293
|
*/
|
|
295
294
|
function lowpassChain(ctx) {
|
|
296
295
|
const { boxes, lines, device } = ctx;
|
|
297
296
|
removeBox(boxes, lines, "obj-midiin");
|
|
298
297
|
removeBox(boxes, lines, "obj-midiout");
|
|
299
298
|
|
|
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);
|
|
299
|
+
const paramId = requireParam(ctx, "lowpass", device?.cutoffParam ?? "cutoff", "cutoffParam");
|
|
309
300
|
|
|
310
301
|
boxes.push(box("obj-plugin", "plugin~", { numinlets: 1, numoutlets: 2, outlettype: ["signal", "signal"] }));
|
|
311
302
|
boxes.push(box("obj-plugout", "plugout~", { numinlets: 2, numoutlets: 0 }));
|
|
312
303
|
|
|
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
304
|
// One filter per channel: a signal object handles ONE signal, and plugin~ hands
|
|
330
305
|
// us a stereo pair. Both take the same cutoff, so the image does not shift.
|
|
331
306
|
for (const [i, id] of [
|
|
@@ -335,10 +310,10 @@ function lowpassChain(ctx) {
|
|
|
335
310
|
boxes.push(box(id, "onepole~ 18000.", { numinlets: 2, numoutlets: 1, outlettype: ["signal"] }));
|
|
336
311
|
lines.push(line("obj-plugin", i, id, 0));
|
|
337
312
|
lines.push(line(id, 0, "obj-plugout", i));
|
|
338
|
-
//
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
|
|
313
|
+
// The cutoff, in Hz, into the RIGHT inlet - from both of its sources: the dial
|
|
314
|
+
// (a knob turn, an automation lane, a Push encoder) and the route (the app's
|
|
315
|
+
// write, which the dial will not re-emit because it arrives as `set`).
|
|
316
|
+
fanParamInto(ctx, paramId, id, 1);
|
|
342
317
|
}
|
|
343
318
|
}
|
|
344
319
|
|
|
@@ -356,48 +331,12 @@ export function registerChain(name, fn) {
|
|
|
356
331
|
}
|
|
357
332
|
|
|
358
333
|
/**
|
|
359
|
-
*
|
|
360
|
-
*
|
|
361
|
-
*
|
|
334
|
+
* Parameters used to be declared in the manifest and generated here, by
|
|
335
|
+
* `addParameters()`, in ONE direction: object -> app. Writing one back was a
|
|
336
|
+
* per-chain hand-roll (`writableParams()`).
|
|
362
337
|
*
|
|
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".
|
|
338
|
+
* Both are gone. A device's parameters are declared in `src/app/<ui>/surface.ts`
|
|
339
|
+
* and compiled by `applySurface()` in surface.mjs - objects, both directions, and
|
|
340
|
+
* the fan-out that the `set` behaviour forces. A chain reaches them through
|
|
341
|
+
* `fanParamInto()` above.
|
|
369
342
|
*/
|
|
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, 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)), "..");
|
|
@@ -164,17 +165,46 @@ export async function generatePatchers(root) {
|
|
|
164
165
|
|
|
165
166
|
const unmatchedId = d.unmatchedTo === "js" ? "obj-js" : (d.unmatchedTo ?? "obj-js");
|
|
166
167
|
|
|
168
|
+
/**
|
|
169
|
+
* The device's parameters, declared once in src/app/<ui>/surface.ts. A chain
|
|
170
|
+
* that drives DSP from a parameter needs two things from it, and needs BOTH:
|
|
171
|
+
*
|
|
172
|
+
* paramObject(id) the live.* object's outlet - a knob turn, an automation
|
|
173
|
+
* lane, a Push encoder.
|
|
174
|
+
* paramValue(id) the route outlet carrying what the APP wrote. Not
|
|
175
|
+
* redundant: the app's write reaches the object as `set`,
|
|
176
|
+
* which updates it WITHOUT output, so the object would
|
|
177
|
+
* never pass that value on. See surface.mjs.
|
|
178
|
+
*/
|
|
179
|
+
// The manifest carried `parameters` until 0.4.0. It is now declared in
|
|
180
|
+
// src/app/<ui>/surface.ts and generated from there - so a leftover field is not
|
|
181
|
+
// a harmless extra key, it is a device whose parameters have SILENTLY
|
|
182
|
+
// disappeared. Fail the build and say where they went.
|
|
183
|
+
if (d.parameters) {
|
|
184
|
+
throw new Error(
|
|
185
|
+
`device "${d.name}" still declares \`parameters\` in patcher/devices.mjs. ` +
|
|
186
|
+
`That field is gone: declare them in src/app/${d.ui ?? d.name}/surface.ts with defineSurface(), ` +
|
|
187
|
+
`which generates the live.* objects, both wiring directions and the protocol selectors. ` +
|
|
188
|
+
`See doc/SURFACE.md.`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const surface = await loadSurface(root, d.ui ?? d.name);
|
|
193
|
+
const ctx = { boxes, lines, jwebId: "obj-jweb", unmatchedId, device: d, ...surfaceContext(surface) };
|
|
194
|
+
|
|
167
195
|
for (const name of d.chains ?? []) {
|
|
168
196
|
const chain = CHAINS[name];
|
|
169
197
|
if (!chain) throw new Error(`unknown chain "${name}" for device "${d.name}" (known: ${Object.keys(CHAINS).join(", ")})`);
|
|
170
|
-
chain(
|
|
198
|
+
chain(ctx);
|
|
171
199
|
}
|
|
172
200
|
|
|
173
|
-
//
|
|
174
|
-
|
|
201
|
+
// AFTER the chains: the Surface routes every `set_<id>` off the app's message
|
|
202
|
+
// stream, and passes on what nobody claimed (ui_ready, ...) to the wrapper.
|
|
203
|
+
applySurface(ctx);
|
|
175
204
|
|
|
176
205
|
writeFileSync(path.join(outDir, `${d.name}.json`), JSON.stringify(p, null, "\t"));
|
|
177
|
-
|
|
206
|
+
const params = surface ? surface.ids.join(", ") : "none";
|
|
207
|
+
console.log(`m4l-jweb: ${d.name}.json (${d.type}, chains: ${(d.chains ?? []).join(", ") || "none"}, params: ${params || "none"})`);
|
|
178
208
|
}
|
|
179
209
|
return devices;
|
|
180
210
|
}
|
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.4.0",
|
|
21
|
+
"@m4l-jweb/surface": "^0.4.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.4.0",
|
|
27
27
|
"@types/node": "^22.0.0",
|
|
28
28
|
"@types/react": "^19.0.0",
|
|
29
29
|
"@types/react-dom": "^19.0.0",
|
|
@@ -19,15 +19,14 @@
|
|
|
19
19
|
* parameter. An audio effect you can hear.
|
|
20
20
|
* "gain" plugin~ -> *~ -> plugout~, with a `gain` parameter.
|
|
21
21
|
* "passthrough" plugin~ -> plugout~. Does nothing to the audio.
|
|
22
|
-
* parameters real Live parameters: automatable, MIDI-mappable, and what Push
|
|
23
|
-
* reads. Each becomes a live.* object, and reaches the app as
|
|
24
|
-
* `<id> <value>`.
|
|
25
|
-
*
|
|
26
|
-
* Set `default`. Without it the object loads at the BOTTOM of its
|
|
27
|
-
* range, which for many parameters is a broken device.
|
|
28
22
|
* unmatchedTo where messages the chains did not consume go. "js" sends them to
|
|
29
23
|
* the wrapper (ui_ready, ...).
|
|
30
24
|
*
|
|
25
|
+
* Parameters are NOT here: they are declared in src/app/<ui>/surface.ts, and the
|
|
26
|
+
* build generates the live.* objects and their wiring from that one declaration.
|
|
27
|
+
* A chain that names a parameter (`lowpass` wants `cutoff`) fails the build if the
|
|
28
|
+
* surface does not declare it.
|
|
29
|
+
*
|
|
31
30
|
* Add a second device by adding an entry here and a folder at src/app/<name>/.
|
|
32
31
|
*/
|
|
33
32
|
export default [
|
|
@@ -35,7 +34,6 @@ export default [
|
|
|
35
34
|
name: "{{name}}",
|
|
36
35
|
type: "midi",
|
|
37
36
|
chains: ["midiin", "midiout"],
|
|
38
|
-
parameters: [{ id: "density", object: "live.dial", range: [0, 1], default: 0.5 }],
|
|
39
37
|
unmatchedTo: "js",
|
|
40
38
|
},
|
|
41
39
|
];
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* shared/Frame.tsx - the chrome every device wears: a title, a where-am-I badge,
|
|
3
|
-
* and
|
|
3
|
+
* and the two build stamps.
|
|
4
4
|
*
|
|
5
|
-
* The
|
|
5
|
+
* The stamps are not decoration. Live embeds a COPY of a device into the set, so
|
|
6
6
|
* reinstalling does not update instances already on tracks - and a stale device
|
|
7
7
|
* behaves like a bug in code you have already fixed. The stamps make that
|
|
8
8
|
* visible instead of mysterious.
|
|
9
|
+
*
|
|
10
|
+
* They live in the HEADER, top right, and that placement is load-bearing: the
|
|
11
|
+
* device view is a fixed ~169 px and overgrown UI clips silently at the BOTTOM.
|
|
12
|
+
* A stamp in a footer is a staleness check that disappears exactly when the
|
|
13
|
+
* device has grown enough to be worth checking. Anchored to the header, it
|
|
14
|
+
* survives whatever the device does below it.
|
|
9
15
|
*/
|
|
10
16
|
import type { ReactNode } from "react";
|
|
11
17
|
import { inJweb } from "@m4l-jweb/bridge";
|
|
@@ -19,15 +25,19 @@ export function Frame({ title, device, children }: { title: string; device: Devi
|
|
|
19
25
|
<header>
|
|
20
26
|
<h1>{title}</h1>
|
|
21
27
|
<span className={`badge ${inJweb ? "live" : "dev"}`}>{inJweb ? "in Max" : "browser dev"}</span>
|
|
28
|
+
<span className="stamp" title={`ui ${__APP_VERSION__} / wrapper ${device.build ?? "-"}`}>
|
|
29
|
+
{device.stale ? (
|
|
30
|
+
<span className="warn">stale - delete and re-drag the device</span>
|
|
31
|
+
) : (
|
|
32
|
+
<>
|
|
33
|
+
<span>ui {__APP_VERSION__}</span>
|
|
34
|
+
<span>wrapper {device.build ?? "-"}</span>
|
|
35
|
+
</>
|
|
36
|
+
)}
|
|
37
|
+
</span>
|
|
22
38
|
</header>
|
|
23
39
|
|
|
24
40
|
<dl>{children}</dl>
|
|
25
|
-
|
|
26
|
-
<footer>
|
|
27
|
-
<span>ui {__APP_VERSION__}</span>
|
|
28
|
-
<span>wrapper {device.build ?? "-"}</span>
|
|
29
|
-
{device.stale && <span className="warn">stale install - delete and re-drag the device</span>}
|
|
30
|
-
</footer>
|
|
31
41
|
</main>
|
|
32
42
|
);
|
|
33
43
|
}
|
|
@@ -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
|
|
|
@@ -87,6 +87,23 @@ h1 {
|
|
|
87
87
|
color: var(--fg);
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/* The build stamps, top right. Deliberately NOT in a footer: the device view is
|
|
91
|
+
a fixed ~169 px and clips at the bottom, so a footer stamp vanishes exactly
|
|
92
|
+
when the UI has grown enough for staleness to be worth checking. The stamps
|
|
93
|
+
are long (version + ISO timestamp), so they ellipsize - the full text is in
|
|
94
|
+
the title attribute, and the hover works in Max's jweb. */
|
|
95
|
+
.stamp {
|
|
96
|
+
color: var(--muted);
|
|
97
|
+
display: flex;
|
|
98
|
+
font-size: 9px;
|
|
99
|
+
gap: 8px;
|
|
100
|
+
margin-left: auto;
|
|
101
|
+
max-width: 45%;
|
|
102
|
+
overflow: hidden;
|
|
103
|
+
text-overflow: ellipsis;
|
|
104
|
+
white-space: nowrap;
|
|
105
|
+
}
|
|
106
|
+
|
|
90
107
|
dl {
|
|
91
108
|
column-gap: 10px;
|
|
92
109
|
display: grid;
|
|
@@ -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;
|