@m4l-jweb/bridge 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/index.ts +107 -145
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.9",
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
@@ -168,10 +168,10 @@ export const CHAIN_IN = {
168
168
  fetch_error: "fetch_error",
169
169
  /** download -> UI: `fetch_progress <requestId> <downloaded> <total>`. */
170
170
  fetch_progress: "fetch_progress",
171
- /** samples -> UI: `buffer_ready <slot> <sampleRate> <ms> <channels>` - what actually loaded. */
172
- buffer_ready: "buffer_ready",
173
- /** samples -> UI: `buffer_error <slot> <msg>` - there was no readable file at that path. */
174
- buffer_error: "buffer_error",
171
+ /** download -> UI: `save_done <requestId> <bytes>` - the file is written and verified. */
172
+ save_done: "save_done",
173
+ /** download -> UI: `save_error <requestId> <msg>` - the write or atomic place failed. */
174
+ save_error: "save_error",
175
175
  } as const;
176
176
 
177
177
  /** Selectors the packaged chains RECEIVE from the UI. Requires the `midiout` chain. */
@@ -182,18 +182,16 @@ export const CHAIN_OUT = {
182
182
  flush: "flush",
183
183
  /** UI -> download: `fetch_to_file <requestId> <url> <destPath>`. */
184
184
  fetch_to_file: "fetch_to_file",
185
- /** UI -> samples: `buffer_load <slot> <path>` - read a file into that slot's [buffer~]. */
186
- buffer_load: "buffer_load",
187
- /** UI -> samples: `buffer_play <slot>` - preview it through the track. */
188
- buffer_play: "buffer_play",
189
- /** UI -> samples: `buffer_stop` - stop the preview. */
190
- buffer_stop: "buffer_stop",
191
- /** UI -> instrument: `voice_play <pitch> <vel> <durMs> <channels>` - play one poly~ voice. */
192
- voice_play: "voice_play",
193
185
  /** UI -> remote: `remote_bind <slot> <lomId>` - point a live.remote~ at a Live parameter. */
194
186
  remote_bind: "remote_bind",
195
187
  /** UI -> remote: `remote_val <slot> <value>` - the next value for that slot, ramped. */
196
188
  remote_val: "remote_val",
189
+ /** UI -> download: `save_begin <requestId> <destPath> <byteCount>` - open a .part file. */
190
+ save_begin: "save_begin",
191
+ /** UI -> download: `save_chunk <requestId> <base64>` - one slice of the payload. */
192
+ save_chunk: "save_chunk",
193
+ /** UI -> download: `save_end <requestId>` - close, verify size, atomic place. */
194
+ save_end: "save_end",
197
195
  } as const;
198
196
 
199
197
  /**
@@ -281,15 +279,56 @@ export function sendNote(note: Note): void {
281
279
  outlet(CHAIN_OUT.midinote, note.pitch, note.velocity, note.durationMs, note.channel ?? 1, note.delayMs ?? 0);
282
280
  }
283
281
 
284
- /** Bind incoming MIDI. Note-offs are filtered out: `makenote` already owns the release. */
285
- export function onNote(fn: (pitch: number, velocity: number) => void): void {
282
+ /* ------------------------------------------------------------------ *
283
+ * Incoming MIDI notes
284
+ *
285
+ * `bindInlet` keeps ONE handler per selector - a second bind REPLACES the first. So
286
+ * `onNote` and `onNoteOff` must not each call it: whichever ran last would win and the
287
+ * other's events would vanish. (They did, and a synth that bound both went deaf to
288
+ * every note-on.) Instead ONE `notein` binding fans out to two subscriber sets, which
289
+ * also means two `onNote` callers no longer silently clobber each other.
290
+ * ------------------------------------------------------------------ */
291
+
292
+ const noteOnHandlers = new Set<(pitch: number, velocity: number) => void>();
293
+ const noteOffHandlers = new Set<(pitch: number) => void>();
294
+ let noteinBound = false;
295
+
296
+ function bindNotein(): void {
297
+ if (noteinBound) return;
298
+ noteinBound = true;
286
299
  bindInlet(CHAIN_IN.notein, (pitch, velocity) => {
287
- const v = Number(velocity);
288
- if (v === 0) return;
289
- fn(Number(pitch), v);
300
+ const p = Number(pitch);
301
+ // Max's `midiparse` reports a release as velocity ZERO, not as a distinct message.
302
+ if (Number(velocity) === 0) for (const fn of noteOffHandlers) fn(p);
303
+ else for (const fn of noteOnHandlers) fn(p, Number(velocity));
290
304
  });
291
305
  }
292
306
 
307
+ /**
308
+ * Bind incoming MIDI note-ONS. Note-offs are filtered out, which is what a one-shot
309
+ * wants: a struck sample (a piano, a drum) decays on its own and a release message
310
+ * would only cut it short. A SUSTAINING voice needs the other half - see `onNoteOff`.
311
+ *
312
+ * Requires the `midiin` chain, and therefore a device of type "instrument" (an audio
313
+ * effect has no MIDI ports).
314
+ */
315
+ export function onNote(fn: (pitch: number, velocity: number) => void): void {
316
+ noteOnHandlers.add(fn);
317
+ bindNotein();
318
+ }
319
+
320
+ /**
321
+ * Bind incoming MIDI note-OFFS - the releases `onNote` drops.
322
+ *
323
+ * Bind this when the voice you start SUSTAINS: an oscillator held open by a note-on
324
+ * rings forever unless something tells it the key came up. Safe to use alongside
325
+ * `onNote` - they share one binding and split the stream by velocity.
326
+ */
327
+ export function onNoteOff(fn: (pitch: number) => void): void {
328
+ noteOffHandlers.add(fn);
329
+ bindNotein();
330
+ }
331
+
293
332
  /**
294
333
  * Release every note the chain is currently holding.
295
334
  *
@@ -349,149 +388,72 @@ export function fetchToFile(url: string, destPath: string, onProgress?: (downloa
349
388
  });
350
389
  }
351
390
 
352
- /* ------------------------------------------------------------------ *
353
- * Samples - the `samples` chain
354
- * ------------------------------------------------------------------ */
355
-
356
- /** What a slot actually holds, once the read completed. Reported by [info~], not assumed. */
357
- export interface LoadedSample {
358
- /** The FILE's sample rate, which need not be Live's. */
359
- sampleRate: number;
360
- durationMs: number;
361
- /** The file's channel count. `replace` adopts it - a slot is not mono by wishing. */
362
- channels: number;
363
- /** Derived from the two above. Nobody counted them. */
364
- frames: number;
391
+ /**
392
+ * Base64-encode raw bytes in slices, so a multi-megabyte buffer does not blow the
393
+ * call stack (`String.fromCharCode(...hugeArray)` does). btoa wants a binary string;
394
+ * we build it 32 KB at a time. No newlines, so the result is one safe Max symbol
395
+ * once re-sliced by SAVE_B64_CHUNK.
396
+ */
397
+ export function encodeBytesBase64(bytes: ArrayBuffer): string {
398
+ const view = new Uint8Array(bytes);
399
+ const SLICE = 0x8000; // 32 KB per fromCharCode call
400
+ let binary = "";
401
+ for (let o = 0; o < view.length; o += SLICE) {
402
+ binary += String.fromCharCode(...view.subarray(o, o + SLICE));
403
+ }
404
+ return btoa(binary);
365
405
  }
366
406
 
367
- const sampleResolvers = new Map<string, { resolve: (s: LoadedSample) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> }>();
368
- let samplesBound = false;
407
+ const SAVE_B64_CHUNK = 8192; // base64 chars per message; well under Max atom limits
408
+
409
+ const saveResolvers = new Map<string, { resolve: (v: { bytes: number }) => void; reject: (e: Error) => void }>();
410
+ let saveBound = false;
369
411
 
370
412
  /**
371
- * Read a file from disk into a slot's [buffer~], and resolve with WHAT LANDED.
372
- *
373
- * Requires the `samples` chain, and the file must already be on disk - that is what
374
- * `fetchToFile()` is for. The bytes never cross the bridge in either direction: Max
375
- * reads the file, and what comes back is a description of it.
376
- *
377
- * WAV, AIFF OR NEXT/SUN - NOT MP3. That is [buffer~]'s list, from its reference page,
378
- * and it is shorter than Max's: MP3, OGG, FLAC and M4A belong to [sfplay~], which
379
- * streams from disk rather than filling a buffer. Handing this an MP3 gets you an
380
- * error in the Max console, no reply, and the timeout below - the file downloads
381
- * perfectly and simply never becomes audio.
382
- *
383
- * The resolved value is measured, not assumed. `replace` adopts the file's channel
384
- * count and sample rate, so a stereo file in a slot you think of as mono is a stereo
385
- * slot - and a frame count is not proof of a read, because a FAILED read leaves the
386
- * previous contents of the buffer exactly where they were. The chain only replies
387
- * when [buffer~] says the read completed; a file Max cannot read produces an error in
388
- * the Max console and NO reply at all, which is what the timeout below is for. There
389
- * is no bang for failure to bind to.
413
+ * Write raw bytes straight to disk via the `download` chain's [js] wrapper.
414
+ *
415
+ * The inverse of `fetchToFile`: instead of Max pulling a URL, the UI hands the bytes
416
+ * over base64 in slices and the wrapper writes them to a `.part` file, then atomically
417
+ * places it at `destPath` via [maxurl] (the same move fetchToFile phase 2 uses). The
418
+ * bytes travel base64 because Max splits messages on spaces/commas and base64 has
419
+ * neither. Requires the `download` chain in the device manifest.
420
+ *
421
+ * @param destPath Absolute path (or device-relative, resolved wrapper-side) to write.
422
+ * @param bytes The payload. A per-cycle WAV is ~350 KB => ~60 messages.
423
+ * @returns Resolves with the verified byte count once the file is placed.
390
424
  */
391
- export function loadSample(slot: string, path: string, timeoutMs = 10_000): Promise<LoadedSample> {
392
- if (!samplesBound) {
393
- samplesBound = true;
394
- bindInlet(CHAIN_IN.buffer_ready, (id, sampleRate, ms, channels) => {
395
- const p = sampleResolvers.get(String(id));
396
- if (!p) return;
397
- sampleResolvers.delete(String(id));
398
- clearTimeout(p.timer);
399
- const sr = Number(sampleRate);
400
- const durationMs = Number(ms);
401
- const chans = Number(channels);
402
- // An empty buffer is a read that "worked" and gave us nothing. Do not hand the
403
- // app a slot it will play in silence and blame itself for.
404
- if (!(sr > 0) || !(durationMs > 0) || !(chans > 0)) {
405
- p.reject(new Error(`slot "${id}" loaded empty: ${durationMs} ms, ${chans} channels at ${sr} Hz`));
406
- return;
425
+ export function saveToFile(destPath: string, bytes: ArrayBuffer): Promise<{ bytes: number }> {
426
+ if (!saveBound) {
427
+ saveBound = true;
428
+ bindInlet(CHAIN_IN.save_done, (id, n) => {
429
+ const p = saveResolvers.get(String(id));
430
+ if (p) {
431
+ p.resolve({ bytes: Number(n) });
432
+ saveResolvers.delete(String(id));
407
433
  }
408
- p.resolve({ sampleRate: sr, durationMs, channels: chans, frames: Math.round((durationMs / 1000) * sr) });
409
434
  });
410
- // The failure the wrapper CAN see - no file, or an empty one - arrives at once,
411
- // rather than as ten seconds of silence. The failure it cannot see (a file Max
412
- // will not decode) still has no event: [buffer~] says nothing, and the timeout is
413
- // the only thing that ever will.
414
- bindInlet(CHAIN_IN.buffer_error, (id, msg) => {
415
- const p = sampleResolvers.get(String(id));
416
- if (!p) return;
417
- sampleResolvers.delete(String(id));
418
- clearTimeout(p.timer);
419
- p.reject(new Error(String(msg)));
435
+ bindInlet(CHAIN_IN.save_error, (id, msg) => {
436
+ const p = saveResolvers.get(String(id));
437
+ if (p) {
438
+ p.reject(new Error(String(msg)));
439
+ saveResolvers.delete(String(id));
440
+ }
420
441
  });
421
442
  }
422
443
 
423
444
  return new Promise((resolve, reject) => {
424
- const timer = setTimeout(() => {
425
- sampleResolvers.delete(slot);
426
- reject(new Error(`slot "${slot}": [buffer~] never reported a completed read of "${path}" (${timeoutMs} ms). See the Max console.`));
427
- }, timeoutMs);
428
- sampleResolvers.set(slot, { resolve, reject, timer });
429
- outlet(CHAIN_OUT.buffer_load, slot, path);
445
+ const requestId = Math.random().toString(36).substring(2, 10);
446
+ saveResolvers.set(requestId, { resolve, reject });
447
+ const b64 = encodeBytesBase64(bytes);
448
+ outlet(CHAIN_OUT.save_begin, requestId, destPath, bytes.byteLength);
449
+ for (let o = 0; o < b64.length; o += SAVE_B64_CHUNK) {
450
+ outlet(CHAIN_OUT.save_chunk, requestId, b64.slice(o, o + SAVE_B64_CHUNK));
451
+ }
452
+ outlet(CHAIN_OUT.save_end, requestId);
430
453
  });
431
454
  }
432
455
 
433
- /**
434
- * Play a loaded slot, once, from the beginning - THROUGH THE TRACK.
435
- *
436
- * That last part is the whole reason this exists. Audio a page plays for itself goes
437
- * to the OS output device: [jweb] has no signal outlets, so it bypasses the track,
438
- * the fader and the monitor cue. A preview Live can hear has to be [buffer~] in the
439
- * patcher, which is this.
440
- */
441
- export function playSample(slot: string): void {
442
- outlet(CHAIN_OUT.buffer_play, slot);
443
- }
444
456
 
445
- /** Stop the preview. One voice, so this stops whichever slot is sounding. */
446
- export function stopSample(): void {
447
- outlet(CHAIN_OUT.buffer_stop);
448
- }
449
-
450
- /* ------------------------------------------------------------------ *
451
- * Instrument - the `instrument` chain ([poly~] voices)
452
- * ------------------------------------------------------------------ */
453
-
454
- /** One played note, handed to the `instrument` chain's [poly~]. The app times it; Max allocates a voice. */
455
- export interface Voice {
456
- /**
457
- * Which slot's buffer to play, as a 0-based INDEX into the device's `slots` (the
458
- * order declared in the manifest). The instrument is a keymap of named buffers, so a
459
- * note names its sample; the app owns the slot order and passes the index.
460
- */
461
- slot: number;
462
- /**
463
- * Playback RATE. 1 plays the buffer at its recorded pitch; 2 is an octave up, 0.5 an
464
- * octave down. EXPLICIT, so the app decides whether a note plays a dedicated sample
465
- * (rate 1) or a repitched one - the chain does no pitch arithmetic.
466
- */
467
- rate: number;
468
- /** 1-127. Scaled to amplitude in the voice. */
469
- velocity: number;
470
- /** How long to HOLD the voice, in ms - after which [poly~] frees it for re-use. */
471
- durationMs: number;
472
- /**
473
- * That slot's channel count, as reported by `loadSample`'s `buffer_ready`. A mono
474
- * buffer (1) is folded to both ears in the voice; pass what you measured, not what
475
- * you hoped for. Defaults to 2 (no fold).
476
- */
477
- channels?: number;
478
- }
479
-
480
- /**
481
- * Play one note through the `instrument` chain's [poly~].
482
- *
483
- * Requires the note's slot already loaded (via `loadSample`, the same call the sampler
484
- * uses). Polyphony and voice-stealing are Max's job: send as many overlapping notes as
485
- * you like - across any slots - and [poly~] hands each to a free voice, or steals the
486
- * oldest. A chord is several `playVoice()` calls in the same tick.
487
- *
488
- * The note is a control-plane message, not audio: it says which sample, at what rate,
489
- * how loud and how long to hold the voice, and Max makes the sound sample-accurately.
490
- * Pass the `channels` you learned from `loadSample` so a mono sample folds to both ears.
491
- */
492
- export function playVoice(v: Voice): void {
493
- outlet(CHAIN_OUT.voice_play, v.slot, v.rate, v.velocity, v.durationMs, v.channels ?? 2);
494
- }
495
457
 
496
458
  /* ------------------------------------------------------------------ *
497
459
  * Remote - the `remote` chain (live.remote~ modulation)