@m4l-jweb/bridge 1.3.1 → 1.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 +141 -7
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m4l-jweb/bridge",
3
- "version": "1.3.1",
3
+ "version": "1.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
@@ -215,6 +215,139 @@ export const CHAIN_OUT = {
215
215
  save_end: "save_end",
216
216
  } as const;
217
217
 
218
+ /* ------------------------------------------------------------------ *
219
+ * The CONTROL SURFACE contract - the pads, declared with defineControls()
220
+ *
221
+ * A device that claims a Push control gets three things across the bridge, and
222
+ * they are the whole vocabulary: pad events IN, a frame OUT, and a running answer
223
+ * to "do we actually hold it right now".
224
+ *
225
+ * THE KEY IS PART OF THE SELECTOR ON THE WAY IN AND AN ARGUMENT ON THE WAY OUT,
226
+ * for the same reason a state slot's id is: the BRIDGE dispatches on the selector,
227
+ * so one binding per declared control means the app never unpacks a key; MAX
228
+ * dispatches on the first word, so an id baked into an outbound selector goes
229
+ * looking for a handler no device has. `pad_<key>` in, `controls_frame <key> ...`
230
+ * out. Neither name is yours to type - the hooks derive both from the declaration.
231
+ *
232
+ * Y COUNTS FROM THE TOP ON THIS WIRE. The hardware numbers rows downward and
233
+ * `defineControls`' API numbers them upward (see PUSH-USECASES.md), and the flip
234
+ * happens in exactly ONE place - `usePadGrid` - so everything either side of it is
235
+ * in its own natural orientation. Get that wrong somewhere else and every device on
236
+ * the grid is mirrored vertically with nothing to report it.
237
+ * ------------------------------------------------------------------ */
238
+
239
+ /** Selectors the TAKEOVER chain and the wrapper send to a device that declares controls. */
240
+ export const CONTROLS_IN = {
241
+ /**
242
+ * chain -> UI: `pad_<key> <value> <x> <yFromTop> <unknown>` - one event off the
243
+ * grabbed control's `value`, straight out of a `[live.observer]` with no `[js]`
244
+ * between the hardware and the page.
245
+ *
246
+ * MEASURED on a Push 3 (doc/MAX-FACTS.md): the value is four atoms,
247
+ * `<velocity> <x> <y> <1>`, y from the TOP, and nothing arrives between a press
248
+ * and its release - no pressure, no slide. The fourth atom has been `1` on every
249
+ * event observed and is passed through unnamed rather than guessed at.
250
+ *
251
+ * `live.observer` also emits the property's CURRENT value the moment it is
252
+ * pointed at an object, which for a control that has never been touched is not a
253
+ * press: the arity is what tells them apart, and `usePadGrid` drops anything
254
+ * shorter than three atoms.
255
+ *
256
+ * A `stream` control (a jog wheel, a touch strip) uses the same selector with
257
+ * whatever atoms its own `value` carries.
258
+ */
259
+ pad: "pad_",
260
+ /**
261
+ * wrapper -> UI: `controls_role <key> <1 resolved | 0 not on this hardware>`.
262
+ *
263
+ * A Push 3 answers `get_control_names` with 176 names and they are NOT the Push 2
264
+ * set, so a role is resolved at runtime against that answer. A role that is not
265
+ * there is the one failure this API can actually report - say so, rather than
266
+ * grabbing nothing and looking grabbed.
267
+ */
268
+ controls_role: "controls_role",
269
+ /**
270
+ * wrapper -> UI: `controls_held <0|1> <reason>` - do we own the declared controls
271
+ * RIGHT NOW, and if not, why not?
272
+ *
273
+ * It is not the same question as "is `takeover` on": the `focus` parameter decides
274
+ * whether an enabled device holds the grid while another track is selected, and two
275
+ * of these devices in one set is the normal case. Resent on `ui_ready`.
276
+ *
277
+ * THE REASON IS THE POINT. `off`, `no_surface`, `unresolved` and `not_focused` are
278
+ * four different problems that look identical on the hardware - a dark Push - and
279
+ * Live will not tell anyone apart from them, because a rejected grab is a console
280
+ * line and a normal return. This is the wrapper reporting the decision it made,
281
+ * which is the only half that can be known in code.
282
+ */
283
+ controls_held: "controls_held",
284
+ } as const;
285
+
286
+ /** Selectors the wrapper RECEIVES from a device that declares controls. */
287
+ export const CONTROLS_OUT = {
288
+ /**
289
+ * UI -> wrapper: `controls_frame <key> <c0> <c1> ... <c63>` - the WHOLE grid, as
290
+ * palette indices, row-major from the TOP-LEFT in hardware order.
291
+ *
292
+ * One message per frame, not one per cell: sixty-four messages a frame is a data
293
+ * plane and `[js]` is a control plane. The wrapper keeps the last frame it
294
+ * actually sent to the hardware and issues `send_value` only for the cells that
295
+ * changed, so a device redraws freely - every tick, every state change - and pays
296
+ * for the pads that moved. A blinking cursor costs one cell per blink.
297
+ */
298
+ controls_frame: "controls_frame",
299
+ /**
300
+ * UI -> wrapper: `controls_refresh` - forget the last frame and repaint everything.
301
+ *
302
+ * The page uses it after a re-grab, because Live repaints the matrix as it hands
303
+ * it over and the wrapper's idea of what is lit is then a lie.
304
+ */
305
+ controls_refresh: "controls_refresh",
306
+ } as const;
307
+
308
+ /** The inbound selector one declared control's events arrive on. */
309
+ export const padSelector = (key: string): string => `${CONTROLS_IN.pad}${key}`;
310
+
311
+ /**
312
+ * Open a URL in the user's real web browser.
313
+ *
314
+ * A page inside `[jweb]` cannot do this itself. It is a `file://` document, and an
315
+ * ordinary `<a target="_blank">` either does nothing or navigates the device view - which
316
+ * replaces your UI with a web page inside a 169 px box, with no way back.
317
+ *
318
+ * So the page asks Max, and Max asks the shell: `; max launchbrowser <url>`. That message
319
+ * is measured to REACH the shell (doc/MAX-FACTS.md - a wrong `file://` path raised a real
320
+ * "cannot find the file" dialog naming it). What it cannot do is reveal a FOLDER, which is
321
+ * a different intent and is why `copyPath()` exists; opening an `http(s)` URL is the thing
322
+ * `launchbrowser` is actually for.
323
+ */
324
+ export const SHELL_OUT = {
325
+ /** UI -> wrapper: `open_url <url>` - hand it to the default browser. */
326
+ open_url: "open_url",
327
+ } as const;
328
+
329
+ /**
330
+ * Open `url` in the user's browser - in Live, and in the dev harness.
331
+ *
332
+ * MAX SPLITS A MESSAGE ON WHITESPACE, and treats `,` and `;` as message separators, so a
333
+ * URL containing any of them arrives at the wrapper in pieces. Rather than guess at an
334
+ * escaping the receiving end would have to undo, this refuses such a URL and says so - a
335
+ * link that silently opened the wrong page would be worse than one that did not open.
336
+ */
337
+ export function openUrl(url: string): boolean {
338
+ if (/[\s,;]/.test(url)) {
339
+ console.warn(`[m4l-jweb] openUrl: "${url}" contains whitespace, a comma or a semicolon, which Max would split the message on`);
340
+ return false;
341
+ }
342
+ // In a browser there is no Max to ask, and a new tab is the same intent.
343
+ if (!inJweb) {
344
+ window.open(url, "_blank", "noopener");
345
+ return true;
346
+ }
347
+ outlet(SHELL_OUT.open_url, url);
348
+ return true;
349
+ }
350
+
218
351
  /**
219
352
  * Selectors the WRAPPER handles for a device that declares `state` in its surface.
220
353
  *
@@ -359,10 +492,7 @@ export const PARAM_IN = {
359
492
  * behaviour, and is worth it only where a unit readout on the dial beats
360
493
  * automation and macros on it.
361
494
  */
362
- export function describeParam(
363
- id: string,
364
- desc: { name?: string; unit?: string; range?: [number, number]; widenRange?: boolean },
365
- ): void {
495
+ export function describeParam(id: string, desc: { name?: string; unit?: string; range?: [number, number]; widenRange?: boolean }): void {
366
496
  if (desc.name) outlet(PARAM_OUT.param_label, id, desc.name);
367
497
  if (desc.unit) outlet(PARAM_OUT.param_unit, id, desc.unit);
368
498
  if (desc.widenRange && desc.range && desc.range[1] > desc.range[0]) {
@@ -654,8 +784,6 @@ export function onDeviceFolder(fn: (folder: string) => void): () => void {
654
784
  };
655
785
  }
656
786
 
657
-
658
-
659
787
  /* ------------------------------------------------------------------ *
660
788
  * Remote - the `remote` chain (live.remote~ modulation)
661
789
  * ------------------------------------------------------------------ */
@@ -790,7 +918,13 @@ function bindClipRead(): void {
790
918
  clearTimeout(p.timer);
791
919
  // The wrapper says WHY: "no_clip" (track has none) or "no_selection" (highlighted
792
920
  // slot is empty). Either way there is nothing to read.
793
- p.reject(new Error(String(reason) === "no_selection" ? "no clip in the highlighted slot - click a clip first" : "no clip on this track - create or play a MIDI clip first"));
921
+ p.reject(
922
+ new Error(
923
+ String(reason) === "no_selection"
924
+ ? "no clip in the highlighted slot - click a clip first"
925
+ : "no clip on this track - create or play a MIDI clip first",
926
+ ),
927
+ );
794
928
  });
795
929
  }
796
930