@m4l-jweb/bridge 0.7.0 → 0.9.1
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 +1 -1
- package/src/index.ts +226 -0
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -190,6 +190,10 @@ export const CHAIN_OUT = {
|
|
|
190
190
|
buffer_stop: "buffer_stop",
|
|
191
191
|
/** UI -> instrument: `voice_play <pitch> <vel> <durMs> <channels>` - play one poly~ voice. */
|
|
192
192
|
voice_play: "voice_play",
|
|
193
|
+
/** UI -> remote: `remote_bind <slot> <lomId>` - point a live.remote~ at a Live parameter. */
|
|
194
|
+
remote_bind: "remote_bind",
|
|
195
|
+
/** UI -> remote: `remote_val <slot> <value>` - the next value for that slot, ramped. */
|
|
196
|
+
remote_val: "remote_val",
|
|
193
197
|
} as const;
|
|
194
198
|
|
|
195
199
|
/**
|
|
@@ -212,6 +216,41 @@ export const STATE_OUT = {
|
|
|
212
216
|
sync_state: "sync_state",
|
|
213
217
|
} as const;
|
|
214
218
|
|
|
219
|
+
/** Selectors the WRAPPER answers about this device's own Live parameters. */
|
|
220
|
+
export const PARAM_OUT = {
|
|
221
|
+
/** UI -> wrapper: `get_param_id <id>` - what is this parameter's LOM id? Reply on `param_id`. */
|
|
222
|
+
get_param_id: "get_param_id",
|
|
223
|
+
} as const;
|
|
224
|
+
|
|
225
|
+
/** ...and the reply. */
|
|
226
|
+
export const PARAM_IN = {
|
|
227
|
+
/** wrapper -> UI: `param_id <id> <lomId>` - 0 means no parameter of that name resolved. */
|
|
228
|
+
param_id: "param_id",
|
|
229
|
+
} as const;
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Selectors the WRAPPER handles for reading and writing the CLIP on this device's
|
|
233
|
+
* track (`read_notes`/`write_clip` in liveapi.ts). Wrapper-owned, like DEVICE_IN -
|
|
234
|
+
* no chain is involved, so a device that reads clips needs `unmatchedTo: "js"` so the
|
|
235
|
+
* bare selector reaches `[js]`. `readClip()` / `writeClip()` below are the shaped API.
|
|
236
|
+
*/
|
|
237
|
+
export const CLIP_OUT = {
|
|
238
|
+
/** UI -> wrapper: read this TRACK's playing (else first) clip, ignoring the selection. Reply on `notes`. */
|
|
239
|
+
read_notes: "read_notes",
|
|
240
|
+
/** UI -> wrapper: read the clip the CURSOR is on (Live's highlighted slot). Reply on `notes`. */
|
|
241
|
+
read_selected_clip: "read_selected_clip",
|
|
242
|
+
/** UI -> wrapper: `write_clip <lengthBeats> <n> <pitch start duration velocity> ...` - fill the first empty slot. */
|
|
243
|
+
write_clip: "write_clip",
|
|
244
|
+
} as const;
|
|
245
|
+
|
|
246
|
+
/** ...and the reply from a read. */
|
|
247
|
+
export const CLIP_IN = {
|
|
248
|
+
/** wrapper -> UI: `notes <loopEndBeats> <n> <pitch start duration> ...`. Velocity is not read back. */
|
|
249
|
+
notes: "notes",
|
|
250
|
+
/** wrapper -> UI: there was no clip on this track to read. */
|
|
251
|
+
read_error: "read_error",
|
|
252
|
+
} as const;
|
|
253
|
+
|
|
215
254
|
/** A note handed to the `midiout` chain. Max does the placing; you do the timing. */
|
|
216
255
|
export interface Note {
|
|
217
256
|
/** MIDI pitch, 0-127. */
|
|
@@ -454,6 +493,193 @@ export function playVoice(v: Voice): void {
|
|
|
454
493
|
outlet(CHAIN_OUT.voice_play, v.slot, v.rate, v.velocity, v.durationMs, v.channels ?? 2);
|
|
455
494
|
}
|
|
456
495
|
|
|
496
|
+
/* ------------------------------------------------------------------ *
|
|
497
|
+
* Remote - the `remote` chain (live.remote~ modulation)
|
|
498
|
+
* ------------------------------------------------------------------ */
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Point a `remote` slot at a Live parameter, by LOM id.
|
|
502
|
+
*
|
|
503
|
+
* The slot is an index into the device's declared `remotes: <n>`; the id is a LiveAPI
|
|
504
|
+
* object id for a `DeviceParameter` - `new LiveAPI(...).id`, whatever the app resolved
|
|
505
|
+
* it from. Until a slot is bound it modulates nothing, which is the safe resting state.
|
|
506
|
+
*
|
|
507
|
+
* **Re-bind on every load, and never persist the id.** LOM ids are handles into the
|
|
508
|
+
* running set, not names: they are not stable across a set reload, so an id saved
|
|
509
|
+
* yesterday points at whatever occupies that slot today - or at nothing. Persist how
|
|
510
|
+
* you FOUND the parameter (the device's position, the parameter's name) and resolve it
|
|
511
|
+
* again; that is the same rule Translate mode's reconciler follows (doc/TODO.md item 1).
|
|
512
|
+
*/
|
|
513
|
+
export function bindRemote(slot: number, lomId: number | string): void {
|
|
514
|
+
outlet(CHAIN_OUT.remote_bind, slot, lomId);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* What is this device's own parameter's LOM id? - the id `bindRemote` needs.
|
|
519
|
+
*
|
|
520
|
+
* `id` is a surface parameter id, the same name you passed to `useParam()`: the build
|
|
521
|
+
* wrote it into the patcher as the Live parameter's `parameter_longname`, so the two
|
|
522
|
+
* cannot drift. Resolves to 0 if no parameter of that name is on the device, which is
|
|
523
|
+
* a programming error rather than a runtime condition - check it.
|
|
524
|
+
*
|
|
525
|
+
* ASK AGAIN AFTER EVERY LOAD, and never persist what comes back. A LOM id is a handle
|
|
526
|
+
* into the running set: it is not stable across a set reload, and an id from last time
|
|
527
|
+
* points at whatever occupies that slot now. This call is cheap; a stale binding is a
|
|
528
|
+
* filter sweep on someone else's device.
|
|
529
|
+
*/
|
|
530
|
+
export function resolveParamId(id: string, timeoutMs = 2000): Promise<number> {
|
|
531
|
+
if (!paramIdBound) {
|
|
532
|
+
paramIdBound = true;
|
|
533
|
+
bindInlet(PARAM_IN.param_id, (which, lomId) => {
|
|
534
|
+
const p = paramIdResolvers.get(String(which));
|
|
535
|
+
if (!p) return;
|
|
536
|
+
paramIdResolvers.delete(String(which));
|
|
537
|
+
clearTimeout(p.timer);
|
|
538
|
+
p.resolve(Number(lomId));
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
return new Promise((resolve, reject) => {
|
|
542
|
+
const timer = setTimeout(() => {
|
|
543
|
+
paramIdResolvers.delete(id);
|
|
544
|
+
reject(new Error(`parameter "${id}": the wrapper never answered get_param_id (${timeoutMs} ms). See the Max console.`));
|
|
545
|
+
}, timeoutMs);
|
|
546
|
+
paramIdResolvers.set(id, { resolve, timer });
|
|
547
|
+
outlet(PARAM_OUT.get_param_id, id);
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const paramIdResolvers = new Map<string, { resolve: (id: number) => void; timer: ReturnType<typeof setTimeout> }>();
|
|
552
|
+
let paramIdBound = false;
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Send a slot's next value. Ramped, not stepped.
|
|
556
|
+
*
|
|
557
|
+
* Call this on the transport tick with the pattern's value for that moment. The chain
|
|
558
|
+
* ramps to it over ~20 ms (`REMOTE_RAMP_MS`) rather than jumping, so a control-rate
|
|
559
|
+
* stream of values comes out of Max as continuous modulation - see the `remote` chain
|
|
560
|
+
* for why that ramp is the whole point.
|
|
561
|
+
*
|
|
562
|
+
* THE VALUE IS NOT IN THE PARAMETER'S OWN UNITS - measured in Live, not read
|
|
563
|
+
* anywhere. live.remote~ treats it as a LINEAR position across the parameter's
|
|
564
|
+
* range and applies the knob's `exponent` curve on top, as if the number had
|
|
565
|
+
* grabbed the dial by its travel. For an exponent-1 parameter the two notions
|
|
566
|
+
* coincide and raw units work; for a curved one (a filter cutoff at exponent 4)
|
|
567
|
+
* the app must pre-warp: aim the travel at norm(v)^(1/e) so Live's ^e lands on v.
|
|
568
|
+
* See m4l-strudel's useModulation.toRemote for the worked inverse.
|
|
569
|
+
*/
|
|
570
|
+
export function writeRemote(slot: number, value: number): void {
|
|
571
|
+
outlet(CHAIN_OUT.remote_val, slot, value);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/* ------------------------------------------------------------------ *
|
|
575
|
+
* Clip I/O - read and write the MIDI clip on this device's track
|
|
576
|
+
*
|
|
577
|
+
* The wrapper does the LiveAPI work (`read_notes`/`write_clip` in liveapi.ts); these
|
|
578
|
+
* are the shaped API over it. No chain and no `[node.script]` - a clip is note data,
|
|
579
|
+
* which is control-plane, so it crosses the bridge as a message. Needs
|
|
580
|
+
* `unmatchedTo: "js"` in the manifest so the bare selector reaches `[js]`.
|
|
581
|
+
* ------------------------------------------------------------------ */
|
|
582
|
+
|
|
583
|
+
/** One note in a clip. Times are in BEATS, from the clip's start. */
|
|
584
|
+
export interface ClipNote {
|
|
585
|
+
pitch: number;
|
|
586
|
+
/** Start, in beats from the clip's start. */
|
|
587
|
+
start: number;
|
|
588
|
+
/** Length, in beats. */
|
|
589
|
+
duration: number;
|
|
590
|
+
/** 1-127. Only WRITTEN - a read does not return it. */
|
|
591
|
+
velocity: number;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** What a clip read returns: the loop length and the notes (velocity is not read back). */
|
|
595
|
+
export interface ReadClip {
|
|
596
|
+
/** The clip's loop end, in beats - its musical length. */
|
|
597
|
+
loopEnd: number;
|
|
598
|
+
notes: Omit<ClipNote, "velocity">[];
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const readClipResolvers: { resolve: (c: ReadClip) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> }[] = [];
|
|
602
|
+
let clipReadBound = false;
|
|
603
|
+
|
|
604
|
+
/** Bind the shared reply path once - both read paths answer on `notes` / `read_error`. */
|
|
605
|
+
function bindClipRead(): void {
|
|
606
|
+
if (clipReadBound) return;
|
|
607
|
+
clipReadBound = true;
|
|
608
|
+
// One reply per outstanding read, in order - Live answers a read with one `notes`
|
|
609
|
+
// (or one `read_error`), and reads are not interleaved in practice.
|
|
610
|
+
bindInlet(CLIP_IN.notes, (...args) => {
|
|
611
|
+
const p = readClipResolvers.shift();
|
|
612
|
+
if (!p) return;
|
|
613
|
+
clearTimeout(p.timer);
|
|
614
|
+
const loopEnd = Number(args[0]);
|
|
615
|
+
const n = Number(args[1]);
|
|
616
|
+
const notes: Omit<ClipNote, "velocity">[] = [];
|
|
617
|
+
for (let k = 0; k < n; k++) {
|
|
618
|
+
const o = 2 + k * 3;
|
|
619
|
+
notes.push({ pitch: Number(args[o]), start: Number(args[o + 1]), duration: Number(args[o + 2]) });
|
|
620
|
+
}
|
|
621
|
+
p.resolve({ loopEnd, notes });
|
|
622
|
+
});
|
|
623
|
+
bindInlet(CLIP_IN.read_error, (reason) => {
|
|
624
|
+
const p = readClipResolvers.shift();
|
|
625
|
+
if (!p) return;
|
|
626
|
+
clearTimeout(p.timer);
|
|
627
|
+
// The wrapper says WHY: "no_clip" (track has none) or "no_selection" (highlighted
|
|
628
|
+
// slot is empty). Either way there is nothing to read.
|
|
629
|
+
p.reject(new Error(String(reason) === "no_selection" ? "no clip in the highlighted slot - click a clip first" : "no clip on this track - create or play a MIDI clip first"));
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function requestClipRead(selector: string, timeoutMs: number): Promise<ReadClip> {
|
|
634
|
+
bindClipRead();
|
|
635
|
+
return new Promise((resolve, reject) => {
|
|
636
|
+
const timer = setTimeout(() => {
|
|
637
|
+
const i = readClipResolvers.findIndex((r) => r.timer === timer);
|
|
638
|
+
if (i >= 0) readClipResolvers.splice(i, 1);
|
|
639
|
+
reject(new Error(`the wrapper never answered ${selector} (${timeoutMs} ms). See the Max console.`));
|
|
640
|
+
}, timeoutMs);
|
|
641
|
+
readClipResolvers.push({ resolve, reject, timer });
|
|
642
|
+
outlet(selector);
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Read the MIDI clip on this device's TRACK - the PLAYING one, or the first found,
|
|
648
|
+
* regardless of the selection. Rejects if the track has no clip, or if the wrapper does
|
|
649
|
+
* not answer inside `timeoutMs`. This is the right call for a device that operates on
|
|
650
|
+
* its own track's pattern; for the clip the user has CLICKED, use `readSelectedClip()`.
|
|
651
|
+
*
|
|
652
|
+
* The bytes are notes, not audio, so unlike a sample they DO cross the bridge: the
|
|
653
|
+
* wrapper reads them with LiveAPI's `get_notes_extended` and sends
|
|
654
|
+
* `notes <loopEnd> <n> <pitch start duration> ...`.
|
|
655
|
+
*/
|
|
656
|
+
export function readClip(timeoutMs = 2000): Promise<ReadClip> {
|
|
657
|
+
return requestClipRead(CLIP_OUT.read_notes, timeoutMs);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Read the clip the CURSOR is on - Live's highlighted clip slot, whichever track and
|
|
662
|
+
* scene. An empty highlighted slot rejects with "no clip in the highlighted slot", so
|
|
663
|
+
* clicking an empty slot reads nothing rather than falling back to another clip. Same
|
|
664
|
+
* resolved shape as `readClip()`.
|
|
665
|
+
*/
|
|
666
|
+
export function readSelectedClip(timeoutMs = 2000): Promise<ReadClip> {
|
|
667
|
+
return requestClipRead(CLIP_OUT.read_selected_clip, timeoutMs);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Write a clip into the first empty slot on this device's track.
|
|
672
|
+
*
|
|
673
|
+
* `lengthBeats` is the clip's length; the notes' `start`/`duration` are in beats
|
|
674
|
+
* within it. The message is a flat list (`write_clip <lengthBeats> <n> <p s d v> ...`)
|
|
675
|
+
* because Max has no nested arguments - the wrapper reads it back four atoms at a time.
|
|
676
|
+
*/
|
|
677
|
+
export function writeClip(lengthBeats: number, notes: readonly ClipNote[]): void {
|
|
678
|
+
const flat: number[] = [lengthBeats, notes.length];
|
|
679
|
+
for (const nt of notes) flat.push(nt.pitch, nt.start, nt.duration, nt.velocity);
|
|
680
|
+
outlet(CLIP_OUT.write_clip, ...flat);
|
|
681
|
+
}
|
|
682
|
+
|
|
457
683
|
/**
|
|
458
684
|
* Max splits messages on commas and semicolons, so any structured payload -
|
|
459
685
|
* JSON, code, a filesystem path - must be encoded before it crosses the bridge.
|