@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.
- package/package.json +1 -1
- package/src/index.ts +107 -145
package/package.json
CHANGED
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
|
-
/**
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
|
|
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
|
-
|
|
285
|
-
|
|
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
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
*
|
|
354
|
-
*
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
|
368
|
-
|
|
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
|
-
*
|
|
372
|
-
*
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
*
|
|
378
|
-
*
|
|
379
|
-
*
|
|
380
|
-
*
|
|
381
|
-
*
|
|
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
|
|
392
|
-
if (!
|
|
393
|
-
|
|
394
|
-
bindInlet(CHAIN_IN.
|
|
395
|
-
const p =
|
|
396
|
-
if (
|
|
397
|
-
|
|
398
|
-
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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)
|