@m4l-jweb/bridge 1.2.1 → 1.3.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 +235 -1
package/package.json
CHANGED
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
|
|
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.
|