@m4l-jweb/bridge 0.9.1 → 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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/index.ts +143 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m4l-jweb/bridge",
3
- "version": "0.9.1",
3
+ "version": "0.9.5",
4
4
  "description": "m4l-jweb: the browser-side bridge connecting a device's web UI to Max for Live.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/index.ts CHANGED
@@ -172,6 +172,12 @@ export const CHAIN_IN = {
172
172
  buffer_ready: "buffer_ready",
173
173
  /** samples -> UI: `buffer_error <slot> <msg>` - there was no readable file at that path. */
174
174
  buffer_error: "buffer_error",
175
+ /** download -> UI: `save_done <requestId> <bytes>` - the file is written and verified. */
176
+ save_done: "save_done",
177
+ /** download -> UI: `save_error <requestId> <msg>` - the write or atomic place failed. */
178
+ save_error: "save_error",
179
+ /** renderplay -> UI: `render_ready <slot>` - the WAV finished loading into that slot's buffer~. */
180
+ render_ready: "render_ready",
175
181
  } as const;
176
182
 
177
183
  /** Selectors the packaged chains RECEIVE from the UI. Requires the `midiout` chain. */
@@ -194,6 +200,20 @@ export const CHAIN_OUT = {
194
200
  remote_bind: "remote_bind",
195
201
  /** UI -> remote: `remote_val <slot> <value>` - the next value for that slot, ramped. */
196
202
  remote_val: "remote_val",
203
+ /** UI -> download: `save_begin <requestId> <destPath> <byteCount>` - open a .part file. */
204
+ save_begin: "save_begin",
205
+ /** UI -> download: `save_chunk <requestId> <base64>` - one slice of the payload. */
206
+ save_chunk: "save_chunk",
207
+ /** UI -> download: `save_end <requestId>` - close, verify size, atomic place. */
208
+ save_end: "save_end",
209
+ /** UI -> renderplay: `render_load <slot> <path> <lengthBeats>` - read a WAV into a slot's buffer~. */
210
+ render_load: "render_load",
211
+ /** UI -> renderplay: `render_arm <slot>` - swap playback to that slot at the next loop boundary. */
212
+ render_arm: "render_arm",
213
+ /** UI -> renderplay: `render_sync <slot> <positionMs>` - relocate a slot's loop to a transport phase. */
214
+ render_sync: "render_sync",
215
+ /** UI -> renderplay: `render_stop` - fade out and stop. */
216
+ render_stop: "render_stop",
197
217
  } as const;
198
218
 
199
219
  /**
@@ -349,6 +369,129 @@ export function fetchToFile(url: string, destPath: string, onProgress?: (downloa
349
369
  });
350
370
  }
351
371
 
372
+ /**
373
+ * Base64-encode raw bytes in slices, so a multi-megabyte buffer does not blow the
374
+ * call stack (`String.fromCharCode(...hugeArray)` does). btoa wants a binary string;
375
+ * we build it 32 KB at a time. No newlines, so the result is one safe Max symbol
376
+ * once re-sliced by SAVE_B64_CHUNK.
377
+ */
378
+ export function encodeBytesBase64(bytes: ArrayBuffer): string {
379
+ const view = new Uint8Array(bytes);
380
+ const SLICE = 0x8000; // 32 KB per fromCharCode call
381
+ let binary = "";
382
+ for (let o = 0; o < view.length; o += SLICE) {
383
+ binary += String.fromCharCode(...view.subarray(o, o + SLICE));
384
+ }
385
+ return btoa(binary);
386
+ }
387
+
388
+ const SAVE_B64_CHUNK = 8192; // base64 chars per message; well under Max atom limits
389
+
390
+ const saveResolvers = new Map<string, { resolve: (v: { bytes: number }) => void; reject: (e: Error) => void }>();
391
+ let saveBound = false;
392
+
393
+ /**
394
+ * Write raw bytes straight to disk via the `download` chain's [js] wrapper.
395
+ *
396
+ * The inverse of `fetchToFile`: instead of Max pulling a URL, the UI hands the bytes
397
+ * over base64 in slices and the wrapper writes them to a `.part` file, then atomically
398
+ * places it at `destPath` via [maxurl] (the same move fetchToFile phase 2 uses). The
399
+ * bytes travel base64 because Max splits messages on spaces/commas and base64 has
400
+ * neither. Requires the `download` chain in the device manifest.
401
+ *
402
+ * @param destPath Absolute path (or device-relative, resolved wrapper-side) to write.
403
+ * @param bytes The payload. A per-cycle WAV is ~350 KB => ~60 messages.
404
+ * @returns Resolves with the verified byte count once the file is placed.
405
+ */
406
+ export function saveToFile(destPath: string, bytes: ArrayBuffer): Promise<{ bytes: number }> {
407
+ if (!saveBound) {
408
+ saveBound = true;
409
+ bindInlet(CHAIN_IN.save_done, (id, n) => {
410
+ const p = saveResolvers.get(String(id));
411
+ if (p) {
412
+ p.resolve({ bytes: Number(n) });
413
+ saveResolvers.delete(String(id));
414
+ }
415
+ });
416
+ bindInlet(CHAIN_IN.save_error, (id, msg) => {
417
+ const p = saveResolvers.get(String(id));
418
+ if (p) {
419
+ p.reject(new Error(String(msg)));
420
+ saveResolvers.delete(String(id));
421
+ }
422
+ });
423
+ }
424
+
425
+ return new Promise((resolve, reject) => {
426
+ const requestId = Math.random().toString(36).substring(2, 10);
427
+ saveResolvers.set(requestId, { resolve, reject });
428
+ const b64 = encodeBytesBase64(bytes);
429
+ outlet(CHAIN_OUT.save_begin, requestId, destPath, bytes.byteLength);
430
+ for (let o = 0; o < b64.length; o += SAVE_B64_CHUNK) {
431
+ outlet(CHAIN_OUT.save_chunk, requestId, b64.slice(o, o + SAVE_B64_CHUNK));
432
+ }
433
+ outlet(CHAIN_OUT.save_end, requestId);
434
+ });
435
+ }
436
+
437
+ /* ------------------------------------------------------------------ *
438
+ * Renderplay - the `renderplay` chain (transport-locked WAV loop playback)
439
+ * ------------------------------------------------------------------ */
440
+
441
+ let renderReadyBound = false;
442
+ const renderReadyHandlers = new Set<(slot: string) => void>();
443
+
444
+ /**
445
+ * Load a rendered WAV from disk into one of the renderplay slots' [buffer~].
446
+ *
447
+ * The path is resolved wrapper-side (relative -> device folder, same as saveToFile
448
+ * wrote it) and handed to `replace` as one symbol, so spaces in the Live library path
449
+ * survive. When the buffer finishes reading, the chain replies `render_ready <slot>`;
450
+ * bind it with `onRenderReady`. `lengthBeats` is how many Live beats one loop of this
451
+ * WAV spans - the chain uses it to lock the loop to the transport.
452
+ *
453
+ * Requires the `renderplay` chain (and `download`, for the [maxurl] the wrapper uses to
454
+ * resolve the path). Pair the slot names with the device manifest's `renderSlots`.
455
+ */
456
+ export function renderLoad(slot: string, path: string, lengthBeats: number): void {
457
+ outlet(CHAIN_OUT.render_load, slot, path, lengthBeats);
458
+ }
459
+
460
+ /** At the NEXT loop boundary, crossfade playback to `slot` and adopt its loop length. */
461
+ export function renderArm(slot: string): void {
462
+ outlet(CHAIN_OUT.render_arm, slot);
463
+ }
464
+
465
+ /**
466
+ * Relocate a slot's loop to a transport phase: `positionMs` is a playback position in
467
+ * milliseconds, dropped straight into that slot's [groove~] left inlet.
468
+ *
469
+ * The conductor sends this on (re)start / relocate to pin the audio to Live's exact
470
+ * transport phase - `(beats mod loopBeats) / loopBeats * loopSeconds * 1000`. Because the
471
+ * WAV is rendered to be exactly `loopBeats` long at the current tempo, a rate-1 `@loop`
472
+ * then HOLDS the lock with no per-loop re-sync (Live's transport and the audio share one
473
+ * clock). Sync both slots to keep the double buffer mutually phase-aligned.
474
+ */
475
+ export function renderSync(slot: string, positionMs: number): void {
476
+ outlet(CHAIN_OUT.render_sync, slot, positionMs);
477
+ }
478
+
479
+ /** Fade out and stop renderplay. */
480
+ export function renderStop(): void {
481
+ outlet(CHAIN_OUT.render_stop);
482
+ }
483
+
484
+ /** Called with the slot name each time a `render_load` finishes reading into its buffer~. */
485
+ export function onRenderReady(fn: (slot: string) => void): void {
486
+ renderReadyHandlers.add(fn);
487
+ if (!renderReadyBound) {
488
+ renderReadyBound = true;
489
+ bindInlet(CHAIN_IN.render_ready, (slot) => {
490
+ for (const h of renderReadyHandlers) h(String(slot));
491
+ });
492
+ }
493
+ }
494
+
352
495
  /* ------------------------------------------------------------------ *
353
496
  * Samples - the `samples` chain
354
497
  * ------------------------------------------------------------------ */