@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m4l-jweb/build",
3
- "version": "0.6.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.6.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: 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
@@ -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: 0,
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.6.0",
21
- "@m4l-jweb/surface": "^0.6.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.6.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",