@m4l-jweb/wrapper 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m4l-jweb/wrapper",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "description": "m4l-jweb: the Max for Live glue layer connecting a device to LiveAPI.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/core.ts CHANGED
@@ -124,6 +124,8 @@ function ui_ready(): void {
124
124
  reply("build", buildStamp());
125
125
  sendCurrentTempo(); // liveapi.ts
126
126
  resendWatches(); // watch.ts - the current value of every declared watch, for a late page
127
+ sendDeviceFolder(); // where this device's files land, for a device that declares any
128
+ sendTrackKind(); // liveapi.ts - audio | midi | none, which decides what clips it can make
127
129
  // The device resends its own state here. The page loads asynchronously, so
128
130
  // anything sent before it was listening is simply gone.
129
131
  if (typeof onUiReady === "function") onUiReady();
@@ -761,6 +763,27 @@ function deviceFolder(): string | null {
761
763
  return fp && fp.length ? fp.replace(/\/[^\/]*$/, "") : null;
762
764
  }
763
765
 
766
+ /**
767
+ * Tell the page where its files land - the folder half of defineFiles().
768
+ *
769
+ * A page cannot know its own device's folder: it is a `file://` document extracted
770
+ * from the .amxd, and nothing in it names the .amxd. So the wrapper says, at
771
+ * ui_ready, and it is the SAME resolution the save used - which is what makes the
772
+ * path the page shows the real one rather than a plausible one.
773
+ *
774
+ * It goes out as ONE symbol. A real install has spaces in this path ("Ableton
775
+ * Library"), and a path travelling as message text would split there into atoms.
776
+ *
777
+ * No folder means an UNSAVED patcher, and that is worth a console line rather than
778
+ * silence: every relative path the device writes then resolves against nowhere.
779
+ */
780
+ function sendDeviceFolder(): void {
781
+ if (typeof FILES_SPEC === "undefined" || !FILES_SPEC.tellPage) return;
782
+ var folder = deviceFolder();
783
+ if (folder) reply("device_folder", folder);
784
+ else post("m4l-jweb: patcher is not saved - no device folder, and every relative path resolves against nowhere\n");
785
+ }
786
+
764
787
  /**
765
788
  * Write every non-UI payload the build embedded (manifest `payloads`) next to
766
789
  * the .amxd. Same reason as the UI: anything that is not a Max-native object is
@@ -966,6 +989,24 @@ function partPath(destPath: string): string {
966
989
  return destPath + ".part";
967
990
  }
968
991
 
992
+ /**
993
+ * The `file://` SOURCE of an atomic place, for [maxurl]'s request dict.
994
+ *
995
+ * PERCENT-ENCODED, and it has to be: libcurl parses this as a URL, and a raw space
996
+ * is not a URL. Handing it the plain path comes back as
997
+ *
998
+ * error URL using bad/illegal format or missing URL
999
+ *
1000
+ * which is CURLE_URL_MALFORMAT, measured in Live. Whether maxurl then DECODES what
1001
+ * it parsed is a separate question and an open one - a spaced device folder has also
1002
+ * been seen failing with `Couldn't open file <the encoded path>`, and the conformance
1003
+ * check in `wrapper/device.ts` does the same thing and passes. Do not change the
1004
+ * encoding again without measuring both.
1005
+ */
1006
+ function placeUrl(path: string): string {
1007
+ return encodeURI("file:///" + path);
1008
+ }
1009
+
969
1010
  /**
970
1011
  * Where a SAVE lands before it has earned its destination - and deliberately NOT
971
1012
  * `<dest>.part`.
@@ -1007,12 +1048,23 @@ var currentFetch: ActiveFetch | null = null;
1007
1048
  * A relative path is resolved against the DEVICE's folder - the one place a
1008
1049
  * device can always write, and the same folder the UI payload is extracted into.
1009
1050
  * An absolute path (POSIX `/...`, or Windows `C:/...`) is passed through.
1051
+ *
1052
+ * NULL when it cannot be resolved, and the caller must then refuse to write. A bare
1053
+ * relative name handed to `[js]`'s `File` is not created in the current directory -
1054
+ * it is created in MAX'S OWN FOLDER, and from that moment the name exists on Max's
1055
+ * search path, so every later `new File(<absolute path>, "write")` for that name
1056
+ * resolves to the stray and writes THERE instead. The file the wrapper then verifies
1057
+ * is the stray (`File` in read mode searches the same path and finds it at the right
1058
+ * size), while `[maxurl]`, which takes the literal path, correctly reports nothing
1059
+ * there. One save from an unsaved patcher poisons that filename permanently, for
1060
+ * every device on the machine. This is the whole of doc/MAX-FACTS.md's stray-file
1061
+ * entry, and it cost weeks.
1010
1062
  */
1011
- function resolveFetchPath(destPath: string): string {
1063
+ function resolveFetchPath(destPath: string): string | null {
1012
1064
  var isAbsolute = destPath.indexOf("/") === 0 || destPath.indexOf(":") === 1;
1013
1065
  if (isAbsolute) return destPath;
1014
1066
  var folder = deviceFolder();
1015
- return folder ? folder + "/" + destPath : destPath;
1067
+ return folder ? folder + "/" + destPath : null;
1016
1068
  }
1017
1069
 
1018
1070
  /** PHASE 1: download the URL to `<dest>.part`. Nothing touches `<dest>` yet. */
@@ -1021,7 +1073,11 @@ function processNextFetch(): void {
1021
1073
  var next = fetchQueue.shift();
1022
1074
  if (!next) return;
1023
1075
  currentFetch = next;
1024
- next.destPath = resolveFetchPath(next.destPath);
1076
+ var fetchDest = resolveFetchPath(next.destPath);
1077
+ if (fetchDest === null) {
1078
+ return failFetch(next, "the patcher is not saved, so there is no folder to fetch into - save the Live set first");
1079
+ }
1080
+ next.destPath = fetchDest;
1025
1081
 
1026
1082
  var reqDict = new Dict();
1027
1083
  reqDict.set("url", next.url);
@@ -1043,7 +1099,7 @@ function processNextFetch(): void {
1043
1099
  */
1044
1100
  function placeFetch(fetched: ActiveFetch): void {
1045
1101
  var reqDict = new Dict();
1046
- reqDict.set("url", encodeURI("file:///" + partPath(fetched.destPath)));
1102
+ reqDict.set("url", placeUrl(partPath(fetched.destPath)));
1047
1103
  reqDict.set("http_method", "get");
1048
1104
  reqDict.set("filename_out", fetched.destPath);
1049
1105
  reqDict.set("overwrite_output_file", 1);
@@ -1070,21 +1126,55 @@ interface ActiveSave {
1070
1126
  destPath: string;
1071
1127
  /** Bytes the app promised in save_begin - the size the .part must match. */
1072
1128
  expect: number;
1073
- /** Bytes actually written so far, for the verify. */
1129
+ /** Bytes actually written, once save_end has written them. */
1074
1130
  written: number;
1075
- /** The open .part file, held across the chunk messages. */
1076
- file: File | null;
1131
+ /** The base64 slices as they arrive, held until save_end writes the file. */
1132
+ chunks: string[];
1077
1133
  }
1078
1134
 
1079
1135
  var activeSave: ActiveSave | null = null;
1080
1136
 
1081
- /** The app: `save_begin <requestId> <destPath> <byteCount>` - open the .part file. */
1137
+ /**
1138
+ * The app: `save_begin <requestId> <destPath> <byteCount>` - start collecting.
1139
+ *
1140
+ * The bytes are held and written in one go by `save_end`, so the `.part` File is
1141
+ * opened and closed inside a single call. That is a simplification, not a fix: the
1142
+ * bug it was written for turned out to be a stray file on Max's search path (see
1143
+ * `resolveFetchPath`), and streaming across the chunk messages would work too. It
1144
+ * costs about 1.33x the payload in [js] memory until the save completes, which is
1145
+ * worth revisiting for large exports - doc/TODO.md carries it.
1146
+ *
1147
+ * A save with no resolvable folder is REFUSED here rather than written relative,
1148
+ * because writing relative is what creates the stray in the first place.
1149
+ */
1082
1150
  function save_begin(requestId: string, destPath: string, byteCount: number): void {
1083
- // A save already in flight is abandoned - its .part is left for the next place/overwrite.
1084
- if (activeSave && activeSave.file && activeSave.file.isopen) activeSave.file.close();
1085
-
1086
1151
  var resolved = resolveFetchPath(destPath);
1087
- var target = savePartPath(resolved);
1152
+ if (resolved === null) {
1153
+ outlet(0, "save_error", requestId, "the patcher is not saved, so there is no folder to save into - save the Live set first");
1154
+ activeSave = null;
1155
+ return;
1156
+ }
1157
+ // Every step of a save posts what it MEASURED, not what it attempted. A save that
1158
+ // fails at the place step looks identical to one that never wrote a byte, and the
1159
+ // console was the only place that could tell them apart - it could not.
1160
+ post("m4l-jweb: save begin " + savePartPath(resolved) + " (expect " + Number(byteCount) + " bytes)\n");
1161
+ activeSave = { requestId: requestId, destPath: resolved, expect: Number(byteCount), written: 0, chunks: [] };
1162
+ }
1163
+
1164
+ /** The app: `save_chunk <requestId> <base64>` - hold one slice for save_end. */
1165
+ function save_chunk(requestId: string, b64: string): void {
1166
+ if (!activeSave || activeSave.requestId !== requestId) return;
1167
+ activeSave.chunks.push(b64);
1168
+ }
1169
+
1170
+ /** The app: `save_end <requestId>` - write the whole .part, verify, place atomically. */
1171
+ function save_end(requestId: string): void {
1172
+ if (!activeSave || activeSave.requestId !== requestId) return;
1173
+ var save = activeSave;
1174
+ var target = savePartPath(save.destPath);
1175
+
1176
+ // The entire life of this File - open, write, close - happens inside this one call.
1177
+ // See save_begin for what happens when it does not.
1088
1178
  var f: File | null = null;
1089
1179
  try {
1090
1180
  f = new File(target, "write");
@@ -1098,40 +1188,32 @@ function save_begin(requestId: string, destPath: string, byteCount: number): voi
1098
1188
  return;
1099
1189
  }
1100
1190
  f.eof = 0;
1101
- activeSave = { requestId: requestId, destPath: resolved, expect: Number(byteCount), written: 0, file: f };
1102
- }
1103
-
1104
- /** The app: `save_chunk <requestId> <base64>` - write one slice. */
1105
- function save_chunk(requestId: string, b64: string): void {
1106
- if (!activeSave || activeSave.requestId !== requestId || !activeSave.file) return;
1107
- var bytes = b64decode(b64);
1108
1191
  var SLICE = 4096; // File.writebytes truncates large calls (~16 KB cap) - same as extractPayload
1109
- for (var off = 0; off < bytes.length; off += SLICE) {
1110
- activeSave.file.writebytes(bytes.slice(off, off + SLICE));
1192
+ for (var c = 0; c < save.chunks.length; c++) {
1193
+ var bytes = b64decode(save.chunks[c]);
1194
+ for (var off = 0; off < bytes.length; off += SLICE) {
1195
+ f.writebytes(bytes.slice(off, off + SLICE));
1196
+ }
1197
+ save.written += bytes.length;
1111
1198
  }
1112
- activeSave.written += bytes.length;
1113
- }
1114
-
1115
- /** The app: `save_end <requestId>` - close, verify size, place atomically. */
1116
- function save_end(requestId: string): void {
1117
- if (!activeSave || activeSave.requestId !== requestId) return;
1118
- var save = activeSave;
1119
- if (save.file && save.file.isopen) save.file.close();
1199
+ f.close();
1200
+ save.chunks = []; // the payload is on disk now - do not hold a second copy of it
1120
1201
 
1121
1202
  var onDisk = fileSize(savePartPath(save.destPath));
1203
+ post("m4l-jweb: save wrote " + save.written + " bytes, " + onDisk + " on disk, expected " + save.expect + "\n");
1122
1204
  if (onDisk !== save.expect) {
1123
1205
  outlet(0, "save_error", requestId, "size mismatch: wrote " + onDisk + " bytes, expected " + save.expect);
1124
1206
  activeSave = null;
1125
1207
  return;
1126
1208
  }
1127
- // PHASE 3: place the verified .part over the destination via [maxurl]. NOTE the
1128
- // destPath must be a FLAT filename in the device folder - maxurl (libcurl) and Max's
1129
- // [js] File resolve a subdirectory differently, so `sub/x.wav` writes the .part where
1130
- // File agrees but maxurl cannot reach it, and the place returns -1. Keep saves flat.
1131
- var placeUrl = encodeURI("file:///" + savePartPath(save.destPath));
1132
- post("m4l-jweb: save place " + placeUrl + " -> " + save.destPath + "\n");
1209
+ // PHASE 3: place the verified .part over the destination via [maxurl]. Keep destPath
1210
+ // a FLAT name in the device folder - not because a subdirectory is known to fail (that
1211
+ // was measured against the stray-file bug and never held up), but because flat is the
1212
+ // only shape measured working. See doc/ARCHITECTURE.md before adding an option.
1213
+ var source = placeUrl(savePartPath(save.destPath));
1214
+ post("m4l-jweb: save place " + source + " -> " + save.destPath + "\n");
1133
1215
  var reqDict = new Dict();
1134
- reqDict.set("url", placeUrl);
1216
+ reqDict.set("url", source);
1135
1217
  reqDict.set("http_method", "get");
1136
1218
  reqDict.set("filename_out", save.destPath);
1137
1219
  reqDict.set("overwrite_output_file", 1);
@@ -1143,8 +1225,26 @@ function save_end(requestId: string): void {
1143
1225
  function finishSavePlace(): void {
1144
1226
  if (!activeSave) return;
1145
1227
  var save = activeSave;
1228
+ // What maxurl SAID, before what the disk shows. The place was validated on bytes
1229
+ // alone, so a reply carrying an explicit error was thrown away and the failure
1230
+ // arrived as a bare `-1 bytes at destination` with no cause attached.
1231
+ var reply = new Dict(SAVE_PLACE_RESPONSE_DICT);
1232
+ post(
1233
+ "m4l-jweb: save place reply status " +
1234
+ String(reply.get("status")) +
1235
+ ", error " +
1236
+ String(reply.get("error")) +
1237
+ ", part now " +
1238
+ fileSize(savePartPath(save.destPath)) +
1239
+ " bytes\n",
1240
+ );
1146
1241
  var placed = fileSize(save.destPath);
1147
1242
  if (placed !== save.expect) {
1243
+ // `Couldn't read a file:// file` here means the .part is not where this says it
1244
+ // is: [js] wrote it to a STRAY of the same name earlier on Max's search path, and
1245
+ // fileSize() found that stray rather than nothing. Naming it costs one line and
1246
+ // saves the week it took to find the first time.
1247
+ post("m4l-jweb: the .part is not at that path. Look for a stray m4l-jweb-save.part in Max's own folder and remove it.\n");
1148
1248
  outlet(0, "save_error", save.requestId, "could not place save: " + placed + " bytes at destination, expected " + save.expect);
1149
1249
  activeSave = null;
1150
1250
  return;
package/src/liveapi.ts CHANGED
@@ -131,6 +131,33 @@ function observeProperty(objectPath: string, property: string, selector: string)
131
131
  * Clip I/O
132
132
  * ------------------------------------------------------------------ */
133
133
 
134
+ /**
135
+ * Tell the page which CONTAINER it is in: `track_kind <audio|midi|none>`.
136
+ *
137
+ * A page cannot see its own track, and what a device may do depends on it - an audio clip
138
+ * only lands on an audio track, a MIDI clip only on a MIDI one. Baking the answer into the
139
+ * build instead would mean two builds differing in a fact Live already knows, and a
140
+ * manifest that can lie. Sent at ui_ready, alongside the device folder.
141
+ *
142
+ * `has_audio_input` is the audio-track test and `has_midi_input` the MIDI one; a MIDI
143
+ * track hosting an instrument still answers `has_audio_input 0`, which is the distinction
144
+ * that matters here.
145
+ */
146
+ function sendTrackKind(): void {
147
+ try {
148
+ var track = ownTrack();
149
+ if (!track || !track.id || track.type !== "Track") {
150
+ reply("track_kind", "none");
151
+ return;
152
+ }
153
+ var kind = Number(track.get("has_audio_input")) === 1 ? "audio" : Number(track.get("has_midi_input")) === 1 ? "midi" : "none";
154
+ reply("track_kind", kind);
155
+ post("m4l-jweb: this device is on " + (kind === "none" ? "no reachable track" : "a " + kind + " track") + "\n");
156
+ } catch (e) {
157
+ post("m4l-jweb: track_kind unavailable - " + (e as Error).message + "\n");
158
+ }
159
+ }
160
+
134
161
  interface LiveNote {
135
162
  pitch: number;
136
163
  start_time: number;
@@ -322,6 +349,247 @@ function pickClip(): LiveAPI | null {
322
349
  }
323
350
  }
324
351
 
352
+ /* ------------------------------------------------------------------ *
353
+ * A file on disk INTO an audio clip
354
+ *
355
+ * `create_audio_clip <requestId> <base64 spec>`; `clip_created <requestId>` or
356
+ * `clip_error <requestId> <reason> <msg...>` back. `createAudioClip()` in the bridge is
357
+ * the shaped API, and the spec is base64 JSON because a path and a clip name BOTH
358
+ * contain spaces in practice - two variadic fields cannot be recovered from one flat
359
+ * Max message.
360
+ *
361
+ * WHY THIS IS GATED ON A VERSION READ AND NOT ON A TRY/CATCH. `create_audio_clip` is
362
+ * documented well before Live 12.0.5 and does nothing there - so the failure to design
363
+ * around is a call that raises nothing and leaves the slot empty, which is
364
+ * indistinguishable from a slot that was already empty. Ask Live what it is.
365
+ *
366
+ * WHAT THE TARGET MUST BE: an audio track, not frozen, not being recorded into. An
367
+ * instrument device can never satisfy that from its own view - it sits on a MIDI track,
368
+ * and the highlighted slot is always one of its own, because a device's UI is only on
369
+ * screen while its track is selected. Hence `target: "new"`, and hence the audio-effect
370
+ * flavour of a device that wants to bounce into the track it is already on.
371
+ * ------------------------------------------------------------------ */
372
+
373
+ /** Live's version, read once. null when neither spelling of the getter answered. */
374
+ var liveVersionCache: number[] | null = null;
375
+ var liveVersionAsked = false;
376
+
377
+ /**
378
+ * [major, minor, bugfix], or null.
379
+ *
380
+ * TWO SPELLINGS ARE TRIED, and that is not hedging: the LOM's Application functions are
381
+ * `get_major_version` in Live's own documentation, and this repo has been wrong about a
382
+ * name it never looked up more than once (see MAX-FACTS on names Max does not validate).
383
+ * Whichever answers is posted, so the console says which one this Live has.
384
+ */
385
+ function liveVersion(): number[] | null {
386
+ if (liveVersionAsked) return liveVersionCache;
387
+ liveVersionAsked = true;
388
+ var spellings = [
389
+ ["get_major_version", "get_minor_version", "get_bugfix_version"],
390
+ ["get_version_major", "get_version_minor", "get_version_bugfix"],
391
+ ];
392
+ try {
393
+ var app = new LiveAPI("live_app");
394
+ for (var i = 0; i < spellings.length; i++) {
395
+ try {
396
+ var major = Number(app.call(spellings[i][0]));
397
+ var minor = Number(app.call(spellings[i][1]));
398
+ var bugfix = Number(app.call(spellings[i][2]));
399
+ if (major > 0) {
400
+ liveVersionCache = [major, minor, bugfix];
401
+ post("m4l-jweb: Live " + major + "." + minor + "." + bugfix + " (via " + spellings[i][0] + ")\n");
402
+ return liveVersionCache;
403
+ }
404
+ } catch (inner) {
405
+ /* the other spelling gets its turn */
406
+ }
407
+ }
408
+ post("m4l-jweb: could not read Live's version - neither get_major_version nor get_version_major answered\n");
409
+ } catch (e) {
410
+ post("m4l-jweb: live_app unavailable - " + (e as Error).message + "\n");
411
+ }
412
+ return null;
413
+ }
414
+
415
+ /** Is this Live new enough for create_audio_clip to DO anything? Unknown counts as yes. */
416
+ function hasAudioClipApi(): boolean {
417
+ var v = liveVersion();
418
+ if (!v) return true; // cannot tell: attempt, and report what the slot says afterwards
419
+ if (v[0] > 12) return true;
420
+ if (v[0] < 12) return false;
421
+ if (v[1] > 0) return true;
422
+ return v[2] >= 5;
423
+ }
424
+
425
+ /** base64 -> string, UTF-8 aware. b64decode (core.ts) gives bytes; this reads them. */
426
+ function b64ToString(b64: string): string {
427
+ var bytes = b64decode(b64);
428
+ var out = "";
429
+ for (var i = 0; i < bytes.length; i++) {
430
+ var c = bytes[i];
431
+ if (c < 0x80) out += String.fromCharCode(c);
432
+ else if (c < 0xe0) out += String.fromCharCode(((c & 0x1f) << 6) | (bytes[++i] & 0x3f));
433
+ else if (c < 0xf0) out += String.fromCharCode(((c & 0x0f) << 12) | ((bytes[++i] & 0x3f) << 6) | (bytes[++i] & 0x3f));
434
+ else {
435
+ // Outside the BMP: rebuild the surrogate pair, or a name with an emoji in it
436
+ // silently becomes garbage rather than the character the user typed.
437
+ var cp = ((c & 0x07) << 18) | ((bytes[++i] & 0x3f) << 12) | ((bytes[++i] & 0x3f) << 6) | (bytes[++i] & 0x3f);
438
+ cp -= 0x10000;
439
+ out += String.fromCharCode(0xd800 + (cp >> 10), 0xdc00 + (cp & 0x3ff));
440
+ }
441
+ }
442
+ return out;
443
+ }
444
+
445
+ interface AudioClipSpec {
446
+ path: string;
447
+ target: string;
448
+ track?: number;
449
+ slot?: number;
450
+ name?: string;
451
+ warp?: boolean;
452
+ warpMode?: number;
453
+ loopEnd?: number;
454
+ }
455
+
456
+ function clipError(requestId: string, reason: string, message: string): void {
457
+ post("m4l-jweb: create_audio_clip " + reason + " - " + message + "\n");
458
+ // Fixed arity, never outlet.apply - see emitClipNotes. The reason is its own atom so
459
+ // the app can branch on it without parsing the human sentence beside it.
460
+ outlet(0, "clip_error", requestId, reason, message);
461
+ }
462
+
463
+ function create_audio_clip(requestId: string, b64: string): void {
464
+ var spec: AudioClipSpec;
465
+ try {
466
+ spec = JSON.parse(b64ToString(String(b64)));
467
+ } catch (e) {
468
+ return clipError(String(requestId), "failed", "could not read the request: " + (e as Error).message);
469
+ }
470
+
471
+ if (!hasAudioClipApi()) {
472
+ var v = liveVersion();
473
+ return clipError(
474
+ String(requestId),
475
+ "needs_live_1205",
476
+ "this Live is " + (v ? v.join(".") : "older than 12.0.5") + "; ClipSlot.create_audio_clip does nothing before 12.0.5",
477
+ );
478
+ }
479
+
480
+ // The same resolution a save uses, so a device passes back exactly the name it wrote.
481
+ var path = resolveFetchPath(String(spec.path));
482
+ if (path === null) {
483
+ return clipError(String(requestId), "no_file", "the patcher is not saved, so a relative path resolves against nowhere");
484
+ }
485
+ // Ask the filesystem before asking Live. A missing file makes create_audio_clip fail in
486
+ // a way that reads exactly like a wrong target, and this is one line.
487
+ if (fileSize(path) <= 0) {
488
+ return clipError(String(requestId), "no_file", "nothing on disk at " + path);
489
+ }
490
+
491
+ var slot = resolveClipSlot(spec);
492
+ if (!slot || !slot.id) {
493
+ return clipError(String(requestId), "no_slot", spec.target === "selected" ? "no clip slot is highlighted in Live" : "no clip slot at that index");
494
+ }
495
+
496
+ var track = new LiveAPI(slot.unquotedpath + " canonical_parent");
497
+ if (Number(track.get("has_audio_input")) !== 1) {
498
+ return clipError(
499
+ String(requestId),
500
+ "not_audio_track",
501
+ "the target is not an audio track - an audio clip can only be created on one",
502
+ );
503
+ }
504
+ if (Number(track.get("is_frozen")) === 1) {
505
+ return clipError(String(requestId), "track_busy", "that track is frozen");
506
+ }
507
+ try {
508
+ if (Number(slot.get("is_recording")) === 1) {
509
+ return clipError(String(requestId), "track_busy", "that slot is being recorded into");
510
+ }
511
+ } catch (eRec) {
512
+ /* an older ClipSlot may not carry is_recording; the call below still refuses */
513
+ }
514
+
515
+ post("m4l-jweb: create_audio_clip -> " + slot.unquotedpath + " <- " + path + "\n");
516
+ try {
517
+ slot.call("create_audio_clip", path);
518
+ } catch (eCall) {
519
+ return clipError(String(requestId), "failed", (eCall as Error).message);
520
+ }
521
+
522
+ // The slot is the evidence, not the call. A LOM method that declines prints to the Max
523
+ // window and returns nothing, so "it did not throw" is not "there is a clip".
524
+ if (Number(slot.get("has_clip")) !== 1) {
525
+ return clipError(String(requestId), "failed", "the call ran and the slot is still empty - see the Max window for Live's own error");
526
+ }
527
+
528
+ setupAudioClip(new LiveAPI(slot.unquotedpath + " clip"), spec);
529
+ post("m4l-jweb: created an audio clip in " + slot.unquotedpath + "\n");
530
+ outlet(0, "clip_created", requestId);
531
+ }
532
+
533
+ /** The slot a spec names, creating a track for `target: "new"`. */
534
+ function resolveClipSlot(spec: AudioClipSpec): LiveAPI | null {
535
+ try {
536
+ if (spec.target === "new") {
537
+ var song = new LiveAPI("live_set");
538
+ // -1 appends. The new track is therefore the last one, and asking for the count
539
+ // AFTER the call is what identifies it - there is no return value to trust.
540
+ song.call("create_audio_track", -1);
541
+ var last = song.getcount("tracks") - 1;
542
+ if (last < 0) return null;
543
+ // Select it, so the bounce is where the user is looking rather than somewhere
544
+ // off the right edge of a wide set.
545
+ try {
546
+ new LiveAPI("live_set view").set("selected_track", "id " + new LiveAPI("live_set tracks " + last).id);
547
+ } catch (eSel) {
548
+ /* cosmetic */
549
+ }
550
+ return new LiveAPI("live_set tracks " + last + " clip_slots 0");
551
+ }
552
+ if (spec.target === "track") {
553
+ return new LiveAPI("live_set tracks " + Number(spec.track) + " clip_slots " + Number(spec.slot));
554
+ }
555
+ return new LiveAPI("live_set view highlighted_clip_slot");
556
+ } catch (e) {
557
+ post("m4l-jweb: create_audio_clip target error - " + (e as Error).message + "\n");
558
+ return null;
559
+ }
560
+ }
561
+
562
+ /**
563
+ * Name it, warp it, and give it the loop the render actually has.
564
+ *
565
+ * Every write is individually guarded and NONE of them fails the request: the clip is
566
+ * already in the slot by the time this runs, and a bounce that landed under the wrong
567
+ * name is not a bounce that failed. What did not take is posted.
568
+ *
569
+ * ORDER MATTERS. `warping` comes first - loop points on an unwarped clip are in seconds
570
+ * of sample time, not beats - and the END markers are widened before the loop is set,
571
+ * because Live clamps a loop to the markers it currently has.
572
+ */
573
+ function setupAudioClip(clip: LiveAPI, spec: AudioClipSpec): void {
574
+ if (!clip || !clip.id) return;
575
+ var writes: unknown[][] = [];
576
+ if (spec.warp !== false) writes.push(["warping", 1]);
577
+ if (spec.warpMode !== undefined) writes.push(["warp_mode", Number(spec.warpMode)]);
578
+ if (spec.loopEnd !== undefined && Number(spec.loopEnd) > 0) {
579
+ writes.push(["end_marker", Number(spec.loopEnd)], ["loop_end", Number(spec.loopEnd)], ["start_marker", 0], ["loop_start", 0], ["looping", 1]);
580
+ }
581
+ // Last, so a name is not lost to an exception thrown by a loop point.
582
+ if (spec.name) writes.push(["name", String(spec.name)]);
583
+
584
+ for (var i = 0; i < writes.length; i++) {
585
+ try {
586
+ clip.set(String(writes[i][0]), writes[i][1]);
587
+ } catch (e) {
588
+ post("m4l-jweb: clip " + writes[i][0] + " = " + writes[i][1] + " did not take - " + (e as Error).message + "\n");
589
+ }
590
+ }
591
+ }
592
+
325
593
  function getNotes(clip: LiveAPI, loopEnd: number): LiveNote[] | null {
326
594
  // Live 11+: get_notes_extended returns a JSON string.
327
595
  try {
package/src/max.d.ts CHANGED
@@ -203,6 +203,14 @@ declare const EXTRA_PAYLOAD_B64: string[][] | undefined;
203
203
  */
204
204
  declare const WATCH_SPECS: { key: string; path: string; property: string }[] | undefined;
205
205
 
206
+ /**
207
+ * Injected by @m4l-jweb/build from the device's `defineFiles()`: what this device
208
+ * does with disk. Undefined for a device that declares none - which is also a
209
+ * device with no [maxurl], because the declaration is what derives the `download`
210
+ * chain (files.mjs).
211
+ */
212
+ declare const FILES_SPEC: { saves: boolean; fetches: boolean; tellPage: boolean } | undefined;
213
+
206
214
  /**
207
215
  * Injected by @m4l-jweb/build for every window declared with `site:` - window id
208
216
  * -> the path of its index.html RELATIVE to the device folder.