@m4l-jweb/bridge 0.9.9 → 1.1.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/README.md +52 -0
- package/package.json +1 -1
- package/src/index.ts +73 -1
package/README.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# @m4l-jweb/bridge
|
|
2
|
+
|
|
3
|
+
The browser half of a device. Your React UI runs inside `[jweb~]`, Max's embedded Chromium; this is how it talks to the Max patcher around it - messages out, messages in, MIDI, clip I/O, and files.
|
|
4
|
+
|
|
5
|
+
Part of **[m4l-jweb](https://github.com/alienmind/m4l-jweb)** - build Ableton Live devices (`.amxd`) from a TypeScript repo: React UI, LiveAPI glue, CI builds, no Max editor.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pnpm add @m4l-jweb/bridge
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { bindInlet, outlet, onNote, onNoteOff, sendNote, uiReady } from "@m4l-jweb/bridge";
|
|
17
|
+
|
|
18
|
+
// Tell the wrapper the page is up, and ask for current state.
|
|
19
|
+
uiReady();
|
|
20
|
+
|
|
21
|
+
// Incoming MIDI (needs the `midiin` chain). Both binders share one subscription.
|
|
22
|
+
onNote((pitch, velocity) => voiceOn(pitch, velocity));
|
|
23
|
+
onNoteOff((pitch) => voiceOff(pitch));
|
|
24
|
+
|
|
25
|
+
// Outgoing MIDI (needs the `midiout` chain). Max applies the delay, not the browser,
|
|
26
|
+
// so note timing does not depend on a Chromium timer.
|
|
27
|
+
sendNote({ pitch: 60, velocity: 100, durationMs: 250, delayMs: 80 });
|
|
28
|
+
|
|
29
|
+
// Anything else you route yourself.
|
|
30
|
+
bindInlet("tick", (playing, beats) => setTransport(Boolean(playing), Number(beats)));
|
|
31
|
+
outlet("my_selector", 1, "two");
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Notes
|
|
35
|
+
|
|
36
|
+
- **Audio does not go through here.** Under `[jweb~]` the page's Web Audio output is carried on the object's signal outlets, straight into the track. The bridge is a control plane; sound never crosses it as messages.
|
|
37
|
+
- `fetchToFile()` and `saveToFile()` move bytes between the page and disk via Max's `[maxurl]`, so large files never travel through the message bridge either.
|
|
38
|
+
- `tapMessages()` observes every message in both directions - the whole contract of a device, live.
|
|
39
|
+
|
|
40
|
+
## Requirements
|
|
41
|
+
|
|
42
|
+
Ableton Live 12 with Max 9. Devices are built on `[jweb~]`, the browser view with signal outlets; older hosts are unverified.
|
|
43
|
+
|
|
44
|
+
## Links
|
|
45
|
+
|
|
46
|
+
- [Repository and full README](https://github.com/alienmind/m4l-jweb)
|
|
47
|
+
- [Architecture](https://github.com/alienmind/m4l-jweb/blob/main/doc/ARCHITECTURE.md)
|
|
48
|
+
- [What Max actually does: the measured facts](https://github.com/alienmind/m4l-jweb/blob/main/doc/MAX-FACTS.md)
|
|
49
|
+
|
|
50
|
+
## License
|
|
51
|
+
|
|
52
|
+
MIT
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -127,6 +127,22 @@ export function uiReady(): void {
|
|
|
127
127
|
outlet("ui_ready");
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Send a message to the page in another of this device's windows.
|
|
132
|
+
*
|
|
133
|
+
* Two pages of one device are two Chromium contexts: no shared globals, no
|
|
134
|
+
* shared memory, no events between them. They talk through Max, and this is the
|
|
135
|
+
* message that does not persist - the receiving page gets `<selector> <value>` on
|
|
136
|
+
* the inlet it bound with `bindInlet`, and nothing is written to the Live set.
|
|
137
|
+
*
|
|
138
|
+
* Use a STATE SLOT instead for anything that must survive a save (the pattern,
|
|
139
|
+
* a preset). Use this for what is happening NOW: a transport change, a knob
|
|
140
|
+
* position, a nudge to re-read something.
|
|
141
|
+
*/
|
|
142
|
+
export function sendToWindow(windowId: string, selector: string, value: unknown): void {
|
|
143
|
+
outlet("window_send", windowId, selector, value);
|
|
144
|
+
}
|
|
145
|
+
|
|
130
146
|
/* ------------------------------------------------------------------ *
|
|
131
147
|
* The chain contract
|
|
132
148
|
*
|
|
@@ -218,14 +234,69 @@ export const STATE_OUT = {
|
|
|
218
234
|
export const PARAM_OUT = {
|
|
219
235
|
/** UI -> wrapper: `get_param_id <id>` - what is this parameter's LOM id? Reply on `param_id`. */
|
|
220
236
|
get_param_id: "get_param_id",
|
|
237
|
+
/** UI -> wrapper: `param_label <id> <name>` - rename the dial on the device panel. */
|
|
238
|
+
param_label: "param_label",
|
|
239
|
+
/** UI -> wrapper: `param_unit <id> <unit>` - how Live PRINTS the value. */
|
|
240
|
+
param_unit: "param_unit",
|
|
241
|
+
/** UI -> wrapper: `param_range <id> <lo> <hi>` - the dial's travel. Answered. */
|
|
242
|
+
param_range: "param_range",
|
|
221
243
|
} as const;
|
|
222
244
|
|
|
223
245
|
/** ...and the reply. */
|
|
224
246
|
export const PARAM_IN = {
|
|
225
247
|
/** wrapper -> UI: `param_id <id> <lomId>` - 0 means no parameter of that name resolved. */
|
|
226
248
|
param_id: "param_id",
|
|
249
|
+
/** wrapper -> UI: `param_range_ok <id>` - Live took the range; the parameter now carries it. */
|
|
250
|
+
param_range_ok: "param_range_ok",
|
|
251
|
+
/** wrapper -> UI: `param_range_failed <id>` - it did not; the parameter is still as declared. */
|
|
252
|
+
param_range_failed: "param_range_failed",
|
|
253
|
+
/** wrapper -> device view: `param_desc <id> <name>` - what some page called this parameter. */
|
|
254
|
+
param_desc: "param_desc",
|
|
255
|
+
/** wrapper -> device view: `param_desc_range <id> <lo> <hi> <took>` - and its travel. */
|
|
256
|
+
param_desc_range: "param_desc_range",
|
|
227
257
|
} as const;
|
|
228
258
|
|
|
259
|
+
/**
|
|
260
|
+
* Say what a parameter IS, at runtime: its name, how Live should print it, and the
|
|
261
|
+
* travel it should have.
|
|
262
|
+
*
|
|
263
|
+
* A surface declares all three at BUILD time, which is right when the device knows
|
|
264
|
+
* what its controls do. It does not when the control is whatever the user's code
|
|
265
|
+
* just asked for - a pattern's `slider()`, a modulation slot pointed at something
|
|
266
|
+
* new - and then the dial reads `S1`, travels 0..1 and says nothing about the sound
|
|
267
|
+
* it is moving.
|
|
268
|
+
*
|
|
269
|
+
* ------------------------------------------------------------------------------
|
|
270
|
+
* WHAT TAKES, AND WHAT DOES NOT - measured in Live, not assumed.
|
|
271
|
+
*
|
|
272
|
+
* `name` reaches the DEVICE PANEL. It does NOT reach Live's parameter registry or
|
|
273
|
+
* the Rack macro picker, which keep the declared short name: a frozen
|
|
274
|
+
* device cannot rename a parameter there.
|
|
275
|
+
* `unit` takes. It is the unit STYLE, so the readout says "600 Hz" and not "600";
|
|
276
|
+
* anything outside Max's list becomes a custom unit and still prints.
|
|
277
|
+
* `range` takes, AND CHANGES THE DOMAIN THE PARAMETER REPORTS. That is the trap:
|
|
278
|
+
* a page that goes on normalizing 0..1 will scale a value that is already
|
|
279
|
+
* scaled, and the control sticks at its minimum. So the wrapper ANSWERS -
|
|
280
|
+
* `param_range_ok` or `param_range_failed` - and the caller must stop
|
|
281
|
+
* normalizing when it took. `onParamRange()` below is that answer.
|
|
282
|
+
*/
|
|
283
|
+
export function describeParam(id: string, desc: { name?: string; unit?: string; range?: [number, number] }): void {
|
|
284
|
+
if (desc.name) outlet(PARAM_OUT.param_label, id, desc.name);
|
|
285
|
+
if (desc.unit) outlet(PARAM_OUT.param_unit, id, desc.unit);
|
|
286
|
+
if (desc.range && desc.range[1] > desc.range[0]) outlet(PARAM_OUT.param_range, id, desc.range[0], desc.range[1]);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Hear whether a `describeParam` range took, per parameter.
|
|
291
|
+
*
|
|
292
|
+
* Until this says yes, the parameter is still in its declared domain and the page
|
|
293
|
+
* owns the scaling. Exactly one scaling, wherever it ends up living.
|
|
294
|
+
*/
|
|
295
|
+
export function onParamRange(fn: (id: string, took: boolean) => void): void {
|
|
296
|
+
bindInlet(PARAM_IN.param_range_ok, (id) => fn(String(id), true));
|
|
297
|
+
bindInlet(PARAM_IN.param_range_failed, (id) => fn(String(id), false));
|
|
298
|
+
}
|
|
299
|
+
|
|
229
300
|
/**
|
|
230
301
|
* Selectors the WRAPPER handles for reading and writing the CLIP on this device's
|
|
231
302
|
* track (`read_notes`/`write_clip` in liveapi.ts). Wrapper-owned, like DEVICE_IN -
|
|
@@ -470,7 +541,8 @@ export function saveToFile(destPath: string, bytes: ArrayBuffer): Promise<{ byte
|
|
|
470
541
|
* running set, not names: they are not stable across a set reload, so an id saved
|
|
471
542
|
* yesterday points at whatever occupies that slot today - or at nothing. Persist how
|
|
472
543
|
* you FOUND the parameter (the device's position, the parameter's name) and resolve it
|
|
473
|
-
* again
|
|
544
|
+
* again. (Translate mode's reconciler followed the same rule; that mode was settled as
|
|
545
|
+
* adopt-only, permanently - Live's Browser is unreachable from [js].)
|
|
474
546
|
*/
|
|
475
547
|
export function bindRemote(slot: number, lomId: number | string): void {
|
|
476
548
|
outlet(CHAIN_OUT.remote_bind, slot, lomId);
|