@m4l-jweb/wrapper 0.6.5 → 0.9.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m4l-jweb/wrapper",
3
- "version": "0.6.5",
3
+ "version": "0.9.0",
4
4
  "description": "m4l-jweb: the Max for Live glue layer connecting a device to LiveAPI.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/sources.mjs CHANGED
@@ -24,4 +24,5 @@ export const types = src("max.d.ts");
24
24
  export const sources = [
25
25
  src("core.ts"), // build stamp, lifecycle, the anything() guard, payload extraction
26
26
  src("liveapi.ts"), // transport poll, tempo observer, clip I/O
27
+ src("watch.ts"), // defineWatch() observers - after liveapi, it calls observeProperty()
27
28
  ];
package/src/core.ts CHANGED
@@ -50,6 +50,7 @@ function bang(): void {
50
50
  loadWebview();
51
51
  setupTempoObserver(); // liveapi.ts
52
52
  startTickPoll(); // liveapi.ts
53
+ setupWatches(); // watch.ts - the device's declared defineWatch() observers
53
54
  // A device's own wrapper/device.ts hooks in here: this is the ONLY safe place
54
55
  // to create LiveAPI objects (see the loadbang trap above).
55
56
  if (typeof onDeviceReady === "function") onDeviceReady();
@@ -67,6 +68,7 @@ function reload(): void {
67
68
  loadWebview();
68
69
  setupTempoObserver();
69
70
  startTickPoll();
71
+ setupWatches(); // watch.ts
70
72
  }
71
73
 
72
74
  /**
@@ -119,6 +121,7 @@ function ui_ready(): void {
119
121
  // mixed install (stale .amxd instance vs newer extracted UI, or vice versa).
120
122
  reply("build", buildStamp());
121
123
  sendCurrentTempo(); // liveapi.ts
124
+ resendWatches(); // watch.ts - the current value of every declared watch, for a late page
122
125
  // The device resends its own state here. The page loads asynchronously, so
123
126
  // anything sent before it was listening is simply gone.
124
127
  if (typeof onUiReady === "function") onUiReady();
@@ -204,6 +207,156 @@ function broadcastState(id: string, json: string): void {
204
207
  }
205
208
  }
206
209
 
210
+ /* ------------------------------------------------------------------ *
211
+ * Native dial visibility (layout.native runtime show/hide) - SPIKE
212
+ *
213
+ * The app (useNativeVisibility) sends `native_show`/`native_hide <varname>` to hide a
214
+ * native `live.*` object the current state does not use - the dynamic visibility a
215
+ * static `presentation` attribute cannot give. We reach the object through the Maxobj
216
+ * API (`this.patcher.getnamed`, the same `this.patcher` deviceFolder() uses) and set
217
+ * its `hidden` flag, LOGGING what we find.
218
+ *
219
+ * The open question, and the whole point of the spike: whether `hidden` (or anything
220
+ * reachable from here) makes a native object leave the M4L device PRESENTATION view
221
+ * at runtime. A first attempt - a `[thispatcher]` running `script hide` - was tried
222
+ * and did NOT work (script acts on the patching canvas, not the presentation). If
223
+ * this one also fails, a frozen M4L device cannot hide a native object at runtime and
224
+ * the fallback is a build-time choice. Watch the Max console for the lines below.
225
+ * ------------------------------------------------------------------ */
226
+
227
+ function native_show(varname: string): void {
228
+ setNativeHidden(varname, 0);
229
+ }
230
+ function native_hide(varname: string): void {
231
+ setNativeHidden(varname, 1);
232
+ }
233
+
234
+ function setNativeHidden(varname: string, hidden: number): void {
235
+ try {
236
+ // `this.patcher` is the device patcher (Max's global object IS the jsthis, so a
237
+ // plainly-called function still sees it - deviceFolder() relies on the same).
238
+ var obj = this.patcher.getnamed(varname);
239
+ if (!obj) {
240
+ post("m4l-jweb: native " + varname + " -> getnamed() null (no such scripting name)\n");
241
+ return;
242
+ }
243
+ // `hidden` is the documented Maxobj visibility toggle. CONFIRMED in Live: it
244
+ // reaches the M4L PRESENTATION view, so a native object vanishes from the device
245
+ // view - which is what the two-screen panel flip (useNativePanel) rides on.
246
+ //
247
+ // Reposition/resize does NOT work the same way: setting `presentation_rect` at
248
+ // runtime is accepted but never redrawn in a frozen M4L device (measured). So
249
+ // there is no `native_rect` here - the panel LAYERS views and hides one, rather
250
+ // than reflowing objects, because hide/show is the only thing that takes.
251
+ obj.hidden = hidden;
252
+ } catch (e) {
253
+ post("m4l-jweb: native " + varname + " error: " + (e as Error).message + "\n");
254
+ }
255
+ }
256
+
257
+ /* ------------------------------------------------------------------ *
258
+ * Parameter LOM ids - what the `remote` chain binds to
259
+ *
260
+ * `get_param_id <id>` from the app; `param_id <id> <lomId>` back, 0 if unresolved.
261
+ *
262
+ * WHY THE WRAPPER AND NOT THE APP. A live.remote~ is bound by LOM id, and only [js]
263
+ * can ask Live for one. The app knows the NAME of the parameter it declared; the LOM
264
+ * knows ids and a `name` per DeviceParameter. This is the one place that can join
265
+ * those, because the build wrote `parameter_longname: <id>` from the same surface
266
+ * declaration the app imports - so a surface id IS the Live parameter's name, and the
267
+ * match needs no second table anyone has to keep in step.
268
+ *
269
+ * WHY IT IS ASKED FOR, NOT PUSHED. LOM ids are handles into the running set and are
270
+ * NOT stable across reloads, so there is no moment at which a list of them could be
271
+ * cached and trusted. The app asks when it binds, and asks again on the next load;
272
+ * anything else persists an id, which is the documented way to modulate the wrong
273
+ * parameter after a set reopens.
274
+ *
275
+ * THE REPLY GOES TO THE DEVICE VIEW, not to a window, and it is `outlet(0, ...)` for
276
+ * the same reason `buffer_error` and `fetch_done` are: reply() carries ONE value by
277
+ * fixed arity (a Max host function will not take .apply - it fails silently in Live,
278
+ * which is how the whole ui_ready handshake was once lost), and this answer is a pair.
279
+ * A window is an editor, not an engine - `tick` never reaches one either, and it is the
280
+ * tick that a bound slot is streamed on.
281
+ * ------------------------------------------------------------------ */
282
+
283
+ function get_param_id(id: string): void {
284
+ // `this_device` resolves to the device this [js] lives in. Its `parameters` are the
285
+ // live.* objects the surface generated, in the order they were created - but ORDER
286
+ // IS NOT A CONTRACT (add a dial and every index shifts), so match on a name.
287
+ //
288
+ // WHICH name is the hard-won part. The build stores the surface id as the
289
+ // parameter's longname, and the box KEEPS it (`_parameter_longname` reads back
290
+ // "cutoff") - but Live's parameter registration names the DeviceParameter after
291
+ // the SHORTNAME anyway, and no patcher data we found overrides that. So do not
292
+ // bet on either policy: ask the BOX for both of its names and accept whichever
293
+ // one Live used. The surface id stays the only key an app ever passes; the
294
+ // display names never leave this function.
295
+ var found = 0;
296
+ try {
297
+ var dev = new LiveAPI("this_device");
298
+ if (!dev || !dev.id) {
299
+ post("m4l-jweb: get_param_id " + id + " -> no this_device (called during load?)\n");
300
+ outlet(0, "param_id", id, 0);
301
+ return;
302
+ }
303
+
304
+ // The box is the authority on its own names - reading them here (instead of
305
+ // shipping a second id->shortname table) means a renamed dial cannot drift
306
+ // out of step with this lookup.
307
+ var accept: string[] = [id];
308
+ try {
309
+ var mobj = this.patcher.getnamed("param-" + id);
310
+ if (mobj) {
311
+ var ln = mobj.getattr("_parameter_longname");
312
+ var sn = mobj.getattr("_parameter_shortname");
313
+ if (ln !== null && ln !== undefined) accept.push(String(ln));
314
+ if (sn !== null && sn !== undefined) accept.push(String(sn));
315
+ }
316
+ } catch (eb) {
317
+ /* no box, no extra candidates - the surface id alone still gets a chance */
318
+ }
319
+
320
+ var n = dev.getcount("parameters");
321
+ var seen: string[] = [];
322
+ for (var i = 0; i < n; i++) {
323
+ var p = new LiveAPI("this_device parameters " + i);
324
+ if (!p || !p.id) continue;
325
+ var pname = String(p.get("name"));
326
+ var hit = false;
327
+ for (var k = 0; k < accept.length; k++) {
328
+ if (pname === accept[k]) {
329
+ hit = true;
330
+ break;
331
+ }
332
+ }
333
+ if (hit) {
334
+ if (found) {
335
+ // Two parameters wearing one accepted name: refuse to guess. Binding a
336
+ // live.remote~ to the wrong parameter is a modulation on someone else's
337
+ // control, which is worse than no modulation.
338
+ post("m4l-jweb: get_param_id " + id + ' -> AMBIGUOUS: two parameters answer to "' + pname + '". Give them distinct short names.\n');
339
+ found = 0;
340
+ break;
341
+ }
342
+ found = p.id;
343
+ } else {
344
+ seen.push(pname);
345
+ }
346
+ }
347
+ // Print what IS there: "no parameter of that name" alone cannot distinguish a
348
+ // renamed parameter from an empty list from a shifted path.
349
+ if (!found) {
350
+ post(
351
+ "m4l-jweb: get_param_id " + id + " -> no match (accepted: " + accept.join(" | ") + ") among " + n + " parameters: " + seen.join(", ") + "\n",
352
+ );
353
+ }
354
+ } catch (e) {
355
+ post("m4l-jweb: get_param_id " + id + " error: " + (e as Error).message + "\n");
356
+ }
357
+ outlet(0, "param_id", id, found);
358
+ }
359
+
207
360
  /* ------------------------------------------------------------------ *
208
361
  * Floating-window messages
209
362
  *
package/src/liveapi.ts CHANGED
@@ -103,15 +103,21 @@ function sendCurrentTempo(): void {
103
103
  * observeProperty("live_set", "scale_name", "scale") forwards every change to
104
104
  * the UI as `scale <value>`. Returns the LiveAPI object so you can keep it
105
105
  * alive; drop it and the observer dies with it.
106
+ *
107
+ * The value is the property's FIRST atom - a watch is scalar (a tempo, a
108
+ * numerator, a name), which is every real Live property one wants to observe.
109
+ * That is not a shortcut: it is forced by how `outlet` must be called. `outlet`
110
+ * is a Max HOST function, and `.apply`-ing it to spread a variadic message is
111
+ * unreliable - it errors "jsliveapi: bad outlet index 0" from inside a LiveAPI
112
+ * callback in Live, killing the notification. So the value goes out fixed-arity,
113
+ * exactly as the tempo observer's `outlet(0, "tempo", a[1])` does - the one shape
114
+ * that works. A property with several atoms forwards its first.
106
115
  */
107
116
  function observeProperty(objectPath: string, property: string, selector: string): LiveAPI | null {
108
117
  try {
109
118
  var api = new LiveAPI(function (a: unknown[]) {
110
- if (a && a[0] == property) {
111
- var args: unknown[] = [0, selector];
112
- for (var i = 1; i < a.length; i++) args.push(a[i]);
113
- (outlet as Function).apply(this, args);
114
- }
119
+ // a = [property, value] for a scalar property. Fixed-arity, never outlet.apply.
120
+ if (a && a[0] == property) outlet(0, selector, a[1]);
115
121
  }, objectPath);
116
122
  api.property = property;
117
123
  return api;
@@ -192,9 +198,13 @@ function firstEmptySlot(): LiveAPI | null {
192
198
  }
193
199
 
194
200
  /**
195
- * read_notes - pick a clip on this device's track (the playing one, else the
196
- * first found), read its notes and send them to the UI as
201
+ * read_notes - pick a clip on this device's TRACK (the playing one, else the first
202
+ * found), read its notes and send them to the UI as
197
203
  * "notes <loopEnd> <n> <pitch start duration> ...".
204
+ *
205
+ * This ignores the Live SELECTION on purpose: a device that reads/writes its own
206
+ * track's pattern (m4l-strudel) wants its track's clip, not wherever the cursor
207
+ * happens to be. For the selection-driven case use read_selected_clip below.
198
208
  */
199
209
  function read_notes(): void {
200
210
  var clip = pickClip();
@@ -203,6 +213,40 @@ function read_notes(): void {
203
213
  outlet(0, "read_error", "no_clip");
204
214
  return;
205
215
  }
216
+ emitClipNotes(clip);
217
+ }
218
+
219
+ /**
220
+ * read_selected_clip - read the clip the CURSOR is on (Live's highlighted clip slot),
221
+ * whichever track and scene that is. An empty highlighted slot is "no clip", which is
222
+ * what makes clicking an empty slot and reading report nothing rather than falling
223
+ * back to some other clip on the track. Same reply shape as read_notes.
224
+ */
225
+ function read_selected_clip(): void {
226
+ var clip = selectedClip();
227
+ if (!clip) {
228
+ post("m4l-jweb: highlighted clip slot is empty (or none)\n");
229
+ outlet(0, "read_error", "no_selection");
230
+ return;
231
+ }
232
+ emitClipNotes(clip);
233
+ }
234
+
235
+ /** The clip in Live's highlighted clip slot, or null if that slot is empty. */
236
+ function selectedClip(): LiveAPI | null {
237
+ try {
238
+ var slot = new LiveAPI("live_set view highlighted_clip_slot");
239
+ if (!slot || !slot.id || Number(slot.id) === 0) return null;
240
+ if (parseInt(String(slot.get("has_clip")), 10) !== 1) return null;
241
+ return new LiveAPI(slot.unquotedpath + " clip");
242
+ } catch (e) {
243
+ post("m4l-jweb: selectedClip error " + (e as Error).message + "\n");
244
+ return null;
245
+ }
246
+ }
247
+
248
+ /** Send a clip's notes to the UI: "notes <loopEnd> <n> <pitch start duration> ...". */
249
+ function emitClipNotes(clip: LiveAPI): void {
206
250
  var loopEnd = parseFloat(String(clip.get("loop_end")));
207
251
  var notes = getNotes(clip, loopEnd);
208
252
  if (!notes) return;
@@ -211,9 +255,12 @@ function read_notes(): void {
211
255
  for (var i = 0; i < notes.length; i++) {
212
256
  out.push(notes[i].pitch, notes[i].start_time, notes[i].duration);
213
257
  }
214
- // A note list is variadic, so the message has to be spread with apply().
215
- // outlet()'s typed signature cannot express that; go through Function.
216
- (outlet as Function).apply(this, ([0] as unknown[]).concat(out));
258
+ // A note list is variadic. Do NOT spread it with `outlet.apply` - `outlet` is a Max
259
+ // HOST function, and calling `.apply` on it faults the [js] engine (js.mxe64) with an
260
+ // access violation, taking Live down; "jsliveapi: bad outlet index 0" is its warning
261
+ // shot. Max outputs an ARRAY passed as the single argument as a list, first atom the
262
+ // selector - so `outlet(0, ["notes", ...])` sends the same message, no apply.
263
+ outlet(0, out);
217
264
  post("m4l-jweb: read " + notes.length + " notes (loop_end " + loopEnd + ")\n");
218
265
  }
219
266
 
package/src/max.d.ts CHANGED
@@ -131,6 +131,28 @@ declare class LiveAPI {
131
131
  constructor(pathOrCallback: string | ((args: unknown[]) => void), path?: string);
132
132
  property: string;
133
133
  unquotedpath: string;
134
+ /**
135
+ * The object's LOM id, and the FIRST thing to check on anything you construct: a
136
+ * path that does not resolve gives you an object with id 0 rather than an error,
137
+ * and every get() on it then returns nothing, quietly. `if (!api.id)` is the guard.
138
+ *
139
+ * NOT STABLE ACROSS SET RELOADS. An id is a handle into the running set, not a
140
+ * name - never persist one (see the `remote` chain, which binds by id and therefore
141
+ * makes re-binding on load the app's job).
142
+ */
143
+ readonly id: number;
144
+ /** The canonical path this object resolved to - not necessarily the one you asked for. */
145
+ readonly path: string;
146
+ /** The LOM class name, e.g. "Track", "DeviceParameter". */
147
+ readonly type: string;
148
+ /**
149
+ * What this object actually HAS - its properties, children and methods, as text.
150
+ *
151
+ * The honest way to ask whether a method exists before calling it: a blind call
152
+ * cannot tell "no such method" from "the method failed", and the LOM is only
153
+ * partly documented. Worth a post() when you are exploring.
154
+ */
155
+ readonly info: string;
134
156
  get(prop: string): unknown;
135
157
  set(prop: string, value: unknown): void;
136
158
  getcount(child: string): number;
@@ -154,6 +176,13 @@ declare const EXTRA_PAYLOAD_NAMES: string[] | undefined;
154
176
  declare const EXTRA_PAYLOAD_BYTES: number[] | undefined;
155
177
  declare const EXTRA_PAYLOAD_B64: string[][] | undefined;
156
178
 
179
+ /**
180
+ * Injected by @m4l-jweb/build from the device's `defineWatch()`: the Live
181
+ * properties to observe. The packaged wrapper attaches an observer per entry from
182
+ * bang() (setupWatches, watch.ts) - undefined for a device that declares none.
183
+ */
184
+ declare const WATCH_SPECS: { key: string; path: string; property: string }[] | undefined;
185
+
157
186
  /*
158
187
  * Device hooks.
159
188
  *
package/src/watch.ts ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * watch.ts - the observers a device DECLARED with defineWatch(), created here.
3
+ *
4
+ * Concatenated after liveapi.ts, so it can call observeProperty(). The list of
5
+ * what to observe is injected by the build as WATCH_SPECS (like BUILD_STAMP and
6
+ * the payloads) - one array per device, from its src/app/<device>/watch.ts. This
7
+ * file is generic: it observes whatever the array names, and a device that
8
+ * declared no watches ships an undefined WATCH_SPECS and this does nothing.
9
+ *
10
+ * WHY THE DECLARATION EXISTS AT ALL. A LiveAPI observer built during loadbang is
11
+ * DEAD - it constructs without error and notifies nothing, forever (hard rule 4).
12
+ * The only safe moment is live.thisdevice's bang. So the observers are not
13
+ * hand-written in a device's wrapper/device.ts, where the trap is one typo away;
14
+ * they are declared as data and created HERE, from bang(), unconditionally - which
15
+ * is the one place, and the one way, that is correct by construction.
16
+ */
17
+
18
+ /**
19
+ * The attached observers, kept alive for the life of the device: drop the LiveAPI
20
+ * and its observer dies with it. Recreated (not reused) on every bang - an object
21
+ * from a previous, loading context is dead and must not be trusted.
22
+ */
23
+ var watchObservers: (LiveAPI | null)[] = [];
24
+
25
+ /** Attach every declared observer. Call from bang() (and reload()), never loadbang(). */
26
+ function setupWatches(): void {
27
+ if (typeof WATCH_SPECS === "undefined") return;
28
+ // Recreate unconditionally - a guard like `if (watchObservers.length) return`
29
+ // would make hard rule 4 permanent, keeping a dead observer forever.
30
+ watchObservers = [];
31
+ for (var i = 0; i < WATCH_SPECS.length; i++) {
32
+ var w = WATCH_SPECS[i];
33
+ // observeProperty forwards every change as `watch_<key> <value...>` - the same
34
+ // shape a parameter uses, so the app binds it exactly like `useParam`'s inlet.
35
+ watchObservers.push(observeProperty(w.path, w.property, "watch_" + w.key));
36
+ }
37
+ if (WATCH_SPECS.length) post("m4l-jweb: watching " + WATCH_SPECS.length + " Live propert" + (WATCH_SPECS.length === 1 ? "y" : "ies") + "\n");
38
+ }
39
+
40
+ /**
41
+ * Send each watched property's CURRENT value once, on ui_ready.
42
+ *
43
+ * The observer's first callback can beat the page's binding - the page loads
44
+ * asynchronously and long after bang() attached the observer - so the app would
45
+ * miss the value it had at load. This is the watch twin of sendCurrentTempo(): a
46
+ * fresh read, straight to the device view. It goes out outlet(0) like tick and
47
+ * tempo, not through reply(): a watch streams to the device UI, and a window is an
48
+ * editor that never receives the transport clock either.
49
+ */
50
+ function resendWatches(): void {
51
+ if (typeof WATCH_SPECS === "undefined") return;
52
+ for (var i = 0; i < WATCH_SPECS.length; i++) {
53
+ var w = WATCH_SPECS[i];
54
+ try {
55
+ var api = new LiveAPI(w.path);
56
+ var v = api.get(w.property);
57
+ // LiveAPI.get returns an array for a multi-atom property and a bare value for a
58
+ // scalar; take the first atom either way. Duck-typed, not `instanceof Array`: a
59
+ // Max array is not a JS Array instance, nor is one crossing a vm realm in tests.
60
+ if (v !== null && typeof v === "object" && typeof (v as { length?: unknown }).length === "number") {
61
+ v = (v as unknown[])[0];
62
+ }
63
+ // Fixed-arity, never outlet.apply - the same rule observeProperty follows, and for
64
+ // the same reason: apply on the host outlet errors "bad outlet index 0" in Live.
65
+ outlet(0, "watch_" + w.key, v);
66
+ } catch (e) {
67
+ post("m4l-jweb: watch resend failed for " + w.key + " - " + (e as Error).message + "\n");
68
+ }
69
+ }
70
+ }