@m4l-jweb/bridge 0.5.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.
- package/package.json +1 -1
- package/src/index.ts +185 -0
package/package.json
CHANGED
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.
|