@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 +1 -0
- package/package.json +1 -1
- package/src/clipboard.ts +174 -0
- package/src/index.ts +273 -8
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
package/src/clipboard.ts
ADDED
|
@@ -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
|
|
278
|
-
* a page that goes on normalizing 0..1 will scale a value that is
|
|
279
|
-
* scaled, and the control sticks at its minimum. So the wrapper
|
|
280
|
-
* `param_range_ok` or `param_range_failed` - and the caller must
|
|
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(
|
|
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.
|
|
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
|
|
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:
|