livepilot 1.9.9 → 1.9.12
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/.claude-plugin/marketplace.json +1 -1
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +92 -0
- package/CODE_OF_CONDUCT.md +27 -0
- package/CONTRIBUTING.md +131 -0
- package/README.md +112 -387
- package/SECURITY.md +48 -0
- package/bin/livepilot.js +42 -2
- package/livepilot/.Codex-plugin/plugin.json +8 -0
- package/livepilot/.claude-plugin/plugin.json +1 -1
- package/livepilot/commands/beat.md +18 -6
- package/livepilot/commands/sounddesign.md +6 -5
- package/livepilot/skills/livepilot-core/SKILL.md +36 -13
- package/livepilot/skills/livepilot-core/references/overview.md +1 -1
- package/livepilot/skills/livepilot-release/SKILL.md +11 -8
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/LivePilot_Analyzer.maxpat +378 -4
- package/m4l_device/capture_2026_04_07_192216.wav +0 -0
- package/m4l_device/livepilot_bridge.js +487 -184
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/connection.py +40 -1
- package/mcp_server/curves.py +5 -1
- package/mcp_server/m4l_bridge.py +93 -26
- package/mcp_server/server.py +26 -31
- package/mcp_server/tools/_perception_engine.py +26 -3
- package/mcp_server/tools/_theory_engine.py +36 -5
- package/mcp_server/tools/analyzer.py +74 -18
- package/mcp_server/tools/arrangement.py +20 -5
- package/mcp_server/tools/automation.py +56 -1
- package/mcp_server/tools/devices.py +201 -13
- package/mcp_server/tools/generative.py +8 -1
- package/mcp_server/tools/harmony.py +16 -3
- package/mcp_server/tools/midi_io.py +22 -4
- package/mcp_server/tools/notes.py +4 -1
- package/mcp_server/tools/perception.py +27 -1
- package/mcp_server/tools/scenes.py +4 -1
- package/mcp_server/tools/theory.py +23 -8
- package/mcp_server/tools/transport.py +16 -6
- package/package.json +1 -1
- package/remote_script/LivePilot/__init__.py +1 -1
- package/remote_script/LivePilot/arrangement.py +25 -134
- package/remote_script/LivePilot/browser.py +19 -8
- package/remote_script/LivePilot/clip_automation.py +5 -4
- package/remote_script/LivePilot/clips.py +14 -6
- package/remote_script/LivePilot/devices.py +6 -3
- package/remote_script/LivePilot/diagnostics.py +81 -5
- package/remote_script/LivePilot/router.py +22 -0
- package/remote_script/LivePilot/server.py +41 -17
- package/remote_script/LivePilot/tracks.py +7 -2
- package/remote_script/LivePilot/transport.py +3 -3
- package/requirements.txt +3 -1
|
@@ -34,6 +34,7 @@ var detected_scale = "";
|
|
|
34
34
|
var capture_active = false;
|
|
35
35
|
var capture_timer = null;
|
|
36
36
|
var capture_filename = "";
|
|
37
|
+
var capture_file_path = "";
|
|
37
38
|
var current_sample_rate = 44100; // Updated by dspstate~ via inlet 1
|
|
38
39
|
|
|
39
40
|
// Base64 encoding table
|
|
@@ -67,7 +68,7 @@ function anything() {
|
|
|
67
68
|
// OSC messages arrive as messagename — strip leading / if present
|
|
68
69
|
var cmd = messagename;
|
|
69
70
|
if (cmd.charAt(0) === "/") cmd = cmd.substring(1);
|
|
70
|
-
var args = arrayfromargs(arguments);
|
|
71
|
+
var args = _decode_arg_strings(arrayfromargs(arguments));
|
|
71
72
|
|
|
72
73
|
// Defer to low-priority thread for LiveAPI safety
|
|
73
74
|
var task = new Task(function() {
|
|
@@ -83,7 +84,7 @@ function anything() {
|
|
|
83
84
|
function dispatch(cmd, args) {
|
|
84
85
|
switch(cmd) {
|
|
85
86
|
case "ping":
|
|
86
|
-
send_response({"ok": true, "version": "1.9.
|
|
87
|
+
send_response({"ok": true, "version": "1.9.12"});
|
|
87
88
|
break;
|
|
88
89
|
case "get_params":
|
|
89
90
|
cmd_get_params(args);
|
|
@@ -149,7 +150,7 @@ function dispatch(cmd, args) {
|
|
|
149
150
|
cmd_capture_stop();
|
|
150
151
|
break;
|
|
151
152
|
case "check_flucoma":
|
|
152
|
-
|
|
153
|
+
cmd_check_flucoma();
|
|
153
154
|
break;
|
|
154
155
|
// ── Phase 2: Clip & Display ──
|
|
155
156
|
case "scrub_clip":
|
|
@@ -188,36 +189,40 @@ function cmd_get_params(args) {
|
|
|
188
189
|
var param_count = cursor_a.getcount("parameters");
|
|
189
190
|
var params = [];
|
|
190
191
|
|
|
191
|
-
|
|
192
|
-
var batch_size = 4;
|
|
192
|
+
var batch_size = 8;
|
|
193
193
|
var current = 0;
|
|
194
194
|
|
|
195
195
|
function read_batch() {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
}
|
|
213
|
-
current = end;
|
|
196
|
+
try {
|
|
197
|
+
var end = Math.min(current + batch_size, param_count);
|
|
198
|
+
for (var i = current; i < end; i++) {
|
|
199
|
+
cursor_b.goto(path + " parameters " + i);
|
|
200
|
+
params.push({
|
|
201
|
+
index: i,
|
|
202
|
+
name: cursor_b.get("name").toString(),
|
|
203
|
+
value: parseFloat(cursor_b.get("value")),
|
|
204
|
+
min: parseFloat(cursor_b.get("min")),
|
|
205
|
+
max: parseFloat(cursor_b.get("max")),
|
|
206
|
+
is_quantized: parseInt(cursor_b.get("is_quantized")) === 1,
|
|
207
|
+
automation_state: parseInt(cursor_b.get("automation_state")),
|
|
208
|
+
state: parseInt(cursor_b.get("state"))
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
current = end;
|
|
214
212
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
213
|
+
if (current < param_count) {
|
|
214
|
+
var next_task = new Task(read_batch);
|
|
215
|
+
next_task.schedule(20);
|
|
216
|
+
} else {
|
|
217
|
+
send_response({"track": track_idx, "device": device_idx, "params": params});
|
|
218
|
+
}
|
|
219
|
+
} catch (e) {
|
|
220
|
+
send_response({
|
|
221
|
+
"error": "Failed reading parameter " + current + ": " + String(e),
|
|
222
|
+
"track": track_idx,
|
|
223
|
+
"device": device_idx,
|
|
224
|
+
"partial_params": params
|
|
225
|
+
});
|
|
221
226
|
}
|
|
222
227
|
}
|
|
223
228
|
|
|
@@ -226,8 +231,7 @@ function cmd_get_params(args) {
|
|
|
226
231
|
|
|
227
232
|
function cmd_get_hidden_params(args) {
|
|
228
233
|
// Returns ALL parameters including hidden ones not in ControlSurface API
|
|
229
|
-
//
|
|
230
|
-
// accessible from the standard API
|
|
234
|
+
// Includes value_string (human-readable) via str_for_value where safe
|
|
231
235
|
var track_idx = parseInt(args[0]);
|
|
232
236
|
var device_idx = parseInt(args[1]);
|
|
233
237
|
var path = build_device_path(track_idx, device_idx);
|
|
@@ -235,39 +239,51 @@ function cmd_get_hidden_params(args) {
|
|
|
235
239
|
cursor_a.goto(path);
|
|
236
240
|
var param_count = cursor_a.getcount("parameters");
|
|
237
241
|
var device_name = cursor_a.get("name").toString();
|
|
242
|
+
var device_class = cursor_a.get("class_name").toString();
|
|
238
243
|
var params = [];
|
|
239
244
|
var current = 0;
|
|
240
|
-
var batch_size =
|
|
245
|
+
var batch_size = 8;
|
|
241
246
|
|
|
242
247
|
function read_batch() {
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
248
|
+
try {
|
|
249
|
+
var end = Math.min(current + batch_size, param_count);
|
|
250
|
+
for (var i = current; i < end; i++) {
|
|
251
|
+
cursor_b.goto(path + " parameters " + i);
|
|
252
|
+
var val = parseFloat(cursor_b.get("value"));
|
|
253
|
+
params.push({
|
|
254
|
+
index: i,
|
|
255
|
+
name: cursor_b.get("name").toString(),
|
|
256
|
+
value: val,
|
|
257
|
+
value_string: _safe_display_string(cursor_b, val, device_class),
|
|
258
|
+
min: parseFloat(cursor_b.get("min")),
|
|
259
|
+
max: parseFloat(cursor_b.get("max")),
|
|
260
|
+
default_value: parseFloat(cursor_b.get("default_value")),
|
|
261
|
+
is_quantized: parseInt(cursor_b.get("is_quantized")) === 1,
|
|
262
|
+
automation_state: parseInt(cursor_b.get("automation_state")),
|
|
263
|
+
state: parseInt(cursor_b.get("state"))
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
current = end;
|
|
267
|
+
|
|
268
|
+
if (current < param_count) {
|
|
269
|
+
var next_task = new Task(read_batch);
|
|
270
|
+
next_task.schedule(20);
|
|
271
|
+
} else {
|
|
272
|
+
send_response({
|
|
273
|
+
"track": track_idx,
|
|
274
|
+
"device": device_idx,
|
|
275
|
+
"device_name": device_name,
|
|
276
|
+
"total_params": param_count,
|
|
277
|
+
"params": params
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
} catch (e) {
|
|
265
281
|
send_response({
|
|
282
|
+
"error": "Failed reading parameter " + current + ": " + String(e),
|
|
266
283
|
"track": track_idx,
|
|
267
284
|
"device": device_idx,
|
|
268
285
|
"device_name": device_name,
|
|
269
|
-
"
|
|
270
|
-
"params": params
|
|
286
|
+
"partial_params": params
|
|
271
287
|
});
|
|
272
288
|
}
|
|
273
289
|
}
|
|
@@ -285,31 +301,40 @@ function cmd_get_auto_state(args) {
|
|
|
285
301
|
var param_count = cursor_a.getcount("parameters");
|
|
286
302
|
var results = [];
|
|
287
303
|
var current = 0;
|
|
288
|
-
var batch_size =
|
|
304
|
+
var batch_size = 8;
|
|
289
305
|
|
|
290
306
|
function read_batch() {
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
307
|
+
try {
|
|
308
|
+
var end = Math.min(current + batch_size, param_count);
|
|
309
|
+
for (var i = current; i < end; i++) {
|
|
310
|
+
cursor_b.goto(path + " parameters " + i);
|
|
311
|
+
var state = parseInt(cursor_b.get("automation_state"));
|
|
312
|
+
if (state > 0) {
|
|
313
|
+
results.push({
|
|
314
|
+
index: i,
|
|
315
|
+
name: cursor_b.get("name").toString(),
|
|
316
|
+
automation_state: state,
|
|
317
|
+
state_label: state === 1 ? "active" : "overridden"
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
current = end;
|
|
322
|
+
|
|
323
|
+
if (current < param_count) {
|
|
324
|
+
var next_task = new Task(read_batch);
|
|
325
|
+
next_task.schedule(20);
|
|
326
|
+
} else {
|
|
327
|
+
send_response({
|
|
328
|
+
"track": track_idx,
|
|
329
|
+
"device": device_idx,
|
|
330
|
+
"total_params": param_count,
|
|
331
|
+
"automated_params": results,
|
|
332
|
+
"automated_count": results.length
|
|
303
333
|
});
|
|
304
334
|
}
|
|
305
|
-
}
|
|
306
|
-
current = end;
|
|
307
|
-
|
|
308
|
-
if (current < param_count) {
|
|
309
|
-
var next_task = new Task(read_batch);
|
|
310
|
-
next_task.schedule(50);
|
|
311
|
-
} else {
|
|
335
|
+
} catch (e) {
|
|
312
336
|
send_response({
|
|
337
|
+
"error": "Failed reading automation state at param " + current + ": " + String(e),
|
|
313
338
|
"track": track_idx,
|
|
314
339
|
"device": device_idx,
|
|
315
340
|
"total_params": param_count,
|
|
@@ -391,62 +416,89 @@ function walk_device(path, depth) {
|
|
|
391
416
|
}
|
|
392
417
|
|
|
393
418
|
function cmd_get_chains_deep(args) {
|
|
394
|
-
// Get detailed chain info including all devices in each chain
|
|
395
419
|
var track_idx = parseInt(args[0]);
|
|
396
420
|
var device_idx = parseInt(args[1]);
|
|
397
421
|
var path = build_device_path(track_idx, device_idx);
|
|
398
422
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
for (var c = 0; c < chain_count; c++) {
|
|
404
|
-
var chain_path = path + " chains " + c;
|
|
405
|
-
cursor_b.goto(chain_path);
|
|
406
|
-
var chain = {
|
|
407
|
-
index: c,
|
|
408
|
-
name: cursor_b.get("name").toString(),
|
|
409
|
-
volume: parseFloat(cursor_b.get("volume")),
|
|
410
|
-
panning: parseFloat(cursor_b.get("panning")),
|
|
411
|
-
mute: parseInt(cursor_b.get("mute")) === 1,
|
|
412
|
-
solo: parseInt(cursor_b.get("solo")) === 1,
|
|
413
|
-
devices: []
|
|
414
|
-
};
|
|
423
|
+
try {
|
|
424
|
+
cursor_a.goto(path);
|
|
425
|
+
var chain_count = cursor_a.getcount("chains");
|
|
426
|
+
var chains = [];
|
|
415
427
|
|
|
416
|
-
var
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
chain
|
|
420
|
-
index:
|
|
421
|
-
name:
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
428
|
+
for (var c = 0; c < chain_count; c++) {
|
|
429
|
+
var chain_path = path + " chains " + c;
|
|
430
|
+
cursor_b.goto(chain_path);
|
|
431
|
+
var chain = {
|
|
432
|
+
index: c,
|
|
433
|
+
name: cursor_b.get("name").toString(),
|
|
434
|
+
volume: parseFloat(cursor_b.get("volume")),
|
|
435
|
+
panning: parseFloat(cursor_b.get("panning")),
|
|
436
|
+
mute: parseInt(cursor_b.get("mute")) === 1,
|
|
437
|
+
solo: parseInt(cursor_b.get("solo")) === 1,
|
|
438
|
+
devices: []
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
var dev_count = cursor_b.getcount("devices");
|
|
442
|
+
for (var d = 0; d < dev_count; d++) {
|
|
443
|
+
cursor_a.goto(chain_path + " devices " + d);
|
|
444
|
+
chain.devices.push({
|
|
445
|
+
index: d,
|
|
446
|
+
name: cursor_a.get("name").toString(),
|
|
447
|
+
class_name: cursor_a.get("class_name").toString(),
|
|
448
|
+
is_active: parseInt(cursor_a.get("is_active")) === 1,
|
|
449
|
+
param_count: cursor_a.getcount("parameters")
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
chains.push(chain);
|
|
426
453
|
}
|
|
427
|
-
|
|
454
|
+
|
|
455
|
+
send_response({"track": track_idx, "device": device_idx, "chains": chains});
|
|
456
|
+
} catch (e) {
|
|
457
|
+
send_response({"error": "Failed reading chains: " + String(e), "track": track_idx, "device": device_idx});
|
|
428
458
|
}
|
|
459
|
+
}
|
|
429
460
|
|
|
430
|
-
|
|
461
|
+
function cmd_check_flucoma() {
|
|
462
|
+
// Check if FluCoMa externals are installed.
|
|
463
|
+
// Max JS cannot reliably probe the object search path at runtime,
|
|
464
|
+
// so we check if the FluCoMa package folder exists on disk.
|
|
465
|
+
try {
|
|
466
|
+
var pkg_path = max.appsupportpath + "/Packages/FluidCorpusManipulation";
|
|
467
|
+
var f = new Folder(pkg_path);
|
|
468
|
+
var available = !f.end; // end === true means folder not found
|
|
469
|
+
f.close();
|
|
470
|
+
send_response({"flucoma_available": available});
|
|
471
|
+
} catch (e) {
|
|
472
|
+
// Can't probe — report unknown rather than lying
|
|
473
|
+
send_response({"flucoma_available": false, "probe_error": String(e)});
|
|
474
|
+
}
|
|
431
475
|
}
|
|
432
476
|
|
|
433
477
|
function cmd_get_track_cpu(args) {
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
for (var t = 0; t < track_count; t++) {
|
|
440
|
-
cursor_b.goto("live_set tracks " + t);
|
|
441
|
-
results.push({
|
|
442
|
-
index: t,
|
|
443
|
-
name: cursor_b.get("name").toString(),
|
|
444
|
-
// performance_impact is 0.0-1.0 representing CPU load
|
|
445
|
-
cpu: parseFloat(cursor_b.get("performance_impact") || 0)
|
|
446
|
-
});
|
|
447
|
-
}
|
|
478
|
+
try {
|
|
479
|
+
var results = [];
|
|
480
|
+
cursor_a.goto("live_set");
|
|
481
|
+
var track_count = cursor_a.getcount("tracks");
|
|
448
482
|
|
|
449
|
-
|
|
483
|
+
for (var t = 0; t < track_count; t++) {
|
|
484
|
+
cursor_b.goto("live_set tracks " + t);
|
|
485
|
+
var cpu = 0;
|
|
486
|
+
try {
|
|
487
|
+
cpu = parseFloat(cursor_b.get("performance_impact") || 0);
|
|
488
|
+
} catch (e) {
|
|
489
|
+
cpu = -1;
|
|
490
|
+
}
|
|
491
|
+
results.push({
|
|
492
|
+
index: t,
|
|
493
|
+
name: cursor_b.get("name").toString(),
|
|
494
|
+
cpu: cpu
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
send_response({"tracks": results, "count": track_count});
|
|
499
|
+
} catch (e) {
|
|
500
|
+
send_response({"error": "Failed reading track CPU: " + String(e)});
|
|
501
|
+
}
|
|
450
502
|
}
|
|
451
503
|
|
|
452
504
|
function cmd_get_selected() {
|
|
@@ -475,6 +527,25 @@ function cmd_get_selected() {
|
|
|
475
527
|
break;
|
|
476
528
|
}
|
|
477
529
|
}
|
|
530
|
+
// Check return tracks if not found in main tracks
|
|
531
|
+
if (result.selected_track === -1) {
|
|
532
|
+
cursor_a.goto("live_set");
|
|
533
|
+
var rtc = cursor_a.getcount("return_tracks");
|
|
534
|
+
for (var j = 0; j < rtc; j++) {
|
|
535
|
+
cursor_a.goto("live_set return_tracks " + j);
|
|
536
|
+
if (cursor_a.get("name").toString() === result.selected_track_name) {
|
|
537
|
+
result.selected_track = -(j + 1); // -1, -2, ... convention
|
|
538
|
+
break;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
// Check master track if still not found
|
|
543
|
+
if (result.selected_track === -1) {
|
|
544
|
+
cursor_a.goto("live_set master_track");
|
|
545
|
+
if (cursor_a.get("name").toString() === result.selected_track_name) {
|
|
546
|
+
result.selected_track = -1000; // master convention
|
|
547
|
+
}
|
|
548
|
+
}
|
|
478
549
|
} catch(e) {}
|
|
479
550
|
|
|
480
551
|
// Selected scene
|
|
@@ -599,12 +670,12 @@ function send_response(obj) {
|
|
|
599
670
|
}
|
|
600
671
|
|
|
601
672
|
function base64_encode(str) {
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
}
|
|
673
|
+
// UTF-8 encode first, then base64 encode the byte sequence.
|
|
674
|
+
// This preserves non-ASCII characters (accented names, CJK, emoji)
|
|
675
|
+
// that would otherwise be truncated by charCodeAt & 0xFF.
|
|
676
|
+
var bytes = _utf8_encode(str);
|
|
607
677
|
|
|
678
|
+
var result = "";
|
|
608
679
|
for (var i = 0; i < bytes.length; i += 3) {
|
|
609
680
|
var b0 = bytes[i];
|
|
610
681
|
var b1 = (i + 1 < bytes.length) ? bytes[i + 1] : 0;
|
|
@@ -623,6 +694,130 @@ function base64_encode(str) {
|
|
|
623
694
|
return result;
|
|
624
695
|
}
|
|
625
696
|
|
|
697
|
+
function _utf8_encode(str) {
|
|
698
|
+
// Convert a JavaScript string to a UTF-8 byte array.
|
|
699
|
+
// Handles codepoints U+0000..U+FFFF (BMP) which covers all
|
|
700
|
+
// characters Max JS can produce from LiveAPI get() calls.
|
|
701
|
+
var bytes = [];
|
|
702
|
+
for (var i = 0; i < str.length; i++) {
|
|
703
|
+
var c = str.charCodeAt(i);
|
|
704
|
+
if (c < 0x80) {
|
|
705
|
+
bytes.push(c);
|
|
706
|
+
} else if (c < 0x800) {
|
|
707
|
+
bytes.push(0xC0 | (c >> 6));
|
|
708
|
+
bytes.push(0x80 | (c & 0x3F));
|
|
709
|
+
} else {
|
|
710
|
+
bytes.push(0xE0 | (c >> 12));
|
|
711
|
+
bytes.push(0x80 | ((c >> 6) & 0x3F));
|
|
712
|
+
bytes.push(0x80 | (c & 0x3F));
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
return bytes;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function base64_decode(str) {
|
|
719
|
+
var clean = String(str || "").replace(/=/g, "");
|
|
720
|
+
var bytes = [];
|
|
721
|
+
|
|
722
|
+
for (var i = 0; i < clean.length; i += 4) {
|
|
723
|
+
var c0 = B64.indexOf(clean.charAt(i));
|
|
724
|
+
var c1 = B64.indexOf(clean.charAt(i + 1));
|
|
725
|
+
var c2 = (i + 2 < clean.length) ? B64.indexOf(clean.charAt(i + 2)) : -1;
|
|
726
|
+
var c3 = (i + 3 < clean.length) ? B64.indexOf(clean.charAt(i + 3)) : -1;
|
|
727
|
+
|
|
728
|
+
if (c0 < 0 || c1 < 0 || (c2 < 0 && i + 2 < clean.length) || (c3 < 0 && i + 3 < clean.length)) {
|
|
729
|
+
throw new Error("Invalid base64 input");
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
bytes.push(((c0 << 2) | (c1 >> 4)) & 0xFF);
|
|
733
|
+
if (c2 !== -1) {
|
|
734
|
+
bytes.push((((c1 & 15) << 4) | (c2 >> 2)) & 0xFF);
|
|
735
|
+
}
|
|
736
|
+
if (c3 !== -1) {
|
|
737
|
+
bytes.push((((c2 & 3) << 6) | c3) & 0xFF);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
return bytes;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function _utf8_decode(bytes) {
|
|
745
|
+
// Convert a UTF-8 byte array back to a JavaScript string.
|
|
746
|
+
// Handles BMP codepoints which covers the text LivePilot exchanges.
|
|
747
|
+
var result = "";
|
|
748
|
+
for (var i = 0; i < bytes.length;) {
|
|
749
|
+
var b0 = bytes[i];
|
|
750
|
+
if (b0 < 0x80) {
|
|
751
|
+
result += String.fromCharCode(b0);
|
|
752
|
+
i += 1;
|
|
753
|
+
} else if ((b0 & 0xE0) === 0xC0 && i + 1 < bytes.length) {
|
|
754
|
+
var b1 = bytes[i + 1];
|
|
755
|
+
result += String.fromCharCode(((b0 & 0x1F) << 6) | (b1 & 0x3F));
|
|
756
|
+
i += 2;
|
|
757
|
+
} else if (i + 2 < bytes.length) {
|
|
758
|
+
var b2 = bytes[i + 1];
|
|
759
|
+
var b3 = bytes[i + 2];
|
|
760
|
+
result += String.fromCharCode(
|
|
761
|
+
((b0 & 0x0F) << 12) |
|
|
762
|
+
((b2 & 0x3F) << 6) |
|
|
763
|
+
(b3 & 0x3F)
|
|
764
|
+
);
|
|
765
|
+
i += 3;
|
|
766
|
+
} else {
|
|
767
|
+
break;
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
return result;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function _decode_b64_arg(arg) {
|
|
774
|
+
if (arg === null || arg === undefined) {
|
|
775
|
+
return arg;
|
|
776
|
+
}
|
|
777
|
+
var text = String(arg);
|
|
778
|
+
if (text.indexOf("b64:") !== 0) {
|
|
779
|
+
return arg;
|
|
780
|
+
}
|
|
781
|
+
try {
|
|
782
|
+
return _utf8_decode(base64_decode(text.substring(4)));
|
|
783
|
+
} catch (e) {
|
|
784
|
+
return arg;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function _decode_arg_strings(args) {
|
|
789
|
+
var decoded = [];
|
|
790
|
+
for (var i = 0; i < args.length; i++) {
|
|
791
|
+
decoded.push(_decode_b64_arg(args[i]));
|
|
792
|
+
}
|
|
793
|
+
return decoded;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function _to_posix_path(path) {
|
|
797
|
+
if (!path || path.length < 2) return path;
|
|
798
|
+
|
|
799
|
+
// Keep Windows-style drive paths unchanged.
|
|
800
|
+
if (path.length >= 3 && path.charAt(1) === ":" &&
|
|
801
|
+
(path.charAt(2) === "/" || path.charAt(2) === "\\")) {
|
|
802
|
+
return path;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
var colon = path.indexOf(":");
|
|
806
|
+
var slash = path.indexOf("/");
|
|
807
|
+
if (colon <= 0 || (slash !== -1 && colon > slash)) {
|
|
808
|
+
return path;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
var rest = path.substring(colon + 1);
|
|
812
|
+
if (rest.indexOf(":") !== -1) {
|
|
813
|
+
rest = rest.replace(/:/g, "/");
|
|
814
|
+
}
|
|
815
|
+
if (rest.charAt(0) !== "/") {
|
|
816
|
+
rest = "/" + rest.replace(/^[/\\]+/, "");
|
|
817
|
+
}
|
|
818
|
+
return rest;
|
|
819
|
+
}
|
|
820
|
+
|
|
626
821
|
// ── Phase 2: Sample Operations ────────────────────────────────────────
|
|
627
822
|
|
|
628
823
|
function cmd_get_clip_file_path(args) {
|
|
@@ -641,7 +836,7 @@ function cmd_get_clip_file_path(args) {
|
|
|
641
836
|
send_response({
|
|
642
837
|
"track": track_idx,
|
|
643
838
|
"clip": clip_idx,
|
|
644
|
-
"file_path": sample_path,
|
|
839
|
+
"file_path": _to_posix_path(sample_path),
|
|
645
840
|
"length": parseFloat(cursor_a.get("length")),
|
|
646
841
|
"name": cursor_a.get("name").toString()
|
|
647
842
|
});
|
|
@@ -653,7 +848,8 @@ function cmd_get_clip_file_path(args) {
|
|
|
653
848
|
function cmd_replace_simpler_sample(args) {
|
|
654
849
|
var track_idx = parseInt(args[0]);
|
|
655
850
|
var device_idx = parseInt(args[1]);
|
|
656
|
-
//
|
|
851
|
+
// Keep the join for backward compatibility with older clients.
|
|
852
|
+
// Current clients send file paths as a single decoded b64: arg.
|
|
657
853
|
var parts = [];
|
|
658
854
|
for (var i = 2; i < args.length; i++) parts.push(args[i].toString());
|
|
659
855
|
var file_path = parts.join(" ");
|
|
@@ -906,6 +1102,10 @@ function cmd_stop_scrub(args) {
|
|
|
906
1102
|
var path = build_track_path(track_idx) + " clip_slots " + clip_idx + " clip";
|
|
907
1103
|
|
|
908
1104
|
cursor_a.goto(path);
|
|
1105
|
+
if (cursor_a.id === 0) {
|
|
1106
|
+
send_response({"error": "No clip at track " + track_idx + " slot " + clip_idx});
|
|
1107
|
+
return;
|
|
1108
|
+
}
|
|
909
1109
|
try {
|
|
910
1110
|
cursor_a.call("stop_scrub");
|
|
911
1111
|
send_response({"ok": true});
|
|
@@ -914,6 +1114,28 @@ function cmd_stop_scrub(args) {
|
|
|
914
1114
|
}
|
|
915
1115
|
}
|
|
916
1116
|
|
|
1117
|
+
// Device classes where str_for_value freezes Max JS (uncatchable hang).
|
|
1118
|
+
// Auto Filter is the confirmed case; others may exist.
|
|
1119
|
+
var STR_FOR_VALUE_BLACKLIST = ["AutoFilter"];
|
|
1120
|
+
|
|
1121
|
+
function _safe_display_string(cursor, val, device_class) {
|
|
1122
|
+
// Return the human-readable UI string for a parameter value.
|
|
1123
|
+
// Falls back to raw value string for blacklisted device classes
|
|
1124
|
+
// where str_for_value hangs Max's JS engine.
|
|
1125
|
+
if (STR_FOR_VALUE_BLACKLIST.indexOf(device_class) !== -1) {
|
|
1126
|
+
return String(val);
|
|
1127
|
+
}
|
|
1128
|
+
try {
|
|
1129
|
+
var result = cursor.call("str_for_value", val);
|
|
1130
|
+
if (result !== undefined && result !== null && String(result) !== "") {
|
|
1131
|
+
return String(result);
|
|
1132
|
+
}
|
|
1133
|
+
} catch(e) {
|
|
1134
|
+
// str_for_value not available on this parameter
|
|
1135
|
+
}
|
|
1136
|
+
return String(val);
|
|
1137
|
+
}
|
|
1138
|
+
|
|
917
1139
|
function cmd_get_display_values(args) {
|
|
918
1140
|
var track_idx = parseInt(args[0]);
|
|
919
1141
|
var device_idx = parseInt(args[1]);
|
|
@@ -922,34 +1144,46 @@ function cmd_get_display_values(args) {
|
|
|
922
1144
|
cursor_a.goto(path);
|
|
923
1145
|
var param_count = cursor_a.getcount("parameters");
|
|
924
1146
|
var device_name = cursor_a.get("name").toString();
|
|
1147
|
+
var device_class = cursor_a.get("class_name").toString();
|
|
925
1148
|
var params = [];
|
|
926
1149
|
var current = 0;
|
|
927
|
-
var batch_size =
|
|
1150
|
+
var batch_size = 8;
|
|
928
1151
|
|
|
929
1152
|
function read_batch() {
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
1153
|
+
try {
|
|
1154
|
+
var end = Math.min(current + batch_size, param_count);
|
|
1155
|
+
for (var i = current; i < end; i++) {
|
|
1156
|
+
cursor_b.goto(path + " parameters " + i);
|
|
1157
|
+
var state = parseInt(cursor_b.get("state"));
|
|
1158
|
+
if (state !== 2) {
|
|
1159
|
+
var val = parseFloat(cursor_b.get("value"));
|
|
1160
|
+
params.push({
|
|
1161
|
+
index: i,
|
|
1162
|
+
name: cursor_b.get("name").toString(),
|
|
1163
|
+
display_value: _safe_display_string(cursor_b, val, device_class),
|
|
1164
|
+
value: val
|
|
1165
|
+
});
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
current = end;
|
|
1169
|
+
if (current < param_count) {
|
|
1170
|
+
var next_task = new Task(read_batch);
|
|
1171
|
+
next_task.schedule(20);
|
|
1172
|
+
} else {
|
|
1173
|
+
send_response({
|
|
1174
|
+
"track": track_idx,
|
|
1175
|
+
"device": device_idx,
|
|
1176
|
+
"device_name": device_name,
|
|
1177
|
+
"params": params
|
|
940
1178
|
});
|
|
941
1179
|
}
|
|
942
|
-
}
|
|
943
|
-
current = end;
|
|
944
|
-
if (current < param_count) {
|
|
945
|
-
var next_task = new Task(read_batch);
|
|
946
|
-
next_task.schedule(50);
|
|
947
|
-
} else {
|
|
1180
|
+
} catch (e) {
|
|
948
1181
|
send_response({
|
|
1182
|
+
"error": "Failed reading parameter " + current + ": " + String(e),
|
|
949
1183
|
"track": track_idx,
|
|
950
1184
|
"device": device_idx,
|
|
951
1185
|
"device_name": device_name,
|
|
952
|
-
"
|
|
1186
|
+
"partial_params": params
|
|
953
1187
|
});
|
|
954
1188
|
}
|
|
955
1189
|
}
|
|
@@ -970,6 +1204,15 @@ function cmd_capture_audio(args) {
|
|
|
970
1204
|
var duration_ms = parseInt(args[0]) || 10000;
|
|
971
1205
|
var requested_name = args[1] ? args[1].toString().trim() : "";
|
|
972
1206
|
|
|
1207
|
+
// Sanitize filename — strip any directory components (defense-in-depth)
|
|
1208
|
+
if (requested_name.length > 0) {
|
|
1209
|
+
requested_name = _safe_filename(requested_name);
|
|
1210
|
+
if (!requested_name || requested_name.length === 0) {
|
|
1211
|
+
send_response({"error": "Invalid capture filename (path traversal blocked)"});
|
|
1212
|
+
return;
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
|
|
973
1216
|
// Generate a timestamped filename if none provided
|
|
974
1217
|
var d = new Date();
|
|
975
1218
|
var ts = d.getFullYear() + "_"
|
|
@@ -977,16 +1220,16 @@ function cmd_capture_audio(args) {
|
|
|
977
1220
|
+ pad2(d.getDate()) + "_"
|
|
978
1221
|
+ pad2(d.getHours()) + pad2(d.getMinutes()) + pad2(d.getSeconds());
|
|
979
1222
|
capture_filename = requested_name.length > 0 ? requested_name : ("capture_" + ts + ".wav");
|
|
1223
|
+
capture_file_path = _join_path(_get_captures_dir(), capture_filename);
|
|
980
1224
|
|
|
981
1225
|
// Calculate sample count from duration and current sample rate
|
|
982
1226
|
var num_samples = Math.ceil((duration_ms / 1000.0) * current_sample_rate);
|
|
983
1227
|
|
|
984
1228
|
capture_active = true;
|
|
985
1229
|
|
|
986
|
-
// Tell the Max patch to start recording
|
|
987
|
-
//
|
|
988
|
-
|
|
989
|
-
outlet(1, "capture_start", capture_filename, num_samples);
|
|
1230
|
+
// Tell the Max patch to start recording the incoming stereo signal.
|
|
1231
|
+
// Message: "capture_start <absolute_path> <num_samples>"
|
|
1232
|
+
outlet(1, "capture_start", capture_file_path, num_samples);
|
|
990
1233
|
|
|
991
1234
|
// Set a timer to call cmd_capture_write_done after duration_ms.
|
|
992
1235
|
// If the buffer~ fires its bang first (via a connected message), that
|
|
@@ -1008,12 +1251,18 @@ function cmd_capture_write_done() {
|
|
|
1008
1251
|
}
|
|
1009
1252
|
|
|
1010
1253
|
var written = capture_filename;
|
|
1254
|
+
var written_path = capture_file_path;
|
|
1011
1255
|
capture_filename = "";
|
|
1256
|
+
capture_file_path = "";
|
|
1257
|
+
|
|
1258
|
+
// Stop the recorder before reporting completion so the file is flushed.
|
|
1259
|
+
outlet(1, "capture_stop");
|
|
1012
1260
|
|
|
1013
1261
|
// Send /capture_complete back to the MCP server via outlet 0.
|
|
1014
1262
|
var encoded = base64_encode(JSON.stringify({
|
|
1015
1263
|
"ok": true,
|
|
1016
1264
|
"file": written,
|
|
1265
|
+
"file_path": _to_posix_path(written_path),
|
|
1017
1266
|
"sample_rate": current_sample_rate
|
|
1018
1267
|
}));
|
|
1019
1268
|
outlet(0, "/capture_complete", encoded);
|
|
@@ -1036,9 +1285,11 @@ function cmd_capture_stop() {
|
|
|
1036
1285
|
|
|
1037
1286
|
capture_active = false;
|
|
1038
1287
|
var written = capture_filename;
|
|
1288
|
+
var written_path = capture_file_path;
|
|
1039
1289
|
capture_filename = "";
|
|
1290
|
+
capture_file_path = "";
|
|
1040
1291
|
|
|
1041
|
-
send_response({"ok": true, "stopped": true, "file": written});
|
|
1292
|
+
send_response({"ok": true, "stopped": true, "file": written, "file_path": _to_posix_path(written_path)});
|
|
1042
1293
|
}
|
|
1043
1294
|
|
|
1044
1295
|
function pad2(n) {
|
|
@@ -1069,37 +1320,48 @@ function cmd_get_plugin_params(args) {
|
|
|
1069
1320
|
var param_count = cursor_a.getcount("parameters");
|
|
1070
1321
|
var params = [];
|
|
1071
1322
|
var current = 0;
|
|
1072
|
-
var batch_size =
|
|
1323
|
+
var batch_size = 8;
|
|
1073
1324
|
|
|
1074
1325
|
function read_batch() {
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1326
|
+
try {
|
|
1327
|
+
var end = Math.min(current + batch_size, param_count);
|
|
1328
|
+
for (var i = current; i < end; i++) {
|
|
1329
|
+
cursor_b.goto(path + " parameters " + i);
|
|
1330
|
+
var val = parseFloat(cursor_b.get("value"));
|
|
1331
|
+
params.push({
|
|
1332
|
+
index: i,
|
|
1333
|
+
name: cursor_b.get("name").toString(),
|
|
1334
|
+
value: val,
|
|
1335
|
+
min: parseFloat(cursor_b.get("min")),
|
|
1336
|
+
max: parseFloat(cursor_b.get("max")),
|
|
1337
|
+
default_value: parseFloat(cursor_b.get("default_value")),
|
|
1338
|
+
is_quantized: parseInt(cursor_b.get("is_quantized")) === 1,
|
|
1339
|
+
value_string: String(val)
|
|
1340
|
+
});
|
|
1341
|
+
}
|
|
1342
|
+
current = end;
|
|
1343
|
+
|
|
1344
|
+
if (current < param_count) {
|
|
1345
|
+
var next_task = new Task(read_batch);
|
|
1346
|
+
next_task.schedule(20);
|
|
1347
|
+
} else {
|
|
1348
|
+
send_response({
|
|
1349
|
+
"track": track_idx,
|
|
1350
|
+
"device": device_idx,
|
|
1351
|
+
"name": device_name,
|
|
1352
|
+
"class_name": class_name,
|
|
1353
|
+
"is_plugin": true,
|
|
1354
|
+
"parameter_count": param_count,
|
|
1355
|
+
"parameters": params
|
|
1356
|
+
});
|
|
1357
|
+
}
|
|
1358
|
+
} catch (e) {
|
|
1095
1359
|
send_response({
|
|
1360
|
+
"error": "Failed reading plugin param " + current + ": " + String(e),
|
|
1096
1361
|
"track": track_idx,
|
|
1097
1362
|
"device": device_idx,
|
|
1098
1363
|
"name": device_name,
|
|
1099
|
-
"
|
|
1100
|
-
"is_plugin": true,
|
|
1101
|
-
"parameter_count": param_count,
|
|
1102
|
-
"parameters": params
|
|
1364
|
+
"partial_params": params
|
|
1103
1365
|
});
|
|
1104
1366
|
}
|
|
1105
1367
|
}
|
|
@@ -1133,13 +1395,10 @@ function cmd_map_plugin_param(args) {
|
|
|
1133
1395
|
return;
|
|
1134
1396
|
}
|
|
1135
1397
|
|
|
1136
|
-
// Navigate to the parameter and read its name
|
|
1137
|
-
cursor_b.goto(path + " parameters " + param_idx);
|
|
1138
|
-
var param_name = cursor_b.get("name").toString();
|
|
1139
|
-
|
|
1140
|
-
// Select the parameter — this is how Ableton's Configure mode works
|
|
1141
|
-
// via LiveAPI. The parameter becomes visible in the device's macro panel.
|
|
1142
1398
|
try {
|
|
1399
|
+
cursor_b.goto(path + " parameters " + param_idx);
|
|
1400
|
+
var param_name = cursor_b.get("name").toString();
|
|
1401
|
+
|
|
1143
1402
|
cursor_a.set("selected_parameter", param_idx);
|
|
1144
1403
|
cursor_a.call("store_chosen_bank");
|
|
1145
1404
|
send_response({
|
|
@@ -1149,9 +1408,8 @@ function cmd_map_plugin_param(args) {
|
|
|
1149
1408
|
});
|
|
1150
1409
|
} catch(e) {
|
|
1151
1410
|
send_response({
|
|
1152
|
-
"error": "Failed to map parameter: " + e
|
|
1153
|
-
"parameter_index": param_idx
|
|
1154
|
-
"parameter_name": param_name
|
|
1411
|
+
"error": "Failed to map parameter: " + String(e),
|
|
1412
|
+
"parameter_index": param_idx
|
|
1155
1413
|
});
|
|
1156
1414
|
}
|
|
1157
1415
|
}
|
|
@@ -1227,3 +1485,48 @@ function build_device_path(track_idx, device_idx) {
|
|
|
1227
1485
|
return "live_set tracks " + track_idx + " devices " + device_idx;
|
|
1228
1486
|
}
|
|
1229
1487
|
}
|
|
1488
|
+
|
|
1489
|
+
function _get_captures_dir() {
|
|
1490
|
+
// Stable captures directory: ~/Documents/LivePilot/captures/
|
|
1491
|
+
// max.appsupportpath = "/Users/<name>/Library/Application Support/Cycling '74"
|
|
1492
|
+
// Walk up to get home directory
|
|
1493
|
+
try {
|
|
1494
|
+
var support = max.appsupportpath;
|
|
1495
|
+
var parts = support.split("/");
|
|
1496
|
+
// /Users/<name>/Library/... → first 3 parts = /Users/<name>
|
|
1497
|
+
var home = parts.slice(0, 3).join("/");
|
|
1498
|
+
return home + "/Documents/LivePilot/captures/";
|
|
1499
|
+
} catch (e) {
|
|
1500
|
+
// Fallback to patcher directory if home detection fails
|
|
1501
|
+
return _get_patcher_dir();
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
function _get_patcher_dir() {
|
|
1506
|
+
try {
|
|
1507
|
+
var filepath = this.patcher && this.patcher.filepath ? this.patcher.filepath.toString() : "";
|
|
1508
|
+
if (!filepath) return "";
|
|
1509
|
+
var slash = Math.max(filepath.lastIndexOf("/"), filepath.lastIndexOf("\\"));
|
|
1510
|
+
if (slash < 0) return "";
|
|
1511
|
+
return filepath.substring(0, slash + 1);
|
|
1512
|
+
} catch (e) {
|
|
1513
|
+
return "";
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
function _safe_filename(name) {
|
|
1518
|
+
// Strip directory components and reject traversal attempts.
|
|
1519
|
+
// This is defense-in-depth — Python should sanitize first.
|
|
1520
|
+
if (!name || name.length === 0) return name;
|
|
1521
|
+
var slash = Math.max(name.lastIndexOf("/"), name.lastIndexOf("\\"));
|
|
1522
|
+
if (slash >= 0) name = name.substring(slash + 1);
|
|
1523
|
+
if (name === "." || name === ".." || name.length === 0) return "";
|
|
1524
|
+
return name;
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
function _join_path(dir, file) {
|
|
1528
|
+
if (!dir) return file;
|
|
1529
|
+
var last = dir.charAt(dir.length - 1);
|
|
1530
|
+
if (last !== "/" && last !== "\\") dir += "/";
|
|
1531
|
+
return dir + file;
|
|
1532
|
+
}
|