livepilot 1.9.11 → 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 (37) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/AGENTS.md +1 -1
  3. package/CHANGELOG.md +47 -0
  4. package/README.md +11 -10
  5. package/bin/livepilot.js +1 -1
  6. package/livepilot/.Codex-plugin/plugin.json +1 -1
  7. package/livepilot/.claude-plugin/plugin.json +1 -1
  8. package/livepilot/skills/livepilot-core/SKILL.md +6 -6
  9. package/livepilot/skills/livepilot-core/references/overview.md +1 -1
  10. package/livepilot/skills/livepilot-release/SKILL.md +4 -4
  11. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  12. package/m4l_device/capture_2026_04_07_192216.wav +0 -0
  13. package/m4l_device/livepilot_bridge.js +77 -3
  14. package/mcp_server/__init__.py +1 -1
  15. package/mcp_server/connection.py +5 -1
  16. package/mcp_server/curves.py +5 -1
  17. package/mcp_server/m4l_bridge.py +37 -20
  18. package/mcp_server/server.py +10 -2
  19. package/mcp_server/tools/analyzer.py +12 -1
  20. package/mcp_server/tools/arrangement.py +20 -5
  21. package/mcp_server/tools/automation.py +52 -0
  22. package/mcp_server/tools/devices.py +2 -3
  23. package/mcp_server/tools/generative.py +4 -0
  24. package/mcp_server/tools/midi_io.py +22 -4
  25. package/mcp_server/tools/theory.py +8 -1
  26. package/mcp_server/tools/transport.py +16 -6
  27. package/package.json +1 -1
  28. package/remote_script/LivePilot/__init__.py +1 -1
  29. package/remote_script/LivePilot/arrangement.py +12 -18
  30. package/remote_script/LivePilot/browser.py +19 -8
  31. package/remote_script/LivePilot/clip_automation.py +5 -4
  32. package/remote_script/LivePilot/clips.py +14 -6
  33. package/remote_script/LivePilot/devices.py +6 -3
  34. package/remote_script/LivePilot/server.py +18 -6
  35. package/remote_script/LivePilot/tracks.py +7 -2
  36. package/remote_script/LivePilot/transport.py +3 -3
  37. package/requirements.txt +3 -1
@@ -10,7 +10,7 @@
10
10
  {
11
11
  "name": "livepilot",
12
12
  "description": "Agentic production system for Ableton Live 12 — 178 tools, 17 domains, device atlas, spectral perception, technique memory, neo-Riemannian harmony, Euclidean rhythm, species counterpoint, MIDI I/O",
13
- "version": "1.9.11",
13
+ "version": "1.9.12",
14
14
  "author": {
15
15
  "name": "Pilot Studio"
16
16
  },
package/AGENTS.md CHANGED
@@ -1,4 +1,4 @@
1
- # LivePilot v1.9.11 — Ableton Live 12
1
+ # LivePilot v1.9.12 — Ableton Live 12
2
2
 
3
3
  ## Project
4
4
  - **Repo:** This directory (LivePilot)
package/CHANGELOG.md CHANGED
@@ -1,5 +1,45 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.12 — Deep Audit: 21 Fixes Across 15 Files (April 2026)
4
+
5
+ **Full codebase audit — 5 critical, 10 important, 6 doc/test fixes.**
6
+
7
+ ### Critical Fixes
8
+ - Fix(P1): `capture_stop` no longer deadlocks — `cancel_capture_future` removed lock acquisition that blocked behind `send_capture`
9
+ - Fix(P1): `import_midi_to_clip` now distinguishes empty-slot NOT_FOUND from INDEX_ERROR/TIMEOUT instead of swallowing all AbletonConnectionErrors
10
+ - Fix(P1): capture audio files now write to `~/Documents/LivePilot/captures/` (stable path) instead of beside the .amxd preset
11
+ - Fix(P1): `check_flucoma` now uses `Folder.end` to detect FluCoMa — `typelist` check was always true
12
+ - Fix(P1): CI workflow updated to `actions/checkout@v4` + `actions/setup-python@v5` (v6 doesn't exist)
13
+
14
+ ### Safety & Validation
15
+ - Fix(P2): 5 automation tools now validate `track_index >= 0` and `clip_index >= 0` (matching all peer modules)
16
+ - Fix(P2): `cmd_stop_scrub` now checks `cursor_a.id === 0` for empty clip slots (matching all peer bridge functions)
17
+ - Fix(P2): `cmd_get_selected` now resolves return tracks (negative indices) and master track (-1000)
18
+ - Fix(P2): `duplicate_track` uses count-before/after delta for correct group track duplication index
19
+ - Fix(P2): `create_arrangement_clip` locates first clip by `start_time` instead of stale index after trim pass
20
+ - Fix(P2): `get_session_info` reuses already-built lists instead of re-iterating `song.tracks`/`song.scenes`
21
+ - Fix(P2): client disconnect race — socket now closes before clearing `_client_connected` flag
22
+
23
+ ### Tests
24
+ - Fix: transport validation tests now import production `_validate_tempo`/`_validate_time_signature` instead of testing local copies
25
+ - Fix: added `load_sample_to_simpler` to analyzer tool contract (was 28/29)
26
+ - Fix: removed duplicate `test_release_quick_verify_checks_both_plugin_manifests`
27
+ - New: 5 automation negative tests (index validation, parameter_type validation)
28
+
29
+ ### Documentation
30
+ - Fix: `docs/manual/index.md` domain map — Tracks 14→17, Devices 12→15, Scenes 8→12
31
+ - Fix: README perception split — 145+33 → 149+29 (actual analyzer tool count is 29)
32
+ - Fix: M4L_BRIDGE.md command count — 22→28 (6 commands undocumented)
33
+ - Fix: tool-reference.md MIDI docs — `export_clip_midi` and `import_midi_to_clip` parameter tables matched to actual signatures
34
+
35
+ ### Deferred (documented, low-impact)
36
+ - Timed-out commands still execute on main thread (needs cancellation token redesign)
37
+ - Chunked UDP reassembly fragile on packet loss (loopback mitigates)
38
+ - Diatonic transpose octave correction edge case (needs musical test suite)
39
+ - `cmd_map_plugin_param` reports false success (LiveAPI lacks Configure mapping API)
40
+
41
+ Verification: 145 tests passing (non-fastmcp), 178 tools confirmed, 15 files changed
42
+
3
43
  ## 1.9.11 — Session Diagnostics + Client Conflict Clarity (March 2026)
4
44
 
5
45
  **Live-tested against the open Ableton set after reloading the updated Remote Script.**
@@ -11,6 +51,13 @@
11
51
  - Fix(P2): `--status` and TCP timeout paths now explain when another LivePilot client appears to be connected instead of only reporting a generic timeout
12
52
  - Docs: beat/sounddesign/core skill guidance now includes device-health checks, sample-dependent plugin cautions, and pitch-audit discipline from the live stress-test sessions
13
53
  - Verification: `292 passed`, `npm pack --dry-run` passed, live set diagnostics succeeded, analyzer bridge streamed on the master track, and conflict reproduction now reports the competing client PID
54
+ - Fix(P1): brownian automation curve reflection loop now has 100-iteration guard with hard clamp fallback — high volatility values could previously hang the server
55
+ - Fix(P1): schema coercion now recurses into array `items` so `list[float]` params benefit from string-to-number widening for MCP clients that serialize as strings
56
+ - Fix(P1): `apply_automation_shape` and `apply_automation_recipe` now validate `parameter_type` and required companion params before sending to Ableton
57
+ - Fix(P2): Remote Script `AssertionError` fallbacks now return STATE_ERROR instead of running LOM calls on the TCP thread during ControlSurface disconnection
58
+ - Fix(P2): M4L bridge ping version corrected to 1.9.11; `check_flucoma` now probes disk for FluCoMa package instead of returning hardcoded `true`
59
+ - Verification: deep audit across 45+ files (3 parallel agents), 292 unit tests + 15 live integration tests against Ableton session, all passing
60
+
14
61
  ## 1.9.10 — Analyzer Capture Finalization + Release Sync (March 2026)
15
62
 
16
63
  **Live-tested in Ableton after a full analyzer rebuild and master-track validation.**
package/README.md CHANGED
@@ -1,10 +1,11 @@
1
- <p align="center">
2
- <picture>
3
- <source media="(prefers-color-scheme: dark)" srcset="docs/assets/banner-dark.svg">
4
- <source media="(prefers-color-scheme: light)" srcset="docs/assets/banner-light.svg">
5
- <img alt="LivePilot" src="docs/assets/banner-light.svg" width="600">
6
- </picture>
7
- </p>
1
+ ```
2
+ ██╗ ██╗██╗ ██╗███████╗██████╗ ██╗██╗ ██████╗ ████████╗
3
+ ██║ ██║██║ ██║██╔════╝██╔══██╗██║██║ ██╔═══██╗╚══██╔══╝
4
+ ██║ ██║██║ ██║█████╗ ██████╔╝██║██║ ██║ ██║ ██║
5
+ ██║ ██║╚██╗ ██╔╝██╔══╝ ██╔═══╝ ██║██║ ██║ ██║ ██║
6
+ ███████╗██║ ╚████╔╝ ███████╗██║ ██║███████╗╚██████╔╝ ██║
7
+ ╚══════╝╚═╝ ╚═══╝ ╚══════╝╚═╝ ╚═╝╚══════╝ ╚═════╝ ╚═╝
8
+ ```
8
9
 
9
10
  <p align="center">
10
11
  <a href="https://github.com/dreamrec/LivePilot/actions"><img src="https://img.shields.io/github/actions/workflow/status/dreamrec/LivePilot/ci.yml?style=flat-square&label=CI" alt="CI"></a>
@@ -90,7 +91,7 @@ All three feed into 178 deterministic tools that execute on Ableton's main threa
90
91
  | Tracks | 17 | create MIDI/audio/return, delete, duplicate, arm, mute, solo, color, freeze, flatten |
91
92
  | Clips | 11 | create, delete, duplicate, fire, stop, loop, launch mode, warp mode, quantize |
92
93
  | Notes | 8 | add/get/remove/modify MIDI notes, transpose, duplicate, per-note probability |
93
- | Devices | 12 | load by name or URI, get/set parameters, batch edit, racks, chains, presets |
94
+ | Devices | 15 | load by name or URI, get/set parameters, batch edit, racks, chains, presets, plugin deep control |
94
95
  | Scenes | 12 | create, delete, duplicate, fire, name, color, tempo, scene matrix |
95
96
  | Browser | 4 | search library, browse tree, load items, filter by category |
96
97
  | Mixing | 11 | volume, pan, sends, routing, meters, return tracks, master, full mix snapshot |
@@ -98,13 +99,13 @@ All three feed into 178 deterministic tools that execute on Ableton's main threa
98
99
 
99
100
  <br>
100
101
 
101
- ### Perception — 32 tools `[M4L]`
102
+ ### Perception — 29 tools `[M4L]`
102
103
 
103
104
  The M4L Analyzer sits on the master track. UDP 9880 carries spectral data
104
105
  from Max to the server. OSC 9881 sends commands back.
105
106
 
106
107
  > [!TIP]
107
- > All 146 core tools work without the analyzer — it adds 32 more and closes the feedback loop.
108
+ > All 149 core tools work without the analyzer — it adds 29 more and closes the feedback loop.
108
109
 
109
110
  ```
110
111
  SPECTRAL ─────── 8-band frequency decomposition (sub → air)
package/bin/livepilot.js CHANGED
@@ -95,7 +95,7 @@ function ensureVenv(systemPython, prefixArgs) {
95
95
  // Check if venv already exists and has our deps
96
96
  if (fs.existsSync(venvPy)) {
97
97
  try {
98
- execFileSync(venvPy, ["-c", "import fastmcp; import midiutil; import pretty_midi; import numpy; import pyloudnorm; import soundfile; import scipy"], {
98
+ execFileSync(venvPy, ["-c", "import fastmcp; import midiutil; import pretty_midi; import numpy; import pyloudnorm; import soundfile; import scipy; import mutagen"], {
99
99
  encoding: "utf-8",
100
100
  timeout: 10000,
101
101
  stdio: "pipe",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "livepilot",
3
- "version": "1.9.11",
3
+ "version": "1.9.12",
4
4
  "description": "Agentic production system for Ableton Live 12 — 178 tools, 17 domains, device atlas, spectral perception, technique memory, neo-Riemannian harmony, Euclidean rhythm, species counterpoint, MIDI I/O",
5
5
  "author": {
6
6
  "name": "Pilot Studio"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "livepilot",
3
- "version": "1.9.11",
3
+ "version": "1.9.12",
4
4
  "description": "Agentic production system for Ableton Live 12 — 178 tools, 17 domains, device atlas, spectral perception, technique memory, neo-Riemannian harmony, Euclidean rhythm, species counterpoint, MIDI I/O",
5
5
  "author": {
6
6
  "name": "Pilot Studio"
@@ -138,8 +138,8 @@ Never skip levels. The user's question determines the entry point, but always st
138
138
  ### Transport (12)
139
139
  `get_session_info` · `set_tempo` · `set_time_signature` · `start_playback` · `stop_playback` · `continue_playback` · `toggle_metronome` · `set_session_loop` · `undo` · `redo` · `get_recent_actions` · `get_session_diagnostics`
140
140
 
141
- ### Tracks (14)
142
- `get_track_info` · `create_midi_track` · `create_audio_track` · `create_return_track` · `delete_track` · `duplicate_track` · `set_track_name` · `set_track_color` · `set_track_mute` · `set_track_solo` · `set_track_arm` · `stop_track_clips` · `set_group_fold` · `set_track_input_monitoring`
141
+ ### Tracks (17)
142
+ `get_track_info` · `create_midi_track` · `create_audio_track` · `create_return_track` · `delete_track` · `duplicate_track` · `set_track_name` · `set_track_color` · `set_track_mute` · `set_track_solo` · `set_track_arm` · `stop_track_clips` · `set_group_fold` · `set_track_input_monitoring` · `freeze_track` · `flatten_track` · `get_freeze_status`
143
143
 
144
144
  ### Clips (11)
145
145
  `get_clip_info` · `create_clip` · `delete_clip` · `duplicate_clip` · `fire_clip` · `stop_clip` · `set_clip_name` · `set_clip_color` · `set_clip_loop` · `set_clip_launch` · `set_clip_warp_mode`
@@ -147,11 +147,11 @@ Never skip levels. The user's question determines the entry point, but always st
147
147
  ### Notes (8)
148
148
  `add_notes` · `get_notes` · `remove_notes` · `remove_notes_by_id` · `modify_notes` · `duplicate_notes` · `transpose_notes` · `quantize_clip`
149
149
 
150
- ### Devices (12)
151
- `get_device_info` · `get_device_parameters` · `set_device_parameter` · `batch_set_parameters` · `toggle_device` · `delete_device` · `load_device_by_uri` · `find_and_load_device` · `set_simpler_playback_mode` · `get_rack_chains` · `set_chain_volume` · `get_device_presets`
150
+ ### Devices (15)
151
+ `get_device_info` · `get_device_parameters` · `set_device_parameter` · `batch_set_parameters` · `toggle_device` · `delete_device` · `load_device_by_uri` · `find_and_load_device` · `set_simpler_playback_mode` · `get_rack_chains` · `set_chain_volume` · `get_device_presets` · `get_plugin_parameters` · `map_plugin_parameter` · `get_plugin_presets`
152
152
 
153
- ### Scenes (8)
154
- `get_scenes_info` · `create_scene` · `delete_scene` · `duplicate_scene` · `fire_scene` · `set_scene_name` · `set_scene_color` · `set_scene_tempo`
153
+ ### Scenes (12)
154
+ `get_scenes_info` · `create_scene` · `delete_scene` · `duplicate_scene` · `fire_scene` · `set_scene_name` · `set_scene_color` · `set_scene_tempo` · `get_scene_matrix` · `fire_scene_clips` · `stop_all_clips` · `get_playing_clips`
155
155
 
156
156
  ### Mixing (11)
157
157
  `set_track_volume` · `set_track_pan` · `set_track_send` · `get_return_tracks` · `get_master_track` · `set_master_volume` · `get_track_routing` · `set_track_routing` · `get_track_meters` · `get_master_meters` · `get_mix_snapshot`
@@ -1,4 +1,4 @@
1
- # LivePilot v1.9.11 — Architecture & Tool Reference
1
+ # LivePilot v1.9.12 — Architecture & Tool Reference
2
2
 
3
3
  Agentic production system for Ableton Live 12. 178 tools across 17 domains. Device atlas (280+ devices), spectral perception (M4L analyzer), technique memory, automation intelligence (16 curve types, 15 recipes), music theory (Krumhansl-Schmuckler, species counterpoint), generative algorithms (Euclidean rhythm, tintinnabuli, phase shift, additive process), neo-Riemannian harmony (PRL transforms, Tonnetz), MIDI file I/O.
4
4
 
@@ -29,12 +29,12 @@ Run this checklist EVERY time the user says "update everything", "push", "releas
29
29
  ## 2. Tool Count (must ALL match)
30
30
 
31
31
  Current: **178 tools across 17 domains**.
32
- Core (no M4L): **139**. Analyzer (M4L): **29**. Perception (offline): **4**.
32
+ Core (no M4L): **149**. Analyzer (M4L): **29**. Perception (offline): **4**.
33
33
 
34
34
  Verify: `grep -rc "@mcp.tool" mcp_server/tools/ | grep -v ":0" | awk -F: '{sum+=$2} END{print sum}'`
35
35
 
36
36
  Files that reference tool count:
37
- - [ ] `README.md` — header, PERCEPTION section ("139 core...29 analyzer"), Analyzer table header "(29)", Perception table header "(4)"
37
+ - [ ] `README.md` — header, PERCEPTION section ("149 core...29 analyzer"), Analyzer table header "(29)", Perception table header "(4)"
38
38
  - [ ] `package.json` → `"description"` (178 tools, 17 domains)
39
39
  - [ ] `server.json` → `"description"`
40
40
  - [ ] `livepilot/.Codex-plugin/plugin.json` → `"description"` (primary Codex manifest)
@@ -44,10 +44,10 @@ Files that reference tool count:
44
44
  - [ ] `livepilot/skills/livepilot-core/SKILL.md` — "178 tools across 17 domains", Analyzer (29), Perception (4)
45
45
  - [ ] `livepilot/skills/livepilot-core/references/overview.md` — "178 tools across 17 domains"
46
46
  - [ ] `docs/manual/index.md` — domain table: Analyzer (29), Perception (4)
47
- - [ ] `docs/manual/getting-started.md` — "139 core tools...29 analyzer"
47
+ - [ ] `docs/manual/getting-started.md` — "149 core tools...29 analyzer"
48
48
  - [ ] `docs/manual/tool-reference.md` — all domains present with correct counts
49
49
  - [ ] `docs/TOOL_REFERENCE.md` — all domains present
50
- - [ ] `docs/M4L_BRIDGE.md` — "139 core tools...29 analyzer"
50
+ - [ ] `docs/M4L_BRIDGE.md` — "149 core tools...29 analyzer"
51
51
  - [ ] `docs/social-banner.html`
52
52
  - [ ] `mcp_server/tools/analyzer.py` → module docstring
53
53
  - [ ] `tests/test_tools_contract.py` → expected total count
Binary file
@@ -84,7 +84,7 @@ function anything() {
84
84
  function dispatch(cmd, args) {
85
85
  switch(cmd) {
86
86
  case "ping":
87
- send_response({"ok": true, "version": "1.9.10"});
87
+ send_response({"ok": true, "version": "1.9.12"});
88
88
  break;
89
89
  case "get_params":
90
90
  cmd_get_params(args);
@@ -150,7 +150,7 @@ function dispatch(cmd, args) {
150
150
  cmd_capture_stop();
151
151
  break;
152
152
  case "check_flucoma":
153
- send_response({"flucoma_available": true, "version": "1.0.9"});
153
+ cmd_check_flucoma();
154
154
  break;
155
155
  // ── Phase 2: Clip & Display ──
156
156
  case "scrub_clip":
@@ -458,6 +458,22 @@ function cmd_get_chains_deep(args) {
458
458
  }
459
459
  }
460
460
 
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
+ }
475
+ }
476
+
461
477
  function cmd_get_track_cpu(args) {
462
478
  try {
463
479
  var results = [];
@@ -511,6 +527,25 @@ function cmd_get_selected() {
511
527
  break;
512
528
  }
513
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
+ }
514
549
  } catch(e) {}
515
550
 
516
551
  // Selected scene
@@ -1067,6 +1102,10 @@ function cmd_stop_scrub(args) {
1067
1102
  var path = build_track_path(track_idx) + " clip_slots " + clip_idx + " clip";
1068
1103
 
1069
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
+ }
1070
1109
  try {
1071
1110
  cursor_a.call("stop_scrub");
1072
1111
  send_response({"ok": true});
@@ -1165,6 +1204,15 @@ function cmd_capture_audio(args) {
1165
1204
  var duration_ms = parseInt(args[0]) || 10000;
1166
1205
  var requested_name = args[1] ? args[1].toString().trim() : "";
1167
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
+
1168
1216
  // Generate a timestamped filename if none provided
1169
1217
  var d = new Date();
1170
1218
  var ts = d.getFullYear() + "_"
@@ -1172,7 +1220,7 @@ function cmd_capture_audio(args) {
1172
1220
  + pad2(d.getDate()) + "_"
1173
1221
  + pad2(d.getHours()) + pad2(d.getMinutes()) + pad2(d.getSeconds());
1174
1222
  capture_filename = requested_name.length > 0 ? requested_name : ("capture_" + ts + ".wav");
1175
- capture_file_path = _join_path(_get_patcher_dir(), capture_filename);
1223
+ capture_file_path = _join_path(_get_captures_dir(), capture_filename);
1176
1224
 
1177
1225
  // Calculate sample count from duration and current sample rate
1178
1226
  var num_samples = Math.ceil((duration_ms / 1000.0) * current_sample_rate);
@@ -1438,6 +1486,22 @@ function build_device_path(track_idx, device_idx) {
1438
1486
  }
1439
1487
  }
1440
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
+
1441
1505
  function _get_patcher_dir() {
1442
1506
  try {
1443
1507
  var filepath = this.patcher && this.patcher.filepath ? this.patcher.filepath.toString() : "";
@@ -1450,6 +1514,16 @@ function _get_patcher_dir() {
1450
1514
  }
1451
1515
  }
1452
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
+
1453
1527
  function _join_path(dir, file) {
1454
1528
  if (!dir) return file;
1455
1529
  var last = dir.charAt(dir.length - 1);
@@ -1,2 +1,2 @@
1
1
  """LivePilot MCP Server — bridges MCP protocol to Ableton Live."""
2
- __version__ = "1.9.11"
2
+ __version__ = "1.9.12"
@@ -213,7 +213,7 @@ class AbletonConnection:
213
213
  raise AbletonConnectionError(f"Failed to send command: {exc}") from exc
214
214
 
215
215
  # Read until newline, preserving any trailing bytes in _recv_buf
216
- buf = getattr(self, "_recv_buf", b"")
216
+ buf = self._recv_buf
217
217
  try:
218
218
  while b"\n" not in buf:
219
219
  chunk = self._socket.recv(4096)
@@ -222,6 +222,10 @@ class AbletonConnection:
222
222
  self.disconnect()
223
223
  raise AbletonConnectionError("Connection closed by Ableton")
224
224
  buf += chunk
225
+ if len(buf) > 10 * 1024 * 1024: # 10 MB
226
+ self._recv_buf = b""
227
+ self.disconnect()
228
+ raise AbletonConnectionError("Response too large (>10 MB)")
225
229
  except socket.timeout as exc:
226
230
  self._recv_buf = buf
227
231
  self.disconnect()
@@ -366,11 +366,15 @@ def _brownian(duration: float, density: int, start: float = 0.5,
366
366
  step = drift / density + volatility * _det_random(i, seed)
367
367
  value += step
368
368
  # Soft boundary reflection (bounce off 0/1 until within range)
369
- while value > 1.0 or value < 0.0:
369
+ for _ in range(100):
370
+ if 0.0 <= value <= 1.0:
371
+ break
370
372
  if value > 1.0:
371
373
  value = 2.0 - value
372
374
  if value < 0.0:
373
375
  value = -value
376
+ else:
377
+ value = max(0.0, min(1.0, value))
374
378
  return points
375
379
 
376
380
 
@@ -141,6 +141,7 @@ class SpectralReceiver(asyncio.DatagramProtocol):
141
141
  def __init__(self, cache: SpectralCache):
142
142
  self.cache = cache
143
143
  self._chunks: dict[str, dict] = {} # Reassembly buffer for chunked responses
144
+ self._chunk_times: dict[str, float] = {} # Monotonic timestamp per chunk sequence
144
145
  self._chunk_id = 0
145
146
  self._response_callback: Optional[asyncio.Future] = None
146
147
  self._capture_future: Optional[asyncio.Future] = None
@@ -284,6 +285,7 @@ class SpectralReceiver(asyncio.DatagramProtocol):
284
285
  key = str(self._chunk_id)
285
286
  if key not in self._chunks:
286
287
  self._chunks[key] = {"parts": {}, "total": total}
288
+ self._chunk_times[key] = time.monotonic()
287
289
 
288
290
  self._chunks[key]["parts"][index] = encoded
289
291
 
@@ -293,8 +295,16 @@ class SpectralReceiver(asyncio.DatagramProtocol):
293
295
  for i in range(total):
294
296
  full += self._chunks[key]["parts"][i]
295
297
  del self._chunks[key]
298
+ self._chunk_times.pop(key, None)
296
299
  self._handle_response(full)
297
300
 
301
+ # Evict incomplete chunk sequences older than 30 seconds
302
+ now = time.monotonic()
303
+ stale = [k for k, t in self._chunk_times.items() if now - t > 30.0]
304
+ for k in stale:
305
+ self._chunks.pop(k, None)
306
+ self._chunk_times.pop(k, None)
307
+
298
308
  def _handle_capture_complete(self, encoded: str) -> None:
299
309
  """Decode a /capture_complete OSC message and resolve _capture_future."""
300
310
  try:
@@ -359,30 +369,37 @@ class M4LBridge:
359
369
  if not self.cache.is_connected:
360
370
  return {"error": "LivePilot Analyzer not connected. Drop it on the master track."}
361
371
 
362
- # Cancel any stale capture future before creating a new one
363
- if self.receiver and self.receiver._capture_future and not self.receiver._capture_future.done():
364
- self.receiver._capture_future.cancel()
372
+ async with self._cmd_lock:
373
+ # Cancel any stale capture future before creating a new one
374
+ if self.receiver and self.receiver._capture_future and not self.receiver._capture_future.done():
375
+ self.receiver._capture_future.cancel()
365
376
 
366
- loop = asyncio.get_running_loop()
367
- future = loop.create_future()
368
- if self.receiver:
369
- self.receiver.set_capture_future(future)
377
+ loop = asyncio.get_running_loop()
378
+ future = loop.create_future()
379
+ if self.receiver:
380
+ self.receiver.set_capture_future(future)
370
381
 
371
- osc_data = self._build_osc(command, args)
372
- self._sock.sendto(osc_data, self._m4l_addr)
382
+ osc_data = self._build_osc(command, args)
383
+ self._sock.sendto(osc_data, self._m4l_addr)
373
384
 
374
- try:
375
- result = await asyncio.wait_for(future, timeout=timeout)
376
- return result
377
- except asyncio.TimeoutError:
378
- # Clean up the dangling future
379
- if self.receiver:
380
- self.receiver._capture_future = None
381
- return {"error": "M4L capture timeout — device may be busy or removed"}
385
+ try:
386
+ result = await asyncio.wait_for(future, timeout=timeout)
387
+ return result
388
+ except asyncio.TimeoutError:
389
+ # Clean up the dangling future
390
+ if self.receiver:
391
+ self.receiver._capture_future = None
392
+ return {"error": "M4L capture timeout — device may be busy or removed"}
382
393
 
383
- def cancel_capture_future(self) -> None:
384
- """Cancel any in-progress capture future (called by capture_stop)."""
385
- if self.receiver and self.receiver._capture_future and not self.receiver._capture_future.done():
394
+ async def cancel_capture_future(self) -> None:
395
+ """Cancel any in-progress capture future (called by capture_stop).
396
+
397
+ Does NOT acquire _cmd_lock — send_capture holds it during recording.
398
+ Cancelling the future causes send_capture's wait_for to raise
399
+ CancelledError, which releases the lock naturally.
400
+ """
401
+ if self.receiver and self.receiver._capture_future \
402
+ and not self.receiver._capture_future.done():
386
403
  self.receiver._capture_future.cancel()
387
404
  self.receiver._capture_future = None
388
405
 
@@ -119,12 +119,20 @@ from .tools import perception # noqa: F401, E402
119
119
 
120
120
  def _coerce_schema_property(prop: dict) -> None:
121
121
  """Widen a single JSON Schema property to also accept strings."""
122
- if prop.get("type") in ("integer", "number"):
122
+ if prop.get("type") in ("integer", "number") and "anyOf" not in prop:
123
123
  original_type = prop.pop("type")
124
124
  prop["anyOf"] = [{"type": original_type}, {"type": "string"}]
125
125
  elif "anyOf" in prop:
126
+ # Skip if this anyOf was already coerced (contains both a numeric and string type)
127
+ variant_types = {v.get("type") for v in prop["anyOf"] if isinstance(v, dict)}
128
+ if "string" in variant_types and variant_types & {"integer", "number"}:
129
+ return
126
130
  for variant in prop["anyOf"]:
127
- _coerce_schema_property(variant)
131
+ if isinstance(variant, dict):
132
+ _coerce_schema_property(variant)
133
+ # Recurse into array items so list[int]/list[float] params also accept strings
134
+ if "items" in prop and isinstance(prop["items"], dict):
135
+ _coerce_schema_property(prop["items"])
128
136
 
129
137
 
130
138
  def _get_all_tools():
@@ -590,7 +590,18 @@ async def capture_audio(
590
590
  if source not in ("master",):
591
591
  raise ValueError(f"Unsupported source '{source}'. Valid: 'master'")
592
592
 
593
+ # Sanitize filename — strip directory components to prevent path traversal
594
+ if filename:
595
+ safe_name = os.path.basename(filename)
596
+ if not safe_name or safe_name != filename:
597
+ raise ValueError(
598
+ f"Filename must not contain path separators or '..' segments: {filename!r}"
599
+ )
600
+ filename = safe_name
601
+
593
602
  bridge = _get_m4l(ctx)
603
+ # Ensure captures directory exists before sending to bridge
604
+ os.makedirs(CAPTURE_DIR, exist_ok=True)
594
605
  duration_ms = duration_seconds * 1000
595
606
  result = await bridge.send_capture(
596
607
  "capture_audio",
@@ -613,7 +624,7 @@ async def capture_stop(ctx: Context) -> dict:
613
624
  _require_analyzer(cache)
614
625
  bridge = _get_m4l(ctx)
615
626
  # Cancel the capture future so send_capture doesn't hang forever
616
- bridge.cancel_capture_future()
627
+ await bridge.cancel_capture_future()
617
628
  return await bridge.send_command("capture_stop")
618
629
 
619
630
 
@@ -162,7 +162,10 @@ def add_arrangement_notes(
162
162
  _validate_track_index(track_index)
163
163
  _validate_clip_index(clip_index)
164
164
  if isinstance(notes, str):
165
- notes = json.loads(notes)
165
+ try:
166
+ notes = json.loads(notes)
167
+ except json.JSONDecodeError as exc:
168
+ raise ValueError(f"Invalid JSON in notes parameter: {exc}") from exc
166
169
  for note in notes:
167
170
  _validate_note(note)
168
171
  return _get_ableton(ctx).send_command("add_arrangement_notes", {
@@ -206,7 +209,10 @@ def set_arrangement_automation(
206
209
  if parameter_type == "send" and send_index is None:
207
210
  raise ValueError("send_index required for parameter_type='send'")
208
211
  if isinstance(points, str):
209
- points = json.loads(points)
212
+ try:
213
+ points = json.loads(points)
214
+ except json.JSONDecodeError as exc:
215
+ raise ValueError(f"Invalid JSON in points parameter: {exc}") from exc
210
216
  if not points:
211
217
  raise ValueError("points list cannot be empty")
212
218
  params: dict = {
@@ -352,7 +358,10 @@ def remove_arrangement_notes_by_id(
352
358
  _validate_track_index(track_index)
353
359
  _validate_clip_index(clip_index)
354
360
  if isinstance(note_ids, str):
355
- note_ids = json.loads(note_ids)
361
+ try:
362
+ note_ids = json.loads(note_ids)
363
+ except json.JSONDecodeError as exc:
364
+ raise ValueError(f"Invalid JSON in note_ids parameter: {exc}") from exc
356
365
  if not note_ids:
357
366
  raise ValueError("note_ids list cannot be empty")
358
367
  return _get_ableton(ctx).send_command("remove_arrangement_notes_by_id", {
@@ -374,7 +383,10 @@ def modify_arrangement_notes(
374
383
  _validate_track_index(track_index)
375
384
  _validate_clip_index(clip_index)
376
385
  if isinstance(modifications, str):
377
- modifications = json.loads(modifications)
386
+ try:
387
+ modifications = json.loads(modifications)
388
+ except json.JSONDecodeError as exc:
389
+ raise ValueError(f"Invalid JSON in modifications parameter: {exc}") from exc
378
390
  if not modifications:
379
391
  raise ValueError("modifications list cannot be empty")
380
392
  for mod in modifications:
@@ -407,7 +419,10 @@ def duplicate_arrangement_notes(
407
419
  _validate_track_index(track_index)
408
420
  _validate_clip_index(clip_index)
409
421
  if isinstance(note_ids, str):
410
- note_ids = json.loads(note_ids)
422
+ try:
423
+ note_ids = json.loads(note_ids)
424
+ except json.JSONDecodeError as exc:
425
+ raise ValueError(f"Invalid JSON in note_ids parameter: {exc}") from exc
411
426
  if not note_ids:
412
427
  raise ValueError("note_ids list cannot be empty")
413
428
  return _get_ableton(ctx).send_command("duplicate_arrangement_notes", {
@@ -43,6 +43,10 @@ def get_clip_automation(
43
43
  parameter name, and type (mixer/send/device). Use this to see
44
44
  what's already automated before writing new curves.
45
45
  """
46
+ if track_index < 0:
47
+ raise ValueError("track_index must be >= 0")
48
+ if clip_index < 0:
49
+ raise ValueError("clip_index must be >= 0")
46
50
  return _get_ableton(ctx).send_command("get_clip_automation", {
47
51
  "track_index": track_index,
48
52
  "clip_index": clip_index,
@@ -72,6 +76,17 @@ def set_clip_automation(
72
76
  Tip: Use apply_automation_shape to generate points from curves/recipes
73
77
  instead of calculating points manually.
74
78
  """
79
+ if track_index < 0:
80
+ raise ValueError("track_index must be >= 0")
81
+ if clip_index < 0:
82
+ raise ValueError("clip_index must be >= 0")
83
+ if parameter_type not in ("device", "volume", "panning", "send"):
84
+ raise ValueError("parameter_type must be 'device', 'volume', 'panning', or 'send'")
85
+ if parameter_type == "device":
86
+ if device_index is None or parameter_index is None:
87
+ raise ValueError("device_index and parameter_index required for parameter_type='device'")
88
+ if parameter_type == "send" and send_index is None:
89
+ raise ValueError("send_index required for parameter_type='send'")
75
90
  params: dict = {
76
91
  "track_index": track_index,
77
92
  "clip_index": clip_index,
@@ -102,11 +117,22 @@ def clear_clip_automation(
102
117
  If parameter_type is omitted, clears ALL envelopes.
103
118
  If provided, clears only that parameter's envelope.
104
119
  """
120
+ if track_index < 0:
121
+ raise ValueError("track_index must be >= 0")
122
+ if clip_index < 0:
123
+ raise ValueError("clip_index must be >= 0")
105
124
  params: dict = {
106
125
  "track_index": track_index,
107
126
  "clip_index": clip_index,
108
127
  }
109
128
  if parameter_type is not None:
129
+ if parameter_type not in ("device", "volume", "panning", "send"):
130
+ raise ValueError("parameter_type must be 'device', 'volume', 'panning', or 'send'")
131
+ if parameter_type == "device":
132
+ if device_index is None or parameter_index is None:
133
+ raise ValueError("device_index and parameter_index required for parameter_type='device'")
134
+ if parameter_type == "send" and send_index is None:
135
+ raise ValueError("send_index required for parameter_type='send'")
110
136
  params["parameter_type"] = parameter_type
111
137
  if device_index is not None:
112
138
  params["device_index"] = device_index
@@ -190,6 +216,19 @@ def apply_automation_shape(
190
216
  - Throws: use spike with short duration (1-2 beats)
191
217
  - Tremolo/pan: use sine with frequency in musical divisions
192
218
  """
219
+ # Validate indices and parameter_type (same rules as set_clip_automation)
220
+ if track_index < 0:
221
+ raise ValueError("track_index must be >= 0")
222
+ if clip_index < 0:
223
+ raise ValueError("clip_index must be >= 0")
224
+ if parameter_type not in ("device", "volume", "panning", "send"):
225
+ raise ValueError("parameter_type must be 'device', 'volume', 'panning', or 'send'")
226
+ if parameter_type == "device":
227
+ if device_index is None or parameter_index is None:
228
+ raise ValueError("device_index and parameter_index required for parameter_type='device'")
229
+ if parameter_type == "send" and send_index is None:
230
+ raise ValueError("send_index required for parameter_type='send'")
231
+
193
232
  # Generate the curve
194
233
  points = generate_curve(
195
234
  curve_type=curve_type,
@@ -272,6 +311,19 @@ def apply_automation_recipe(
272
311
  - vinyl_crackle: slow bit reduction movement
273
312
  - stereo_narrow: collapse to mono before drop
274
313
  """
314
+ # Validate indices and parameter_type (same rules as set_clip_automation)
315
+ if track_index < 0:
316
+ raise ValueError("track_index must be >= 0")
317
+ if clip_index < 0:
318
+ raise ValueError("clip_index must be >= 0")
319
+ if parameter_type not in ("device", "volume", "panning", "send"):
320
+ raise ValueError("parameter_type must be 'device', 'volume', 'panning', or 'send'")
321
+ if parameter_type == "device":
322
+ if device_index is None or parameter_index is None:
323
+ raise ValueError("device_index and parameter_index required for parameter_type='device'")
324
+ if parameter_type == "send" and send_index is None:
325
+ raise ValueError("send_index required for parameter_type='send'")
326
+
275
327
  points = generate_from_recipe(recipe, duration=duration, density=density)
276
328
 
277
329
  if time_offset > 0:
@@ -180,12 +180,11 @@ def _postflight_loaded_device(ctx: Context, result: dict) -> dict:
180
180
 
181
181
  def _validate_track_index(track_index: int):
182
182
  if track_index < 0 and track_index != MASTER_TRACK_INDEX:
183
- if track_index < -100:
183
+ if not (-99 <= track_index <= -1):
184
184
  raise ValueError(
185
185
  "track_index must be >= 0 for regular tracks, "
186
- "negative for return tracks (-1=A, -2=B), or -1000 for master"
186
+ "-1..-99 for return tracks (-1=A, -2=B), or -1000 for master"
187
187
  )
188
- # Negative values -1..-99 are valid return track indices
189
188
 
190
189
 
191
190
  def _validate_device_index(device_index: int):
@@ -94,6 +94,10 @@ def layer_euclidean_rhythms(
94
94
  for layer in layers:
95
95
  p = int(layer["pulses"])
96
96
  s = int(layer["steps"])
97
+ if s < 1 or s > 64:
98
+ raise ValueError(f"steps must be between 1 and 64, got {s}")
99
+ if p < 0 or p > s:
100
+ raise ValueError(f"pulses must be between 0 and steps ({s}), got {p}")
97
101
  rot = int(layer.get("rotation", 0))
98
102
  pitch = int(layer["pitch"])
99
103
  vel = int(layer.get("velocity", 100))
@@ -51,6 +51,22 @@ def _output_dir() -> Path:
51
51
  return d
52
52
 
53
53
 
54
+ def _safe_output_path(directory: Path, filename: str) -> Path:
55
+ """Join *filename* to *directory* with path-traversal containment.
56
+
57
+ Strips directory components (``../../evil.mid`` → ``evil.mid``),
58
+ resolves the result, and verifies it is still inside *directory*.
59
+ Raises ``ValueError`` on any escape attempt.
60
+ """
61
+ safe_name = Path(filename).name # strip directory components
62
+ if not safe_name:
63
+ raise ValueError(f"Invalid filename: {filename!r}")
64
+ out = (directory / safe_name).resolve()
65
+ if not str(out).startswith(str(directory.resolve())):
66
+ raise ValueError(f"Filename escapes output directory: {filename!r}")
67
+ return out
68
+
69
+
54
70
  def _validate_midi_path(file_path: str) -> Path:
55
71
  p = Path(file_path)
56
72
  if not p.exists():
@@ -112,7 +128,7 @@ def export_clip_midi(
112
128
  if not filename.endswith((".mid", ".midi")):
113
129
  filename += ".mid"
114
130
 
115
- out_path = _output_dir() / filename
131
+ out_path = _safe_output_path(_output_dir(), filename)
116
132
 
117
133
  midi = MIDIFile(1)
118
134
  midi.addTempo(0, 0, tempo)
@@ -185,9 +201,11 @@ def import_midi_to_clip(
185
201
  "clip_index": clip_index,
186
202
  })
187
203
  slot_has_clip = True
188
- except AbletonConnectionError:
189
- # Slot is empty — no clip to clear
190
- pass
204
+ except AbletonConnectionError as exc:
205
+ msg = str(exc)
206
+ if "NOT_FOUND" not in msg and "STATE_ERROR" not in msg:
207
+ raise # propagate INDEX_ERROR, TIMEOUT, connection failures
208
+ # Slot is empty (NOT_FOUND) or no clip (STATE_ERROR)
191
209
 
192
210
  if slot_has_clip:
193
211
  # Clip exists — clear its notes before importing
@@ -669,7 +669,14 @@ def transpose_smart(
669
669
 
670
670
  source_tonic = source_key["tonic"]
671
671
  target_tonic = target["tonic"]
672
- semitone_shift = target_tonic - source_tonic
672
+ # Compute nearest-path semitone shift (never more than ±6)
673
+ raw_shift = target_tonic - source_tonic
674
+ if raw_shift > 6:
675
+ semitone_shift = raw_shift - 12
676
+ elif raw_shift < -6:
677
+ semitone_shift = raw_shift + 12
678
+ else:
679
+ semitone_shift = raw_shift
673
680
 
674
681
  if mode == "chromatic":
675
682
  transposed = []
@@ -23,21 +23,31 @@ def get_session_info(ctx: Context) -> dict:
23
23
  return _get_ableton(ctx).send_command("get_session_info")
24
24
 
25
25
 
26
+ def _validate_tempo(tempo: float) -> None:
27
+ """Validate tempo is within Ableton's accepted range."""
28
+ if not 20 <= tempo <= 999:
29
+ raise ValueError("Tempo must be between 20 and 999 BPM")
30
+
31
+
32
+ def _validate_time_signature(numerator: int, denominator: int) -> None:
33
+ """Validate time signature components."""
34
+ if numerator < 1 or numerator > 99:
35
+ raise ValueError("Numerator must be between 1 and 99")
36
+ if denominator not in (1, 2, 4, 8, 16):
37
+ raise ValueError("Denominator must be 1, 2, 4, 8, or 16")
38
+
39
+
26
40
  @mcp.tool()
27
41
  def set_tempo(ctx: Context, tempo: float) -> dict:
28
42
  """Set the song tempo in BPM (20-999)."""
29
- if not 20 <= tempo <= 999:
30
- raise ValueError("Tempo must be between 20 and 999 BPM")
43
+ _validate_tempo(tempo)
31
44
  return _get_ableton(ctx).send_command("set_tempo", {"tempo": tempo})
32
45
 
33
46
 
34
47
  @mcp.tool()
35
48
  def set_time_signature(ctx: Context, numerator: int, denominator: int) -> dict:
36
49
  """Set the time signature (e.g., 4/4, 3/4, 6/8)."""
37
- if numerator < 1 or numerator > 99:
38
- raise ValueError("Numerator must be between 1 and 99")
39
- if denominator not in (1, 2, 4, 8, 16):
40
- raise ValueError("Denominator must be 1, 2, 4, 8, or 16")
50
+ _validate_time_signature(numerator, denominator)
41
51
  return _get_ableton(ctx).send_command("set_time_signature", {
42
52
  "numerator": numerator,
43
53
  "denominator": denominator,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "livepilot",
3
- "version": "1.9.11",
3
+ "version": "1.9.12",
4
4
  "mcpName": "io.github.dreamrec/livepilot",
5
5
  "description": "Agentic production system for Ableton Live 12 — 178 tools, 17 domains, device atlas, spectral perception, technique memory, neo-Riemannian harmony, Euclidean rhythm, species counterpoint, MIDI I/O",
6
6
  "author": "Pilot Studio",
@@ -5,7 +5,7 @@ Entry point for the ControlSurface. Ableton calls create_instance(c_instance)
5
5
  when this script is selected in Preferences > Link, Tempo & MIDI.
6
6
  """
7
7
 
8
- __version__ = "1.9.11"
8
+ __version__ = "1.9.12"
9
9
 
10
10
  from _Framework.ControlSurface import ControlSurface
11
11
  from .server import LivePilotServer
@@ -85,29 +85,19 @@ def create_arrangement_clip(song, params):
85
85
  first_clip_index = None
86
86
 
87
87
  while pos < end_pos:
88
- # Snapshot clip IDs before duplication to identify the new one
89
- old_ids = set(id(c) for c in track.arrangement_clips)
90
-
91
88
  track.duplicate_clip_to_arrangement(source_clip, pos)
92
89
 
93
- # Find the NEW clip (not in old_ids) at the target position
90
+ # Find the new clip by position (id()-based detection is unreliable
91
+ # because CPython can reuse addresses of GC'd LOM wrappers)
94
92
  arr_clips = list(track.arrangement_clips)
95
93
  new_clip = None
96
94
  new_clip_idx = None
97
95
  for i, c in enumerate(arr_clips):
98
- if id(c) not in old_ids and abs(c.start_time - pos) < 0.01:
96
+ if abs(c.start_time - pos) < 0.01:
99
97
  new_clip = c
100
98
  new_clip_idx = i
101
99
  break
102
100
 
103
- # Fallback: if id-based detection fails, match by position
104
- if new_clip is None:
105
- for i, c in enumerate(arr_clips):
106
- if abs(c.start_time - pos) < 0.01:
107
- new_clip = c
108
- new_clip_idx = i
109
- break
110
-
111
101
  if new_clip is not None:
112
102
  if first_clip_index is None:
113
103
  first_clip_index = new_clip_idx
@@ -158,20 +148,24 @@ def create_arrangement_clip(song, params):
158
148
  finally:
159
149
  song.end_undo_step()
160
150
 
161
- # Re-read to get accurate final state
151
+ # Re-read to get accurate final state — locate by start_time, not stored
152
+ # index, because the trim pass (remove_notes_extended) can shift indices.
162
153
  arr_clips = list(track.arrangement_clips)
163
- if first_clip_index is None or first_clip_index >= len(arr_clips):
154
+ first_clip = None
155
+ for c in arr_clips:
156
+ if abs(c.start_time - start_time) < 0.01:
157
+ first_clip = c
158
+ break
159
+ if first_clip is None:
164
160
  raise ValueError("Failed to place any clips in arrangement")
165
- first_clip = arr_clips[first_clip_index]
166
161
 
167
162
  return {
168
163
  "track_index": track_index,
169
- "clip_index": first_clip_index,
170
164
  "start_time": start_time,
171
165
  "length": length,
172
166
  "clip_count": clip_count,
173
167
  "source_length": source_length,
174
- "name": first_clip.name if first_clip else "",
168
+ "name": first_clip.name,
175
169
  }
176
170
 
177
171
 
@@ -76,13 +76,19 @@ def _navigate_path(browser, path):
76
76
  return current
77
77
 
78
78
 
79
+ _MAX_SEARCH_ITERATIONS = 100000
80
+
81
+
79
82
  def _search_recursive(item, name_filter, loadable_only, results, depth, max_depth,
80
- max_results=100):
81
- """Recursively search browser children."""
83
+ max_results=100, _counter=None):
84
+ """Recursively search browser children with iteration cap."""
85
+ if _counter is None:
86
+ _counter = [0] # mutable counter shared across recursion
82
87
  if depth > max_depth or len(results) >= max_results:
83
88
  return
84
89
  for child in item.children:
85
- if len(results) >= max_results:
90
+ _counter[0] += 1
91
+ if _counter[0] > _MAX_SEARCH_ITERATIONS or len(results) >= max_results:
86
92
  return
87
93
  match = True
88
94
  if name_filter and name_filter.lower() not in child.name.lower():
@@ -102,7 +108,7 @@ def _search_recursive(item, name_filter, loadable_only, results, depth, max_dept
102
108
  if child.is_folder:
103
109
  _search_recursive(
104
110
  child, name_filter, loadable_only, results, depth + 1, max_depth,
105
- max_results
111
+ max_results, _counter
106
112
  )
107
113
  if len(results) >= max_results:
108
114
  return
@@ -125,12 +131,17 @@ def get_browser_tree(song, params):
125
131
 
126
132
  result = []
127
133
  for name, item in categories.items():
128
- children = list(item.children)
129
- child_names = [c.name for c in children[:20]]
134
+ # Count lazily without materializing the full children list
135
+ children_preview = []
136
+ count = 0
137
+ for c in item.children:
138
+ count += 1
139
+ if len(children_preview) < 20:
140
+ children_preview.append(c.name)
130
141
  result.append({
131
142
  "name": name,
132
- "children_count": len(children),
133
- "children_preview": child_names,
143
+ "children_count": count,
144
+ "children_preview": children_preview,
134
145
  })
135
146
  return {"categories": result}
136
147
 
@@ -20,16 +20,17 @@ def get_clip_automation(song, params):
20
20
  envelopes = []
21
21
 
22
22
  # Check mixer parameters: volume, panning, sends
23
+ # Use the specific parameter_type that set/clear accept (not generic "mixer")
23
24
  mixer = track.mixer_device
24
- for param_name, param in [
25
- ("Volume", mixer.volume),
26
- ("Pan", mixer.panning),
25
+ for param_name, param_type, param in [
26
+ ("Volume", "volume", mixer.volume),
27
+ ("Pan", "panning", mixer.panning),
27
28
  ]:
28
29
  env = clip.automation_envelope(param)
29
30
  if env is not None:
30
31
  envelopes.append({
31
32
  "parameter_name": param_name,
32
- "parameter_type": "mixer",
33
+ "parameter_type": param_type,
33
34
  "has_envelope": True,
34
35
  })
35
36
 
@@ -147,12 +147,14 @@ def set_clip_loop(song, params):
147
147
  clip_index = int(params["clip_index"])
148
148
  clip = get_clip(song, track_index, clip_index)
149
149
 
150
- if "enabled" in params:
151
- clip.looping = bool(params["enabled"])
152
- if "start" in params:
153
- clip.loop_start = float(params["start"])
150
+ # Set end before start to avoid Live's loop_start < loop_end clamping.
151
+ # Expanding the window first ensures the left edge can move freely.
154
152
  if "end" in params:
155
153
  clip.loop_end = float(params["end"])
154
+ if "start" in params:
155
+ clip.loop_start = float(params["start"])
156
+ if "enabled" in params:
157
+ clip.looping = bool(params["enabled"])
156
158
 
157
159
  return {
158
160
  "track_index": track_index,
@@ -199,11 +201,17 @@ def set_clip_warp_mode(song, params):
199
201
  "3=Re-Pitch, 4=Complex, 6=Complex Pro" % mode
200
202
  )
201
203
 
202
- if "warping" in params:
203
- clip.warping = bool(params["warping"])
204
+ # Enable warping first so warp_mode assignment is accepted by Live,
205
+ # then disable afterwards if requested.
206
+ enable_warping = params.get("warping")
207
+ if enable_warping is not None and bool(enable_warping):
208
+ clip.warping = True
204
209
 
205
210
  clip.warp_mode = mode
206
211
 
212
+ if enable_warping is not None and not bool(enable_warping):
213
+ clip.warping = False
214
+
207
215
  return {
208
216
  "track_index": track_index,
209
217
  "clip_index": clip_index,
@@ -3,6 +3,7 @@ LivePilot - Device domain handlers (11 commands).
3
3
  """
4
4
 
5
5
  import Live
6
+ from collections import deque
6
7
 
7
8
  from .router import register
8
9
  from .utils import get_track, get_device
@@ -178,6 +179,8 @@ def toggle_device(song, params):
178
179
  break
179
180
  if on_param is None:
180
181
  # Fallback to parameter 0 for devices that don't use "Device On"
182
+ if not list(device.parameters):
183
+ raise ValueError("Device '%s' has no parameters to toggle" % device.name)
181
184
  on_param = device.parameters[0]
182
185
 
183
186
  on_param.value = 1.0 if active else 0.0
@@ -399,10 +402,10 @@ def find_and_load_device(song, params):
399
402
  This ensures raw 'Operator' is found before 'Hello Operator.adg' buried
400
403
  in a user_library subfolder."""
401
404
  nonlocal iterations
402
- # Queue of (item, depth) tuples
403
- queue = [(category, 0)]
405
+ # Queue of (item, depth) tuples — deque for O(1) popleft
406
+ queue = deque([(category, 0)])
404
407
  while queue:
405
- item, depth = queue.pop(0)
408
+ item, depth = queue.popleft()
406
409
  if depth > 8:
407
410
  continue
408
411
  try:
@@ -187,12 +187,12 @@ class LivePilotServer(object):
187
187
  except OSError as exc:
188
188
  self._log("Client error: %s" % exc)
189
189
  finally:
190
- with self._client_lock:
191
- self._client_connected = False
192
190
  try:
193
191
  client.close()
194
192
  except OSError:
195
193
  pass
194
+ with self._client_lock:
195
+ self._client_connected = False
196
196
  self._log("Client disconnected")
197
197
 
198
198
  def _handle_client(self, client):
@@ -205,6 +205,9 @@ class LivePilotServer(object):
205
205
  if not data:
206
206
  break
207
207
  buf += data.decode("utf-8", errors="replace")
208
+ if len(buf) > 4 * 1024 * 1024: # 4 MB
209
+ self._log("Client buffer overflow — disconnecting")
210
+ break
208
211
  while "\n" in buf:
209
212
  line, buf = buf.split("\n", 1)
210
213
  line = line.strip()
@@ -249,8 +252,14 @@ class LivePilotServer(object):
249
252
  try:
250
253
  self._cs.schedule_message(0, self._process_next_command)
251
254
  except AssertionError:
252
- # Already on main thread process directly
253
- self._process_next_command()
255
+ # ControlSurface is disconnectingreturn error instead of
256
+ # running LOM calls on the TCP thread (which would be unsafe)
257
+ response_queue.put({
258
+ "id": request_id,
259
+ "ok": False,
260
+ "error": {"code": "STATE_ERROR", "message": "Script is disconnecting"},
261
+ })
262
+ return
254
263
 
255
264
  # Wait for response from main thread
256
265
  try:
@@ -296,7 +305,8 @@ class LivePilotServer(object):
296
305
  try:
297
306
  self._cs.schedule_message(1, send_response) # ~100ms
298
307
  except AssertionError:
299
- send_response()
308
+ # ControlSurface disconnecting — send result immediately
309
+ response_queue.put(result)
300
310
  else:
301
311
  response_queue.put(result)
302
312
  # Drain any remaining queued commands
@@ -308,7 +318,9 @@ class LivePilotServer(object):
308
318
  try:
309
319
  self._cs.schedule_message(0, self._process_next_command)
310
320
  except AssertionError:
311
- self._process_next_command()
321
+ # ControlSurface disconnecting — drop remaining commands
322
+ # rather than running LOM calls on the wrong thread
323
+ pass
312
324
 
313
325
  # ── Socket I/O ───────────────────────────────────────────────────────
314
326
 
@@ -175,9 +175,14 @@ def duplicate_track(song, params):
175
175
  "Track index %d out of range (0..%d)"
176
176
  % (track_index, len(tracks) - 1)
177
177
  )
178
+ count_before = len(tracks)
178
179
  song.duplicate_track(track_index)
179
- new_index = track_index + 1
180
- return {"index": new_index, "name": list(song.tracks)[new_index].name}
180
+ all_tracks = list(song.tracks)
181
+ # For group tracks, Ableton duplicates the group + all children.
182
+ # The duplicate block starts right after the original group's last child.
183
+ added = len(all_tracks) - count_before
184
+ new_index = track_index + added
185
+ return {"index": new_index, "name": all_tracks[new_index].name}
181
186
 
182
187
 
183
188
  @register("set_track_name")
@@ -53,9 +53,9 @@ def get_session_info(song, params):
53
53
  "metronome": song.metronome,
54
54
  "record_mode": song.record_mode,
55
55
  "session_record": song.session_record,
56
- "track_count": len(list(song.tracks)),
57
- "return_track_count": len(list(song.return_tracks)),
58
- "scene_count": len(list(song.scenes)),
56
+ "track_count": len(tracks_info),
57
+ "return_track_count": len(return_tracks_info),
58
+ "scene_count": len(scenes_info),
59
59
  "tracks": tracks_info,
60
60
  "return_tracks": return_tracks_info,
61
61
  "scenes": scenes_info,
package/requirements.txt CHANGED
@@ -3,9 +3,11 @@ numpy>=1.24.0
3
3
  fastmcp>=3.0.0,<4.0.0
4
4
  midiutil>=1.2.1
5
5
  pretty_midi>=0.2.10
6
- opycleid>=0.5.1
7
6
  # v1.8 Perception Layer (offline analysis)
8
7
  pyloudnorm>=0.1.0
9
8
  soundfile>=0.12.0
10
9
  scipy>=1.11.0
11
10
  mutagen>=1.47.0
11
+
12
+ # Optional: neo-Riemannian group theory (not required — harmony engine is pure Python)
13
+ # pip install opycleid>=0.5.1