@m4l-jweb/wrapper 0.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/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@m4l-jweb/wrapper",
3
+ "version": "0.1.0",
4
+ "description": "m4l-jweb: the Max for Live glue layer connecting a device to LiveAPI.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/alienmind/m4l-jweb.git",
10
+ "directory": "packages/wrapper"
11
+ },
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "exports": {
16
+ "./sources": "./sources.mjs",
17
+ "./package.json": "./package.json"
18
+ },
19
+ "files": [
20
+ "src",
21
+ "sources.mjs"
22
+ ]
23
+ }
package/sources.mjs ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The wrapper is not a library you import - Max's [js] has no module system.
3
+ * It is a set of TypeScript sources the build COMPILES TOGETHER and
4
+ * CONCATENATES, in this order, into one ES5 script.
5
+ *
6
+ * Because they all compile as global scripts in a single TS program, they see
7
+ * each other's functions (core's bang() calls liveapi's startTickPoll()), and
8
+ * TypeScript still typechecks across the seam.
9
+ */
10
+ import path from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+
13
+ const here = path.dirname(fileURLToPath(import.meta.url));
14
+ const src = (f) => path.join(here, "src", f);
15
+
16
+ /** Ambient types for post/outlet/LiveAPI/Task/File. Typechecked, never emitted. */
17
+ export const types = src("max.d.ts");
18
+
19
+ /**
20
+ * Ordered. core must come first: it owns the lifecycle that the rest hooks
21
+ * into. A device's own `wrapper/device.ts`, if present, is appended after these
22
+ * by the build, so it can define extra message handlers.
23
+ */
24
+ export const sources = [
25
+ src("core.ts"), // build stamp, lifecycle, the anything() guard, payload extraction
26
+ src("liveapi.ts"), // transport poll, tempo observer, clip I/O
27
+ ];
package/src/core.ts ADDED
@@ -0,0 +1,267 @@
1
+ /**
2
+ * core.ts - lifecycle, the message guard, and the self-extracting UI payload.
3
+ *
4
+ * This runs inside Max's [js]: an ES5-era interpreter with no modules, no
5
+ * `console` (use `post`), and no `setTimeout` (use `Task`). It is compiled with
6
+ * `target: "ES5"` and the build then re-parses the output with acorn at
7
+ * `ecmaVersion: 5`, refusing to package on failure. One stray modern token
8
+ * kills the whole script at load, with a one-line error and no stack.
9
+ *
10
+ * Outlets:
11
+ * 0 - to [jweb] ("url ...", "mode ...", "tick ...", "tempo ...", "build ...")
12
+ * 1 - spare/aux (a second consumer: another engine, a print, ...)
13
+ */
14
+
15
+ autowatch = 1;
16
+ inlets = 1;
17
+ outlets = 2;
18
+
19
+ /**
20
+ * Set from the object box: `js wrapper.js <mode>` (see patcher/devices.mjs).
21
+ * Note jsarguments[0] is the SCRIPT NAME, not the first argument - the device
22
+ * mode is at index 1. Reading index 0 gets you the string "wrapper.js" and a
23
+ * mode comparison that is silently false forever.
24
+ */
25
+ var MODE: string = jsarguments.length > 1 ? String(jsarguments[1]) : "midi";
26
+
27
+ post("m4l-jweb: wrapper loaded (build " + buildStamp() + ", mode " + MODE + ")\n");
28
+
29
+ /** The build this device instance actually is. Injected by the build. */
30
+ function buildStamp(): string {
31
+ return typeof BUILD_STAMP !== "undefined" ? BUILD_STAMP : "dev";
32
+ }
33
+
34
+ /* ------------------------------------------------------------------ *
35
+ * Lifecycle
36
+ *
37
+ * LiveAPI objects created in a patcher-loading context (loadbang) are DEAD:
38
+ * they construct without error and then observe nothing, forever. Create every
39
+ * observer from live.thisdevice's bang, which fires once the device is fully in
40
+ * the Live set. Guard code like `if (obs) return` turns this bug permanent -
41
+ * recreate unconditionally.
42
+ *
43
+ * loadbang does file work only.
44
+ * ------------------------------------------------------------------ */
45
+
46
+ /** live.thisdevice -> the device is fully loaded. Everything LiveAPI starts here. */
47
+ function bang(): void {
48
+ post("m4l-jweb: bang (device ready)\n");
49
+ extractExtraPayloads();
50
+ loadWebview();
51
+ setupTempoObserver(); // liveapi.ts
52
+ startTickPoll(); // liveapi.ts
53
+ // A device's own wrapper/device.ts hooks in here: this is the ONLY safe place
54
+ // to create LiveAPI objects (see the loadbang trap above).
55
+ if (typeof onDeviceReady === "function") onDeviceReady();
56
+ }
57
+
58
+ /** Patcher loaded. File work is safe here; LiveAPI is NOT. */
59
+ function loadbang(): void {
60
+ post("m4l-jweb: loadbang\n");
61
+ extractExtraPayloads();
62
+ loadWebview();
63
+ }
64
+
65
+ /** Manual re-init, handy while developing. */
66
+ function reload(): void {
67
+ loadWebview();
68
+ setupTempoObserver();
69
+ startTickPoll();
70
+ }
71
+
72
+ /**
73
+ * The [jweb] outlet can fan out to this [js] AND to other consumers, so
74
+ * messages meant for someone else land here too. Swallow them instead of
75
+ * logging "js: no function <name>" on every one.
76
+ */
77
+ function anything(): void {}
78
+
79
+ /**
80
+ * The UI announces it finished loading. The page loads asynchronously, so never
81
+ * assume it was listening when state last changed - resend all of it.
82
+ */
83
+ function ui_ready(): void {
84
+ outlet(0, "mode", MODE);
85
+ // The UI shows this next to its own baked-in version: a mismatch means a
86
+ // mixed install (stale .amxd instance vs newer extracted UI, or vice versa).
87
+ outlet(0, "build", buildStamp());
88
+ sendCurrentTempo(); // liveapi.ts
89
+ // The device resends its own state here. The page loads asynchronously, so
90
+ // anything sent before it was listening is simply gone.
91
+ if (typeof onUiReady === "function") onUiReady();
92
+ }
93
+
94
+ /* ------------------------------------------------------------------ *
95
+ * The self-extracting UI payload
96
+ *
97
+ * Chromium (jweb) cannot read Max's frozen virtual filesystem, so a frozen
98
+ * dependency is invisible to it - you cannot ship a UI inside your own device
99
+ * and then open it. But THIS script always runs. So the build appends the UI
100
+ * html to it as base64 (UI_PAYLOAD_B64 / _BYTES / _NAME), and we write it to a
101
+ * real file next to the .amxd on first load and point jweb at that file:// URL.
102
+ * ------------------------------------------------------------------ */
103
+
104
+ function loadWebview(): void {
105
+ try {
106
+ var url = resolveUiUrl();
107
+ if (!url) return;
108
+ outlet(0, "url", url);
109
+ post("m4l-jweb: sent url " + url + "\n");
110
+ } catch (e) {
111
+ post("m4l-jweb: loadWebview error " + (e as Error).message + "\n");
112
+ }
113
+ }
114
+
115
+ function resolveUiUrl(): string | null {
116
+ var folder = deviceFolder();
117
+ if (!folder) {
118
+ post("m4l-jweb: patcher not saved yet - UI path unknown\n");
119
+ return null;
120
+ }
121
+ var name = typeof UI_PAYLOAD_NAME !== "undefined" ? UI_PAYLOAD_NAME : "ui.html";
122
+ var target = folder + "/" + name;
123
+
124
+ if (typeof UI_PAYLOAD_B64 !== "undefined" && typeof UI_PAYLOAD_BYTES !== "undefined") {
125
+ extractPayload(target, UI_PAYLOAD_B64, UI_PAYLOAD_BYTES);
126
+ } else {
127
+ post("m4l-jweb: no embedded payload (dev build) - using " + target + "\n");
128
+ }
129
+ // Cache-buster: the URL changes per build, so Chromium can never serve a page
130
+ // it cached from a previous build of the same file path.
131
+ return encodeURI("file:///" + target) + "?v=" + encodeURIComponent(buildStamp());
132
+ }
133
+
134
+ /** The folder the .amxd lives in, derived from the patcher's own path. */
135
+ function deviceFolder(): string | null {
136
+ var fp: string = this.patcher.filepath;
137
+ return fp && fp.length ? fp.replace(/\/[^\/]*$/, "") : null;
138
+ }
139
+
140
+ /**
141
+ * Write every non-UI payload the build embedded (manifest `payloads`) next to
142
+ * the .amxd. Same reason as the UI: anything that is not a Max-native object is
143
+ * blind to the frozen virtual filesystem, so it needs a real file.
144
+ *
145
+ * Idempotent, and cheap after the first load: extractPayload() skips a file whose
146
+ * size and build stamp already match.
147
+ */
148
+ function extractExtraPayloads(): void {
149
+ // The build emits all three together or none at all; bind them locally so the
150
+ // compiler can see that too.
151
+ if (typeof EXTRA_PAYLOAD_NAMES === "undefined" || typeof EXTRA_PAYLOAD_B64 === "undefined" || typeof EXTRA_PAYLOAD_BYTES === "undefined") {
152
+ return;
153
+ }
154
+ var names = EXTRA_PAYLOAD_NAMES;
155
+ var blobs = EXTRA_PAYLOAD_B64;
156
+ var sizes = EXTRA_PAYLOAD_BYTES;
157
+
158
+ var folder = deviceFolder();
159
+ if (!folder) {
160
+ post("m4l-jweb: patcher path unknown - cannot extract payloads\n");
161
+ return;
162
+ }
163
+ for (var i = 0; i < names.length; i++) {
164
+ extractPayload(folder + "/" + names[i], blobs[i], sizes[i]);
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Write an embedded base64 payload to targetPath.
170
+ *
171
+ * Skipped only when BOTH the size matches AND a sidecar .stamp file records the
172
+ * same build - size alone proved too weak, since different builds can collide
173
+ * and leave a stale file that no longer matches the wrapper driving it.
174
+ */
175
+ function extractPayload(targetPath: string, b64chunks: string[], byteCount: number): void {
176
+ try {
177
+ var existing = new File(targetPath);
178
+ if (existing.isopen) {
179
+ var sameSize = existing.eof === byteCount;
180
+ existing.close();
181
+ if (sameSize && readTextFile(targetPath + ".stamp") === buildStamp()) return;
182
+ }
183
+ } catch (e) {
184
+ /* fall through and (re)write */
185
+ }
186
+ try {
187
+ var out = new File(targetPath, "write");
188
+ if (!out.isopen) out.open();
189
+ if (!out.isopen) {
190
+ post("m4l-jweb: cannot write " + targetPath + "\n");
191
+ return;
192
+ }
193
+ out.eof = 0;
194
+ // File.writebytes silently truncates large calls (observed ~16 KB cap), so
195
+ // write in small slices and verify the byte count afterwards.
196
+ var SLICE = 4096;
197
+ for (var i = 0; i < b64chunks.length; i++) {
198
+ var bytes = b64decode(b64chunks[i]);
199
+ for (var off = 0; off < bytes.length; off += SLICE) {
200
+ out.writebytes(bytes.slice(off, off + SLICE));
201
+ }
202
+ }
203
+ out.close();
204
+
205
+ var check = new File(targetPath);
206
+ var written = check.isopen ? check.eof : -1;
207
+ if (check.isopen) check.close();
208
+ if (written === byteCount) {
209
+ post("m4l-jweb: extracted " + written + " bytes to " + targetPath + "\n");
210
+ writeTextFile(targetPath + ".stamp", buildStamp());
211
+ } else {
212
+ post("m4l-jweb: extract SIZE MISMATCH - wrote " + written + ", expected " + byteCount + "\n");
213
+ }
214
+ } catch (e2) {
215
+ post("m4l-jweb: extract failed - " + (e2 as Error).message + "\n");
216
+ }
217
+ }
218
+
219
+ function readTextFile(p: string): string | null {
220
+ try {
221
+ var f = new File(p);
222
+ if (!f.isopen) return null;
223
+ var s = f.readstring(Math.min(f.eof, 256));
224
+ f.close();
225
+ return s;
226
+ } catch (e) {
227
+ return null;
228
+ }
229
+ }
230
+
231
+ function writeTextFile(p: string, s: string): void {
232
+ try {
233
+ var f = new File(p, "write");
234
+ if (!f.isopen) f.open();
235
+ if (!f.isopen) return;
236
+ f.eof = 0;
237
+ f.writestring(s);
238
+ f.close();
239
+ } catch (e) {
240
+ /* non-fatal */
241
+ }
242
+ }
243
+
244
+ var B64CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
245
+ var b64lookup: { [c: string]: number } | null = null;
246
+
247
+ /** Base64 -> array of byte values. Max's [js] has no atob. */
248
+ function b64decode(s: string): number[] {
249
+ if (!b64lookup) {
250
+ b64lookup = {};
251
+ for (var i = 0; i < B64CHARS.length; i++) b64lookup[B64CHARS.charAt(i)] = i;
252
+ }
253
+ var out: number[] = [];
254
+ var buffer = 0;
255
+ var bits = 0;
256
+ for (var j = 0; j < s.length; j++) {
257
+ var c = s.charAt(j);
258
+ if (c === "=") break;
259
+ buffer = (buffer << 6) | b64lookup[c];
260
+ bits += 6;
261
+ if (bits >= 8) {
262
+ bits -= 8;
263
+ out.push((buffer >> bits) & 0xff);
264
+ }
265
+ }
266
+ return out;
267
+ }
package/src/liveapi.ts ADDED
@@ -0,0 +1,250 @@
1
+ /**
2
+ * liveapi.ts - everything that touches Live's object model.
3
+ *
4
+ * Concatenated after core.ts into a single ES5 script, so these functions are
5
+ * visible to core's lifecycle (bang() calls startTickPoll() and
6
+ * setupTempoObserver()) without any module system.
7
+ *
8
+ * If your device does not touch clips, the clip I/O half is dead weight but
9
+ * harmless - Max only calls what the patcher routes to it.
10
+ */
11
+
12
+ /* ------------------------------------------------------------------ *
13
+ * Transport: "tick <playing> <beats>" at 20 Hz
14
+ *
15
+ * Polled from LiveAPI (live_set is_playing + current_song_time), NOT from a
16
+ * [plugsync~] -> [snapshot~] signal chain: MIDI-effect devices do not reliably
17
+ * run a DSP graph, and such a chain reads zero in the field. LiveAPI has no such
18
+ * dependency and works in every device type. An engine's lookahead window
19
+ * absorbs the 20 Hz poll rate.
20
+ * ------------------------------------------------------------------ */
21
+
22
+ var tickPoll = new Task(pollTransport, this);
23
+ var liveSetApi: LiveAPI | null = null;
24
+
25
+ function startTickPoll(): void {
26
+ try {
27
+ liveSetApi = new LiveAPI("live_set");
28
+ } catch (e) {
29
+ post("m4l-jweb: tick poll unavailable - " + (e as Error).message + "\n");
30
+ return;
31
+ }
32
+ tickPoll.cancel();
33
+ tickPoll.interval = 50;
34
+ tickPoll.repeat();
35
+ post("m4l-jweb: transport poll on\n");
36
+ }
37
+
38
+ function pollTransport(): void {
39
+ if (!liveSetApi) return;
40
+ try {
41
+ var playing = parseInt(String(liveSetApi.get("is_playing")), 10);
42
+ var beats = parseFloat(String(liveSetApi.get("current_song_time")));
43
+ outlet(0, "tick", playing, beats);
44
+ // A device with a second consumer (another engine on outlet 1) mirrors the
45
+ // clock here rather than polling Live twice.
46
+ if (typeof onTick === "function") onTick(playing, beats);
47
+ } catch (e) {
48
+ /* transient - the next poll retries */
49
+ }
50
+ }
51
+
52
+ /* ------------------------------------------------------------------ *
53
+ * Tempo: observed, not polled.
54
+ *
55
+ * The signal-domain alternative reports samples-per-beat, not BPM. The observer
56
+ * callback fires once on attach and then on every change.
57
+ * ------------------------------------------------------------------ */
58
+
59
+ var tempoObs: LiveAPI | null = null;
60
+
61
+ function setupTempoObserver(): void {
62
+ // Recreate unconditionally: an object left over from a loading context is
63
+ // dead and must not block the real one.
64
+ try {
65
+ tempoObs = new LiveAPI(onTempo, "live_set");
66
+ tempoObs.property = "tempo";
67
+ post("m4l-jweb: tempo observer on (current " + tempoObs.get("tempo") + ")\n");
68
+ } catch (e) {
69
+ post("m4l-jweb: tempo observer unavailable - " + (e as Error).message + "\n");
70
+ }
71
+ }
72
+
73
+ function onTempo(a: unknown[]): void {
74
+ if (a && a[0] == "tempo") {
75
+ outlet(0, "tempo", a[1]);
76
+ if (typeof onTempoChange === "function") onTempoChange(Number(a[1]));
77
+ }
78
+ }
79
+
80
+ /** The observer's first callback can beat the page's binding - ui_ready re-reads. */
81
+ function sendCurrentTempo(): void {
82
+ try {
83
+ var api = new LiveAPI("live_set");
84
+ var t = parseFloat(String(api.get("tempo")));
85
+ if (t > 0) {
86
+ outlet(0, "tempo", t);
87
+ if (typeof onTempoChange === "function") onTempoChange(t);
88
+ }
89
+ } catch (e) {
90
+ post("m4l-jweb: tempo read failed - " + (e as Error).message + "\n");
91
+ }
92
+ }
93
+
94
+ /* ------------------------------------------------------------------ *
95
+ * Generic property observer
96
+ *
97
+ * Anything observable in Live (scale, track name, selected scene...) reaches
98
+ * the UI the same way: attach, forward on change. Call this from bang(), never
99
+ * from loadbang().
100
+ * ------------------------------------------------------------------ */
101
+
102
+ /**
103
+ * observeProperty("live_set", "scale_name", "scale") forwards every change to
104
+ * the UI as `scale <value>`. Returns the LiveAPI object so you can keep it
105
+ * alive; drop it and the observer dies with it.
106
+ */
107
+ function observeProperty(objectPath: string, property: string, selector: string): LiveAPI | null {
108
+ try {
109
+ 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
+ }
115
+ }, objectPath);
116
+ api.property = property;
117
+ return api;
118
+ } catch (e) {
119
+ post("m4l-jweb: cannot observe " + objectPath + " " + property + " - " + (e as Error).message + "\n");
120
+ return null;
121
+ }
122
+ }
123
+
124
+ /* ------------------------------------------------------------------ *
125
+ * Clip I/O
126
+ * ------------------------------------------------------------------ */
127
+
128
+ interface LiveNote {
129
+ pitch: number;
130
+ start_time: number;
131
+ duration: number;
132
+ velocity: number;
133
+ mute?: number;
134
+ }
135
+
136
+ /** The LiveAPI for the track this device sits on. */
137
+ function ownTrack(): LiveAPI {
138
+ return new LiveAPI("this_device canonical_parent");
139
+ }
140
+
141
+ /**
142
+ * write_clip <lengthBeats> <n> <pitch start duration velocity> ...
143
+ * Creates a clip in the first empty slot on this device's track and fills it.
144
+ */
145
+ function write_clip(): void {
146
+ var a = arrayfromargs(arguments);
147
+ if (a.length < 2) return;
148
+ var lengthBeats = a[0];
149
+ var n = Number(a[1]);
150
+
151
+ var slot = firstEmptySlot();
152
+ if (!slot) {
153
+ post("m4l-jweb: no empty clip slot on this track\n");
154
+ return;
155
+ }
156
+ slot.call("create_clip", lengthBeats);
157
+ var clip = new LiveAPI(slot.unquotedpath + " clip");
158
+
159
+ var notes: LiveNote[] = [];
160
+ for (var k = 0; k < n; k++) {
161
+ var o = 2 + k * 4;
162
+ notes.push({
163
+ pitch: Number(a[o]),
164
+ start_time: Number(a[o + 1]),
165
+ duration: Number(a[o + 2]),
166
+ velocity: Number(a[o + 3]),
167
+ mute: 0,
168
+ });
169
+ }
170
+ try {
171
+ clip.call("add_new_notes", { notes: notes });
172
+ } catch (e) {
173
+ post("m4l-jweb: add_new_notes failed - " + (e as Error).message + "\n");
174
+ return;
175
+ }
176
+ post("m4l-jweb: wrote " + n + " notes over " + lengthBeats + " beats\n");
177
+ }
178
+
179
+ function firstEmptySlot(): LiveAPI | null {
180
+ try {
181
+ var track = ownTrack();
182
+ var count = parseInt(String(track.getcount("clip_slots")), 10);
183
+ for (var i = 0; i < count; i++) {
184
+ var s = new LiveAPI(track.unquotedpath + " clip_slots " + i);
185
+ if (parseInt(String(s.get("has_clip")), 10) === 0) return s;
186
+ }
187
+ return null;
188
+ } catch (e) {
189
+ post("m4l-jweb: firstEmptySlot error " + (e as Error).message + "\n");
190
+ return null;
191
+ }
192
+ }
193
+
194
+ /**
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
197
+ * "notes <loopEnd> <n> <pitch start duration> ...".
198
+ */
199
+ function read_notes(): void {
200
+ var clip = pickClip();
201
+ if (!clip) {
202
+ post("m4l-jweb: no clip found on this track\n");
203
+ outlet(0, "read_error", "no_clip");
204
+ return;
205
+ }
206
+ var loopEnd = parseFloat(String(clip.get("loop_end")));
207
+ var notes = getNotes(clip, loopEnd);
208
+ if (!notes) return;
209
+
210
+ var out: unknown[] = ["notes", loopEnd, notes.length];
211
+ for (var i = 0; i < notes.length; i++) {
212
+ out.push(notes[i].pitch, notes[i].start_time, notes[i].duration);
213
+ }
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));
217
+ post("m4l-jweb: read " + notes.length + " notes (loop_end " + loopEnd + ")\n");
218
+ }
219
+
220
+ function pickClip(): LiveAPI | null {
221
+ try {
222
+ var track = ownTrack();
223
+ var count = parseInt(String(track.getcount("clip_slots")), 10);
224
+ var firstWithClip: LiveAPI | null = null;
225
+ for (var i = 0; i < count; i++) {
226
+ var s = new LiveAPI(track.unquotedpath + " clip_slots " + i);
227
+ if (parseInt(String(s.get("has_clip")), 10) === 1) {
228
+ var c = new LiveAPI(s.unquotedpath + " clip");
229
+ if (parseInt(String(c.get("is_playing")), 10) === 1) return c;
230
+ if (!firstWithClip) firstWithClip = c;
231
+ }
232
+ }
233
+ return firstWithClip;
234
+ } catch (e) {
235
+ post("m4l-jweb: pickClip error " + (e as Error).message + "\n");
236
+ return null;
237
+ }
238
+ }
239
+
240
+ function getNotes(clip: LiveAPI, loopEnd: number): LiveNote[] | null {
241
+ // Live 11+: get_notes_extended returns a JSON string.
242
+ try {
243
+ var d = clip.call("get_notes_extended", 0, 128, 0, loopEnd);
244
+ var obj = typeof d === "string" ? JSON.parse(d) : d;
245
+ if (obj && obj.notes) return obj.notes;
246
+ } catch (e) {
247
+ post("m4l-jweb: get_notes_extended failed - " + (e as Error).message + "\n");
248
+ }
249
+ return null;
250
+ }
package/src/max.d.ts ADDED
@@ -0,0 +1,105 @@
1
+ /**
2
+ * max.d.ts - ambient types for the globals Max's [js] object provides.
3
+ *
4
+ * The [js] runtime is an ES5-era interpreter: no modules, no `console`, no
5
+ * `setTimeout`. It is also the ONLY place with LiveAPI access, and it always
6
+ * runs - even inside a frozen device. Hence this file: enough typing to write
7
+ * the glue in TypeScript without pretending it is a browser or Node.
8
+ */
9
+
10
+ /** Print to the Max console. There is no `console` here. */
11
+ declare function post(...args: unknown[]): void;
12
+
13
+ /** Send a message out of outlet `n`. */
14
+ declare function outlet(n: number, ...args: unknown[]): void;
15
+
16
+ /** Collect the `arguments` of a Max message handler into a real array. */
17
+ declare function arrayfromargs(args: IArguments): unknown[];
18
+
19
+ /** Arguments given to the object box, e.g. `js wrapper.js midi` -> ["midi"]. */
20
+ declare const jsarguments: unknown[];
21
+
22
+ declare let autowatch: number;
23
+ declare let inlets: number;
24
+ declare let outlets: number;
25
+
26
+ /*
27
+ * The patcher hosting this [js] is reached as `this.patcher` - at [js] global
28
+ * scope `this` IS the jsthis object, and a plainly-called function inherits it.
29
+ * `this.patcher.filepath` is how the wrapper locates the .amxd's folder on
30
+ * disk. (Hence `noImplicitThis: false` in wrapper/tsconfig.json.)
31
+ */
32
+
33
+ /** Max's scheduler. There is no `setTimeout` in [js]. */
34
+ declare class Task {
35
+ constructor(fn: () => void, ctx: unknown);
36
+ interval: number;
37
+ repeat(count?: number): void;
38
+ schedule(delayMs?: number): void;
39
+ cancel(): void;
40
+ }
41
+
42
+ /** Max's file object. Note: `writebytes` truncates silently past ~16 KB. */
43
+ declare class File {
44
+ constructor(path: string, mode?: "read" | "write" | "readwrite");
45
+ isopen: boolean;
46
+ eof: number;
47
+ open(): void;
48
+ close(): void;
49
+ readstring(count: number): string;
50
+ writestring(s: string): void;
51
+ writebytes(bytes: number[]): void;
52
+ }
53
+
54
+ /** The Live object model. The whole reason [js] still exists in this stack. */
55
+ declare class LiveAPI {
56
+ constructor(pathOrCallback: string | ((args: unknown[]) => void), path?: string);
57
+ property: string;
58
+ unquotedpath: string;
59
+ get(prop: string): unknown;
60
+ set(prop: string, value: unknown): void;
61
+ getcount(child: string): number;
62
+ call(method: string, ...args: unknown[]): unknown;
63
+ }
64
+
65
+ /** Injected by @m4l-jweb/build: "<version> <iso date>". */
66
+ declare const BUILD_STAMP: string | undefined;
67
+
68
+ /** Injected by @m4l-jweb/build: the UI html, base64, in chunks. */
69
+ declare const UI_PAYLOAD_NAME: string | undefined;
70
+ declare const UI_PAYLOAD_BYTES: number | undefined;
71
+ declare const UI_PAYLOAD_B64: string[] | undefined;
72
+
73
+ /**
74
+ * Injected by @m4l-jweb/build from the manifest's `payloads`: any other file
75
+ * that must exist on disk next to the .amxd, because whatever reads it is not a
76
+ * Max-native object and so cannot see the frozen virtual filesystem.
77
+ */
78
+ declare const EXTRA_PAYLOAD_NAMES: string[] | undefined;
79
+ declare const EXTRA_PAYLOAD_BYTES: number[] | undefined;
80
+ declare const EXTRA_PAYLOAD_B64: string[][] | undefined;
81
+
82
+ /*
83
+ * Device hooks.
84
+ *
85
+ * Define any of these as a plain function in your repo's `wrapper/device.ts` and
86
+ * the packaged wrapper will call it; leave it out and nothing happens. They are
87
+ * declared here (rather than defined) so both sides typecheck: the wrapper guards
88
+ * every call with `typeof onX === "function"`, which is safe even when the
89
+ * identifier was never declared at runtime.
90
+ */
91
+
92
+ /**
93
+ * live.thisdevice has fired: the device is fully loaded and LiveAPI is finally
94
+ * safe. Create your observers HERE - objects built during loadbang are dead.
95
+ */
96
+ declare function onDeviceReady(): void;
97
+
98
+ /** The UI announced itself. Resend any device-specific state it needs. */
99
+ declare function onUiReady(): void;
100
+
101
+ /** Every transport poll (20 Hz), after the packaged wrapper has sent its tick. */
102
+ declare function onTick(playing: number, beats: number): void;
103
+
104
+ /** Live's tempo changed (and once on attach). */
105
+ declare function onTempoChange(bpm: number): void;