@m4l-jweb/build 0.6.0 → 0.7.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 +2 -2
- package/src/chains.mjs +431 -4
- package/src/index.mjs +29 -4
- package/src/surface.mjs +177 -7
- 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.7.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",
|
|
@@ -33,6 +33,6 @@
|
|
|
33
33
|
"archiver": "^7.0.1",
|
|
34
34
|
"esbuild": "^0.25.0",
|
|
35
35
|
"typescript": "^5.7.0",
|
|
36
|
-
"@m4l-jweb/wrapper": "0.
|
|
36
|
+
"@m4l-jweb/wrapper": "0.7.0"
|
|
37
37
|
}
|
|
38
38
|
}
|
package/src/chains.mjs
CHANGED
|
@@ -363,7 +363,7 @@ function gainChain(ctx) {
|
|
|
363
363
|
* chain that reintroduces a mapping like that DOUBLE-MAPS a parameter that already
|
|
364
364
|
* carries its own curve, and lies to every readout while it does it.
|
|
365
365
|
*/
|
|
366
|
-
function fanParamInto(ctx, paramId, dstId, dstInlet) {
|
|
366
|
+
export function fanParamInto(ctx, paramId, dstId, dstInlet) {
|
|
367
367
|
const [objId, objOut] = ctx.paramObject(paramId);
|
|
368
368
|
const [routeId, routeOut] = ctx.paramValue(paramId);
|
|
369
369
|
ctx.lines.push(line(objId, objOut, dstId, dstInlet));
|
|
@@ -611,6 +611,22 @@ function samplesChain(ctx) {
|
|
|
611
611
|
);
|
|
612
612
|
lines.push(line("obj-samples-rate", 0, "obj-samples-groove", 0));
|
|
613
613
|
|
|
614
|
+
// MONO FOLD. `groove~ <buf> 2` hard-wires two signal outlets to L and R, and a
|
|
615
|
+
// MONO buffer drives outlet 0 ONLY - so a mono file (most of tidal-drum-machines
|
|
616
|
+
// is mono) plays in one ear. The channel count is not a build-time fact: [info~]
|
|
617
|
+
// MEASURES it when the buffer loads. So gate the R channel at RUNTIME - a
|
|
618
|
+
// [selector~ 2] whose control says which groove~ outlet is the real right channel:
|
|
619
|
+
// input 1 = groove~ outlet 1 (a stereo file's true R)
|
|
620
|
+
// input 2 = groove~ outlet 0 (fold the mono signal to R as well)
|
|
621
|
+
// The control is the currently-loaded slot's channel count run through
|
|
622
|
+
// [expr ($i1==1)+1]: mono(1) -> 2 (fold), stereo(2) -> 1 (real R). The L channel
|
|
623
|
+
// always takes groove~ outlet 0, so it needs no gate.
|
|
624
|
+
boxes.push(box("obj-samples-rsel", "selector~ 2", { numinlets: 3, numoutlets: 1, outlettype: ["signal"] }));
|
|
625
|
+
boxes.push(box("obj-samples-rgate", "expr ($i1==1)+1", { numinlets: 1, numoutlets: 1, outlettype: ["int"] }));
|
|
626
|
+
lines.push(line("obj-samples-groove", 1, "obj-samples-rsel", 1)); // stereo R -> input 1
|
|
627
|
+
lines.push(line("obj-samples-groove", 0, "obj-samples-rsel", 2)); // mono fold -> input 2
|
|
628
|
+
lines.push(line("obj-samples-rgate", 0, "obj-samples-rsel", 0)); // which one, per loaded slot
|
|
629
|
+
|
|
614
630
|
// Stop: a bare `buffer_stop` arrives from [route] as a BANG - the word is gone -
|
|
615
631
|
// so re-materialize it in a message box, or groove~ hears nothing it knows.
|
|
616
632
|
boxes.push(box("obj-samples-stopmsg", "stop", { maxclass: "message", numinlets: 2, numoutlets: 1 }));
|
|
@@ -648,18 +664,29 @@ function samplesChain(ctx) {
|
|
|
648
664
|
lines.push(line(`obj-samples-pack-${slot}`, 0, `obj-samples-ready-${slot}`, 0));
|
|
649
665
|
lines.push(line(`obj-samples-ready-${slot}`, 0, jwebId, 0));
|
|
650
666
|
|
|
667
|
+
// Retain THIS slot's measured channel count, so play can re-assert the mono
|
|
668
|
+
// fold for whichever buffer is loaded into the one shared groove~. [info~]
|
|
669
|
+
// outlet 8 stores it in [f]'s cold inlet (no output); the play trigger bangs it
|
|
670
|
+
// out to the shared gate below. Every slot's [f] feeds that one gate.
|
|
671
|
+
boxes.push(box(`obj-samples-chans-${slot}`, "f", { numinlets: 2, numoutlets: 1, outlettype: [""] }));
|
|
672
|
+
lines.push(line(info, 8, `obj-samples-chans-${slot}`, 1)); // measured channels, cold-stored
|
|
673
|
+
lines.push(line(`obj-samples-chans-${slot}`, 0, "obj-samples-rgate", 0));
|
|
674
|
+
|
|
651
675
|
// Play: pick the buffer, THEN start it. Two cords out of one outlet fire in an
|
|
652
676
|
// order Max chooses, so the sequence goes through a [t b b] - right outlet
|
|
653
677
|
// first - and never through a fan-out. Starting the OLD buffer and then
|
|
654
678
|
// switching is exactly the bug that looks like "the wrong sample previewed".
|
|
655
|
-
boxes.push(box(`obj-samples-trig-${slot}`, "t b b", { numinlets: 1, numoutlets:
|
|
679
|
+
boxes.push(box(`obj-samples-trig-${slot}`, "t b b b", { numinlets: 1, numoutlets: 3, outlettype: ["bang", "bang", "bang"] }));
|
|
656
680
|
boxes.push(box(`obj-samples-set-${slot}`, `set ${bufName(slot)}`, { maxclass: "message", numinlets: 2, numoutlets: 1 }));
|
|
657
681
|
// A float in groove~'s left inlet is a playback POSITION in ms, and 0 is "from
|
|
658
682
|
// the beginning" - which is also what starts it after a `stop`.
|
|
659
683
|
boxes.push(box(`obj-samples-start-${slot}`, "0", { maxclass: "message", numinlets: 2, numoutlets: 1 }));
|
|
660
684
|
|
|
685
|
+
// [t] fires right-to-left, so: set the fold gate, THEN the buffer, THEN start -
|
|
686
|
+
// the gate and the buffer are both in place before groove~ makes a sound.
|
|
661
687
|
lines.push(line("obj-samples-playslot", i, `obj-samples-trig-${slot}`, 0));
|
|
662
|
-
lines.push(line(`obj-samples-trig-${slot}`,
|
|
688
|
+
lines.push(line(`obj-samples-trig-${slot}`, 2, `obj-samples-chans-${slot}`, 0)); // first: assert the fold
|
|
689
|
+
lines.push(line(`obj-samples-trig-${slot}`, 1, `obj-samples-set-${slot}`, 0)); // then: set the buffer
|
|
663
690
|
lines.push(line(`obj-samples-trig-${slot}`, 0, `obj-samples-start-${slot}`, 0)); // ...then play it
|
|
664
691
|
lines.push(line(`obj-samples-set-${slot}`, 0, "obj-samples-groove", 0));
|
|
665
692
|
lines.push(line(`obj-samples-start-${slot}`, 0, "obj-samples-groove", 0));
|
|
@@ -674,7 +701,372 @@ function samplesChain(ctx) {
|
|
|
674
701
|
const [srcId, srcOut] = ctx.audioIn(ch);
|
|
675
702
|
boxes.push(box(id, "+~", { numinlets: 2, numoutlets: 1, outlettype: ["signal"] }));
|
|
676
703
|
lines.push(line(srcId, srcOut, id, 0));
|
|
677
|
-
|
|
704
|
+
// L takes groove~ outlet 0 directly; R takes the mono-fold [selector~] instead
|
|
705
|
+
// of groove~ outlet 1, so a mono buffer reaches both ears.
|
|
706
|
+
if (ch === 0) lines.push(line("obj-samples-groove", 0, id, 1));
|
|
707
|
+
else lines.push(line("obj-samples-rsel", 0, id, 1));
|
|
708
|
+
ctx.setAudioOut(ch, id, 0);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* "delay" - a feedback delay, sent from a dry/wet knob: `.delay()`, `.delaytime()`
|
|
714
|
+
* and `.delayfeedback()` mapped straight onto Max's delay line.
|
|
715
|
+
*
|
|
716
|
+
* dry -->[+~ fbsum]-->[tapin~ 2000]-->[tapout~ 250]--+--> wet
|
|
717
|
+
* ^ |
|
|
718
|
+
* +----------[*~ feedback]<-----------------+
|
|
719
|
+
* out = dry + delay * wet
|
|
720
|
+
*
|
|
721
|
+
* SEND-STYLE, WHICH IS WHAT MAKES IT NEUTRAL. The dry input reaches the output
|
|
722
|
+
* summing [+~] at UNITY, untouched; the delayed signal is scaled by the `delay`
|
|
723
|
+
* mix and added on top. At `delay` = 0 the wet [*~] contributes exactly 0.0, so the
|
|
724
|
+
* output is bit-for-bit the input - a straight wire, which is the neutrality
|
|
725
|
+
* contract this chain declares in CHAIN_NEUTRAL below. That is the whole reason a
|
|
726
|
+
* wet path is SUMMED rather than crossfaded: `dry + 0` is identity, `dry*1 + wet*0`
|
|
727
|
+
* is too but invites a `1 - mix` no one can null-test by eye.
|
|
728
|
+
*
|
|
729
|
+
* tapin~/tapout~, per Max's reference (read on disk, not remembered): tapin~ writes
|
|
730
|
+
* a signal into a delay line; tapout~ reads it back, and the connection between them
|
|
731
|
+
* is NOT a signal cord - it is the delay-line link. tapout~'s inlet 0 takes BOTH
|
|
732
|
+
* that link and the delay time (a float in ms), the same way groove~'s left inlet
|
|
733
|
+
* takes a signal and messages at once. `tapin~ 2000` sizes the line to the top of
|
|
734
|
+
* `delaytime`'s range; a delay time past the max is silently clamped, so the max is
|
|
735
|
+
* a real limit, not decoration.
|
|
736
|
+
*
|
|
737
|
+
* Feedback closes the loop THROUGH a [+~] before tapin~, so the delayed signal is
|
|
738
|
+
* re-injected with the dry input rather than replacing it. At `delayfeedback` = 0
|
|
739
|
+
* the loop gain is 0 and you get a single echo.
|
|
740
|
+
*
|
|
741
|
+
* Requires `delay`, `delaytime`, `delayfeedback` in the device's surface (or point
|
|
742
|
+
* the chain at other names with `delayParam` / `delaytimeParam` /
|
|
743
|
+
* `delayfeedbackParam`). All in REAL units: the mix and feedback are 0-1, the time
|
|
744
|
+
* is in ms - the chain does no arithmetic on any of them.
|
|
745
|
+
*/
|
|
746
|
+
function delayChain(ctx) {
|
|
747
|
+
const { boxes, lines, device } = ctx;
|
|
748
|
+
const mixParam = requireParam(ctx, "delay", device?.delayParam ?? "delay", "delayParam");
|
|
749
|
+
const timeParam = requireParam(ctx, "delay", device?.delaytimeParam ?? "delaytime", "delaytimeParam");
|
|
750
|
+
const fbParam = requireParam(ctx, "delay", device?.delayfeedbackParam ?? "delayfeedback", "delayfeedbackParam");
|
|
751
|
+
|
|
752
|
+
for (const [ch, s] of [
|
|
753
|
+
[0, "l"],
|
|
754
|
+
[1, "r"],
|
|
755
|
+
]) {
|
|
756
|
+
const [srcId, srcOut] = ctx.audioIn(ch);
|
|
757
|
+
const fbsum = `obj-delay-fbsum-${s}`;
|
|
758
|
+
const tin = `obj-delay-tapin-${s}`;
|
|
759
|
+
const tout = `obj-delay-tapout-${s}`;
|
|
760
|
+
const fb = `obj-delay-fb-${s}`;
|
|
761
|
+
const wet = `obj-delay-wet-${s}`;
|
|
762
|
+
const mix = `obj-delay-mix-${s}`;
|
|
763
|
+
|
|
764
|
+
boxes.push(box(fbsum, "+~", { numinlets: 2, numoutlets: 1, outlettype: ["signal"] }));
|
|
765
|
+
boxes.push(box(tin, "tapin~ 2000", { numinlets: 1, numoutlets: 1, outlettype: ["signal"] }));
|
|
766
|
+
boxes.push(box(tout, "tapout~ 250", { numinlets: 1, numoutlets: 1, outlettype: ["signal"] }));
|
|
767
|
+
// A float, not int - the feedback and wet gains start at 0.0 so the stage is a
|
|
768
|
+
// wire until its parameters load, and the right inlets stay in float mode.
|
|
769
|
+
boxes.push(box(fb, "*~ 0.", { numinlets: 2, numoutlets: 1, outlettype: ["signal"] }));
|
|
770
|
+
boxes.push(box(wet, "*~ 0.", { numinlets: 2, numoutlets: 1, outlettype: ["signal"] }));
|
|
771
|
+
boxes.push(box(mix, "+~", { numinlets: 2, numoutlets: 1, outlettype: ["signal"] }));
|
|
772
|
+
|
|
773
|
+
// dry (+ feedback) into the delay line
|
|
774
|
+
lines.push(line(srcId, srcOut, fbsum, 0));
|
|
775
|
+
lines.push(line(fb, 0, fbsum, 1));
|
|
776
|
+
lines.push(line(fbsum, 0, tin, 0));
|
|
777
|
+
lines.push(line(tin, 0, tout, 0)); // the delay-line link, not a signal cord
|
|
778
|
+
fanParamInto(ctx, timeParam, tout, 0); // delay time, ms, into tapout~'s inlet
|
|
779
|
+
|
|
780
|
+
// feedback loop: the tap, scaled, back to the sum
|
|
781
|
+
lines.push(line(tout, 0, fb, 0));
|
|
782
|
+
fanParamInto(ctx, fbParam, fb, 1);
|
|
783
|
+
|
|
784
|
+
// the send: dry at unity + the tap scaled by `delay`
|
|
785
|
+
lines.push(line(tout, 0, wet, 0));
|
|
786
|
+
fanParamInto(ctx, mixParam, wet, 1);
|
|
787
|
+
lines.push(line(srcId, srcOut, mix, 0));
|
|
788
|
+
lines.push(line(wet, 0, mix, 1));
|
|
789
|
+
ctx.setAudioOut(ch, mix, 0);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* "reverb" - `cverb~` sent from a dry/wet knob (`.room()`).
|
|
795
|
+
*
|
|
796
|
+
* dry --+--------------------> [+~ mix] --> out
|
|
797
|
+
* | ^
|
|
798
|
+
* +-->[cverb~]-->[*~ room]-+
|
|
799
|
+
*
|
|
800
|
+
* cverb~ SHIPS INSIDE LIVE (`resources/externals/m4l/cverb~.mxe64`, checked on disk)
|
|
801
|
+
* and is MONAURAL and WET-ONLY: its output is pure reverberation, no dry component.
|
|
802
|
+
* A wet-only object dropped straight in the path is a colouration you cannot switch
|
|
803
|
+
* off - the exact trap the neutrality contract exists to forbid. So the dry/wet is a
|
|
804
|
+
* property of THIS chain, not something a device is trusted to remember: the dry
|
|
805
|
+
* signal reaches the output [+~] at unity, and the reverb is scaled by `room` and
|
|
806
|
+
* summed on top. At `room` = 0 the wet [*~] is 0.0 and the output is the input,
|
|
807
|
+
* bit-for-bit (CHAIN_NEUTRAL below).
|
|
808
|
+
*
|
|
809
|
+
* Mono, so one cverb~ per channel. The reverb TIME is fixed at the object's argument
|
|
810
|
+
* - the `fx` surface exposes only the send (`room`), not a time - so there is no
|
|
811
|
+
* parameter for it; add one and fan it into inlet 1 (signal/float, reverb time in
|
|
812
|
+
* ms, per the reference) if a device wants it.
|
|
813
|
+
*
|
|
814
|
+
* Requires `room` in the device's surface (or `roomParam`), 0-1.
|
|
815
|
+
*/
|
|
816
|
+
function reverbChain(ctx) {
|
|
817
|
+
const { boxes, lines, device } = ctx;
|
|
818
|
+
const mixParam = requireParam(ctx, "reverb", device?.roomParam ?? "room", "roomParam");
|
|
819
|
+
|
|
820
|
+
for (const [ch, s] of [
|
|
821
|
+
[0, "l"],
|
|
822
|
+
[1, "r"],
|
|
823
|
+
]) {
|
|
824
|
+
const [srcId, srcOut] = ctx.audioIn(ch);
|
|
825
|
+
const rv = `obj-reverb-cverb-${s}`;
|
|
826
|
+
const wet = `obj-reverb-wet-${s}`;
|
|
827
|
+
const mix = `obj-reverb-mix-${s}`;
|
|
828
|
+
|
|
829
|
+
boxes.push(box(rv, "cverb~ 2000.", { numinlets: 2, numoutlets: 1, outlettype: ["signal"] }));
|
|
830
|
+
boxes.push(box(wet, "*~ 0.", { numinlets: 2, numoutlets: 1, outlettype: ["signal"] }));
|
|
831
|
+
boxes.push(box(mix, "+~", { numinlets: 2, numoutlets: 1, outlettype: ["signal"] }));
|
|
832
|
+
|
|
833
|
+
lines.push(line(srcId, srcOut, rv, 0));
|
|
834
|
+
lines.push(line(rv, 0, wet, 0));
|
|
835
|
+
fanParamInto(ctx, mixParam, wet, 1);
|
|
836
|
+
lines.push(line(srcId, srcOut, mix, 0)); // dry at unity
|
|
837
|
+
lines.push(line(wet, 0, mix, 1)); // + reverb scaled by `room`
|
|
838
|
+
ctx.setAudioOut(ch, mix, 0);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* The [poly~] voice patch: ONE played note, start to finish, as its own patcher.
|
|
844
|
+
*
|
|
845
|
+
* [poly~] loads N copies of this and hands each `note` message to the first voice
|
|
846
|
+
* whose [thispoly~] is not busy - so polyphony and voice-stealing are Max's job, not
|
|
847
|
+
* the app's, which is the whole reason to spend a [poly~] rather than run N groove~s
|
|
848
|
+
* and a scheduler by hand. Max cannot embed this inline (no factory device does; they
|
|
849
|
+
* all ship it as a named .maxpat), so the build freezes it into the .amxd as a
|
|
850
|
+
* dependency and [poly~] resolves it by name from the device's own bundle - the same
|
|
851
|
+
* way `Analogue Drums.amxd` carries `analog.Kick~.maxpat` (checked on disk).
|
|
852
|
+
*
|
|
853
|
+
* MULTI-SAMPLE. A voice can play ANY of the device's slots - one named [buffer~] per
|
|
854
|
+
* slot - so the instrument is a keymap, not one repitched sample. The voice request at
|
|
855
|
+
* [in 1] is the list `slot rate velocity durMs channels`:
|
|
856
|
+
* - slot -> which buffer: [sel 0 1 ...] picks the matching `set <bufName>`
|
|
857
|
+
* message, so groove~ switches to that slot's buffer before it starts.
|
|
858
|
+
* - rate -> playback RATE straight into [sig~] -> groove~'s left (signal) inlet.
|
|
859
|
+
* EXPLICIT, not derived: the app decides whether a note plays a
|
|
860
|
+
* dedicated sample at rate 1 or a repitched one at rate 2, which is
|
|
861
|
+
* what makes this a multi-sample keymap rather than one stretched
|
|
862
|
+
* buffer. No pitch->rate arithmetic here.
|
|
863
|
+
* - velocity -> amplitude, /127, on both channels' [*~].
|
|
864
|
+
* - durMs -> when to FREE the voice: [delay durMs] -> 0 to [thispoly~] and
|
|
865
|
+
* `stop` to groove~. The app times the note; Max holds the voice
|
|
866
|
+
* exactly that long. A one-shot past the sample's end is silent
|
|
867
|
+
* anyway (@loop 0), so this only bounds the allocation.
|
|
868
|
+
* - channels -> the mono fold, the SAME runtime gate the samples chain uses: a
|
|
869
|
+
* mono buffer drives groove~ outlet 0 only, so a [selector~ 2] keyed
|
|
870
|
+
* on the measured channel count folds it into R (see samplesChain).
|
|
871
|
+
*
|
|
872
|
+
* The order is sequenced with [t]: the whole list reaches [unpack] (buffer selected,
|
|
873
|
+
* rate, gate and duration all set) BEFORE the voice marks itself busy and starts
|
|
874
|
+
* groove~ from 0. Buffer-select is separate from start - `set <buf>` switches the
|
|
875
|
+
* buffer WITHOUT playing, and only the seq's `0` starts it - so a note never begins on
|
|
876
|
+
* the previous note's buffer or rate.
|
|
877
|
+
*/
|
|
878
|
+
function instrumentVoicePatch(bufNames) {
|
|
879
|
+
let vy = 40;
|
|
880
|
+
const vbox = (id, text, extra = {}) => ({
|
|
881
|
+
box: { id, maxclass: "newobj", text, numinlets: 1, numoutlets: 1, outlettype: [""], patching_rect: [24, (vy += 30), 150, 22], ...extra },
|
|
882
|
+
});
|
|
883
|
+
const vmsg = (id, text) => ({
|
|
884
|
+
box: { id, maxclass: "message", text, numinlets: 2, numoutlets: 1, outlettype: [""], patching_rect: [200, (vy += 30), 90, 22] },
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
const L = (s, so, d, di) => ({ patchline: { source: [s, so], destination: [d, di] } });
|
|
888
|
+
|
|
889
|
+
const boxes = [
|
|
890
|
+
vbox("v-in", "in 1", { numinlets: 0, numoutlets: 1 }),
|
|
891
|
+
vbox("v-trig", "t b l", { numoutlets: 2, outlettype: ["bang", ""] }),
|
|
892
|
+
vbox("v-unpack", "unpack 0 0. 0 0 0", { numoutlets: 5, outlettype: ["int", "float", "int", "int", "int"] }),
|
|
893
|
+
vbox("v-seq", "t b b", { numoutlets: 2, outlettype: ["bang", "bang"] }),
|
|
894
|
+
vmsg("v-busy", "1"),
|
|
895
|
+
vmsg("v-start", "0"),
|
|
896
|
+
vbox("v-thispoly", "thispoly~", { numoutlets: 2, outlettype: ["int", "int"] }),
|
|
897
|
+
// slot -> which buffer. [sel] fires the matching outlet; the last (rightmost) is
|
|
898
|
+
// the no-match passthrough, left unwired.
|
|
899
|
+
vbox("v-sel", `sel ${bufNames.map((_, i) => i).join(" ")}`, { numoutlets: bufNames.length + 1, outlettype: bufNames.map(() => "bang").concat("") }),
|
|
900
|
+
vbox("v-sig", "sig~", { numoutlets: 1, outlettype: ["signal"] }),
|
|
901
|
+
vbox("v-vel", "/ 127.", { outlettype: ["float"] }),
|
|
902
|
+
// groove~ starts on the FIRST slot's buffer; `set` switches it per note.
|
|
903
|
+
vbox("v-groove", `groove~ ${bufNames[0]} 2 @loop 0`, { numinlets: 3, numoutlets: 3, outlettype: ["signal", "signal", "signal"] }),
|
|
904
|
+
vbox("v-gate", "expr ($i1==1)+1", { outlettype: ["int"] }),
|
|
905
|
+
vbox("v-rsel", "selector~ 2", { numinlets: 3, numoutlets: 1, outlettype: ["signal"] }),
|
|
906
|
+
vbox("v-ampL", "*~", { numinlets: 2, numoutlets: 1, outlettype: ["signal"] }),
|
|
907
|
+
vbox("v-ampR", "*~", { numinlets: 2, numoutlets: 1, outlettype: ["signal"] }),
|
|
908
|
+
vbox("v-del", "delay", { numinlets: 2, numoutlets: 1, outlettype: ["bang"] }),
|
|
909
|
+
vbox("v-free", "t b b", { numoutlets: 2, outlettype: ["bang", "bang"] }),
|
|
910
|
+
vmsg("v-freebusy", "0"),
|
|
911
|
+
vmsg("v-stop", "stop"),
|
|
912
|
+
vbox("v-out1", "out~ 1", { numinlets: 1, numoutlets: 0 }),
|
|
913
|
+
vbox("v-out2", "out~ 2", { numinlets: 1, numoutlets: 0 }),
|
|
914
|
+
];
|
|
915
|
+
|
|
916
|
+
const lines = [
|
|
917
|
+
L("v-in", 0, "v-trig", 0),
|
|
918
|
+
// list first (right outlet), then the start sequence (left) - values before play.
|
|
919
|
+
L("v-trig", 1, "v-unpack", 0),
|
|
920
|
+
L("v-trig", 0, "v-seq", 0),
|
|
921
|
+
L("v-unpack", 0, "v-sel", 0), // slot -> pick a buffer
|
|
922
|
+
L("v-unpack", 1, "v-sig", 0), // rate (explicit) -> signal rate
|
|
923
|
+
L("v-sig", 0, "v-groove", 0),
|
|
924
|
+
L("v-unpack", 2, "v-vel", 0), // velocity -> amp
|
|
925
|
+
L("v-vel", 0, "v-ampL", 1),
|
|
926
|
+
L("v-vel", 0, "v-ampR", 1),
|
|
927
|
+
L("v-unpack", 3, "v-del", 1), // duration -> delay time (cold)
|
|
928
|
+
L("v-unpack", 4, "v-gate", 0), // channels -> mono fold gate
|
|
929
|
+
L("v-gate", 0, "v-rsel", 0),
|
|
930
|
+
// the start sequence: mark busy, then start groove~ from 0 AND arm the free timer.
|
|
931
|
+
L("v-seq", 1, "v-busy", 0),
|
|
932
|
+
L("v-busy", 0, "v-thispoly", 0),
|
|
933
|
+
L("v-seq", 0, "v-start", 0),
|
|
934
|
+
L("v-start", 0, "v-groove", 0),
|
|
935
|
+
L("v-seq", 0, "v-del", 0), // bang the delay: it fires after durMs
|
|
936
|
+
// groove~ out: L direct, R via the mono-fold selector (stereo R vs. folded mono).
|
|
937
|
+
L("v-groove", 0, "v-ampL", 0),
|
|
938
|
+
L("v-groove", 1, "v-rsel", 1), // stereo R
|
|
939
|
+
L("v-groove", 0, "v-rsel", 2), // mono fold
|
|
940
|
+
L("v-rsel", 0, "v-ampR", 0),
|
|
941
|
+
L("v-ampL", 0, "v-out1", 0),
|
|
942
|
+
L("v-ampR", 0, "v-out2", 0),
|
|
943
|
+
// free the voice when the note's duration elapses.
|
|
944
|
+
L("v-del", 0, "v-free", 0),
|
|
945
|
+
L("v-free", 1, "v-freebusy", 0),
|
|
946
|
+
L("v-freebusy", 0, "v-thispoly", 0),
|
|
947
|
+
L("v-free", 0, "v-stop", 0),
|
|
948
|
+
L("v-stop", 0, "v-groove", 0),
|
|
949
|
+
];
|
|
950
|
+
|
|
951
|
+
// One `set <buffer>, ` message per slot: [sel] outlet i switches groove~ to that
|
|
952
|
+
// slot's buffer WITHOUT starting it (the seq's `0` does that). A trailing comma
|
|
953
|
+
// would start it; there is none, deliberately.
|
|
954
|
+
bufNames.forEach((buf, i) => {
|
|
955
|
+
const setId = `v-set-${i}`;
|
|
956
|
+
boxes.push(vmsg(setId, `set ${buf}`));
|
|
957
|
+
lines.push(L("v-sel", i, setId, 0));
|
|
958
|
+
lines.push(L(setId, 0, "v-groove", 0));
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
return {
|
|
962
|
+
patcher: {
|
|
963
|
+
fileversion: 1,
|
|
964
|
+
appversion: { major: 8, minor: 0, revision: 0, architecture: "x64", modernui: 1 },
|
|
965
|
+
rect: [100, 100, 320, 600],
|
|
966
|
+
boxes,
|
|
967
|
+
lines,
|
|
968
|
+
},
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/**
|
|
973
|
+
* "instrument" - the marquee: a [poly~] of sample voices over a KEYMAP of named
|
|
974
|
+
* buffers, PLAYED by the note contract the bridge exports. `samples` made the first
|
|
975
|
+
* sound as ONE preview voice; this is the other half - N voices, N buffers, Max doing
|
|
976
|
+
* the allocation and stealing.
|
|
977
|
+
*
|
|
978
|
+
* app -> voice_play <slot> <rate> <vel> <durMs> <chans> -> [route] -> [prepend note] -> [poly~]
|
|
979
|
+
* buffer_load <slot> <path> -> [js] resolves -> [buffer~ <slot>]
|
|
980
|
+
* app <- buffer_ready <slot> <sr> <ms> <chans> when that slot's read completed
|
|
981
|
+
*
|
|
982
|
+
* The load path is the samples chain's, deliberately: the bytes never cross the bridge
|
|
983
|
+
* ([buffer~] reads the file, the wrapper resolves the path on its aux outlet), and the
|
|
984
|
+
* reply reports what [info~] MEASURED, not what was asked for. Every slot is its own
|
|
985
|
+
* named [buffer~]; the voice picks one per note by index and plays it at the rate the
|
|
986
|
+
* app chose - so a dedicated sample plays at rate 1 and a repitched one at rate 2,
|
|
987
|
+
* which is the app's decision, not the chain's.
|
|
988
|
+
*
|
|
989
|
+
* `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).
|
|
993
|
+
*
|
|
994
|
+
* It SUMS into the signal path ([+~]): an instrument makes sound where there was none
|
|
995
|
+
* at its input, so there is nothing to claim a stage over.
|
|
996
|
+
*/
|
|
997
|
+
function instrumentChain(ctx) {
|
|
998
|
+
const { boxes, lines, device, jwebId, unmatchedId } = ctx;
|
|
999
|
+
const slots = device?.slots ?? ["voice"];
|
|
1000
|
+
const bufName = (slot) => `buf-${device?.name}-${slot}`;
|
|
1001
|
+
const voices = device?.voices ?? 8;
|
|
1002
|
+
const voiceFile = `${device?.name}-voice.maxpat`;
|
|
1003
|
+
|
|
1004
|
+
// 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)) });
|
|
1007
|
+
// [poly~]'s name is the file WITHOUT its extension, per Max's abstraction lookup.
|
|
1008
|
+
boxes.push(
|
|
1009
|
+
box(`obj-instr-poly`, `poly~ ${voiceFile.replace(/\.maxpat$/, "")} ${voices}`, {
|
|
1010
|
+
numinlets: 1,
|
|
1011
|
+
numoutlets: 2,
|
|
1012
|
+
outlettype: ["signal", "signal"],
|
|
1013
|
+
}),
|
|
1014
|
+
);
|
|
1015
|
+
|
|
1016
|
+
// voice_play -> `note <slot> <rate> <vel> <durMs> <chans>` -> poly~. `route` strips
|
|
1017
|
+
// the selector, leaving the bare args; `prepend note` is the word poly~ dispatches on
|
|
1018
|
+
// to pick a free voice. Claimed in series so ui_ready still reaches the wrapper.
|
|
1019
|
+
boxes.push(box("obj-instr-playroute", "route voice_play", { numoutlets: 2, outlettype: ["", ""] }));
|
|
1020
|
+
claimAppMessages(ctx, "obj-instr-playroute", 1);
|
|
1021
|
+
boxes.push(box("obj-instr-note", "prepend note"));
|
|
1022
|
+
lines.push(line("obj-instr-playroute", 0, "obj-instr-note", 0));
|
|
1023
|
+
lines.push(line("obj-instr-note", 0, "obj-instr-poly", 0));
|
|
1024
|
+
|
|
1025
|
+
// The load path, from the wrapper's aux outlet - identical in shape to samples: a
|
|
1026
|
+
// bare buffer name resolves against MAX'S SEARCH PATH, not the device folder, so the
|
|
1027
|
+
// wrapper resolves it and hands back `buffer_replace <slot> <abs path>`. One buffer
|
|
1028
|
+
// per slot, dispatched by slot name (route matches a whole word).
|
|
1029
|
+
boxes.push(box("obj-instr-replaceroute", "route buffer_replace", { numoutlets: 2, outlettype: ["", ""] }));
|
|
1030
|
+
lines.push(line(unmatchedId, 1, "obj-instr-replaceroute", 0));
|
|
1031
|
+
boxes.push(box("obj-instr-loadslot", `route ${slots.join(" ")}`, { numoutlets: slots.length + 1, outlettype: slots.map(() => "").concat("") }));
|
|
1032
|
+
lines.push(line("obj-instr-replaceroute", 0, "obj-instr-loadslot", 0));
|
|
1033
|
+
|
|
1034
|
+
slots.forEach((slot, i) => {
|
|
1035
|
+
const buf = `obj-instr-buf-${slot}`;
|
|
1036
|
+
const info = `obj-instr-info-${slot}`;
|
|
1037
|
+
boxes.push(box(buf, `buffer~ ${bufName(slot)}`, { numinlets: 1, numoutlets: 2, outlettype: ["float", "bang"] }));
|
|
1038
|
+
boxes.push(box(`obj-instr-replace-${slot}`, "prepend replace"));
|
|
1039
|
+
lines.push(line("obj-instr-loadslot", i, `obj-instr-replace-${slot}`, 0));
|
|
1040
|
+
lines.push(line(`obj-instr-replace-${slot}`, 0, buf, 0));
|
|
1041
|
+
|
|
1042
|
+
// Report what LOADED, from the read-completed outlet (1). info~ fires right-to-left,
|
|
1043
|
+
// so the sample rate (outlet 0) arrives last and drives [pack]'s hot inlet.
|
|
1044
|
+
boxes.push(
|
|
1045
|
+
box(info, `info~ ${bufName(slot)}`, {
|
|
1046
|
+
numinlets: 1,
|
|
1047
|
+
numoutlets: 10,
|
|
1048
|
+
outlettype: ["float", "list", "float", "float", "float", "float", "float", "", "int", ""],
|
|
1049
|
+
}),
|
|
1050
|
+
);
|
|
1051
|
+
boxes.push(box(`obj-instr-pack-${slot}`, "pack 0. 0. 0", { numinlets: 3, numoutlets: 1, outlettype: [""] }));
|
|
1052
|
+
boxes.push(box(`obj-instr-ready-${slot}`, `prepend buffer_ready ${slot}`));
|
|
1053
|
+
lines.push(line(buf, 1, info, 0));
|
|
1054
|
+
lines.push(line(info, 8, `obj-instr-pack-${slot}`, 2)); // channels
|
|
1055
|
+
lines.push(line(info, 6, `obj-instr-pack-${slot}`, 1)); // duration ms
|
|
1056
|
+
lines.push(line(info, 0, `obj-instr-pack-${slot}`, 0)); // sample rate, hot
|
|
1057
|
+
lines.push(line(`obj-instr-pack-${slot}`, 0, `obj-instr-ready-${slot}`, 0));
|
|
1058
|
+
lines.push(line(`obj-instr-ready-${slot}`, 0, jwebId, 0));
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
// Sum the voices into the signal path - an instrument originates sound.
|
|
1062
|
+
for (const [ch, id] of [
|
|
1063
|
+
[0, "obj-instr-mix-l"],
|
|
1064
|
+
[1, "obj-instr-mix-r"],
|
|
1065
|
+
]) {
|
|
1066
|
+
const [srcId, srcOut] = ctx.audioIn(ch);
|
|
1067
|
+
boxes.push(box(id, "+~", { numinlets: 2, numoutlets: 1, outlettype: ["signal"] }));
|
|
1068
|
+
lines.push(line(srcId, srcOut, id, 0));
|
|
1069
|
+
lines.push(line("obj-instr-poly", ch, id, 1));
|
|
678
1070
|
ctx.setAudioOut(ch, id, 0);
|
|
679
1071
|
}
|
|
680
1072
|
}
|
|
@@ -682,14 +1074,49 @@ function samplesChain(ctx) {
|
|
|
682
1074
|
export const CHAINS = {
|
|
683
1075
|
midiin: midiInChain,
|
|
684
1076
|
samples: samplesChain,
|
|
1077
|
+
instrument: instrumentChain,
|
|
685
1078
|
midiout: midiOutChain,
|
|
686
1079
|
passthrough: passthroughChain,
|
|
687
1080
|
gain: gainChain,
|
|
688
1081
|
lowpass: lowpassChain,
|
|
689
1082
|
drive: driveChain,
|
|
1083
|
+
delay: delayChain,
|
|
1084
|
+
reverb: reverbChain,
|
|
690
1085
|
download: downloadChain,
|
|
691
1086
|
};
|
|
692
1087
|
|
|
1088
|
+
/**
|
|
1089
|
+
* The NEUTRALITY CONTRACT: the parameter value at which each stage is a straight
|
|
1090
|
+
* wire - identical input and output samples.
|
|
1091
|
+
*
|
|
1092
|
+
* The DSP graph is written at BUILD time and every stage is ALWAYS in the signal
|
|
1093
|
+
* path, including the ones a device's line never mentions. That is only safe if each
|
|
1094
|
+
* stage has a setting where it does nothing, and if the library can say what it is.
|
|
1095
|
+
* Two kinds of stage reach neutral two ways:
|
|
1096
|
+
*
|
|
1097
|
+
* 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.
|
|
1099
|
+
* send-style wet/dry - `delay` (0), `reverb` (0): the wet branch is scaled to 0.0
|
|
1100
|
+
* and summed onto an untouched dry path, so the output is `dry + 0`. A WET-ONLY
|
|
1101
|
+
* object (cverb~) has NO neutral of its own - it must carry this dry/wet, which
|
|
1102
|
+
* is why it is a property of the chain and pinned here, not left to a device.
|
|
1103
|
+
*
|
|
1104
|
+
* `tests/neutrality.test.mjs` null-tests the send-style stages structurally (the dry
|
|
1105
|
+
* wire survives at unity and the only other path is gain-0), which is what can be
|
|
1106
|
+
* proven with no Max in the loop; the bit-exact acoustic check is a listening test,
|
|
1107
|
+
* the same standing as hello-audio.
|
|
1108
|
+
*/
|
|
1109
|
+
export const CHAIN_NEUTRAL = {
|
|
1110
|
+
gain: { gain: 1 },
|
|
1111
|
+
lowpass: { cutoff: 18000 },
|
|
1112
|
+
drive: { drive: 1 },
|
|
1113
|
+
delay: { delay: 0 },
|
|
1114
|
+
reverb: { room: 0 },
|
|
1115
|
+
};
|
|
1116
|
+
|
|
1117
|
+
/** The send-style chains: wet-only or naturally additive, neutral by a 0 mix they own. */
|
|
1118
|
+
export const WET_DRY_CHAINS = ["delay", "reverb"];
|
|
1119
|
+
|
|
693
1120
|
/** Add a chain to the vocabulary. Called before generatePatchers(). */
|
|
694
1121
|
export function registerChain(name, fn) {
|
|
695
1122
|
CHAINS[name] = fn;
|
package/src/index.mjs
CHANGED
|
@@ -196,7 +196,13 @@ export function composePatcher(base, d, surface) {
|
|
|
196
196
|
* which updates it WITHOUT output, so the object would
|
|
197
197
|
* never pass that value on. See surface.mjs.
|
|
198
198
|
*/
|
|
199
|
-
|
|
199
|
+
// Extra frozen dependencies a chain contributes to the .amxd - a [poly~] voice
|
|
200
|
+
// patch, say, which Max cannot embed inline and must resolve as a named .maxpat
|
|
201
|
+
// from the device's own bundle (the same way a frozen M4L instrument ships its
|
|
202
|
+
// voice abstraction). A chain pushes { name, data } here; generatePatchers writes
|
|
203
|
+
// each next to the device patcher and packageDevices freezes it into the container.
|
|
204
|
+
const extras = [];
|
|
205
|
+
const ctx = { boxes, lines, jwebId: "obj-jweb", unmatchedId, device: d, extras, ...surfaceContext(surface) };
|
|
200
206
|
|
|
201
207
|
openAudio(ctx);
|
|
202
208
|
|
|
@@ -212,6 +218,9 @@ export function composePatcher(base, d, surface) {
|
|
|
212
218
|
closeAudio(ctx);
|
|
213
219
|
assertUniqueBoxIds(boxes, d.name);
|
|
214
220
|
|
|
221
|
+
// Ride the chain-contributed extras out on the returned object. Destructuring
|
|
222
|
+
// `{ patcher }` (the tests, the writer) ignores it; the packager reads it.
|
|
223
|
+
p.extras = extras;
|
|
215
224
|
return p;
|
|
216
225
|
}
|
|
217
226
|
|
|
@@ -239,6 +248,15 @@ export async function generatePatchers(root) {
|
|
|
239
248
|
const surface = await loadSurface(root, d.ui ?? d.name);
|
|
240
249
|
const p = composePatcher(base, d, surface);
|
|
241
250
|
|
|
251
|
+
// A chain's frozen dependencies (e.g. a [poly~] voice patch) are written beside
|
|
252
|
+
// the device patcher and their names recorded in a sidecar, so packageDevices -
|
|
253
|
+
// a separate pass that only sees dist/ - knows which files to freeze into which
|
|
254
|
+
// device. Strip them from the patcher json itself; they are not part of it.
|
|
255
|
+
const extras = p.extras ?? [];
|
|
256
|
+
delete p.extras;
|
|
257
|
+
for (const ex of extras) writeFileSync(path.join(outDir, ex.name), typeof ex.data === "string" ? ex.data : JSON.stringify(ex.data));
|
|
258
|
+
writeFileSync(path.join(outDir, `${d.name}.extras.json`), JSON.stringify(extras.map((e) => e.name)));
|
|
259
|
+
|
|
242
260
|
writeFileSync(path.join(outDir, `${d.name}.json`), JSON.stringify(p, null, "\t"));
|
|
243
261
|
const params = surface ? surface.ids.join(", ") : "none";
|
|
244
262
|
console.log(`m4l-jweb: ${d.name}.json (${d.type}, chains: ${(d.chains ?? []).join(", ") || "none"}, params: ${params || "none"})`);
|
|
@@ -321,13 +339,20 @@ export async function packageDevices(root) {
|
|
|
321
339
|
|
|
322
340
|
if (payloads.length) wrapperData += extraPayloadsJs(payloads);
|
|
323
341
|
|
|
342
|
+
// Frozen dependencies: readable by Max-native objects only (a poly~ voice
|
|
343
|
+
// patcher, say), which is exactly why they can stay frozen rather than being
|
|
344
|
+
// extracted to disk like the UI. Two sources: manifest `extraFiles` (files in
|
|
345
|
+
// the repo) and chain-generated files recorded in the sidecar by generatePatchers.
|
|
346
|
+
const chainExtrasList = path.join(dist, "patchers", `${d.name}.extras.json`);
|
|
347
|
+
const chainExtras = existsSync(chainExtrasList)
|
|
348
|
+
? JSON.parse(readFileSync(chainExtrasList, "utf8")).map((name) => ({ name, data: readFileSync(path.join(dist, "patchers", name)) }))
|
|
349
|
+
: [];
|
|
350
|
+
|
|
324
351
|
const amxd = buildAmxd({
|
|
325
352
|
patcherJson: readFileSync(path.join(dist, "patchers", `${d.name}.json`), "utf8"),
|
|
326
353
|
wrapperJs: wrapperData,
|
|
327
354
|
deviceName,
|
|
328
|
-
|
|
329
|
-
// voice patcher, say), which is exactly why they can stay frozen.
|
|
330
|
-
extras: (d.extraFiles ?? []).map((f) => ({ name: path.basename(f), data: readFileSync(path.join(root, f)) })),
|
|
355
|
+
extras: [...(d.extraFiles ?? []).map((f) => ({ name: path.basename(f), data: readFileSync(path.join(root, f)) })), ...chainExtras],
|
|
331
356
|
});
|
|
332
357
|
writeFileSync(path.join(outDir, deviceName), amxd);
|
|
333
358
|
console.log(`m4l-jweb: ${deviceName} (${d.type}, ${amxd.length} bytes)`);
|
package/src/surface.mjs
CHANGED
|
@@ -124,7 +124,61 @@ export async function loadSurface(root, uiDir) {
|
|
|
124
124
|
* Generating the objects
|
|
125
125
|
* ------------------------------------------------------------------ */
|
|
126
126
|
|
|
127
|
-
const MAXCLASS = { dial: "live.dial", toggle: "live.toggle", menu: "live.menu" };
|
|
127
|
+
const MAXCLASS = { dial: "live.dial", toggle: "live.toggle", menu: "live.menu", button: "live.text" };
|
|
128
|
+
|
|
129
|
+
/* ------------------------------------------------------------------ *
|
|
130
|
+
* Native declarative layout (surface `layout.native`)
|
|
131
|
+
*
|
|
132
|
+
* A parameter listed in `layout.native` renders as a native `live.*` object IN
|
|
133
|
+
* THE DEVICE VIEW, next to a `[jweb]` shifted right to make room. This is a pure
|
|
134
|
+
* PRESENTATION overlay: the dial the compiler already emits gains three keys
|
|
135
|
+
* (`presentation`, `presentation_rect`, `varname`) and nothing about its wiring,
|
|
136
|
+
* its fan-out or the app's `set_<id>` route changes. A dial that carries no rect
|
|
137
|
+
* is exactly today's invisible object.
|
|
138
|
+
* ------------------------------------------------------------------ */
|
|
139
|
+
|
|
140
|
+
// The device view Live gives an M4L device. Height is fixed at ~169 px; the width
|
|
141
|
+
// is whatever the presentation content needs, which is the whole mechanism this
|
|
142
|
+
// relies on (Live recomputes device width from the presentation rects).
|
|
143
|
+
const DEVICE_H = 169;
|
|
144
|
+
const MARGIN = 8;
|
|
145
|
+
// Per-kind native sizes, from Max's own live.* defaults. A live.dial includes its
|
|
146
|
+
// own label under the knob, which is why it is taller than a bare toggle.
|
|
147
|
+
const NATIVE_SIZE = { dial: [44, 48], toggle: [44, 15], menu: [100, 15], button: [48, 15] };
|
|
148
|
+
// Vertical pitch: a dial is 48 px tall and wants 8 px of air beneath its label.
|
|
149
|
+
const PITCH_Y = 56;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* `id -> presentation_rect` for every native parameter, plus the zone's total
|
|
153
|
+
* width (how far `[jweb]` shifts right). A pure function of the declaration, so
|
|
154
|
+
* `tests/` can drive it directly.
|
|
155
|
+
*
|
|
156
|
+
* Column-major: fills `rows` down the first column, then starts a new column to
|
|
157
|
+
* the right. Adding a parameter at the end therefore never reshuffles the ones
|
|
158
|
+
* before it - the reading order stays stable.
|
|
159
|
+
*/
|
|
160
|
+
export function computeNativeSlots(surface) {
|
|
161
|
+
const native = surface?.layout?.native;
|
|
162
|
+
if (!native || native.params.length === 0) return { slots: new Map(), width: 0 };
|
|
163
|
+
const rows = native.rows ?? 3;
|
|
164
|
+
const slots = new Map();
|
|
165
|
+
let row = 0;
|
|
166
|
+
let colW = 0;
|
|
167
|
+
let x = MARGIN;
|
|
168
|
+
for (const id of native.params) {
|
|
169
|
+
const [w, h] = NATIVE_SIZE[surface.params[id].kind];
|
|
170
|
+
if (row >= rows) {
|
|
171
|
+
// Column full: step right by the widest box in the column just finished.
|
|
172
|
+
row = 0;
|
|
173
|
+
x += colW + MARGIN;
|
|
174
|
+
colW = 0;
|
|
175
|
+
}
|
|
176
|
+
slots.set(id, [x, MARGIN + row * PITCH_Y, w, h]);
|
|
177
|
+
colW = Math.max(colW, w);
|
|
178
|
+
row += 1;
|
|
179
|
+
}
|
|
180
|
+
return { slots, width: x + colW + MARGIN };
|
|
181
|
+
}
|
|
128
182
|
|
|
129
183
|
/**
|
|
130
184
|
* Max's `parameter_type`: 0 = float, 1 = int, 2 = enum.
|
|
@@ -134,7 +188,7 @@ const MAXCLASS = { dial: "live.dial", toggle: "live.toggle", menu: "live.menu" }
|
|
|
134
188
|
* where a float one would read "2.4 of [off 1/4 1/8 ...]".
|
|
135
189
|
*/
|
|
136
190
|
function parameterType(spec) {
|
|
137
|
-
if (spec.kind === "menu" || spec.kind === "toggle") return 2;
|
|
191
|
+
if (spec.kind === "menu" || spec.kind === "toggle" || spec.kind === "button") return 2;
|
|
138
192
|
return spec.step === 1 ? 1 : 0;
|
|
139
193
|
}
|
|
140
194
|
|
|
@@ -183,7 +237,7 @@ function unitAttrs(spec) {
|
|
|
183
237
|
|
|
184
238
|
/** The parameter's value as MAX stores it: numbers, always. */
|
|
185
239
|
function initialValue(spec) {
|
|
186
|
-
if (spec.kind === "toggle") return spec.default ? 1 : 0;
|
|
240
|
+
if (spec.kind === "toggle" || spec.kind === "button") return spec.default ? 1 : 0;
|
|
187
241
|
if (spec.kind === "menu") return spec.options.indexOf(spec.default);
|
|
188
242
|
return spec.default;
|
|
189
243
|
}
|
|
@@ -226,7 +280,7 @@ function parameterAttrs(id, spec) {
|
|
|
226
280
|
if (spec.steps !== undefined) attrs.parameter_steps = spec.steps;
|
|
227
281
|
}
|
|
228
282
|
|
|
229
|
-
if (spec.kind === "toggle") {
|
|
283
|
+
if (spec.kind === "toggle" || spec.kind === "button") {
|
|
230
284
|
attrs.parameter_mmax = 1;
|
|
231
285
|
attrs.parameter_enum = ["off", "on"];
|
|
232
286
|
}
|
|
@@ -239,6 +293,16 @@ function parameterAttrs(id, spec) {
|
|
|
239
293
|
return attrs;
|
|
240
294
|
}
|
|
241
295
|
|
|
296
|
+
/**
|
|
297
|
+
* Box-level (not parameter) attributes a kind needs. A `button` is a `live.text`,
|
|
298
|
+
* which unlike a `live.toggle` carries VISIBLE TEXT: `text`/`texton` is the label it
|
|
299
|
+
* shows off/on, and `mode: 1` makes it a toggle rather than a momentary button.
|
|
300
|
+
*/
|
|
301
|
+
function kindBoxAttrs(spec) {
|
|
302
|
+
if (spec.kind === "button") return { text: spec.label, texton: spec.label, mode: 1 };
|
|
303
|
+
return {};
|
|
304
|
+
}
|
|
305
|
+
|
|
242
306
|
/**
|
|
243
307
|
* Compile the Surface into the patcher.
|
|
244
308
|
*
|
|
@@ -250,9 +314,29 @@ export function applySurface(ctx) {
|
|
|
250
314
|
const { boxes, lines, surface, jwebId } = ctx;
|
|
251
315
|
if (!surface || surface.ids.length === 0) return;
|
|
252
316
|
|
|
317
|
+
// Where the native dials go, if any were declared. `slots` is empty for a
|
|
318
|
+
// surface with no `layout`, and then this whole feature is inert - a param
|
|
319
|
+
// carries no presentation rect and stays the invisible object it is today.
|
|
320
|
+
const native = surface.layout?.native;
|
|
321
|
+
const { slots, width: nativeW } = computeNativeSlots(surface);
|
|
322
|
+
|
|
323
|
+
// The view switch (panel layouts) is NOT a grid dial: it is pinned to the
|
|
324
|
+
// top-right, over where the web UI paints its own switch button, so the control
|
|
325
|
+
// stays in one place when the app flips between the two views. It is excluded from
|
|
326
|
+
// `computeNativeSlots` (it is not in `native.params`), so give it a rect here.
|
|
327
|
+
const switchId = native?.switch;
|
|
328
|
+
const jwebBox = boxes.find((b) => b.box.id === jwebId)?.box;
|
|
329
|
+
const [, , jpw] = jwebBox?.presentation_rect ?? [0, 0, 420, DEVICE_H];
|
|
330
|
+
let switchRect = null;
|
|
331
|
+
if (switchId) {
|
|
332
|
+
const [sw, sh] = NATIVE_SIZE[surface.params[switchId].kind];
|
|
333
|
+
switchRect = [jpw - sw - MARGIN, MARGIN, sw, sh];
|
|
334
|
+
}
|
|
335
|
+
|
|
253
336
|
let x = 480;
|
|
254
337
|
for (const id of surface.ids) {
|
|
255
338
|
const spec = surface.params[id];
|
|
339
|
+
const rect = id === switchId ? switchRect : slots.get(id);
|
|
256
340
|
boxes.push({
|
|
257
341
|
box: {
|
|
258
342
|
id: paramObject(id),
|
|
@@ -262,6 +346,13 @@ export function applySurface(ctx) {
|
|
|
262
346
|
outlettype: [""],
|
|
263
347
|
parameter_enable: 1,
|
|
264
348
|
patching_rect: [x, 300, 44, 48],
|
|
349
|
+
...kindBoxAttrs(spec),
|
|
350
|
+
// A native param is shown in the device view: it gets a presentation rect
|
|
351
|
+
// and a scripting name (prefixed `param-` so it cannot collide with a state
|
|
352
|
+
// dict's `obj-state-<id>` varname). Everything else about the box - the
|
|
353
|
+
// wiring below, the fan-out, useParam() - is UNCHANGED. Presentation is
|
|
354
|
+
// purely a display overlay on the same graph.
|
|
355
|
+
...(rect ? { presentation: 1, presentation_rect: rect, varname: `param-${id}` } : {}),
|
|
265
356
|
saved_attribute_attributes: { valueof: parameterAttrs(id, spec) },
|
|
266
357
|
},
|
|
267
358
|
});
|
|
@@ -273,6 +364,26 @@ export function applySurface(ctx) {
|
|
|
273
364
|
x += 56;
|
|
274
365
|
}
|
|
275
366
|
|
|
367
|
+
// Position [jweb] for the native layout. WIDTH is preserved (React layouts were
|
|
368
|
+
// built for 420 px). A surface with no native content leaves [jweb] where the
|
|
369
|
+
// template put it.
|
|
370
|
+
if ((nativeW > 0 || switchId) && jwebBox) {
|
|
371
|
+
const [, py, pw, ph] = jwebBox.presentation_rect ?? [0, 0, 420, DEVICE_H];
|
|
372
|
+
if (native.panel) {
|
|
373
|
+
// LAYERED two-screen: [jweb] covers the whole device and the dials overlap its
|
|
374
|
+
// left. The app shows one layer at a time (useNativePanel), so the overlap is
|
|
375
|
+
// never seen - web mode fills the full width, no reserved strip.
|
|
376
|
+
jwebBox.presentation_rect = [0, py, Math.max(pw, nativeW), ph];
|
|
377
|
+
} else {
|
|
378
|
+
// Side-by-side: the dials to the left, [jweb] shifted right, both visible.
|
|
379
|
+
jwebBox.presentation_rect = [nativeW, py, pw, ph];
|
|
380
|
+
}
|
|
381
|
+
// Give [jweb] a scripting name so the wrapper can hide/show it at runtime
|
|
382
|
+
// (useNativePanel). Must equal JWEB_VARNAME in @m4l-jweb/surface - the one string
|
|
383
|
+
// the app and this codegen must agree on.
|
|
384
|
+
jwebBox.varname = "obj-jweb";
|
|
385
|
+
}
|
|
386
|
+
|
|
276
387
|
// Write direction: one route for every `set_<id>` the app can send. It goes at
|
|
277
388
|
// the END of the chain of routes (see claimAppMessages), so a chain that already
|
|
278
389
|
// took [jweb]'s outlet keeps it and hands us what it did not match.
|
|
@@ -295,6 +406,36 @@ export function applySurface(ctx) {
|
|
|
295
406
|
});
|
|
296
407
|
}
|
|
297
408
|
|
|
409
|
+
/**
|
|
410
|
+
* Runtime show/hide of native dials - the `layout.native` visibility override.
|
|
411
|
+
*
|
|
412
|
+
* `layout.native` makes a parameter a native `live.*` object, and its presentation
|
|
413
|
+
* is STATIC: `presentation: 1` is stamped into the .amxd at build time, so every
|
|
414
|
+
* listed dial is always visible. A device like the fx line wants the opposite - a
|
|
415
|
+
* stage the current line does not name should not clutter the view, the way its old
|
|
416
|
+
* HTML slider was simply not rendered. React can hide a slider; a native object
|
|
417
|
+
* cannot hide itself, so the app drives it from outside via `native_show`/
|
|
418
|
+
* `native_hide <varname>` (useNativeVisibility).
|
|
419
|
+
*
|
|
420
|
+
* ------------------------------------------------------------------------------
|
|
421
|
+
* THIS IS A SPIKE, and its FIRST mechanism already FAILED IN LIVE.
|
|
422
|
+
*
|
|
423
|
+
* Attempt 1 (this codegen, now retired): a `[thispatcher]` running `script hide
|
|
424
|
+
* <varname>`. Verified in Live - the dials did NOT disappear. `script hide` acts on
|
|
425
|
+
* the PATCHING canvas; the M4L device view is the PRESENTATION, and `script` has no
|
|
426
|
+
* documented reach into it.
|
|
427
|
+
*
|
|
428
|
+
* Attempt 2 (current): the wrapper's [js] handles `native_show`/`native_hide` and
|
|
429
|
+
* manipulates the object through the Maxobj API (`this.patcher.getnamed(varname)`),
|
|
430
|
+
* with console diagnostics. So this codegen no longer claims the messages - it lets
|
|
431
|
+
* them fall through to the wrapper. See `native_show`/`native_hide` in core.ts.
|
|
432
|
+
*
|
|
433
|
+
* If attempt 2 also fails to reach the presentation, the honest conclusion is that a
|
|
434
|
+
* frozen M4L device cannot hide a native object at runtime, and the fallback is a
|
|
435
|
+
* build-time choice (fewer native params, or a device keeps HTML controls).
|
|
436
|
+
* ------------------------------------------------------------------------------
|
|
437
|
+
*/
|
|
438
|
+
|
|
298
439
|
/**
|
|
299
440
|
* Compile a declared `window` into the patcher: a subpatcher holding its own
|
|
300
441
|
* [jweb], and a [pcontrol] that opens it when the app asks.
|
|
@@ -324,7 +465,7 @@ export function applySurface(ctx) {
|
|
|
324
465
|
* ------------------------------------------------------------------------------
|
|
325
466
|
*/
|
|
326
467
|
export function applyWindows(ctx) {
|
|
327
|
-
const { boxes, lines, surface } = ctx;
|
|
468
|
+
const { boxes, lines, surface, unmatchedId } = ctx;
|
|
328
469
|
const windowIds = surface?.windows ? Object.keys(surface.windows) : [];
|
|
329
470
|
if (windowIds.length === 0) return;
|
|
330
471
|
|
|
@@ -374,6 +515,12 @@ export function applyWindows(ctx) {
|
|
|
374
515
|
|
|
375
516
|
const subpatcherId = `obj-window-${id}-sub`;
|
|
376
517
|
lines.push(line(pcontrolId, 0, subpatcherId, 0));
|
|
518
|
+
// The window's [jweb] can now TALK BACK. Its output leaves the subpatcher on an
|
|
519
|
+
// [outlet] and this cord carries it to the wrapper's [js] - the same [js] the
|
|
520
|
+
// device view feeds. It is tagged `window <id>` inside (below), so the wrapper
|
|
521
|
+
// tells the two apart and can answer the right one. Without this the window's
|
|
522
|
+
// page could display but never send a message: the [jweb] outlet went nowhere.
|
|
523
|
+
lines.push(line(subpatcherId, 0, unmatchedId, 0));
|
|
377
524
|
boxes.push({
|
|
378
525
|
box: {
|
|
379
526
|
id: subpatcherId,
|
|
@@ -383,7 +530,8 @@ export function applyWindows(ctx) {
|
|
|
383
530
|
// subpatcher that has no inlets, so [pcontrol] would end up wired to
|
|
384
531
|
// NOTHING - silently, in the saved file. That was attempt 1.
|
|
385
532
|
numinlets: 1,
|
|
386
|
-
numoutlets:
|
|
533
|
+
numoutlets: 1,
|
|
534
|
+
outlettype: [""],
|
|
387
535
|
patching_rect: [16, 620, 120, 22],
|
|
388
536
|
patcher: {
|
|
389
537
|
fileversion: 1,
|
|
@@ -418,8 +566,30 @@ export function applyWindows(ctx) {
|
|
|
418
566
|
presentation_rect: [0, 0, spec.width, spec.height],
|
|
419
567
|
},
|
|
420
568
|
},
|
|
569
|
+
// TAG the window's messages with which window they are, so the wrapper's
|
|
570
|
+
// `window()` can answer THIS window (its [jweb] has no cord from [js], so
|
|
571
|
+
// a reply goes back by name). The page emits bare selectors; the tag is
|
|
572
|
+
// added here, in the patcher, not in the app - so a window page is an
|
|
573
|
+
// ordinary bridge client that happens to be in its own runtime.
|
|
574
|
+
{
|
|
575
|
+
box: {
|
|
576
|
+
id: "obj-tag",
|
|
577
|
+
maxclass: "newobj",
|
|
578
|
+
text: `prepend window ${id}`,
|
|
579
|
+
numinlets: 1,
|
|
580
|
+
numoutlets: 1,
|
|
581
|
+
outlettype: [""],
|
|
582
|
+
patching_rect: [16, 96 + spec.height + 16, 160, 22],
|
|
583
|
+
},
|
|
584
|
+
},
|
|
585
|
+
{ box: { id: "obj-out", maxclass: "outlet", patching_rect: [16, 96 + spec.height + 48, 30, 30], numinlets: 1, numoutlets: 0 } },
|
|
586
|
+
],
|
|
587
|
+
lines: [
|
|
588
|
+
{ patchline: { source: ["obj-recv", 0], destination: ["obj-jweb", 0] } },
|
|
589
|
+
// [jweb] outlet 0 is the page's messages; tag them and send them out.
|
|
590
|
+
{ patchline: { source: ["obj-jweb", 0], destination: ["obj-tag", 0] } },
|
|
591
|
+
{ patchline: { source: ["obj-tag", 0], destination: ["obj-out", 0] } },
|
|
421
592
|
],
|
|
422
|
-
lines: [{ patchline: { source: ["obj-recv", 0], destination: ["obj-jweb", 0] } }],
|
|
423
593
|
},
|
|
424
594
|
},
|
|
425
595
|
});
|
|
@@ -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.7.0",
|
|
21
|
+
"@m4l-jweb/surface": "^0.7.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.7.0",
|
|
27
27
|
"@types/node": "^22.0.0",
|
|
28
28
|
"@types/react": "^19.0.0",
|
|
29
29
|
"@types/react-dom": "^19.0.0",
|