@m4l-jweb/bridge 1.2.0 → 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.
package/README.md CHANGED
@@ -36,6 +36,7 @@ outlet("my_selector", 1, "two");
36
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
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
38
  - `tapMessages()` observes every message in both directions - the whole contract of a device, live.
39
+ - `copyPath()` answers "where did my file go". Nothing in Max can open a file manager, and inside `[jweb~]` a clipboard write can be claimed but never confirmed - so the result is `copied` / `manual` / `cancelled`, never a boolean that might be lying.
39
40
 
40
41
  ## Requirements
41
42
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m4l-jweb/bridge",
3
- "version": "1.2.0",
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",
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Putting a PATH in front of the user, because revealing a folder is impossible.
3
+ *
4
+ * Every device that writes files hits the same question - "where did it go" - and
5
+ * neither the page nor Max can open a file manager. `; max launchbrowser <folder>` is
6
+ * the only door Max offers and it does not open: measured in Live on Windows 11 in
7
+ * both forms, a percent-encoded `file:///C:/...` URL with a WRONG path raised a real
8
+ * shell dialog naming it, while a correct one opened nothing and reported nothing; a
9
+ * native backslash path behaved identically. `[js]` has no shell call and there is no
10
+ * second Max object that reveals a path. See doc/MAX-FACTS.md.
11
+ *
12
+ * So the answer is the clipboard, and the clipboard inside jweb lies:
13
+ * `document.execCommand("copy")` RETURNS TRUE and copies nothing, and the page cannot
14
+ * tell, because `navigator.clipboard.readText()` needs a secure context and a `file://`
15
+ * page does not have one. A copy can be claimed but never confirmed - which produced
16
+ * the worst possible UI, a status line reading "Path copied" next to an empty paste.
17
+ *
18
+ * Hence the rule this module encodes: inside jweb, never trust a claim. Attempt the
19
+ * programmatic copy anyway (it costs nothing and works in some builds), then show a
20
+ * focused, pre-selected field and wait for the browser's own `copy` event, which fires
21
+ * only when a copy really happens. The result is three-way rather than boolean, so a
22
+ * caller cannot accidentally report success it does not have.
23
+ */
24
+
25
+ /**
26
+ * Read at call time, not imported from ./index: index re-exports this module, and a
27
+ * cycle that only works because one side happens to be evaluated first is not worth
28
+ * the one binding it saves.
29
+ */
30
+ const inJweb = () => typeof window !== "undefined" && !!(window as { max?: unknown }).max;
31
+
32
+ export type CopyResult =
33
+ /** The programmatic copy was made AND could be trusted (outside jweb). */
34
+ | "copied"
35
+ /** The user pressed Ctrl+C on the field we showed - the browser confirmed it. */
36
+ | "manual"
37
+ /** The field was dismissed without a copy. The caller still shows the path. */
38
+ | "cancelled";
39
+
40
+ /**
41
+ * Copy `text`, falling back to the user's own Ctrl+C, and report what really happened.
42
+ *
43
+ * Resolves as soon as the outcome is known: immediately outside jweb, and on the copy
44
+ * event (or dismissal) inside it.
45
+ */
46
+ export async function copyPath(text: string): Promise<CopyResult> {
47
+ const claimed = await attemptCopy(text);
48
+ // Outside Max (browser dev) the APIs are trustworthy: a secure context, a real
49
+ // clipboard, and a `writeText` that rejects when it fails.
50
+ if (claimed && !inJweb()) return "copied";
51
+ return promptCopy(text);
52
+ }
53
+
54
+ /** The programmatic attempt, both mechanisms. Its `true` is a CLAIM, not a fact. */
55
+ async function attemptCopy(text: string): Promise<boolean> {
56
+ if (execCommandCopy(text)) return true;
57
+ try {
58
+ // NOT `navigator.clipboard?.writeText(...)`: optional chaining on a MISSING API
59
+ // yields undefined, `await undefined` resolves, and this reported success on
60
+ // exactly the page where there is no clipboard API at all.
61
+ if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) return false;
62
+ await navigator.clipboard.writeText(text);
63
+ return true;
64
+ } catch {
65
+ return false;
66
+ }
67
+ }
68
+
69
+ /** The selection-based copy, synchronous. Must run inside the click's user gesture. */
70
+ function execCommandCopy(text: string): boolean {
71
+ if (typeof document === "undefined") return false;
72
+ const previous = document.activeElement as HTMLElement | null;
73
+ const area = document.createElement("textarea");
74
+ try {
75
+ area.value = text;
76
+ // IN the viewport, not off at -1000px: an element scrolled out of view can fail to
77
+ // take focus, and a selection nothing owns copies nothing. Invisible is enough.
78
+ area.style.cssText = "position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;padding:0;border:0";
79
+ area.setAttribute("readonly", "");
80
+ document.body.appendChild(area);
81
+ // focus() BEFORE select(): select() alone leaves the selection unowned in CEF.
82
+ area.focus();
83
+ area.select();
84
+ area.setSelectionRange(0, text.length);
85
+ return document.execCommand("copy");
86
+ } catch {
87
+ return false;
88
+ } finally {
89
+ area.remove();
90
+ previous?.focus?.();
91
+ }
92
+ }
93
+
94
+ const PROMPT_ID = "m4l-copy-prompt";
95
+
96
+ /**
97
+ * Show the path in a focused, pre-selected field and wait for the user to copy it.
98
+ *
99
+ * Resolves "manual" on the browser's `copy` event - the only confirmation this page can
100
+ * get - and "cancelled" on Escape, on the close button, or on a click outside. It does
101
+ * NOT dismiss on blur: focus can be lost to Max itself, and a field that vanishes when
102
+ * you look away is a field you cannot use.
103
+ */
104
+ export function promptCopy(text: string): Promise<CopyResult> {
105
+ return new Promise((resolve) => {
106
+ try {
107
+ if (typeof document === "undefined") {
108
+ resolve("cancelled");
109
+ return;
110
+ }
111
+ document.getElementById(PROMPT_ID)?.remove();
112
+
113
+ const box = document.createElement("div");
114
+ box.id = PROMPT_ID;
115
+ box.style.cssText =
116
+ "position:fixed;inset:0;z-index:9999;display:flex;flex-direction:column;gap:4px;" +
117
+ "justify-content:center;padding:8px;background:rgba(0,0,0,.92);color:#fff;font:11px system-ui";
118
+
119
+ const label = document.createElement("div");
120
+ label.textContent = "Press Ctrl+C (Cmd+C) to copy this path";
121
+ label.style.cssText = "opacity:.7";
122
+
123
+ const input = document.createElement("input");
124
+ input.readOnly = true;
125
+ input.value = text;
126
+ input.style.cssText =
127
+ "width:100%;background:#111;color:#fff;border:1px solid #444;border-radius:3px;" +
128
+ "padding:3px 4px;font:10px monospace";
129
+
130
+ const close = document.createElement("button");
131
+ close.textContent = "Close";
132
+ close.style.cssText =
133
+ "align-self:flex-end;background:#222;color:#fff;border:1px solid #444;border-radius:3px;" +
134
+ "padding:2px 8px;font:10px system-ui;cursor:pointer";
135
+
136
+ let done = false;
137
+ const finish = (r: CopyResult) => {
138
+ if (done) return;
139
+ done = true;
140
+ box.remove();
141
+ resolve(r);
142
+ };
143
+
144
+ // The one honest confirmation: the browser only fires this when a copy happens.
145
+ input.addEventListener("copy", () => finish("manual"));
146
+ input.addEventListener("keydown", (e) => {
147
+ if (e.key === "Escape") finish("cancelled");
148
+ });
149
+ close.addEventListener("click", () => finish("cancelled"));
150
+ box.addEventListener("mousedown", (e) => {
151
+ if (e.target === box) finish("cancelled");
152
+ });
153
+
154
+ box.append(label, input, close);
155
+ document.body.appendChild(box);
156
+ input.focus();
157
+ input.select();
158
+ } catch {
159
+ resolve("cancelled");
160
+ }
161
+ });
162
+ }
163
+
164
+ /**
165
+ * What to tell the user, per outcome.
166
+ *
167
+ * Here rather than in each device because the wording is the whole point: the status
168
+ * line is the only place any of this is visible, and it must never claim a copy that
169
+ * did not happen - which is the bug this module exists to stop.
170
+ */
171
+ export function copyMessage(result: CopyResult, path: string): string {
172
+ if (result === "copied" || result === "manual") return `Path copied: ${path}`;
173
+ return `Not copied - the folder is ${path}`;
174
+ }
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`. */
@@ -274,16 +339,35 @@ export const PARAM_IN = {
274
339
  * device cannot rename a parameter there.
275
340
  * `unit` takes. It is the unit STYLE, so the readout says "600 Hz" and not "600";
276
341
  * anything outside Max's list becomes a custom unit and still prints.
277
- * `range` takes, AND CHANGES THE DOMAIN THE PARAMETER REPORTS. That is the trap:
278
- * a page that goes on normalizing 0..1 will scale a value that is already
279
- * scaled, and the control sticks at its minimum. So the wrapper ANSWERS -
280
- * `param_range_ok` or `param_range_failed` - and the caller must stop
281
- * normalizing when it took. `onParamRange()` below is that answer.
342
+ * `range` takes, AND CHANGES THE DOMAIN THE PARAMETER REPORTS. That is the first
343
+ * trap: a page that goes on normalizing 0..1 will scale a value that is
344
+ * already scaled, and the control sticks at its minimum. So the wrapper
345
+ * ANSWERS - `param_range_ok` or `param_range_failed` - and the caller must
346
+ * stop normalizing when it took. `onParamRange()` below is that answer.
347
+ *
348
+ * AND IT IS NOW OFF BY DEFAULT, because of a second trap that is worse.
349
+ * Measured in Live: a dial whose range was widened at runtime STOPS
350
+ * FOLLOWING ITS AUTOMATION LANE AND ANY RACK MACRO MAPPED TO IT. Both go on
351
+ * driving the parameter in its BUILD-TIME domain - the frozen device is what
352
+ * Live bound them against - so a macro at 0.5 writes 0.5 into a parameter
353
+ * now spanning 200..2000, and the dial sits at the bottom, unmoving, with no
354
+ * error anywhere. A sibling dial left at 0..1 in the same device responds
355
+ * normally, which is what isolates it to the widening.
356
+ *
357
+ * So `range` alone is DESCRIPTIVE: the page keeps the scaling and the
358
+ * parameter stays automatable. `widenRange: true` asks for the old
359
+ * behaviour, and is worth it only where a unit readout on the dial beats
360
+ * automation and macros on it.
282
361
  */
283
- export function describeParam(id: string, desc: { name?: string; unit?: string; range?: [number, number] }): void {
362
+ export function describeParam(
363
+ id: string,
364
+ desc: { name?: string; unit?: string; range?: [number, number]; widenRange?: boolean },
365
+ ): void {
284
366
  if (desc.name) outlet(PARAM_OUT.param_label, id, desc.name);
285
367
  if (desc.unit) outlet(PARAM_OUT.param_unit, id, desc.unit);
286
- if (desc.range && desc.range[1] > desc.range[0]) outlet(PARAM_OUT.param_range, id, desc.range[0], desc.range[1]);
368
+ if (desc.widenRange && desc.range && desc.range[1] > desc.range[0]) {
369
+ outlet(PARAM_OUT.param_range, id, desc.range[0], desc.range[1]);
370
+ }
287
371
  }
288
372
 
289
373
  /**
@@ -310,6 +394,8 @@ export const CLIP_OUT = {
310
394
  read_selected_clip: "read_selected_clip",
311
395
  /** UI -> wrapper: `write_clip <lengthBeats> <n> <pitch start duration velocity> ...` - fill the first empty slot. */
312
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",
313
399
  } as const;
314
400
 
315
401
  /** ...and the reply from a read. */
@@ -318,6 +404,10 @@ export const CLIP_IN = {
318
404
  notes: "notes",
319
405
  /** wrapper -> UI: there was no clip on this track to read. */
320
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",
321
411
  } as const;
322
412
 
323
413
  /** A note handed to the `midiout` chain. Max does the placing; you do the timing. */
@@ -487,7 +577,8 @@ let saveBound = false;
487
577
  * over base64 in slices and the wrapper writes them to a `.part` file, then atomically
488
578
  * places it at `destPath` via [maxurl] (the same move fetchToFile phase 2 uses). The
489
579
  * bytes travel base64 because Max splits messages on spaces/commas and base64 has
490
- * 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.
491
582
  *
492
583
  * @param destPath Absolute path (or device-relative, resolved wrapper-side) to write.
493
584
  * @param bytes The payload. A per-cycle WAV is ~350 KB => ~60 messages.
@@ -524,6 +615,45 @@ export function saveToFile(destPath: string, bytes: ArrayBuffer): Promise<{ byte
524
615
  });
525
616
  }
526
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
+
527
657
 
528
658
 
529
659
  /* ------------------------------------------------------------------ *
@@ -714,6 +844,129 @@ export function writeClip(lengthBeats: number, notes: readonly ClipNote[]): void
714
844
  outlet(CLIP_OUT.write_clip, ...flat);
715
845
  }
716
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
+
717
970
  /**
718
971
  * Max splits messages on commas and semicolons, so any structured payload -
719
972
  * JSON, code, a filesystem path - must be encoded before it crosses the bridge.
@@ -733,6 +986,18 @@ export function decodeBase64(s: string): string {
733
986
  return new TextDecoder().decode(bytes);
734
987
  }
735
988
 
989
+ /* ------------------------------------------------------------------ *
990
+ * Telling the user where a file went
991
+ * ------------------------------------------------------------------ */
992
+
993
+ /**
994
+ * A device that writes files needs to answer "where did it go", and neither the page
995
+ * nor Max can open a file manager. Re-exported here so `@m4l-jweb/bridge` stays the one
996
+ * import; the reasoning and the failure modes are in ./clipboard.
997
+ */
998
+ export { copyPath, promptCopy, copyMessage } from "./clipboard";
999
+ export type { CopyResult } from "./clipboard";
1000
+
736
1001
  /**
737
1002
  * Dev shim. Outside Max there is no window.max, so route messages straight into
738
1003
  * the bound handlers and let the developer drive the device from the console: