@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 +42 -0
- package/package.json +2 -2
- package/src/chains.mjs +15 -656
- package/templates/base.json +4 -4
- package/templates/starter/package.json +3 -3
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.
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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
|
-
* "
|
|
1443
|
-
*
|
|
1444
|
-
*
|
|
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
|
|
1468
|
-
const { boxes, lines,
|
|
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
|
-
|
|
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(
|
|
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
|
/**
|
package/templates/base.json
CHANGED
|
@@ -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":
|
|
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",
|
|
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.
|
|
21
|
-
"@m4l-jweb/surface": "^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.
|
|
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",
|