@m4l-jweb/wrapper 0.7.0 → 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 +1 -1
- package/sources.mjs +1 -0
- package/src/core.ts +106 -0
- package/src/liveapi.ts +57 -10
- package/src/max.d.ts +29 -0
- package/src/watch.ts +70 -0
package/package.json
CHANGED
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
|
-
|
|
111
|
-
|
|
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
|
|
196
|
-
*
|
|
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
|
|
215
|
-
//
|
|
216
|
-
|
|
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
|
+
}
|