@m4l-jweb/wrapper 0.9.0 → 0.9.5
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 +141 -0
- package/src/liveapi.ts +40 -2
package/package.json
CHANGED
package/src/core.ts
CHANGED
|
@@ -753,6 +753,110 @@ function placeFetch(fetched: ActiveFetch): void {
|
|
|
753
753
|
outlet(1, "maxurl", "dictionary", reqDict.name);
|
|
754
754
|
}
|
|
755
755
|
|
|
756
|
+
/* ------------------------------------------------------------------ *
|
|
757
|
+
* Save-to-disk
|
|
758
|
+
*
|
|
759
|
+
* The inverse of fetch-to-disk: the APP has the bytes (a rendered WAV), and hands
|
|
760
|
+
* them over base64 in slices (`saveToFile()` in the bridge). We writebytes them into
|
|
761
|
+
* a `<dest>.part` file exactly as extractPayload does - same 16 KB truncation cap, same
|
|
762
|
+
* byte-count verification - then reuse the fetch machinery's PHASE 3 to atomically place
|
|
763
|
+
* `.part` over the destination through [maxurl]. The destination is never touched until
|
|
764
|
+
* the .part has been written AND verified. Requires the `download` chain (for [maxurl]).
|
|
765
|
+
* ------------------------------------------------------------------ */
|
|
766
|
+
|
|
767
|
+
var SAVE_PLACE_RESPONSE_DICT = "m4ljweb_save_place_response";
|
|
768
|
+
|
|
769
|
+
interface ActiveSave {
|
|
770
|
+
requestId: string;
|
|
771
|
+
/** Absolute, resolved destination. */
|
|
772
|
+
destPath: string;
|
|
773
|
+
/** Bytes the app promised in save_begin - the size the .part must match. */
|
|
774
|
+
expect: number;
|
|
775
|
+
/** Bytes actually written so far, for the verify. */
|
|
776
|
+
written: number;
|
|
777
|
+
/** The open .part file, held across the chunk messages. */
|
|
778
|
+
file: File | null;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
var activeSave: ActiveSave | null = null;
|
|
782
|
+
|
|
783
|
+
/** The app: `save_begin <requestId> <destPath> <byteCount>` - open the .part file. */
|
|
784
|
+
function save_begin(requestId: string, destPath: string, byteCount: number): void {
|
|
785
|
+
// A save already in flight is abandoned - its .part is left for the next place/overwrite.
|
|
786
|
+
if (activeSave && activeSave.file && activeSave.file.isopen) activeSave.file.close();
|
|
787
|
+
|
|
788
|
+
var resolved = resolveFetchPath(destPath);
|
|
789
|
+
var target = partPath(resolved);
|
|
790
|
+
var f: File | null = null;
|
|
791
|
+
try {
|
|
792
|
+
f = new File(target, "write");
|
|
793
|
+
if (!f.isopen) f.open();
|
|
794
|
+
} catch (e) {
|
|
795
|
+
f = null;
|
|
796
|
+
}
|
|
797
|
+
if (!f || !f.isopen) {
|
|
798
|
+
outlet(0, "save_error", requestId, "cannot open " + target);
|
|
799
|
+
activeSave = null;
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
f.eof = 0;
|
|
803
|
+
activeSave = { requestId: requestId, destPath: resolved, expect: Number(byteCount), written: 0, file: f };
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/** The app: `save_chunk <requestId> <base64>` - write one slice. */
|
|
807
|
+
function save_chunk(requestId: string, b64: string): void {
|
|
808
|
+
if (!activeSave || activeSave.requestId !== requestId || !activeSave.file) return;
|
|
809
|
+
var bytes = b64decode(b64);
|
|
810
|
+
var SLICE = 4096; // File.writebytes truncates large calls (~16 KB cap) - same as extractPayload
|
|
811
|
+
for (var off = 0; off < bytes.length; off += SLICE) {
|
|
812
|
+
activeSave.file.writebytes(bytes.slice(off, off + SLICE));
|
|
813
|
+
}
|
|
814
|
+
activeSave.written += bytes.length;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/** The app: `save_end <requestId>` - close, verify size, place atomically. */
|
|
818
|
+
function save_end(requestId: string): void {
|
|
819
|
+
if (!activeSave || activeSave.requestId !== requestId) return;
|
|
820
|
+
var save = activeSave;
|
|
821
|
+
if (save.file && save.file.isopen) save.file.close();
|
|
822
|
+
|
|
823
|
+
var onDisk = fileSize(partPath(save.destPath));
|
|
824
|
+
if (onDisk !== save.expect) {
|
|
825
|
+
outlet(0, "save_error", requestId, "size mismatch: wrote " + onDisk + " bytes, expected " + save.expect);
|
|
826
|
+
activeSave = null;
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
// PHASE 3: place the verified .part over the destination via [maxurl]. NOTE the
|
|
830
|
+
// destPath must be a FLAT filename in the device folder - maxurl (libcurl) and Max's
|
|
831
|
+
// [js] File resolve a subdirectory differently, so `sub/x.wav` writes the .part where
|
|
832
|
+
// File agrees but maxurl cannot reach it, and the place returns -1. Keep saves flat.
|
|
833
|
+
var placeUrl = encodeURI("file:///" + partPath(save.destPath));
|
|
834
|
+
post("m4l-jweb: save place " + placeUrl + " -> " + save.destPath + "\n");
|
|
835
|
+
var reqDict = new Dict();
|
|
836
|
+
reqDict.set("url", placeUrl);
|
|
837
|
+
reqDict.set("http_method", "get");
|
|
838
|
+
reqDict.set("filename_out", save.destPath);
|
|
839
|
+
reqDict.set("overwrite_output_file", 1);
|
|
840
|
+
reqDict.set("response_dict", SAVE_PLACE_RESPONSE_DICT);
|
|
841
|
+
outlet(1, "maxurl", "dictionary", reqDict.name);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/** maxurl finished the save's place copy. Validate on bytes, like finishPlace. */
|
|
845
|
+
function finishSavePlace(): void {
|
|
846
|
+
if (!activeSave) return;
|
|
847
|
+
var save = activeSave;
|
|
848
|
+
var placed = fileSize(save.destPath);
|
|
849
|
+
if (placed !== save.expect) {
|
|
850
|
+
outlet(0, "save_error", save.requestId, "could not place save: " + placed + " bytes at destination, expected " + save.expect);
|
|
851
|
+
activeSave = null;
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
truncate(partPath(save.destPath)); // [js] cannot delete; zero it
|
|
855
|
+
post("m4l-jweb: saved " + placed + " bytes to " + save.destPath + "\n");
|
|
856
|
+
outlet(0, "save_done", save.requestId, placed);
|
|
857
|
+
activeSave = null;
|
|
858
|
+
}
|
|
859
|
+
|
|
756
860
|
/* ------------------------------------------------------------------ *
|
|
757
861
|
* Samples - the `samples` chain
|
|
758
862
|
* ------------------------------------------------------------------ */
|
|
@@ -806,6 +910,41 @@ function buffer_load(slot: string, path: string): void {
|
|
|
806
910
|
outlet(1, "buffer_replace", slot, resolved);
|
|
807
911
|
}
|
|
808
912
|
|
|
913
|
+
/**
|
|
914
|
+
* The app: `render_load <slot> <path> <lengthBeats>` - read a rendered WAV into a
|
|
915
|
+
* renderplay slot's [buffer~].
|
|
916
|
+
*
|
|
917
|
+
* Same path problem, same fix as buffer_load: a relative path is resolved against the
|
|
918
|
+
* device folder (where saveToFile wrote it), and the resolved path - which contains
|
|
919
|
+
* spaces on a normal Live install - is handed out of [js] as ONE symbol via the aux
|
|
920
|
+
* outlet, so `replace` sees a single file rather than three atoms. The loop length rides
|
|
921
|
+
* alongside as `render_len` for the chain's transport-lock detector.
|
|
922
|
+
*/
|
|
923
|
+
function render_load(slot: string, path: string, lengthBeats: number): void {
|
|
924
|
+
var resolved = resolveFetchPath(path);
|
|
925
|
+
var f: File | null = null;
|
|
926
|
+
try {
|
|
927
|
+
f = new File(resolved, "read");
|
|
928
|
+
} catch (e) {
|
|
929
|
+
f = null;
|
|
930
|
+
}
|
|
931
|
+
if (!f || !f.isopen) {
|
|
932
|
+
outlet(0, "render_error", slot, "no file at " + resolved);
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
var bytes = f.eof;
|
|
936
|
+
f.close();
|
|
937
|
+
if (!bytes) {
|
|
938
|
+
outlet(0, "render_error", slot, "empty file at " + resolved);
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
post("m4l-jweb: render_load " + slot + " -> " + resolved + " (" + lengthBeats + " beats)\n");
|
|
942
|
+
// Aux outlet (1): the renderplay chain routes render_replace and render_len off it,
|
|
943
|
+
// the same way the download chain routes maxurl. Outlet 0 belongs to [jweb].
|
|
944
|
+
outlet(1, "render_len", slot, lengthBeats);
|
|
945
|
+
outlet(1, "render_replace", slot, resolved);
|
|
946
|
+
}
|
|
947
|
+
|
|
809
948
|
/** The app: `fetch_to_file <requestId> <url> <destPath>`. */
|
|
810
949
|
function fetch_to_file(requestId: string, url: string, destPath: string): void {
|
|
811
950
|
fetchQueue.push({ requestId: requestId, url: url, destPath: destPath, partBytes: 0 });
|
|
@@ -824,6 +963,8 @@ function maxurl_done(msgType: string, dictName: string): void {
|
|
|
824
963
|
// hook, a request the device made lands here, finds no `currentFetch`, and is
|
|
825
964
|
// dropped in silence - which looks exactly like maxurl never answering.
|
|
826
965
|
if (typeof onMaxurlReply === "function" && onMaxurlReply(dictName)) return;
|
|
966
|
+
// A save's place copy comes back on its own dict, and has no `currentFetch` behind it.
|
|
967
|
+
if (dictName === SAVE_PLACE_RESPONSE_DICT) return finishSavePlace();
|
|
827
968
|
if (!currentFetch) return;
|
|
828
969
|
var fetched = currentFetch;
|
|
829
970
|
|
package/src/liveapi.ts
CHANGED
|
@@ -139,9 +139,26 @@ interface LiveNote {
|
|
|
139
139
|
mute?: number;
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
-
/**
|
|
142
|
+
/**
|
|
143
|
+
* The LiveAPI for the TRACK this device sits on - clip I/O belongs to the track.
|
|
144
|
+
*
|
|
145
|
+
* `this_device canonical_parent` is the track ONLY when the device sits directly on
|
|
146
|
+
* it. Inside a Rack it is the CHAIN the device is in, and a Chain has no `clip_slots` -
|
|
147
|
+
* so `getcount("clip_slots")` on it throws "invalid property name" once a second (the
|
|
148
|
+
* clip-availability poll), and clip read/write silently target the wrong object. So
|
|
149
|
+
* climb the `canonical_parent` chain until a Track is reached, which handles a device
|
|
150
|
+
* on a bare track (no climb), in a rack (one hop), and in a nested rack (several).
|
|
151
|
+
*/
|
|
143
152
|
function ownTrack(): LiveAPI {
|
|
144
|
-
|
|
153
|
+
var api = new LiveAPI("this_device canonical_parent");
|
|
154
|
+
var guard = 0;
|
|
155
|
+
// id 0 is an unresolved path; stop rather than build "... canonical_parent" onto
|
|
156
|
+
// nothing. The guard is a backstop against a parent cycle the LOM should never have.
|
|
157
|
+
while (api && api.id && api.type !== "Track" && guard < 12) {
|
|
158
|
+
api = new LiveAPI(api.unquotedpath + " canonical_parent");
|
|
159
|
+
guard++;
|
|
160
|
+
}
|
|
161
|
+
return api;
|
|
145
162
|
}
|
|
146
163
|
|
|
147
164
|
/**
|
|
@@ -154,9 +171,20 @@ function write_clip(): void {
|
|
|
154
171
|
var lengthBeats = a[0];
|
|
155
172
|
var n = Number(a[1]);
|
|
156
173
|
|
|
174
|
+
// No reachable Track is a STRUCTURAL failure (clip I/O is impossible here), distinct
|
|
175
|
+
// from a track whose slots are all full. The UI disables clip export on the former
|
|
176
|
+
// and only reports the latter, so `write_error` says which.
|
|
177
|
+
var track = ownTrack();
|
|
178
|
+
if (!track || !track.id || track.type !== "Track") {
|
|
179
|
+
post("m4l-jweb: write_clip - no reachable track (device not on a track?)\n");
|
|
180
|
+
outlet(0, "write_error", "no_track");
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
157
184
|
var slot = firstEmptySlot();
|
|
158
185
|
if (!slot) {
|
|
159
186
|
post("m4l-jweb: no empty clip slot on this track\n");
|
|
187
|
+
outlet(0, "write_error", "no_slot");
|
|
160
188
|
return;
|
|
161
189
|
}
|
|
162
190
|
slot.call("create_clip", lengthBeats);
|
|
@@ -177,6 +205,7 @@ function write_clip(): void {
|
|
|
177
205
|
clip.call("add_new_notes", { notes: notes });
|
|
178
206
|
} catch (e) {
|
|
179
207
|
post("m4l-jweb: add_new_notes failed - " + (e as Error).message + "\n");
|
|
208
|
+
outlet(0, "write_error", "add_failed");
|
|
180
209
|
return;
|
|
181
210
|
}
|
|
182
211
|
post("m4l-jweb: wrote " + n + " notes over " + lengthBeats + " beats\n");
|
|
@@ -207,6 +236,15 @@ function firstEmptySlot(): LiveAPI | null {
|
|
|
207
236
|
* happens to be. For the selection-driven case use read_selected_clip below.
|
|
208
237
|
*/
|
|
209
238
|
function read_notes(): void {
|
|
239
|
+
// Structural failure (no reachable track) is reported distinctly from "a track with
|
|
240
|
+
// no clip", so the UI can disable clip import where it is impossible and merely say
|
|
241
|
+
// "no clip" where it is not.
|
|
242
|
+
var track = ownTrack();
|
|
243
|
+
if (!track || !track.id || track.type !== "Track") {
|
|
244
|
+
post("m4l-jweb: read_notes - no reachable track (device not on a track?)\n");
|
|
245
|
+
outlet(0, "read_error", "no_track");
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
210
248
|
var clip = pickClip();
|
|
211
249
|
if (!clip) {
|
|
212
250
|
post("m4l-jweb: no clip found on this track\n");
|