@m4l-jweb/bridge 0.9.5 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -0
- package/package.json +1 -1
- package/src/index.ts +46 -227
package/README.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# @m4l-jweb/bridge
|
|
2
|
+
|
|
3
|
+
The browser half of a device. Your React UI runs inside `[jweb~]`, Max's embedded Chromium; this is how it talks to the Max patcher around it - messages out, messages in, MIDI, clip I/O, and files.
|
|
4
|
+
|
|
5
|
+
Part of **[m4l-jweb](https://github.com/alienmind/m4l-jweb)** - build Ableton Live devices (`.amxd`) from a TypeScript repo: React UI, LiveAPI glue, CI builds, no Max editor.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pnpm add @m4l-jweb/bridge
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { bindInlet, outlet, onNote, onNoteOff, sendNote, uiReady } from "@m4l-jweb/bridge";
|
|
17
|
+
|
|
18
|
+
// Tell the wrapper the page is up, and ask for current state.
|
|
19
|
+
uiReady();
|
|
20
|
+
|
|
21
|
+
// Incoming MIDI (needs the `midiin` chain). Both binders share one subscription.
|
|
22
|
+
onNote((pitch, velocity) => voiceOn(pitch, velocity));
|
|
23
|
+
onNoteOff((pitch) => voiceOff(pitch));
|
|
24
|
+
|
|
25
|
+
// Outgoing MIDI (needs the `midiout` chain). Max applies the delay, not the browser,
|
|
26
|
+
// so note timing does not depend on a Chromium timer.
|
|
27
|
+
sendNote({ pitch: 60, velocity: 100, durationMs: 250, delayMs: 80 });
|
|
28
|
+
|
|
29
|
+
// Anything else you route yourself.
|
|
30
|
+
bindInlet("tick", (playing, beats) => setTransport(Boolean(playing), Number(beats)));
|
|
31
|
+
outlet("my_selector", 1, "two");
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Notes
|
|
35
|
+
|
|
36
|
+
- **Audio does not go through here.** Under `[jweb~]` the page's Web Audio output is carried on the object's signal outlets, straight into the track. The bridge is a control plane; sound never crosses it as messages.
|
|
37
|
+
- `fetchToFile()` and `saveToFile()` move bytes between the page and disk via Max's `[maxurl]`, so large files never travel through the message bridge either.
|
|
38
|
+
- `tapMessages()` observes every message in both directions - the whole contract of a device, live.
|
|
39
|
+
|
|
40
|
+
## Requirements
|
|
41
|
+
|
|
42
|
+
Ableton Live 12 with Max 9. Devices are built on `[jweb~]`, the browser view with signal outlets; older hosts are unverified.
|
|
43
|
+
|
|
44
|
+
## Links
|
|
45
|
+
|
|
46
|
+
- [Repository and full README](https://github.com/alienmind/m4l-jweb)
|
|
47
|
+
- [Architecture](https://github.com/alienmind/m4l-jweb/blob/main/doc/ARCHITECTURE.md)
|
|
48
|
+
- [What Max actually does: the measured facts](https://github.com/alienmind/m4l-jweb/blob/main/doc/MAX-FACTS.md)
|
|
49
|
+
|
|
50
|
+
## License
|
|
51
|
+
|
|
52
|
+
MIT
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -168,16 +168,10 @@ export const CHAIN_IN = {
|
|
|
168
168
|
fetch_error: "fetch_error",
|
|
169
169
|
/** download -> UI: `fetch_progress <requestId> <downloaded> <total>`. */
|
|
170
170
|
fetch_progress: "fetch_progress",
|
|
171
|
-
/** samples -> UI: `buffer_ready <slot> <sampleRate> <ms> <channels>` - what actually loaded. */
|
|
172
|
-
buffer_ready: "buffer_ready",
|
|
173
|
-
/** samples -> UI: `buffer_error <slot> <msg>` - there was no readable file at that path. */
|
|
174
|
-
buffer_error: "buffer_error",
|
|
175
171
|
/** download -> UI: `save_done <requestId> <bytes>` - the file is written and verified. */
|
|
176
172
|
save_done: "save_done",
|
|
177
173
|
/** download -> UI: `save_error <requestId> <msg>` - the write or atomic place failed. */
|
|
178
174
|
save_error: "save_error",
|
|
179
|
-
/** renderplay -> UI: `render_ready <slot>` - the WAV finished loading into that slot's buffer~. */
|
|
180
|
-
render_ready: "render_ready",
|
|
181
175
|
} as const;
|
|
182
176
|
|
|
183
177
|
/** Selectors the packaged chains RECEIVE from the UI. Requires the `midiout` chain. */
|
|
@@ -188,14 +182,6 @@ export const CHAIN_OUT = {
|
|
|
188
182
|
flush: "flush",
|
|
189
183
|
/** UI -> download: `fetch_to_file <requestId> <url> <destPath>`. */
|
|
190
184
|
fetch_to_file: "fetch_to_file",
|
|
191
|
-
/** UI -> samples: `buffer_load <slot> <path>` - read a file into that slot's [buffer~]. */
|
|
192
|
-
buffer_load: "buffer_load",
|
|
193
|
-
/** UI -> samples: `buffer_play <slot>` - preview it through the track. */
|
|
194
|
-
buffer_play: "buffer_play",
|
|
195
|
-
/** UI -> samples: `buffer_stop` - stop the preview. */
|
|
196
|
-
buffer_stop: "buffer_stop",
|
|
197
|
-
/** UI -> instrument: `voice_play <pitch> <vel> <durMs> <channels>` - play one poly~ voice. */
|
|
198
|
-
voice_play: "voice_play",
|
|
199
185
|
/** UI -> remote: `remote_bind <slot> <lomId>` - point a live.remote~ at a Live parameter. */
|
|
200
186
|
remote_bind: "remote_bind",
|
|
201
187
|
/** UI -> remote: `remote_val <slot> <value>` - the next value for that slot, ramped. */
|
|
@@ -206,14 +192,6 @@ export const CHAIN_OUT = {
|
|
|
206
192
|
save_chunk: "save_chunk",
|
|
207
193
|
/** UI -> download: `save_end <requestId>` - close, verify size, atomic place. */
|
|
208
194
|
save_end: "save_end",
|
|
209
|
-
/** UI -> renderplay: `render_load <slot> <path> <lengthBeats>` - read a WAV into a slot's buffer~. */
|
|
210
|
-
render_load: "render_load",
|
|
211
|
-
/** UI -> renderplay: `render_arm <slot>` - swap playback to that slot at the next loop boundary. */
|
|
212
|
-
render_arm: "render_arm",
|
|
213
|
-
/** UI -> renderplay: `render_sync <slot> <positionMs>` - relocate a slot's loop to a transport phase. */
|
|
214
|
-
render_sync: "render_sync",
|
|
215
|
-
/** UI -> renderplay: `render_stop` - fade out and stop. */
|
|
216
|
-
render_stop: "render_stop",
|
|
217
195
|
} as const;
|
|
218
196
|
|
|
219
197
|
/**
|
|
@@ -301,15 +279,56 @@ export function sendNote(note: Note): void {
|
|
|
301
279
|
outlet(CHAIN_OUT.midinote, note.pitch, note.velocity, note.durationMs, note.channel ?? 1, note.delayMs ?? 0);
|
|
302
280
|
}
|
|
303
281
|
|
|
304
|
-
|
|
305
|
-
|
|
282
|
+
/* ------------------------------------------------------------------ *
|
|
283
|
+
* Incoming MIDI notes
|
|
284
|
+
*
|
|
285
|
+
* `bindInlet` keeps ONE handler per selector - a second bind REPLACES the first. So
|
|
286
|
+
* `onNote` and `onNoteOff` must not each call it: whichever ran last would win and the
|
|
287
|
+
* other's events would vanish. (They did, and a synth that bound both went deaf to
|
|
288
|
+
* every note-on.) Instead ONE `notein` binding fans out to two subscriber sets, which
|
|
289
|
+
* also means two `onNote` callers no longer silently clobber each other.
|
|
290
|
+
* ------------------------------------------------------------------ */
|
|
291
|
+
|
|
292
|
+
const noteOnHandlers = new Set<(pitch: number, velocity: number) => void>();
|
|
293
|
+
const noteOffHandlers = new Set<(pitch: number) => void>();
|
|
294
|
+
let noteinBound = false;
|
|
295
|
+
|
|
296
|
+
function bindNotein(): void {
|
|
297
|
+
if (noteinBound) return;
|
|
298
|
+
noteinBound = true;
|
|
306
299
|
bindInlet(CHAIN_IN.notein, (pitch, velocity) => {
|
|
307
|
-
const
|
|
308
|
-
|
|
309
|
-
|
|
300
|
+
const p = Number(pitch);
|
|
301
|
+
// Max's `midiparse` reports a release as velocity ZERO, not as a distinct message.
|
|
302
|
+
if (Number(velocity) === 0) for (const fn of noteOffHandlers) fn(p);
|
|
303
|
+
else for (const fn of noteOnHandlers) fn(p, Number(velocity));
|
|
310
304
|
});
|
|
311
305
|
}
|
|
312
306
|
|
|
307
|
+
/**
|
|
308
|
+
* Bind incoming MIDI note-ONS. Note-offs are filtered out, which is what a one-shot
|
|
309
|
+
* wants: a struck sample (a piano, a drum) decays on its own and a release message
|
|
310
|
+
* would only cut it short. A SUSTAINING voice needs the other half - see `onNoteOff`.
|
|
311
|
+
*
|
|
312
|
+
* Requires the `midiin` chain, and therefore a device of type "instrument" (an audio
|
|
313
|
+
* effect has no MIDI ports).
|
|
314
|
+
*/
|
|
315
|
+
export function onNote(fn: (pitch: number, velocity: number) => void): void {
|
|
316
|
+
noteOnHandlers.add(fn);
|
|
317
|
+
bindNotein();
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Bind incoming MIDI note-OFFS - the releases `onNote` drops.
|
|
322
|
+
*
|
|
323
|
+
* Bind this when the voice you start SUSTAINS: an oscillator held open by a note-on
|
|
324
|
+
* rings forever unless something tells it the key came up. Safe to use alongside
|
|
325
|
+
* `onNote` - they share one binding and split the stream by velocity.
|
|
326
|
+
*/
|
|
327
|
+
export function onNoteOff(fn: (pitch: number) => void): void {
|
|
328
|
+
noteOffHandlers.add(fn);
|
|
329
|
+
bindNotein();
|
|
330
|
+
}
|
|
331
|
+
|
|
313
332
|
/**
|
|
314
333
|
* Release every note the chain is currently holding.
|
|
315
334
|
*
|
|
@@ -434,207 +453,7 @@ export function saveToFile(destPath: string, bytes: ArrayBuffer): Promise<{ byte
|
|
|
434
453
|
});
|
|
435
454
|
}
|
|
436
455
|
|
|
437
|
-
/* ------------------------------------------------------------------ *
|
|
438
|
-
* Renderplay - the `renderplay` chain (transport-locked WAV loop playback)
|
|
439
|
-
* ------------------------------------------------------------------ */
|
|
440
|
-
|
|
441
|
-
let renderReadyBound = false;
|
|
442
|
-
const renderReadyHandlers = new Set<(slot: string) => void>();
|
|
443
|
-
|
|
444
|
-
/**
|
|
445
|
-
* Load a rendered WAV from disk into one of the renderplay slots' [buffer~].
|
|
446
|
-
*
|
|
447
|
-
* The path is resolved wrapper-side (relative -> device folder, same as saveToFile
|
|
448
|
-
* wrote it) and handed to `replace` as one symbol, so spaces in the Live library path
|
|
449
|
-
* survive. When the buffer finishes reading, the chain replies `render_ready <slot>`;
|
|
450
|
-
* bind it with `onRenderReady`. `lengthBeats` is how many Live beats one loop of this
|
|
451
|
-
* WAV spans - the chain uses it to lock the loop to the transport.
|
|
452
|
-
*
|
|
453
|
-
* Requires the `renderplay` chain (and `download`, for the [maxurl] the wrapper uses to
|
|
454
|
-
* resolve the path). Pair the slot names with the device manifest's `renderSlots`.
|
|
455
|
-
*/
|
|
456
|
-
export function renderLoad(slot: string, path: string, lengthBeats: number): void {
|
|
457
|
-
outlet(CHAIN_OUT.render_load, slot, path, lengthBeats);
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
/** At the NEXT loop boundary, crossfade playback to `slot` and adopt its loop length. */
|
|
461
|
-
export function renderArm(slot: string): void {
|
|
462
|
-
outlet(CHAIN_OUT.render_arm, slot);
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
/**
|
|
466
|
-
* Relocate a slot's loop to a transport phase: `positionMs` is a playback position in
|
|
467
|
-
* milliseconds, dropped straight into that slot's [groove~] left inlet.
|
|
468
|
-
*
|
|
469
|
-
* The conductor sends this on (re)start / relocate to pin the audio to Live's exact
|
|
470
|
-
* transport phase - `(beats mod loopBeats) / loopBeats * loopSeconds * 1000`. Because the
|
|
471
|
-
* WAV is rendered to be exactly `loopBeats` long at the current tempo, a rate-1 `@loop`
|
|
472
|
-
* then HOLDS the lock with no per-loop re-sync (Live's transport and the audio share one
|
|
473
|
-
* clock). Sync both slots to keep the double buffer mutually phase-aligned.
|
|
474
|
-
*/
|
|
475
|
-
export function renderSync(slot: string, positionMs: number): void {
|
|
476
|
-
outlet(CHAIN_OUT.render_sync, slot, positionMs);
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
/** Fade out and stop renderplay. */
|
|
480
|
-
export function renderStop(): void {
|
|
481
|
-
outlet(CHAIN_OUT.render_stop);
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
/** Called with the slot name each time a `render_load` finishes reading into its buffer~. */
|
|
485
|
-
export function onRenderReady(fn: (slot: string) => void): void {
|
|
486
|
-
renderReadyHandlers.add(fn);
|
|
487
|
-
if (!renderReadyBound) {
|
|
488
|
-
renderReadyBound = true;
|
|
489
|
-
bindInlet(CHAIN_IN.render_ready, (slot) => {
|
|
490
|
-
for (const h of renderReadyHandlers) h(String(slot));
|
|
491
|
-
});
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
/* ------------------------------------------------------------------ *
|
|
496
|
-
* Samples - the `samples` chain
|
|
497
|
-
* ------------------------------------------------------------------ */
|
|
498
456
|
|
|
499
|
-
/** What a slot actually holds, once the read completed. Reported by [info~], not assumed. */
|
|
500
|
-
export interface LoadedSample {
|
|
501
|
-
/** The FILE's sample rate, which need not be Live's. */
|
|
502
|
-
sampleRate: number;
|
|
503
|
-
durationMs: number;
|
|
504
|
-
/** The file's channel count. `replace` adopts it - a slot is not mono by wishing. */
|
|
505
|
-
channels: number;
|
|
506
|
-
/** Derived from the two above. Nobody counted them. */
|
|
507
|
-
frames: number;
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
const sampleResolvers = new Map<string, { resolve: (s: LoadedSample) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> }>();
|
|
511
|
-
let samplesBound = false;
|
|
512
|
-
|
|
513
|
-
/**
|
|
514
|
-
* Read a file from disk into a slot's [buffer~], and resolve with WHAT LANDED.
|
|
515
|
-
*
|
|
516
|
-
* Requires the `samples` chain, and the file must already be on disk - that is what
|
|
517
|
-
* `fetchToFile()` is for. The bytes never cross the bridge in either direction: Max
|
|
518
|
-
* reads the file, and what comes back is a description of it.
|
|
519
|
-
*
|
|
520
|
-
* WAV, AIFF OR NEXT/SUN - NOT MP3. That is [buffer~]'s list, from its reference page,
|
|
521
|
-
* and it is shorter than Max's: MP3, OGG, FLAC and M4A belong to [sfplay~], which
|
|
522
|
-
* streams from disk rather than filling a buffer. Handing this an MP3 gets you an
|
|
523
|
-
* error in the Max console, no reply, and the timeout below - the file downloads
|
|
524
|
-
* perfectly and simply never becomes audio.
|
|
525
|
-
*
|
|
526
|
-
* The resolved value is measured, not assumed. `replace` adopts the file's channel
|
|
527
|
-
* count and sample rate, so a stereo file in a slot you think of as mono is a stereo
|
|
528
|
-
* slot - and a frame count is not proof of a read, because a FAILED read leaves the
|
|
529
|
-
* previous contents of the buffer exactly where they were. The chain only replies
|
|
530
|
-
* when [buffer~] says the read completed; a file Max cannot read produces an error in
|
|
531
|
-
* the Max console and NO reply at all, which is what the timeout below is for. There
|
|
532
|
-
* is no bang for failure to bind to.
|
|
533
|
-
*/
|
|
534
|
-
export function loadSample(slot: string, path: string, timeoutMs = 10_000): Promise<LoadedSample> {
|
|
535
|
-
if (!samplesBound) {
|
|
536
|
-
samplesBound = true;
|
|
537
|
-
bindInlet(CHAIN_IN.buffer_ready, (id, sampleRate, ms, channels) => {
|
|
538
|
-
const p = sampleResolvers.get(String(id));
|
|
539
|
-
if (!p) return;
|
|
540
|
-
sampleResolvers.delete(String(id));
|
|
541
|
-
clearTimeout(p.timer);
|
|
542
|
-
const sr = Number(sampleRate);
|
|
543
|
-
const durationMs = Number(ms);
|
|
544
|
-
const chans = Number(channels);
|
|
545
|
-
// An empty buffer is a read that "worked" and gave us nothing. Do not hand the
|
|
546
|
-
// app a slot it will play in silence and blame itself for.
|
|
547
|
-
if (!(sr > 0) || !(durationMs > 0) || !(chans > 0)) {
|
|
548
|
-
p.reject(new Error(`slot "${id}" loaded empty: ${durationMs} ms, ${chans} channels at ${sr} Hz`));
|
|
549
|
-
return;
|
|
550
|
-
}
|
|
551
|
-
p.resolve({ sampleRate: sr, durationMs, channels: chans, frames: Math.round((durationMs / 1000) * sr) });
|
|
552
|
-
});
|
|
553
|
-
// The failure the wrapper CAN see - no file, or an empty one - arrives at once,
|
|
554
|
-
// rather than as ten seconds of silence. The failure it cannot see (a file Max
|
|
555
|
-
// will not decode) still has no event: [buffer~] says nothing, and the timeout is
|
|
556
|
-
// the only thing that ever will.
|
|
557
|
-
bindInlet(CHAIN_IN.buffer_error, (id, msg) => {
|
|
558
|
-
const p = sampleResolvers.get(String(id));
|
|
559
|
-
if (!p) return;
|
|
560
|
-
sampleResolvers.delete(String(id));
|
|
561
|
-
clearTimeout(p.timer);
|
|
562
|
-
p.reject(new Error(String(msg)));
|
|
563
|
-
});
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
return new Promise((resolve, reject) => {
|
|
567
|
-
const timer = setTimeout(() => {
|
|
568
|
-
sampleResolvers.delete(slot);
|
|
569
|
-
reject(new Error(`slot "${slot}": [buffer~] never reported a completed read of "${path}" (${timeoutMs} ms). See the Max console.`));
|
|
570
|
-
}, timeoutMs);
|
|
571
|
-
sampleResolvers.set(slot, { resolve, reject, timer });
|
|
572
|
-
outlet(CHAIN_OUT.buffer_load, slot, path);
|
|
573
|
-
});
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
/**
|
|
577
|
-
* Play a loaded slot, once, from the beginning - THROUGH THE TRACK.
|
|
578
|
-
*
|
|
579
|
-
* That last part is the whole reason this exists. Audio a page plays for itself goes
|
|
580
|
-
* to the OS output device: [jweb] has no signal outlets, so it bypasses the track,
|
|
581
|
-
* the fader and the monitor cue. A preview Live can hear has to be [buffer~] in the
|
|
582
|
-
* patcher, which is this.
|
|
583
|
-
*/
|
|
584
|
-
export function playSample(slot: string): void {
|
|
585
|
-
outlet(CHAIN_OUT.buffer_play, slot);
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
/** Stop the preview. One voice, so this stops whichever slot is sounding. */
|
|
589
|
-
export function stopSample(): void {
|
|
590
|
-
outlet(CHAIN_OUT.buffer_stop);
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
/* ------------------------------------------------------------------ *
|
|
594
|
-
* Instrument - the `instrument` chain ([poly~] voices)
|
|
595
|
-
* ------------------------------------------------------------------ */
|
|
596
|
-
|
|
597
|
-
/** One played note, handed to the `instrument` chain's [poly~]. The app times it; Max allocates a voice. */
|
|
598
|
-
export interface Voice {
|
|
599
|
-
/**
|
|
600
|
-
* Which slot's buffer to play, as a 0-based INDEX into the device's `slots` (the
|
|
601
|
-
* order declared in the manifest). The instrument is a keymap of named buffers, so a
|
|
602
|
-
* note names its sample; the app owns the slot order and passes the index.
|
|
603
|
-
*/
|
|
604
|
-
slot: number;
|
|
605
|
-
/**
|
|
606
|
-
* Playback RATE. 1 plays the buffer at its recorded pitch; 2 is an octave up, 0.5 an
|
|
607
|
-
* octave down. EXPLICIT, so the app decides whether a note plays a dedicated sample
|
|
608
|
-
* (rate 1) or a repitched one - the chain does no pitch arithmetic.
|
|
609
|
-
*/
|
|
610
|
-
rate: number;
|
|
611
|
-
/** 1-127. Scaled to amplitude in the voice. */
|
|
612
|
-
velocity: number;
|
|
613
|
-
/** How long to HOLD the voice, in ms - after which [poly~] frees it for re-use. */
|
|
614
|
-
durationMs: number;
|
|
615
|
-
/**
|
|
616
|
-
* That slot's channel count, as reported by `loadSample`'s `buffer_ready`. A mono
|
|
617
|
-
* buffer (1) is folded to both ears in the voice; pass what you measured, not what
|
|
618
|
-
* you hoped for. Defaults to 2 (no fold).
|
|
619
|
-
*/
|
|
620
|
-
channels?: number;
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
/**
|
|
624
|
-
* Play one note through the `instrument` chain's [poly~].
|
|
625
|
-
*
|
|
626
|
-
* Requires the note's slot already loaded (via `loadSample`, the same call the sampler
|
|
627
|
-
* uses). Polyphony and voice-stealing are Max's job: send as many overlapping notes as
|
|
628
|
-
* you like - across any slots - and [poly~] hands each to a free voice, or steals the
|
|
629
|
-
* oldest. A chord is several `playVoice()` calls in the same tick.
|
|
630
|
-
*
|
|
631
|
-
* The note is a control-plane message, not audio: it says which sample, at what rate,
|
|
632
|
-
* how loud and how long to hold the voice, and Max makes the sound sample-accurately.
|
|
633
|
-
* Pass the `channels` you learned from `loadSample` so a mono sample folds to both ears.
|
|
634
|
-
*/
|
|
635
|
-
export function playVoice(v: Voice): void {
|
|
636
|
-
outlet(CHAIN_OUT.voice_play, v.slot, v.rate, v.velocity, v.durationMs, v.channels ?? 2);
|
|
637
|
-
}
|
|
638
457
|
|
|
639
458
|
/* ------------------------------------------------------------------ *
|
|
640
459
|
* Remote - the `remote` chain (live.remote~ modulation)
|