@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m4l-jweb/wrapper",
3
- "version": "0.9.0",
3
+ "version": "0.9.5",
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
@@ -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
- /** The LiveAPI for the track this device sits on. */
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
- return new LiveAPI("this_device canonical_parent");
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");