@m4l-jweb/wrapper 0.7.0 → 0.9.1

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.7.0",
3
+ "version": "0.9.1",
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();
@@ -251,6 +254,109 @@ function setNativeHidden(varname: string, hidden: number): void {
251
254
  }
252
255
  }
253
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
+
254
360
  /* ------------------------------------------------------------------ *
255
361
  * Floating-window messages
256
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;
@@ -133,9 +139,26 @@ interface LiveNote {
133
139
  mute?: number;
134
140
  }
135
141
 
136
- /** The LiveAPI for the track this device sits on. */
142
+ /**
143
+ * The LiveAPI for the TRACK this device sits on - clip I/O belongs to the track.
144
+ *
145
+ * `this_device canonical_parent` is the track ONLY when the device sits directly on
146
+ * it. Inside a Rack it is the CHAIN the device is in, and a Chain has no `clip_slots` -
147
+ * so `getcount("clip_slots")` on it throws "invalid property name" once a second (the
148
+ * clip-availability poll), and clip read/write silently target the wrong object. So
149
+ * climb the `canonical_parent` chain until a Track is reached, which handles a device
150
+ * on a bare track (no climb), in a rack (one hop), and in a nested rack (several).
151
+ */
137
152
  function ownTrack(): LiveAPI {
138
- return new LiveAPI("this_device canonical_parent");
153
+ var api = new LiveAPI("this_device canonical_parent");
154
+ var guard = 0;
155
+ // id 0 is an unresolved path; stop rather than build "... canonical_parent" onto
156
+ // nothing. The guard is a backstop against a parent cycle the LOM should never have.
157
+ while (api && api.id && api.type !== "Track" && guard < 12) {
158
+ api = new LiveAPI(api.unquotedpath + " canonical_parent");
159
+ guard++;
160
+ }
161
+ return api;
139
162
  }
140
163
 
141
164
  /**
@@ -148,9 +171,20 @@ function write_clip(): void {
148
171
  var lengthBeats = a[0];
149
172
  var n = Number(a[1]);
150
173
 
174
+ // No reachable Track is a STRUCTURAL failure (clip I/O is impossible here), distinct
175
+ // from a track whose slots are all full. The UI disables clip export on the former
176
+ // and only reports the latter, so `write_error` says which.
177
+ var track = ownTrack();
178
+ if (!track || !track.id || track.type !== "Track") {
179
+ post("m4l-jweb: write_clip - no reachable track (device not on a track?)\n");
180
+ outlet(0, "write_error", "no_track");
181
+ return;
182
+ }
183
+
151
184
  var slot = firstEmptySlot();
152
185
  if (!slot) {
153
186
  post("m4l-jweb: no empty clip slot on this track\n");
187
+ outlet(0, "write_error", "no_slot");
154
188
  return;
155
189
  }
156
190
  slot.call("create_clip", lengthBeats);
@@ -171,6 +205,7 @@ function write_clip(): void {
171
205
  clip.call("add_new_notes", { notes: notes });
172
206
  } catch (e) {
173
207
  post("m4l-jweb: add_new_notes failed - " + (e as Error).message + "\n");
208
+ outlet(0, "write_error", "add_failed");
174
209
  return;
175
210
  }
176
211
  post("m4l-jweb: wrote " + n + " notes over " + lengthBeats + " beats\n");
@@ -192,17 +227,64 @@ function firstEmptySlot(): LiveAPI | null {
192
227
  }
193
228
 
194
229
  /**
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
230
+ * read_notes - pick a clip on this device's TRACK (the playing one, else the first
231
+ * found), read its notes and send them to the UI as
197
232
  * "notes <loopEnd> <n> <pitch start duration> ...".
233
+ *
234
+ * This ignores the Live SELECTION on purpose: a device that reads/writes its own
235
+ * track's pattern (m4l-strudel) wants its track's clip, not wherever the cursor
236
+ * happens to be. For the selection-driven case use read_selected_clip below.
198
237
  */
199
238
  function read_notes(): void {
239
+ // Structural failure (no reachable track) is reported distinctly from "a track with
240
+ // no clip", so the UI can disable clip import where it is impossible and merely say
241
+ // "no clip" where it is not.
242
+ var track = ownTrack();
243
+ if (!track || !track.id || track.type !== "Track") {
244
+ post("m4l-jweb: read_notes - no reachable track (device not on a track?)\n");
245
+ outlet(0, "read_error", "no_track");
246
+ return;
247
+ }
200
248
  var clip = pickClip();
201
249
  if (!clip) {
202
250
  post("m4l-jweb: no clip found on this track\n");
203
251
  outlet(0, "read_error", "no_clip");
204
252
  return;
205
253
  }
254
+ emitClipNotes(clip);
255
+ }
256
+
257
+ /**
258
+ * read_selected_clip - read the clip the CURSOR is on (Live's highlighted clip slot),
259
+ * whichever track and scene that is. An empty highlighted slot is "no clip", which is
260
+ * what makes clicking an empty slot and reading report nothing rather than falling
261
+ * back to some other clip on the track. Same reply shape as read_notes.
262
+ */
263
+ function read_selected_clip(): void {
264
+ var clip = selectedClip();
265
+ if (!clip) {
266
+ post("m4l-jweb: highlighted clip slot is empty (or none)\n");
267
+ outlet(0, "read_error", "no_selection");
268
+ return;
269
+ }
270
+ emitClipNotes(clip);
271
+ }
272
+
273
+ /** The clip in Live's highlighted clip slot, or null if that slot is empty. */
274
+ function selectedClip(): LiveAPI | null {
275
+ try {
276
+ var slot = new LiveAPI("live_set view highlighted_clip_slot");
277
+ if (!slot || !slot.id || Number(slot.id) === 0) return null;
278
+ if (parseInt(String(slot.get("has_clip")), 10) !== 1) return null;
279
+ return new LiveAPI(slot.unquotedpath + " clip");
280
+ } catch (e) {
281
+ post("m4l-jweb: selectedClip error " + (e as Error).message + "\n");
282
+ return null;
283
+ }
284
+ }
285
+
286
+ /** Send a clip's notes to the UI: "notes <loopEnd> <n> <pitch start duration> ...". */
287
+ function emitClipNotes(clip: LiveAPI): void {
206
288
  var loopEnd = parseFloat(String(clip.get("loop_end")));
207
289
  var notes = getNotes(clip, loopEnd);
208
290
  if (!notes) return;
@@ -211,9 +293,12 @@ function read_notes(): void {
211
293
  for (var i = 0; i < notes.length; i++) {
212
294
  out.push(notes[i].pitch, notes[i].start_time, notes[i].duration);
213
295
  }
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));
296
+ // A note list is variadic. Do NOT spread it with `outlet.apply` - `outlet` is a Max
297
+ // HOST function, and calling `.apply` on it faults the [js] engine (js.mxe64) with an
298
+ // access violation, taking Live down; "jsliveapi: bad outlet index 0" is its warning
299
+ // shot. Max outputs an ARRAY passed as the single argument as a list, first atom the
300
+ // selector - so `outlet(0, ["notes", ...])` sends the same message, no apply.
301
+ outlet(0, out);
217
302
  post("m4l-jweb: read " + notes.length + " notes (loop_end " + loopEnd + ")\n");
218
303
  }
219
304
 
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
+ }