@m4l-jweb/build 0.9.0 → 0.9.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +2 -2
  2. package/src/chains.mjs +231 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m4l-jweb/build",
3
- "version": "0.9.0",
3
+ "version": "0.9.5",
4
4
  "description": "m4l-jweb: the CLI that builds and packages a device repo into installable Max for Live devices.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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.0"
37
+ "@m4l-jweb/wrapper": "0.9.5"
38
38
  }
39
39
  }
package/src/chains.mjs CHANGED
@@ -748,6 +748,236 @@ 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
+
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
+
751
981
  /**
752
982
  * "samples" - the first chain that ORIGINATES a sound: a named [buffer~] per slot,
753
983
  * loaded from a file on disk, played back through [groove~] into the signal path.
@@ -1327,6 +1557,7 @@ export const CHAINS = {
1327
1557
  reverb: reverbChain,
1328
1558
  remote: remoteChain,
1329
1559
  download: downloadChain,
1560
+ renderplay: renderplayChain,
1330
1561
  };
1331
1562
 
1332
1563
  /**