@m4l-jweb/build 0.5.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 +649 -3
- package/src/index.mjs +56 -9
- package/src/surface.mjs +234 -0
- package/templates/starter/package.json +3 -3
- package/templates/starter/scripts/build-ui.mjs +34 -3
- package/templates/starter/vite.config.ts +4 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m4l-jweb/build",
|
|
3
|
-
"version": "0.5
|
|
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.5
|
|
36
|
+
"@m4l-jweb/wrapper": "0.6.5"
|
|
37
37
|
}
|
|
38
38
|
}
|
package/src/chains.mjs
CHANGED
|
@@ -192,18 +192,26 @@ export function closeAudio(ctx) {
|
|
|
192
192
|
* that is about to be written, so it covers the canned chains, a device repo's own
|
|
193
193
|
* `patcher/chains.mjs`, and the template underneath both.
|
|
194
194
|
*/
|
|
195
|
-
export function assertUniqueBoxIds(boxes, deviceName) {
|
|
195
|
+
export function assertUniqueBoxIds(boxes, deviceName, scope = "the patcher") {
|
|
196
196
|
const seen = new Set();
|
|
197
197
|
const dupes = new Set();
|
|
198
198
|
for (const { box: b } of boxes) (seen.has(b.id) ? dupes : seen).add(b.id);
|
|
199
199
|
if (dupes.size) {
|
|
200
200
|
throw new Error(
|
|
201
|
-
`device "${deviceName}" generated duplicate box ids: ${[...dupes].join(", ")}. ` +
|
|
201
|
+
`device "${deviceName}" generated duplicate box ids in ${scope}: ${[...dupes].join(", ")}. ` +
|
|
202
202
|
`Two boxes with one id is a patcher Max will interpret however it likes. ` +
|
|
203
203
|
`A chain that creates a box another chain also creates is not a stage - it is claiming to be the whole device. ` +
|
|
204
204
|
`Take the stage before you (ctx.audioIn / ctx.appOut) and hand yours on, or name your boxes after your chain.`,
|
|
205
205
|
);
|
|
206
206
|
}
|
|
207
|
+
// A SUBPATCHER is its own id namespace, so its boxes are checked against each
|
|
208
|
+
// other and not against ours - a floating window's [jweb] may be called
|
|
209
|
+
// `obj-jweb` even though the device's is too. It is checked, though: the window
|
|
210
|
+
// codegen emitted two inlets sharing one id and nothing said a word, because
|
|
211
|
+
// this only ever looked at the top level.
|
|
212
|
+
for (const { box: b } of boxes) {
|
|
213
|
+
if (b.patcher?.boxes) assertUniqueBoxIds(b.patcher.boxes, deviceName, `subpatcher [${b.text ?? b.id}]`);
|
|
214
|
+
}
|
|
207
215
|
}
|
|
208
216
|
|
|
209
217
|
/**
|
|
@@ -355,7 +363,7 @@ function gainChain(ctx) {
|
|
|
355
363
|
* chain that reintroduces a mapping like that DOUBLE-MAPS a parameter that already
|
|
356
364
|
* carries its own curve, and lies to every readout while it does it.
|
|
357
365
|
*/
|
|
358
|
-
function fanParamInto(ctx, paramId, dstId, dstInlet) {
|
|
366
|
+
export function fanParamInto(ctx, paramId, dstId, dstInlet) {
|
|
359
367
|
const [objId, objOut] = ctx.paramObject(paramId);
|
|
360
368
|
const [routeId, routeOut] = ctx.paramValue(paramId);
|
|
361
369
|
ctx.lines.push(line(objId, objOut, dstId, dstInlet));
|
|
@@ -462,15 +470,653 @@ function driveChain(ctx) {
|
|
|
462
470
|
}
|
|
463
471
|
}
|
|
464
472
|
|
|
473
|
+
/**
|
|
474
|
+
* "download" - `[maxurl]`, so the app can fetch a file to DISK.
|
|
475
|
+
*
|
|
476
|
+
* [js] out 1 -> [route maxurl] -> [maxurl] -> [prepend maxurl_done] -> [js] in 0
|
|
477
|
+
* -> [prepend maxurl_progress] -> [js] in 0
|
|
478
|
+
*
|
|
479
|
+
* The bytes never enter the Max message stream: [js] hands maxurl a request dict
|
|
480
|
+
* carrying `filename_out` and libcurl writes the file itself. What comes back here
|
|
481
|
+
* is only the RESULT - a response dict name, and progress. That is the whole point:
|
|
482
|
+
* [js] is a control plane, not a data plane. See the fetch section of core.ts for
|
|
483
|
+
* the request dict, which is where this feature was actually broken.
|
|
484
|
+
*
|
|
485
|
+
* The request leaves [js] on OUTLET 1 (the aux outlet) rather than outlet 0, which
|
|
486
|
+
* belongs to [jweb]: a `maxurl` message on outlet 0 would be delivered to the web
|
|
487
|
+
* view, which has no idea what to do with it. The [route maxurl] then strips the
|
|
488
|
+
* tag, so what reaches the object is the bare `dictionary <name>` it expects.
|
|
489
|
+
*
|
|
490
|
+
* maxurl has TWO outlets (per its reference page): 0 = the response dictionary,
|
|
491
|
+
* 1 = progress. Not three.
|
|
492
|
+
*/
|
|
493
|
+
function downloadChain(ctx) {
|
|
494
|
+
const { boxes, lines, unmatchedId } = ctx; // unmatchedId is the wrapper's [js]
|
|
495
|
+
|
|
496
|
+
boxes.push(box("obj-maxurl", "maxurl", { numinlets: 2, numoutlets: 2, outlettype: ["", ""] }));
|
|
497
|
+
|
|
498
|
+
boxes.push(box("obj-route-maxurl", "route maxurl", { numoutlets: 2, outlettype: ["", ""] }));
|
|
499
|
+
lines.push(line(unmatchedId, 1, "obj-route-maxurl", 0));
|
|
500
|
+
lines.push(line("obj-route-maxurl", 0, "obj-maxurl", 0));
|
|
501
|
+
|
|
502
|
+
// Tag both replies, so [js] dispatches them to a handler rather than to
|
|
503
|
+
// anything(), which would swallow them - they arrive as a bare `dictionary
|
|
504
|
+
// <name>` and a bare list otherwise.
|
|
505
|
+
boxes.push(box("obj-prepend-maxurl-done", "prepend maxurl_done"));
|
|
506
|
+
boxes.push(box("obj-prepend-maxurl-progress", "prepend maxurl_progress"));
|
|
507
|
+
|
|
508
|
+
lines.push(line("obj-maxurl", 0, "obj-prepend-maxurl-done", 0));
|
|
509
|
+
lines.push(line("obj-maxurl", 1, "obj-prepend-maxurl-progress", 0));
|
|
510
|
+
|
|
511
|
+
lines.push(line("obj-prepend-maxurl-done", 0, unmatchedId, 0));
|
|
512
|
+
lines.push(line("obj-prepend-maxurl-progress", 0, unmatchedId, 0));
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* "samples" - the first chain that ORIGINATES a sound: a named [buffer~] per slot,
|
|
517
|
+
* loaded from a file on disk, played back through [groove~] into the signal path.
|
|
518
|
+
*
|
|
519
|
+
* app -> buffer_load <slot> <path> -> [js] (resolves the path)
|
|
520
|
+
* -> buffer_replace -> [buffer~ <name>]
|
|
521
|
+
* buffer_play <slot> -> [groove~] set + play
|
|
522
|
+
* buffer_stop -> [groove~] stop
|
|
523
|
+
* app <- buffer_ready <slot> <sr> <ms> <chans> when the read actually completed
|
|
524
|
+
* buffer_error <slot> <msg> when there was no file to read
|
|
525
|
+
*
|
|
526
|
+
* THE BYTES NEVER CROSS THE BRIDGE. [buffer~] reads the file itself; what travels in
|
|
527
|
+
* Max messages is a path and, coming back, a description of what landed - the same
|
|
528
|
+
* rule the `download` chain follows, which is how you get the file there in the
|
|
529
|
+
* first place.
|
|
530
|
+
*
|
|
531
|
+
* WAV/AIFF/Next-Sun ONLY. [buffer~]'s `read`/`replace` does not take MP3 - that list
|
|
532
|
+
* (MP3, OGG, FLAC, M4A) is [sfplay~]'s, which streams from disk rather than filling a
|
|
533
|
+
* buffer, and is therefore a different chain. A format it cannot read is an error in
|
|
534
|
+
* the Max console and NO bang, so the app's promise times out rather than lying.
|
|
535
|
+
*
|
|
536
|
+
* WHAT LOADED IS NOT WHAT YOU ASKED FOR, so the chain reports what it GOT. `replace`
|
|
537
|
+
* resizes the buffer and adopts the FILE's channel count and sample rate, so a slot
|
|
538
|
+
* is not mono because you wanted it to be. [info~] is banged from the buffer's own
|
|
539
|
+
* "read completed" outlet (outlet 1 - outlet 0 is a mouse position in the editing
|
|
540
|
+
* window) and reports sample rate, duration and channels; the app derives frames from
|
|
541
|
+
* those. A frame count on its own is not proof of a read: a failed `replace` leaves
|
|
542
|
+
* the PREVIOUS contents in place, so "there are samples in there" says nothing. The
|
|
543
|
+
* bang does - it only fires when a read completed - and until it does the app's
|
|
544
|
+
* promise is still open (see loadSample() in @m4l-jweb/bridge, which is where the
|
|
545
|
+
* timeout lives).
|
|
546
|
+
*
|
|
547
|
+
* info~'s outlets fire right-to-left, so the LAST one to arrive is outlet 0, the
|
|
548
|
+
* sample rate - which is therefore the one wired to [pack]'s hot inlet. Wire it the
|
|
549
|
+
* other way round and the message goes out carrying the PREVIOUS load's numbers.
|
|
550
|
+
*
|
|
551
|
+
* ONE VOICE, DELIBERATELY. `groove~` takes `set <buffer-name>` to switch buffers, so
|
|
552
|
+
* one stereo player covers N slots: this is a PREVIEW - the sample browser's "let me
|
|
553
|
+
* hear it, through the track" - not a sampler. Polyphony is the `instrument` chain,
|
|
554
|
+
* and it is still open (doc/TODO.md item 2).
|
|
555
|
+
*
|
|
556
|
+
* It SUMS into the signal path rather than claiming it ([+~]), because it makes sound
|
|
557
|
+
* of its own: on an audio effect the preview plays over the track's audio, and on an
|
|
558
|
+
* instrument there is nothing at the input to add.
|
|
559
|
+
*
|
|
560
|
+
* The buffer NAMES are global to Max, and they are generated from the device name
|
|
561
|
+
* (`buf-<device>-<slot>`), so two INSTANCES of the same device on two tracks name
|
|
562
|
+
* their buffers alike - and Max hands the name to whichever loaded last. Not
|
|
563
|
+
* verified in Live yet; a preview device you drag onto one track is unaffected.
|
|
564
|
+
*
|
|
565
|
+
* Slots default to one, named "preview". `slots: ["kick", "snare"]` in the manifest
|
|
566
|
+
* gives you more.
|
|
567
|
+
*/
|
|
568
|
+
function samplesChain(ctx) {
|
|
569
|
+
const { boxes, lines, device, jwebId, unmatchedId } = ctx;
|
|
570
|
+
const slots = device?.slots ?? ["preview"];
|
|
571
|
+
const bufName = (slot) => `buf-${device?.name}-${slot}`;
|
|
572
|
+
|
|
573
|
+
// `buffer_load` is NOT claimed from [jweb]. It goes on to the wrapper, which
|
|
574
|
+
// resolves the path and hands it back on its AUX OUTLET as `buffer_replace <slot>
|
|
575
|
+
// <abs path>` - the same shape the `download` chain takes `maxurl` in.
|
|
576
|
+
//
|
|
577
|
+
// The detour is the whole fix. The app writes a path relative to the device's
|
|
578
|
+
// folder (that is where fetchToFile puts the file), and [buffer~] does not resolve
|
|
579
|
+
// it that way: a bare name is looked up in MAX'S SEARCH PATH, which the device's
|
|
580
|
+
// folder is not in, so a file that was downloaded correctly reports "can't open".
|
|
581
|
+
// The resolved path then contains SPACES on a normal Live install ("Ableton
|
|
582
|
+
// Library"), and a path travelling through the patcher as message text would split
|
|
583
|
+
// there into atoms. Out of [js] it stays one symbol.
|
|
584
|
+
boxes.push(box("obj-samples-route", "route buffer_play buffer_stop", { numoutlets: 3, outlettype: ["", "", ""] }));
|
|
585
|
+
claimAppMessages(ctx, "obj-samples-route", 2);
|
|
586
|
+
|
|
587
|
+
boxes.push(box("obj-samples-replaceroute", "route buffer_replace", { numoutlets: 2, outlettype: ["", ""] }));
|
|
588
|
+
lines.push(line(unmatchedId, 1, "obj-samples-replaceroute", 0));
|
|
589
|
+
|
|
590
|
+
// `route` strips the selector, so the slot name is now the first word of both
|
|
591
|
+
// remaining messages: a second route per stream dispatches on it.
|
|
592
|
+
const slotList = slots.join(" ");
|
|
593
|
+
const slotOutlets = { numoutlets: slots.length + 1, outlettype: slots.map(() => "").concat("") };
|
|
594
|
+
boxes.push(box("obj-samples-loadslot", `route ${slotList}`, slotOutlets));
|
|
595
|
+
boxes.push(box("obj-samples-playslot", `route ${slotList}`, slotOutlets));
|
|
596
|
+
lines.push(line("obj-samples-replaceroute", 0, "obj-samples-loadslot", 0));
|
|
597
|
+
lines.push(line("obj-samples-route", 0, "obj-samples-playslot", 0));
|
|
598
|
+
|
|
599
|
+
// The player. `groove~ <buffer> 2` = two signal outlets (plus a loop-sync outlet),
|
|
600
|
+
// and it MIXES a buffer with more channels down rather than dropping them.
|
|
601
|
+
// @loop 0 makes it a one-shot: a preview that loops forever is a preview you have
|
|
602
|
+
// to fight. [sig~ 1.] is the playback rate, in the left inlet, which is a SIGNAL
|
|
603
|
+
// inlet - a float there means something else entirely (a position, in ms).
|
|
604
|
+
boxes.push(box("obj-samples-rate", "sig~ 1.", { numinlets: 1, numoutlets: 1, outlettype: ["signal"] }));
|
|
605
|
+
boxes.push(
|
|
606
|
+
box("obj-samples-groove", `groove~ ${bufName(slots[0])} 2 @loop 0`, {
|
|
607
|
+
numinlets: 3,
|
|
608
|
+
numoutlets: 3,
|
|
609
|
+
outlettype: ["signal", "signal", "signal"],
|
|
610
|
+
}),
|
|
611
|
+
);
|
|
612
|
+
lines.push(line("obj-samples-rate", 0, "obj-samples-groove", 0));
|
|
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
|
+
|
|
630
|
+
// Stop: a bare `buffer_stop` arrives from [route] as a BANG - the word is gone -
|
|
631
|
+
// so re-materialize it in a message box, or groove~ hears nothing it knows.
|
|
632
|
+
boxes.push(box("obj-samples-stopmsg", "stop", { maxclass: "message", numinlets: 2, numoutlets: 1 }));
|
|
633
|
+
lines.push(line("obj-samples-route", 1, "obj-samples-stopmsg", 0));
|
|
634
|
+
lines.push(line("obj-samples-stopmsg", 0, "obj-samples-groove", 0));
|
|
635
|
+
|
|
636
|
+
slots.forEach((slot, i) => {
|
|
637
|
+
const buf = `obj-samples-buf-${slot}`;
|
|
638
|
+
const info = `obj-samples-info-${slot}`;
|
|
639
|
+
|
|
640
|
+
// buffer~: outlet 0 is a mouse position, outlet 1 is the bang on a completed
|
|
641
|
+
// read. Only the second one means anything here.
|
|
642
|
+
boxes.push(box(buf, `buffer~ ${bufName(slot)}`, { numinlets: 1, numoutlets: 2, outlettype: ["float", "bang"] }));
|
|
643
|
+
boxes.push(box(`obj-samples-replace-${slot}`, "prepend replace"));
|
|
644
|
+
lines.push(line("obj-samples-loadslot", i, `obj-samples-replace-${slot}`, 0));
|
|
645
|
+
lines.push(line(`obj-samples-replace-${slot}`, 0, buf, 0));
|
|
646
|
+
|
|
647
|
+
// What actually landed. info~'s outlets: 0 = sample rate, 6 = duration in ms,
|
|
648
|
+
// 8 = number of channels (per its reference page - do not count them from
|
|
649
|
+
// memory). They fire right-to-left, so 0 arrives last and is the hot one.
|
|
650
|
+
boxes.push(
|
|
651
|
+
box(info, `info~ ${bufName(slot)}`, {
|
|
652
|
+
numinlets: 1,
|
|
653
|
+
numoutlets: 10,
|
|
654
|
+
outlettype: ["float", "list", "float", "float", "float", "float", "float", "", "int", ""],
|
|
655
|
+
}),
|
|
656
|
+
);
|
|
657
|
+
boxes.push(box(`obj-samples-pack-${slot}`, "pack 0. 0. 0", { numinlets: 3, numoutlets: 1, outlettype: [""] }));
|
|
658
|
+
boxes.push(box(`obj-samples-ready-${slot}`, `prepend buffer_ready ${slot}`));
|
|
659
|
+
|
|
660
|
+
lines.push(line(buf, 1, info, 0)); // the read completed - ask what it was
|
|
661
|
+
lines.push(line(info, 8, `obj-samples-pack-${slot}`, 2)); // channels (fires first)
|
|
662
|
+
lines.push(line(info, 6, `obj-samples-pack-${slot}`, 1)); // duration, ms
|
|
663
|
+
lines.push(line(info, 0, `obj-samples-pack-${slot}`, 0)); // sample rate - hot, last
|
|
664
|
+
lines.push(line(`obj-samples-pack-${slot}`, 0, `obj-samples-ready-${slot}`, 0));
|
|
665
|
+
lines.push(line(`obj-samples-ready-${slot}`, 0, jwebId, 0));
|
|
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
|
+
|
|
675
|
+
// Play: pick the buffer, THEN start it. Two cords out of one outlet fire in an
|
|
676
|
+
// order Max chooses, so the sequence goes through a [t b b] - right outlet
|
|
677
|
+
// first - and never through a fan-out. Starting the OLD buffer and then
|
|
678
|
+
// switching is exactly the bug that looks like "the wrong sample previewed".
|
|
679
|
+
boxes.push(box(`obj-samples-trig-${slot}`, "t b b b", { numinlets: 1, numoutlets: 3, outlettype: ["bang", "bang", "bang"] }));
|
|
680
|
+
boxes.push(box(`obj-samples-set-${slot}`, `set ${bufName(slot)}`, { maxclass: "message", numinlets: 2, numoutlets: 1 }));
|
|
681
|
+
// A float in groove~'s left inlet is a playback POSITION in ms, and 0 is "from
|
|
682
|
+
// the beginning" - which is also what starts it after a `stop`.
|
|
683
|
+
boxes.push(box(`obj-samples-start-${slot}`, "0", { maxclass: "message", numinlets: 2, numoutlets: 1 }));
|
|
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.
|
|
687
|
+
lines.push(line("obj-samples-playslot", i, `obj-samples-trig-${slot}`, 0));
|
|
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
|
|
690
|
+
lines.push(line(`obj-samples-trig-${slot}`, 0, `obj-samples-start-${slot}`, 0)); // ...then play it
|
|
691
|
+
lines.push(line(`obj-samples-set-${slot}`, 0, "obj-samples-groove", 0));
|
|
692
|
+
lines.push(line(`obj-samples-start-${slot}`, 0, "obj-samples-groove", 0));
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
// Sum into the signal path: this chain MAKES sound, it does not process what came
|
|
696
|
+
// before. Claiming the stage instead would silence the track the preview plays over.
|
|
697
|
+
for (const [ch, id] of [
|
|
698
|
+
[0, "obj-samples-mix-l"],
|
|
699
|
+
[1, "obj-samples-mix-r"],
|
|
700
|
+
]) {
|
|
701
|
+
const [srcId, srcOut] = ctx.audioIn(ch);
|
|
702
|
+
boxes.push(box(id, "+~", { numinlets: 2, numoutlets: 1, outlettype: ["signal"] }));
|
|
703
|
+
lines.push(line(srcId, srcOut, id, 0));
|
|
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));
|
|
1070
|
+
ctx.setAudioOut(ch, id, 0);
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
|
|
465
1074
|
export const CHAINS = {
|
|
466
1075
|
midiin: midiInChain,
|
|
1076
|
+
samples: samplesChain,
|
|
1077
|
+
instrument: instrumentChain,
|
|
467
1078
|
midiout: midiOutChain,
|
|
468
1079
|
passthrough: passthroughChain,
|
|
469
1080
|
gain: gainChain,
|
|
470
1081
|
lowpass: lowpassChain,
|
|
471
1082
|
drive: driveChain,
|
|
1083
|
+
delay: delayChain,
|
|
1084
|
+
reverb: reverbChain,
|
|
1085
|
+
download: downloadChain,
|
|
472
1086
|
};
|
|
473
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
|
+
|
|
474
1120
|
/** Add a chain to the vocabulary. Called before generatePatchers(). */
|
|
475
1121
|
export function registerChain(name, fn) {
|
|
476
1122
|
CHAINS[name] = fn;
|
package/src/index.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* wrapper/device.ts - extra [js] message handlers, concatenated last
|
|
9
9
|
*/
|
|
10
10
|
import archiver from "archiver";
|
|
11
|
-
import { createReadStream, createWriteStream, existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
|
|
11
|
+
import { createReadStream, createWriteStream, existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, readdirSync } from "node:fs";
|
|
12
12
|
import { copyFile, rename, stat } from "node:fs/promises";
|
|
13
13
|
import { execFileSync } from "node:child_process";
|
|
14
14
|
import { createRequire } from "node:module";
|
|
@@ -17,7 +17,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
17
17
|
|
|
18
18
|
import { AMXD_TYPES, assertES5, buildAmxd, extraPayloadsJs, payloadJs } from "./amxd.mjs";
|
|
19
19
|
import { CHAINS, assertUniqueBoxIds, closeAudio, openAudio, resetLayout } from "./chains.mjs";
|
|
20
|
-
import { applySurface, loadSurface, surfaceContext } from "./surface.mjs";
|
|
20
|
+
import { applySurface, applyWindows, applyPersistence, loadSurface, surfaceContext } from "./surface.mjs";
|
|
21
21
|
|
|
22
22
|
const require = createRequire(import.meta.url);
|
|
23
23
|
const pkgDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
@@ -80,6 +80,14 @@ export function buildWrapper(root) {
|
|
|
80
80
|
// At [js] global scope `this` IS the jsthis object - that is how
|
|
81
81
|
// `this.patcher.filepath` works.
|
|
82
82
|
noImplicitThis: false,
|
|
83
|
+
// ...and that is ONLY true in sloppy mode. `strict: true` makes tsc emit
|
|
84
|
+
// "use strict" at the top of every file, under which `this` inside a plainly
|
|
85
|
+
// called function is UNDEFINED - so `this.patcher` would throw. Max's [js] is
|
|
86
|
+
// an ES5-era interpreter that does not enforce strict semantics, so the
|
|
87
|
+
// device works anyway; it works by accident, on a technicality of a very old
|
|
88
|
+
// engine, and any [js] that ever grew a real strict mode would take every
|
|
89
|
+
// device down with it. Emit what we actually target.
|
|
90
|
+
alwaysStrict: false,
|
|
83
91
|
noImplicitAny: false,
|
|
84
92
|
skipLibCheck: true,
|
|
85
93
|
types: [],
|
|
@@ -188,7 +196,13 @@ export function composePatcher(base, d, surface) {
|
|
|
188
196
|
* which updates it WITHOUT output, so the object would
|
|
189
197
|
* never pass that value on. See surface.mjs.
|
|
190
198
|
*/
|
|
191
|
-
|
|
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) };
|
|
192
206
|
|
|
193
207
|
openAudio(ctx);
|
|
194
208
|
|
|
@@ -199,9 +213,14 @@ export function composePatcher(base, d, surface) {
|
|
|
199
213
|
}
|
|
200
214
|
|
|
201
215
|
applySurface(ctx);
|
|
216
|
+
applyWindows(ctx);
|
|
217
|
+
applyPersistence(ctx);
|
|
202
218
|
closeAudio(ctx);
|
|
203
219
|
assertUniqueBoxIds(boxes, d.name);
|
|
204
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;
|
|
205
224
|
return p;
|
|
206
225
|
}
|
|
207
226
|
|
|
@@ -229,6 +248,15 @@ export async function generatePatchers(root) {
|
|
|
229
248
|
const surface = await loadSurface(root, d.ui ?? d.name);
|
|
230
249
|
const p = composePatcher(base, d, surface);
|
|
231
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
|
+
|
|
232
260
|
writeFileSync(path.join(outDir, `${d.name}.json`), JSON.stringify(p, null, "\t"));
|
|
233
261
|
const params = surface ? surface.ids.join(", ") : "none";
|
|
234
262
|
console.log(`m4l-jweb: ${d.name}.json (${d.type}, chains: ${(d.chains ?? []).join(", ") || "none"}, params: ${params || "none"})`);
|
|
@@ -292,20 +320,39 @@ export async function packageDevices(root) {
|
|
|
292
320
|
/**
|
|
293
321
|
* Payloads ride inside wrapper.js as base64 and are written to real files
|
|
294
322
|
* next to the .amxd on first load, because Chromium and any external
|
|
295
|
-
* process are blind to Max's frozen virtual filesystem.
|
|
296
|
-
*
|
|
323
|
+
* process are blind to Max's frozen virtual filesystem.
|
|
324
|
+
* We embed ALL .html files found in the UI directory (including the main UI and any windows).
|
|
297
325
|
*/
|
|
298
|
-
let wrapperData = banner + wrapperJs
|
|
326
|
+
let wrapperData = banner + wrapperJs;
|
|
327
|
+
const uiDirContent = readdirSync(path.join(dist, "ui", d.ui ?? d.name)).filter((f) => f.endsWith(".html"));
|
|
328
|
+
|
|
329
|
+
// Main UI payload
|
|
330
|
+
wrapperData += payloadJs("UI_PAYLOAD", uiName, readFileSync(path.join(dist, "ui", d.ui ?? d.name, "index.html")));
|
|
331
|
+
|
|
332
|
+
// Additional window payloads
|
|
333
|
+
const extraWindows = uiDirContent.filter((f) => f !== "index.html");
|
|
299
334
|
const payloads = (d.payloads ?? []).map((f) => ({ name: path.basename(f), data: readFileSync(path.join(root, f)) }));
|
|
335
|
+
|
|
336
|
+
for (const winHtml of extraWindows) {
|
|
337
|
+
payloads.push({ name: `${d.name}_${winHtml}`, data: readFileSync(path.join(dist, "ui", d.ui ?? d.name, winHtml)) });
|
|
338
|
+
}
|
|
339
|
+
|
|
300
340
|
if (payloads.length) wrapperData += extraPayloadsJs(payloads);
|
|
301
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
|
+
|
|
302
351
|
const amxd = buildAmxd({
|
|
303
352
|
patcherJson: readFileSync(path.join(dist, "patchers", `${d.name}.json`), "utf8"),
|
|
304
353
|
wrapperJs: wrapperData,
|
|
305
354
|
deviceName,
|
|
306
|
-
|
|
307
|
-
// voice patcher, say), which is exactly why they can stay frozen.
|
|
308
|
-
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],
|
|
309
356
|
});
|
|
310
357
|
writeFileSync(path.join(outDir, deviceName), amxd);
|
|
311
358
|
console.log(`m4l-jweb: ${deviceName} (${d.type}, ${amxd.length} bytes)`);
|
package/src/surface.mjs
CHANGED
|
@@ -294,3 +294,237 @@ export function applySurface(ctx) {
|
|
|
294
294
|
lines.push(line(`obj-set-${id}`, 0, paramObject(id), 0));
|
|
295
295
|
});
|
|
296
296
|
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Compile a declared `window` into the patcher: a subpatcher holding its own
|
|
300
|
+
* [jweb], and a [pcontrol] that opens it when the app asks.
|
|
301
|
+
*
|
|
302
|
+
* [jweb] -> [route window_x_open window_x_close] -> [t b] -> [open( -> [pcontrol] -> [p Title]
|
|
303
|
+
*
|
|
304
|
+
* ------------------------------------------------------------------------------
|
|
305
|
+
* A MAXCLASS IS NOT A NAME YOU INVENT, and that is what was broken here.
|
|
306
|
+
*
|
|
307
|
+
* This generated the open/close message boxes as `newobj` with the TEXT "open"
|
|
308
|
+
* and "wclose", and the [pcontrol] as a box with `maxclass: "pcontrol"`. Neither
|
|
309
|
+
* is a thing:
|
|
310
|
+
*
|
|
311
|
+
* - `newobj` means "an object box", and its text names the object. There is no
|
|
312
|
+
* Max object called `open`, so what got built was a BROKEN box - the dashed
|
|
313
|
+
* outline you would see instantly in the Max editor, and see nothing of here.
|
|
314
|
+
* A message box is `maxclass: "message"` with the message in `text`
|
|
315
|
+
* (chains.mjs's [flush( does this - copy that, do not reinvent it).
|
|
316
|
+
* - `pcontrol` is an object, not a box class: `maxclass: "newobj"`,
|
|
317
|
+
* `text: "pcontrol"`. A box with an unknown maxclass does not instantiate.
|
|
318
|
+
*
|
|
319
|
+
* A patcher full of broken boxes still LOADS, keeps its cords, and does nothing.
|
|
320
|
+
* That is why the message reached [jweb]'s outlet, the route matched, and the
|
|
321
|
+
* window never opened - and why the failure was misread as `[route]` refusing to
|
|
322
|
+
* match jweb's output. The route was fine. It was firing into a box that had
|
|
323
|
+
* failed to exist.
|
|
324
|
+
* ------------------------------------------------------------------------------
|
|
325
|
+
*/
|
|
326
|
+
export function applyWindows(ctx) {
|
|
327
|
+
const { boxes, lines, surface, unmatchedId } = ctx;
|
|
328
|
+
const windowIds = surface?.windows ? Object.keys(surface.windows) : [];
|
|
329
|
+
if (windowIds.length === 0) return;
|
|
330
|
+
|
|
331
|
+
const selectors = windowIds.flatMap((id) => [`window_${id}_open`, `window_${id}_close`]);
|
|
332
|
+
|
|
333
|
+
const routeId = "obj-windows-route";
|
|
334
|
+
boxes.push(
|
|
335
|
+
box(routeId, `route ${selectors.join(" ")}`, {
|
|
336
|
+
numoutlets: selectors.length + 1,
|
|
337
|
+
outlettype: selectors.map(() => "").concat(""),
|
|
338
|
+
}),
|
|
339
|
+
);
|
|
340
|
+
// IN SERIES, like every other claimant of the app's message stream. Hanging this
|
|
341
|
+
// route off [jweb] in parallel (which it did) leaves two paths to [js], so the
|
|
342
|
+
// wrapper sees every unrouted message - `ui_ready` included - twice.
|
|
343
|
+
claimAppMessages(ctx, routeId, selectors.length);
|
|
344
|
+
|
|
345
|
+
windowIds.forEach((id, index) => {
|
|
346
|
+
const spec = surface.windows[id];
|
|
347
|
+
|
|
348
|
+
// `route` STRIPS the selector, so `window_x_open 1` emerges as a bare `1`. A
|
|
349
|
+
// message box would try to interpret that as its own argument, so bang it
|
|
350
|
+
// instead: [t b] makes the trigger unambiguous whatever the app sent.
|
|
351
|
+
//
|
|
352
|
+
// The words are [pcontrol]'s: `open` and `close`. NOT `wclose` - that is
|
|
353
|
+
// [thispatcher]'s vocabulary, and pcontrol says so out loud
|
|
354
|
+
// ("pcontrol: doesn't understand \"wclose\""), which is the one thing an
|
|
355
|
+
// unrecognised NAME usually does not do. See pcontrol.maxref.xml.
|
|
356
|
+
for (const [outlet, verb, tag] of [
|
|
357
|
+
[index * 2, "open", "open"],
|
|
358
|
+
[index * 2 + 1, "close", "close"],
|
|
359
|
+
]) {
|
|
360
|
+
const triggerId = `obj-window-${id}-t-${tag}`;
|
|
361
|
+
const msgId = `obj-window-${id}-${tag}msg`;
|
|
362
|
+
boxes.push(box(triggerId, "t b"));
|
|
363
|
+
// A MESSAGE box: maxclass "message", the message in `text`. Not a newobj.
|
|
364
|
+
boxes.push(box(msgId, verb, { maxclass: "message", numinlets: 2, numoutlets: 1 }));
|
|
365
|
+
lines.push(line(routeId, outlet, triggerId, 0));
|
|
366
|
+
lines.push(line(triggerId, 0, msgId, 0));
|
|
367
|
+
lines.push(line(msgId, 0, `obj-window-${id}-pcontrol`, 0));
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// [pcontrol] is the supported way to open a subpatcher's window from outside
|
|
371
|
+
// it. `open` shows it, `wclose` hides it.
|
|
372
|
+
const pcontrolId = `obj-window-${id}-pcontrol`;
|
|
373
|
+
boxes.push(box(pcontrolId, "pcontrol"));
|
|
374
|
+
|
|
375
|
+
const subpatcherId = `obj-window-${id}-sub`;
|
|
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));
|
|
383
|
+
boxes.push({
|
|
384
|
+
box: {
|
|
385
|
+
id: subpatcherId,
|
|
386
|
+
maxclass: "newobj",
|
|
387
|
+
text: `p ${spec.title}`,
|
|
388
|
+
// The inlet is not decoration: Max will not connect a patch cord to a
|
|
389
|
+
// subpatcher that has no inlets, so [pcontrol] would end up wired to
|
|
390
|
+
// NOTHING - silently, in the saved file. That was attempt 1.
|
|
391
|
+
numinlets: 1,
|
|
392
|
+
numoutlets: 1,
|
|
393
|
+
outlettype: [""],
|
|
394
|
+
patching_rect: [16, 620, 120, 22],
|
|
395
|
+
patcher: {
|
|
396
|
+
fileversion: 1,
|
|
397
|
+
appversion: { major: 8, minor: 0, revision: 0, architecture: "x64", modernui: 1 },
|
|
398
|
+
rect: [100, 100, spec.width, spec.height],
|
|
399
|
+
openinpresentation: 1,
|
|
400
|
+
boxes: [
|
|
401
|
+
{ box: { id: "obj-in", maxclass: "inlet", patching_rect: [16, 16, 30, 30], numinlets: 0, numoutlets: 1, outlettype: [""] } },
|
|
402
|
+
// The page's URL cannot be WIRED here - this [jweb] is inside a
|
|
403
|
+
// subpatcher and the wrapper's [js] is outside it. The wrapper reaches
|
|
404
|
+
// it by NAME instead (messnamed), once the payload is extracted.
|
|
405
|
+
{
|
|
406
|
+
box: {
|
|
407
|
+
id: "obj-recv",
|
|
408
|
+
maxclass: "newobj",
|
|
409
|
+
text: `r window-read-${id}`,
|
|
410
|
+
numinlets: 0,
|
|
411
|
+
numoutlets: 1,
|
|
412
|
+
outlettype: [""],
|
|
413
|
+
patching_rect: [16, 56, 160, 22],
|
|
414
|
+
},
|
|
415
|
+
},
|
|
416
|
+
{
|
|
417
|
+
box: {
|
|
418
|
+
id: "obj-jweb",
|
|
419
|
+
maxclass: "jweb",
|
|
420
|
+
numinlets: 1,
|
|
421
|
+
numoutlets: 2,
|
|
422
|
+
outlettype: ["", ""],
|
|
423
|
+
patching_rect: [16, 96, spec.width, spec.height],
|
|
424
|
+
presentation: 1,
|
|
425
|
+
presentation_rect: [0, 0, spec.width, spec.height],
|
|
426
|
+
},
|
|
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] } },
|
|
451
|
+
],
|
|
452
|
+
},
|
|
453
|
+
},
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Compile a declared `state` slot: a named [dict] the app reads and writes, and a
|
|
460
|
+
* [pattr] bound to it, which is what actually SAVES.
|
|
461
|
+
*
|
|
462
|
+
* ------------------------------------------------------------------------------
|
|
463
|
+
* WHAT MAKES LIVE SAVE IT IS `parameter_enable`, and nothing else.
|
|
464
|
+
*
|
|
465
|
+
* A pattr on its own persists in a PATCHER. A Max for Live device is not saved as a
|
|
466
|
+
* patcher - Live saves the SET - so the pattr's value goes with it only when the
|
|
467
|
+
* pattr is a Live parameter. Max's own pattr help says so in one line:
|
|
468
|
+
*
|
|
469
|
+
* "In Max for Live, if you activate the parameter_enable attribute, the pattr
|
|
470
|
+
* value will be saved with the Live set."
|
|
471
|
+
*
|
|
472
|
+
* The first version of this emitted `@save 1`, which is not a pattr attribute at
|
|
473
|
+
* all ("pattr: 'save' is not a valid attribute argument"), and `@autorestore 1`,
|
|
474
|
+
* which restores from the PATCHER and so does nothing here. The state survived a
|
|
475
|
+
* reload of the page and would not have survived a reload of the set.
|
|
476
|
+
*
|
|
477
|
+
* The recipe below is copied from a device Ableton ships - `pattr Delays` in
|
|
478
|
+
* "Max DelayTaps.amxd", which persists an array of tap times exactly this way:
|
|
479
|
+
*
|
|
480
|
+
* parameter_type 3 BLOB. Not a float, not an enum - an opaque value Live
|
|
481
|
+
* stores and hands back, which is the whole point: JSON is
|
|
482
|
+
* not a number and must never pretend to be one.
|
|
483
|
+
* parameter_invisible 1 ...so it stays out of the automation lane and off Push.
|
|
484
|
+
* A blob cannot be automated, and a slot that offered to be
|
|
485
|
+
* would be a lie in every Live UI that listed it.
|
|
486
|
+
* parameter_enable 1 in saved_object_attributes - the switch itself.
|
|
487
|
+
*
|
|
488
|
+
* `dict` is an OBJECT (`maxclass: "newobj"`, text `dict <name>`), not a box class.
|
|
489
|
+
* It was emitted as `maxclass: "dict"` - a box class Max does not have, so the box
|
|
490
|
+
* never instantiated and the pattr bound to nothing. Same mistake as the windows,
|
|
491
|
+
* same silence.
|
|
492
|
+
* ------------------------------------------------------------------------------
|
|
493
|
+
*/
|
|
494
|
+
export function applyPersistence(ctx) {
|
|
495
|
+
const { boxes, surface } = ctx;
|
|
496
|
+
const stateIds = surface?.state ? Object.keys(surface.state) : [];
|
|
497
|
+
|
|
498
|
+
for (const id of stateIds) {
|
|
499
|
+
const dictId = `obj-state-${id}`;
|
|
500
|
+
// `varname` is the SCRIPTING name, which is what [pattr]'s @bindto resolves -
|
|
501
|
+
// it binds to a NAMED object in the patcher, not to a box id (which is ours, and
|
|
502
|
+
// which Max is free to renumber). The dict's own name argument is what [js]
|
|
503
|
+
// addresses with `new Dict("obj-state-<id>")`, so all three agree.
|
|
504
|
+
boxes.push(box(dictId, `dict ${dictId}`, { varname: dictId, numinlets: 2, numoutlets: 4, outlettype: ["dictionary", "", "", ""] }));
|
|
505
|
+
|
|
506
|
+
const pattrId = `obj-pattr-${id}`;
|
|
507
|
+
boxes.push(
|
|
508
|
+
box(pattrId, `pattr ${pattrId} @bindto ${dictId}`, {
|
|
509
|
+
numinlets: 2,
|
|
510
|
+
numoutlets: 3,
|
|
511
|
+
outlettype: ["", "", ""],
|
|
512
|
+
varname: pattrId,
|
|
513
|
+
saved_attribute_attributes: {
|
|
514
|
+
valueof: {
|
|
515
|
+
parameter_longname: pattrId,
|
|
516
|
+
// Live truncates a short name at 8 characters (see the parameter
|
|
517
|
+
// compiler above), and it is never displayed for an invisible blob -
|
|
518
|
+
// but it must still be there and still be unique.
|
|
519
|
+
parameter_shortname: `st_${id}`.slice(0, 8),
|
|
520
|
+
parameter_type: 3, // blob
|
|
521
|
+
parameter_invisible: 1,
|
|
522
|
+
},
|
|
523
|
+
},
|
|
524
|
+
saved_object_attributes: {
|
|
525
|
+
parameter_enable: 1, // THE switch: no parameter, no save.
|
|
526
|
+
},
|
|
527
|
+
}),
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
@@ -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": "^0.6.0",
|
|
21
|
+
"@m4l-jweb/surface": "^0.6.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": "^0.6.0",
|
|
27
27
|
"@types/node": "^22.0.0",
|
|
28
28
|
"@types/react": "^19.0.0",
|
|
29
29
|
"@types/react-dom": "^19.0.0",
|
|
@@ -1,19 +1,50 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* build-ui.mjs - bundle one self-contained UI per device.
|
|
2
|
+
* build-ui.mjs - bundle one self-contained UI per device, plus one per WINDOW.
|
|
3
3
|
*
|
|
4
|
-
* Emits dist/ui/<device>/index.html
|
|
5
|
-
*
|
|
4
|
+
* Emits dist/ui/<device>/index.html (the device view) and, for every window the
|
|
5
|
+
* device declares in its surface.ts, dist/ui/<device>/<winId>.html. `m4l-jweb
|
|
6
|
+
* build` embeds all of them into the matching .amxd as base64 payloads.
|
|
6
7
|
*
|
|
7
8
|
* Sequential, not parallel: vite reads DEVICE from the environment, and two
|
|
8
9
|
* concurrent builds would race on it. The builds are ~500 ms each.
|
|
10
|
+
*
|
|
11
|
+
* THE RENAME DANCE around the window builds is load-bearing. Vite always writes
|
|
12
|
+
* its entry as `index.html`, so a window build lands on top of the device view we
|
|
13
|
+
* just produced - and `emptyOutDir` cannot be left on for it either, or the window
|
|
14
|
+
* build wipes the folder instead. So the device view is moved aside, the windows
|
|
15
|
+
* are built and renamed one by one, and it is moved back.
|
|
9
16
|
*/
|
|
10
17
|
import { build } from "vite";
|
|
11
18
|
import { uiDirs } from "./devices.mjs";
|
|
19
|
+
// The PACKAGE export, not a path into packages/ - this file is copied verbatim
|
|
20
|
+
// into the `m4l-jweb init` scaffold (tests/starter.test.mjs pins that), and a
|
|
21
|
+
// scaffolded repo has no packages/ directory to reach into.
|
|
22
|
+
import { loadSurface } from "@m4l-jweb/build/surface";
|
|
23
|
+
import { renameSync } from "node:fs";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
|
|
26
|
+
const root = process.cwd();
|
|
12
27
|
|
|
13
28
|
for (const dir of uiDirs) {
|
|
14
29
|
process.env.DEVICE = dir;
|
|
30
|
+
delete process.env.WINDOW; // ...so this build empties the out dir and is the device view
|
|
15
31
|
console.log(`\nm4l-jweb: bundling UI for ${dir}`);
|
|
16
32
|
await build();
|
|
33
|
+
|
|
34
|
+
const outDir = path.join(root, "dist", "ui", dir);
|
|
35
|
+
const surface = await loadSurface(root, dir);
|
|
36
|
+
const windows = surface?.windows ? Object.keys(surface.windows) : [];
|
|
37
|
+
if (!windows.length) continue;
|
|
38
|
+
|
|
39
|
+
renameSync(path.join(outDir, "index.html"), path.join(outDir, "_device.html"));
|
|
40
|
+
for (const winId of windows) {
|
|
41
|
+
process.env.WINDOW = winId;
|
|
42
|
+
process.env.WINDOW_ENTRY = surface.windows[winId].entry;
|
|
43
|
+
console.log(`\nm4l-jweb: bundling window ${winId} for ${dir}`);
|
|
44
|
+
await build();
|
|
45
|
+
renameSync(path.join(outDir, "index.html"), path.join(outDir, `${winId}.html`));
|
|
46
|
+
}
|
|
47
|
+
renameSync(path.join(outDir, "_device.html"), path.join(outDir, "index.html"));
|
|
17
48
|
}
|
|
18
49
|
|
|
19
50
|
console.log(`\nm4l-jweb: ${uiDirs.length} UI bundle(s) -> dist/ui/`);
|
|
@@ -33,11 +33,14 @@ export default defineConfig(() => {
|
|
|
33
33
|
// The device UI is bundled into ONE self-contained index.html (every script,
|
|
34
34
|
// style and asset inlined) so it can be embedded in the .amxd as a base64
|
|
35
35
|
// payload and extracted to a real file:// path that jweb (Chromium) reads.
|
|
36
|
+
const WINDOW_ENTRY = process.env.WINDOW_ENTRY;
|
|
37
|
+
|
|
36
38
|
const config: UserConfig = {
|
|
37
39
|
base: "./",
|
|
38
40
|
plugins: [react(), viteSingleFile()],
|
|
39
41
|
resolve: {
|
|
40
42
|
alias: [
|
|
43
|
+
{ find: "@device/App", replacement: fileURLToPath(new URL(`./src/app/${DEVICE}/${WINDOW_ENTRY ? WINDOW_ENTRY : "App"}`, import.meta.url)) },
|
|
41
44
|
{ find: "@device", replacement: fileURLToPath(new URL(`./src/app/${DEVICE}`, import.meta.url)) },
|
|
42
45
|
{ find: "@", replacement: fileURLToPath(new URL("./src", import.meta.url)) },
|
|
43
46
|
],
|
|
@@ -60,7 +63,7 @@ export default defineConfig(() => {
|
|
|
60
63
|
build: {
|
|
61
64
|
// dist/ui/<device>/index.html - one per device, picked up by `m4l-jweb build`.
|
|
62
65
|
outDir: `dist/ui/${DEVICE}`,
|
|
63
|
-
emptyOutDir:
|
|
66
|
+
emptyOutDir: !process.env.WINDOW,
|
|
64
67
|
},
|
|
65
68
|
};
|
|
66
69
|
return config;
|