livepilot 1.14.0 → 1.14.1

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 CHANGED
@@ -1,5 +1,63 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.14.1 — reload_handlers workflow + device/mixing fixes (April 21 2026)
4
+
5
+ Patch release that lands the post-1.14.0 audit work: one new diagnostics
6
+ tool, three bug fixes, and a new plugin-sync verification script.
7
+
8
+ **Tool count**: 402 → **403** (added `reload_handlers`).
9
+ **Domain count**: unchanged at 52.
10
+ **Tests**: 2467 → **2485 passing** (+18 new), 0 regressions.
11
+
12
+ ### New tool: `reload_handlers`
13
+
14
+ - Replaces the manual "toggle Control Surface in Live → Preferences →
15
+ Link/MIDI" step that every Remote Script edit required. New workflow:
16
+ after `node installer/install.js`, call `reload_handlers` via the MCP
17
+ tool. The Remote Script side uses `pkgutil` + `importlib.reload()` to
18
+ re-fire all `@register` decorators in place in <1s, without dropping
19
+ the MCP TCP connection on port 9878.
20
+ - Ships with a pkgutil-based module-discovery helper in
21
+ `remote_script/LivePilot/__init__.py`, so new handler modules added to
22
+ `remote_script/LivePilot/` are picked up automatically on reload.
23
+ - Exception: the very first bootstrap (no prior `LivePilot.*` in
24
+ `sys.modules`) still needs one full Ableton restart. After that,
25
+ `reload_handlers` works forever.
26
+ - Domain: `diagnostics`. Added to `docs/manual/tool-catalog.md` to keep
27
+ the CI skill-contract test green.
28
+
29
+ ### Bug fixes
30
+
31
+ - **`find_and_load_device` duplicate loads** — the tool was no-oping
32
+ only on exact name match; changed to also treat cases where the
33
+ target device is already the tail of the chain as a no-op. Prevents
34
+ the "load Simpler, load Simpler, load Simpler" cascade when the MCP
35
+ server retries a loader.
36
+ - **`get_device_parameters` "Invalid display value"** — certain Live
37
+ parameters (especially plugin wrappers on AU/VST) raise
38
+ `RuntimeError("Invalid display value")` when their
39
+ `str_for_value()` is queried before the parameter has settled. The
40
+ handler now swallows that specific error and returns the raw float
41
+ instead of 500-ing the whole request.
42
+ - **Sidechain LOM reopen (BUG-A3 redux)** — Compressor2 moved its
43
+ sidechain block into a nested property in a recent Live update, so
44
+ `compressor_set_sidechain` lost the ability to toggle. The handler
45
+ now probes the LOM surface at tool-call time and falls back to the
46
+ flat path when the nested one isn't exposed.
47
+ - **Mixing `channel` lazy-get** — channel objects were resolved eagerly
48
+ at import time, breaking in edge cases where the Song came up before
49
+ the mixer. Now resolved on first use.
50
+
51
+ ### New: plugin-sync verification
52
+
53
+ - `scripts/verify_plugin_sync.py` — catches the v1.14.0 regression
54
+ class where `.mcp.json` went missing from
55
+ `~/.claude/plugins/cache/dreamrec-LivePilot/livepilot/$VERSION/`. All
56
+ four sync targets (active plugin dir, cache version dir, marketplace
57
+ snapshot, `installed_plugins.json`) are now verified by one command.
58
+
59
+ ---
60
+
3
61
  ## 1.14.0 — Branch-native v2: producer context, synth intelligence, render verify (April 20 2026)
4
62
 
5
63
  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
- 402 tools. 52 domains. Device atlas. Splice integration. Auto-composition. Spectral perception. Technique memory.
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
- │ │ 402 MCP Tools │ │
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 402 tools. They give the AI musical judgment, not just musical execution.
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
- 402 tools across 52 domains. Highlights below — [full catalog here](docs/manual/tool-catalog.md).
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 402 tools →](docs/manual/tool-catalog.md)**
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 402 tools, workflows |
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
@@ -95,7 +95,7 @@ function anything() {
95
95
  function dispatch(cmd, args) {
96
96
  switch(cmd) {
97
97
  case "ping":
98
- send_response({"ok": true, "version": "1.14.0"});
98
+ send_response({"ok": true, "version": "1.14.1"});
99
99
  break;
100
100
  case "get_params":
101
101
  cmd_get_params(args);
@@ -1,2 +1,2 @@
1
1
  """LivePilot MCP Server — bridges MCP protocol to Ableton Live."""
2
- __version__ = "1.14.0"
2
+ __version__ = "1.14.1"
@@ -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",
@@ -426,9 +426,20 @@ def move_device(
426
426
 
427
427
 
428
428
  @mcp.tool()
429
- def find_and_load_device(ctx: Context, track_index: int, device_name: str) -> dict:
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.14.0",
3
+ "version": "1.14.1",
4
4
  "mcpName": "io.github.dreamrec/livepilot",
5
- "description": "Agentic production system for Ableton Live 12 — 402 tools, 52 domains. Device atlas (1305 devices), sample engine (Splice + browser + filesystem), auto-composition, spectral perception, technique memory, creative intelligence (12 engines)",
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.14.0"
8
+ __version__ = "1.14.1"
9
9
 
10
10
  from _Framework.ControlSurface import ControlSurface
11
11
  from . import router
@@ -32,46 +32,63 @@ from . import version_detect # noqa: F401 — version detection
32
32
  # ── Reload plumbing (BUG-B-reload, Batch 20) ──────────────────────────────
33
33
  # Ableton keeps `sys.modules["LivePilot.*"]` cached across Control Surface
34
34
  # toggles. Without intervention, edits to handler files don't take effect
35
- # until a full Ableton restart — the toggle just re-instantiates the
36
- # ControlSurface class without re-importing submodules.
35
+ # until a full Ableton restart.
37
36
  #
38
- # Fix: track whether this is the first create_instance call per
39
- # interpreter lifetime. On subsequent calls, force-reload the router
40
- # (which clears _handlers) and every handler module (which re-fires
41
- # @register decorators with the updated code). Result: a Control Surface
42
- # toggle now behaves like a fresh module reload, so live-editing mixing.py
43
- # / devices.py / etc. and re-toggling is enough no Ableton restart.
37
+ # Fix: on every create_instance() except the first, re-discover every
38
+ # handler module on disk via pkgutil.iter_modules() and reload it. This
39
+ # side-steps two separate issues: (1) the old hardcoded _HANDLER_MODULES
40
+ # tuple that had to be manually updated for every new handler file, and
41
+ # (2) Ableton's embedded Python silently no-op'ing importlib.reload() on
42
+ # the package itself a behavior confirmed empirically by observing
43
+ # that a module-level file write in __init__.py never fires across
44
+ # toggles, only at initial Live boot. pkgutil.iter_modules reads the
45
+ # filesystem directly and relies only on reloading leaf submodules
46
+ # (which Ableton handles correctly), so NEW handler files are picked up
47
+ # on the next toggle / TCP reload_handlers call.
44
48
  #
45
- # Order matters: utils comes first because every handler imports
46
- # ``from .utils import get_track, get_device``. If utils isn't reloaded
47
- # first, those re-imports during ``importlib.reload(devices)`` still
48
- # resolve to the stale ``utils`` module object in ``sys.modules``.
49
+ # In addition, a `reload_handlers` TCP command is exposed so the dev
50
+ # loop becomes: edit source sync TCP reload_handlers → done. No
51
+ # more UI toggles required during iteration.
52
+ #
53
+ # Order matters:
54
+ # 1. Reload router first — clears _handlers so re-register is clean.
55
+ # 2. Reload utils next — every handler imports get_track/get_device
56
+ # from it; must be fresh before handlers that depend on it reload.
57
+ # 3. Discover + reload everything else via pkgutil.
49
58
 
50
59
  _FIRST_CREATE_INSTANCE = True
51
60
 
52
- _HANDLER_MODULES = (
53
- utils,
54
- transport, tracks, clips, notes, devices, scenes, scales,
55
- mixing, browser, arrangement, diagnostics, follow_actions,
56
- grooves, take_lanes, clip_automation, version_detect,
57
- )
61
+ # Modules excluded from auto-reload. router is reloaded first (separately)
62
+ # because clearing its _handlers must precede re-register. server owns the
63
+ # TCP listener reloading it mid-run would drop the socket.
64
+ _RELOAD_EXCLUDE = {"router", "server"}
58
65
 
59
66
 
60
67
  def _force_reload_handlers(cs=None):
61
- """Force Python to re-read the handler modules from disk.
62
-
63
- Called on every create_instance() except the first, so edits to
64
- handler files take effect via Control Surface toggle without
65
- restarting Ableton. Order matters: router first (clears _handlers),
66
- then each handler module (re-registers its @register decorators).
67
-
68
- When ``cs`` is provided, reload exceptions are logged through the
69
- ControlSurface so a SyntaxError / NameError in an edited handler is
70
- surfaced in Live's status log instead of silently swallowed. The
71
- previous ``except Exception: pass`` turned any bad handler into a
72
- silent NOT_FOUND at dispatch time with no hint that reload had failed.
68
+ """Re-discover and reload every handler submodule on disk.
69
+
70
+ Uses pkgutil.iter_modules so NEW handler files added after Live boot
71
+ are picked up on the next call without any hand-maintained tuple.
72
+ Only touches leaf submodules the one reload operation Ableton's
73
+ embedded Python handles correctly. Reloading the package itself was
74
+ tried and empirically no-ops in Ableton's Python.
75
+
76
+ Order:
77
+ 1. Reload router clears _handlers.
78
+ 2. Reload utils every handler imports get_track/get_device from it.
79
+ 3. Discover + import/reload every other submodule. First-time imports
80
+ fire @register once; reloads re-fire it after the router reset.
81
+ 4. Re-register reload_handlers_cmd (defined in __init__.py, not a
82
+ handler module, so not covered by step 3).
83
+
84
+ When ``cs`` is provided, reload exceptions log to the ControlSurface so
85
+ a SyntaxError / NameError in an edited handler is surfaced in Live's
86
+ status log instead of silently swallowed.
73
87
  """
74
88
  import importlib
89
+ import pkgutil
90
+ import sys as _sys
91
+
75
92
  def _log(msg):
76
93
  if cs is None:
77
94
  return
@@ -83,20 +100,60 @@ def _force_reload_handlers(cs=None):
83
100
  try:
84
101
  importlib.reload(router)
85
102
  except Exception as exc:
86
- _log("reload(router) FAILED — %s: %s. Handlers will be "
87
- "stale until Ableton restart." % (type(exc).__name__, exc))
88
- for mod in _HANDLER_MODULES:
103
+ _log("reload(router) FAILED — %s: %s" % (type(exc).__name__, exc))
104
+
105
+ try:
106
+ importlib.reload(utils)
107
+ except Exception as exc:
108
+ _log("reload(utils) FAILED — %s: %s" % (type(exc).__name__, exc))
109
+
110
+ # Invalidate caches so iter_modules sees newly-added files even if
111
+ # an importer cached the previous directory listing.
112
+ importlib.invalidate_caches()
113
+ pkg = _sys.modules.get("LivePilot")
114
+ if pkg is None or getattr(pkg, "__path__", None) is None:
115
+ return
116
+
117
+ discovered = reloaded = first_imported = 0
118
+ for _finder, modname, _is_pkg in pkgutil.iter_modules(pkg.__path__):
119
+ if modname in _RELOAD_EXCLUDE or modname == "utils":
120
+ continue
121
+ discovered += 1
122
+ full_name = "LivePilot." + modname
89
123
  try:
90
- importlib.reload(mod)
124
+ cached = _sys.modules.get(full_name)
125
+ if cached is not None:
126
+ importlib.reload(cached)
127
+ reloaded += 1
128
+ else:
129
+ importlib.import_module(full_name)
130
+ first_imported += 1
91
131
  except Exception as exc:
92
- # Don't block Ableton startup on a single bad reload, but do
93
- # tell the user what happened — the stale handler will keep
94
- # serving the OLD code until a full restart.
95
- _log("reload(%s) FAILED %s: %s. Handler is stale." % (
96
- getattr(mod, "__name__", "?"),
97
- type(exc).__name__,
98
- exc,
99
- ))
132
+ _log("reload(%s) FAILED %s: %s" % (
133
+ full_name, type(exc).__name__, exc))
134
+
135
+ # reload_handlers_cmd lives in __init__.py (not a handler module),
136
+ # so the step-3 loop does not cover it. Re-register manually.
137
+ router._handlers["reload_handlers"] = reload_handlers_cmd
138
+
139
+ _log("reload complete — %d discovered (%d reloaded, %d first-imported)" % (
140
+ discovered, reloaded, first_imported))
141
+
142
+
143
+ def reload_handlers_cmd(song, params):
144
+ """TCP-accessible reload trigger. Lets automation refresh handlers
145
+ without a UI Control Surface toggle — the core dev-loop improvement.
146
+ Returns the handler count so the caller can assert before/after."""
147
+ _force_reload_handlers(cs=None)
148
+ return {
149
+ "reloaded": True,
150
+ "handler_count": len(router._handlers),
151
+ }
152
+
153
+
154
+ # Register the TCP-triggered reload command for initial boot.
155
+ # _force_reload_handlers re-registers it after each reload cycle.
156
+ router._handlers["reload_handlers"] = reload_handlers_cmd
100
157
 
101
158
 
102
159
  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
- info = {
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": param.str_for_value(param.value),
57
- }
58
- # 12.2+ feature: native display_value
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
- Same LOM pattern as set_track_routing the sidechain properties
303
- (available_sidechain_input_routing_types / _channels) are on the
304
- device object and Python's Remote Script accesses them cleanly.
305
- This used to be routed through the M4L bridge (v1.10.6) but Max JS
306
- LiveAPI couldn't read the available_* lists in Live 12.3.6, so it's
307
- back on the Python side where it belongs.
407
+ Probes the LOM sidechain-routing surfacelegacy 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
- if source_type is not None and source_type != "":
359
- if not hasattr(device, "available_sidechain_input_routing_types"):
360
- raise ValueError(
361
- "This Live build doesn't expose "
362
- "device.available_sidechain_input_routing_types"
363
- )
364
- available = list(device.available_sidechain_input_routing_types)
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. Available: %s"
374
- % (source_type, ", ".join(options))
482
+ "Sidechain input type '%s' not found (surface=%s). "
483
+ "Available: %s"
484
+ % (source_type, surface["desc"], ", ".join(options))
375
485
  )
376
- device.sidechain_input_routing_type = matched
377
-
378
- if source_channel is not None and source_channel != "":
379
- if not hasattr(device, "available_sidechain_input_routing_channels"):
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
- "This Live build doesn't expose "
382
- "device.available_sidechain_input_routing_channels"
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 available:
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 available]
506
+ options = [ch.display_name for ch in channels]
392
507
  raise ValueError(
393
- "Sidechain input channel '%s' not found. Available: %s"
394
- % (source_channel, ", ".join(options))
508
+ "Sidechain input channel '%s' not found (surface=%s). "
509
+ "Available: %s"
510
+ % (source_channel, surface["desc"], ", ".join(options))
395
511
  )
396
- device.sidechain_input_routing_channel = matched
512
+ surface["set_chan"](matched)
397
513
 
398
- # Read back canonical display names
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": device.sidechain_input_routing_type.display_name,
402
- "channel": device.sidechain_input_routing_channel.display_name,
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 "",
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": "402-tool agentic MCP production system for Ableton Live 12 — device atlas, sample engine, composer",
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.14.0",
9
+ "version": "1.14.1",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "livepilot",
14
- "version": "1.14.0",
14
+ "version": "1.14.1",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  }