@m4l-jweb/wrapper 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/core.ts +136 -36
- package/src/liveapi.ts +268 -0
- package/src/max.d.ts +8 -0
package/package.json
CHANGED
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 :
|
|
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
|
-
|
|
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",
|
|
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
|
|
1129
|
+
/** Bytes actually written, once save_end has written them. */
|
|
1074
1130
|
written: number;
|
|
1075
|
-
/** The
|
|
1076
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
1110
|
-
|
|
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
|
-
|
|
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].
|
|
1128
|
-
//
|
|
1129
|
-
//
|
|
1130
|
-
//
|
|
1131
|
-
var
|
|
1132
|
-
post("m4l-jweb: save place " +
|
|
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",
|
|
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.
|