@m4l-jweb/build 0.6.0 → 0.6.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.
- package/package.json +2 -2
- package/src/chains.mjs +431 -4
- package/src/index.mjs +29 -4
- package/src/surface.mjs +32 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m4l-jweb/build",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.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",
|
|
@@ -33,6 +33,6 @@
|
|
|
33
33
|
"archiver": "^7.0.1",
|
|
34
34
|
"esbuild": "^0.25.0",
|
|
35
35
|
"typescript": "^5.7.0",
|
|
36
|
-
"@m4l-jweb/wrapper": "0.6.
|
|
36
|
+
"@m4l-jweb/wrapper": "0.6.5"
|
|
37
37
|
}
|
|
38
38
|
}
|
package/src/chains.mjs
CHANGED
|
@@ -363,7 +363,7 @@ function gainChain(ctx) {
|
|
|
363
363
|
* chain that reintroduces a mapping like that DOUBLE-MAPS a parameter that already
|
|
364
364
|
* carries its own curve, and lies to every readout while it does it.
|
|
365
365
|
*/
|
|
366
|
-
function fanParamInto(ctx, paramId, dstId, dstInlet) {
|
|
366
|
+
export function fanParamInto(ctx, paramId, dstId, dstInlet) {
|
|
367
367
|
const [objId, objOut] = ctx.paramObject(paramId);
|
|
368
368
|
const [routeId, routeOut] = ctx.paramValue(paramId);
|
|
369
369
|
ctx.lines.push(line(objId, objOut, dstId, dstInlet));
|
|
@@ -611,6 +611,22 @@ function samplesChain(ctx) {
|
|
|
611
611
|
);
|
|
612
612
|
lines.push(line("obj-samples-rate", 0, "obj-samples-groove", 0));
|
|
613
613
|
|
|
614
|
+
// MONO FOLD. `groove~ <buf> 2` hard-wires two signal outlets to L and R, and a
|
|
615
|
+
// MONO buffer drives outlet 0 ONLY - so a mono file (most of tidal-drum-machines
|
|
616
|
+
// is mono) plays in one ear. The channel count is not a build-time fact: [info~]
|
|
617
|
+
// MEASURES it when the buffer loads. So gate the R channel at RUNTIME - a
|
|
618
|
+
// [selector~ 2] whose control says which groove~ outlet is the real right channel:
|
|
619
|
+
// input 1 = groove~ outlet 1 (a stereo file's true R)
|
|
620
|
+
// input 2 = groove~ outlet 0 (fold the mono signal to R as well)
|
|
621
|
+
// The control is the currently-loaded slot's channel count run through
|
|
622
|
+
// [expr ($i1==1)+1]: mono(1) -> 2 (fold), stereo(2) -> 1 (real R). The L channel
|
|
623
|
+
// always takes groove~ outlet 0, so it needs no gate.
|
|
624
|
+
boxes.push(box("obj-samples-rsel", "selector~ 2", { numinlets: 3, numoutlets: 1, outlettype: ["signal"] }));
|
|
625
|
+
boxes.push(box("obj-samples-rgate", "expr ($i1==1)+1", { numinlets: 1, numoutlets: 1, outlettype: ["int"] }));
|
|
626
|
+
lines.push(line("obj-samples-groove", 1, "obj-samples-rsel", 1)); // stereo R -> input 1
|
|
627
|
+
lines.push(line("obj-samples-groove", 0, "obj-samples-rsel", 2)); // mono fold -> input 2
|
|
628
|
+
lines.push(line("obj-samples-rgate", 0, "obj-samples-rsel", 0)); // which one, per loaded slot
|
|
629
|
+
|
|
614
630
|
// Stop: a bare `buffer_stop` arrives from [route] as a BANG - the word is gone -
|
|
615
631
|
// so re-materialize it in a message box, or groove~ hears nothing it knows.
|
|
616
632
|
boxes.push(box("obj-samples-stopmsg", "stop", { maxclass: "message", numinlets: 2, numoutlets: 1 }));
|
|
@@ -648,18 +664,29 @@ function samplesChain(ctx) {
|
|
|
648
664
|
lines.push(line(`obj-samples-pack-${slot}`, 0, `obj-samples-ready-${slot}`, 0));
|
|
649
665
|
lines.push(line(`obj-samples-ready-${slot}`, 0, jwebId, 0));
|
|
650
666
|
|
|
667
|
+
// Retain THIS slot's measured channel count, so play can re-assert the mono
|
|
668
|
+
// fold for whichever buffer is loaded into the one shared groove~. [info~]
|
|
669
|
+
// outlet 8 stores it in [f]'s cold inlet (no output); the play trigger bangs it
|
|
670
|
+
// out to the shared gate below. Every slot's [f] feeds that one gate.
|
|
671
|
+
boxes.push(box(`obj-samples-chans-${slot}`, "f", { numinlets: 2, numoutlets: 1, outlettype: [""] }));
|
|
672
|
+
lines.push(line(info, 8, `obj-samples-chans-${slot}`, 1)); // measured channels, cold-stored
|
|
673
|
+
lines.push(line(`obj-samples-chans-${slot}`, 0, "obj-samples-rgate", 0));
|
|
674
|
+
|
|
651
675
|
// Play: pick the buffer, THEN start it. Two cords out of one outlet fire in an
|
|
652
676
|
// order Max chooses, so the sequence goes through a [t b b] - right outlet
|
|
653
677
|
// first - and never through a fan-out. Starting the OLD buffer and then
|
|
654
678
|
// switching is exactly the bug that looks like "the wrong sample previewed".
|
|
655
|
-
boxes.push(box(`obj-samples-trig-${slot}`, "t b b", { numinlets: 1, numoutlets:
|
|
679
|
+
boxes.push(box(`obj-samples-trig-${slot}`, "t b b b", { numinlets: 1, numoutlets: 3, outlettype: ["bang", "bang", "bang"] }));
|
|
656
680
|
boxes.push(box(`obj-samples-set-${slot}`, `set ${bufName(slot)}`, { maxclass: "message", numinlets: 2, numoutlets: 1 }));
|
|
657
681
|
// A float in groove~'s left inlet is a playback POSITION in ms, and 0 is "from
|
|
658
682
|
// the beginning" - which is also what starts it after a `stop`.
|
|
659
683
|
boxes.push(box(`obj-samples-start-${slot}`, "0", { maxclass: "message", numinlets: 2, numoutlets: 1 }));
|
|
660
684
|
|
|
685
|
+
// [t] fires right-to-left, so: set the fold gate, THEN the buffer, THEN start -
|
|
686
|
+
// the gate and the buffer are both in place before groove~ makes a sound.
|
|
661
687
|
lines.push(line("obj-samples-playslot", i, `obj-samples-trig-${slot}`, 0));
|
|
662
|
-
lines.push(line(`obj-samples-trig-${slot}`,
|
|
688
|
+
lines.push(line(`obj-samples-trig-${slot}`, 2, `obj-samples-chans-${slot}`, 0)); // first: assert the fold
|
|
689
|
+
lines.push(line(`obj-samples-trig-${slot}`, 1, `obj-samples-set-${slot}`, 0)); // then: set the buffer
|
|
663
690
|
lines.push(line(`obj-samples-trig-${slot}`, 0, `obj-samples-start-${slot}`, 0)); // ...then play it
|
|
664
691
|
lines.push(line(`obj-samples-set-${slot}`, 0, "obj-samples-groove", 0));
|
|
665
692
|
lines.push(line(`obj-samples-start-${slot}`, 0, "obj-samples-groove", 0));
|
|
@@ -674,7 +701,372 @@ function samplesChain(ctx) {
|
|
|
674
701
|
const [srcId, srcOut] = ctx.audioIn(ch);
|
|
675
702
|
boxes.push(box(id, "+~", { numinlets: 2, numoutlets: 1, outlettype: ["signal"] }));
|
|
676
703
|
lines.push(line(srcId, srcOut, id, 0));
|
|
677
|
-
|
|
704
|
+
// L takes groove~ outlet 0 directly; R takes the mono-fold [selector~] instead
|
|
705
|
+
// of groove~ outlet 1, so a mono buffer reaches both ears.
|
|
706
|
+
if (ch === 0) lines.push(line("obj-samples-groove", 0, id, 1));
|
|
707
|
+
else lines.push(line("obj-samples-rsel", 0, id, 1));
|
|
708
|
+
ctx.setAudioOut(ch, id, 0);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* "delay" - a feedback delay, sent from a dry/wet knob: `.delay()`, `.delaytime()`
|
|
714
|
+
* and `.delayfeedback()` mapped straight onto Max's delay line.
|
|
715
|
+
*
|
|
716
|
+
* dry -->[+~ fbsum]-->[tapin~ 2000]-->[tapout~ 250]--+--> wet
|
|
717
|
+
* ^ |
|
|
718
|
+
* +----------[*~ feedback]<-----------------+
|
|
719
|
+
* out = dry + delay * wet
|
|
720
|
+
*
|
|
721
|
+
* SEND-STYLE, WHICH IS WHAT MAKES IT NEUTRAL. The dry input reaches the output
|
|
722
|
+
* summing [+~] at UNITY, untouched; the delayed signal is scaled by the `delay`
|
|
723
|
+
* mix and added on top. At `delay` = 0 the wet [*~] contributes exactly 0.0, so the
|
|
724
|
+
* output is bit-for-bit the input - a straight wire, which is the neutrality
|
|
725
|
+
* contract this chain declares in CHAIN_NEUTRAL below. That is the whole reason a
|
|
726
|
+
* wet path is SUMMED rather than crossfaded: `dry + 0` is identity, `dry*1 + wet*0`
|
|
727
|
+
* is too but invites a `1 - mix` no one can null-test by eye.
|
|
728
|
+
*
|
|
729
|
+
* tapin~/tapout~, per Max's reference (read on disk, not remembered): tapin~ writes
|
|
730
|
+
* a signal into a delay line; tapout~ reads it back, and the connection between them
|
|
731
|
+
* is NOT a signal cord - it is the delay-line link. tapout~'s inlet 0 takes BOTH
|
|
732
|
+
* that link and the delay time (a float in ms), the same way groove~'s left inlet
|
|
733
|
+
* takes a signal and messages at once. `tapin~ 2000` sizes the line to the top of
|
|
734
|
+
* `delaytime`'s range; a delay time past the max is silently clamped, so the max is
|
|
735
|
+
* a real limit, not decoration.
|
|
736
|
+
*
|
|
737
|
+
* Feedback closes the loop THROUGH a [+~] before tapin~, so the delayed signal is
|
|
738
|
+
* re-injected with the dry input rather than replacing it. At `delayfeedback` = 0
|
|
739
|
+
* the loop gain is 0 and you get a single echo.
|
|
740
|
+
*
|
|
741
|
+
* Requires `delay`, `delaytime`, `delayfeedback` in the device's surface (or point
|
|
742
|
+
* the chain at other names with `delayParam` / `delaytimeParam` /
|
|
743
|
+
* `delayfeedbackParam`). All in REAL units: the mix and feedback are 0-1, the time
|
|
744
|
+
* is in ms - the chain does no arithmetic on any of them.
|
|
745
|
+
*/
|
|
746
|
+
function delayChain(ctx) {
|
|
747
|
+
const { boxes, lines, device } = ctx;
|
|
748
|
+
const mixParam = requireParam(ctx, "delay", device?.delayParam ?? "delay", "delayParam");
|
|
749
|
+
const timeParam = requireParam(ctx, "delay", device?.delaytimeParam ?? "delaytime", "delaytimeParam");
|
|
750
|
+
const fbParam = requireParam(ctx, "delay", device?.delayfeedbackParam ?? "delayfeedback", "delayfeedbackParam");
|
|
751
|
+
|
|
752
|
+
for (const [ch, s] of [
|
|
753
|
+
[0, "l"],
|
|
754
|
+
[1, "r"],
|
|
755
|
+
]) {
|
|
756
|
+
const [srcId, srcOut] = ctx.audioIn(ch);
|
|
757
|
+
const fbsum = `obj-delay-fbsum-${s}`;
|
|
758
|
+
const tin = `obj-delay-tapin-${s}`;
|
|
759
|
+
const tout = `obj-delay-tapout-${s}`;
|
|
760
|
+
const fb = `obj-delay-fb-${s}`;
|
|
761
|
+
const wet = `obj-delay-wet-${s}`;
|
|
762
|
+
const mix = `obj-delay-mix-${s}`;
|
|
763
|
+
|
|
764
|
+
boxes.push(box(fbsum, "+~", { numinlets: 2, numoutlets: 1, outlettype: ["signal"] }));
|
|
765
|
+
boxes.push(box(tin, "tapin~ 2000", { numinlets: 1, numoutlets: 1, outlettype: ["signal"] }));
|
|
766
|
+
boxes.push(box(tout, "tapout~ 250", { numinlets: 1, numoutlets: 1, outlettype: ["signal"] }));
|
|
767
|
+
// A float, not int - the feedback and wet gains start at 0.0 so the stage is a
|
|
768
|
+
// wire until its parameters load, and the right inlets stay in float mode.
|
|
769
|
+
boxes.push(box(fb, "*~ 0.", { numinlets: 2, numoutlets: 1, outlettype: ["signal"] }));
|
|
770
|
+
boxes.push(box(wet, "*~ 0.", { numinlets: 2, numoutlets: 1, outlettype: ["signal"] }));
|
|
771
|
+
boxes.push(box(mix, "+~", { numinlets: 2, numoutlets: 1, outlettype: ["signal"] }));
|
|
772
|
+
|
|
773
|
+
// dry (+ feedback) into the delay line
|
|
774
|
+
lines.push(line(srcId, srcOut, fbsum, 0));
|
|
775
|
+
lines.push(line(fb, 0, fbsum, 1));
|
|
776
|
+
lines.push(line(fbsum, 0, tin, 0));
|
|
777
|
+
lines.push(line(tin, 0, tout, 0)); // the delay-line link, not a signal cord
|
|
778
|
+
fanParamInto(ctx, timeParam, tout, 0); // delay time, ms, into tapout~'s inlet
|
|
779
|
+
|
|
780
|
+
// feedback loop: the tap, scaled, back to the sum
|
|
781
|
+
lines.push(line(tout, 0, fb, 0));
|
|
782
|
+
fanParamInto(ctx, fbParam, fb, 1);
|
|
783
|
+
|
|
784
|
+
// the send: dry at unity + the tap scaled by `delay`
|
|
785
|
+
lines.push(line(tout, 0, wet, 0));
|
|
786
|
+
fanParamInto(ctx, mixParam, wet, 1);
|
|
787
|
+
lines.push(line(srcId, srcOut, mix, 0));
|
|
788
|
+
lines.push(line(wet, 0, mix, 1));
|
|
789
|
+
ctx.setAudioOut(ch, mix, 0);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* "reverb" - `cverb~` sent from a dry/wet knob (`.room()`).
|
|
795
|
+
*
|
|
796
|
+
* dry --+--------------------> [+~ mix] --> out
|
|
797
|
+
* | ^
|
|
798
|
+
* +-->[cverb~]-->[*~ room]-+
|
|
799
|
+
*
|
|
800
|
+
* cverb~ SHIPS INSIDE LIVE (`resources/externals/m4l/cverb~.mxe64`, checked on disk)
|
|
801
|
+
* and is MONAURAL and WET-ONLY: its output is pure reverberation, no dry component.
|
|
802
|
+
* A wet-only object dropped straight in the path is a colouration you cannot switch
|
|
803
|
+
* off - the exact trap the neutrality contract exists to forbid. So the dry/wet is a
|
|
804
|
+
* property of THIS chain, not something a device is trusted to remember: the dry
|
|
805
|
+
* signal reaches the output [+~] at unity, and the reverb is scaled by `room` and
|
|
806
|
+
* summed on top. At `room` = 0 the wet [*~] is 0.0 and the output is the input,
|
|
807
|
+
* bit-for-bit (CHAIN_NEUTRAL below).
|
|
808
|
+
*
|
|
809
|
+
* Mono, so one cverb~ per channel. The reverb TIME is fixed at the object's argument
|
|
810
|
+
* - the `fx` surface exposes only the send (`room`), not a time - so there is no
|
|
811
|
+
* parameter for it; add one and fan it into inlet 1 (signal/float, reverb time in
|
|
812
|
+
* ms, per the reference) if a device wants it.
|
|
813
|
+
*
|
|
814
|
+
* Requires `room` in the device's surface (or `roomParam`), 0-1.
|
|
815
|
+
*/
|
|
816
|
+
function reverbChain(ctx) {
|
|
817
|
+
const { boxes, lines, device } = ctx;
|
|
818
|
+
const mixParam = requireParam(ctx, "reverb", device?.roomParam ?? "room", "roomParam");
|
|
819
|
+
|
|
820
|
+
for (const [ch, s] of [
|
|
821
|
+
[0, "l"],
|
|
822
|
+
[1, "r"],
|
|
823
|
+
]) {
|
|
824
|
+
const [srcId, srcOut] = ctx.audioIn(ch);
|
|
825
|
+
const rv = `obj-reverb-cverb-${s}`;
|
|
826
|
+
const wet = `obj-reverb-wet-${s}`;
|
|
827
|
+
const mix = `obj-reverb-mix-${s}`;
|
|
828
|
+
|
|
829
|
+
boxes.push(box(rv, "cverb~ 2000.", { numinlets: 2, numoutlets: 1, outlettype: ["signal"] }));
|
|
830
|
+
boxes.push(box(wet, "*~ 0.", { numinlets: 2, numoutlets: 1, outlettype: ["signal"] }));
|
|
831
|
+
boxes.push(box(mix, "+~", { numinlets: 2, numoutlets: 1, outlettype: ["signal"] }));
|
|
832
|
+
|
|
833
|
+
lines.push(line(srcId, srcOut, rv, 0));
|
|
834
|
+
lines.push(line(rv, 0, wet, 0));
|
|
835
|
+
fanParamInto(ctx, mixParam, wet, 1);
|
|
836
|
+
lines.push(line(srcId, srcOut, mix, 0)); // dry at unity
|
|
837
|
+
lines.push(line(wet, 0, mix, 1)); // + reverb scaled by `room`
|
|
838
|
+
ctx.setAudioOut(ch, mix, 0);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* The [poly~] voice patch: ONE played note, start to finish, as its own patcher.
|
|
844
|
+
*
|
|
845
|
+
* [poly~] loads N copies of this and hands each `note` message to the first voice
|
|
846
|
+
* whose [thispoly~] is not busy - so polyphony and voice-stealing are Max's job, not
|
|
847
|
+
* the app's, which is the whole reason to spend a [poly~] rather than run N groove~s
|
|
848
|
+
* and a scheduler by hand. Max cannot embed this inline (no factory device does; they
|
|
849
|
+
* all ship it as a named .maxpat), so the build freezes it into the .amxd as a
|
|
850
|
+
* dependency and [poly~] resolves it by name from the device's own bundle - the same
|
|
851
|
+
* way `Analogue Drums.amxd` carries `analog.Kick~.maxpat` (checked on disk).
|
|
852
|
+
*
|
|
853
|
+
* MULTI-SAMPLE. A voice can play ANY of the device's slots - one named [buffer~] per
|
|
854
|
+
* slot - so the instrument is a keymap, not one repitched sample. The voice request at
|
|
855
|
+
* [in 1] is the list `slot rate velocity durMs channels`:
|
|
856
|
+
* - slot -> which buffer: [sel 0 1 ...] picks the matching `set <bufName>`
|
|
857
|
+
* message, so groove~ switches to that slot's buffer before it starts.
|
|
858
|
+
* - rate -> playback RATE straight into [sig~] -> groove~'s left (signal) inlet.
|
|
859
|
+
* EXPLICIT, not derived: the app decides whether a note plays a
|
|
860
|
+
* dedicated sample at rate 1 or a repitched one at rate 2, which is
|
|
861
|
+
* what makes this a multi-sample keymap rather than one stretched
|
|
862
|
+
* buffer. No pitch->rate arithmetic here.
|
|
863
|
+
* - velocity -> amplitude, /127, on both channels' [*~].
|
|
864
|
+
* - durMs -> when to FREE the voice: [delay durMs] -> 0 to [thispoly~] and
|
|
865
|
+
* `stop` to groove~. The app times the note; Max holds the voice
|
|
866
|
+
* exactly that long. A one-shot past the sample's end is silent
|
|
867
|
+
* anyway (@loop 0), so this only bounds the allocation.
|
|
868
|
+
* - channels -> the mono fold, the SAME runtime gate the samples chain uses: a
|
|
869
|
+
* mono buffer drives groove~ outlet 0 only, so a [selector~ 2] keyed
|
|
870
|
+
* on the measured channel count folds it into R (see samplesChain).
|
|
871
|
+
*
|
|
872
|
+
* The order is sequenced with [t]: the whole list reaches [unpack] (buffer selected,
|
|
873
|
+
* rate, gate and duration all set) BEFORE the voice marks itself busy and starts
|
|
874
|
+
* groove~ from 0. Buffer-select is separate from start - `set <buf>` switches the
|
|
875
|
+
* buffer WITHOUT playing, and only the seq's `0` starts it - so a note never begins on
|
|
876
|
+
* the previous note's buffer or rate.
|
|
877
|
+
*/
|
|
878
|
+
function instrumentVoicePatch(bufNames) {
|
|
879
|
+
let vy = 40;
|
|
880
|
+
const vbox = (id, text, extra = {}) => ({
|
|
881
|
+
box: { id, maxclass: "newobj", text, numinlets: 1, numoutlets: 1, outlettype: [""], patching_rect: [24, (vy += 30), 150, 22], ...extra },
|
|
882
|
+
});
|
|
883
|
+
const vmsg = (id, text) => ({
|
|
884
|
+
box: { id, maxclass: "message", text, numinlets: 2, numoutlets: 1, outlettype: [""], patching_rect: [200, (vy += 30), 90, 22] },
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
const L = (s, so, d, di) => ({ patchline: { source: [s, so], destination: [d, di] } });
|
|
888
|
+
|
|
889
|
+
const boxes = [
|
|
890
|
+
vbox("v-in", "in 1", { numinlets: 0, numoutlets: 1 }),
|
|
891
|
+
vbox("v-trig", "t b l", { numoutlets: 2, outlettype: ["bang", ""] }),
|
|
892
|
+
vbox("v-unpack", "unpack 0 0. 0 0 0", { numoutlets: 5, outlettype: ["int", "float", "int", "int", "int"] }),
|
|
893
|
+
vbox("v-seq", "t b b", { numoutlets: 2, outlettype: ["bang", "bang"] }),
|
|
894
|
+
vmsg("v-busy", "1"),
|
|
895
|
+
vmsg("v-start", "0"),
|
|
896
|
+
vbox("v-thispoly", "thispoly~", { numoutlets: 2, outlettype: ["int", "int"] }),
|
|
897
|
+
// slot -> which buffer. [sel] fires the matching outlet; the last (rightmost) is
|
|
898
|
+
// the no-match passthrough, left unwired.
|
|
899
|
+
vbox("v-sel", `sel ${bufNames.map((_, i) => i).join(" ")}`, { numoutlets: bufNames.length + 1, outlettype: bufNames.map(() => "bang").concat("") }),
|
|
900
|
+
vbox("v-sig", "sig~", { numoutlets: 1, outlettype: ["signal"] }),
|
|
901
|
+
vbox("v-vel", "/ 127.", { outlettype: ["float"] }),
|
|
902
|
+
// groove~ starts on the FIRST slot's buffer; `set` switches it per note.
|
|
903
|
+
vbox("v-groove", `groove~ ${bufNames[0]} 2 @loop 0`, { numinlets: 3, numoutlets: 3, outlettype: ["signal", "signal", "signal"] }),
|
|
904
|
+
vbox("v-gate", "expr ($i1==1)+1", { outlettype: ["int"] }),
|
|
905
|
+
vbox("v-rsel", "selector~ 2", { numinlets: 3, numoutlets: 1, outlettype: ["signal"] }),
|
|
906
|
+
vbox("v-ampL", "*~", { numinlets: 2, numoutlets: 1, outlettype: ["signal"] }),
|
|
907
|
+
vbox("v-ampR", "*~", { numinlets: 2, numoutlets: 1, outlettype: ["signal"] }),
|
|
908
|
+
vbox("v-del", "delay", { numinlets: 2, numoutlets: 1, outlettype: ["bang"] }),
|
|
909
|
+
vbox("v-free", "t b b", { numoutlets: 2, outlettype: ["bang", "bang"] }),
|
|
910
|
+
vmsg("v-freebusy", "0"),
|
|
911
|
+
vmsg("v-stop", "stop"),
|
|
912
|
+
vbox("v-out1", "out~ 1", { numinlets: 1, numoutlets: 0 }),
|
|
913
|
+
vbox("v-out2", "out~ 2", { numinlets: 1, numoutlets: 0 }),
|
|
914
|
+
];
|
|
915
|
+
|
|
916
|
+
const lines = [
|
|
917
|
+
L("v-in", 0, "v-trig", 0),
|
|
918
|
+
// list first (right outlet), then the start sequence (left) - values before play.
|
|
919
|
+
L("v-trig", 1, "v-unpack", 0),
|
|
920
|
+
L("v-trig", 0, "v-seq", 0),
|
|
921
|
+
L("v-unpack", 0, "v-sel", 0), // slot -> pick a buffer
|
|
922
|
+
L("v-unpack", 1, "v-sig", 0), // rate (explicit) -> signal rate
|
|
923
|
+
L("v-sig", 0, "v-groove", 0),
|
|
924
|
+
L("v-unpack", 2, "v-vel", 0), // velocity -> amp
|
|
925
|
+
L("v-vel", 0, "v-ampL", 1),
|
|
926
|
+
L("v-vel", 0, "v-ampR", 1),
|
|
927
|
+
L("v-unpack", 3, "v-del", 1), // duration -> delay time (cold)
|
|
928
|
+
L("v-unpack", 4, "v-gate", 0), // channels -> mono fold gate
|
|
929
|
+
L("v-gate", 0, "v-rsel", 0),
|
|
930
|
+
// the start sequence: mark busy, then start groove~ from 0 AND arm the free timer.
|
|
931
|
+
L("v-seq", 1, "v-busy", 0),
|
|
932
|
+
L("v-busy", 0, "v-thispoly", 0),
|
|
933
|
+
L("v-seq", 0, "v-start", 0),
|
|
934
|
+
L("v-start", 0, "v-groove", 0),
|
|
935
|
+
L("v-seq", 0, "v-del", 0), // bang the delay: it fires after durMs
|
|
936
|
+
// groove~ out: L direct, R via the mono-fold selector (stereo R vs. folded mono).
|
|
937
|
+
L("v-groove", 0, "v-ampL", 0),
|
|
938
|
+
L("v-groove", 1, "v-rsel", 1), // stereo R
|
|
939
|
+
L("v-groove", 0, "v-rsel", 2), // mono fold
|
|
940
|
+
L("v-rsel", 0, "v-ampR", 0),
|
|
941
|
+
L("v-ampL", 0, "v-out1", 0),
|
|
942
|
+
L("v-ampR", 0, "v-out2", 0),
|
|
943
|
+
// free the voice when the note's duration elapses.
|
|
944
|
+
L("v-del", 0, "v-free", 0),
|
|
945
|
+
L("v-free", 1, "v-freebusy", 0),
|
|
946
|
+
L("v-freebusy", 0, "v-thispoly", 0),
|
|
947
|
+
L("v-free", 0, "v-stop", 0),
|
|
948
|
+
L("v-stop", 0, "v-groove", 0),
|
|
949
|
+
];
|
|
950
|
+
|
|
951
|
+
// One `set <buffer>, ` message per slot: [sel] outlet i switches groove~ to that
|
|
952
|
+
// slot's buffer WITHOUT starting it (the seq's `0` does that). A trailing comma
|
|
953
|
+
// would start it; there is none, deliberately.
|
|
954
|
+
bufNames.forEach((buf, i) => {
|
|
955
|
+
const setId = `v-set-${i}`;
|
|
956
|
+
boxes.push(vmsg(setId, `set ${buf}`));
|
|
957
|
+
lines.push(L("v-sel", i, setId, 0));
|
|
958
|
+
lines.push(L(setId, 0, "v-groove", 0));
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
return {
|
|
962
|
+
patcher: {
|
|
963
|
+
fileversion: 1,
|
|
964
|
+
appversion: { major: 8, minor: 0, revision: 0, architecture: "x64", modernui: 1 },
|
|
965
|
+
rect: [100, 100, 320, 600],
|
|
966
|
+
boxes,
|
|
967
|
+
lines,
|
|
968
|
+
},
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/**
|
|
973
|
+
* "instrument" - the marquee: a [poly~] of sample voices over a KEYMAP of named
|
|
974
|
+
* buffers, PLAYED by the note contract the bridge exports. `samples` made the first
|
|
975
|
+
* sound as ONE preview voice; this is the other half - N voices, N buffers, Max doing
|
|
976
|
+
* the allocation and stealing.
|
|
977
|
+
*
|
|
978
|
+
* app -> voice_play <slot> <rate> <vel> <durMs> <chans> -> [route] -> [prepend note] -> [poly~]
|
|
979
|
+
* buffer_load <slot> <path> -> [js] resolves -> [buffer~ <slot>]
|
|
980
|
+
* app <- buffer_ready <slot> <sr> <ms> <chans> when that slot's read completed
|
|
981
|
+
*
|
|
982
|
+
* The load path is the samples chain's, deliberately: the bytes never cross the bridge
|
|
983
|
+
* ([buffer~] reads the file, the wrapper resolves the path on its aux outlet), and the
|
|
984
|
+
* reply reports what [info~] MEASURED, not what was asked for. Every slot is its own
|
|
985
|
+
* named [buffer~]; the voice picks one per note by index and plays it at the rate the
|
|
986
|
+
* app chose - so a dedicated sample plays at rate 1 and a repitched one at rate 2,
|
|
987
|
+
* which is the app's decision, not the chain's.
|
|
988
|
+
*
|
|
989
|
+
* `slots: ["c", "e", "g"]` in the manifest is three buffers; the default is one
|
|
990
|
+
* ("voice"). The buffer NAMES are `buf-<device>-<slot>`, global to Max - so two copies
|
|
991
|
+
* of the device on two tracks name their buffers alike (harmless for a demo, the drum-
|
|
992
|
+
* rack instance problem noted in doc/TODO.md).
|
|
993
|
+
*
|
|
994
|
+
* It SUMS into the signal path ([+~]): an instrument makes sound where there was none
|
|
995
|
+
* at its input, so there is nothing to claim a stage over.
|
|
996
|
+
*/
|
|
997
|
+
function instrumentChain(ctx) {
|
|
998
|
+
const { boxes, lines, device, jwebId, unmatchedId } = ctx;
|
|
999
|
+
const slots = device?.slots ?? ["voice"];
|
|
1000
|
+
const bufName = (slot) => `buf-${device?.name}-${slot}`;
|
|
1001
|
+
const voices = device?.voices ?? 8;
|
|
1002
|
+
const voiceFile = `${device?.name}-voice.maxpat`;
|
|
1003
|
+
|
|
1004
|
+
// The frozen voice patch (a keymap of every slot's buffer), and the [poly~] that
|
|
1005
|
+
// loads N copies of it.
|
|
1006
|
+
ctx.extras.push({ name: voiceFile, data: instrumentVoicePatch(slots.map(bufName)) });
|
|
1007
|
+
// [poly~]'s name is the file WITHOUT its extension, per Max's abstraction lookup.
|
|
1008
|
+
boxes.push(
|
|
1009
|
+
box(`obj-instr-poly`, `poly~ ${voiceFile.replace(/\.maxpat$/, "")} ${voices}`, {
|
|
1010
|
+
numinlets: 1,
|
|
1011
|
+
numoutlets: 2,
|
|
1012
|
+
outlettype: ["signal", "signal"],
|
|
1013
|
+
}),
|
|
1014
|
+
);
|
|
1015
|
+
|
|
1016
|
+
// voice_play -> `note <slot> <rate> <vel> <durMs> <chans>` -> poly~. `route` strips
|
|
1017
|
+
// the selector, leaving the bare args; `prepend note` is the word poly~ dispatches on
|
|
1018
|
+
// to pick a free voice. Claimed in series so ui_ready still reaches the wrapper.
|
|
1019
|
+
boxes.push(box("obj-instr-playroute", "route voice_play", { numoutlets: 2, outlettype: ["", ""] }));
|
|
1020
|
+
claimAppMessages(ctx, "obj-instr-playroute", 1);
|
|
1021
|
+
boxes.push(box("obj-instr-note", "prepend note"));
|
|
1022
|
+
lines.push(line("obj-instr-playroute", 0, "obj-instr-note", 0));
|
|
1023
|
+
lines.push(line("obj-instr-note", 0, "obj-instr-poly", 0));
|
|
1024
|
+
|
|
1025
|
+
// The load path, from the wrapper's aux outlet - identical in shape to samples: a
|
|
1026
|
+
// bare buffer name resolves against MAX'S SEARCH PATH, not the device folder, so the
|
|
1027
|
+
// wrapper resolves it and hands back `buffer_replace <slot> <abs path>`. One buffer
|
|
1028
|
+
// per slot, dispatched by slot name (route matches a whole word).
|
|
1029
|
+
boxes.push(box("obj-instr-replaceroute", "route buffer_replace", { numoutlets: 2, outlettype: ["", ""] }));
|
|
1030
|
+
lines.push(line(unmatchedId, 1, "obj-instr-replaceroute", 0));
|
|
1031
|
+
boxes.push(box("obj-instr-loadslot", `route ${slots.join(" ")}`, { numoutlets: slots.length + 1, outlettype: slots.map(() => "").concat("") }));
|
|
1032
|
+
lines.push(line("obj-instr-replaceroute", 0, "obj-instr-loadslot", 0));
|
|
1033
|
+
|
|
1034
|
+
slots.forEach((slot, i) => {
|
|
1035
|
+
const buf = `obj-instr-buf-${slot}`;
|
|
1036
|
+
const info = `obj-instr-info-${slot}`;
|
|
1037
|
+
boxes.push(box(buf, `buffer~ ${bufName(slot)}`, { numinlets: 1, numoutlets: 2, outlettype: ["float", "bang"] }));
|
|
1038
|
+
boxes.push(box(`obj-instr-replace-${slot}`, "prepend replace"));
|
|
1039
|
+
lines.push(line("obj-instr-loadslot", i, `obj-instr-replace-${slot}`, 0));
|
|
1040
|
+
lines.push(line(`obj-instr-replace-${slot}`, 0, buf, 0));
|
|
1041
|
+
|
|
1042
|
+
// Report what LOADED, from the read-completed outlet (1). info~ fires right-to-left,
|
|
1043
|
+
// so the sample rate (outlet 0) arrives last and drives [pack]'s hot inlet.
|
|
1044
|
+
boxes.push(
|
|
1045
|
+
box(info, `info~ ${bufName(slot)}`, {
|
|
1046
|
+
numinlets: 1,
|
|
1047
|
+
numoutlets: 10,
|
|
1048
|
+
outlettype: ["float", "list", "float", "float", "float", "float", "float", "", "int", ""],
|
|
1049
|
+
}),
|
|
1050
|
+
);
|
|
1051
|
+
boxes.push(box(`obj-instr-pack-${slot}`, "pack 0. 0. 0", { numinlets: 3, numoutlets: 1, outlettype: [""] }));
|
|
1052
|
+
boxes.push(box(`obj-instr-ready-${slot}`, `prepend buffer_ready ${slot}`));
|
|
1053
|
+
lines.push(line(buf, 1, info, 0));
|
|
1054
|
+
lines.push(line(info, 8, `obj-instr-pack-${slot}`, 2)); // channels
|
|
1055
|
+
lines.push(line(info, 6, `obj-instr-pack-${slot}`, 1)); // duration ms
|
|
1056
|
+
lines.push(line(info, 0, `obj-instr-pack-${slot}`, 0)); // sample rate, hot
|
|
1057
|
+
lines.push(line(`obj-instr-pack-${slot}`, 0, `obj-instr-ready-${slot}`, 0));
|
|
1058
|
+
lines.push(line(`obj-instr-ready-${slot}`, 0, jwebId, 0));
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
// Sum the voices into the signal path - an instrument originates sound.
|
|
1062
|
+
for (const [ch, id] of [
|
|
1063
|
+
[0, "obj-instr-mix-l"],
|
|
1064
|
+
[1, "obj-instr-mix-r"],
|
|
1065
|
+
]) {
|
|
1066
|
+
const [srcId, srcOut] = ctx.audioIn(ch);
|
|
1067
|
+
boxes.push(box(id, "+~", { numinlets: 2, numoutlets: 1, outlettype: ["signal"] }));
|
|
1068
|
+
lines.push(line(srcId, srcOut, id, 0));
|
|
1069
|
+
lines.push(line("obj-instr-poly", ch, id, 1));
|
|
678
1070
|
ctx.setAudioOut(ch, id, 0);
|
|
679
1071
|
}
|
|
680
1072
|
}
|
|
@@ -682,14 +1074,49 @@ function samplesChain(ctx) {
|
|
|
682
1074
|
export const CHAINS = {
|
|
683
1075
|
midiin: midiInChain,
|
|
684
1076
|
samples: samplesChain,
|
|
1077
|
+
instrument: instrumentChain,
|
|
685
1078
|
midiout: midiOutChain,
|
|
686
1079
|
passthrough: passthroughChain,
|
|
687
1080
|
gain: gainChain,
|
|
688
1081
|
lowpass: lowpassChain,
|
|
689
1082
|
drive: driveChain,
|
|
1083
|
+
delay: delayChain,
|
|
1084
|
+
reverb: reverbChain,
|
|
690
1085
|
download: downloadChain,
|
|
691
1086
|
};
|
|
692
1087
|
|
|
1088
|
+
/**
|
|
1089
|
+
* The NEUTRALITY CONTRACT: the parameter value at which each stage is a straight
|
|
1090
|
+
* wire - identical input and output samples.
|
|
1091
|
+
*
|
|
1092
|
+
* The DSP graph is written at BUILD time and every stage is ALWAYS in the signal
|
|
1093
|
+
* path, including the ones a device's line never mentions. That is only safe if each
|
|
1094
|
+
* stage has a setting where it does nothing, and if the library can say what it is.
|
|
1095
|
+
* Two kinds of stage reach neutral two ways:
|
|
1096
|
+
*
|
|
1097
|
+
* naturally transparent - `gain` (1.0), `lowpass` (18 kHz, nothing left to remove),
|
|
1098
|
+
* `drive` (1x): the DSP object itself is a wire at that value.
|
|
1099
|
+
* send-style wet/dry - `delay` (0), `reverb` (0): the wet branch is scaled to 0.0
|
|
1100
|
+
* and summed onto an untouched dry path, so the output is `dry + 0`. A WET-ONLY
|
|
1101
|
+
* object (cverb~) has NO neutral of its own - it must carry this dry/wet, which
|
|
1102
|
+
* is why it is a property of the chain and pinned here, not left to a device.
|
|
1103
|
+
*
|
|
1104
|
+
* `tests/neutrality.test.mjs` null-tests the send-style stages structurally (the dry
|
|
1105
|
+
* wire survives at unity and the only other path is gain-0), which is what can be
|
|
1106
|
+
* proven with no Max in the loop; the bit-exact acoustic check is a listening test,
|
|
1107
|
+
* the same standing as hello-audio.
|
|
1108
|
+
*/
|
|
1109
|
+
export const CHAIN_NEUTRAL = {
|
|
1110
|
+
gain: { gain: 1 },
|
|
1111
|
+
lowpass: { cutoff: 18000 },
|
|
1112
|
+
drive: { drive: 1 },
|
|
1113
|
+
delay: { delay: 0 },
|
|
1114
|
+
reverb: { room: 0 },
|
|
1115
|
+
};
|
|
1116
|
+
|
|
1117
|
+
/** The send-style chains: wet-only or naturally additive, neutral by a 0 mix they own. */
|
|
1118
|
+
export const WET_DRY_CHAINS = ["delay", "reverb"];
|
|
1119
|
+
|
|
693
1120
|
/** Add a chain to the vocabulary. Called before generatePatchers(). */
|
|
694
1121
|
export function registerChain(name, fn) {
|
|
695
1122
|
CHAINS[name] = fn;
|
package/src/index.mjs
CHANGED
|
@@ -196,7 +196,13 @@ export function composePatcher(base, d, surface) {
|
|
|
196
196
|
* which updates it WITHOUT output, so the object would
|
|
197
197
|
* never pass that value on. See surface.mjs.
|
|
198
198
|
*/
|
|
199
|
-
|
|
199
|
+
// Extra frozen dependencies a chain contributes to the .amxd - a [poly~] voice
|
|
200
|
+
// patch, say, which Max cannot embed inline and must resolve as a named .maxpat
|
|
201
|
+
// from the device's own bundle (the same way a frozen M4L instrument ships its
|
|
202
|
+
// voice abstraction). A chain pushes { name, data } here; generatePatchers writes
|
|
203
|
+
// each next to the device patcher and packageDevices freezes it into the container.
|
|
204
|
+
const extras = [];
|
|
205
|
+
const ctx = { boxes, lines, jwebId: "obj-jweb", unmatchedId, device: d, extras, ...surfaceContext(surface) };
|
|
200
206
|
|
|
201
207
|
openAudio(ctx);
|
|
202
208
|
|
|
@@ -212,6 +218,9 @@ export function composePatcher(base, d, surface) {
|
|
|
212
218
|
closeAudio(ctx);
|
|
213
219
|
assertUniqueBoxIds(boxes, d.name);
|
|
214
220
|
|
|
221
|
+
// Ride the chain-contributed extras out on the returned object. Destructuring
|
|
222
|
+
// `{ patcher }` (the tests, the writer) ignores it; the packager reads it.
|
|
223
|
+
p.extras = extras;
|
|
215
224
|
return p;
|
|
216
225
|
}
|
|
217
226
|
|
|
@@ -239,6 +248,15 @@ export async function generatePatchers(root) {
|
|
|
239
248
|
const surface = await loadSurface(root, d.ui ?? d.name);
|
|
240
249
|
const p = composePatcher(base, d, surface);
|
|
241
250
|
|
|
251
|
+
// A chain's frozen dependencies (e.g. a [poly~] voice patch) are written beside
|
|
252
|
+
// the device patcher and their names recorded in a sidecar, so packageDevices -
|
|
253
|
+
// a separate pass that only sees dist/ - knows which files to freeze into which
|
|
254
|
+
// device. Strip them from the patcher json itself; they are not part of it.
|
|
255
|
+
const extras = p.extras ?? [];
|
|
256
|
+
delete p.extras;
|
|
257
|
+
for (const ex of extras) writeFileSync(path.join(outDir, ex.name), typeof ex.data === "string" ? ex.data : JSON.stringify(ex.data));
|
|
258
|
+
writeFileSync(path.join(outDir, `${d.name}.extras.json`), JSON.stringify(extras.map((e) => e.name)));
|
|
259
|
+
|
|
242
260
|
writeFileSync(path.join(outDir, `${d.name}.json`), JSON.stringify(p, null, "\t"));
|
|
243
261
|
const params = surface ? surface.ids.join(", ") : "none";
|
|
244
262
|
console.log(`m4l-jweb: ${d.name}.json (${d.type}, chains: ${(d.chains ?? []).join(", ") || "none"}, params: ${params || "none"})`);
|
|
@@ -321,13 +339,20 @@ export async function packageDevices(root) {
|
|
|
321
339
|
|
|
322
340
|
if (payloads.length) wrapperData += extraPayloadsJs(payloads);
|
|
323
341
|
|
|
342
|
+
// Frozen dependencies: readable by Max-native objects only (a poly~ voice
|
|
343
|
+
// patcher, say), which is exactly why they can stay frozen rather than being
|
|
344
|
+
// extracted to disk like the UI. Two sources: manifest `extraFiles` (files in
|
|
345
|
+
// the repo) and chain-generated files recorded in the sidecar by generatePatchers.
|
|
346
|
+
const chainExtrasList = path.join(dist, "patchers", `${d.name}.extras.json`);
|
|
347
|
+
const chainExtras = existsSync(chainExtrasList)
|
|
348
|
+
? JSON.parse(readFileSync(chainExtrasList, "utf8")).map((name) => ({ name, data: readFileSync(path.join(dist, "patchers", name)) }))
|
|
349
|
+
: [];
|
|
350
|
+
|
|
324
351
|
const amxd = buildAmxd({
|
|
325
352
|
patcherJson: readFileSync(path.join(dist, "patchers", `${d.name}.json`), "utf8"),
|
|
326
353
|
wrapperJs: wrapperData,
|
|
327
354
|
deviceName,
|
|
328
|
-
|
|
329
|
-
// voice patcher, say), which is exactly why they can stay frozen.
|
|
330
|
-
extras: (d.extraFiles ?? []).map((f) => ({ name: path.basename(f), data: readFileSync(path.join(root, f)) })),
|
|
355
|
+
extras: [...(d.extraFiles ?? []).map((f) => ({ name: path.basename(f), data: readFileSync(path.join(root, f)) })), ...chainExtras],
|
|
331
356
|
});
|
|
332
357
|
writeFileSync(path.join(outDir, deviceName), amxd);
|
|
333
358
|
console.log(`m4l-jweb: ${deviceName} (${d.type}, ${amxd.length} bytes)`);
|
package/src/surface.mjs
CHANGED
|
@@ -324,7 +324,7 @@ export function applySurface(ctx) {
|
|
|
324
324
|
* ------------------------------------------------------------------------------
|
|
325
325
|
*/
|
|
326
326
|
export function applyWindows(ctx) {
|
|
327
|
-
const { boxes, lines, surface } = ctx;
|
|
327
|
+
const { boxes, lines, surface, unmatchedId } = ctx;
|
|
328
328
|
const windowIds = surface?.windows ? Object.keys(surface.windows) : [];
|
|
329
329
|
if (windowIds.length === 0) return;
|
|
330
330
|
|
|
@@ -374,6 +374,12 @@ export function applyWindows(ctx) {
|
|
|
374
374
|
|
|
375
375
|
const subpatcherId = `obj-window-${id}-sub`;
|
|
376
376
|
lines.push(line(pcontrolId, 0, subpatcherId, 0));
|
|
377
|
+
// The window's [jweb] can now TALK BACK. Its output leaves the subpatcher on an
|
|
378
|
+
// [outlet] and this cord carries it to the wrapper's [js] - the same [js] the
|
|
379
|
+
// device view feeds. It is tagged `window <id>` inside (below), so the wrapper
|
|
380
|
+
// tells the two apart and can answer the right one. Without this the window's
|
|
381
|
+
// page could display but never send a message: the [jweb] outlet went nowhere.
|
|
382
|
+
lines.push(line(subpatcherId, 0, unmatchedId, 0));
|
|
377
383
|
boxes.push({
|
|
378
384
|
box: {
|
|
379
385
|
id: subpatcherId,
|
|
@@ -383,7 +389,8 @@ export function applyWindows(ctx) {
|
|
|
383
389
|
// subpatcher that has no inlets, so [pcontrol] would end up wired to
|
|
384
390
|
// NOTHING - silently, in the saved file. That was attempt 1.
|
|
385
391
|
numinlets: 1,
|
|
386
|
-
numoutlets:
|
|
392
|
+
numoutlets: 1,
|
|
393
|
+
outlettype: [""],
|
|
387
394
|
patching_rect: [16, 620, 120, 22],
|
|
388
395
|
patcher: {
|
|
389
396
|
fileversion: 1,
|
|
@@ -418,8 +425,30 @@ export function applyWindows(ctx) {
|
|
|
418
425
|
presentation_rect: [0, 0, spec.width, spec.height],
|
|
419
426
|
},
|
|
420
427
|
},
|
|
428
|
+
// TAG the window's messages with which window they are, so the wrapper's
|
|
429
|
+
// `window()` can answer THIS window (its [jweb] has no cord from [js], so
|
|
430
|
+
// a reply goes back by name). The page emits bare selectors; the tag is
|
|
431
|
+
// added here, in the patcher, not in the app - so a window page is an
|
|
432
|
+
// ordinary bridge client that happens to be in its own runtime.
|
|
433
|
+
{
|
|
434
|
+
box: {
|
|
435
|
+
id: "obj-tag",
|
|
436
|
+
maxclass: "newobj",
|
|
437
|
+
text: `prepend window ${id}`,
|
|
438
|
+
numinlets: 1,
|
|
439
|
+
numoutlets: 1,
|
|
440
|
+
outlettype: [""],
|
|
441
|
+
patching_rect: [16, 96 + spec.height + 16, 160, 22],
|
|
442
|
+
},
|
|
443
|
+
},
|
|
444
|
+
{ box: { id: "obj-out", maxclass: "outlet", patching_rect: [16, 96 + spec.height + 48, 30, 30], numinlets: 1, numoutlets: 0 } },
|
|
445
|
+
],
|
|
446
|
+
lines: [
|
|
447
|
+
{ patchline: { source: ["obj-recv", 0], destination: ["obj-jweb", 0] } },
|
|
448
|
+
// [jweb] outlet 0 is the page's messages; tag them and send them out.
|
|
449
|
+
{ patchline: { source: ["obj-jweb", 0], destination: ["obj-tag", 0] } },
|
|
450
|
+
{ patchline: { source: ["obj-tag", 0], destination: ["obj-out", 0] } },
|
|
421
451
|
],
|
|
422
|
-
lines: [{ patchline: { source: ["obj-recv", 0], destination: ["obj-jweb", 0] } }],
|
|
423
452
|
},
|
|
424
453
|
},
|
|
425
454
|
});
|