@m4l-jweb/wrapper 0.1.0 → 0.3.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/LICENSE +21 -0
- package/package.json +3 -2
- package/src/core.ts +141 -141
- package/src/liveapi.ts +151 -151
- package/src/max.d.ts +85 -20
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jaime Lopez (alienmind)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m4l-jweb/wrapper",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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",
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
},
|
|
19
19
|
"files": [
|
|
20
20
|
"src",
|
|
21
|
-
"sources.mjs"
|
|
21
|
+
"sources.mjs",
|
|
22
|
+
"LICENSE"
|
|
22
23
|
]
|
|
23
24
|
}
|
package/src/core.ts
CHANGED
|
@@ -28,7 +28,7 @@ post("m4l-jweb: wrapper loaded (build " + buildStamp() + ", mode " + MODE + ")\n
|
|
|
28
28
|
|
|
29
29
|
/** The build this device instance actually is. Injected by the build. */
|
|
30
30
|
function buildStamp(): string {
|
|
31
|
-
|
|
31
|
+
return typeof BUILD_STAMP !== "undefined" ? BUILD_STAMP : "dev";
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
/* ------------------------------------------------------------------ *
|
|
@@ -45,28 +45,28 @@ function buildStamp(): string {
|
|
|
45
45
|
|
|
46
46
|
/** live.thisdevice -> the device is fully loaded. Everything LiveAPI starts here. */
|
|
47
47
|
function bang(): void {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
56
|
}
|
|
57
57
|
|
|
58
58
|
/** Patcher loaded. File work is safe here; LiveAPI is NOT. */
|
|
59
59
|
function loadbang(): void {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
post("m4l-jweb: loadbang\n");
|
|
61
|
+
extractExtraPayloads();
|
|
62
|
+
loadWebview();
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
/** Manual re-init, handy while developing. */
|
|
66
66
|
function reload(): void {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
67
|
+
loadWebview();
|
|
68
|
+
setupTempoObserver();
|
|
69
|
+
startTickPoll();
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
/**
|
|
@@ -81,14 +81,14 @@ function anything(): void {}
|
|
|
81
81
|
* assume it was listening when state last changed - resend all of it.
|
|
82
82
|
*/
|
|
83
83
|
function ui_ready(): void {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
92
|
}
|
|
93
93
|
|
|
94
94
|
/* ------------------------------------------------------------------ *
|
|
@@ -102,39 +102,39 @@ function ui_ready(): void {
|
|
|
102
102
|
* ------------------------------------------------------------------ */
|
|
103
103
|
|
|
104
104
|
function loadWebview(): void {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
113
|
}
|
|
114
114
|
|
|
115
115
|
function resolveUiUrl(): string | null {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
123
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
132
|
}
|
|
133
133
|
|
|
134
134
|
/** The folder the .amxd lives in, derived from the patcher's own path. */
|
|
135
135
|
function deviceFolder(): string | null {
|
|
136
|
-
|
|
137
|
-
|
|
136
|
+
var fp: string = this.patcher.filepath;
|
|
137
|
+
return fp && fp.length ? fp.replace(/\/[^\/]*$/, "") : null;
|
|
138
138
|
}
|
|
139
139
|
|
|
140
140
|
/**
|
|
@@ -146,23 +146,23 @@ function deviceFolder(): string | null {
|
|
|
146
146
|
* size and build stamp already match.
|
|
147
147
|
*/
|
|
148
148
|
function extractExtraPayloads(): void {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
157
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
166
|
}
|
|
167
167
|
|
|
168
168
|
/**
|
|
@@ -173,72 +173,72 @@ function extractExtraPayloads(): void {
|
|
|
173
173
|
* and leave a stale file that no longer matches the wrapper driving it.
|
|
174
174
|
*/
|
|
175
175
|
function extractPayload(targetPath: string, b64chunks: string[], byteCount: number): void {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
204
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
217
|
}
|
|
218
218
|
|
|
219
219
|
function readTextFile(p: string): string | null {
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
229
|
}
|
|
230
230
|
|
|
231
231
|
function writeTextFile(p: string, s: string): void {
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
242
|
}
|
|
243
243
|
|
|
244
244
|
var B64CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
@@ -246,22 +246,22 @@ var b64lookup: { [c: string]: number } | null = null;
|
|
|
246
246
|
|
|
247
247
|
/** Base64 -> array of byte values. Max's [js] has no atob. */
|
|
248
248
|
function b64decode(s: string): number[] {
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
267
|
}
|
package/src/liveapi.ts
CHANGED
|
@@ -23,30 +23,30 @@ var tickPoll = new Task(pollTransport, this);
|
|
|
23
23
|
var liveSetApi: LiveAPI | null = null;
|
|
24
24
|
|
|
25
25
|
function startTickPoll(): void {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
36
|
}
|
|
37
37
|
|
|
38
38
|
function pollTransport(): void {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
50
|
}
|
|
51
51
|
|
|
52
52
|
/* ------------------------------------------------------------------ *
|
|
@@ -59,36 +59,36 @@ function pollTransport(): void {
|
|
|
59
59
|
var tempoObs: LiveAPI | null = null;
|
|
60
60
|
|
|
61
61
|
function setupTempoObserver(): void {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
71
|
}
|
|
72
72
|
|
|
73
73
|
function onTempo(a: unknown[]): void {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
74
|
+
if (a && a[0] == "tempo") {
|
|
75
|
+
outlet(0, "tempo", a[1]);
|
|
76
|
+
if (typeof onTempoChange === "function") onTempoChange(Number(a[1]));
|
|
77
|
+
}
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
/** The observer's first callback can beat the page's binding - ui_ready re-reads. */
|
|
81
81
|
function sendCurrentTempo(): void {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
92
|
}
|
|
93
93
|
|
|
94
94
|
/* ------------------------------------------------------------------ *
|
|
@@ -105,20 +105,20 @@ function sendCurrentTempo(): void {
|
|
|
105
105
|
* alive; drop it and the observer dies with it.
|
|
106
106
|
*/
|
|
107
107
|
function observeProperty(objectPath: string, property: string, selector: string): LiveAPI | null {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
122
|
}
|
|
123
123
|
|
|
124
124
|
/* ------------------------------------------------------------------ *
|
|
@@ -126,16 +126,16 @@ function observeProperty(objectPath: string, property: string, selector: string)
|
|
|
126
126
|
* ------------------------------------------------------------------ */
|
|
127
127
|
|
|
128
128
|
interface LiveNote {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
129
|
+
pitch: number;
|
|
130
|
+
start_time: number;
|
|
131
|
+
duration: number;
|
|
132
|
+
velocity: number;
|
|
133
|
+
mute?: number;
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
/** The LiveAPI for the track this device sits on. */
|
|
137
137
|
function ownTrack(): LiveAPI {
|
|
138
|
-
|
|
138
|
+
return new LiveAPI("this_device canonical_parent");
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
/**
|
|
@@ -143,52 +143,52 @@ function ownTrack(): LiveAPI {
|
|
|
143
143
|
* Creates a clip in the first empty slot on this device's track and fills it.
|
|
144
144
|
*/
|
|
145
145
|
function write_clip(): void {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
177
|
}
|
|
178
178
|
|
|
179
179
|
function firstEmptySlot(): LiveAPI | null {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
192
|
}
|
|
193
193
|
|
|
194
194
|
/**
|
|
@@ -197,54 +197,54 @@ function firstEmptySlot(): LiveAPI | null {
|
|
|
197
197
|
* "notes <loopEnd> <n> <pitch start duration> ...".
|
|
198
198
|
*/
|
|
199
199
|
function read_notes(): void {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
218
|
}
|
|
219
219
|
|
|
220
220
|
function pickClip(): LiveAPI | null {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
238
|
}
|
|
239
239
|
|
|
240
240
|
function getNotes(clip: LiveAPI, loopEnd: number): LiveNote[] | null {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
250
|
}
|
package/src/max.d.ts
CHANGED
|
@@ -32,34 +32,99 @@ declare let outlets: number;
|
|
|
32
32
|
|
|
33
33
|
/** Max's scheduler. There is no `setTimeout` in [js]. */
|
|
34
34
|
declare class Task {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
+
/**
|
|
43
|
+
* A named Max dictionary, addressed from [js].
|
|
44
|
+
*
|
|
45
|
+
* The reason this exists here at all is [maxurl]: downloading to a FILE is not
|
|
46
|
+
* expressible as a flat Max message. Per the reference, the file form is a
|
|
47
|
+
* `dictionary <name>` message carrying a dict with a `filename_out` key - so
|
|
48
|
+
* something has to build that dict, and [js] is the only thing in the patcher
|
|
49
|
+
* that can.
|
|
50
|
+
*
|
|
51
|
+
* VERIFIED in Live (doc/SPIKES.md spike 1.3): [js] built a maxurl request dict
|
|
52
|
+
* and read the response dict back. `constructor`, `set`, `clear` and `stringify`
|
|
53
|
+
* all behave as declared. `get`, `parse` and `freepeer` were not exercised.
|
|
54
|
+
*/
|
|
55
|
+
declare class Dict {
|
|
56
|
+
constructor(name?: string);
|
|
57
|
+
name: string;
|
|
58
|
+
set(key: string, value: unknown): void;
|
|
59
|
+
/** UNVERIFIED - the spike only ever wrote, and read back via stringify(). */
|
|
60
|
+
get(key: string): unknown;
|
|
61
|
+
/** The whole dict as JSON - the cheapest way to see what maxurl replied. */
|
|
62
|
+
stringify(): string;
|
|
63
|
+
/** UNVERIFIED. */
|
|
64
|
+
parse(json: string): void;
|
|
65
|
+
clear(): void;
|
|
66
|
+
/** Release the dict's reference. Max dictionaries are refcounted. UNVERIFIED. */
|
|
67
|
+
freepeer(): void;
|
|
40
68
|
}
|
|
41
69
|
|
|
42
70
|
/** Max's file object. Note: `writebytes` truncates silently past ~16 KB. */
|
|
43
71
|
declare class File {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
72
|
+
constructor(path: string, mode?: "read" | "write" | "readwrite");
|
|
73
|
+
isopen: boolean;
|
|
74
|
+
eof: number;
|
|
75
|
+
open(): void;
|
|
76
|
+
close(): void;
|
|
77
|
+
readstring(count: number): string;
|
|
78
|
+
writestring(s: string): void;
|
|
79
|
+
writebytes(bytes: number[]): void;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A named [buffer~] in the patcher, addressed from [js].
|
|
84
|
+
*
|
|
85
|
+
* This is the seam that makes "disk is the audio transport" work: [js] tells a
|
|
86
|
+
* buffer~ to `replace` a file on disk and MSP plays it from there, so audio
|
|
87
|
+
* never crosses the Max message bridge (a text-parsed protocol) as data.
|
|
88
|
+
*
|
|
89
|
+
* The buffer~ must ALREADY EXIST in the patcher under this name - `new Buffer`
|
|
90
|
+
* binds to one, it does not create one. And `send("replace", path)` is
|
|
91
|
+
* ASYNCHRONOUS: framecount() right after it still reads the old size. Come back
|
|
92
|
+
* on a Task.
|
|
93
|
+
*
|
|
94
|
+
* VERIFIED in Live (doc/SPIKES.md spike 1.2): an empty buffer~ went to 124439
|
|
95
|
+
* frames, 1 channel, midsample -0.0319 after `send("replace", "jongly.aif")`.
|
|
96
|
+
* `send`, `framecount`, `channelcount` and `peek` are all real and behave as
|
|
97
|
+
* declared. `poke` is the one member here still taken on faith from the docs.
|
|
98
|
+
*
|
|
99
|
+
* `replace` on a file buffer~ cannot decode is a SILENT NO-OP: no error, and the
|
|
100
|
+
* buffer keeps whatever it held before. So a frame count on its own never means
|
|
101
|
+
* "the read worked" - it only means something next to what the count was before.
|
|
102
|
+
*
|
|
103
|
+
* `replace` also adopts the FILE's channel count, whatever the buffer~ was
|
|
104
|
+
* declared with (a stereo .wav gave channelcount() === 2 from a buffer~ created
|
|
105
|
+
* with no arguments). Never assume mono. Ask.
|
|
106
|
+
*/
|
|
107
|
+
declare class Buffer {
|
|
108
|
+
constructor(name: string);
|
|
109
|
+
/** Send a message to the buffer~ itself, e.g. `send("replace", "/path/x.wav")`. */
|
|
110
|
+
send(message: string, ...args: unknown[]): void;
|
|
111
|
+
framecount(): number;
|
|
112
|
+
channelcount(): number;
|
|
113
|
+
/** peek(channel, frameIndex, count) - channels and frames are 1-indexed. */
|
|
114
|
+
peek(channel: number, index: number, count?: number): number;
|
|
115
|
+
/** UNVERIFIED - spike 1.2 exercised everything above this, but not poke. */
|
|
116
|
+
poke(channel: number, index: number, value: number): void;
|
|
52
117
|
}
|
|
53
118
|
|
|
54
119
|
/** The Live object model. The whole reason [js] still exists in this stack. */
|
|
55
120
|
declare class LiveAPI {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
121
|
+
constructor(pathOrCallback: string | ((args: unknown[]) => void), path?: string);
|
|
122
|
+
property: string;
|
|
123
|
+
unquotedpath: string;
|
|
124
|
+
get(prop: string): unknown;
|
|
125
|
+
set(prop: string, value: unknown): void;
|
|
126
|
+
getcount(child: string): number;
|
|
127
|
+
call(method: string, ...args: unknown[]): unknown;
|
|
63
128
|
}
|
|
64
129
|
|
|
65
130
|
/** Injected by @m4l-jweb/build: "<version> <iso date>". */
|