@m4l-jweb/build 0.9.1 → 0.9.9

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.9.1",
3
+ "version": "0.9.9",
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",
@@ -34,6 +34,6 @@
34
34
  "archiver": "^7.0.1",
35
35
  "esbuild": "^0.25.0",
36
36
  "typescript": "^5.7.0",
37
- "@m4l-jweb/wrapper": "0.9.1"
37
+ "@m4l-jweb/wrapper": "0.9.9"
38
38
  }
39
39
  }
package/src/chains.mjs CHANGED
@@ -82,7 +82,7 @@ export function removeLine(lines, srcId, dstId) {
82
82
  * and fatal in Live - it is the bang every LiveAPI observer is created from.
83
83
  */
84
84
  export function claimAppMessages(ctx, routeId, unmatchedOutlet) {
85
- const [srcId, srcOutlet] = ctx.appOut ?? [ctx.jwebId, 0];
85
+ const [srcId, srcOutlet] = ctx.appOut ?? [ctx.jwebId, 2];
86
86
 
87
87
  // Nobody has claimed the stream yet, and yet [jweb] no longer reaches the
88
88
  // wrapper: a chain cut that cord by hand (the old `removeLine(jwebId,
@@ -748,205 +748,7 @@ function downloadChain(ctx) {
748
748
  lines.push(line("obj-prepend-maxurl-progress", 0, unmatchedId, 0));
749
749
  }
750
750
 
751
- /**
752
- * "samples" - the first chain that ORIGINATES a sound: a named [buffer~] per slot,
753
- * loaded from a file on disk, played back through [groove~] into the signal path.
754
- *
755
- * app -> buffer_load <slot> <path> -> [js] (resolves the path)
756
- * -> buffer_replace -> [buffer~ <name>]
757
- * buffer_play <slot> -> [groove~] set + play
758
- * buffer_stop -> [groove~] stop
759
- * app <- buffer_ready <slot> <sr> <ms> <chans> when the read actually completed
760
- * buffer_error <slot> <msg> when there was no file to read
761
- *
762
- * THE BYTES NEVER CROSS THE BRIDGE. [buffer~] reads the file itself; what travels in
763
- * Max messages is a path and, coming back, a description of what landed - the same
764
- * rule the `download` chain follows, which is how you get the file there in the
765
- * first place.
766
- *
767
- * WAV/AIFF/Next-Sun ONLY. [buffer~]'s `read`/`replace` does not take MP3 - that list
768
- * (MP3, OGG, FLAC, M4A) is [sfplay~]'s, which streams from disk rather than filling a
769
- * buffer, and is therefore a different chain. A format it cannot read is an error in
770
- * the Max console and NO bang, so the app's promise times out rather than lying.
771
- *
772
- * WHAT LOADED IS NOT WHAT YOU ASKED FOR, so the chain reports what it GOT. `replace`
773
- * resizes the buffer and adopts the FILE's channel count and sample rate, so a slot
774
- * is not mono because you wanted it to be. [info~] is banged from the buffer's own
775
- * "read completed" outlet (outlet 1 - outlet 0 is a mouse position in the editing
776
- * window) and reports sample rate, duration and channels; the app derives frames from
777
- * those. A frame count on its own is not proof of a read: a failed `replace` leaves
778
- * the PREVIOUS contents in place, so "there are samples in there" says nothing. The
779
- * bang does - it only fires when a read completed - and until it does the app's
780
- * promise is still open (see loadSample() in @m4l-jweb/bridge, which is where the
781
- * timeout lives).
782
- *
783
- * info~'s outlets fire right-to-left, so the LAST one to arrive is outlet 0, the
784
- * sample rate - which is therefore the one wired to [pack]'s hot inlet. Wire it the
785
- * other way round and the message goes out carrying the PREVIOUS load's numbers.
786
- *
787
- * ONE VOICE, DELIBERATELY. `groove~` takes `set <buffer-name>` to switch buffers, so
788
- * one stereo player covers N slots: this is a PREVIEW - the sample browser's "let me
789
- * hear it, through the track" - not a sampler. Polyphony is the `instrument` chain,
790
- * and it is still open (doc/TODO.md item 2).
791
- *
792
- * It SUMS into the signal path rather than claiming it ([+~]), because it makes sound
793
- * of its own: on an audio effect the preview plays over the track's audio, and on an
794
- * instrument there is nothing at the input to add.
795
- *
796
- * The buffer names are INSTANCE-SCOPED (`deviceBufName`, `---buf-<device>-<slot>`), so
797
- * two copies of this device on two tracks own separate buffers. They used to be global
798
- * to Max and generated from the device name alone, which meant the second copy loaded
799
- * silently stole the first's samples.
800
- *
801
- * Slots default to one, named "preview". `slots: ["kick", "snare"]` in the manifest
802
- * gives you more.
803
- */
804
- function samplesChain(ctx) {
805
- const { boxes, lines, device, jwebId, unmatchedId } = ctx;
806
- const slots = device?.slots ?? ["preview"];
807
- // Instance-scoped, for the same reason the instrument's are: two copies of a preview
808
- // device on two tracks are two devices, and a global name would hand both the same
809
- // buffer. `---` expands per device instance in Live - see deviceBufName.
810
- const bufName = (slot) => deviceBufName(device, slot);
811
-
812
- // `buffer_load` is NOT claimed from [jweb]. It goes on to the wrapper, which
813
- // resolves the path and hands it back on its AUX OUTLET as `buffer_replace <slot>
814
- // <abs path>` - the same shape the `download` chain takes `maxurl` in.
815
- //
816
- // The detour is the whole fix. The app writes a path relative to the device's
817
- // folder (that is where fetchToFile puts the file), and [buffer~] does not resolve
818
- // it that way: a bare name is looked up in MAX'S SEARCH PATH, which the device's
819
- // folder is not in, so a file that was downloaded correctly reports "can't open".
820
- // The resolved path then contains SPACES on a normal Live install ("Ableton
821
- // Library"), and a path travelling through the patcher as message text would split
822
- // there into atoms. Out of [js] it stays one symbol.
823
- boxes.push(box("obj-samples-route", "route buffer_play buffer_stop", { numoutlets: 3, outlettype: ["", "", ""] }));
824
- claimAppMessages(ctx, "obj-samples-route", 2);
825
-
826
- boxes.push(box("obj-samples-replaceroute", "route buffer_replace", { numoutlets: 2, outlettype: ["", ""] }));
827
- lines.push(line(unmatchedId, 1, "obj-samples-replaceroute", 0));
828
-
829
- // `route` strips the selector, so the slot name is now the first word of both
830
- // remaining messages: a second route per stream dispatches on it.
831
- const slotList = slots.join(" ");
832
- const slotOutlets = { numoutlets: slots.length + 1, outlettype: slots.map(() => "").concat("") };
833
- boxes.push(box("obj-samples-loadslot", `route ${slotList}`, slotOutlets));
834
- boxes.push(box("obj-samples-playslot", `route ${slotList}`, slotOutlets));
835
- lines.push(line("obj-samples-replaceroute", 0, "obj-samples-loadslot", 0));
836
- lines.push(line("obj-samples-route", 0, "obj-samples-playslot", 0));
837
-
838
- // The player. `groove~ <buffer> 2` = two signal outlets (plus a loop-sync outlet),
839
- // and it MIXES a buffer with more channels down rather than dropping them.
840
- // @loop 0 makes it a one-shot: a preview that loops forever is a preview you have
841
- // to fight. [sig~ 1.] is the playback rate, in the left inlet, which is a SIGNAL
842
- // inlet - a float there means something else entirely (a position, in ms).
843
- boxes.push(box("obj-samples-rate", "sig~ 1.", { numinlets: 1, numoutlets: 1, outlettype: ["signal"] }));
844
- boxes.push(
845
- box("obj-samples-groove", `groove~ ${bufName(slots[0])} 2 @loop 0`, {
846
- numinlets: 3,
847
- numoutlets: 3,
848
- outlettype: ["signal", "signal", "signal"],
849
- }),
850
- );
851
- lines.push(line("obj-samples-rate", 0, "obj-samples-groove", 0));
852
-
853
- // MONO FOLD. `groove~ <buf> 2` hard-wires two signal outlets to L and R, and a
854
- // MONO buffer drives outlet 0 ONLY - so a mono file (most of tidal-drum-machines
855
- // is mono) plays in one ear. The channel count is not a build-time fact: [info~]
856
- // MEASURES it when the buffer loads. So gate the R channel at RUNTIME - a
857
- // [selector~ 2] whose control says which groove~ outlet is the real right channel:
858
- // input 1 = groove~ outlet 1 (a stereo file's true R)
859
- // input 2 = groove~ outlet 0 (fold the mono signal to R as well)
860
- // The control is the currently-loaded slot's channel count run through
861
- // [expr ($i1==1)+1]: mono(1) -> 2 (fold), stereo(2) -> 1 (real R). The L channel
862
- // always takes groove~ outlet 0, so it needs no gate.
863
- boxes.push(box("obj-samples-rsel", "selector~ 2", { numinlets: 3, numoutlets: 1, outlettype: ["signal"] }));
864
- boxes.push(box("obj-samples-rgate", "expr ($i1==1)+1", { numinlets: 1, numoutlets: 1, outlettype: ["int"] }));
865
- lines.push(line("obj-samples-groove", 1, "obj-samples-rsel", 1)); // stereo R -> input 1
866
- lines.push(line("obj-samples-groove", 0, "obj-samples-rsel", 2)); // mono fold -> input 2
867
- lines.push(line("obj-samples-rgate", 0, "obj-samples-rsel", 0)); // which one, per loaded slot
868
-
869
- // Stop: a bare `buffer_stop` arrives from [route] as a BANG - the word is gone -
870
- // so re-materialize it in a message box, or groove~ hears nothing it knows.
871
- boxes.push(box("obj-samples-stopmsg", "stop", { maxclass: "message", numinlets: 2, numoutlets: 1 }));
872
- lines.push(line("obj-samples-route", 1, "obj-samples-stopmsg", 0));
873
- lines.push(line("obj-samples-stopmsg", 0, "obj-samples-groove", 0));
874
-
875
- slots.forEach((slot, i) => {
876
- const buf = `obj-samples-buf-${slot}`;
877
- const info = `obj-samples-info-${slot}`;
878
-
879
- // buffer~: outlet 0 is a mouse position, outlet 1 is the bang on a completed
880
- // read. Only the second one means anything here.
881
- boxes.push(box(buf, `buffer~ ${bufName(slot)}`, { numinlets: 1, numoutlets: 2, outlettype: ["float", "bang"] }));
882
- boxes.push(box(`obj-samples-replace-${slot}`, "prepend replace"));
883
- lines.push(line("obj-samples-loadslot", i, `obj-samples-replace-${slot}`, 0));
884
- lines.push(line(`obj-samples-replace-${slot}`, 0, buf, 0));
885
-
886
- // What actually landed. info~'s outlets: 0 = sample rate, 6 = duration in ms,
887
- // 8 = number of channels (per its reference page - do not count them from
888
- // memory). They fire right-to-left, so 0 arrives last and is the hot one.
889
- boxes.push(
890
- box(info, `info~ ${bufName(slot)}`, {
891
- numinlets: 1,
892
- numoutlets: 10,
893
- outlettype: ["float", "list", "float", "float", "float", "float", "float", "", "int", ""],
894
- }),
895
- );
896
- boxes.push(box(`obj-samples-pack-${slot}`, "pack 0. 0. 0", { numinlets: 3, numoutlets: 1, outlettype: [""] }));
897
- boxes.push(box(`obj-samples-ready-${slot}`, `prepend buffer_ready ${slot}`));
898
-
899
- lines.push(line(buf, 1, info, 0)); // the read completed - ask what it was
900
- lines.push(line(info, 8, `obj-samples-pack-${slot}`, 2)); // channels (fires first)
901
- lines.push(line(info, 6, `obj-samples-pack-${slot}`, 1)); // duration, ms
902
- lines.push(line(info, 0, `obj-samples-pack-${slot}`, 0)); // sample rate - hot, last
903
- lines.push(line(`obj-samples-pack-${slot}`, 0, `obj-samples-ready-${slot}`, 0));
904
- lines.push(line(`obj-samples-ready-${slot}`, 0, jwebId, 0));
905
-
906
- // Retain THIS slot's measured channel count, so play can re-assert the mono
907
- // fold for whichever buffer is loaded into the one shared groove~. [info~]
908
- // outlet 8 stores it in [f]'s cold inlet (no output); the play trigger bangs it
909
- // out to the shared gate below. Every slot's [f] feeds that one gate.
910
- boxes.push(box(`obj-samples-chans-${slot}`, "f", { numinlets: 2, numoutlets: 1, outlettype: [""] }));
911
- lines.push(line(info, 8, `obj-samples-chans-${slot}`, 1)); // measured channels, cold-stored
912
- lines.push(line(`obj-samples-chans-${slot}`, 0, "obj-samples-rgate", 0));
913
-
914
- // Play: pick the buffer, THEN start it. Two cords out of one outlet fire in an
915
- // order Max chooses, so the sequence goes through a [t b b] - right outlet
916
- // first - and never through a fan-out. Starting the OLD buffer and then
917
- // switching is exactly the bug that looks like "the wrong sample previewed".
918
- boxes.push(box(`obj-samples-trig-${slot}`, "t b b b", { numinlets: 1, numoutlets: 3, outlettype: ["bang", "bang", "bang"] }));
919
- boxes.push(box(`obj-samples-set-${slot}`, `set ${bufName(slot)}`, { maxclass: "message", numinlets: 2, numoutlets: 1 }));
920
- // A float in groove~'s left inlet is a playback POSITION in ms, and 0 is "from
921
- // the beginning" - which is also what starts it after a `stop`.
922
- boxes.push(box(`obj-samples-start-${slot}`, "0", { maxclass: "message", numinlets: 2, numoutlets: 1 }));
923
-
924
- // [t] fires right-to-left, so: set the fold gate, THEN the buffer, THEN start -
925
- // the gate and the buffer are both in place before groove~ makes a sound.
926
- lines.push(line("obj-samples-playslot", i, `obj-samples-trig-${slot}`, 0));
927
- lines.push(line(`obj-samples-trig-${slot}`, 2, `obj-samples-chans-${slot}`, 0)); // first: assert the fold
928
- lines.push(line(`obj-samples-trig-${slot}`, 1, `obj-samples-set-${slot}`, 0)); // then: set the buffer
929
- lines.push(line(`obj-samples-trig-${slot}`, 0, `obj-samples-start-${slot}`, 0)); // ...then play it
930
- lines.push(line(`obj-samples-set-${slot}`, 0, "obj-samples-groove", 0));
931
- lines.push(line(`obj-samples-start-${slot}`, 0, "obj-samples-groove", 0));
932
- });
933
-
934
- // Sum into the signal path: this chain MAKES sound, it does not process what came
935
- // before. Claiming the stage instead would silence the track the preview plays over.
936
- for (const [ch, id] of [
937
- [0, "obj-samples-mix-l"],
938
- [1, "obj-samples-mix-r"],
939
- ]) {
940
- const [srcId, srcOut] = ctx.audioIn(ch);
941
- boxes.push(box(id, "+~", { numinlets: 2, numoutlets: 1, outlettype: ["signal"] }));
942
- lines.push(line(srcId, srcOut, id, 0));
943
- // L takes groove~ outlet 0 directly; R takes the mono-fold [selector~] instead
944
- // of groove~ outlet 1, so a mono buffer reaches both ears.
945
- if (ch === 0) lines.push(line("obj-samples-groove", 0, id, 1));
946
- else lines.push(line("obj-samples-rsel", 0, id, 1));
947
- ctx.setAudioOut(ch, id, 0);
948
- }
949
- }
751
+
950
752
 
951
753
  /**
952
754
  * "delay" - a feedback delay, sent from a dry/wet knob: `.delay()`, `.delaytime()`
@@ -1078,244 +880,32 @@ function reverbChain(ctx) {
1078
880
  }
1079
881
  }
1080
882
 
1081
- /**
1082
- * The [poly~] voice patch: ONE played note, start to finish, as its own patcher.
1083
- *
1084
- * [poly~] loads N copies of this and hands each `note` message to the first voice
1085
- * whose [thispoly~] is not busy - so polyphony and voice-stealing are Max's job, not
1086
- * the app's, which is the whole reason to spend a [poly~] rather than run N groove~s
1087
- * and a scheduler by hand. Max cannot embed this inline (no factory device does; they
1088
- * all ship it as a named .maxpat), so the build freezes it into the .amxd as a
1089
- * dependency and [poly~] resolves it by name from the device's own bundle - the same
1090
- * way `Analogue Drums.amxd` carries `analog.Kick~.maxpat` (checked on disk).
1091
- *
1092
- * MULTI-SAMPLE. A voice can play ANY of the device's slots - one named [buffer~] per
1093
- * slot - so the instrument is a keymap, not one repitched sample. The voice request at
1094
- * [in 1] is the list `slot rate velocity durMs channels`:
1095
- * - slot -> which buffer: [sel 0 1 ...] picks the matching `set <bufName>`
1096
- * message, so groove~ switches to that slot's buffer before it starts.
1097
- * - rate -> playback RATE straight into [sig~] -> groove~'s left (signal) inlet.
1098
- * EXPLICIT, not derived: the app decides whether a note plays a
1099
- * dedicated sample at rate 1 or a repitched one at rate 2, which is
1100
- * what makes this a multi-sample keymap rather than one stretched
1101
- * buffer. No pitch->rate arithmetic here.
1102
- * - velocity -> amplitude, /127, on both channels' [*~].
1103
- * - durMs -> when to FREE the voice: [delay durMs] -> 0 to [thispoly~] and
1104
- * `stop` to groove~. The app times the note; Max holds the voice
1105
- * exactly that long. A one-shot past the sample's end is silent
1106
- * anyway (@loop 0), so this only bounds the allocation.
1107
- * - channels -> the mono fold, the SAME runtime gate the samples chain uses: a
1108
- * mono buffer drives groove~ outlet 0 only, so a [selector~ 2] keyed
1109
- * on the measured channel count folds it into R (see samplesChain).
1110
- *
1111
- * The order is sequenced with [t]: the whole list reaches [unpack] (buffer selected,
1112
- * rate, gate and duration all set) BEFORE the voice marks itself busy and starts
1113
- * groove~ from 0. Buffer-select is separate from start - `set <buf>` switches the
1114
- * buffer WITHOUT playing, and only the seq's `0` starts it - so a note never begins on
1115
- * the previous note's buffer or rate.
1116
- */
1117
- function instrumentVoicePatch(bufNames) {
1118
- let vy = 40;
1119
- const vbox = (id, text, extra = {}) => ({
1120
- box: { id, maxclass: "newobj", text, numinlets: 1, numoutlets: 1, outlettype: [""], patching_rect: [24, (vy += 30), 150, 22], ...extra },
1121
- });
1122
- const vmsg = (id, text) => ({
1123
- box: { id, maxclass: "message", text, numinlets: 2, numoutlets: 1, outlettype: [""], patching_rect: [200, (vy += 30), 90, 22] },
1124
- });
1125
-
1126
- const L = (s, so, d, di) => ({ patchline: { source: [s, so], destination: [d, di] } });
1127
-
1128
- const boxes = [
1129
- vbox("v-in", "in 1", { numinlets: 0, numoutlets: 1 }),
1130
- vbox("v-trig", "t b l", { numoutlets: 2, outlettype: ["bang", ""] }),
1131
- vbox("v-unpack", "unpack 0 0. 0 0 0", { numoutlets: 5, outlettype: ["int", "float", "int", "int", "int"] }),
1132
- vbox("v-seq", "t b b", { numoutlets: 2, outlettype: ["bang", "bang"] }),
1133
- vmsg("v-busy", "1"),
1134
- vmsg("v-start", "0"),
1135
- vbox("v-thispoly", "thispoly~", { numoutlets: 2, outlettype: ["int", "int"] }),
1136
- // slot -> which buffer. [sel] fires the matching outlet; the last (rightmost) is
1137
- // the no-match passthrough, left unwired.
1138
- vbox("v-sel", `sel ${bufNames.map((_, i) => i).join(" ")}`, { numoutlets: bufNames.length + 1, outlettype: bufNames.map(() => "bang").concat("") }),
1139
- vbox("v-sig", "sig~", { numoutlets: 1, outlettype: ["signal"] }),
1140
- vbox("v-vel", "/ 127.", { outlettype: ["float"] }),
1141
- // groove~ starts on the FIRST slot's buffer; `set` switches it per note.
1142
- vbox("v-groove", `groove~ ${bufNames[0]} 2 @loop 0`, { numinlets: 3, numoutlets: 3, outlettype: ["signal", "signal", "signal"] }),
1143
- vbox("v-gate", "expr ($i1==1)+1", { outlettype: ["int"] }),
1144
- vbox("v-rsel", "selector~ 2", { numinlets: 3, numoutlets: 1, outlettype: ["signal"] }),
1145
- vbox("v-ampL", "*~", { numinlets: 2, numoutlets: 1, outlettype: ["signal"] }),
1146
- vbox("v-ampR", "*~", { numinlets: 2, numoutlets: 1, outlettype: ["signal"] }),
1147
- vbox("v-del", "delay", { numinlets: 2, numoutlets: 1, outlettype: ["bang"] }),
1148
- vbox("v-free", "t b b", { numoutlets: 2, outlettype: ["bang", "bang"] }),
1149
- vmsg("v-freebusy", "0"),
1150
- vmsg("v-stop", "stop"),
1151
- vbox("v-out1", "out~ 1", { numinlets: 1, numoutlets: 0 }),
1152
- vbox("v-out2", "out~ 2", { numinlets: 1, numoutlets: 0 }),
1153
- ];
1154
-
1155
- const lines = [
1156
- L("v-in", 0, "v-trig", 0),
1157
- // list first (right outlet), then the start sequence (left) - values before play.
1158
- L("v-trig", 1, "v-unpack", 0),
1159
- L("v-trig", 0, "v-seq", 0),
1160
- L("v-unpack", 0, "v-sel", 0), // slot -> pick a buffer
1161
- L("v-unpack", 1, "v-sig", 0), // rate (explicit) -> signal rate
1162
- L("v-sig", 0, "v-groove", 0),
1163
- L("v-unpack", 2, "v-vel", 0), // velocity -> amp
1164
- L("v-vel", 0, "v-ampL", 1),
1165
- L("v-vel", 0, "v-ampR", 1),
1166
- L("v-unpack", 3, "v-del", 1), // duration -> delay time (cold)
1167
- L("v-unpack", 4, "v-gate", 0), // channels -> mono fold gate
1168
- L("v-gate", 0, "v-rsel", 0),
1169
- // the start sequence: mark busy, then start groove~ from 0 AND arm the free timer.
1170
- L("v-seq", 1, "v-busy", 0),
1171
- L("v-busy", 0, "v-thispoly", 0),
1172
- L("v-seq", 0, "v-start", 0),
1173
- L("v-start", 0, "v-groove", 0),
1174
- L("v-seq", 0, "v-del", 0), // bang the delay: it fires after durMs
1175
- // groove~ out: L direct, R via the mono-fold selector (stereo R vs. folded mono).
1176
- L("v-groove", 0, "v-ampL", 0),
1177
- L("v-groove", 1, "v-rsel", 1), // stereo R
1178
- L("v-groove", 0, "v-rsel", 2), // mono fold
1179
- L("v-rsel", 0, "v-ampR", 0),
1180
- L("v-ampL", 0, "v-out1", 0),
1181
- L("v-ampR", 0, "v-out2", 0),
1182
- // free the voice when the note's duration elapses.
1183
- L("v-del", 0, "v-free", 0),
1184
- L("v-free", 1, "v-freebusy", 0),
1185
- L("v-freebusy", 0, "v-thispoly", 0),
1186
- L("v-free", 0, "v-stop", 0),
1187
- L("v-stop", 0, "v-groove", 0),
1188
- ];
1189
-
1190
- // One `set <buffer>, ` message per slot: [sel] outlet i switches groove~ to that
1191
- // slot's buffer WITHOUT starting it (the seq's `0` does that). A trailing comma
1192
- // would start it; there is none, deliberately.
1193
- bufNames.forEach((buf, i) => {
1194
- const setId = `v-set-${i}`;
1195
- boxes.push(vmsg(setId, `set ${buf}`));
1196
- lines.push(L("v-sel", i, setId, 0));
1197
- lines.push(L(setId, 0, "v-groove", 0));
1198
- });
1199
-
1200
- return {
1201
- patcher: {
1202
- fileversion: 1,
1203
- appversion: { major: 8, minor: 0, revision: 0, architecture: "x64", modernui: 1 },
1204
- rect: [100, 100, 320, 600],
1205
- boxes,
1206
- lines,
1207
- },
1208
- };
1209
- }
883
+
884
+
1210
885
 
1211
886
  /**
1212
- * "instrument" - the marquee: a [poly~] of sample voices over a KEYMAP of named
1213
- * buffers, PLAYED by the note contract the bridge exports. `samples` made the first
1214
- * sound as ONE preview voice; this is the other half - N voices, N buffers, Max doing
1215
- * the allocation and stealing.
1216
- *
1217
- * app -> voice_play <slot> <rate> <vel> <durMs> <chans> -> [route] -> [prepend note] -> [poly~]
1218
- * buffer_load <slot> <path> -> [js] resolves -> [buffer~ <slot>]
1219
- * app <- buffer_ready <slot> <sr> <ms> <chans> when that slot's read completed
1220
- *
1221
- * The load path is the samples chain's, deliberately: the bytes never cross the bridge
1222
- * ([buffer~] reads the file, the wrapper resolves the path on its aux outlet), and the
1223
- * reply reports what [info~] MEASURED, not what was asked for. Every slot is its own
1224
- * named [buffer~]; the voice picks one per note by index and plays it at the rate the
1225
- * app chose - so a dedicated sample plays at rate 1 and a repitched one at rate 2,
1226
- * which is the app's decision, not the chain's.
1227
- *
1228
- * `slots: ["c", "e", "g"]` in the manifest is three buffers; the default is one
1229
- * ("voice"). The buffer names are INSTANCE-SCOPED (`deviceBufName`): each copy of the
1230
- * device owns its own, which is what lets a drum rack exist on two tracks at once.
1231
- * `---` scopes per DEVICE, so the voice spells the same name the device does - see
1232
- * deviceBufName.
1233
- *
1234
- * It SUMS into the signal path ([+~]): an instrument makes sound where there was none
1235
- * at its input, so there is nothing to claim a stage over.
887
+ * "webaudio" - the primary audio source for modern m4l-jweb devices.
888
+ * It takes the L and R signal outlets from [jweb~] (outlets 0 and 1)
889
+ * and pipes them directly into the device's audio path.
1236
890
  */
1237
- function instrumentChain(ctx) {
1238
- const { boxes, lines, device, jwebId, unmatchedId } = ctx;
1239
- const slots = device?.slots ?? ["voice"];
1240
- const bufName = (slot) => deviceBufName(device, slot);
1241
- const voices = device?.voices ?? 8;
1242
- const voiceFile = `${device?.name}-voice.maxpat`;
1243
-
1244
- // The frozen voice patch (a keymap of every slot's buffer), and the [poly~] that
1245
- // loads N copies of it. The voice spells the buffers exactly as the device does:
1246
- // `---` is device-scoped, so no id travels through poly~'s arguments.
1247
- ctx.extras.push({ name: voiceFile, data: instrumentVoicePatch(slots.map((s) => voiceBufName(device, s))) });
1248
- // [poly~]'s name is the file WITHOUT its extension, per Max's abstraction lookup.
1249
- boxes.push(
1250
- box(`obj-instr-poly`, `poly~ ${voiceFile.replace(/\.maxpat$/, "")} ${voices}`, {
1251
- numinlets: 1,
1252
- numoutlets: 2,
1253
- outlettype: ["signal", "signal"],
1254
- }),
1255
- );
891
+ function webaudioChain(ctx) {
892
+ const { boxes, lines, jwebId } = ctx;
1256
893
 
1257
- // voice_play -> `note <slot> <rate> <vel> <durMs> <chans>` -> poly~. `route` strips
1258
- // the selector, leaving the bare args; `prepend note` is the word poly~ dispatches on
1259
- // to pick a free voice. Claimed in series so ui_ready still reaches the wrapper.
1260
- boxes.push(box("obj-instr-playroute", "route voice_play", { numoutlets: 2, outlettype: ["", ""] }));
1261
- claimAppMessages(ctx, "obj-instr-playroute", 1);
1262
- boxes.push(box("obj-instr-note", "prepend note"));
1263
- lines.push(line("obj-instr-playroute", 0, "obj-instr-note", 0));
1264
- lines.push(line("obj-instr-note", 0, "obj-instr-poly", 0));
1265
-
1266
- // The load path, from the wrapper's aux outlet - identical in shape to samples: a
1267
- // bare buffer name resolves against MAX'S SEARCH PATH, not the device folder, so the
1268
- // wrapper resolves it and hands back `buffer_replace <slot> <abs path>`. One buffer
1269
- // per slot, dispatched by slot name (route matches a whole word).
1270
- boxes.push(box("obj-instr-replaceroute", "route buffer_replace", { numoutlets: 2, outlettype: ["", ""] }));
1271
- lines.push(line(unmatchedId, 1, "obj-instr-replaceroute", 0));
1272
- boxes.push(box("obj-instr-loadslot", `route ${slots.join(" ")}`, { numoutlets: slots.length + 1, outlettype: slots.map(() => "").concat("") }));
1273
- lines.push(line("obj-instr-replaceroute", 0, "obj-instr-loadslot", 0));
1274
-
1275
- slots.forEach((slot, i) => {
1276
- const buf = `obj-instr-buf-${slot}`;
1277
- const info = `obj-instr-info-${slot}`;
1278
- boxes.push(box(buf, `buffer~ ${bufName(slot)}`, { numinlets: 1, numoutlets: 2, outlettype: ["float", "bang"] }));
1279
- boxes.push(box(`obj-instr-replace-${slot}`, "prepend replace"));
1280
- lines.push(line("obj-instr-loadslot", i, `obj-instr-replace-${slot}`, 0));
1281
- lines.push(line(`obj-instr-replace-${slot}`, 0, buf, 0));
1282
-
1283
- // Report what LOADED, from the read-completed outlet (1). info~ fires right-to-left,
1284
- // so the sample rate (outlet 0) arrives last and drives [pack]'s hot inlet.
1285
- boxes.push(
1286
- box(info, `info~ ${bufName(slot)}`, {
1287
- numinlets: 1,
1288
- numoutlets: 10,
1289
- outlettype: ["float", "list", "float", "float", "float", "float", "float", "", "int", ""],
1290
- }),
1291
- );
1292
- boxes.push(box(`obj-instr-pack-${slot}`, "pack 0. 0. 0", { numinlets: 3, numoutlets: 1, outlettype: [""] }));
1293
- boxes.push(box(`obj-instr-ready-${slot}`, `prepend buffer_ready ${slot}`));
1294
- lines.push(line(buf, 1, info, 0));
1295
- lines.push(line(info, 8, `obj-instr-pack-${slot}`, 2)); // channels
1296
- lines.push(line(info, 6, `obj-instr-pack-${slot}`, 1)); // duration ms
1297
- lines.push(line(info, 0, `obj-instr-pack-${slot}`, 0)); // sample rate, hot
1298
- lines.push(line(`obj-instr-pack-${slot}`, 0, `obj-instr-ready-${slot}`, 0));
1299
- lines.push(line(`obj-instr-ready-${slot}`, 0, jwebId, 0));
1300
- });
1301
-
1302
- // Sum the voices into the signal path - an instrument originates sound.
1303
- for (const [ch, id] of [
1304
- [0, "obj-instr-mix-l"],
1305
- [1, "obj-instr-mix-r"],
1306
- ]) {
894
+ for (const ch of [0, 1]) {
1307
895
  const [srcId, srcOut] = ctx.audioIn(ch);
896
+ const id = `obj-webaudio-mix-${ch === 0 ? "l" : "r"}`;
897
+
1308
898
  boxes.push(box(id, "+~", { numinlets: 2, numoutlets: 1, outlettype: ["signal"] }));
1309
- lines.push(line(srcId, srcOut, id, 0));
1310
- lines.push(line("obj-instr-poly", ch, id, 1));
899
+ lines.push(line(srcId, srcOut, id, 0)); // The device input
900
+ lines.push(line(jwebId, ch, id, 1)); // jweb~ output
901
+
1311
902
  ctx.setAudioOut(ch, id, 0);
1312
903
  }
1313
904
  }
1314
905
 
1315
906
  export const CHAINS = {
907
+ webaudio: webaudioChain,
1316
908
  midiin: midiInChain,
1317
- samples: samplesChain,
1318
- instrument: instrumentChain,
1319
909
  midiout: midiOutChain,
1320
910
  passthrough: passthroughChain,
1321
911
  gain: gainChain,
@@ -69,10 +69,10 @@
69
69
  "box": {
70
70
  "disablefind": 0,
71
71
  "id": "obj-jweb",
72
- "maxclass": "jweb",
72
+ "maxclass": "jweb~",
73
73
  "numinlets": 1,
74
- "numoutlets": 1,
75
- "outlettype": [""],
74
+ "numoutlets": 3,
75
+ "outlettype": ["signal", "signal", ""],
76
76
  "patching_rect": [16.0, 104.0, 400.0, 169.0],
77
77
  "presentation": 1,
78
78
  "presentation_rect": [0.0, 0.0, 420.0, 169.0],
@@ -84,7 +84,7 @@
84
84
  { "patchline": { "destination": ["obj-midiout", 0], "source": ["obj-midiin", 0] } },
85
85
  { "patchline": { "destination": ["obj-js", 0], "source": ["obj-thisdevice", 0] } },
86
86
  { "patchline": { "destination": ["obj-jweb", 0], "source": ["obj-js", 0] } },
87
- { "patchline": { "destination": ["obj-js", 0], "source": ["obj-jweb", 0] } }
87
+ { "patchline": { "destination": ["obj-js", 0], "source": ["obj-jweb", 2] } }
88
88
  ],
89
89
  "latency": 0,
90
90
  "is_mpe": 0,