@m4l-jweb/build 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m4l-jweb/build",
3
- "version": "0.7.0",
3
+ "version": "0.9.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",
@@ -19,6 +19,7 @@
19
19
  ".": "./src/index.mjs",
20
20
  "./chains": "./src/chains.mjs",
21
21
  "./surface": "./src/surface.mjs",
22
+ "./watch": "./src/watch.mjs",
22
23
  "./amxd": "./src/amxd.mjs",
23
24
  "./init": "./src/init.mjs"
24
25
  },
@@ -33,6 +34,6 @@
33
34
  "archiver": "^7.0.1",
34
35
  "esbuild": "^0.25.0",
35
36
  "typescript": "^5.7.0",
36
- "@m4l-jweb/wrapper": "0.7.0"
37
+ "@m4l-jweb/wrapper": "0.9.0"
37
38
  }
38
39
  }
package/src/chains.mjs CHANGED
@@ -112,6 +112,54 @@ export function claimAppMessages(ctx, routeId, unmatchedOutlet) {
112
112
  export const AUDIO_IN = "obj-plugin";
113
113
  export const AUDIO_OUT = "obj-plugout";
114
114
 
115
+ /**
116
+ * A [buffer~] name that is unique PER DEVICE INSTANCE.
117
+ *
118
+ * THE BUG THIS EXISTS TO KILL. Buffer names are GLOBAL to Max, and they used to be
119
+ * generated from the device name alone (`buf-<device>-<slot>`) and frozen into the
120
+ * patcher at BUILD time. So two copies of one device - a drum rack on two tracks, which
121
+ * is the normal case, not an exotic one - named their buffers identically, and Max gave
122
+ * both to whichever loaded last. One rack's samples silently became the other's. No
123
+ * error, no console line: just the wrong sound.
124
+ *
125
+ * A name minted by the wrapper after load cannot reach a box frozen at build time (a
126
+ * buffer takes its name from its creation argument and there is no documented runtime
127
+ * rename), so the scoping has to be a load-time substitution Max itself performs.
128
+ *
129
+ * `#0` WAS TRIED AND DOES NOT WORK (spike, doc/TODO.md item 0, run 2026-07-17 in
130
+ * Live). `#0` is documented for abstractions, and an .amxd device patcher turned out
131
+ * not to count as one: the token stayed literal in every instance, so writer and
132
+ * reader still agreed on one global name and the collision survived, silently.
133
+ *
134
+ * `---` IS THE MECHANISM BUILT FOR THIS. Max for Live replaces a leading `---` in a
135
+ * name with an id unique to the DEVICE instance - and the scope is the whole device,
136
+ * subpatchers and [poly~] voices included, not one patcher. That kills the `#0`/`#1`
137
+ * hand-off the first attempt needed: the voice spells the SAME name the device does,
138
+ * and no id has to travel through [poly~]'s arguments.
139
+ *
140
+ * OUTSIDE LIVE `---` stays literal (it is a Live-only substitution). Both writer and
141
+ * reader keep agreeing, so a patcher opened in standalone Max degrades to the old
142
+ * shared-name behavior instead of breaking - acceptable, since the devices only
143
+ * meaningfully run in Live.
144
+ */
145
+ export const deviceBufName = (device, slot) => `---buf-${device?.name}-${slot}`;
146
+
147
+ /** The same buffer, as a [poly~] voice spells it: identical - `---` scopes per DEVICE,
148
+ * not per patcher, so the voice shares the expansion with the patcher that loaded it. */
149
+ export const voiceBufName = (device, slot) => deviceBufName(device, slot);
150
+
151
+ /**
152
+ * How long a `remote` slot takes to slide to each new value, in ms.
153
+ *
154
+ * It is a RAMP TIME, not a rate: the app sends values on the transport tick, and each
155
+ * one is ramped to over this long. Exported because the number is only right in
156
+ * relation to the app's tick - it wants to be about one tick, so a ramp is still
157
+ * arriving when the next value lands and the slots join into a continuous line. Much
158
+ * shorter and the stepping it exists to remove comes back as a staircase with flat
159
+ * treads; much longer and the modulation lags visibly behind the pattern.
160
+ */
161
+ export const REMOTE_RAMP_MS = 20;
162
+
115
163
  /** plugin~ hands us a stereo pair, so every stage is a pair of signal objects. */
116
164
  export const AUDIO_CHANNELS = 2;
117
165
 
@@ -470,6 +518,194 @@ function driveChain(ctx) {
470
518
  }
471
519
  }
472
520
 
521
+ /**
522
+ * "hpf" - a high-pass next to `lowpass`, and a WIRE at 0 Hz.
523
+ *
524
+ * dry --+--------------------> [-~] --> out
525
+ * | ^
526
+ * +--> [onepole~] --------+ (the low end, subtracted away)
527
+ *
528
+ * WHY A SUBTRACTION AND NOT A HIGHPASS OBJECT. `onepole~` is lowpass-only, and the
529
+ * one-pole highpass is its exact complement: everything the lowpass keeps is what a
530
+ * highpass throws away, so `dry - lowpass(dry)` IS the highpass, sample for sample.
531
+ * No second filter design, no new object, and the same 6 dB/octave slope, resonance-
532
+ * free and impossible to blow up, that `lowpass` was chosen for.
533
+ *
534
+ * IT IS ALSO WHAT MAKES 0 HZ A REAL NEUTRAL, which is the frozen-graph law's whole
535
+ * demand (CHAIN_NEUTRAL below). A one-pole lowpass at cutoff 0 has nothing to pass:
536
+ * its output is silence, so the subtraction reads `dry - 0` and the stage is the
537
+ * input, bit for bit. A highpass object would instead be neutral at its cutoff floor,
538
+ * where it is NOT a wire - it still turns DC and the bottom octave, which is a
539
+ * colouration a device could not switch off. The complement has no such setting.
540
+ *
541
+ * NOT a wet/dry send, despite the shape: there is no mix gain, and the parameter that
542
+ * reaches neutral is the CUTOFF itself. It is naturally transparent, like `lowpass` -
543
+ * see the two kinds of neutral in CHAIN_NEUTRAL.
544
+ *
545
+ * The cutoff is in Hz and the chain does no arithmetic on it - the range, unit and
546
+ * curve ride the PARAMETER, exactly as in `lowpass`.
547
+ *
548
+ * Requires a parameter named `hpfreq` (or pass `device.hpfreqParam`), in Hz.
549
+ */
550
+ function hpfChain(ctx) {
551
+ const { boxes, lines, device } = ctx;
552
+ const paramId = requireParam(ctx, "hpf", device?.hpfreqParam ?? "hpfreq", "hpfreqParam");
553
+
554
+ for (const [ch, s] of [
555
+ [0, "l"],
556
+ [1, "r"],
557
+ ]) {
558
+ const [srcId, srcOut] = ctx.audioIn(ch);
559
+ const lp = `obj-hpf-lp-${s}`;
560
+ const sub = `obj-hpf-sub-${s}`;
561
+
562
+ // The low end to remove. A float argument (0.) so the stage starts as a wire
563
+ // before any parameter loads, and the right inlet stays in float mode.
564
+ boxes.push(box(lp, "onepole~ 0.", { numinlets: 2, numoutlets: 1, outlettype: ["signal"] }));
565
+ boxes.push(box(sub, "-~", { numinlets: 2, numoutlets: 1, outlettype: ["signal"] }));
566
+
567
+ lines.push(line(srcId, srcOut, lp, 0));
568
+ lines.push(line(srcId, srcOut, sub, 0)); // dry at unity, into the LEFT inlet
569
+ lines.push(line(lp, 0, sub, 1)); // minus the low end
570
+ fanParamInto(ctx, paramId, lp, 1);
571
+ ctx.setAudioOut(ch, sub, 0);
572
+ }
573
+ }
574
+
575
+ /**
576
+ * "crush" - bit-depth reduction (`degrade~`), neutral at full depth.
577
+ *
578
+ * `degrade~` takes a sample-rate RATIO (inlet 1, 1.0 = untouched) and a BIT DEPTH
579
+ * (inlet 2). This chain drives the bit depth only and leaves the ratio at 1.0: two
580
+ * knobs on one stage would be a second parameter the fx line has no word for -
581
+ * Strudel's `.crush(n)` is bit depth, and `.coarse()` is the rate reduction, a
582
+ * separate effect and a separate chain when someone wants it.
583
+ *
584
+ * THE NEUTRAL IS FULL DEPTH, NOT STRUDEL'S 16. Strudel calls `.crush(16)` "minimum
585
+ * crush", but 16-bit quantisation is not a wire - it is a quiet crush, and a stage
586
+ * that is always in the path (the frozen-graph law) must have a setting where it does
587
+ * NOTHING. So the parameter's range runs to the object's full 24 bits and rests
588
+ * there, where degrade~ passes its input through. A user who types `.crush(16)` gets
589
+ * 16-bit quantisation - the same sound superdough's crush(16) makes - and a user who
590
+ * never types `.crush()` at all gets their signal back untouched. Both are honest;
591
+ * only the second is possible with a neutral of 16.
592
+ *
593
+ * The depth is in BITS and the chain does no arithmetic on it - the range rides the
594
+ * parameter, as everywhere else.
595
+ *
596
+ * Requires a parameter named `crush` (or pass `device.crushParam`), in bits.
597
+ */
598
+ function crushChain(ctx) {
599
+ const { boxes, lines, device } = ctx;
600
+ const paramId = requireParam(ctx, "crush", device?.crushParam ?? "crush", "crushParam");
601
+
602
+ for (const [ch, s] of [
603
+ [0, "l"],
604
+ [1, "r"],
605
+ ]) {
606
+ const [srcId, srcOut] = ctx.audioIn(ch);
607
+ const id = `obj-crush-${s}`;
608
+
609
+ // `degrade~ 1. 24.` - rate ratio 1.0 (untouched), 24 bits (full depth): a wire
610
+ // until the parameter says otherwise.
611
+ boxes.push(box(id, "degrade~ 1. 24.", { numinlets: 3, numoutlets: 1, outlettype: ["signal"] }));
612
+ lines.push(line(srcId, srcOut, id, 0));
613
+ ctx.setAudioOut(ch, id, 0);
614
+ // Bit depth into inlet 2. Inlet 1 (the rate ratio) is left at its argument.
615
+ fanParamInto(ctx, paramId, id, 2);
616
+ }
617
+ }
618
+
619
+ /**
620
+ * "remote" - `live.remote~`, so a PATTERN can modulate a Live parameter.
621
+ *
622
+ * app -> remote_bind <slot> <lomId> -> [route <slot>] -> [prepend id] -> [live.remote~]
623
+ * remote_val <slot> <v> -> [route <slot>] -> [pack f 20] -> [line~] -> ^
624
+ *
625
+ * WHY THIS EXISTS AT ALL. `.lpf(sine.range(200, 2000))` describes something
626
+ * CONTINUOUS. The obvious implementation - have the app read the pattern on the
627
+ * transport tick and write the parameter - produces about 20 values a second, and it
628
+ * is wrong three times over: the steps are audible, every write fights the automation
629
+ * lane for ownership of the parameter, and the lane fills with the app's own noise.
630
+ * `live.remote~` is Live's answer to exactly this: it takes a SIGNAL and modulates the
631
+ * parameter without writing automation, which is what a modulation source is meant to
632
+ * do.
633
+ *
634
+ * THE `[line~]` IS THE WHOLE TRICK, and it is why this is a chain rather than a bridge
635
+ * call. The app is still control-rate: it can only send a value per tick, and a bare
636
+ * number into live.remote~ would step exactly as badly as a parameter write. A
637
+ * `[line~]` given a 20 ms ramp turns each of those values into a SIGNAL that slides to
638
+ * the next one, so the control-rate stream becomes signal-rate at the Max end and the
639
+ * stepping disappears. The ramp is about one tick long by design: it is still arriving
640
+ * when the next value does, so the ramps chain into a continuous line rather than a
641
+ * staircase with pauses in it.
642
+ *
643
+ * BIND BY LOM ID, AND THE APP OWNS THE BINDING. live.remote~ names its target with an
644
+ * `id <lomId>` message, so the chain does not know or care WHICH parameter a slot
645
+ * drives - the app resolves that (our own filter, or an Auto Filter the user placed by
646
+ * hand) and says so. That is what makes this bigger than an LFO on our own DSP: a slot
647
+ * can point at any parameter in the set. **LOM ids are not stable across set reloads**,
648
+ * so the app must re-bind on load and must never persist a raw id - see the diff rules
649
+ * in doc/TODO.md item 1.
650
+ *
651
+ * NOT IN THE SIGNAL PATH. It touches no audio: `ctx.audioIn`/`setAudioOut` are never
652
+ * called, so `remote` composes with any chain list without taking a stage. It has no
653
+ * neutral for the same reason - an unbound slot modulates nothing.
654
+ *
655
+ * `remotes: <n>` in the manifest declares how many slots. There is no default: a
656
+ * device that asks for this chain and forgets the count would build a patcher with no
657
+ * live.remote~ in it and fail silently at runtime, which is the failure this repo
658
+ * exists to prevent.
659
+ */
660
+ function remoteChain(ctx) {
661
+ const { boxes, lines, device } = ctx;
662
+ const n = device?.remotes;
663
+ if (!Number.isInteger(n) || n < 1) {
664
+ throw new Error(
665
+ `chain "remote" on device "${device?.name}" needs \`remotes: <n>\` in the manifest ` +
666
+ `(got ${JSON.stringify(n)}). It is the number of live.remote~ slots to generate, ` +
667
+ `and there is no sensible default - 0 slots is a chain that silently does nothing.`,
668
+ );
669
+ }
670
+
671
+ const slots = Array.from({ length: n }, (_, i) => i);
672
+ const slotList = slots.join(" ");
673
+ // `route` needs an outlet per slot, plus the unmatched one it never uses here.
674
+ const slotRoute = { numoutlets: n + 1, outlettype: slots.map(() => "").concat("") };
675
+
676
+ boxes.push(box("obj-remote-route", "route remote_bind remote_val", { numoutlets: 3, outlettype: ["", "", ""] }));
677
+ claimAppMessages(ctx, "obj-remote-route", 2);
678
+
679
+ // `route` strips the selector, so the SLOT is now the first word of both messages
680
+ // and a second route dispatches on it.
681
+ boxes.push(box("obj-remote-bindroute", `route ${slotList}`, slotRoute));
682
+ boxes.push(box("obj-remote-valroute", `route ${slotList}`, slotRoute));
683
+ lines.push(line("obj-remote-route", 0, "obj-remote-bindroute", 0));
684
+ lines.push(line("obj-remote-route", 1, "obj-remote-valroute", 0));
685
+
686
+ for (const slot of slots) {
687
+ const bind = `obj-remote-bind-${slot}`;
688
+ const pack = `obj-remote-pack-${slot}`;
689
+ const ramp = `obj-remote-line-${slot}`;
690
+ const rem = `obj-remote-${slot}`;
691
+
692
+ // The binding: `id <lomId>` is how live.remote~ is told what to modulate.
693
+ boxes.push(box(bind, "prepend id"));
694
+ // `<target> <rampMs>` is line~'s list form - the pack is what makes the ramp time
695
+ // ride along with every value, rather than being set once and forgotten.
696
+ boxes.push(box(pack, `pack f ${REMOTE_RAMP_MS}`, { numinlets: 2, numoutlets: 1 }));
697
+ boxes.push(box(ramp, "line~", { numinlets: 2, numoutlets: 2, outlettype: ["signal", "bang"] }));
698
+ boxes.push(box(rem, "live.remote~", { numinlets: 1, numoutlets: 0 }));
699
+
700
+ lines.push(line("obj-remote-bindroute", slot, bind, 0));
701
+ lines.push(line(bind, 0, rem, 0));
702
+
703
+ lines.push(line("obj-remote-valroute", slot, pack, 0));
704
+ lines.push(line(pack, 0, ramp, 0));
705
+ lines.push(line(ramp, 0, rem, 0));
706
+ }
707
+ }
708
+
473
709
  /**
474
710
  * "download" - `[maxurl]`, so the app can fetch a file to DISK.
475
711
  *
@@ -557,10 +793,10 @@ function downloadChain(ctx) {
557
793
  * of its own: on an audio effect the preview plays over the track's audio, and on an
558
794
  * instrument there is nothing at the input to add.
559
795
  *
560
- * The buffer NAMES are global to Max, and they are generated from the device name
561
- * (`buf-<device>-<slot>`), so two INSTANCES of the same device on two tracks name
562
- * their buffers alike - and Max hands the name to whichever loaded last. Not
563
- * verified in Live yet; a preview device you drag onto one track is unaffected.
796
+ * The buffer names are INSTANCE-SCOPED (`deviceBufName`, `---buf-<device>-<slot>`), so
797
+ * two copies of this device on two tracks own separate buffers. They used to be global
798
+ * to Max and generated from the device name alone, which meant the second copy loaded
799
+ * silently stole the first's samples.
564
800
  *
565
801
  * Slots default to one, named "preview". `slots: ["kick", "snare"]` in the manifest
566
802
  * gives you more.
@@ -568,7 +804,10 @@ function downloadChain(ctx) {
568
804
  function samplesChain(ctx) {
569
805
  const { boxes, lines, device, jwebId, unmatchedId } = ctx;
570
806
  const slots = device?.slots ?? ["preview"];
571
- const bufName = (slot) => `buf-${device?.name}-${slot}`;
807
+ // Instance-scoped, for the same reason the instrument's are: two copies of a preview
808
+ // device on two tracks are two devices, and a global name would hand both the same
809
+ // buffer. `---` expands per device instance in Live - see deviceBufName.
810
+ const bufName = (slot) => deviceBufName(device, slot);
572
811
 
573
812
  // `buffer_load` is NOT claimed from [jweb]. It goes on to the wrapper, which
574
813
  // resolves the path and hands it back on its AUX OUTLET as `buffer_replace <slot>
@@ -987,9 +1226,10 @@ function instrumentVoicePatch(bufNames) {
987
1226
  * which is the app's decision, not the chain's.
988
1227
  *
989
1228
  * `slots: ["c", "e", "g"]` in the manifest is three buffers; the default is one
990
- * ("voice"). The buffer NAMES are `buf-<device>-<slot>`, global to Max - so two copies
991
- * of the device on two tracks name their buffers alike (harmless for a demo, the drum-
992
- * rack instance problem noted in doc/TODO.md).
1229
+ * ("voice"). The buffer names are INSTANCE-SCOPED (`deviceBufName`): each copy of the
1230
+ * device owns its own, which is what lets a drum rack exist on two tracks at once.
1231
+ * `---` scopes per DEVICE, so the voice spells the same name the device does - see
1232
+ * deviceBufName.
993
1233
  *
994
1234
  * It SUMS into the signal path ([+~]): an instrument makes sound where there was none
995
1235
  * at its input, so there is nothing to claim a stage over.
@@ -997,13 +1237,14 @@ function instrumentVoicePatch(bufNames) {
997
1237
  function instrumentChain(ctx) {
998
1238
  const { boxes, lines, device, jwebId, unmatchedId } = ctx;
999
1239
  const slots = device?.slots ?? ["voice"];
1000
- const bufName = (slot) => `buf-${device?.name}-${slot}`;
1240
+ const bufName = (slot) => deviceBufName(device, slot);
1001
1241
  const voices = device?.voices ?? 8;
1002
1242
  const voiceFile = `${device?.name}-voice.maxpat`;
1003
1243
 
1004
1244
  // The frozen voice patch (a keymap of every slot's buffer), and the [poly~] that
1005
- // loads N copies of it.
1006
- ctx.extras.push({ name: voiceFile, data: instrumentVoicePatch(slots.map(bufName)) });
1245
+ // loads N copies of it. The voice spells the buffers exactly as the device does:
1246
+ // `---` is device-scoped, so no id travels through poly~'s arguments.
1247
+ ctx.extras.push({ name: voiceFile, data: instrumentVoicePatch(slots.map((s) => voiceBufName(device, s))) });
1007
1248
  // [poly~]'s name is the file WITHOUT its extension, per Max's abstraction lookup.
1008
1249
  boxes.push(
1009
1250
  box(`obj-instr-poly`, `poly~ ${voiceFile.replace(/\.maxpat$/, "")} ${voices}`, {
@@ -1079,9 +1320,12 @@ export const CHAINS = {
1079
1320
  passthrough: passthroughChain,
1080
1321
  gain: gainChain,
1081
1322
  lowpass: lowpassChain,
1323
+ hpf: hpfChain,
1082
1324
  drive: driveChain,
1325
+ crush: crushChain,
1083
1326
  delay: delayChain,
1084
1327
  reverb: reverbChain,
1328
+ remote: remoteChain,
1085
1329
  download: downloadChain,
1086
1330
  };
1087
1331
 
@@ -1095,7 +1339,8 @@ export const CHAINS = {
1095
1339
  * Two kinds of stage reach neutral two ways:
1096
1340
  *
1097
1341
  * naturally transparent - `gain` (1.0), `lowpass` (18 kHz, nothing left to remove),
1098
- * `drive` (1x): the DSP object itself is a wire at that value.
1342
+ * `drive` (1x), `crush` (24 bits, full depth), `hpf` (0 Hz, where its subtracted
1343
+ * low end is silence): the DSP object itself is a wire at that value.
1099
1344
  * send-style wet/dry - `delay` (0), `reverb` (0): the wet branch is scaled to 0.0
1100
1345
  * and summed onto an untouched dry path, so the output is `dry + 0`. A WET-ONLY
1101
1346
  * object (cverb~) has NO neutral of its own - it must carry this dry/wet, which
@@ -1109,7 +1354,9 @@ export const CHAINS = {
1109
1354
  export const CHAIN_NEUTRAL = {
1110
1355
  gain: { gain: 1 },
1111
1356
  lowpass: { cutoff: 18000 },
1357
+ hpf: { hpfreq: 0 },
1112
1358
  drive: { drive: 1 },
1359
+ crush: { crush: 24 },
1113
1360
  delay: { delay: 0 },
1114
1361
  reverb: { room: 0 },
1115
1362
  };
package/src/index.mjs CHANGED
@@ -17,7 +17,8 @@ import { fileURLToPath, pathToFileURL } from "node:url";
17
17
 
18
18
  import { AMXD_TYPES, assertES5, buildAmxd, extraPayloadsJs, payloadJs } from "./amxd.mjs";
19
19
  import { CHAINS, assertUniqueBoxIds, closeAudio, openAudio, resetLayout } from "./chains.mjs";
20
- import { applySurface, applyWindows, applyPersistence, loadSurface, surfaceContext } from "./surface.mjs";
20
+ import { applySurface, applyWindows, applyPersistence, loadSurface, parameterRegistry, surfaceContext } from "./surface.mjs";
21
+ import { loadWatch, watchSpecsBanner } from "./watch.mjs";
21
22
 
22
23
  const require = createRequire(import.meta.url);
23
24
  const pkgDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
@@ -218,6 +219,12 @@ export function composePatcher(base, d, surface) {
218
219
  closeAudio(ctx);
219
220
  assertUniqueBoxIds(boxes, d.name);
220
221
 
222
+ // The patcher-level parameter registry - without it Live ignores every
223
+ // parameter_longname and renames the parameters after their shortnames,
224
+ // which breaks resolveParamId's contract. See parameterRegistry().
225
+ const registry = parameterRegistry(surface);
226
+ if (registry) p.patcher.parameters = registry;
227
+
221
228
  // Ride the chain-contributed extras out on the returned object. Destructuring
222
229
  // `{ patcher }` (the tests, the writer) ignores it; the packager reads it.
223
230
  p.extras = extras;
@@ -323,7 +330,10 @@ export async function packageDevices(root) {
323
330
  * process are blind to Max's frozen virtual filesystem.
324
331
  * We embed ALL .html files found in the UI directory (including the main UI and any windows).
325
332
  */
326
- let wrapperData = banner + wrapperJs;
333
+ // The device's declared watches ride in as a data banner, like the build stamp:
334
+ // WATCH_SPECS is what the packaged wrapper's setupWatches() attaches observers from.
335
+ const watch = await loadWatch(root, d.ui ?? d.name);
336
+ let wrapperData = banner + watchSpecsBanner(watch) + wrapperJs;
327
337
  const uiDirContent = readdirSync(path.join(dist, "ui", d.ui ?? d.name)).filter((f) => f.endsWith(".html"));
328
338
 
329
339
  // Main UI payload
@@ -375,6 +385,17 @@ export async function packageDevices(root) {
375
385
  console.log(`m4l-jweb: ${path.basename(f)} -> dist/${name}/ (loose)`);
376
386
  }
377
387
 
388
+ // Presets ride along with the devices. A consumer repo's `presets/` holds
389
+ // hand-saved Live files (an .adg rack is gzipped XML, undocumented - committed,
390
+ // never generated); they land next to the .amxd in dist, the zip and the User
391
+ // Library, all through this one list so the three cannot skew.
392
+ const presetsDir = path.join(root, "presets");
393
+ const presets = existsSync(presetsDir) ? readdirSync(presetsDir).filter((f) => f.endsWith(".adg") || f.endsWith(".adv")) : [];
394
+ for (const f of presets) {
395
+ await copyFile(path.join(presetsDir, f), path.join(outDir, f));
396
+ console.log(`m4l-jweb: ${f} -> dist/${name}/ (preset)`);
397
+ }
398
+
378
399
  // Installers go next to the devices so `dist/install-*.ps1` just works.
379
400
  const installers = ["install-windows.ps1", "install-mac.sh"];
380
401
  for (const f of installers) await copyFile(path.join(templates, f), path.join(dist, f));
@@ -390,6 +411,7 @@ export async function packageDevices(root) {
390
411
  ...devices.map((d) => `${d.name}.amxd`),
391
412
  ...devices.map((d) => `${d.name}.html`), // each device's own UI, for inspection
392
413
  ...loose.map((f) => path.basename(f)),
414
+ ...presets,
393
415
  "wrapper.js",
394
416
  ];
395
417
  for (const f of files) {
package/src/surface.mjs CHANGED
@@ -59,6 +59,48 @@ export const paramObject = (id) => `obj-param-${id}`;
59
59
  */
60
60
  export const paramValue = (surface, id) => [SURFACE_ROUTE, surface.ids.indexOf(id)];
61
61
 
62
+ /**
63
+ * The patcher-level `parameters` registry - the block Max itself writes into any
64
+ * patcher that has parameter-enabled boxes, mapping box id -> [longname,
65
+ * shortname, type].
66
+ *
67
+ * IT IS NOT OPTIONAL DECORATION. Live reads THIS registry for parameter names at
68
+ * device load; the per-box `saved_attribute_attributes.valueof.parameter_longname`
69
+ * alone is ignored, and Live regenerates every longname from the shortname. That
70
+ * is how `resolveParamId("cutoff")` came back 0 on a device whose dial plainly
71
+ * said `parameter_longname: "cutoff"`: the DeviceParameter's runtime name was
72
+ * "Cutoff". Emitting the registry is what makes the longname the surface id -
73
+ * the contract resolveParamId and every automation lane name rides on.
74
+ *
75
+ * The pattr state blobs are in it too (type 3), same as Max would write them.
76
+ *
77
+ * `parameterbanks` is the Push/controller paging, from the surface's `banks`: a
78
+ * bank is `{ index, name, parameters }` with EIGHT longname entries, the unused
79
+ * tail padded with "-" - the shape read off devices Max itself saved. Without
80
+ * banks Live falls back to declaration-order pages of eight.
81
+ */
82
+ export function parameterRegistry(surface) {
83
+ const out = {};
84
+ for (const id of surface?.ids ?? []) {
85
+ const spec = surface.params[id];
86
+ out[paramObject(id)] = [id, spec.short ?? id, parameterType(spec)];
87
+ }
88
+ for (const id of surface?.state ? Object.keys(surface.state) : []) {
89
+ const pattrId = `obj-pattr-${id}`;
90
+ out[pattrId] = [pattrId, `st_${id}`.slice(0, 8), 3];
91
+ }
92
+ if (!Object.keys(out).length) return null;
93
+
94
+ const parameterbanks = {};
95
+ (surface.banks ?? []).forEach((bank, i) => {
96
+ const params = bank.params.slice(0, 8);
97
+ while (params.length < 8) params.push("-");
98
+ parameterbanks[String(i)] = { index: i, name: bank.name, parameters: params };
99
+ });
100
+
101
+ return { ...out, parameterbanks, inherited_shortname: 1 };
102
+ }
103
+
62
104
  /**
63
105
  * What a chain is handed to reach the parameters: `surface` (to check a parameter
64
106
  * it needs exists) and the two outlets it must fan a value out of. Spread into the
@@ -464,6 +506,47 @@ export function applySurface(ctx) {
464
506
  * failed to exist.
465
507
  * ------------------------------------------------------------------------------
466
508
  */
509
+
510
+ /**
511
+ * `alwaysOnTop` - keep the window in FRONT of Live rather than behind it.
512
+ *
513
+ * A window you READ while working (a reference) is useless without this: clicking back
514
+ * into the device to type is exactly what buries it. A window you work IN (an editor)
515
+ * wants the default, which is why this is opt-in.
516
+ *
517
+ * `[thispatcher]` + `window flags ... , window exec` is Max's documented route - the
518
+ * flags do not take effect until `exec`, which is why it is two messages and not one.
519
+ *
520
+ * **`window flags` REPLACES the whole list; it does not add to it.** So `float` alone
521
+ * would produce a window with no close box, no title bar and no resize - a reference
522
+ * card the user cannot get rid of. `grow close title` are named alongside it to put
523
+ * back the ones a window is expected to have.
524
+ *
525
+ * A `loadbang` inside the SUBPATCHER fires when the subpatcher is loaded (with the
526
+ * device), not when its window is opened - which is what we want: the flags are a
527
+ * property of the window, set once, whether or not anyone ever opens it.
528
+ */
529
+ const FLOAT_MSG = "window flags grow close title float, window exec";
530
+
531
+ function floatBoxes(spec) {
532
+ if (!spec.alwaysOnTop) return [];
533
+ const y = 96 + spec.height + 80;
534
+ return [
535
+ { box: { id: "obj-float-loadbang", maxclass: "newobj", text: "loadbang", numinlets: 1, numoutlets: 1, outlettype: ["bang"], patching_rect: [220, y, 60, 22] } },
536
+ // A MESSAGE box - the comma is what makes it two messages, which is the point.
537
+ { box: { id: "obj-float-msg", maxclass: "message", text: FLOAT_MSG, numinlets: 2, numoutlets: 1, outlettype: [""], patching_rect: [220, y + 30, 280, 22] } },
538
+ { box: { id: "obj-float-thispatcher", maxclass: "newobj", text: "thispatcher", numinlets: 1, numoutlets: 2, outlettype: ["", ""], patching_rect: [220, y + 60, 80, 22] } },
539
+ ];
540
+ }
541
+
542
+ function floatLines(spec) {
543
+ if (!spec.alwaysOnTop) return [];
544
+ return [
545
+ { patchline: { source: ["obj-float-loadbang", 0], destination: ["obj-float-msg", 0] } },
546
+ { patchline: { source: ["obj-float-msg", 0], destination: ["obj-float-thispatcher", 0] } },
547
+ ];
548
+ }
549
+
467
550
  export function applyWindows(ctx) {
468
551
  const { boxes, lines, surface, unmatchedId } = ctx;
469
552
  const windowIds = surface?.windows ? Object.keys(surface.windows) : [];
@@ -583,12 +666,14 @@ export function applyWindows(ctx) {
583
666
  },
584
667
  },
585
668
  { box: { id: "obj-out", maxclass: "outlet", patching_rect: [16, 96 + spec.height + 48, 30, 30], numinlets: 1, numoutlets: 0 } },
669
+ ...floatBoxes(spec),
586
670
  ],
587
671
  lines: [
588
672
  { patchline: { source: ["obj-recv", 0], destination: ["obj-jweb", 0] } },
589
673
  // [jweb] outlet 0 is the page's messages; tag them and send them out.
590
674
  { patchline: { source: ["obj-jweb", 0], destination: ["obj-tag", 0] } },
591
675
  { patchline: { source: ["obj-tag", 0], destination: ["obj-out", 0] } },
676
+ ...floatLines(spec),
592
677
  ],
593
678
  },
594
679
  },
@@ -642,7 +727,25 @@ export function applyPersistence(ctx) {
642
727
  // it binds to a NAMED object in the patcher, not to a box id (which is ours, and
643
728
  // which Max is free to renumber). The dict's own name argument is what [js]
644
729
  // addresses with `new Dict("obj-state-<id>")`, so all three agree.
645
- boxes.push(box(dictId, `dict ${dictId}`, { varname: dictId, numinlets: 2, numoutlets: 4, outlettype: ["dictionary", "", "", ""] }));
730
+ //
731
+ // THE SEED: `@embed 1` plus box-level `data` is how a Max-saved patcher embeds
732
+ // a dict's contents (the shape read off dict.maxhelp), and it is what makes a
733
+ // declared `default` mean what it says on a FRESH instance - without it the
734
+ // dict loads empty and the app's default is all the truth there is, which held
735
+ // only because the app also knew the default. Enveloped like every other value
736
+ // (`{"__value": ...}`), so the read path cannot tell a seed from a write. A
737
+ // restored [pattr] value overwrites the seed at load, which is the required
738
+ // order: restore beats seed, seed beats nothing.
739
+ boxes.push(
740
+ box(dictId, `dict ${dictId} @embed 1`, {
741
+ varname: dictId,
742
+ numinlets: 2,
743
+ numoutlets: 4,
744
+ outlettype: ["dictionary", "", "", ""],
745
+ data: { __value: surface.state[id].default },
746
+ saved_object_attributes: { embed: 1 },
747
+ }),
748
+ );
646
749
 
647
750
  const pattrId = `obj-pattr-${id}`;
648
751
  boxes.push(
package/src/watch.mjs ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * watch.mjs - the build side of defineWatch().
3
+ *
4
+ * The twin of surface.mjs's loadSurface: it imports a device's watch declaration
5
+ * and turns it into the WATCH_SPECS the packaged wrapper reads. The wrapper is
6
+ * generic; this is where a device's specific list of Live properties to observe
7
+ * enters, as an injected data banner - exactly as BUILD_STAMP and the payloads do.
8
+ *
9
+ * There is no patcher wiring here, and that is the point: an observer is pure
10
+ * LiveAPI, created in [js] from bang(), so nothing crosses into the patcher graph.
11
+ * The declaration produces DATA, not boxes.
12
+ */
13
+ import { existsSync, mkdtempSync, rmSync } from "node:fs";
14
+ import { tmpdir } from "node:os";
15
+ import path from "node:path";
16
+ import { pathToFileURL } from "node:url";
17
+
18
+ /**
19
+ * Load a device's `src/app/<uiDir>/watch.ts`, or null if it declares none.
20
+ *
21
+ * Bundled with esbuild exactly like loadSurface, for the same reason: the
22
+ * declaration is TypeScript importing @m4l-jweb/surface, and Node cannot import
23
+ * that directly.
24
+ */
25
+ export async function loadWatch(root, uiDir) {
26
+ const src = path.join(root, "src", "app", uiDir, "watch.ts");
27
+ if (!existsSync(src)) return null;
28
+
29
+ const { build } = await import("esbuild");
30
+ const tmp = mkdtempSync(path.join(tmpdir(), "m4l-watch-"));
31
+ const out = path.join(tmp, "watch.mjs");
32
+ try {
33
+ await build({
34
+ entryPoints: [src],
35
+ outfile: out,
36
+ bundle: true,
37
+ format: "esm",
38
+ platform: "node",
39
+ logLevel: "silent",
40
+ external: ["react", "react-dom"],
41
+ });
42
+ const mod = await import(pathToFileURL(out).href);
43
+ const watch = mod.default;
44
+ if (!watch?.keys) {
45
+ throw new Error(`${src} must \`export default defineWatch({...})\``);
46
+ }
47
+ return watch;
48
+ } finally {
49
+ rmSync(tmp, { recursive: true, force: true });
50
+ }
51
+ }
52
+
53
+ /**
54
+ * The `var WATCH_SPECS = [...]` banner prepended to a device's wrapper.js.
55
+ *
56
+ * Only what the wrapper needs to ATTACH the observer travels: the selector key,
57
+ * the LOM path, the property. The `default` is the app's business (it seeds
58
+ * useWatch before Live replies) and never reaches Max, so it is dropped here -
59
+ * shipping it would be dead weight inside every .amxd.
60
+ *
61
+ * A device with no watches gets no banner ("") - `typeof WATCH_SPECS === "undefined"`
62
+ * is exactly the guard the wrapper's setupWatches() checks.
63
+ */
64
+ export function watchSpecsBanner(watch) {
65
+ if (!watch || !watch.keys.length) return "";
66
+ const specs = watch.keys.map((key) => {
67
+ const w = watch.watches[key];
68
+ return { key, path: w.path, property: w.property };
69
+ });
70
+ return `var WATCH_SPECS = ${JSON.stringify(specs)};\n`;
71
+ }
@@ -53,6 +53,14 @@ for f in "$src"/*.amxd; do
53
53
  echo " installed $(basename "$f")"
54
54
  done
55
55
 
56
+ # Presets (hand-saved Live racks, packaged next to the devices by the build) go in
57
+ # the same folder, so a rack that names these devices finds them one drag away.
58
+ for f in "$src"/*.adg "$src"/*.adv; do
59
+ [ -e "$f" ] || continue
60
+ cp "$f" "$dest/"
61
+ echo " installed $(basename "$f") (preset)"
62
+ done
63
+
56
64
  echo "Installed to $dest"
57
65
  echo "In Live: User Library > Max For Live > $device_name"
58
66
  echo "NOTE: Live embeds a copy of the device in the set. Instances already"
@@ -58,6 +58,13 @@ foreach ($f in $devices) {
58
58
  Write-Host " installed $($f.Name)"
59
59
  }
60
60
 
61
+ # Presets (hand-saved Live racks, packaged next to the devices by the build) go in
62
+ # the same folder, so a rack that names these devices finds them one drag away.
63
+ foreach ($f in @(Get-ChildItem (Join-Path $src "*.adg") -ErrorAction SilentlyContinue) + @(Get-ChildItem (Join-Path $src "*.adv") -ErrorAction SilentlyContinue)) {
64
+ Copy-Item $f.FullName $dest -Force
65
+ Write-Host " installed $($f.Name) (preset)"
66
+ }
67
+
61
68
  Write-Host "Installed to $dest"
62
69
  Write-Host "In Live: User Library > Max For Live > $deviceName"
63
70
  Write-Host "NOTE: Live embeds a copy of the device in the set. Instances already"
@@ -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.7.0",
21
- "@m4l-jweb/surface": "^0.7.0",
20
+ "@m4l-jweb/bridge": "^0.9.0",
21
+ "@m4l-jweb/surface": "^0.9.0",
22
22
  "react": "^19.0.0",
23
23
  "react-dom": "^19.0.0"
24
24
  },
25
25
  "devDependencies": {
26
- "@m4l-jweb/build": "^0.7.0",
26
+ "@m4l-jweb/build": "^0.9.0",
27
27
  "@types/node": "^22.0.0",
28
28
  "@types/react": "^19.0.0",
29
29
  "@types/react-dom": "^19.0.0",