@m4l-jweb/build 0.6.5 → 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 +3 -2
- package/src/chains.mjs +259 -12
- package/src/index.mjs +24 -2
- package/src/surface.mjs +249 -5
- package/src/watch.mjs +71 -0
- package/templates/install-mac.sh +8 -0
- package/templates/install-windows.ps1 +7 -0
- package/templates/starter/package.json +3 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m4l-jweb/build",
|
|
3
|
-
"version": "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.
|
|
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
|
|
561
|
-
*
|
|
562
|
-
*
|
|
563
|
-
*
|
|
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
|
-
|
|
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
|
|
991
|
-
*
|
|
992
|
-
*
|
|
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) =>
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
|
@@ -124,7 +166,61 @@ export async function loadSurface(root, uiDir) {
|
|
|
124
166
|
* Generating the objects
|
|
125
167
|
* ------------------------------------------------------------------ */
|
|
126
168
|
|
|
127
|
-
const MAXCLASS = { dial: "live.dial", toggle: "live.toggle", menu: "live.menu" };
|
|
169
|
+
const MAXCLASS = { dial: "live.dial", toggle: "live.toggle", menu: "live.menu", button: "live.text" };
|
|
170
|
+
|
|
171
|
+
/* ------------------------------------------------------------------ *
|
|
172
|
+
* Native declarative layout (surface `layout.native`)
|
|
173
|
+
*
|
|
174
|
+
* A parameter listed in `layout.native` renders as a native `live.*` object IN
|
|
175
|
+
* THE DEVICE VIEW, next to a `[jweb]` shifted right to make room. This is a pure
|
|
176
|
+
* PRESENTATION overlay: the dial the compiler already emits gains three keys
|
|
177
|
+
* (`presentation`, `presentation_rect`, `varname`) and nothing about its wiring,
|
|
178
|
+
* its fan-out or the app's `set_<id>` route changes. A dial that carries no rect
|
|
179
|
+
* is exactly today's invisible object.
|
|
180
|
+
* ------------------------------------------------------------------ */
|
|
181
|
+
|
|
182
|
+
// The device view Live gives an M4L device. Height is fixed at ~169 px; the width
|
|
183
|
+
// is whatever the presentation content needs, which is the whole mechanism this
|
|
184
|
+
// relies on (Live recomputes device width from the presentation rects).
|
|
185
|
+
const DEVICE_H = 169;
|
|
186
|
+
const MARGIN = 8;
|
|
187
|
+
// Per-kind native sizes, from Max's own live.* defaults. A live.dial includes its
|
|
188
|
+
// own label under the knob, which is why it is taller than a bare toggle.
|
|
189
|
+
const NATIVE_SIZE = { dial: [44, 48], toggle: [44, 15], menu: [100, 15], button: [48, 15] };
|
|
190
|
+
// Vertical pitch: a dial is 48 px tall and wants 8 px of air beneath its label.
|
|
191
|
+
const PITCH_Y = 56;
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* `id -> presentation_rect` for every native parameter, plus the zone's total
|
|
195
|
+
* width (how far `[jweb]` shifts right). A pure function of the declaration, so
|
|
196
|
+
* `tests/` can drive it directly.
|
|
197
|
+
*
|
|
198
|
+
* Column-major: fills `rows` down the first column, then starts a new column to
|
|
199
|
+
* the right. Adding a parameter at the end therefore never reshuffles the ones
|
|
200
|
+
* before it - the reading order stays stable.
|
|
201
|
+
*/
|
|
202
|
+
export function computeNativeSlots(surface) {
|
|
203
|
+
const native = surface?.layout?.native;
|
|
204
|
+
if (!native || native.params.length === 0) return { slots: new Map(), width: 0 };
|
|
205
|
+
const rows = native.rows ?? 3;
|
|
206
|
+
const slots = new Map();
|
|
207
|
+
let row = 0;
|
|
208
|
+
let colW = 0;
|
|
209
|
+
let x = MARGIN;
|
|
210
|
+
for (const id of native.params) {
|
|
211
|
+
const [w, h] = NATIVE_SIZE[surface.params[id].kind];
|
|
212
|
+
if (row >= rows) {
|
|
213
|
+
// Column full: step right by the widest box in the column just finished.
|
|
214
|
+
row = 0;
|
|
215
|
+
x += colW + MARGIN;
|
|
216
|
+
colW = 0;
|
|
217
|
+
}
|
|
218
|
+
slots.set(id, [x, MARGIN + row * PITCH_Y, w, h]);
|
|
219
|
+
colW = Math.max(colW, w);
|
|
220
|
+
row += 1;
|
|
221
|
+
}
|
|
222
|
+
return { slots, width: x + colW + MARGIN };
|
|
223
|
+
}
|
|
128
224
|
|
|
129
225
|
/**
|
|
130
226
|
* Max's `parameter_type`: 0 = float, 1 = int, 2 = enum.
|
|
@@ -134,7 +230,7 @@ const MAXCLASS = { dial: "live.dial", toggle: "live.toggle", menu: "live.menu" }
|
|
|
134
230
|
* where a float one would read "2.4 of [off 1/4 1/8 ...]".
|
|
135
231
|
*/
|
|
136
232
|
function parameterType(spec) {
|
|
137
|
-
if (spec.kind === "menu" || spec.kind === "toggle") return 2;
|
|
233
|
+
if (spec.kind === "menu" || spec.kind === "toggle" || spec.kind === "button") return 2;
|
|
138
234
|
return spec.step === 1 ? 1 : 0;
|
|
139
235
|
}
|
|
140
236
|
|
|
@@ -183,7 +279,7 @@ function unitAttrs(spec) {
|
|
|
183
279
|
|
|
184
280
|
/** The parameter's value as MAX stores it: numbers, always. */
|
|
185
281
|
function initialValue(spec) {
|
|
186
|
-
if (spec.kind === "toggle") return spec.default ? 1 : 0;
|
|
282
|
+
if (spec.kind === "toggle" || spec.kind === "button") return spec.default ? 1 : 0;
|
|
187
283
|
if (spec.kind === "menu") return spec.options.indexOf(spec.default);
|
|
188
284
|
return spec.default;
|
|
189
285
|
}
|
|
@@ -226,7 +322,7 @@ function parameterAttrs(id, spec) {
|
|
|
226
322
|
if (spec.steps !== undefined) attrs.parameter_steps = spec.steps;
|
|
227
323
|
}
|
|
228
324
|
|
|
229
|
-
if (spec.kind === "toggle") {
|
|
325
|
+
if (spec.kind === "toggle" || spec.kind === "button") {
|
|
230
326
|
attrs.parameter_mmax = 1;
|
|
231
327
|
attrs.parameter_enum = ["off", "on"];
|
|
232
328
|
}
|
|
@@ -239,6 +335,16 @@ function parameterAttrs(id, spec) {
|
|
|
239
335
|
return attrs;
|
|
240
336
|
}
|
|
241
337
|
|
|
338
|
+
/**
|
|
339
|
+
* Box-level (not parameter) attributes a kind needs. A `button` is a `live.text`,
|
|
340
|
+
* which unlike a `live.toggle` carries VISIBLE TEXT: `text`/`texton` is the label it
|
|
341
|
+
* shows off/on, and `mode: 1` makes it a toggle rather than a momentary button.
|
|
342
|
+
*/
|
|
343
|
+
function kindBoxAttrs(spec) {
|
|
344
|
+
if (spec.kind === "button") return { text: spec.label, texton: spec.label, mode: 1 };
|
|
345
|
+
return {};
|
|
346
|
+
}
|
|
347
|
+
|
|
242
348
|
/**
|
|
243
349
|
* Compile the Surface into the patcher.
|
|
244
350
|
*
|
|
@@ -250,9 +356,29 @@ export function applySurface(ctx) {
|
|
|
250
356
|
const { boxes, lines, surface, jwebId } = ctx;
|
|
251
357
|
if (!surface || surface.ids.length === 0) return;
|
|
252
358
|
|
|
359
|
+
// Where the native dials go, if any were declared. `slots` is empty for a
|
|
360
|
+
// surface with no `layout`, and then this whole feature is inert - a param
|
|
361
|
+
// carries no presentation rect and stays the invisible object it is today.
|
|
362
|
+
const native = surface.layout?.native;
|
|
363
|
+
const { slots, width: nativeW } = computeNativeSlots(surface);
|
|
364
|
+
|
|
365
|
+
// The view switch (panel layouts) is NOT a grid dial: it is pinned to the
|
|
366
|
+
// top-right, over where the web UI paints its own switch button, so the control
|
|
367
|
+
// stays in one place when the app flips between the two views. It is excluded from
|
|
368
|
+
// `computeNativeSlots` (it is not in `native.params`), so give it a rect here.
|
|
369
|
+
const switchId = native?.switch;
|
|
370
|
+
const jwebBox = boxes.find((b) => b.box.id === jwebId)?.box;
|
|
371
|
+
const [, , jpw] = jwebBox?.presentation_rect ?? [0, 0, 420, DEVICE_H];
|
|
372
|
+
let switchRect = null;
|
|
373
|
+
if (switchId) {
|
|
374
|
+
const [sw, sh] = NATIVE_SIZE[surface.params[switchId].kind];
|
|
375
|
+
switchRect = [jpw - sw - MARGIN, MARGIN, sw, sh];
|
|
376
|
+
}
|
|
377
|
+
|
|
253
378
|
let x = 480;
|
|
254
379
|
for (const id of surface.ids) {
|
|
255
380
|
const spec = surface.params[id];
|
|
381
|
+
const rect = id === switchId ? switchRect : slots.get(id);
|
|
256
382
|
boxes.push({
|
|
257
383
|
box: {
|
|
258
384
|
id: paramObject(id),
|
|
@@ -262,6 +388,13 @@ export function applySurface(ctx) {
|
|
|
262
388
|
outlettype: [""],
|
|
263
389
|
parameter_enable: 1,
|
|
264
390
|
patching_rect: [x, 300, 44, 48],
|
|
391
|
+
...kindBoxAttrs(spec),
|
|
392
|
+
// A native param is shown in the device view: it gets a presentation rect
|
|
393
|
+
// and a scripting name (prefixed `param-` so it cannot collide with a state
|
|
394
|
+
// dict's `obj-state-<id>` varname). Everything else about the box - the
|
|
395
|
+
// wiring below, the fan-out, useParam() - is UNCHANGED. Presentation is
|
|
396
|
+
// purely a display overlay on the same graph.
|
|
397
|
+
...(rect ? { presentation: 1, presentation_rect: rect, varname: `param-${id}` } : {}),
|
|
265
398
|
saved_attribute_attributes: { valueof: parameterAttrs(id, spec) },
|
|
266
399
|
},
|
|
267
400
|
});
|
|
@@ -273,6 +406,26 @@ export function applySurface(ctx) {
|
|
|
273
406
|
x += 56;
|
|
274
407
|
}
|
|
275
408
|
|
|
409
|
+
// Position [jweb] for the native layout. WIDTH is preserved (React layouts were
|
|
410
|
+
// built for 420 px). A surface with no native content leaves [jweb] where the
|
|
411
|
+
// template put it.
|
|
412
|
+
if ((nativeW > 0 || switchId) && jwebBox) {
|
|
413
|
+
const [, py, pw, ph] = jwebBox.presentation_rect ?? [0, 0, 420, DEVICE_H];
|
|
414
|
+
if (native.panel) {
|
|
415
|
+
// LAYERED two-screen: [jweb] covers the whole device and the dials overlap its
|
|
416
|
+
// left. The app shows one layer at a time (useNativePanel), so the overlap is
|
|
417
|
+
// never seen - web mode fills the full width, no reserved strip.
|
|
418
|
+
jwebBox.presentation_rect = [0, py, Math.max(pw, nativeW), ph];
|
|
419
|
+
} else {
|
|
420
|
+
// Side-by-side: the dials to the left, [jweb] shifted right, both visible.
|
|
421
|
+
jwebBox.presentation_rect = [nativeW, py, pw, ph];
|
|
422
|
+
}
|
|
423
|
+
// Give [jweb] a scripting name so the wrapper can hide/show it at runtime
|
|
424
|
+
// (useNativePanel). Must equal JWEB_VARNAME in @m4l-jweb/surface - the one string
|
|
425
|
+
// the app and this codegen must agree on.
|
|
426
|
+
jwebBox.varname = "obj-jweb";
|
|
427
|
+
}
|
|
428
|
+
|
|
276
429
|
// Write direction: one route for every `set_<id>` the app can send. It goes at
|
|
277
430
|
// the END of the chain of routes (see claimAppMessages), so a chain that already
|
|
278
431
|
// took [jweb]'s outlet keeps it and hands us what it did not match.
|
|
@@ -295,6 +448,36 @@ export function applySurface(ctx) {
|
|
|
295
448
|
});
|
|
296
449
|
}
|
|
297
450
|
|
|
451
|
+
/**
|
|
452
|
+
* Runtime show/hide of native dials - the `layout.native` visibility override.
|
|
453
|
+
*
|
|
454
|
+
* `layout.native` makes a parameter a native `live.*` object, and its presentation
|
|
455
|
+
* is STATIC: `presentation: 1` is stamped into the .amxd at build time, so every
|
|
456
|
+
* listed dial is always visible. A device like the fx line wants the opposite - a
|
|
457
|
+
* stage the current line does not name should not clutter the view, the way its old
|
|
458
|
+
* HTML slider was simply not rendered. React can hide a slider; a native object
|
|
459
|
+
* cannot hide itself, so the app drives it from outside via `native_show`/
|
|
460
|
+
* `native_hide <varname>` (useNativeVisibility).
|
|
461
|
+
*
|
|
462
|
+
* ------------------------------------------------------------------------------
|
|
463
|
+
* THIS IS A SPIKE, and its FIRST mechanism already FAILED IN LIVE.
|
|
464
|
+
*
|
|
465
|
+
* Attempt 1 (this codegen, now retired): a `[thispatcher]` running `script hide
|
|
466
|
+
* <varname>`. Verified in Live - the dials did NOT disappear. `script hide` acts on
|
|
467
|
+
* the PATCHING canvas; the M4L device view is the PRESENTATION, and `script` has no
|
|
468
|
+
* documented reach into it.
|
|
469
|
+
*
|
|
470
|
+
* Attempt 2 (current): the wrapper's [js] handles `native_show`/`native_hide` and
|
|
471
|
+
* manipulates the object through the Maxobj API (`this.patcher.getnamed(varname)`),
|
|
472
|
+
* with console diagnostics. So this codegen no longer claims the messages - it lets
|
|
473
|
+
* them fall through to the wrapper. See `native_show`/`native_hide` in core.ts.
|
|
474
|
+
*
|
|
475
|
+
* If attempt 2 also fails to reach the presentation, the honest conclusion is that a
|
|
476
|
+
* frozen M4L device cannot hide a native object at runtime, and the fallback is a
|
|
477
|
+
* build-time choice (fewer native params, or a device keeps HTML controls).
|
|
478
|
+
* ------------------------------------------------------------------------------
|
|
479
|
+
*/
|
|
480
|
+
|
|
298
481
|
/**
|
|
299
482
|
* Compile a declared `window` into the patcher: a subpatcher holding its own
|
|
300
483
|
* [jweb], and a [pcontrol] that opens it when the app asks.
|
|
@@ -323,6 +506,47 @@ export function applySurface(ctx) {
|
|
|
323
506
|
* failed to exist.
|
|
324
507
|
* ------------------------------------------------------------------------------
|
|
325
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
|
+
|
|
326
550
|
export function applyWindows(ctx) {
|
|
327
551
|
const { boxes, lines, surface, unmatchedId } = ctx;
|
|
328
552
|
const windowIds = surface?.windows ? Object.keys(surface.windows) : [];
|
|
@@ -442,12 +666,14 @@ export function applyWindows(ctx) {
|
|
|
442
666
|
},
|
|
443
667
|
},
|
|
444
668
|
{ box: { id: "obj-out", maxclass: "outlet", patching_rect: [16, 96 + spec.height + 48, 30, 30], numinlets: 1, numoutlets: 0 } },
|
|
669
|
+
...floatBoxes(spec),
|
|
445
670
|
],
|
|
446
671
|
lines: [
|
|
447
672
|
{ patchline: { source: ["obj-recv", 0], destination: ["obj-jweb", 0] } },
|
|
448
673
|
// [jweb] outlet 0 is the page's messages; tag them and send them out.
|
|
449
674
|
{ patchline: { source: ["obj-jweb", 0], destination: ["obj-tag", 0] } },
|
|
450
675
|
{ patchline: { source: ["obj-tag", 0], destination: ["obj-out", 0] } },
|
|
676
|
+
...floatLines(spec),
|
|
451
677
|
],
|
|
452
678
|
},
|
|
453
679
|
},
|
|
@@ -501,7 +727,25 @@ export function applyPersistence(ctx) {
|
|
|
501
727
|
// it binds to a NAMED object in the patcher, not to a box id (which is ours, and
|
|
502
728
|
// which Max is free to renumber). The dict's own name argument is what [js]
|
|
503
729
|
// addresses with `new Dict("obj-state-<id>")`, so all three agree.
|
|
504
|
-
|
|
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
|
+
);
|
|
505
749
|
|
|
506
750
|
const pattrId = `obj-pattr-${id}`;
|
|
507
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
|
+
}
|
package/templates/install-mac.sh
CHANGED
|
@@ -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.
|
|
21
|
-
"@m4l-jweb/surface": "^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.
|
|
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",
|