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.
Files changed (51) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/AGENTS.md +1 -1
  3. package/CHANGELOG.md +92 -0
  4. package/CODE_OF_CONDUCT.md +27 -0
  5. package/CONTRIBUTING.md +131 -0
  6. package/README.md +112 -387
  7. package/SECURITY.md +48 -0
  8. package/bin/livepilot.js +42 -2
  9. package/livepilot/.Codex-plugin/plugin.json +8 -0
  10. package/livepilot/.claude-plugin/plugin.json +1 -1
  11. package/livepilot/commands/beat.md +18 -6
  12. package/livepilot/commands/sounddesign.md +6 -5
  13. package/livepilot/skills/livepilot-core/SKILL.md +36 -13
  14. package/livepilot/skills/livepilot-core/references/overview.md +1 -1
  15. package/livepilot/skills/livepilot-release/SKILL.md +11 -8
  16. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  17. package/m4l_device/LivePilot_Analyzer.maxpat +378 -4
  18. package/m4l_device/capture_2026_04_07_192216.wav +0 -0
  19. package/m4l_device/livepilot_bridge.js +487 -184
  20. package/mcp_server/__init__.py +1 -1
  21. package/mcp_server/connection.py +40 -1
  22. package/mcp_server/curves.py +5 -1
  23. package/mcp_server/m4l_bridge.py +93 -26
  24. package/mcp_server/server.py +26 -31
  25. package/mcp_server/tools/_perception_engine.py +26 -3
  26. package/mcp_server/tools/_theory_engine.py +36 -5
  27. package/mcp_server/tools/analyzer.py +74 -18
  28. package/mcp_server/tools/arrangement.py +20 -5
  29. package/mcp_server/tools/automation.py +56 -1
  30. package/mcp_server/tools/devices.py +201 -13
  31. package/mcp_server/tools/generative.py +8 -1
  32. package/mcp_server/tools/harmony.py +16 -3
  33. package/mcp_server/tools/midi_io.py +22 -4
  34. package/mcp_server/tools/notes.py +4 -1
  35. package/mcp_server/tools/perception.py +27 -1
  36. package/mcp_server/tools/scenes.py +4 -1
  37. package/mcp_server/tools/theory.py +23 -8
  38. package/mcp_server/tools/transport.py +16 -6
  39. package/package.json +1 -1
  40. package/remote_script/LivePilot/__init__.py +1 -1
  41. package/remote_script/LivePilot/arrangement.py +25 -134
  42. package/remote_script/LivePilot/browser.py +19 -8
  43. package/remote_script/LivePilot/clip_automation.py +5 -4
  44. package/remote_script/LivePilot/clips.py +14 -6
  45. package/remote_script/LivePilot/devices.py +6 -3
  46. package/remote_script/LivePilot/diagnostics.py +81 -5
  47. package/remote_script/LivePilot/router.py +22 -0
  48. package/remote_script/LivePilot/server.py +41 -17
  49. package/remote_script/LivePilot/tracks.py +7 -2
  50. package/remote_script/LivePilot/transport.py +3 -3
  51. 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.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
- send_response({"flucoma_available": true, "version": "1.0.9"});
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
- // Chunked reading: 4 params per batch
192
- var batch_size = 4;
192
+ var batch_size = 8;
193
193
  var current = 0;
194
194
 
195
195
  function read_batch() {
196
- var end = Math.min(current + batch_size, param_count);
197
- for (var i = current; i < end; i++) {
198
- cursor_b.goto(path + " parameters " + i);
199
- var p = {
200
- index: i,
201
- name: cursor_b.get("name").toString(),
202
- value: parseFloat(cursor_b.get("value")),
203
- min: parseFloat(cursor_b.get("min")),
204
- max: parseFloat(cursor_b.get("max")),
205
- is_quantized: parseInt(cursor_b.get("is_quantized")) === 1,
206
- automation_state: parseInt(cursor_b.get("automation_state")),
207
- state: parseInt(cursor_b.get("state"))
208
- };
209
- // state: 0=enabled, 1=disabled, 2=irrelevant
210
- // automation_state: 0=none, 1=active, 2=overridden
211
- params.push(p);
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
- if (current < param_count) {
216
- // Schedule next batch in 50ms
217
- var next_task = new Task(read_batch);
218
- next_task.schedule(50);
219
- } else {
220
- send_response({"track": track_idx, "device": device_idx, "params": params});
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
- // Same as get_params but also includes value_string and whether it's
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 = 4;
245
+ var batch_size = 8;
241
246
 
242
247
  function read_batch() {
243
- var end = Math.min(current + batch_size, param_count);
244
- for (var i = current; i < end; i++) {
245
- cursor_b.goto(path + " parameters " + i);
246
- params.push({
247
- index: i,
248
- name: cursor_b.get("name").toString(),
249
- value: parseFloat(cursor_b.get("value")),
250
- min: parseFloat(cursor_b.get("min")),
251
- max: parseFloat(cursor_b.get("max")),
252
- default_value: parseFloat(cursor_b.get("default_value")),
253
- is_quantized: parseInt(cursor_b.get("is_quantized")) === 1,
254
- value_string: String(cursor_b.call("str_for_value", parseFloat(cursor_b.get("value")))),
255
- automation_state: parseInt(cursor_b.get("automation_state")),
256
- state: parseInt(cursor_b.get("state"))
257
- });
258
- }
259
- current = end;
260
-
261
- if (current < param_count) {
262
- var next_task = new Task(read_batch);
263
- next_task.schedule(50);
264
- } else {
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
- "total_params": param_count,
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 = 4;
304
+ var batch_size = 8;
289
305
 
290
306
  function read_batch() {
291
- var end = Math.min(current + batch_size, param_count);
292
- for (var i = current; i < end; i++) {
293
- cursor_b.goto(path + " parameters " + i);
294
- var state = parseInt(cursor_b.get("automation_state"));
295
- // Only include params that HAVE automation (skip state=0)
296
- if (state > 0) {
297
- results.push({
298
- index: i,
299
- name: cursor_b.get("name").toString(),
300
- automation_state: state,
301
- // 1 = automation active, 2 = automation overridden (user moved knob)
302
- state_label: state === 1 ? "active" : "overridden"
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
- cursor_a.goto(path);
400
- var chain_count = cursor_a.getcount("chains");
401
- var chains = [];
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 dev_count = cursor_b.getcount("devices");
417
- for (var d = 0; d < dev_count; d++) {
418
- cursor_a.goto(chain_path + " devices " + d);
419
- chain.devices.push({
420
- index: d,
421
- name: cursor_a.get("name").toString(),
422
- class_name: cursor_a.get("class_name").toString(),
423
- is_active: parseInt(cursor_a.get("is_active")) === 1,
424
- param_count: cursor_a.getcount("parameters")
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
- chains.push(chain);
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
- send_response({"track": track_idx, "device": device_idx, "chains": chains});
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
- // Get CPU performance impact per track
435
- var results = [];
436
- cursor_a.goto("live_set");
437
- var track_count = cursor_a.getcount("tracks");
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
- send_response({"tracks": results, "count": track_count});
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
- var result = "";
603
- var bytes = [];
604
- for (var i = 0; i < str.length; i++) {
605
- bytes.push(str.charCodeAt(i) & 0xFF);
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
- // Reconstruct file path spaces in path split into multiple OSC args
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 = 4;
1150
+ var batch_size = 8;
928
1151
 
929
1152
  function read_batch() {
930
- var end = Math.min(current + batch_size, param_count);
931
- for (var i = current; i < end; i++) {
932
- cursor_b.goto(path + " parameters " + i);
933
- var state = parseInt(cursor_b.get("state"));
934
- if (state !== 2) {
935
- params.push({
936
- index: i,
937
- name: cursor_b.get("name").toString(),
938
- display_value: String(cursor_b.call("str_for_value", parseFloat(cursor_b.get("value")))),
939
- value: parseFloat(cursor_b.get("value"))
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
- "params": params
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 into buffer~ via outlet 1.
987
- // The patch is expected to connect outlet 1 to a buffer~ / record~ rig.
988
- // Message: "capture_start <filename> <num_samples>"
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 = 4;
1323
+ var batch_size = 8;
1073
1324
 
1074
1325
  function read_batch() {
1075
- var end = Math.min(current + batch_size, param_count);
1076
- for (var i = current; i < end; i++) {
1077
- cursor_b.goto(path + " parameters " + i);
1078
- params.push({
1079
- index: i,
1080
- name: cursor_b.get("name").toString(),
1081
- value: parseFloat(cursor_b.get("value")),
1082
- min: parseFloat(cursor_b.get("min")),
1083
- max: parseFloat(cursor_b.get("max")),
1084
- default_value: parseFloat(cursor_b.get("default_value")),
1085
- is_quantized: parseInt(cursor_b.get("is_quantized")) === 1,
1086
- value_string: String(cursor_b.call("str_for_value", parseFloat(cursor_b.get("value"))))
1087
- });
1088
- }
1089
- current = end;
1090
-
1091
- if (current < param_count) {
1092
- var next_task = new Task(read_batch);
1093
- next_task.schedule(50);
1094
- } else {
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
- "class_name": class_name,
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.message,
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
+ }