@m4l-jweb/build 0.7.0 → 0.9.1
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 +104 -1
- 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.1",
|
|
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.1"
|
|
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
|
|
@@ -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
|
-
|
|
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
|
+
}
|
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",
|