@m4l-jweb/build 0.6.0 → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m4l-jweb/build",
3
- "version": "0.6.0",
3
+ "version": "0.6.5",
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.6.0"
36
+ "@m4l-jweb/wrapper": "0.6.5"
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: 2, outlettype: ["bang", "bang"] }));
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}`, 1, `obj-samples-set-${slot}`, 0)); // right first: set the buffer
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
- lines.push(line("obj-samples-groove", ch, id, 1));
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
- const ctx = { boxes, lines, jwebId: "obj-jweb", unmatchedId, device: d, ...surfaceContext(surface) };
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
- // Frozen dependencies: readable by Max-native objects only (a poly~
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
@@ -324,7 +324,7 @@ export function applySurface(ctx) {
324
324
  * ------------------------------------------------------------------------------
325
325
  */
326
326
  export function applyWindows(ctx) {
327
- const { boxes, lines, surface } = ctx;
327
+ const { boxes, lines, surface, unmatchedId } = ctx;
328
328
  const windowIds = surface?.windows ? Object.keys(surface.windows) : [];
329
329
  if (windowIds.length === 0) return;
330
330
 
@@ -374,6 +374,12 @@ export function applyWindows(ctx) {
374
374
 
375
375
  const subpatcherId = `obj-window-${id}-sub`;
376
376
  lines.push(line(pcontrolId, 0, subpatcherId, 0));
377
+ // The window's [jweb] can now TALK BACK. Its output leaves the subpatcher on an
378
+ // [outlet] and this cord carries it to the wrapper's [js] - the same [js] the
379
+ // device view feeds. It is tagged `window <id>` inside (below), so the wrapper
380
+ // tells the two apart and can answer the right one. Without this the window's
381
+ // page could display but never send a message: the [jweb] outlet went nowhere.
382
+ lines.push(line(subpatcherId, 0, unmatchedId, 0));
377
383
  boxes.push({
378
384
  box: {
379
385
  id: subpatcherId,
@@ -383,7 +389,8 @@ export function applyWindows(ctx) {
383
389
  // subpatcher that has no inlets, so [pcontrol] would end up wired to
384
390
  // NOTHING - silently, in the saved file. That was attempt 1.
385
391
  numinlets: 1,
386
- numoutlets: 0,
392
+ numoutlets: 1,
393
+ outlettype: [""],
387
394
  patching_rect: [16, 620, 120, 22],
388
395
  patcher: {
389
396
  fileversion: 1,
@@ -418,8 +425,30 @@ export function applyWindows(ctx) {
418
425
  presentation_rect: [0, 0, spec.width, spec.height],
419
426
  },
420
427
  },
428
+ // TAG the window's messages with which window they are, so the wrapper's
429
+ // `window()` can answer THIS window (its [jweb] has no cord from [js], so
430
+ // a reply goes back by name). The page emits bare selectors; the tag is
431
+ // added here, in the patcher, not in the app - so a window page is an
432
+ // ordinary bridge client that happens to be in its own runtime.
433
+ {
434
+ box: {
435
+ id: "obj-tag",
436
+ maxclass: "newobj",
437
+ text: `prepend window ${id}`,
438
+ numinlets: 1,
439
+ numoutlets: 1,
440
+ outlettype: [""],
441
+ patching_rect: [16, 96 + spec.height + 16, 160, 22],
442
+ },
443
+ },
444
+ { box: { id: "obj-out", maxclass: "outlet", patching_rect: [16, 96 + spec.height + 48, 30, 30], numinlets: 1, numoutlets: 0 } },
445
+ ],
446
+ lines: [
447
+ { patchline: { source: ["obj-recv", 0], destination: ["obj-jweb", 0] } },
448
+ // [jweb] outlet 0 is the page's messages; tag them and send them out.
449
+ { patchline: { source: ["obj-jweb", 0], destination: ["obj-tag", 0] } },
450
+ { patchline: { source: ["obj-tag", 0], destination: ["obj-out", 0] } },
421
451
  ],
422
- lines: [{ patchline: { source: ["obj-recv", 0], destination: ["obj-jweb", 0] } }],
423
452
  },
424
453
  },
425
454
  });