@m4l-jweb/wrapper 0.9.1 → 0.9.9

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/core.ts +90 -37
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m4l-jweb/wrapper",
3
- "version": "0.9.1",
3
+ "version": "0.9.9",
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
@@ -754,56 +754,107 @@ function placeFetch(fetched: ActiveFetch): void {
754
754
  }
755
755
 
756
756
  /* ------------------------------------------------------------------ *
757
- * Samples - the `samples` chain
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]).
758
765
  * ------------------------------------------------------------------ */
759
766
 
760
- /**
761
- * The app: `buffer_load <slot> <path>` - read a file into that slot's [buffer~].
762
- *
763
- * WHY THIS GOES THROUGH [js] AT ALL, when the chain could route it straight to the
764
- * buffer: because the path the app wrote is not a path [buffer~] can open, and it
765
- * fails in the two ways this file exists to prevent.
766
- *
767
- * A RELATIVE path is resolved against the device's folder - the same resolution
768
- * `fetch_to_file` does, and it has to be the same one or the app downloads a file to
769
- * one place and loads it from another. [buffer~] does not resolve it that way: a bare
770
- * name is looked up in MAX's SEARCH PATH, which does not contain the device's folder,
771
- * so `preview.wav` - freshly downloaded, right there next to the .amxd - reports
772
- * "can't open" and the promise times out.
773
- *
774
- * ...and the resolved path CONTAINS SPACES on a normal Live install ("Ableton
775
- * Library", "Max For Live"). A message travelling through the patcher as text would
776
- * split there into three atoms and [buffer~] would open the first one. Handed out of
777
- * [js] as a string, it stays ONE symbol all the way to `replace`.
778
- *
779
- * The file is checked before the buffer is asked for it, because a missing file makes
780
- * [buffer~] print to the Max console and stay silent - there is no failure bang to
781
- * bind to, and the app would learn nothing until the timeout. `buffer_error` says so
782
- * at once.
783
- */
784
- function buffer_load(slot: string, path: string): void {
785
- var resolved = resolveFetchPath(path); // the same folder the download wrote to
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);
786
790
  var f: File | null = null;
787
791
  try {
788
- f = new File(resolved, "read");
792
+ f = new File(target, "write");
793
+ if (!f.isopen) f.open();
789
794
  } catch (e) {
790
795
  f = null;
791
796
  }
792
797
  if (!f || !f.isopen) {
793
- outlet(0, "buffer_error", slot, "no file at " + resolved);
798
+ outlet(0, "save_error", requestId, "cannot open " + target);
799
+ activeSave = null;
794
800
  return;
795
801
  }
796
- var bytes = f.eof;
797
- f.close();
798
- if (!bytes) {
799
- outlet(0, "buffer_error", slot, "empty file at " + resolved);
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;
800
827
  return;
801
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
+ }
802
843
 
803
- // Outlet 1 is the aux outlet; the `samples` chain routes `buffer_replace` off it,
804
- // exactly as the `download` chain routes `maxurl`. Outlet 0 belongs to [jweb].
805
- post("m4l-jweb: buffer_load " + slot + " -> " + resolved + "\n");
806
- outlet(1, "buffer_replace", slot, resolved);
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;
807
858
  }
808
859
 
809
860
  /** The app: `fetch_to_file <requestId> <url> <destPath>`. */
@@ -824,6 +875,8 @@ function maxurl_done(msgType: string, dictName: string): void {
824
875
  // hook, a request the device made lands here, finds no `currentFetch`, and is
825
876
  // dropped in silence - which looks exactly like maxurl never answering.
826
877
  if (typeof onMaxurlReply === "function" && onMaxurlReply(dictName)) return;
878
+ // A save's place copy comes back on its own dict, and has no `currentFetch` behind it.
879
+ if (dictName === SAVE_PLACE_RESPONSE_DICT) return finishSavePlace();
827
880
  if (!currentFetch) return;
828
881
  var fetched = currentFetch;
829
882