livepilot 1.14.0 → 1.15.0-beta
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/CHANGELOG.md +100 -0
- package/README.md +6 -6
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/livepilot_bridge.js +1 -1
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/runtime/live_version.py +4 -2
- package/mcp_server/runtime/remote_commands.py +4 -0
- package/mcp_server/tools/analyzer.py +113 -5
- package/mcp_server/tools/devices.py +14 -2
- package/mcp_server/tools/diagnostics.py +21 -0
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +101 -43
- package/remote_script/LivePilot/devices.py +75 -9
- package/remote_script/LivePilot/mixing.py +154 -34
- package/remote_script/LivePilot/simpler_sample.py +98 -0
- package/server.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,105 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.15.0-beta — Live 12.4 replace_sample native (April 21 2026)
|
|
4
|
+
|
|
5
|
+
First Live 12.4 beta support release. Adds a native fast path for
|
|
6
|
+
SimplerDevice.replace_sample(path) while preserving 100% backward
|
|
7
|
+
compatibility for 12.0-12.3.x users.
|
|
8
|
+
|
|
9
|
+
**Tool count**: unchanged at 403.
|
|
10
|
+
**Domain count**: unchanged at 52.
|
|
11
|
+
**Tests**: 2503 passed, 1 skipped, 0 regressions (+13 new from this release).
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- **Live 12.4 support (beta):** `SimplerDevice.replace_sample(path)` native
|
|
15
|
+
LOM path is now used automatically on Live 12.4+. Handles empty Simplers —
|
|
16
|
+
fixes the long-standing workaround documented in
|
|
17
|
+
`feedback_load_browser_item_is_source_of_truth.md`.
|
|
18
|
+
- New capability tier `"collaborative"` (Live 12.4+) exposed via
|
|
19
|
+
`LiveVersionCapabilities.capability_tier` and `.has_replace_sample_native`.
|
|
20
|
+
- Remote Script: new `replace_sample_native` handler.
|
|
21
|
+
- MCP server: new `_live_caps(ctx)` helper with lazy version-capability
|
|
22
|
+
caching on the lifespan context.
|
|
23
|
+
- Registered `replace_sample_native` in `mcp_server/runtime/remote_commands.py`
|
|
24
|
+
REMOTE_COMMANDS (required by the boundary audit contract).
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
- `replace_simpler_sample` and `load_sample_to_simpler` now route to the
|
|
28
|
+
native path when available and fall back to the M4L-bridge path
|
|
29
|
+
otherwise. Tool signatures, argument names, and return shapes unchanged.
|
|
30
|
+
|
|
31
|
+
### Backward Compatibility
|
|
32
|
+
- Live 12.0–12.3.x: zero behavior change. All routing still goes through
|
|
33
|
+
the M4L bridge.
|
|
34
|
+
- Live 12.4+: native path preferred; bridge used only on fallback.
|
|
35
|
+
|
|
36
|
+
### Verification status
|
|
37
|
+
- Full test suite: 2503 passed, 0 failures.
|
|
38
|
+
- Backward compat on Live 12.4: verified in-session — `replace_simpler_sample`
|
|
39
|
+
and `load_sample_to_simpler` both work via the bridge path on 12.4 (legacy
|
|
40
|
+
flow intact).
|
|
41
|
+
- Native E2E on Live 12.4 empty-Simpler case: deferred until the plugin
|
|
42
|
+
swap activates the worktree's MCP code. Unit tests prove the routing
|
|
43
|
+
logic and the native handler wiring.
|
|
44
|
+
|
|
45
|
+
## 1.14.1 — reload_handlers workflow + device/mixing fixes (April 21 2026)
|
|
46
|
+
|
|
47
|
+
Patch release that lands the post-1.14.0 audit work: one new diagnostics
|
|
48
|
+
tool, three bug fixes, and a new plugin-sync verification script.
|
|
49
|
+
|
|
50
|
+
**Tool count**: 402 → **403** (added `reload_handlers`).
|
|
51
|
+
**Domain count**: unchanged at 52.
|
|
52
|
+
**Tests**: 2467 → **2485 passing** (+18 new), 0 regressions.
|
|
53
|
+
|
|
54
|
+
### New tool: `reload_handlers`
|
|
55
|
+
|
|
56
|
+
- Replaces the manual "toggle Control Surface in Live → Preferences →
|
|
57
|
+
Link/MIDI" step that every Remote Script edit required. New workflow:
|
|
58
|
+
after `npx livepilot --install`, call `reload_handlers` via the MCP
|
|
59
|
+
tool. The Remote Script side uses `pkgutil` + `importlib.reload()` to
|
|
60
|
+
re-fire all `@register` decorators in place in <1s, without dropping
|
|
61
|
+
the MCP TCP connection on port 9878.
|
|
62
|
+
- Ships with a pkgutil-based module-discovery helper in
|
|
63
|
+
`remote_script/LivePilot/__init__.py`, so new handler modules added to
|
|
64
|
+
`remote_script/LivePilot/` are picked up automatically on reload.
|
|
65
|
+
- Exception: the very first bootstrap (no prior `LivePilot.*` in
|
|
66
|
+
`sys.modules`) still needs one full Ableton restart. After that,
|
|
67
|
+
`reload_handlers` works forever.
|
|
68
|
+
- Domain: `diagnostics`. Added to `docs/manual/tool-catalog.md` to keep
|
|
69
|
+
the CI skill-contract test green.
|
|
70
|
+
|
|
71
|
+
### Bug fixes
|
|
72
|
+
|
|
73
|
+
- **`find_and_load_device` duplicate loads** — the tool was no-oping
|
|
74
|
+
only on exact name match; changed to also treat cases where the
|
|
75
|
+
target device is already the tail of the chain as a no-op. Prevents
|
|
76
|
+
the "load Simpler, load Simpler, load Simpler" cascade when the MCP
|
|
77
|
+
server retries a loader.
|
|
78
|
+
- **`get_device_parameters` "Invalid display value"** — certain Live
|
|
79
|
+
parameters (especially plugin wrappers on AU/VST) raise
|
|
80
|
+
`RuntimeError("Invalid display value")` when their
|
|
81
|
+
`str_for_value()` is queried before the parameter has settled. The
|
|
82
|
+
handler now swallows that specific error and returns the raw float
|
|
83
|
+
instead of 500-ing the whole request.
|
|
84
|
+
- **Sidechain LOM reopen (BUG-A3 redux)** — Compressor2 moved its
|
|
85
|
+
sidechain block into a nested property in a recent Live update, so
|
|
86
|
+
`compressor_set_sidechain` lost the ability to toggle. The handler
|
|
87
|
+
now probes the LOM surface at tool-call time and falls back to the
|
|
88
|
+
flat path when the nested one isn't exposed.
|
|
89
|
+
- **Mixing `channel` lazy-get** — channel objects were resolved eagerly
|
|
90
|
+
at import time, breaking in edge cases where the Song came up before
|
|
91
|
+
the mixer. Now resolved on first use.
|
|
92
|
+
|
|
93
|
+
### New: plugin-sync verification
|
|
94
|
+
|
|
95
|
+
- `scripts/verify_plugin_sync.py` — catches the v1.14.0 regression
|
|
96
|
+
class where `.mcp.json` went missing from
|
|
97
|
+
`~/.claude/plugins/cache/dreamrec-LivePilot/livepilot/$VERSION/`. All
|
|
98
|
+
four sync targets (active plugin dir, cache version dir, marketplace
|
|
99
|
+
snapshot, `installed_plugins.json`) are now verified by one command.
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
3
103
|
## 1.14.0 — Branch-native v2: producer context, synth intelligence, render verify (April 20 2026)
|
|
4
104
|
|
|
5
105
|
Five-PR follow-up to v1.13.0 that closes the loops the first pass left
|
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
<p align="center">
|
|
19
19
|
An agentic production system for Ableton Live 12.<br>
|
|
20
|
-
|
|
20
|
+
403 tools. 52 domains. Device atlas. Splice integration. Auto-composition. Spectral perception. Technique memory.
|
|
21
21
|
</p>
|
|
22
22
|
|
|
23
23
|
<br>
|
|
@@ -79,7 +79,7 @@ Most MCP servers are tool collections — they execute commands. LivePilot is an
|
|
|
79
79
|
│ └─────────────────┼──────────────────┘ │
|
|
80
80
|
│ ▼ │
|
|
81
81
|
│ ┌─────────────────┐ │
|
|
82
|
-
│ │
|
|
82
|
+
│ │ 403 MCP Tools │ │
|
|
83
83
|
│ │ 52 domains │ │
|
|
84
84
|
│ └────────┬────────┘ │
|
|
85
85
|
│ │ │
|
|
@@ -120,7 +120,7 @@ Most MCP servers are tool collections — they execute commands. LivePilot is an
|
|
|
120
120
|
|
|
121
121
|
## The Intelligence Layer
|
|
122
122
|
|
|
123
|
-
12 engines sit on top of the
|
|
123
|
+
12 engines sit on top of the 403 tools. They give the AI musical judgment, not just musical execution.
|
|
124
124
|
|
|
125
125
|
### SongBrain — What the Song Is
|
|
126
126
|
|
|
@@ -172,7 +172,7 @@ Every engine follows: **measure before → act → measure after → compare**.
|
|
|
172
172
|
|
|
173
173
|
## Tools
|
|
174
174
|
|
|
175
|
-
|
|
175
|
+
403 tools across 52 domains. Highlights below — [full catalog here](docs/manual/tool-catalog.md).
|
|
176
176
|
|
|
177
177
|
<br>
|
|
178
178
|
|
|
@@ -360,7 +360,7 @@ The V2 intelligence layer. These tools analyze, diagnose, plan, evaluate, and le
|
|
|
360
360
|
| Creative Constraints | 5 | constraint activation, reference-inspired variants |
|
|
361
361
|
| Preview Studio | 5 | variant creation, preview rendering, comparison, commit |
|
|
362
362
|
|
|
363
|
-
> **[View all
|
|
363
|
+
> **[View all 403 tools →](docs/manual/tool-catalog.md)**
|
|
364
364
|
|
|
365
365
|
<br>
|
|
366
366
|
|
|
@@ -587,7 +587,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for architecture details, code guidelines
|
|
|
587
587
|
|
|
588
588
|
| Document | What's inside |
|
|
589
589
|
|----------|---------------|
|
|
590
|
-
| [Manual](docs/manual/index.md) | Complete reference: architecture, all
|
|
590
|
+
| [Manual](docs/manual/index.md) | Complete reference: architecture, all 403 tools, workflows |
|
|
591
591
|
| [Intelligence Layer](docs/manual/intelligence.md) | How the 12 engines connect — conductor, moves, preview, evaluation |
|
|
592
592
|
| [Device Atlas](docs/manual/device-atlas.md) | 1305 devices indexed — search, suggest, chain building |
|
|
593
593
|
| [Samples & Slicing](docs/manual/samples.md) | 3-source search, fitness critics, slice workflows |
|
|
Binary file
|
package/mcp_server/__init__.py
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""LivePilot MCP Server — bridges MCP protocol to Ableton Live."""
|
|
2
|
-
__version__ = "1.
|
|
2
|
+
__version__ = "1.15.0-beta"
|
|
@@ -79,8 +79,10 @@ class LiveVersionCapabilities:
|
|
|
79
79
|
|
|
80
80
|
@property
|
|
81
81
|
def capability_tier(self) -> str:
|
|
82
|
-
"""Human-readable tier: core | enhanced_arrangement | full_intelligence."""
|
|
83
|
-
if self._version_tuple >= (12,
|
|
82
|
+
"""Human-readable tier: core | enhanced_arrangement | full_intelligence | collaborative."""
|
|
83
|
+
if self._version_tuple >= (12, 4, 0):
|
|
84
|
+
return "collaborative"
|
|
85
|
+
elif self._version_tuple >= (12, 3, 0):
|
|
84
86
|
return "full_intelligence"
|
|
85
87
|
elif self._version_tuple >= (12, 1, 10):
|
|
86
88
|
return "enhanced_arrangement"
|
|
@@ -105,6 +105,8 @@ REMOTE_COMMANDS: frozenset[str] = frozenset({
|
|
|
105
105
|
"get_session_diagnostics",
|
|
106
106
|
# control surfaces (diagnostic)
|
|
107
107
|
"list_control_surfaces", "get_control_surface_info",
|
|
108
|
+
# dev-loop helper — reloads handler submodules without a UI toggle
|
|
109
|
+
"reload_handlers",
|
|
108
110
|
# song primitives — transport/link
|
|
109
111
|
"tap_tempo", "nudge_tempo",
|
|
110
112
|
"set_exclusive_arm", "set_exclusive_solo",
|
|
@@ -115,6 +117,8 @@ REMOTE_COMMANDS: frozenset[str] = frozenset({
|
|
|
115
117
|
"get_appointed_device",
|
|
116
118
|
# ping (built-in)
|
|
117
119
|
"ping",
|
|
120
|
+
# Live 12.4+ native Simpler sample replacement (Collaborative tier)
|
|
121
|
+
"replace_sample_native",
|
|
118
122
|
})
|
|
119
123
|
|
|
120
124
|
# M4L bridge commands — routed through TCP but handled by livepilot_bridge.js
|
|
@@ -64,6 +64,71 @@ def _enrich_slice_response(response: Optional[dict]) -> Optional[dict]:
|
|
|
64
64
|
return enriched
|
|
65
65
|
|
|
66
66
|
|
|
67
|
+
def _live_caps(ctx):
|
|
68
|
+
"""Read (or lazily compute + cache) LiveVersionCapabilities on the context.
|
|
69
|
+
|
|
70
|
+
On first call, queries Ableton via get_session_info and caches the
|
|
71
|
+
result in ``ctx.lifespan_context["_live_caps"]``. Subsequent calls
|
|
72
|
+
short-circuit to the cache. If Ableton is unreachable, falls back to
|
|
73
|
+
12.0.0 (conservative — all new-version gates return False).
|
|
74
|
+
|
|
75
|
+
This mirrors the on-demand pattern in
|
|
76
|
+
mcp_server/runtime/capability_probe.py and avoids adding a startup
|
|
77
|
+
round-trip to the lifespan.
|
|
78
|
+
"""
|
|
79
|
+
from mcp_server.runtime.live_version import LiveVersionCapabilities
|
|
80
|
+
|
|
81
|
+
lsc = ctx.lifespan_context
|
|
82
|
+
cached = lsc.get("_live_caps")
|
|
83
|
+
if cached is not None:
|
|
84
|
+
return cached
|
|
85
|
+
|
|
86
|
+
version_str = "12.0.0"
|
|
87
|
+
ableton = lsc.get("ableton")
|
|
88
|
+
if ableton is not None:
|
|
89
|
+
try:
|
|
90
|
+
info = ableton.send_command("get_session_info") or {}
|
|
91
|
+
version_str = info.get("live_version", "12.0.0")
|
|
92
|
+
except Exception:
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
caps = LiveVersionCapabilities.from_version_string(version_str)
|
|
96
|
+
lsc["_live_caps"] = caps
|
|
97
|
+
return caps
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
async def _try_native_replace_sample(ctx, track_index: int, device_index: int,
|
|
101
|
+
file_path: str):
|
|
102
|
+
"""Attempt the Live 12.4+ native SimplerDevice.replace_sample path.
|
|
103
|
+
|
|
104
|
+
Returns the remote-script response dict on success, or None if the
|
|
105
|
+
native path is unavailable (pre-12.4) or failed (caller should fall
|
|
106
|
+
back to the M4L-bridge path).
|
|
107
|
+
|
|
108
|
+
A native "failure" is any of: gate closed, dispatch exception, non-dict
|
|
109
|
+
response, error field present, or missing sample_loaded flag.
|
|
110
|
+
"""
|
|
111
|
+
caps = _live_caps(ctx)
|
|
112
|
+
if not caps.has_replace_sample_native:
|
|
113
|
+
return None
|
|
114
|
+
ableton = ctx.lifespan_context["ableton"]
|
|
115
|
+
try:
|
|
116
|
+
resp = ableton.send_command("replace_sample_native", {
|
|
117
|
+
"track_index": track_index,
|
|
118
|
+
"device_index": device_index,
|
|
119
|
+
"file_path": file_path,
|
|
120
|
+
})
|
|
121
|
+
except Exception:
|
|
122
|
+
return None
|
|
123
|
+
if not isinstance(resp, dict):
|
|
124
|
+
return None
|
|
125
|
+
if "error" in resp:
|
|
126
|
+
return None
|
|
127
|
+
if not resp.get("sample_loaded"):
|
|
128
|
+
return None
|
|
129
|
+
return resp
|
|
130
|
+
|
|
131
|
+
|
|
67
132
|
@mcp.tool()
|
|
68
133
|
async def reconnect_bridge(ctx: Context) -> dict:
|
|
69
134
|
"""Attempt to reconnect the M4L UDP bridge (port 9880).
|
|
@@ -350,12 +415,27 @@ async def replace_simpler_sample(
|
|
|
350
415
|
_require_analyzer(cache)
|
|
351
416
|
bridge = _get_m4l(ctx)
|
|
352
417
|
ableton = ctx.lifespan_context["ableton"]
|
|
418
|
+
|
|
419
|
+
# Live 12.4+: prefer the native SimplerDevice.replace_sample path.
|
|
420
|
+
native = await _try_native_replace_sample(
|
|
421
|
+
ctx, track_index, device_index, file_path
|
|
422
|
+
)
|
|
423
|
+
if native is not None:
|
|
424
|
+
hygiene = await _simpler_post_load_hygiene(
|
|
425
|
+
bridge, ableton, track_index, device_index, file_path
|
|
426
|
+
)
|
|
427
|
+
if not hygiene.get("verified"):
|
|
428
|
+
return hygiene
|
|
429
|
+
result = dict(native)
|
|
430
|
+
result.update(hygiene)
|
|
431
|
+
result["method"] = "native_12_4" # preserved in case hygiene ever adds its own key
|
|
432
|
+
return result
|
|
433
|
+
|
|
434
|
+
# Pre-12.4 fallback: M4L bridge path (unchanged behavior).
|
|
353
435
|
result = await bridge.send_command(
|
|
354
436
|
"replace_simpler_sample", track_index, device_index, file_path
|
|
355
437
|
)
|
|
356
438
|
|
|
357
|
-
# Validate the response — the bridge may report success even when the
|
|
358
|
-
# sample silently failed to load (e.g., empty Simpler, bad path)
|
|
359
439
|
if "error" in result:
|
|
360
440
|
return result
|
|
361
441
|
if not result.get("sample_loaded"):
|
|
@@ -364,8 +444,6 @@ async def replace_simpler_sample(
|
|
|
364
444
|
"has a sample loaded — replace_sample silently fails on empty Simplers."
|
|
365
445
|
}
|
|
366
446
|
|
|
367
|
-
# Verify by reading back the device name — guards against the silent
|
|
368
|
-
# failure mode where the bridge reports success but keeps the placeholder.
|
|
369
447
|
hygiene = await _simpler_post_load_hygiene(
|
|
370
448
|
bridge, ableton, track_index, device_index, file_path
|
|
371
449
|
)
|
|
@@ -405,9 +483,39 @@ async def load_sample_to_simpler(
|
|
|
405
483
|
cache = _get_spectral(ctx)
|
|
406
484
|
_require_analyzer(cache)
|
|
407
485
|
bridge = _get_m4l(ctx)
|
|
486
|
+
ableton = ctx.lifespan_context["ableton"]
|
|
487
|
+
|
|
488
|
+
# Live 12.4+: create an empty Simpler via insert_device, then use the
|
|
489
|
+
# native replace_sample path. Skips the dummy-sample bootstrap entirely.
|
|
490
|
+
caps = _live_caps(ctx)
|
|
491
|
+
if caps.has_replace_sample_native:
|
|
492
|
+
try:
|
|
493
|
+
ins = ableton.send_command("insert_device", {
|
|
494
|
+
"track_index": track_index,
|
|
495
|
+
"device_name": "Simpler",
|
|
496
|
+
})
|
|
497
|
+
except Exception:
|
|
498
|
+
ins = None
|
|
499
|
+
if isinstance(ins, dict) and "error" not in ins:
|
|
500
|
+
actual_device_index = ins.get("device_index", device_index)
|
|
501
|
+
native = await _try_native_replace_sample(
|
|
502
|
+
ctx, track_index, actual_device_index, file_path
|
|
503
|
+
)
|
|
504
|
+
if native is not None:
|
|
505
|
+
hygiene = await _simpler_post_load_hygiene(
|
|
506
|
+
bridge, ableton, track_index, actual_device_index, file_path
|
|
507
|
+
)
|
|
508
|
+
if not hygiene.get("verified"):
|
|
509
|
+
return hygiene
|
|
510
|
+
result = dict(native)
|
|
511
|
+
result.update(hygiene)
|
|
512
|
+
result["method"] = "native_12_4"
|
|
513
|
+
result["device_index"] = actual_device_index
|
|
514
|
+
result["track_index"] = track_index
|
|
515
|
+
return result
|
|
516
|
+
# Fall through to the legacy bootstrap path below on any failure.
|
|
408
517
|
|
|
409
518
|
# Step 1: Load a sample from the browser to create Simpler with content
|
|
410
|
-
ableton = ctx.lifespan_context["ableton"]
|
|
411
519
|
try:
|
|
412
520
|
search = ableton.send_command("search_browser", {
|
|
413
521
|
"path": "samples",
|
|
@@ -426,9 +426,20 @@ def move_device(
|
|
|
426
426
|
|
|
427
427
|
|
|
428
428
|
@mcp.tool()
|
|
429
|
-
def find_and_load_device(
|
|
429
|
+
def find_and_load_device(
|
|
430
|
+
ctx: Context,
|
|
431
|
+
track_index: int,
|
|
432
|
+
device_name: str,
|
|
433
|
+
allow_duplicate: bool = False,
|
|
434
|
+
) -> dict:
|
|
430
435
|
"""Search the browser for a device by name and load it onto a track.
|
|
431
|
-
track_index: 0+ for regular tracks, -1/-2/... for return tracks (A/B/...), -1000 for master.
|
|
436
|
+
track_index: 0+ for regular tracks, -1/-2/... for return tracks (A/B/...), -1000 for master.
|
|
437
|
+
|
|
438
|
+
allow_duplicate (default False): if a device with the same name is
|
|
439
|
+
already on the track's chain, the default behavior is to NO-OP and
|
|
440
|
+
return the existing device's location with `already_present: True`.
|
|
441
|
+
Pass allow_duplicate=True to force a second instance (e.g., parallel
|
|
442
|
+
processing chains where you genuinely want two of the same device)."""
|
|
432
443
|
_validate_track_index(track_index)
|
|
433
444
|
if not device_name.strip():
|
|
434
445
|
raise ValueError("device_name cannot be empty")
|
|
@@ -447,6 +458,7 @@ def find_and_load_device(ctx: Context, track_index: int, device_name: str) -> di
|
|
|
447
458
|
result = _get_ableton(ctx).send_command("find_and_load_device", {
|
|
448
459
|
"track_index": track_index,
|
|
449
460
|
"device_name": device_name,
|
|
461
|
+
"allow_duplicate": allow_duplicate,
|
|
450
462
|
})
|
|
451
463
|
return _postflight_loaded_device(ctx, result)
|
|
452
464
|
|
|
@@ -40,3 +40,24 @@ def get_control_surface_info(ctx: Context, index: int) -> dict:
|
|
|
40
40
|
"""
|
|
41
41
|
return _get_ableton(ctx).send_command("get_control_surface_info",
|
|
42
42
|
{"index": index})
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@mcp.tool()
|
|
46
|
+
def reload_handlers(ctx: Context) -> dict:
|
|
47
|
+
"""Reload every Remote Script handler module in Ableton — dev-loop helper.
|
|
48
|
+
|
|
49
|
+
Client-side wrapper for the `reload_handlers` TCP command exposed by the
|
|
50
|
+
Remote Script (see `remote_script/LivePilot/__init__.py`). Re-discovers
|
|
51
|
+
handler submodules via pkgutil.iter_modules and reloads each one,
|
|
52
|
+
re-firing @register decorators against a freshly-cleared router. Lets
|
|
53
|
+
you edit a handler file → run installer → call this tool, without a
|
|
54
|
+
Control Surface toggle or Ableton restart.
|
|
55
|
+
|
|
56
|
+
Does NOT reload `router`, `server`, or `__init__.py` — Ableton's
|
|
57
|
+
embedded Python handles only leaf-submodule reloads correctly.
|
|
58
|
+
|
|
59
|
+
Returns {reloaded: True, handler_count: int} so callers can assert the
|
|
60
|
+
post-reload registration surface. Raises if the Remote Script is
|
|
61
|
+
pre-PR#16 (will surface as `[NOT_FOUND] Unknown command type`).
|
|
62
|
+
"""
|
|
63
|
+
return _get_ableton(ctx).send_command("reload_handlers", {})
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "livepilot",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.15.0-beta",
|
|
4
4
|
"mcpName": "io.github.dreamrec/livepilot",
|
|
5
|
-
"description": "Agentic production system for Ableton Live 12 —
|
|
5
|
+
"description": "Agentic production system for Ableton Live 12 — 403 tools, 52 domains. Device atlas (1305 devices), sample engine (Splice + browser + filesystem), auto-composition, spectral perception, technique memory, creative intelligence (12 engines)",
|
|
6
6
|
"author": "Pilot Studio",
|
|
7
7
|
"license": "BSL-1.1",
|
|
8
8
|
"type": "commonjs",
|
|
@@ -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.
|
|
8
|
+
__version__ = "1.15.0-beta"
|
|
9
9
|
|
|
10
10
|
from _Framework.ControlSurface import ControlSurface
|
|
11
11
|
from . import router
|
|
@@ -26,52 +26,70 @@ from . import follow_actions # noqa: F401 — registers follow action handle
|
|
|
26
26
|
from . import grooves # noqa: F401 — registers groove pool handlers (11+)
|
|
27
27
|
from . import take_lanes # noqa: F401 — registers take lane handlers (12.0+ read, 12.2+ write)
|
|
28
28
|
from . import clip_automation # noqa: F401 — registers clip automation handlers
|
|
29
|
+
from . import simpler_sample # noqa: F401 — registers replace_sample_native (12.4+)
|
|
29
30
|
from . import version_detect # noqa: F401 — version detection
|
|
30
31
|
|
|
31
32
|
|
|
32
33
|
# ── Reload plumbing (BUG-B-reload, Batch 20) ──────────────────────────────
|
|
33
34
|
# Ableton keeps `sys.modules["LivePilot.*"]` cached across Control Surface
|
|
34
35
|
# toggles. Without intervention, edits to handler files don't take effect
|
|
35
|
-
# until a full Ableton restart
|
|
36
|
-
# ControlSurface class without re-importing submodules.
|
|
36
|
+
# until a full Ableton restart.
|
|
37
37
|
#
|
|
38
|
-
# Fix:
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
#
|
|
38
|
+
# Fix: on every create_instance() except the first, re-discover every
|
|
39
|
+
# handler module on disk via pkgutil.iter_modules() and reload it. This
|
|
40
|
+
# side-steps two separate issues: (1) the old hardcoded _HANDLER_MODULES
|
|
41
|
+
# tuple that had to be manually updated for every new handler file, and
|
|
42
|
+
# (2) Ableton's embedded Python silently no-op'ing importlib.reload() on
|
|
43
|
+
# the package itself — a behavior confirmed empirically by observing
|
|
44
|
+
# that a module-level file write in __init__.py never fires across
|
|
45
|
+
# toggles, only at initial Live boot. pkgutil.iter_modules reads the
|
|
46
|
+
# filesystem directly and relies only on reloading leaf submodules
|
|
47
|
+
# (which Ableton handles correctly), so NEW handler files are picked up
|
|
48
|
+
# on the next toggle / TCP reload_handlers call.
|
|
44
49
|
#
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
#
|
|
50
|
+
# In addition, a `reload_handlers` TCP command is exposed so the dev
|
|
51
|
+
# loop becomes: edit source → sync → TCP reload_handlers → done. No
|
|
52
|
+
# more UI toggles required during iteration.
|
|
53
|
+
#
|
|
54
|
+
# Order matters:
|
|
55
|
+
# 1. Reload router first — clears _handlers so re-register is clean.
|
|
56
|
+
# 2. Reload utils next — every handler imports get_track/get_device
|
|
57
|
+
# from it; must be fresh before handlers that depend on it reload.
|
|
58
|
+
# 3. Discover + reload everything else via pkgutil.
|
|
49
59
|
|
|
50
60
|
_FIRST_CREATE_INSTANCE = True
|
|
51
61
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
grooves, take_lanes, clip_automation, version_detect,
|
|
57
|
-
)
|
|
62
|
+
# Modules excluded from auto-reload. router is reloaded first (separately)
|
|
63
|
+
# because clearing its _handlers must precede re-register. server owns the
|
|
64
|
+
# TCP listener — reloading it mid-run would drop the socket.
|
|
65
|
+
_RELOAD_EXCLUDE = {"router", "server"}
|
|
58
66
|
|
|
59
67
|
|
|
60
68
|
def _force_reload_handlers(cs=None):
|
|
61
|
-
"""
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
69
|
+
"""Re-discover and reload every handler submodule on disk.
|
|
70
|
+
|
|
71
|
+
Uses pkgutil.iter_modules so NEW handler files added after Live boot
|
|
72
|
+
are picked up on the next call without any hand-maintained tuple.
|
|
73
|
+
Only touches leaf submodules — the one reload operation Ableton's
|
|
74
|
+
embedded Python handles correctly. Reloading the package itself was
|
|
75
|
+
tried and empirically no-ops in Ableton's Python.
|
|
76
|
+
|
|
77
|
+
Order:
|
|
78
|
+
1. Reload router → clears _handlers.
|
|
79
|
+
2. Reload utils → every handler imports get_track/get_device from it.
|
|
80
|
+
3. Discover + import/reload every other submodule. First-time imports
|
|
81
|
+
fire @register once; reloads re-fire it after the router reset.
|
|
82
|
+
4. Re-register reload_handlers_cmd (defined in __init__.py, not a
|
|
83
|
+
handler module, so not covered by step 3).
|
|
84
|
+
|
|
85
|
+
When ``cs`` is provided, reload exceptions log to the ControlSurface so
|
|
86
|
+
a SyntaxError / NameError in an edited handler is surfaced in Live's
|
|
87
|
+
status log instead of silently swallowed.
|
|
73
88
|
"""
|
|
74
89
|
import importlib
|
|
90
|
+
import pkgutil
|
|
91
|
+
import sys as _sys
|
|
92
|
+
|
|
75
93
|
def _log(msg):
|
|
76
94
|
if cs is None:
|
|
77
95
|
return
|
|
@@ -83,20 +101,60 @@ def _force_reload_handlers(cs=None):
|
|
|
83
101
|
try:
|
|
84
102
|
importlib.reload(router)
|
|
85
103
|
except Exception as exc:
|
|
86
|
-
_log("reload(router) FAILED — %s: %s
|
|
87
|
-
|
|
88
|
-
|
|
104
|
+
_log("reload(router) FAILED — %s: %s" % (type(exc).__name__, exc))
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
importlib.reload(utils)
|
|
108
|
+
except Exception as exc:
|
|
109
|
+
_log("reload(utils) FAILED — %s: %s" % (type(exc).__name__, exc))
|
|
110
|
+
|
|
111
|
+
# Invalidate caches so iter_modules sees newly-added files even if
|
|
112
|
+
# an importer cached the previous directory listing.
|
|
113
|
+
importlib.invalidate_caches()
|
|
114
|
+
pkg = _sys.modules.get("LivePilot")
|
|
115
|
+
if pkg is None or getattr(pkg, "__path__", None) is None:
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
discovered = reloaded = first_imported = 0
|
|
119
|
+
for _finder, modname, _is_pkg in pkgutil.iter_modules(pkg.__path__):
|
|
120
|
+
if modname in _RELOAD_EXCLUDE or modname == "utils":
|
|
121
|
+
continue
|
|
122
|
+
discovered += 1
|
|
123
|
+
full_name = "LivePilot." + modname
|
|
89
124
|
try:
|
|
90
|
-
|
|
125
|
+
cached = _sys.modules.get(full_name)
|
|
126
|
+
if cached is not None:
|
|
127
|
+
importlib.reload(cached)
|
|
128
|
+
reloaded += 1
|
|
129
|
+
else:
|
|
130
|
+
importlib.import_module(full_name)
|
|
131
|
+
first_imported += 1
|
|
91
132
|
except Exception as exc:
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
133
|
+
_log("reload(%s) FAILED — %s: %s" % (
|
|
134
|
+
full_name, type(exc).__name__, exc))
|
|
135
|
+
|
|
136
|
+
# reload_handlers_cmd lives in __init__.py (not a handler module),
|
|
137
|
+
# so the step-3 loop does not cover it. Re-register manually.
|
|
138
|
+
router._handlers["reload_handlers"] = reload_handlers_cmd
|
|
139
|
+
|
|
140
|
+
_log("reload complete — %d discovered (%d reloaded, %d first-imported)" % (
|
|
141
|
+
discovered, reloaded, first_imported))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def reload_handlers_cmd(song, params):
|
|
145
|
+
"""TCP-accessible reload trigger. Lets automation refresh handlers
|
|
146
|
+
without a UI Control Surface toggle — the core dev-loop improvement.
|
|
147
|
+
Returns the handler count so the caller can assert before/after."""
|
|
148
|
+
_force_reload_handlers(cs=None)
|
|
149
|
+
return {
|
|
150
|
+
"reloaded": True,
|
|
151
|
+
"handler_count": len(router._handlers),
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# Register the TCP-triggered reload command for initial boot.
|
|
156
|
+
# _force_reload_handlers re-registers it after each reload cycle.
|
|
157
|
+
router._handlers["reload_handlers"] = reload_handlers_cmd
|
|
100
158
|
|
|
101
159
|
|
|
102
160
|
def create_instance(c_instance):
|
|
@@ -46,21 +46,29 @@ def get_device_parameters(song, params):
|
|
|
46
46
|
|
|
47
47
|
parameters = []
|
|
48
48
|
for i, param in enumerate(device.parameters):
|
|
49
|
-
|
|
49
|
+
# Live raises RuntimeError("Invalid display value") from
|
|
50
|
+
# str_for_value / display_value when a parameter's internal
|
|
51
|
+
# display string is unset or NaN — seen on Operator,
|
|
52
|
+
# Compressor2, AutoFilter2. Serialize best-effort so one bad
|
|
53
|
+
# parameter does not abort the whole device read.
|
|
54
|
+
try:
|
|
55
|
+
value_string = param.str_for_value(param.value)
|
|
56
|
+
except Exception:
|
|
57
|
+
value_string = None
|
|
58
|
+
try:
|
|
59
|
+
display_value = param.display_value
|
|
60
|
+
except Exception:
|
|
61
|
+
display_value = None
|
|
62
|
+
parameters.append({
|
|
50
63
|
"index": i,
|
|
51
64
|
"name": param.name,
|
|
52
65
|
"value": param.value,
|
|
53
66
|
"min": param.min,
|
|
54
67
|
"max": param.max,
|
|
55
68
|
"is_quantized": param.is_quantized,
|
|
56
|
-
"value_string":
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
try:
|
|
60
|
-
info["display_value"] = param.display_value
|
|
61
|
-
except AttributeError:
|
|
62
|
-
pass
|
|
63
|
-
parameters.append(info)
|
|
69
|
+
"value_string": value_string,
|
|
70
|
+
"display_value": display_value,
|
|
71
|
+
})
|
|
64
72
|
return {"parameters": parameters}
|
|
65
73
|
|
|
66
74
|
|
|
@@ -633,16 +641,74 @@ def set_drum_chain_note(song, params):
|
|
|
633
641
|
}
|
|
634
642
|
|
|
635
643
|
|
|
644
|
+
def _normalize_device_name(name):
|
|
645
|
+
"""Case/space/underscore/dash-insensitive normalization for device names.
|
|
646
|
+
|
|
647
|
+
Matches the convention used by _require_analyzer in the MCP layer —
|
|
648
|
+
the frozen .amxd ships different names across versions
|
|
649
|
+
('LivePilot_Analyzer' vs 'LivePilot Analyzer'), and user devices are
|
|
650
|
+
freely renamed with mixed casing. Collapsing to a canonical form lets
|
|
651
|
+
the duplicate check survive those variants.
|
|
652
|
+
"""
|
|
653
|
+
return " ".join(str(name).replace("_", " ").replace("-", " ").lower().split())
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _find_existing_on_track(track, target_name):
|
|
657
|
+
"""Return (index, device) of the first existing device on `track`
|
|
658
|
+
whose normalized name matches `target_name`, or None.
|
|
659
|
+
|
|
660
|
+
Caller passes `target_name` already lowercased (the handler does
|
|
661
|
+
.lower() on params['device_name']). We normalize again on both sides
|
|
662
|
+
because the existing device name may have extra whitespace or the
|
|
663
|
+
user may have typed with different separators.
|
|
664
|
+
"""
|
|
665
|
+
try:
|
|
666
|
+
devices = list(track.devices)
|
|
667
|
+
except AttributeError:
|
|
668
|
+
return None
|
|
669
|
+
target = _normalize_device_name(target_name)
|
|
670
|
+
for i, dev in enumerate(devices):
|
|
671
|
+
try:
|
|
672
|
+
name = dev.name
|
|
673
|
+
except AttributeError:
|
|
674
|
+
continue
|
|
675
|
+
if _normalize_device_name(name) == target:
|
|
676
|
+
return (i, dev)
|
|
677
|
+
return None
|
|
678
|
+
|
|
679
|
+
|
|
636
680
|
@register("find_and_load_device")
|
|
637
681
|
def find_and_load_device(song, params):
|
|
638
682
|
"""Find a device by name in the browser and load it onto a track.
|
|
639
683
|
|
|
640
684
|
Searches all browser categories including user_library for M4L devices.
|
|
641
685
|
Supports partial matching: 'Kickster' matches 'trnr.Kickster'.
|
|
686
|
+
|
|
687
|
+
If a device with the same (normalized) name already exists on the
|
|
688
|
+
target track's chain, returns the existing device's location without
|
|
689
|
+
loading a second copy. Set `allow_duplicate=True` to force-load a
|
|
690
|
+
second instance (e.g. parallel processing chains).
|
|
642
691
|
"""
|
|
643
692
|
track_index = int(params["track_index"])
|
|
644
693
|
device_name = str(params["device_name"]).lower()
|
|
694
|
+
allow_duplicate = bool(params.get("allow_duplicate", False))
|
|
645
695
|
track = get_track(song, track_index)
|
|
696
|
+
|
|
697
|
+
# Duplicate check — runs BEFORE any load path (12.3 native fast path
|
|
698
|
+
# AND browser search) so both are protected. Previously the analyzer
|
|
699
|
+
# auto-load at session start produced two analyzers on the master if
|
|
700
|
+
# one was already present from a prior session, doubling CPU cost.
|
|
701
|
+
if not allow_duplicate:
|
|
702
|
+
existing = _find_existing_on_track(track, device_name)
|
|
703
|
+
if existing is not None:
|
|
704
|
+
idx, dev = existing
|
|
705
|
+
return {
|
|
706
|
+
"loaded": dev.name,
|
|
707
|
+
"track_index": track_index,
|
|
708
|
+
"device_index": idx,
|
|
709
|
+
"already_present": True,
|
|
710
|
+
}
|
|
711
|
+
|
|
646
712
|
browser = _get_browser()
|
|
647
713
|
|
|
648
714
|
# 12.3+ fast path: try insert_device for native devices
|
|
@@ -295,16 +295,122 @@ def set_track_routing(song, params):
|
|
|
295
295
|
return result
|
|
296
296
|
|
|
297
297
|
|
|
298
|
+
def _find_sidechain_surface(device):
|
|
299
|
+
"""Probe a Compressor device for its sidechain-routing LOM surface.
|
|
300
|
+
|
|
301
|
+
Legacy Compressor (I) exposes flat properties directly on the device:
|
|
302
|
+
available_sidechain_input_routing_types / _channels
|
|
303
|
+
sidechain_input_routing_type / _channel
|
|
304
|
+
Live 12.3.6's Compressor2 may not have those flat attrs (confirmed
|
|
305
|
+
via Batch 18's Max JS probe and Batch 19's Python fallback both
|
|
306
|
+
hitting the same gap in the flat surface). This probe tries a few
|
|
307
|
+
known shapes in order and returns the first that matches.
|
|
308
|
+
|
|
309
|
+
Returns a dict:
|
|
310
|
+
desc: string describing which shape matched
|
|
311
|
+
types: list of RoutingType candidates for input source
|
|
312
|
+
channels: list of RoutingChannel candidates, or None if the
|
|
313
|
+
shape doesn't expose a channel list
|
|
314
|
+
set_type: callable(RoutingType) — assigns the input type
|
|
315
|
+
set_chan: callable(RoutingChannel) — assigns the channel
|
|
316
|
+
read_type: callable() -> RoutingType or None
|
|
317
|
+
read_chan: callable() -> RoutingChannel or None
|
|
318
|
+
Or None if no known shape matches — caller should emit a diagnostic.
|
|
319
|
+
"""
|
|
320
|
+
def _shape(obj, types_attr, chans_attr, type_prop, chan_prop, desc):
|
|
321
|
+
# Channels MUST be read lazily — on Compressor2's input_routing_*
|
|
322
|
+
# shape, available_input_routing_channels depends on the currently
|
|
323
|
+
# selected input_routing_type. Snapshotting at probe time made
|
|
324
|
+
# combined (type + channel) calls fail because the snapshot was
|
|
325
|
+
# taken BEFORE the new type was written. A fresh read per query
|
|
326
|
+
# also keeps us honest against UI-side changes mid-call.
|
|
327
|
+
def _get_channels():
|
|
328
|
+
if not hasattr(obj, chans_attr):
|
|
329
|
+
return None
|
|
330
|
+
return list(getattr(obj, chans_attr))
|
|
331
|
+
|
|
332
|
+
return {
|
|
333
|
+
"desc": desc,
|
|
334
|
+
"types": list(getattr(obj, types_attr)),
|
|
335
|
+
"get_channels": _get_channels,
|
|
336
|
+
"set_type": lambda rt: setattr(obj, type_prop, rt),
|
|
337
|
+
"set_chan": lambda rc: setattr(obj, chan_prop, rc),
|
|
338
|
+
"read_type": lambda: getattr(obj, type_prop, None),
|
|
339
|
+
"read_chan": lambda: getattr(obj, chan_prop, None),
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if hasattr(device, "available_sidechain_input_routing_types"):
|
|
343
|
+
return _shape(
|
|
344
|
+
device,
|
|
345
|
+
"available_sidechain_input_routing_types",
|
|
346
|
+
"available_sidechain_input_routing_channels",
|
|
347
|
+
"sidechain_input_routing_type",
|
|
348
|
+
"sidechain_input_routing_channel",
|
|
349
|
+
"flat device.sidechain_input_routing_*",
|
|
350
|
+
)
|
|
351
|
+
sc_input = getattr(device, "sidechain_input", None)
|
|
352
|
+
if sc_input is not None and hasattr(sc_input, "available_routing_types"):
|
|
353
|
+
return _shape(
|
|
354
|
+
sc_input,
|
|
355
|
+
"available_routing_types",
|
|
356
|
+
"available_routing_channels",
|
|
357
|
+
"routing_type",
|
|
358
|
+
"routing_channel",
|
|
359
|
+
"nested device.sidechain_input.routing_*",
|
|
360
|
+
)
|
|
361
|
+
if hasattr(device, "available_input_routing_types"):
|
|
362
|
+
return _shape(
|
|
363
|
+
device,
|
|
364
|
+
"available_input_routing_types",
|
|
365
|
+
"available_input_routing_channels",
|
|
366
|
+
"input_routing_type",
|
|
367
|
+
"input_routing_channel",
|
|
368
|
+
"flat device.input_routing_* (no sidechain_ prefix)",
|
|
369
|
+
)
|
|
370
|
+
return None
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _collect_routing_diagnostic(device):
|
|
374
|
+
"""Return a CSV of routing/sidechain attrs on device + likely children.
|
|
375
|
+
|
|
376
|
+
Used as a breadcrumb in the error message when _find_sidechain_surface
|
|
377
|
+
returns None, so the first failing call tells us what the current Live
|
|
378
|
+
build actually exposes without a separate probe session.
|
|
379
|
+
"""
|
|
380
|
+
def _attrs(obj, prefix):
|
|
381
|
+
try:
|
|
382
|
+
names = sorted(
|
|
383
|
+
a for a in dir(obj)
|
|
384
|
+
if ("routing" in a.lower() or "sidechain" in a.lower())
|
|
385
|
+
and not a.startswith("_")
|
|
386
|
+
)
|
|
387
|
+
except Exception:
|
|
388
|
+
return []
|
|
389
|
+
return [prefix + n for n in names]
|
|
390
|
+
|
|
391
|
+
found = list(_attrs(device, "device."))
|
|
392
|
+
for child_name in ("sidechain_input", "input", "sidechain", "routing"):
|
|
393
|
+
try:
|
|
394
|
+
child = getattr(device, child_name, None)
|
|
395
|
+
except Exception:
|
|
396
|
+
child = None
|
|
397
|
+
if child is None:
|
|
398
|
+
continue
|
|
399
|
+
found.extend(_attrs(child, "device.%s." % child_name))
|
|
400
|
+
return ", ".join(found) if found else "<none>"
|
|
401
|
+
|
|
402
|
+
|
|
298
403
|
@register("set_compressor_sidechain")
|
|
299
404
|
def set_compressor_sidechain(song, params):
|
|
300
405
|
"""Configure a Compressor's sidechain input routing (BUG-A3).
|
|
301
406
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
407
|
+
Probes the LOM sidechain-routing surface — legacy Compressor (I)
|
|
408
|
+
exposes `available_sidechain_input_routing_types` directly, but
|
|
409
|
+
Live 12.3.6's Compressor2 doesn't, so we fall back to a small set of
|
|
410
|
+
known shapes via _find_sidechain_surface. On no match the error
|
|
411
|
+
message embeds a dir() audit of routing/sidechain attrs on the
|
|
412
|
+
device and its likely children so future Live builds reveal
|
|
413
|
+
themselves without a separate probe.
|
|
308
414
|
|
|
309
415
|
Params:
|
|
310
416
|
track_index: 0+ regular, -1/-2 returns, -1000 master
|
|
@@ -338,12 +444,9 @@ def set_compressor_sidechain(song, params):
|
|
|
338
444
|
|
|
339
445
|
# Older Compressor builds may not expose `sidechain_enabled` as a
|
|
340
446
|
# property; the automatable "S/C On" parameter is the fallback.
|
|
341
|
-
# Try both paths so the sidechain gets enabled whichever surface is
|
|
342
|
-
# available on this Live version.
|
|
343
447
|
try:
|
|
344
448
|
device.sidechain_enabled = True
|
|
345
449
|
except AttributeError:
|
|
346
|
-
# Fallback: find the "S/C On" parameter and toggle it
|
|
347
450
|
for param in device.parameters:
|
|
348
451
|
if param.name == "S/C On":
|
|
349
452
|
param.value = 1
|
|
@@ -355,13 +458,19 @@ def set_compressor_sidechain(song, params):
|
|
|
355
458
|
"device_index": device_index,
|
|
356
459
|
}
|
|
357
460
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
461
|
+
want_type = source_type is not None and source_type != ""
|
|
462
|
+
want_channel = source_channel is not None and source_channel != ""
|
|
463
|
+
surface = _find_sidechain_surface(device)
|
|
464
|
+
|
|
465
|
+
if (want_type or want_channel) and surface is None:
|
|
466
|
+
raise ValueError(
|
|
467
|
+
"This Live build doesn't expose a sidechain routing surface "
|
|
468
|
+
"on %s. Inspected attrs: %s"
|
|
469
|
+
% (class_name, _collect_routing_diagnostic(device))
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
if want_type:
|
|
473
|
+
available = surface["types"]
|
|
365
474
|
matched = None
|
|
366
475
|
for rt in available:
|
|
367
476
|
if rt.display_name == source_type:
|
|
@@ -370,40 +479,51 @@ def set_compressor_sidechain(song, params):
|
|
|
370
479
|
if matched is None:
|
|
371
480
|
options = [rt.display_name for rt in available]
|
|
372
481
|
raise ValueError(
|
|
373
|
-
"Sidechain input type '%s' not found.
|
|
374
|
-
|
|
482
|
+
"Sidechain input type '%s' not found (surface=%s). "
|
|
483
|
+
"Available: %s"
|
|
484
|
+
% (source_type, surface["desc"], ", ".join(options))
|
|
375
485
|
)
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
if
|
|
379
|
-
|
|
486
|
+
surface["set_type"](matched)
|
|
487
|
+
|
|
488
|
+
if want_channel:
|
|
489
|
+
# Lazy fetch — on Compressor2 the channel list depends on the
|
|
490
|
+
# currently-set input_routing_type, so combined calls need the
|
|
491
|
+
# post-type-write state, not the probe-time snapshot.
|
|
492
|
+
channels = surface["get_channels"]()
|
|
493
|
+
if channels is None:
|
|
380
494
|
raise ValueError(
|
|
381
|
-
"
|
|
382
|
-
"
|
|
495
|
+
"Sidechain surface on %s (%s) exposes input types but no "
|
|
496
|
+
"channel list. Inspected attrs: %s"
|
|
497
|
+
% (class_name, surface["desc"],
|
|
498
|
+
_collect_routing_diagnostic(device))
|
|
383
499
|
)
|
|
384
|
-
available = list(device.available_sidechain_input_routing_channels)
|
|
385
500
|
matched = None
|
|
386
|
-
for ch in
|
|
501
|
+
for ch in channels:
|
|
387
502
|
if ch.display_name == source_channel:
|
|
388
503
|
matched = ch
|
|
389
504
|
break
|
|
390
505
|
if matched is None:
|
|
391
|
-
options = [ch.display_name for ch in
|
|
506
|
+
options = [ch.display_name for ch in channels]
|
|
392
507
|
raise ValueError(
|
|
393
|
-
"Sidechain input channel '%s' not found.
|
|
394
|
-
|
|
508
|
+
"Sidechain input channel '%s' not found (surface=%s). "
|
|
509
|
+
"Available: %s"
|
|
510
|
+
% (source_channel, surface["desc"], ", ".join(options))
|
|
395
511
|
)
|
|
396
|
-
|
|
512
|
+
surface["set_chan"](matched)
|
|
397
513
|
|
|
398
|
-
# Read back
|
|
514
|
+
# Read back via the same surface used to write. Fall back to the
|
|
515
|
+
# input params if the surface isn't exposed or raises on read.
|
|
399
516
|
try:
|
|
517
|
+
if surface is None:
|
|
518
|
+
raise AttributeError("no sidechain surface for readback")
|
|
519
|
+
type_obj = surface["read_type"]()
|
|
520
|
+
chan_obj = surface["read_chan"]()
|
|
400
521
|
result["sidechain"] = {
|
|
401
|
-
"type":
|
|
402
|
-
"channel":
|
|
522
|
+
"type": type_obj.display_name if type_obj is not None else "",
|
|
523
|
+
"channel": chan_obj.display_name if chan_obj is not None else "",
|
|
403
524
|
"enabled": bool(getattr(device, "sidechain_enabled", True)),
|
|
404
525
|
}
|
|
405
526
|
except AttributeError:
|
|
406
|
-
# Very old Compressor — fall back to whatever we set above
|
|
407
527
|
result["sidechain"] = {
|
|
408
528
|
"type": source_type or "",
|
|
409
529
|
"channel": source_channel or "",
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# remote_script/LivePilot/simpler_sample.py
|
|
2
|
+
"""
|
|
3
|
+
LivePilot — Simpler sample replacement via the native Live 12.4 LOM API.
|
|
4
|
+
|
|
5
|
+
Exposes a ``replace_sample_native`` command that calls
|
|
6
|
+
``SimplerDevice.replace_sample(absolute_path)`` directly on the main thread.
|
|
7
|
+
Unlike the M4L-bridge path, this handler works on empty Simplers (the whole
|
|
8
|
+
reason 12.4 added the native API) and does not require a Max for Live
|
|
9
|
+
device in the Set.
|
|
10
|
+
|
|
11
|
+
Version-gated on ``replace_sample_native`` (12.4.0+). On earlier versions
|
|
12
|
+
the handler returns a STATE_ERROR; callers (MCP tools) are expected to
|
|
13
|
+
fall back to the bridge path.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from .router import register
|
|
17
|
+
from .version_detect import has_feature, version_string
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@register("replace_sample_native")
|
|
21
|
+
def replace_sample_native(song, params):
|
|
22
|
+
"""Replace the sample in a Simpler device using the Live 12.4+ native API.
|
|
23
|
+
|
|
24
|
+
params dict keys:
|
|
25
|
+
track_index (int): 0-based index into song.tracks.
|
|
26
|
+
device_index (int): 0-based index into the track's devices.
|
|
27
|
+
file_path (str): absolute path to the audio file to load.
|
|
28
|
+
|
|
29
|
+
Returns on success:
|
|
30
|
+
sample_loaded (bool): True.
|
|
31
|
+
track_index (int): echoed from input.
|
|
32
|
+
device_index (int): echoed from input.
|
|
33
|
+
method (str): "native_12_4".
|
|
34
|
+
live_version (str): detected Live version at call time.
|
|
35
|
+
|
|
36
|
+
Returns on error:
|
|
37
|
+
error (str): human-readable message.
|
|
38
|
+
code (str): STATE_ERROR | INDEX_ERROR | INVALID_PARAM | INTERNAL.
|
|
39
|
+
"""
|
|
40
|
+
if not has_feature("replace_sample_native"):
|
|
41
|
+
return {
|
|
42
|
+
"error": (
|
|
43
|
+
"replace_sample_native requires Live 12.4+. "
|
|
44
|
+
"Detected: " + version_string() + ". "
|
|
45
|
+
"Use the M4L-bridge replace_simpler_sample path instead."
|
|
46
|
+
),
|
|
47
|
+
"code": "STATE_ERROR",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
track_index = int(params["track_index"])
|
|
52
|
+
device_index = int(params["device_index"])
|
|
53
|
+
file_path = str(params["file_path"])
|
|
54
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
55
|
+
return {
|
|
56
|
+
"error": "Invalid params: " + str(exc),
|
|
57
|
+
"code": "INVALID_PARAM",
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
tracks = list(song.tracks)
|
|
61
|
+
if track_index < 0 or track_index >= len(tracks):
|
|
62
|
+
return {
|
|
63
|
+
"error": "track_index " + str(track_index) + " out of range",
|
|
64
|
+
"code": "INDEX_ERROR",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
track = tracks[track_index]
|
|
68
|
+
devices = list(track.devices)
|
|
69
|
+
if device_index < 0 or device_index >= len(devices):
|
|
70
|
+
return {
|
|
71
|
+
"error": "device_index " + str(device_index) + " out of range",
|
|
72
|
+
"code": "INDEX_ERROR",
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
device = devices[device_index]
|
|
76
|
+
class_name = getattr(device, "class_name", "")
|
|
77
|
+
if class_name != "SimplerDevice":
|
|
78
|
+
return {
|
|
79
|
+
"error": "Device at [" + str(track_index) + "][" + str(device_index) + "] is "
|
|
80
|
+
+ class_name + ", not Simpler",
|
|
81
|
+
"code": "INVALID_PARAM",
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
device.replace_sample(file_path)
|
|
86
|
+
except Exception as exc:
|
|
87
|
+
return {
|
|
88
|
+
"error": "SimplerDevice.replace_sample failed: " + str(exc),
|
|
89
|
+
"code": "INTERNAL",
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
"sample_loaded": True,
|
|
94
|
+
"track_index": track_index,
|
|
95
|
+
"device_index": device_index,
|
|
96
|
+
"method": "native_12_4",
|
|
97
|
+
"live_version": version_string(),
|
|
98
|
+
}
|
package/server.json
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
3
|
"name": "io.github.dreamrec/livepilot",
|
|
4
|
-
"description": "
|
|
4
|
+
"description": "403-tool agentic MCP production system for Ableton Live 12 — device atlas, sample engine, composer",
|
|
5
5
|
"repository": {
|
|
6
6
|
"url": "https://github.com/dreamrec/LivePilot",
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
|
-
"version": "1.
|
|
9
|
+
"version": "1.15.0-beta",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "livepilot",
|
|
14
|
-
"version": "1.
|
|
14
|
+
"version": "1.15.0-beta",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
}
|