@m4l-jweb/bridge 0.4.0 → 0.6.0

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 +185 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m4l-jweb/bridge",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
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,34 @@ 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
+ } as const;
192
+
193
+ /**
194
+ * Selectors the WRAPPER handles for a device that declares `state` in its surface.
195
+ *
196
+ * THE SLOT ID IS AN ARGUMENT, NOT PART OF THE SELECTOR. `sync_state <id> <json>`,
197
+ * never `sync_state_<id>`: Max dispatches a message on its first word, so an id
198
+ * baked into the selector goes looking for a handler no device has and is swallowed
199
+ * without a word. That shipped, and every write to a state slot was dropped.
200
+ *
201
+ * The reply comes back the other way (`state_<id> <json>`), because the BRIDGE
202
+ * dispatches on the selector too - one binding per slot means the app never unpacks
203
+ * an id. Two dispatchers, two conventions; the id sits on whichever side is doing
204
+ * the looking up. `useStateSync()` handles both, and neither name is yours to type.
205
+ */
206
+ export const STATE_OUT = {
207
+ /** UI -> wrapper: send me slot `<id>`; reply on `state_<id>`. */
208
+ get_state: "get_state",
209
+ /** UI -> wrapper: `sync_state <id> <json>` - persist this slot in the Live set. */
210
+ sync_state: "sync_state",
173
211
  } as const;
174
212
 
175
213
  /** A note handed to the `midiout` chain. Max does the placing; you do the timing. */
@@ -221,6 +259,153 @@ export function flushNotes(): void {
221
259
  outlet(CHAIN_OUT.flush);
222
260
  }
223
261
 
262
+ const fetchResolvers = new Map<
263
+ string,
264
+ {
265
+ resolve: (val: { bytes: number }) => void;
266
+ reject: (err: Error) => void;
267
+ onProgress?: (downloaded: number, total: number) => void;
268
+ }
269
+ >();
270
+ let fetchBound = false;
271
+
272
+ /**
273
+ * Fetch a URL and save it directly to disk via Max's [maxurl].
274
+ * Requires the `download` chain in the device manifest.
275
+ *
276
+ * @param url The URL to download
277
+ * @param destPath The absolute path to save the file
278
+ * @param onProgress Optional callback for progress updates
279
+ * @returns A promise resolving to the downloaded file size in bytes
280
+ */
281
+ export function fetchToFile(url: string, destPath: string, onProgress?: (downloaded: number, total: number) => void): Promise<{ bytes: number }> {
282
+ if (!fetchBound) {
283
+ fetchBound = true;
284
+ bindInlet(CHAIN_IN.fetch_done, (id, bytes) => {
285
+ const p = fetchResolvers.get(String(id));
286
+ if (p) {
287
+ p.resolve({ bytes: Number(bytes) });
288
+ fetchResolvers.delete(String(id));
289
+ }
290
+ });
291
+ bindInlet(CHAIN_IN.fetch_error, (id, msg) => {
292
+ const p = fetchResolvers.get(String(id));
293
+ if (p) {
294
+ p.reject(new Error(String(msg)));
295
+ fetchResolvers.delete(String(id));
296
+ }
297
+ });
298
+ bindInlet(CHAIN_IN.fetch_progress, (id, downloaded, total) => {
299
+ const p = fetchResolvers.get(String(id));
300
+ if (p && p.onProgress) p.onProgress(Number(downloaded), Number(total));
301
+ });
302
+ }
303
+
304
+ return new Promise((resolve, reject) => {
305
+ const requestId = Math.random().toString(36).substring(2, 10);
306
+ fetchResolvers.set(requestId, { resolve, reject, onProgress });
307
+ outlet(CHAIN_OUT.fetch_to_file, requestId, url, destPath);
308
+ });
309
+ }
310
+
311
+ /* ------------------------------------------------------------------ *
312
+ * Samples - the `samples` chain
313
+ * ------------------------------------------------------------------ */
314
+
315
+ /** What a slot actually holds, once the read completed. Reported by [info~], not assumed. */
316
+ export interface LoadedSample {
317
+ /** The FILE's sample rate, which need not be Live's. */
318
+ sampleRate: number;
319
+ durationMs: number;
320
+ /** The file's channel count. `replace` adopts it - a slot is not mono by wishing. */
321
+ channels: number;
322
+ /** Derived from the two above. Nobody counted them. */
323
+ frames: number;
324
+ }
325
+
326
+ const sampleResolvers = new Map<string, { resolve: (s: LoadedSample) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> }>();
327
+ let samplesBound = false;
328
+
329
+ /**
330
+ * Read a file from disk into a slot's [buffer~], and resolve with WHAT LANDED.
331
+ *
332
+ * Requires the `samples` chain, and the file must already be on disk - that is what
333
+ * `fetchToFile()` is for. The bytes never cross the bridge in either direction: Max
334
+ * reads the file, and what comes back is a description of it.
335
+ *
336
+ * WAV, AIFF OR NEXT/SUN - NOT MP3. That is [buffer~]'s list, from its reference page,
337
+ * and it is shorter than Max's: MP3, OGG, FLAC and M4A belong to [sfplay~], which
338
+ * streams from disk rather than filling a buffer. Handing this an MP3 gets you an
339
+ * error in the Max console, no reply, and the timeout below - the file downloads
340
+ * perfectly and simply never becomes audio.
341
+ *
342
+ * The resolved value is measured, not assumed. `replace` adopts the file's channel
343
+ * count and sample rate, so a stereo file in a slot you think of as mono is a stereo
344
+ * slot - and a frame count is not proof of a read, because a FAILED read leaves the
345
+ * previous contents of the buffer exactly where they were. The chain only replies
346
+ * when [buffer~] says the read completed; a file Max cannot read produces an error in
347
+ * the Max console and NO reply at all, which is what the timeout below is for. There
348
+ * is no bang for failure to bind to.
349
+ */
350
+ export function loadSample(slot: string, path: string, timeoutMs = 10_000): Promise<LoadedSample> {
351
+ if (!samplesBound) {
352
+ samplesBound = true;
353
+ bindInlet(CHAIN_IN.buffer_ready, (id, sampleRate, ms, channels) => {
354
+ const p = sampleResolvers.get(String(id));
355
+ if (!p) return;
356
+ sampleResolvers.delete(String(id));
357
+ clearTimeout(p.timer);
358
+ const sr = Number(sampleRate);
359
+ const durationMs = Number(ms);
360
+ const chans = Number(channels);
361
+ // An empty buffer is a read that "worked" and gave us nothing. Do not hand the
362
+ // app a slot it will play in silence and blame itself for.
363
+ if (!(sr > 0) || !(durationMs > 0) || !(chans > 0)) {
364
+ p.reject(new Error(`slot "${id}" loaded empty: ${durationMs} ms, ${chans} channels at ${sr} Hz`));
365
+ return;
366
+ }
367
+ p.resolve({ sampleRate: sr, durationMs, channels: chans, frames: Math.round((durationMs / 1000) * sr) });
368
+ });
369
+ // The failure the wrapper CAN see - no file, or an empty one - arrives at once,
370
+ // rather than as ten seconds of silence. The failure it cannot see (a file Max
371
+ // will not decode) still has no event: [buffer~] says nothing, and the timeout is
372
+ // the only thing that ever will.
373
+ bindInlet(CHAIN_IN.buffer_error, (id, msg) => {
374
+ const p = sampleResolvers.get(String(id));
375
+ if (!p) return;
376
+ sampleResolvers.delete(String(id));
377
+ clearTimeout(p.timer);
378
+ p.reject(new Error(String(msg)));
379
+ });
380
+ }
381
+
382
+ return new Promise((resolve, reject) => {
383
+ const timer = setTimeout(() => {
384
+ sampleResolvers.delete(slot);
385
+ reject(new Error(`slot "${slot}": [buffer~] never reported a completed read of "${path}" (${timeoutMs} ms). See the Max console.`));
386
+ }, timeoutMs);
387
+ sampleResolvers.set(slot, { resolve, reject, timer });
388
+ outlet(CHAIN_OUT.buffer_load, slot, path);
389
+ });
390
+ }
391
+
392
+ /**
393
+ * Play a loaded slot, once, from the beginning - THROUGH THE TRACK.
394
+ *
395
+ * That last part is the whole reason this exists. Audio a page plays for itself goes
396
+ * to the OS output device: [jweb] has no signal outlets, so it bypasses the track,
397
+ * the fader and the monitor cue. A preview Live can hear has to be [buffer~] in the
398
+ * patcher, which is this.
399
+ */
400
+ export function playSample(slot: string): void {
401
+ outlet(CHAIN_OUT.buffer_play, slot);
402
+ }
403
+
404
+ /** Stop the preview. One voice, so this stops whichever slot is sounding. */
405
+ export function stopSample(): void {
406
+ outlet(CHAIN_OUT.buffer_stop);
407
+ }
408
+
224
409
  /**
225
410
  * Max splits messages on commas and semicolons, so any structured payload -
226
411
  * JSON, code, a filesystem path - must be encoded before it crosses the bridge.