@m4l-jweb/bridge 0.6.5 → 0.9.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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/index.ts +226 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m4l-jweb/bridge",
3
- "version": "0.6.5",
3
+ "version": "0.9.0",
4
4
  "description": "m4l-jweb: the browser-side bridge connecting a device's web UI to Max for Live.",
5
5
  "type": "module",
6
6
  "license": "MIT",
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.