@m4l-jweb/bridge 0.5.0 → 0.6.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 +233 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m4l-jweb/bridge",
3
- "version": "0.5.0",
3
+ "version": "0.6.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
@@ -162,6 +162,16 @@ export const DEVICE_IN = {
162
162
  export const CHAIN_IN = {
163
163
  /** midiin -> UI: `notein <pitch> <velocity>`. Velocity 0 is a note-off. */
164
164
  notein: "notein",
165
+ /** download -> UI: `fetch_done <requestId> <bytes>`. */
166
+ fetch_done: "fetch_done",
167
+ /** download -> UI: `fetch_error <requestId> <msg>`. */
168
+ fetch_error: "fetch_error",
169
+ /** download -> UI: `fetch_progress <requestId> <downloaded> <total>`. */
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",
165
175
  } as const;
166
176
 
167
177
  /** Selectors the packaged chains RECEIVE from the UI. Requires the `midiout` chain. */
@@ -170,6 +180,36 @@ export const CHAIN_OUT = {
170
180
  midinote: "midinote",
171
181
  /** UI -> midiout: release every hanging note. */
172
182
  flush: "flush",
183
+ /** UI -> download: `fetch_to_file <requestId> <url> <destPath>`. */
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
+ } as const;
194
+
195
+ /**
196
+ * Selectors the WRAPPER handles for a device that declares `state` in its surface.
197
+ *
198
+ * THE SLOT ID IS AN ARGUMENT, NOT PART OF THE SELECTOR. `sync_state <id> <json>`,
199
+ * never `sync_state_<id>`: Max dispatches a message on its first word, so an id
200
+ * baked into the selector goes looking for a handler no device has and is swallowed
201
+ * without a word. That shipped, and every write to a state slot was dropped.
202
+ *
203
+ * The reply comes back the other way (`state_<id> <json>`), because the BRIDGE
204
+ * dispatches on the selector too - one binding per slot means the app never unpacks
205
+ * an id. Two dispatchers, two conventions; the id sits on whichever side is doing
206
+ * the looking up. `useStateSync()` handles both, and neither name is yours to type.
207
+ */
208
+ export const STATE_OUT = {
209
+ /** UI -> wrapper: send me slot `<id>`; reply on `state_<id>`. */
210
+ get_state: "get_state",
211
+ /** UI -> wrapper: `sync_state <id> <json>` - persist this slot in the Live set. */
212
+ sync_state: "sync_state",
173
213
  } as const;
174
214
 
175
215
  /** A note handed to the `midiout` chain. Max does the placing; you do the timing. */
@@ -221,6 +261,199 @@ export function flushNotes(): void {
221
261
  outlet(CHAIN_OUT.flush);
222
262
  }
223
263
 
264
+ const fetchResolvers = new Map<
265
+ string,
266
+ {
267
+ resolve: (val: { bytes: number }) => void;
268
+ reject: (err: Error) => void;
269
+ onProgress?: (downloaded: number, total: number) => void;
270
+ }
271
+ >();
272
+ let fetchBound = false;
273
+
274
+ /**
275
+ * Fetch a URL and save it directly to disk via Max's [maxurl].
276
+ * Requires the `download` chain in the device manifest.
277
+ *
278
+ * @param url The URL to download
279
+ * @param destPath The absolute path to save the file
280
+ * @param onProgress Optional callback for progress updates
281
+ * @returns A promise resolving to the downloaded file size in bytes
282
+ */
283
+ export function fetchToFile(url: string, destPath: string, onProgress?: (downloaded: number, total: number) => void): Promise<{ bytes: number }> {
284
+ if (!fetchBound) {
285
+ fetchBound = true;
286
+ bindInlet(CHAIN_IN.fetch_done, (id, bytes) => {
287
+ const p = fetchResolvers.get(String(id));
288
+ if (p) {
289
+ p.resolve({ bytes: Number(bytes) });
290
+ fetchResolvers.delete(String(id));
291
+ }
292
+ });
293
+ bindInlet(CHAIN_IN.fetch_error, (id, msg) => {
294
+ const p = fetchResolvers.get(String(id));
295
+ if (p) {
296
+ p.reject(new Error(String(msg)));
297
+ fetchResolvers.delete(String(id));
298
+ }
299
+ });
300
+ bindInlet(CHAIN_IN.fetch_progress, (id, downloaded, total) => {
301
+ const p = fetchResolvers.get(String(id));
302
+ if (p && p.onProgress) p.onProgress(Number(downloaded), Number(total));
303
+ });
304
+ }
305
+
306
+ return new Promise((resolve, reject) => {
307
+ const requestId = Math.random().toString(36).substring(2, 10);
308
+ fetchResolvers.set(requestId, { resolve, reject, onProgress });
309
+ outlet(CHAIN_OUT.fetch_to_file, requestId, url, destPath);
310
+ });
311
+ }
312
+
313
+ /* ------------------------------------------------------------------ *
314
+ * Samples - the `samples` chain
315
+ * ------------------------------------------------------------------ */
316
+
317
+ /** What a slot actually holds, once the read completed. Reported by [info~], not assumed. */
318
+ export interface LoadedSample {
319
+ /** The FILE's sample rate, which need not be Live's. */
320
+ sampleRate: number;
321
+ durationMs: number;
322
+ /** The file's channel count. `replace` adopts it - a slot is not mono by wishing. */
323
+ channels: number;
324
+ /** Derived from the two above. Nobody counted them. */
325
+ frames: number;
326
+ }
327
+
328
+ const sampleResolvers = new Map<string, { resolve: (s: LoadedSample) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> }>();
329
+ let samplesBound = false;
330
+
331
+ /**
332
+ * Read a file from disk into a slot's [buffer~], and resolve with WHAT LANDED.
333
+ *
334
+ * Requires the `samples` chain, and the file must already be on disk - that is what
335
+ * `fetchToFile()` is for. The bytes never cross the bridge in either direction: Max
336
+ * reads the file, and what comes back is a description of it.
337
+ *
338
+ * WAV, AIFF OR NEXT/SUN - NOT MP3. That is [buffer~]'s list, from its reference page,
339
+ * and it is shorter than Max's: MP3, OGG, FLAC and M4A belong to [sfplay~], which
340
+ * streams from disk rather than filling a buffer. Handing this an MP3 gets you an
341
+ * error in the Max console, no reply, and the timeout below - the file downloads
342
+ * perfectly and simply never becomes audio.
343
+ *
344
+ * The resolved value is measured, not assumed. `replace` adopts the file's channel
345
+ * count and sample rate, so a stereo file in a slot you think of as mono is a stereo
346
+ * slot - and a frame count is not proof of a read, because a FAILED read leaves the
347
+ * previous contents of the buffer exactly where they were. The chain only replies
348
+ * when [buffer~] says the read completed; a file Max cannot read produces an error in
349
+ * the Max console and NO reply at all, which is what the timeout below is for. There
350
+ * is no bang for failure to bind to.
351
+ */
352
+ export function loadSample(slot: string, path: string, timeoutMs = 10_000): Promise<LoadedSample> {
353
+ if (!samplesBound) {
354
+ samplesBound = true;
355
+ bindInlet(CHAIN_IN.buffer_ready, (id, sampleRate, ms, channels) => {
356
+ const p = sampleResolvers.get(String(id));
357
+ if (!p) return;
358
+ sampleResolvers.delete(String(id));
359
+ clearTimeout(p.timer);
360
+ const sr = Number(sampleRate);
361
+ const durationMs = Number(ms);
362
+ const chans = Number(channels);
363
+ // An empty buffer is a read that "worked" and gave us nothing. Do not hand the
364
+ // app a slot it will play in silence and blame itself for.
365
+ if (!(sr > 0) || !(durationMs > 0) || !(chans > 0)) {
366
+ p.reject(new Error(`slot "${id}" loaded empty: ${durationMs} ms, ${chans} channels at ${sr} Hz`));
367
+ return;
368
+ }
369
+ p.resolve({ sampleRate: sr, durationMs, channels: chans, frames: Math.round((durationMs / 1000) * sr) });
370
+ });
371
+ // The failure the wrapper CAN see - no file, or an empty one - arrives at once,
372
+ // rather than as ten seconds of silence. The failure it cannot see (a file Max
373
+ // will not decode) still has no event: [buffer~] says nothing, and the timeout is
374
+ // the only thing that ever will.
375
+ bindInlet(CHAIN_IN.buffer_error, (id, msg) => {
376
+ const p = sampleResolvers.get(String(id));
377
+ if (!p) return;
378
+ sampleResolvers.delete(String(id));
379
+ clearTimeout(p.timer);
380
+ p.reject(new Error(String(msg)));
381
+ });
382
+ }
383
+
384
+ return new Promise((resolve, reject) => {
385
+ const timer = setTimeout(() => {
386
+ sampleResolvers.delete(slot);
387
+ reject(new Error(`slot "${slot}": [buffer~] never reported a completed read of "${path}" (${timeoutMs} ms). See the Max console.`));
388
+ }, timeoutMs);
389
+ sampleResolvers.set(slot, { resolve, reject, timer });
390
+ outlet(CHAIN_OUT.buffer_load, slot, path);
391
+ });
392
+ }
393
+
394
+ /**
395
+ * Play a loaded slot, once, from the beginning - THROUGH THE TRACK.
396
+ *
397
+ * That last part is the whole reason this exists. Audio a page plays for itself goes
398
+ * to the OS output device: [jweb] has no signal outlets, so it bypasses the track,
399
+ * the fader and the monitor cue. A preview Live can hear has to be [buffer~] in the
400
+ * patcher, which is this.
401
+ */
402
+ export function playSample(slot: string): void {
403
+ outlet(CHAIN_OUT.buffer_play, slot);
404
+ }
405
+
406
+ /** Stop the preview. One voice, so this stops whichever slot is sounding. */
407
+ export function stopSample(): void {
408
+ outlet(CHAIN_OUT.buffer_stop);
409
+ }
410
+
411
+ /* ------------------------------------------------------------------ *
412
+ * Instrument - the `instrument` chain ([poly~] voices)
413
+ * ------------------------------------------------------------------ */
414
+
415
+ /** One played note, handed to the `instrument` chain's [poly~]. The app times it; Max allocates a voice. */
416
+ export interface Voice {
417
+ /**
418
+ * Which slot's buffer to play, as a 0-based INDEX into the device's `slots` (the
419
+ * order declared in the manifest). The instrument is a keymap of named buffers, so a
420
+ * note names its sample; the app owns the slot order and passes the index.
421
+ */
422
+ slot: number;
423
+ /**
424
+ * Playback RATE. 1 plays the buffer at its recorded pitch; 2 is an octave up, 0.5 an
425
+ * octave down. EXPLICIT, so the app decides whether a note plays a dedicated sample
426
+ * (rate 1) or a repitched one - the chain does no pitch arithmetic.
427
+ */
428
+ rate: number;
429
+ /** 1-127. Scaled to amplitude in the voice. */
430
+ velocity: number;
431
+ /** How long to HOLD the voice, in ms - after which [poly~] frees it for re-use. */
432
+ durationMs: number;
433
+ /**
434
+ * That slot's channel count, as reported by `loadSample`'s `buffer_ready`. A mono
435
+ * buffer (1) is folded to both ears in the voice; pass what you measured, not what
436
+ * you hoped for. Defaults to 2 (no fold).
437
+ */
438
+ channels?: number;
439
+ }
440
+
441
+ /**
442
+ * Play one note through the `instrument` chain's [poly~].
443
+ *
444
+ * Requires the note's slot already loaded (via `loadSample`, the same call the sampler
445
+ * uses). Polyphony and voice-stealing are Max's job: send as many overlapping notes as
446
+ * you like - across any slots - and [poly~] hands each to a free voice, or steals the
447
+ * oldest. A chord is several `playVoice()` calls in the same tick.
448
+ *
449
+ * The note is a control-plane message, not audio: it says which sample, at what rate,
450
+ * how loud and how long to hold the voice, and Max makes the sound sample-accurately.
451
+ * Pass the `channels` you learned from `loadSample` so a mono sample folds to both ears.
452
+ */
453
+ export function playVoice(v: Voice): void {
454
+ outlet(CHAIN_OUT.voice_play, v.slot, v.rate, v.velocity, v.durationMs, v.channels ?? 2);
455
+ }
456
+
224
457
  /**
225
458
  * Max splits messages on commas and semicolons, so any structured payload -
226
459
  * JSON, code, a filesystem path - must be encoded before it crosses the bridge.