@m4l-jweb/build 0.9.5 → 1.0.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/README.md ADDED
@@ -0,0 +1,42 @@
1
+ # @m4l-jweb/build
2
+
3
+ The CLI. It reads a device manifest and writes finished, installable `.amxd` files - generating the Max patcher, compiling the `[js]` wrapper, and freezing your UI bundle inside the device. No Max editor is opened at any point.
4
+
5
+ Part of **[m4l-jweb](https://github.com/alienmind/m4l-jweb)** - build Ableton Live devices (`.amxd`) from a TypeScript repo: React UI, LiveAPI glue, CI builds, no Max editor.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pnpm add @m4l-jweb/build
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```bash
16
+ # in a device repo
17
+ pnpm m4l-jweb build # patchers + wrapper + .amxd, end to end
18
+ pnpm m4l-jweb patchers # just regenerate the patcher JSON
19
+ pnpm m4l-jweb wrapper # just recompile the [js] wrapper
20
+ pnpm m4l-jweb install # copy the built devices into your User Library
21
+ pnpm m4l-jweb init # scaffold a new device repo
22
+ ```
23
+
24
+ ## Notes
25
+
26
+ - Devices are declared as data in `patcher/devices.mjs` - a name, a `type` (`midi` / `audio` / `instrument`), and a list of **chains**. Patch cords become code review.
27
+ - Chains are small functions that each claim a stage of the signal or message path: `webaudio` (the page's own audio, via `[jweb~]`), `midiin`, `midiout`, `lowpass`, `gain`, `download`, `remote`, and your own in `patcher/chains.mjs`.
28
+ - The generated patcher is checked before it is written - duplicate box ids and unrouted selectors fail the build rather than producing a device that loads and silently does nothing.
29
+
30
+ ## Requirements
31
+
32
+ Ableton Live 12 with Max 9. Devices are built on `[jweb~]`, the browser view with signal outlets; older hosts are unverified.
33
+
34
+ ## Links
35
+
36
+ - [Repository and full README](https://github.com/alienmind/m4l-jweb)
37
+ - [Architecture](https://github.com/alienmind/m4l-jweb/blob/main/doc/ARCHITECTURE.md)
38
+ - [What Max actually does: the measured facts](https://github.com/alienmind/m4l-jweb/blob/main/doc/MAX-FACTS.md)
39
+
40
+ ## License
41
+
42
+ MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m4l-jweb/build",
3
- "version": "0.9.5",
3
+ "version": "1.0.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",
@@ -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.5"
37
+ "@m4l-jweb/wrapper": "1.0.0"
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,435 +748,7 @@ function downloadChain(ctx) {
748
748
  lines.push(line("obj-prepend-maxurl-progress", 0, unmatchedId, 0));
749
749
  }
750
750
 
751
- /**
752
- * "renderplay" - double-buffered, transport-locked loop playback of a rendered WAV pair.
753
- *
754
- * This is the Max half of the SUPERDOUGH Rendering design (m4l-strudel
755
- * doc/IDEA-STRUDEL-INSTRUMENT.md, section D.3). The app renders a Strudel pattern to a
756
- * WAV, `saveToFile`s it next to the device, then:
757
- *
758
- * app -> render_load <slot> <path> <lengthBeats> -> [js] resolves path
759
- * -> render_replace -> [buffer~ <name>]
760
- * render_arm <slot> -> swap playback to it at the boundary
761
- * render_stop -> fade out
762
- * app <- render_ready <slot> when the WAV finished loading
763
- *
764
- * TRANSPORT LOCK - the mechanism, and why THIS one. The design names `phasor~ @lock` as
765
- * the first idea and flags it UNVERIFIED, because an arbitrary loop length (7 beats, say)
766
- * does not spell as a note value and the tempo math is fragile. This implements the
767
- * design's fallback instead: both slots play through [groove~ @loop 1] (the same player
768
- * the `samples` chain proves), and a control-rate boundary detector HARD RE-SYNCS both
769
- * grooves to position 0 at every loop boundary. The boundary is read straight off the
770
- * host transport - [plugsync~] outlet 6 is song position in beats (a signal; the
771
- * m4l-strudel engine already samples it via [snapshot~]) - so `floor(beats / lengthBeats)`
772
- * increments exactly once per loop, and [change] turns each increment into the re-sync
773
- * bang. No tempo arithmetic, no note-value spelling: the loop is pinned to the transport
774
- * by restarting it on the transport's own beat count. Drift between re-syncs is at most
775
- * one control tick (~10 ms) and is hidden under the crossfade, which is the fallback's
776
- * whole point.
777
- *
778
- * CROSSFADE, NOT GATING. Both grooves always play, phase-aligned by the shared re-sync;
779
- * only the [line~] gains move. `render_arm <slot>` stores the target slot; at the next
780
- * boundary the stored index is read out and each slot's gain ramps to (armed==slot) over
781
- * 15 ms. So the swap always lands on a loop boundary and a half-faded old loop is never
782
- * heard mid-cycle.
783
- *
784
- * ORIGINATES SOUND, like `samples`: it SUMS into the signal path ([+~]) rather than
785
- * claiming a stage, and the buffer names are instance-scoped (`deviceBufName`).
786
- *
787
- * OPEN (S3, verified in Live, not here): the exact re-sync timing and the equal-power
788
- * curve of the crossfade are tuning knobs to confirm by ear on real transport; and the
789
- * first loop before the first boundary bang plays at the initial gains (slot 0 up), so a
790
- * device should `render_arm` slot 0 once at start. See doc/TEST-CHAIN-RENDERPLAY.md.
791
- */
792
- function renderplayChain(ctx) {
793
- const { boxes, lines, device, jwebId, unmatchedId } = ctx;
794
- const slots = device?.renderSlots;
795
- if (!Array.isArray(slots) || slots.length !== 2) {
796
- throw new Error(
797
- `the "renderplay" chain needs exactly two renderSlots on device "${device?.name}", got ${JSON.stringify(slots)}. ` +
798
- `It is double-buffered by design: one slot loops while the other loads the next render.`,
799
- );
800
- }
801
- const bufName = (slot) => deviceBufName(device, slot);
802
- const slotList = slots.join(" ");
803
751
 
804
- // App stream: claim render_arm / render_stop / render_sync here; render_load falls through
805
- // the last outlet to the wrapper (it needs the path resolved, like `samples`' buffer_load).
806
- // render_sync <slot> <positionMs> relocates a slot's groove to a transport phase; it is
807
- // appended last so render_arm/render_stop keep their outlet indices.
808
- boxes.push(box("obj-render-route", "route render_arm render_stop render_sync", { numoutlets: 4, outlettype: ["", "", "", ""] }));
809
- claimAppMessages(ctx, "obj-render-route", 3);
810
-
811
- // The wrapper resolves the path and hands the load back on its AUX outlet as
812
- // `render_replace <slot> <absPath>` - one symbol, so Live-library spaces survive.
813
- boxes.push(box("obj-render-replaceroute", "route render_replace", { numoutlets: 2, outlettype: ["", ""] }));
814
- lines.push(line(unmatchedId, 1, "obj-render-replaceroute", 0));
815
-
816
- // route strips the selector, so the slot name is the first word now: dispatch per slot.
817
- const slotOutlets = { numoutlets: slots.length + 1, outlettype: slots.map(() => "").concat("") };
818
- boxes.push(box("obj-render-loadslot", `route ${slotList}`, slotOutlets));
819
- lines.push(line("obj-render-replaceroute", 0, "obj-render-loadslot", 0));
820
-
821
- // Boundary clock: the MASTER groove's OWN loop, not the host transport. [plugsync~]
822
- // outlet 6 (song-position-in-beats) was measured stuck at 0 while the transport played
823
- // (its outlet semantics differ by host), so timing the loop off it left the whole device
824
- // silent. groove~'s LAST outlet is a 0..1 sync ramp of loop position, and the groove is
825
- // the one thing we confirmed is running - so it is the reliable clock. `[<~ 0.5]` turns
826
- // the ramp into a once-per-loop square (1 in the first half, 0 in the second), and
827
- // `[edge~]` bangs on its rising edge - which happens right as the ramp wraps to 0, i.e.
828
- // at the loop boundary. Slot 0 is the master; both slots share the loop length.
829
- // (Transport-BAR alignment is deferred: the loop is self-clocked, not pinned to Live's
830
- // bar. Revisit once a host beat source that actually advances is found - S3 open item.)
831
- boxes.push(box("obj-render-syncgate", "<~ 0.5", { numinlets: 2, numoutlets: 1, outlettype: ["signal"] }));
832
- boxes.push(box("obj-render-syncedge", "edge~", { numinlets: 1, numoutlets: 2, outlettype: ["bang", "bang"] }));
833
- boxes.push(box("obj-render-boundary", "t b b", { numinlets: 1, numoutlets: 2, outlettype: ["bang", "bang"] }));
834
- lines.push(line(`obj-render-groove-${slots[0]}`, 2, "obj-render-syncgate", 0)); // master sync ramp
835
- lines.push(line("obj-render-syncgate", 0, "obj-render-syncedge", 0));
836
- lines.push(line("obj-render-syncedge", 0, "obj-render-boundary", 0)); // outlet 0 = rising edge = loop wrap
837
-
838
- // A boundary applies the gains only when a swap is PENDING - the first boundary after an
839
- // arm (and the first after load). [gate 1 1] starts OPEN so slot 0 fades up on load; the
840
- // boundary passes its bang through, then CLOSES the gate. So a held selection is not
841
- // re-ramped every loop - that per-loop re-trigger was the audible tick, and it also fought
842
- // render_stop (the next boundary kept re-raising the gain the stop had just faded out).
843
- // An arm re-opens the gate for exactly one boundary; stop closes it and ramps to silence.
844
- // [t b b] fires right-to-left: the bang goes THROUGH the gate (right) before the gate is
845
- // closed behind it (left).
846
- boxes.push(box("obj-render-pending", "gate 1 1", { numinlets: 2, numoutlets: 1, outlettype: ["bang"] }));
847
- boxes.push(box("obj-render-pendclose", "0", { maxclass: "message", numinlets: 2, numoutlets: 1 }));
848
- lines.push(line("obj-render-boundary", 1, "obj-render-pending", 1)); // right, first: bang through the gate
849
- lines.push(line("obj-render-boundary", 0, "obj-render-pendclose", 0)); // left, then: close behind it
850
- lines.push(line("obj-render-pendclose", 0, "obj-render-pending", 0));
851
- lines.push(line("obj-render-pending", 0, "obj-render-armed", 0)); // gated bang -> read armed -> gains
852
-
853
- // Armed slot index (0/1): stored cold by an arm, read out by a pending boundary.
854
- boxes.push(box("obj-render-armed", "i 0", { numinlets: 2, numoutlets: 1, outlettype: ["int"] }));
855
- boxes.push(box("obj-render-armslot", `route ${slotList}`, slotOutlets));
856
- lines.push(line("obj-render-route", 0, "obj-render-armslot", 0)); // render_arm <slot>
857
-
858
- // render_sync <slot> <positionMs>: strip the slot, drop <positionMs> into that slot's
859
- // groove~ left inlet as a float = a playback position in ms (per slot, in the forEach
860
- // below). The conductor sends this on (re)start / relocate to pin the loop to Live's exact
861
- // transport phase; then rate-1 @loop HOLDS the lock (shared clock, no per-loop re-sync).
862
- // The self-clocked boundary above is only the transport-STOPPED fallback - a sync just
863
- // moves the play head, it does not fight the loop. See doc/TEST-CHAIN-RENDERPLAY.md.
864
- boxes.push(box("obj-render-syncslot", `route ${slotList}`, slotOutlets));
865
- lines.push(line("obj-render-route", 2, "obj-render-syncslot", 0)); // render_sync <slot> <positionMs>
866
-
867
- // An arm re-opens the pending gate (for the next boundary to apply the swap); a stop
868
- // closes it (so no boundary re-raises the gain the stop is fading out).
869
- boxes.push(box("obj-render-pendopen", "1", { maxclass: "message", numinlets: 2, numoutlets: 1 }));
870
- lines.push(line("obj-render-route", 0, "obj-render-pendopen", 0)); // any render_arm
871
- lines.push(line("obj-render-pendopen", 0, "obj-render-pending", 0));
872
- lines.push(line("obj-render-route", 1, "obj-render-pendclose", 0)); // render_stop also closes it
873
-
874
- // Arm stores the target index only (below); the grooves start when their WAV LOADS (so
875
- // an arm never restarts a playing groove and clicks), and the crossfade gains move at the
876
- // BOUNDARY, so a swap lands on the loop boundary rather than the instant you click. That
877
- // makes the transport meaningful: with it stopped there are no boundaries, so nothing
878
- // becomes audible; start it and the armed slot fades up at the next boundary.
879
-
880
- // Summing buses, per channel: (slotA*gainA) + (slotB*gainB), then + the device input.
881
- boxes.push(box("obj-render-sumslots-l", "+~", { numinlets: 2, numoutlets: 1, outlettype: ["signal"] }));
882
- boxes.push(box("obj-render-sumslots-r", "+~", { numinlets: 2, numoutlets: 1, outlettype: ["signal"] }));
883
-
884
- slots.forEach((slot, i) => {
885
- const buf = `obj-render-buf-${slot}`;
886
- const groove = `obj-render-groove-${slot}`;
887
-
888
- // buffer~: outlet 1 bangs when a read completes. replace, then report render_ready.
889
- boxes.push(box(buf, `buffer~ ${bufName(slot)}`, { numinlets: 1, numoutlets: 2, outlettype: ["float", "bang"] }));
890
- boxes.push(box(`obj-render-replace-${slot}`, "prepend replace"));
891
- lines.push(line("obj-render-loadslot", i, `obj-render-replace-${slot}`, 0));
892
- lines.push(line(`obj-render-replace-${slot}`, 0, buf, 0));
893
- boxes.push(box(`obj-render-ready-${slot}`, `prepend render_ready ${slot}`));
894
- lines.push(line(buf, 1, `obj-render-ready-${slot}`, 0));
895
- lines.push(line(`obj-render-ready-${slot}`, 0, jwebId, 0));
896
- // Start this groove looping the moment its WAV has loaded (buffer~ read-complete bang).
897
- // It free-runs from here via @loop 1; the gain stays 0 until a boundary raises it.
898
- lines.push(line(buf, 1, `obj-render-resync-${slot}`, 0));
899
-
900
- // Player: [groove~ <buf> 2 @loop 1] at rate 1 (the WAV's own tempo; a tempo change is
901
- // a full re-render, so rate stays 1). Two signal outlets = L/R.
902
- boxes.push(box(`obj-render-rate-${slot}`, "sig~ 1.", { numinlets: 1, numoutlets: 1, outlettype: ["signal"] }));
903
- boxes.push(box(groove, `groove~ ${bufName(slot)} 2 @loop 1`, { numinlets: 3, numoutlets: 3, outlettype: ["signal", "signal", "signal"] }));
904
- lines.push(line(`obj-render-rate-${slot}`, 0, groove, 0));
905
-
906
- // Start message: a bare `0` (a start position in ms) into groove~'s left inlet starts
907
- // the loop from the top. Banged once, from the buffer read-complete above.
908
- boxes.push(box(`obj-render-resync-${slot}`, "0", { maxclass: "message", numinlets: 2, numoutlets: 1 }));
909
- lines.push(line(`obj-render-resync-${slot}`, 0, groove, 0));
910
-
911
- // render_sync target: a float position (ms) from obj-render-syncslot relocates THIS
912
- // groove's play head to the transport phase. Same inlet, same semantics as the resync 0.
913
- lines.push(line("obj-render-syncslot", i, groove, 0));
914
-
915
- // Gain: this slot's target = (armed == i). At the boundary the armed index is read
916
- // out, [== i] gives 0/1, [pack <v> 50] makes the `<target> 50ms` list for [line~] - a
917
- // short equal-ish crossfade at the boundary.
918
- boxes.push(box(`obj-render-istarget-${slot}`, `expr $i1 == ${i}`, { numinlets: 1, numoutlets: 1, outlettype: ["int"] }));
919
- boxes.push(box(`obj-render-gainpack-${slot}`, "pack 0. 400", { numinlets: 2, numoutlets: 1, outlettype: [""] }));
920
- boxes.push(box(`obj-render-gain-${slot}`, "line~", { numinlets: 2, numoutlets: 1, outlettype: ["signal"] }));
921
- lines.push(line("obj-render-armed", 0, `obj-render-istarget-${slot}`, 0));
922
- lines.push(line(`obj-render-istarget-${slot}`, 0, `obj-render-gainpack-${slot}`, 0));
923
- lines.push(line(`obj-render-gainpack-${slot}`, 0, `obj-render-gain-${slot}`, 0));
924
-
925
- // render_stop: ramp this slot's gain to 0 over 500 ms - a clearly audible fade. The
926
- // pending gate (closed by stop) keeps the next boundary from re-raising it.
927
- boxes.push(box(`obj-render-stopgain-${slot}`, "0. 500", { maxclass: "message", numinlets: 2, numoutlets: 1 }));
928
- lines.push(line("obj-render-route", 1, `obj-render-stopgain-${slot}`, 0));
929
- lines.push(line(`obj-render-stopgain-${slot}`, 0, `obj-render-gain-${slot}`, 0));
930
-
931
- // Apply the gain to each channel, then sum into the per-channel bus.
932
- const mulL = `obj-render-mul-l-${slot}`;
933
- const mulR = `obj-render-mul-r-${slot}`;
934
- boxes.push(box(mulL, "*~", { numinlets: 2, numoutlets: 1, outlettype: ["signal"] }));
935
- boxes.push(box(mulR, "*~", { numinlets: 2, numoutlets: 1, outlettype: ["signal"] }));
936
- lines.push(line(groove, 0, mulL, 0));
937
- lines.push(line(groove, 1, mulR, 0));
938
- lines.push(line(`obj-render-gain-${slot}`, 0, mulL, 1));
939
- lines.push(line(`obj-render-gain-${slot}`, 0, mulR, 1));
940
- lines.push(line(mulL, 0, "obj-render-sumslots-l", i));
941
- lines.push(line(mulR, 0, "obj-render-sumslots-r", i));
942
-
943
- // Arm: store this slot's index (cold) in [i]. That is ALL an arm does - it queues the
944
- // target. The next transport boundary reads it out and fades the gains, so the swap
945
- // lands on the loop boundary, not the instant you click. lengthBeats rode in on
946
- // render_load and reaches the boundary detector's cold inlet via render_len below.
947
- boxes.push(box(`obj-render-armidx-${slot}`, `${i}`, { maxclass: "message", numinlets: 2, numoutlets: 1 }));
948
- lines.push(line("obj-render-armslot", i, `obj-render-armidx-${slot}`, 0));
949
- lines.push(line(`obj-render-armidx-${slot}`, 0, "obj-render-armed", 1)); // cold store, no output
950
- });
951
-
952
- // render_len still arrives from the wrapper (it carries lengthBeats alongside the path)
953
- // but the self-clocked boundary no longer needs it: a [route render_len] swallows it so
954
- // it does not fall through to the wrapper as an unknown message. (Kept for when
955
- // transport-bar alignment returns and wants the loop length again.)
956
- boxes.push(box("obj-render-lenroute", "route render_len", { numoutlets: 2, outlettype: ["", ""] }));
957
- lines.push(line(unmatchedId, 1, "obj-render-lenroute", 0));
958
-
959
- // Sum the two per-channel buses onto the device's signal path and become its tail.
960
- for (const [ch, sumId] of [
961
- [0, "obj-render-sumslots-l"],
962
- [1, "obj-render-sumslots-r"],
963
- ]) {
964
- const [srcId, srcOut] = ctx.audioIn(ch);
965
- const outId = `obj-render-out-${ch}`;
966
- boxes.push(box(outId, "+~", { numinlets: 2, numoutlets: 1, outlettype: ["signal"] }));
967
- lines.push(line(srcId, srcOut, outId, 0)); // the device input (silence on an instrument)
968
- lines.push(line(sumId, 0, outId, 1)); // the rendered mix
969
- ctx.setAudioOut(ch, outId, 0);
970
- }
971
-
972
- // KNOWN MINOR ARTIFACT (S3): a faint tick at the loop wrap, from [groove~]'s loop-point
973
- // interpolation. The WAV loops seamlessly (whole-cycle sine, matched value AND slope at
974
- // the seam) and the gains are no longer re-triggered per loop, so this is groove~ itself
975
- // reading across the boundary. It is worst on a pure sine (the hardest case for loop
976
- // clicks) and is typically inaudible on real rendered content. Proper declick, if ever
977
- // needed on real material: drive playback from a [phasor~] into [play~]/[wave~], or bake a
978
- // few-ms equal-power loop crossfade into the render. Deferred - not worth it on a tone.
979
- }
980
-
981
- /**
982
- * "samples" - the first chain that ORIGINATES a sound: a named [buffer~] per slot,
983
- * loaded from a file on disk, played back through [groove~] into the signal path.
984
- *
985
- * app -> buffer_load <slot> <path> -> [js] (resolves the path)
986
- * -> buffer_replace -> [buffer~ <name>]
987
- * buffer_play <slot> -> [groove~] set + play
988
- * buffer_stop -> [groove~] stop
989
- * app <- buffer_ready <slot> <sr> <ms> <chans> when the read actually completed
990
- * buffer_error <slot> <msg> when there was no file to read
991
- *
992
- * THE BYTES NEVER CROSS THE BRIDGE. [buffer~] reads the file itself; what travels in
993
- * Max messages is a path and, coming back, a description of what landed - the same
994
- * rule the `download` chain follows, which is how you get the file there in the
995
- * first place.
996
- *
997
- * WAV/AIFF/Next-Sun ONLY. [buffer~]'s `read`/`replace` does not take MP3 - that list
998
- * (MP3, OGG, FLAC, M4A) is [sfplay~]'s, which streams from disk rather than filling a
999
- * buffer, and is therefore a different chain. A format it cannot read is an error in
1000
- * the Max console and NO bang, so the app's promise times out rather than lying.
1001
- *
1002
- * WHAT LOADED IS NOT WHAT YOU ASKED FOR, so the chain reports what it GOT. `replace`
1003
- * resizes the buffer and adopts the FILE's channel count and sample rate, so a slot
1004
- * is not mono because you wanted it to be. [info~] is banged from the buffer's own
1005
- * "read completed" outlet (outlet 1 - outlet 0 is a mouse position in the editing
1006
- * window) and reports sample rate, duration and channels; the app derives frames from
1007
- * those. A frame count on its own is not proof of a read: a failed `replace` leaves
1008
- * the PREVIOUS contents in place, so "there are samples in there" says nothing. The
1009
- * bang does - it only fires when a read completed - and until it does the app's
1010
- * promise is still open (see loadSample() in @m4l-jweb/bridge, which is where the
1011
- * timeout lives).
1012
- *
1013
- * info~'s outlets fire right-to-left, so the LAST one to arrive is outlet 0, the
1014
- * sample rate - which is therefore the one wired to [pack]'s hot inlet. Wire it the
1015
- * other way round and the message goes out carrying the PREVIOUS load's numbers.
1016
- *
1017
- * ONE VOICE, DELIBERATELY. `groove~` takes `set <buffer-name>` to switch buffers, so
1018
- * one stereo player covers N slots: this is a PREVIEW - the sample browser's "let me
1019
- * hear it, through the track" - not a sampler. Polyphony is the `instrument` chain,
1020
- * and it is still open (doc/TODO.md item 2).
1021
- *
1022
- * It SUMS into the signal path rather than claiming it ([+~]), because it makes sound
1023
- * of its own: on an audio effect the preview plays over the track's audio, and on an
1024
- * instrument there is nothing at the input to add.
1025
- *
1026
- * The buffer names are INSTANCE-SCOPED (`deviceBufName`, `---buf-<device>-<slot>`), so
1027
- * two copies of this device on two tracks own separate buffers. They used to be global
1028
- * to Max and generated from the device name alone, which meant the second copy loaded
1029
- * silently stole the first's samples.
1030
- *
1031
- * Slots default to one, named "preview". `slots: ["kick", "snare"]` in the manifest
1032
- * gives you more.
1033
- */
1034
- function samplesChain(ctx) {
1035
- const { boxes, lines, device, jwebId, unmatchedId } = ctx;
1036
- const slots = device?.slots ?? ["preview"];
1037
- // Instance-scoped, for the same reason the instrument's are: two copies of a preview
1038
- // device on two tracks are two devices, and a global name would hand both the same
1039
- // buffer. `---` expands per device instance in Live - see deviceBufName.
1040
- const bufName = (slot) => deviceBufName(device, slot);
1041
-
1042
- // `buffer_load` is NOT claimed from [jweb]. It goes on to the wrapper, which
1043
- // resolves the path and hands it back on its AUX OUTLET as `buffer_replace <slot>
1044
- // <abs path>` - the same shape the `download` chain takes `maxurl` in.
1045
- //
1046
- // The detour is the whole fix. The app writes a path relative to the device's
1047
- // folder (that is where fetchToFile puts the file), and [buffer~] does not resolve
1048
- // it that way: a bare name is looked up in MAX'S SEARCH PATH, which the device's
1049
- // folder is not in, so a file that was downloaded correctly reports "can't open".
1050
- // The resolved path then contains SPACES on a normal Live install ("Ableton
1051
- // Library"), and a path travelling through the patcher as message text would split
1052
- // there into atoms. Out of [js] it stays one symbol.
1053
- boxes.push(box("obj-samples-route", "route buffer_play buffer_stop", { numoutlets: 3, outlettype: ["", "", ""] }));
1054
- claimAppMessages(ctx, "obj-samples-route", 2);
1055
-
1056
- boxes.push(box("obj-samples-replaceroute", "route buffer_replace", { numoutlets: 2, outlettype: ["", ""] }));
1057
- lines.push(line(unmatchedId, 1, "obj-samples-replaceroute", 0));
1058
-
1059
- // `route` strips the selector, so the slot name is now the first word of both
1060
- // remaining messages: a second route per stream dispatches on it.
1061
- const slotList = slots.join(" ");
1062
- const slotOutlets = { numoutlets: slots.length + 1, outlettype: slots.map(() => "").concat("") };
1063
- boxes.push(box("obj-samples-loadslot", `route ${slotList}`, slotOutlets));
1064
- boxes.push(box("obj-samples-playslot", `route ${slotList}`, slotOutlets));
1065
- lines.push(line("obj-samples-replaceroute", 0, "obj-samples-loadslot", 0));
1066
- lines.push(line("obj-samples-route", 0, "obj-samples-playslot", 0));
1067
-
1068
- // The player. `groove~ <buffer> 2` = two signal outlets (plus a loop-sync outlet),
1069
- // and it MIXES a buffer with more channels down rather than dropping them.
1070
- // @loop 0 makes it a one-shot: a preview that loops forever is a preview you have
1071
- // to fight. [sig~ 1.] is the playback rate, in the left inlet, which is a SIGNAL
1072
- // inlet - a float there means something else entirely (a position, in ms).
1073
- boxes.push(box("obj-samples-rate", "sig~ 1.", { numinlets: 1, numoutlets: 1, outlettype: ["signal"] }));
1074
- boxes.push(
1075
- box("obj-samples-groove", `groove~ ${bufName(slots[0])} 2 @loop 0`, {
1076
- numinlets: 3,
1077
- numoutlets: 3,
1078
- outlettype: ["signal", "signal", "signal"],
1079
- }),
1080
- );
1081
- lines.push(line("obj-samples-rate", 0, "obj-samples-groove", 0));
1082
-
1083
- // MONO FOLD. `groove~ <buf> 2` hard-wires two signal outlets to L and R, and a
1084
- // MONO buffer drives outlet 0 ONLY - so a mono file (most of tidal-drum-machines
1085
- // is mono) plays in one ear. The channel count is not a build-time fact: [info~]
1086
- // MEASURES it when the buffer loads. So gate the R channel at RUNTIME - a
1087
- // [selector~ 2] whose control says which groove~ outlet is the real right channel:
1088
- // input 1 = groove~ outlet 1 (a stereo file's true R)
1089
- // input 2 = groove~ outlet 0 (fold the mono signal to R as well)
1090
- // The control is the currently-loaded slot's channel count run through
1091
- // [expr ($i1==1)+1]: mono(1) -> 2 (fold), stereo(2) -> 1 (real R). The L channel
1092
- // always takes groove~ outlet 0, so it needs no gate.
1093
- boxes.push(box("obj-samples-rsel", "selector~ 2", { numinlets: 3, numoutlets: 1, outlettype: ["signal"] }));
1094
- boxes.push(box("obj-samples-rgate", "expr ($i1==1)+1", { numinlets: 1, numoutlets: 1, outlettype: ["int"] }));
1095
- lines.push(line("obj-samples-groove", 1, "obj-samples-rsel", 1)); // stereo R -> input 1
1096
- lines.push(line("obj-samples-groove", 0, "obj-samples-rsel", 2)); // mono fold -> input 2
1097
- lines.push(line("obj-samples-rgate", 0, "obj-samples-rsel", 0)); // which one, per loaded slot
1098
-
1099
- // Stop: a bare `buffer_stop` arrives from [route] as a BANG - the word is gone -
1100
- // so re-materialize it in a message box, or groove~ hears nothing it knows.
1101
- boxes.push(box("obj-samples-stopmsg", "stop", { maxclass: "message", numinlets: 2, numoutlets: 1 }));
1102
- lines.push(line("obj-samples-route", 1, "obj-samples-stopmsg", 0));
1103
- lines.push(line("obj-samples-stopmsg", 0, "obj-samples-groove", 0));
1104
-
1105
- slots.forEach((slot, i) => {
1106
- const buf = `obj-samples-buf-${slot}`;
1107
- const info = `obj-samples-info-${slot}`;
1108
-
1109
- // buffer~: outlet 0 is a mouse position, outlet 1 is the bang on a completed
1110
- // read. Only the second one means anything here.
1111
- boxes.push(box(buf, `buffer~ ${bufName(slot)}`, { numinlets: 1, numoutlets: 2, outlettype: ["float", "bang"] }));
1112
- boxes.push(box(`obj-samples-replace-${slot}`, "prepend replace"));
1113
- lines.push(line("obj-samples-loadslot", i, `obj-samples-replace-${slot}`, 0));
1114
- lines.push(line(`obj-samples-replace-${slot}`, 0, buf, 0));
1115
-
1116
- // What actually landed. info~'s outlets: 0 = sample rate, 6 = duration in ms,
1117
- // 8 = number of channels (per its reference page - do not count them from
1118
- // memory). They fire right-to-left, so 0 arrives last and is the hot one.
1119
- boxes.push(
1120
- box(info, `info~ ${bufName(slot)}`, {
1121
- numinlets: 1,
1122
- numoutlets: 10,
1123
- outlettype: ["float", "list", "float", "float", "float", "float", "float", "", "int", ""],
1124
- }),
1125
- );
1126
- boxes.push(box(`obj-samples-pack-${slot}`, "pack 0. 0. 0", { numinlets: 3, numoutlets: 1, outlettype: [""] }));
1127
- boxes.push(box(`obj-samples-ready-${slot}`, `prepend buffer_ready ${slot}`));
1128
-
1129
- lines.push(line(buf, 1, info, 0)); // the read completed - ask what it was
1130
- lines.push(line(info, 8, `obj-samples-pack-${slot}`, 2)); // channels (fires first)
1131
- lines.push(line(info, 6, `obj-samples-pack-${slot}`, 1)); // duration, ms
1132
- lines.push(line(info, 0, `obj-samples-pack-${slot}`, 0)); // sample rate - hot, last
1133
- lines.push(line(`obj-samples-pack-${slot}`, 0, `obj-samples-ready-${slot}`, 0));
1134
- lines.push(line(`obj-samples-ready-${slot}`, 0, jwebId, 0));
1135
-
1136
- // Retain THIS slot's measured channel count, so play can re-assert the mono
1137
- // fold for whichever buffer is loaded into the one shared groove~. [info~]
1138
- // outlet 8 stores it in [f]'s cold inlet (no output); the play trigger bangs it
1139
- // out to the shared gate below. Every slot's [f] feeds that one gate.
1140
- boxes.push(box(`obj-samples-chans-${slot}`, "f", { numinlets: 2, numoutlets: 1, outlettype: [""] }));
1141
- lines.push(line(info, 8, `obj-samples-chans-${slot}`, 1)); // measured channels, cold-stored
1142
- lines.push(line(`obj-samples-chans-${slot}`, 0, "obj-samples-rgate", 0));
1143
-
1144
- // Play: pick the buffer, THEN start it. Two cords out of one outlet fire in an
1145
- // order Max chooses, so the sequence goes through a [t b b] - right outlet
1146
- // first - and never through a fan-out. Starting the OLD buffer and then
1147
- // switching is exactly the bug that looks like "the wrong sample previewed".
1148
- boxes.push(box(`obj-samples-trig-${slot}`, "t b b b", { numinlets: 1, numoutlets: 3, outlettype: ["bang", "bang", "bang"] }));
1149
- boxes.push(box(`obj-samples-set-${slot}`, `set ${bufName(slot)}`, { maxclass: "message", numinlets: 2, numoutlets: 1 }));
1150
- // A float in groove~'s left inlet is a playback POSITION in ms, and 0 is "from
1151
- // the beginning" - which is also what starts it after a `stop`.
1152
- boxes.push(box(`obj-samples-start-${slot}`, "0", { maxclass: "message", numinlets: 2, numoutlets: 1 }));
1153
-
1154
- // [t] fires right-to-left, so: set the fold gate, THEN the buffer, THEN start -
1155
- // the gate and the buffer are both in place before groove~ makes a sound.
1156
- lines.push(line("obj-samples-playslot", i, `obj-samples-trig-${slot}`, 0));
1157
- lines.push(line(`obj-samples-trig-${slot}`, 2, `obj-samples-chans-${slot}`, 0)); // first: assert the fold
1158
- lines.push(line(`obj-samples-trig-${slot}`, 1, `obj-samples-set-${slot}`, 0)); // then: set the buffer
1159
- lines.push(line(`obj-samples-trig-${slot}`, 0, `obj-samples-start-${slot}`, 0)); // ...then play it
1160
- lines.push(line(`obj-samples-set-${slot}`, 0, "obj-samples-groove", 0));
1161
- lines.push(line(`obj-samples-start-${slot}`, 0, "obj-samples-groove", 0));
1162
- });
1163
-
1164
- // Sum into the signal path: this chain MAKES sound, it does not process what came
1165
- // before. Claiming the stage instead would silence the track the preview plays over.
1166
- for (const [ch, id] of [
1167
- [0, "obj-samples-mix-l"],
1168
- [1, "obj-samples-mix-r"],
1169
- ]) {
1170
- const [srcId, srcOut] = ctx.audioIn(ch);
1171
- boxes.push(box(id, "+~", { numinlets: 2, numoutlets: 1, outlettype: ["signal"] }));
1172
- lines.push(line(srcId, srcOut, id, 0));
1173
- // L takes groove~ outlet 0 directly; R takes the mono-fold [selector~] instead
1174
- // of groove~ outlet 1, so a mono buffer reaches both ears.
1175
- if (ch === 0) lines.push(line("obj-samples-groove", 0, id, 1));
1176
- else lines.push(line("obj-samples-rsel", 0, id, 1));
1177
- ctx.setAudioOut(ch, id, 0);
1178
- }
1179
- }
1180
752
 
1181
753
  /**
1182
754
  * "delay" - a feedback delay, sent from a dry/wet knob: `.delay()`, `.delaytime()`
@@ -1308,244 +880,32 @@ function reverbChain(ctx) {
1308
880
  }
1309
881
  }
1310
882
 
1311
- /**
1312
- * The [poly~] voice patch: ONE played note, start to finish, as its own patcher.
1313
- *
1314
- * [poly~] loads N copies of this and hands each `note` message to the first voice
1315
- * whose [thispoly~] is not busy - so polyphony and voice-stealing are Max's job, not
1316
- * the app's, which is the whole reason to spend a [poly~] rather than run N groove~s
1317
- * and a scheduler by hand. Max cannot embed this inline (no factory device does; they
1318
- * all ship it as a named .maxpat), so the build freezes it into the .amxd as a
1319
- * dependency and [poly~] resolves it by name from the device's own bundle - the same
1320
- * way `Analogue Drums.amxd` carries `analog.Kick~.maxpat` (checked on disk).
1321
- *
1322
- * MULTI-SAMPLE. A voice can play ANY of the device's slots - one named [buffer~] per
1323
- * slot - so the instrument is a keymap, not one repitched sample. The voice request at
1324
- * [in 1] is the list `slot rate velocity durMs channels`:
1325
- * - slot -> which buffer: [sel 0 1 ...] picks the matching `set <bufName>`
1326
- * message, so groove~ switches to that slot's buffer before it starts.
1327
- * - rate -> playback RATE straight into [sig~] -> groove~'s left (signal) inlet.
1328
- * EXPLICIT, not derived: the app decides whether a note plays a
1329
- * dedicated sample at rate 1 or a repitched one at rate 2, which is
1330
- * what makes this a multi-sample keymap rather than one stretched
1331
- * buffer. No pitch->rate arithmetic here.
1332
- * - velocity -> amplitude, /127, on both channels' [*~].
1333
- * - durMs -> when to FREE the voice: [delay durMs] -> 0 to [thispoly~] and
1334
- * `stop` to groove~. The app times the note; Max holds the voice
1335
- * exactly that long. A one-shot past the sample's end is silent
1336
- * anyway (@loop 0), so this only bounds the allocation.
1337
- * - channels -> the mono fold, the SAME runtime gate the samples chain uses: a
1338
- * mono buffer drives groove~ outlet 0 only, so a [selector~ 2] keyed
1339
- * on the measured channel count folds it into R (see samplesChain).
1340
- *
1341
- * The order is sequenced with [t]: the whole list reaches [unpack] (buffer selected,
1342
- * rate, gate and duration all set) BEFORE the voice marks itself busy and starts
1343
- * groove~ from 0. Buffer-select is separate from start - `set <buf>` switches the
1344
- * buffer WITHOUT playing, and only the seq's `0` starts it - so a note never begins on
1345
- * the previous note's buffer or rate.
1346
- */
1347
- function instrumentVoicePatch(bufNames) {
1348
- let vy = 40;
1349
- const vbox = (id, text, extra = {}) => ({
1350
- box: { id, maxclass: "newobj", text, numinlets: 1, numoutlets: 1, outlettype: [""], patching_rect: [24, (vy += 30), 150, 22], ...extra },
1351
- });
1352
- const vmsg = (id, text) => ({
1353
- box: { id, maxclass: "message", text, numinlets: 2, numoutlets: 1, outlettype: [""], patching_rect: [200, (vy += 30), 90, 22] },
1354
- });
1355
-
1356
- const L = (s, so, d, di) => ({ patchline: { source: [s, so], destination: [d, di] } });
1357
-
1358
- const boxes = [
1359
- vbox("v-in", "in 1", { numinlets: 0, numoutlets: 1 }),
1360
- vbox("v-trig", "t b l", { numoutlets: 2, outlettype: ["bang", ""] }),
1361
- vbox("v-unpack", "unpack 0 0. 0 0 0", { numoutlets: 5, outlettype: ["int", "float", "int", "int", "int"] }),
1362
- vbox("v-seq", "t b b", { numoutlets: 2, outlettype: ["bang", "bang"] }),
1363
- vmsg("v-busy", "1"),
1364
- vmsg("v-start", "0"),
1365
- vbox("v-thispoly", "thispoly~", { numoutlets: 2, outlettype: ["int", "int"] }),
1366
- // slot -> which buffer. [sel] fires the matching outlet; the last (rightmost) is
1367
- // the no-match passthrough, left unwired.
1368
- vbox("v-sel", `sel ${bufNames.map((_, i) => i).join(" ")}`, { numoutlets: bufNames.length + 1, outlettype: bufNames.map(() => "bang").concat("") }),
1369
- vbox("v-sig", "sig~", { numoutlets: 1, outlettype: ["signal"] }),
1370
- vbox("v-vel", "/ 127.", { outlettype: ["float"] }),
1371
- // groove~ starts on the FIRST slot's buffer; `set` switches it per note.
1372
- vbox("v-groove", `groove~ ${bufNames[0]} 2 @loop 0`, { numinlets: 3, numoutlets: 3, outlettype: ["signal", "signal", "signal"] }),
1373
- vbox("v-gate", "expr ($i1==1)+1", { outlettype: ["int"] }),
1374
- vbox("v-rsel", "selector~ 2", { numinlets: 3, numoutlets: 1, outlettype: ["signal"] }),
1375
- vbox("v-ampL", "*~", { numinlets: 2, numoutlets: 1, outlettype: ["signal"] }),
1376
- vbox("v-ampR", "*~", { numinlets: 2, numoutlets: 1, outlettype: ["signal"] }),
1377
- vbox("v-del", "delay", { numinlets: 2, numoutlets: 1, outlettype: ["bang"] }),
1378
- vbox("v-free", "t b b", { numoutlets: 2, outlettype: ["bang", "bang"] }),
1379
- vmsg("v-freebusy", "0"),
1380
- vmsg("v-stop", "stop"),
1381
- vbox("v-out1", "out~ 1", { numinlets: 1, numoutlets: 0 }),
1382
- vbox("v-out2", "out~ 2", { numinlets: 1, numoutlets: 0 }),
1383
- ];
1384
-
1385
- const lines = [
1386
- L("v-in", 0, "v-trig", 0),
1387
- // list first (right outlet), then the start sequence (left) - values before play.
1388
- L("v-trig", 1, "v-unpack", 0),
1389
- L("v-trig", 0, "v-seq", 0),
1390
- L("v-unpack", 0, "v-sel", 0), // slot -> pick a buffer
1391
- L("v-unpack", 1, "v-sig", 0), // rate (explicit) -> signal rate
1392
- L("v-sig", 0, "v-groove", 0),
1393
- L("v-unpack", 2, "v-vel", 0), // velocity -> amp
1394
- L("v-vel", 0, "v-ampL", 1),
1395
- L("v-vel", 0, "v-ampR", 1),
1396
- L("v-unpack", 3, "v-del", 1), // duration -> delay time (cold)
1397
- L("v-unpack", 4, "v-gate", 0), // channels -> mono fold gate
1398
- L("v-gate", 0, "v-rsel", 0),
1399
- // the start sequence: mark busy, then start groove~ from 0 AND arm the free timer.
1400
- L("v-seq", 1, "v-busy", 0),
1401
- L("v-busy", 0, "v-thispoly", 0),
1402
- L("v-seq", 0, "v-start", 0),
1403
- L("v-start", 0, "v-groove", 0),
1404
- L("v-seq", 0, "v-del", 0), // bang the delay: it fires after durMs
1405
- // groove~ out: L direct, R via the mono-fold selector (stereo R vs. folded mono).
1406
- L("v-groove", 0, "v-ampL", 0),
1407
- L("v-groove", 1, "v-rsel", 1), // stereo R
1408
- L("v-groove", 0, "v-rsel", 2), // mono fold
1409
- L("v-rsel", 0, "v-ampR", 0),
1410
- L("v-ampL", 0, "v-out1", 0),
1411
- L("v-ampR", 0, "v-out2", 0),
1412
- // free the voice when the note's duration elapses.
1413
- L("v-del", 0, "v-free", 0),
1414
- L("v-free", 1, "v-freebusy", 0),
1415
- L("v-freebusy", 0, "v-thispoly", 0),
1416
- L("v-free", 0, "v-stop", 0),
1417
- L("v-stop", 0, "v-groove", 0),
1418
- ];
1419
-
1420
- // One `set <buffer>, ` message per slot: [sel] outlet i switches groove~ to that
1421
- // slot's buffer WITHOUT starting it (the seq's `0` does that). A trailing comma
1422
- // would start it; there is none, deliberately.
1423
- bufNames.forEach((buf, i) => {
1424
- const setId = `v-set-${i}`;
1425
- boxes.push(vmsg(setId, `set ${buf}`));
1426
- lines.push(L("v-sel", i, setId, 0));
1427
- lines.push(L(setId, 0, "v-groove", 0));
1428
- });
1429
-
1430
- return {
1431
- patcher: {
1432
- fileversion: 1,
1433
- appversion: { major: 8, minor: 0, revision: 0, architecture: "x64", modernui: 1 },
1434
- rect: [100, 100, 320, 600],
1435
- boxes,
1436
- lines,
1437
- },
1438
- };
1439
- }
883
+
884
+
1440
885
 
1441
886
  /**
1442
- * "instrument" - the marquee: a [poly~] of sample voices over a KEYMAP of named
1443
- * buffers, PLAYED by the note contract the bridge exports. `samples` made the first
1444
- * sound as ONE preview voice; this is the other half - N voices, N buffers, Max doing
1445
- * the allocation and stealing.
1446
- *
1447
- * app -> voice_play <slot> <rate> <vel> <durMs> <chans> -> [route] -> [prepend note] -> [poly~]
1448
- * buffer_load <slot> <path> -> [js] resolves -> [buffer~ <slot>]
1449
- * app <- buffer_ready <slot> <sr> <ms> <chans> when that slot's read completed
1450
- *
1451
- * The load path is the samples chain's, deliberately: the bytes never cross the bridge
1452
- * ([buffer~] reads the file, the wrapper resolves the path on its aux outlet), and the
1453
- * reply reports what [info~] MEASURED, not what was asked for. Every slot is its own
1454
- * named [buffer~]; the voice picks one per note by index and plays it at the rate the
1455
- * app chose - so a dedicated sample plays at rate 1 and a repitched one at rate 2,
1456
- * which is the app's decision, not the chain's.
1457
- *
1458
- * `slots: ["c", "e", "g"]` in the manifest is three buffers; the default is one
1459
- * ("voice"). The buffer names are INSTANCE-SCOPED (`deviceBufName`): each copy of the
1460
- * device owns its own, which is what lets a drum rack exist on two tracks at once.
1461
- * `---` scopes per DEVICE, so the voice spells the same name the device does - see
1462
- * deviceBufName.
1463
- *
1464
- * It SUMS into the signal path ([+~]): an instrument makes sound where there was none
1465
- * 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.
1466
890
  */
1467
- function instrumentChain(ctx) {
1468
- const { boxes, lines, device, jwebId, unmatchedId } = ctx;
1469
- const slots = device?.slots ?? ["voice"];
1470
- const bufName = (slot) => deviceBufName(device, slot);
1471
- const voices = device?.voices ?? 8;
1472
- const voiceFile = `${device?.name}-voice.maxpat`;
1473
-
1474
- // The frozen voice patch (a keymap of every slot's buffer), and the [poly~] that
1475
- // loads N copies of it. The voice spells the buffers exactly as the device does:
1476
- // `---` is device-scoped, so no id travels through poly~'s arguments.
1477
- ctx.extras.push({ name: voiceFile, data: instrumentVoicePatch(slots.map((s) => voiceBufName(device, s))) });
1478
- // [poly~]'s name is the file WITHOUT its extension, per Max's abstraction lookup.
1479
- boxes.push(
1480
- box(`obj-instr-poly`, `poly~ ${voiceFile.replace(/\.maxpat$/, "")} ${voices}`, {
1481
- numinlets: 1,
1482
- numoutlets: 2,
1483
- outlettype: ["signal", "signal"],
1484
- }),
1485
- );
891
+ function webaudioChain(ctx) {
892
+ const { boxes, lines, jwebId } = ctx;
1486
893
 
1487
- // voice_play -> `note <slot> <rate> <vel> <durMs> <chans>` -> poly~. `route` strips
1488
- // the selector, leaving the bare args; `prepend note` is the word poly~ dispatches on
1489
- // to pick a free voice. Claimed in series so ui_ready still reaches the wrapper.
1490
- boxes.push(box("obj-instr-playroute", "route voice_play", { numoutlets: 2, outlettype: ["", ""] }));
1491
- claimAppMessages(ctx, "obj-instr-playroute", 1);
1492
- boxes.push(box("obj-instr-note", "prepend note"));
1493
- lines.push(line("obj-instr-playroute", 0, "obj-instr-note", 0));
1494
- lines.push(line("obj-instr-note", 0, "obj-instr-poly", 0));
1495
-
1496
- // The load path, from the wrapper's aux outlet - identical in shape to samples: a
1497
- // bare buffer name resolves against MAX'S SEARCH PATH, not the device folder, so the
1498
- // wrapper resolves it and hands back `buffer_replace <slot> <abs path>`. One buffer
1499
- // per slot, dispatched by slot name (route matches a whole word).
1500
- boxes.push(box("obj-instr-replaceroute", "route buffer_replace", { numoutlets: 2, outlettype: ["", ""] }));
1501
- lines.push(line(unmatchedId, 1, "obj-instr-replaceroute", 0));
1502
- boxes.push(box("obj-instr-loadslot", `route ${slots.join(" ")}`, { numoutlets: slots.length + 1, outlettype: slots.map(() => "").concat("") }));
1503
- lines.push(line("obj-instr-replaceroute", 0, "obj-instr-loadslot", 0));
1504
-
1505
- slots.forEach((slot, i) => {
1506
- const buf = `obj-instr-buf-${slot}`;
1507
- const info = `obj-instr-info-${slot}`;
1508
- boxes.push(box(buf, `buffer~ ${bufName(slot)}`, { numinlets: 1, numoutlets: 2, outlettype: ["float", "bang"] }));
1509
- boxes.push(box(`obj-instr-replace-${slot}`, "prepend replace"));
1510
- lines.push(line("obj-instr-loadslot", i, `obj-instr-replace-${slot}`, 0));
1511
- lines.push(line(`obj-instr-replace-${slot}`, 0, buf, 0));
1512
-
1513
- // Report what LOADED, from the read-completed outlet (1). info~ fires right-to-left,
1514
- // so the sample rate (outlet 0) arrives last and drives [pack]'s hot inlet.
1515
- boxes.push(
1516
- box(info, `info~ ${bufName(slot)}`, {
1517
- numinlets: 1,
1518
- numoutlets: 10,
1519
- outlettype: ["float", "list", "float", "float", "float", "float", "float", "", "int", ""],
1520
- }),
1521
- );
1522
- boxes.push(box(`obj-instr-pack-${slot}`, "pack 0. 0. 0", { numinlets: 3, numoutlets: 1, outlettype: [""] }));
1523
- boxes.push(box(`obj-instr-ready-${slot}`, `prepend buffer_ready ${slot}`));
1524
- lines.push(line(buf, 1, info, 0));
1525
- lines.push(line(info, 8, `obj-instr-pack-${slot}`, 2)); // channels
1526
- lines.push(line(info, 6, `obj-instr-pack-${slot}`, 1)); // duration ms
1527
- lines.push(line(info, 0, `obj-instr-pack-${slot}`, 0)); // sample rate, hot
1528
- lines.push(line(`obj-instr-pack-${slot}`, 0, `obj-instr-ready-${slot}`, 0));
1529
- lines.push(line(`obj-instr-ready-${slot}`, 0, jwebId, 0));
1530
- });
1531
-
1532
- // Sum the voices into the signal path - an instrument originates sound.
1533
- for (const [ch, id] of [
1534
- [0, "obj-instr-mix-l"],
1535
- [1, "obj-instr-mix-r"],
1536
- ]) {
894
+ for (const ch of [0, 1]) {
1537
895
  const [srcId, srcOut] = ctx.audioIn(ch);
896
+ const id = `obj-webaudio-mix-${ch === 0 ? "l" : "r"}`;
897
+
1538
898
  boxes.push(box(id, "+~", { numinlets: 2, numoutlets: 1, outlettype: ["signal"] }));
1539
- lines.push(line(srcId, srcOut, id, 0));
1540
- 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
+
1541
902
  ctx.setAudioOut(ch, id, 0);
1542
903
  }
1543
904
  }
1544
905
 
1545
906
  export const CHAINS = {
907
+ webaudio: webaudioChain,
1546
908
  midiin: midiInChain,
1547
- samples: samplesChain,
1548
- instrument: instrumentChain,
1549
909
  midiout: midiOutChain,
1550
910
  passthrough: passthroughChain,
1551
911
  gain: gainChain,
@@ -1557,7 +917,6 @@ export const CHAINS = {
1557
917
  reverb: reverbChain,
1558
918
  remote: remoteChain,
1559
919
  download: downloadChain,
1560
- renderplay: renderplayChain,
1561
920
  };
1562
921
 
1563
922
  /**
@@ -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,
@@ -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.9.0",
21
- "@m4l-jweb/surface": "^0.9.0",
20
+ "@m4l-jweb/bridge": "^1.0.0",
21
+ "@m4l-jweb/surface": "^1.0.0",
22
22
  "react": "^19.0.0",
23
23
  "react-dom": "^19.0.0"
24
24
  },
25
25
  "devDependencies": {
26
- "@m4l-jweb/build": "^0.9.0",
26
+ "@m4l-jweb/build": "^1.0.0",
27
27
  "@types/node": "^22.0.0",
28
28
  "@types/react": "^19.0.0",
29
29
  "@types/react-dom": "^19.0.0",