@m4l-jweb/bridge 1.2.1 → 1.3.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 +235 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m4l-jweb/bridge",
3
- "version": "1.2.1",
3
+ "version": "1.3.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
@@ -124,6 +124,11 @@ export function simulate(name: string, ...args: unknown[]): void {
124
124
  * binding, and treat the reply as the source of truth.
125
125
  */
126
126
  export function uiReady(): void {
127
+ // Every ui_ready reply is a ONE-SHOT: the wrapper sends it once and never
128
+ // repeats it. A selector bound after this line therefore misses its only
129
+ // message. device_folder is bound here rather than by the component that wants
130
+ // it, so no page can lose it by subscribing in a later effect.
131
+ bindDeviceFolder();
127
132
  outlet("ui_ready");
128
133
  }
129
134
 
@@ -230,6 +235,66 @@ export const STATE_OUT = {
230
235
  sync_state: "sync_state",
231
236
  } as const;
232
237
 
238
+ /**
239
+ * Selectors the WRAPPER sends to a device that declares `defineFiles()`.
240
+ *
241
+ * Wrapper-owned, like DEVICE_IN - no chain is involved, so it needs
242
+ * `unmatchedTo: "js"` like every other bare wrapper selector. `onDeviceFolder()`
243
+ * below binds it, and the app never types the name.
244
+ */
245
+ export const FILES_IN = {
246
+ /** wrapper -> UI: `device_folder <path>` - the absolute folder this device's files land in. */
247
+ device_folder: "device_folder",
248
+ } as const;
249
+
250
+ /**
251
+ * What the wrapper says about the TRACK this device is on.
252
+ *
253
+ * A device cannot see its own container from the page, and several things depend on it:
254
+ * an audio clip can only be created on an audio track, a MIDI clip only on a MIDI one,
255
+ * and a device shipped in both flavours (one manifest entry per container) otherwise has
256
+ * to be told which it is at build time - two builds that differ in a fact Live already
257
+ * knows. Sent once at ui_ready, like `device_folder`.
258
+ */
259
+ export const TRACK_IN = {
260
+ /** wrapper -> UI: `track_kind <audio|midi|none>`. `none` means no reachable track. */
261
+ track_kind: "track_kind",
262
+ } as const;
263
+
264
+ /** Which container this device is in, as Live reports it. */
265
+ export type TrackKind = "audio" | "midi" | "none";
266
+
267
+ const trackKindHandlers = new Set<(kind: TrackKind) => void>();
268
+ let trackKindBound = false;
269
+ /** The last kind reported, for a subscriber that mounts after ui_ready. */
270
+ let knownTrackKind: TrackKind | null = null;
271
+
272
+ /**
273
+ * What kind of track is this device on?
274
+ *
275
+ * `audio` takes audio clips, `midi` takes MIDI clips, and `none` means the device is not
276
+ * on a reachable track at all (inside something odd, or mid-load). A late subscriber gets
277
+ * the answer immediately - ui_ready fires once, and a component mounting after it would
278
+ * otherwise wait forever for a message already sent.
279
+ *
280
+ * @returns An unsubscribe, so it drops straight into `useEffect`.
281
+ */
282
+ export function onTrackKind(fn: (kind: TrackKind) => void): () => void {
283
+ trackKindHandlers.add(fn);
284
+ if (!trackKindBound) {
285
+ trackKindBound = true;
286
+ // One binding, many subscribers: `bindInlet` keeps a single handler per selector.
287
+ bindInlet(TRACK_IN.track_kind, (kind) => {
288
+ knownTrackKind = String(kind) as TrackKind;
289
+ for (const h of trackKindHandlers) h(knownTrackKind);
290
+ });
291
+ }
292
+ if (knownTrackKind !== null) fn(knownTrackKind);
293
+ return () => {
294
+ trackKindHandlers.delete(fn);
295
+ };
296
+ }
297
+
233
298
  /** Selectors the WRAPPER answers about this device's own Live parameters. */
234
299
  export const PARAM_OUT = {
235
300
  /** UI -> wrapper: `get_param_id <id>` - what is this parameter's LOM id? Reply on `param_id`. */
@@ -329,6 +394,8 @@ export const CLIP_OUT = {
329
394
  read_selected_clip: "read_selected_clip",
330
395
  /** UI -> wrapper: `write_clip <lengthBeats> <n> <pitch start duration velocity> ...` - fill the first empty slot. */
331
396
  write_clip: "write_clip",
397
+ /** UI -> wrapper: `create_audio_clip <requestId> <base64 spec>` - a WAV on disk into a clip slot. */
398
+ create_audio_clip: "create_audio_clip",
332
399
  } as const;
333
400
 
334
401
  /** ...and the reply from a read. */
@@ -337,6 +404,10 @@ export const CLIP_IN = {
337
404
  notes: "notes",
338
405
  /** wrapper -> UI: there was no clip on this track to read. */
339
406
  read_error: "read_error",
407
+ /** wrapper -> UI: `clip_created <requestId>` - the audio clip is in the slot. */
408
+ clip_created: "clip_created",
409
+ /** wrapper -> UI: `clip_error <requestId> <reason> <msg...>` - it is not, and why. */
410
+ clip_error: "clip_error",
340
411
  } as const;
341
412
 
342
413
  /** A note handed to the `midiout` chain. Max does the placing; you do the timing. */
@@ -506,7 +577,8 @@ let saveBound = false;
506
577
  * over base64 in slices and the wrapper writes them to a `.part` file, then atomically
507
578
  * places it at `destPath` via [maxurl] (the same move fetchToFile phase 2 uses). The
508
579
  * bytes travel base64 because Max splits messages on spaces/commas and base64 has
509
- * neither. Requires the `download` chain in the device manifest.
580
+ * neither. Requires `defineFiles({ saves: true })` in the device's `files.ts`, which
581
+ * is what puts [maxurl] in the patcher for the place.
510
582
  *
511
583
  * @param destPath Absolute path (or device-relative, resolved wrapper-side) to write.
512
584
  * @param bytes The payload. A per-cycle WAV is ~350 KB => ~60 messages.
@@ -543,6 +615,45 @@ export function saveToFile(destPath: string, bytes: ArrayBuffer): Promise<{ byte
543
615
  });
544
616
  }
545
617
 
618
+ const folderHandlers = new Set<(folder: string) => void>();
619
+ let folderBound = false;
620
+ /** The last folder the wrapper reported, for a subscriber that arrives after it. */
621
+ let knownFolder: string | null = null;
622
+
623
+ /**
624
+ * Where this device's files land, as an absolute path.
625
+ *
626
+ * The wrapper sends it once at ui_ready for a device that declares `defineFiles()`.
627
+ * It is the only way a page learns its own device's folder - and the only answer to
628
+ * "where did my export go", because Max cannot open a file manager (`launchbrowser`
629
+ * on a `file://` path reaches the shell and does nothing; see doc/MAX-FACTS.md). Pair
630
+ * it with `copyPath()` and the user can paste the folder into Explorer or Finder.
631
+ *
632
+ * A late subscriber gets the folder immediately: ui_ready fires once, and a component
633
+ * that mounts after it would otherwise wait forever for a message already sent.
634
+ *
635
+ * @returns An unsubscribe, so it drops straight into `useEffect`.
636
+ */
637
+ function bindDeviceFolder(): void {
638
+ if (folderBound) return;
639
+ folderBound = true;
640
+ // One binding, many subscribers - `bindInlet` keeps a single handler per
641
+ // selector, so a second bind on this name would silently replace the first.
642
+ bindInlet(FILES_IN.device_folder, (path) => {
643
+ knownFolder = String(path);
644
+ for (const h of folderHandlers) h(knownFolder);
645
+ });
646
+ }
647
+
648
+ export function onDeviceFolder(fn: (folder: string) => void): () => void {
649
+ folderHandlers.add(fn);
650
+ bindDeviceFolder();
651
+ if (knownFolder !== null) fn(knownFolder);
652
+ return () => {
653
+ folderHandlers.delete(fn);
654
+ };
655
+ }
656
+
546
657
 
547
658
 
548
659
  /* ------------------------------------------------------------------ *
@@ -733,6 +844,129 @@ export function writeClip(lengthBeats: number, notes: readonly ClipNote[]): void
733
844
  outlet(CLIP_OUT.write_clip, ...flat);
734
845
  }
735
846
 
847
+ /* ------------------------------------------------------------------ *
848
+ * A rendered file INTO a Live clip
849
+ *
850
+ * The other half of `saveToFile()`. A device that bounces its pattern writes a WAV and
851
+ * then has no way to hand it over: a page cannot give Live a file (Chromium strips the
852
+ * drag payload), Max cannot open a file manager, so the whole handoff was a path on the
853
+ * clipboard and five manual steps in Explorer.
854
+ *
855
+ * `ClipSlot.create_audio_clip` is ordinary LOM, so `[js]` can call it - which the drawer
856
+ * said for months was impossible. It is Live 12.0.5 or newer (documented earlier, and
857
+ * non-functional before that), so the wrapper reads Live's version and refuses rather
858
+ * than trusting the docs, and answers `clip_error <id> needs_live_1205` where a device
859
+ * should keep offering the clipboard instead.
860
+ *
861
+ * NO CHAIN AND NO BOXES. This is pure LiveAPI, like `defineWatch()` - nothing is derived
862
+ * into the patcher graph. It does need `unmatchedTo: "js"`, like every other bare
863
+ * wrapper selector.
864
+ * ------------------------------------------------------------------ */
865
+
866
+ /** Where a created clip lands. */
867
+ export type AudioClipTarget =
868
+ /** Live's highlighted clip slot - which, in a device's own view, is always a slot on
869
+ * the device's OWN track: a device's UI is only visible while its track is selected,
870
+ * so there is no reachable moment at which the highlighted slot is somebody else's. */
871
+ | { target: "selected" }
872
+ /** A slot named by index. `track` is the index in `live_set tracks`, `slot` the scene. */
873
+ | { target: "track"; track: number; slot: number }
874
+ /** A brand-new audio track (`create_audio_track(-1)`), first slot. The one target that
875
+ * cannot fail on a MIDI track, and therefore the honest escape for an instrument. */
876
+ | { target: "new" };
877
+
878
+ /** What to make of the clip once it exists - the things a render already knows. */
879
+ export interface AudioClipSetup {
880
+ /** The clip's name. What the user wrote, not `export-1785343077706.wav`. */
881
+ name?: string;
882
+ /** Warping on, so the clip follows Live's tempo. */
883
+ warp?: boolean;
884
+ /** Live's warp mode index (0 Beats, 1 Tones, 2 Texture, 3 Re-Pitch, 4 Complex...). */
885
+ warpMode?: number;
886
+ /**
887
+ * The loop end in BEATS - `cycles * beatsPerCycle` for a pattern render.
888
+ *
889
+ * The reason to send it: a device that rendered N whole cycles at a known cps knows the
890
+ * loop length exactly, and Live otherwise guesses it from transient analysis. The
891
+ * difference is a bounce that plays in time and one that needs hand-warping.
892
+ */
893
+ loopEnd?: number;
894
+ }
895
+
896
+ /** Why a clip was not created. Branch on it - `not_audio_track` is the one a device can offer a way out of. */
897
+ export type AudioClipFailure =
898
+ /** Live is older than 12.0.5, where the call exists but does nothing. */
899
+ | "needs_live_1205"
900
+ /** The target is a MIDI track (or a return, or the master). */
901
+ | "not_audio_track"
902
+ /** The target track is frozen, or armed and recording. */
903
+ | "track_busy"
904
+ /** No slot resolved: nothing highlighted, or the index does not exist. */
905
+ | "no_slot"
906
+ /** The file is not on disk at the path given. */
907
+ | "no_file"
908
+ /** The call ran and the slot still holds no clip. */
909
+ | "failed";
910
+
911
+ /** The rejection `createAudioClip()` throws, with the reason machine-readable. */
912
+ export class AudioClipError extends Error {
913
+ readonly reason: AudioClipFailure;
914
+ constructor(reason: AudioClipFailure, message: string) {
915
+ super(message);
916
+ this.name = "AudioClipError";
917
+ this.reason = reason;
918
+ }
919
+ }
920
+
921
+ const clipCreateResolvers = new Map<string, { resolve: () => void; reject: (e: Error) => void }>();
922
+ let clipCreateBound = false;
923
+
924
+ /**
925
+ * Put a file that is already on disk into a Live clip slot.
926
+ *
927
+ * @param path Absolute, or relative to the device folder (resolved wrapper-side, the same
928
+ * resolution `saveToFile()` uses - so pass back exactly the name you saved).
929
+ * @param where Which slot. See AudioClipTarget.
930
+ * @param setup Name, warping and loop points, applied after the clip exists.
931
+ *
932
+ * THE PAYLOAD IS BASE64, and that is not ceremony. Max parses a message into atoms on
933
+ * whitespace, and both of the strings here have spaces in them in practice: a real
934
+ * install's path contains "Ableton Library", and a clip name is whatever the user typed.
935
+ * Two variadic fields cannot be rejoined from one flat message - there is no way to tell
936
+ * where the first ends - so the whole spec travels as one JSON blob in one atom, which is
937
+ * what `encodeBase64` exists for. (m4l-jweb's TODO sketched `create_audio_clip <id>
938
+ * <path> <target...>`; that shape splits at the first space and loses the target.)
939
+ *
940
+ * A CLIP IS NOT ATOMIC. When the call succeeds but a `setup` field does not take, the
941
+ * clip still exists and this still resolves - the wrapper posts what failed. Refusing a
942
+ * bounce that landed because its name did not is the wrong trade.
943
+ */
944
+ export function createAudioClip(path: string, where: AudioClipTarget = { target: "selected" }, setup: AudioClipSetup = {}): Promise<void> {
945
+ if (!clipCreateBound) {
946
+ clipCreateBound = true;
947
+ bindInlet(CLIP_IN.clip_created, (id) => {
948
+ const p = clipCreateResolvers.get(String(id));
949
+ if (!p) return;
950
+ clipCreateResolvers.delete(String(id));
951
+ p.resolve();
952
+ });
953
+ bindInlet(CLIP_IN.clip_error, (id, reason, ...rest) => {
954
+ const p = clipCreateResolvers.get(String(id));
955
+ if (!p) return;
956
+ clipCreateResolvers.delete(String(id));
957
+ // The message is human text and splits on its spaces; the REASON is one word and
958
+ // does not, which is why it is a separate atom rather than parsed out of the text.
959
+ p.reject(new AudioClipError(String(reason) as AudioClipFailure, rest.join(" ") || String(reason)));
960
+ });
961
+ }
962
+
963
+ return new Promise((resolve, reject) => {
964
+ const requestId = Math.random().toString(36).substring(2, 10);
965
+ clipCreateResolvers.set(requestId, { resolve, reject });
966
+ outlet(CLIP_OUT.create_audio_clip, requestId, encodeBase64(JSON.stringify({ path, ...where, ...setup })));
967
+ });
968
+ }
969
+
736
970
  /**
737
971
  * Max splits messages on commas and semicolons, so any structured payload -
738
972
  * JSON, code, a filesystem path - must be encoded before it crosses the bridge.