@m4l-jweb/bridge 0.9.5 → 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 +46 -227
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m4l-jweb/bridge",
3
- "version": "0.9.5",
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,16 +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",
175
171
  /** download -> UI: `save_done <requestId> <bytes>` - the file is written and verified. */
176
172
  save_done: "save_done",
177
173
  /** download -> UI: `save_error <requestId> <msg>` - the write or atomic place failed. */
178
174
  save_error: "save_error",
179
- /** renderplay -> UI: `render_ready <slot>` - the WAV finished loading into that slot's buffer~. */
180
- render_ready: "render_ready",
181
175
  } as const;
182
176
 
183
177
  /** Selectors the packaged chains RECEIVE from the UI. Requires the `midiout` chain. */
@@ -188,14 +182,6 @@ export const CHAIN_OUT = {
188
182
  flush: "flush",
189
183
  /** UI -> download: `fetch_to_file <requestId> <url> <destPath>`. */
190
184
  fetch_to_file: "fetch_to_file",
191
- /** UI -> samples: `buffer_load <slot> <path>` - read a file into that slot's [buffer~]. */
192
- buffer_load: "buffer_load",
193
- /** UI -> samples: `buffer_play <slot>` - preview it through the track. */
194
- buffer_play: "buffer_play",
195
- /** UI -> samples: `buffer_stop` - stop the preview. */
196
- buffer_stop: "buffer_stop",
197
- /** UI -> instrument: `voice_play <pitch> <vel> <durMs> <channels>` - play one poly~ voice. */
198
- voice_play: "voice_play",
199
185
  /** UI -> remote: `remote_bind <slot> <lomId>` - point a live.remote~ at a Live parameter. */
200
186
  remote_bind: "remote_bind",
201
187
  /** UI -> remote: `remote_val <slot> <value>` - the next value for that slot, ramped. */
@@ -206,14 +192,6 @@ export const CHAIN_OUT = {
206
192
  save_chunk: "save_chunk",
207
193
  /** UI -> download: `save_end <requestId>` - close, verify size, atomic place. */
208
194
  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",
217
195
  } as const;
218
196
 
219
197
  /**
@@ -301,15 +279,56 @@ export function sendNote(note: Note): void {
301
279
  outlet(CHAIN_OUT.midinote, note.pitch, note.velocity, note.durationMs, note.channel ?? 1, note.delayMs ?? 0);
302
280
  }
303
281
 
304
- /** Bind incoming MIDI. Note-offs are filtered out: `makenote` already owns the release. */
305
- 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;
306
299
  bindInlet(CHAIN_IN.notein, (pitch, velocity) => {
307
- const v = Number(velocity);
308
- if (v === 0) return;
309
- 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));
310
304
  });
311
305
  }
312
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
+
313
332
  /**
314
333
  * Release every note the chain is currently holding.
315
334
  *
@@ -434,207 +453,7 @@ export function saveToFile(destPath: string, bytes: ArrayBuffer): Promise<{ byte
434
453
  });
435
454
  }
436
455
 
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
-
495
- /* ------------------------------------------------------------------ *
496
- * Samples - the `samples` chain
497
- * ------------------------------------------------------------------ */
498
456
 
499
- /** What a slot actually holds, once the read completed. Reported by [info~], not assumed. */
500
- export interface LoadedSample {
501
- /** The FILE's sample rate, which need not be Live's. */
502
- sampleRate: number;
503
- durationMs: number;
504
- /** The file's channel count. `replace` adopts it - a slot is not mono by wishing. */
505
- channels: number;
506
- /** Derived from the two above. Nobody counted them. */
507
- frames: number;
508
- }
509
-
510
- const sampleResolvers = new Map<string, { resolve: (s: LoadedSample) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> }>();
511
- let samplesBound = false;
512
-
513
- /**
514
- * Read a file from disk into a slot's [buffer~], and resolve with WHAT LANDED.
515
- *
516
- * Requires the `samples` chain, and the file must already be on disk - that is what
517
- * `fetchToFile()` is for. The bytes never cross the bridge in either direction: Max
518
- * reads the file, and what comes back is a description of it.
519
- *
520
- * WAV, AIFF OR NEXT/SUN - NOT MP3. That is [buffer~]'s list, from its reference page,
521
- * and it is shorter than Max's: MP3, OGG, FLAC and M4A belong to [sfplay~], which
522
- * streams from disk rather than filling a buffer. Handing this an MP3 gets you an
523
- * error in the Max console, no reply, and the timeout below - the file downloads
524
- * perfectly and simply never becomes audio.
525
- *
526
- * The resolved value is measured, not assumed. `replace` adopts the file's channel
527
- * count and sample rate, so a stereo file in a slot you think of as mono is a stereo
528
- * slot - and a frame count is not proof of a read, because a FAILED read leaves the
529
- * previous contents of the buffer exactly where they were. The chain only replies
530
- * when [buffer~] says the read completed; a file Max cannot read produces an error in
531
- * the Max console and NO reply at all, which is what the timeout below is for. There
532
- * is no bang for failure to bind to.
533
- */
534
- export function loadSample(slot: string, path: string, timeoutMs = 10_000): Promise<LoadedSample> {
535
- if (!samplesBound) {
536
- samplesBound = true;
537
- bindInlet(CHAIN_IN.buffer_ready, (id, sampleRate, ms, channels) => {
538
- const p = sampleResolvers.get(String(id));
539
- if (!p) return;
540
- sampleResolvers.delete(String(id));
541
- clearTimeout(p.timer);
542
- const sr = Number(sampleRate);
543
- const durationMs = Number(ms);
544
- const chans = Number(channels);
545
- // An empty buffer is a read that "worked" and gave us nothing. Do not hand the
546
- // app a slot it will play in silence and blame itself for.
547
- if (!(sr > 0) || !(durationMs > 0) || !(chans > 0)) {
548
- p.reject(new Error(`slot "${id}" loaded empty: ${durationMs} ms, ${chans} channels at ${sr} Hz`));
549
- return;
550
- }
551
- p.resolve({ sampleRate: sr, durationMs, channels: chans, frames: Math.round((durationMs / 1000) * sr) });
552
- });
553
- // The failure the wrapper CAN see - no file, or an empty one - arrives at once,
554
- // rather than as ten seconds of silence. The failure it cannot see (a file Max
555
- // will not decode) still has no event: [buffer~] says nothing, and the timeout is
556
- // the only thing that ever will.
557
- bindInlet(CHAIN_IN.buffer_error, (id, msg) => {
558
- const p = sampleResolvers.get(String(id));
559
- if (!p) return;
560
- sampleResolvers.delete(String(id));
561
- clearTimeout(p.timer);
562
- p.reject(new Error(String(msg)));
563
- });
564
- }
565
-
566
- return new Promise((resolve, reject) => {
567
- const timer = setTimeout(() => {
568
- sampleResolvers.delete(slot);
569
- reject(new Error(`slot "${slot}": [buffer~] never reported a completed read of "${path}" (${timeoutMs} ms). See the Max console.`));
570
- }, timeoutMs);
571
- sampleResolvers.set(slot, { resolve, reject, timer });
572
- outlet(CHAIN_OUT.buffer_load, slot, path);
573
- });
574
- }
575
-
576
- /**
577
- * Play a loaded slot, once, from the beginning - THROUGH THE TRACK.
578
- *
579
- * That last part is the whole reason this exists. Audio a page plays for itself goes
580
- * to the OS output device: [jweb] has no signal outlets, so it bypasses the track,
581
- * the fader and the monitor cue. A preview Live can hear has to be [buffer~] in the
582
- * patcher, which is this.
583
- */
584
- export function playSample(slot: string): void {
585
- outlet(CHAIN_OUT.buffer_play, slot);
586
- }
587
-
588
- /** Stop the preview. One voice, so this stops whichever slot is sounding. */
589
- export function stopSample(): void {
590
- outlet(CHAIN_OUT.buffer_stop);
591
- }
592
-
593
- /* ------------------------------------------------------------------ *
594
- * Instrument - the `instrument` chain ([poly~] voices)
595
- * ------------------------------------------------------------------ */
596
-
597
- /** One played note, handed to the `instrument` chain's [poly~]. The app times it; Max allocates a voice. */
598
- export interface Voice {
599
- /**
600
- * Which slot's buffer to play, as a 0-based INDEX into the device's `slots` (the
601
- * order declared in the manifest). The instrument is a keymap of named buffers, so a
602
- * note names its sample; the app owns the slot order and passes the index.
603
- */
604
- slot: number;
605
- /**
606
- * Playback RATE. 1 plays the buffer at its recorded pitch; 2 is an octave up, 0.5 an
607
- * octave down. EXPLICIT, so the app decides whether a note plays a dedicated sample
608
- * (rate 1) or a repitched one - the chain does no pitch arithmetic.
609
- */
610
- rate: number;
611
- /** 1-127. Scaled to amplitude in the voice. */
612
- velocity: number;
613
- /** How long to HOLD the voice, in ms - after which [poly~] frees it for re-use. */
614
- durationMs: number;
615
- /**
616
- * That slot's channel count, as reported by `loadSample`'s `buffer_ready`. A mono
617
- * buffer (1) is folded to both ears in the voice; pass what you measured, not what
618
- * you hoped for. Defaults to 2 (no fold).
619
- */
620
- channels?: number;
621
- }
622
-
623
- /**
624
- * Play one note through the `instrument` chain's [poly~].
625
- *
626
- * Requires the note's slot already loaded (via `loadSample`, the same call the sampler
627
- * uses). Polyphony and voice-stealing are Max's job: send as many overlapping notes as
628
- * you like - across any slots - and [poly~] hands each to a free voice, or steals the
629
- * oldest. A chord is several `playVoice()` calls in the same tick.
630
- *
631
- * The note is a control-plane message, not audio: it says which sample, at what rate,
632
- * how loud and how long to hold the voice, and Max makes the sound sample-accurately.
633
- * Pass the `channels` you learned from `loadSample` so a mono sample folds to both ears.
634
- */
635
- export function playVoice(v: Voice): void {
636
- outlet(CHAIN_OUT.voice_play, v.slot, v.rate, v.velocity, v.durationMs, v.channels ?? 2);
637
- }
638
457
 
639
458
  /* ------------------------------------------------------------------ *
640
459
  * Remote - the `remote` chain (live.remote~ modulation)