@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.
- package/package.json +1 -1
- package/src/index.ts +143 -0
package/package.json
CHANGED
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
|
* ------------------------------------------------------------------ */
|