livepilot 1.14.1 → 1.16.0

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 (33) hide show
  1. package/CHANGELOG.md +176 -1
  2. package/README.md +6 -6
  3. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  4. package/mcp_server/__init__.py +1 -1
  5. package/mcp_server/atlas/device_atlas.json +91219 -7161
  6. package/mcp_server/atlas/tools.py +30 -2
  7. package/mcp_server/runtime/live_version.py +4 -2
  8. package/mcp_server/runtime/remote_commands.py +5 -0
  9. package/mcp_server/sample_engine/tools.py +692 -60
  10. package/mcp_server/splice_client/client.py +511 -65
  11. package/mcp_server/splice_client/http_bridge.py +361 -0
  12. package/mcp_server/splice_client/models.py +266 -2
  13. package/mcp_server/splice_client/quota.py +229 -0
  14. package/mcp_server/tools/_analyzer_engine/__init__.py +4 -0
  15. package/mcp_server/tools/_analyzer_engine/sample.py +73 -0
  16. package/mcp_server/tools/analyzer.py +666 -6
  17. package/mcp_server/tools/browser.py +164 -13
  18. package/mcp_server/tools/devices.py +56 -11
  19. package/mcp_server/tools/mixing.py +64 -15
  20. package/mcp_server/tools/scales.py +18 -6
  21. package/mcp_server/tools/tracks.py +92 -4
  22. package/package.json +2 -2
  23. package/remote_script/LivePilot/__init__.py +2 -1
  24. package/remote_script/LivePilot/_clip_helpers.py +86 -0
  25. package/remote_script/LivePilot/_drum_helpers.py +40 -0
  26. package/remote_script/LivePilot/_scale_helpers.py +87 -0
  27. package/remote_script/LivePilot/arrangement.py +44 -15
  28. package/remote_script/LivePilot/clips.py +182 -2
  29. package/remote_script/LivePilot/devices.py +82 -2
  30. package/remote_script/LivePilot/notes.py +17 -2
  31. package/remote_script/LivePilot/scales.py +31 -16
  32. package/remote_script/LivePilot/simpler_sample.py +186 -0
  33. package/server.json +3 -3
@@ -39,11 +39,61 @@ def get_browser_tree(ctx: Context, category_type: str = "all") -> dict:
39
39
 
40
40
 
41
41
  @mcp.tool()
42
- def get_browser_items(ctx: Context, path: str) -> dict:
43
- """List items at a browser path (e.g., 'instruments/Analog')."""
42
+ def get_browser_items(
43
+ ctx: Context,
44
+ path: str,
45
+ limit: int = 500,
46
+ offset: int = 0,
47
+ filter_pattern: Optional[str] = None,
48
+ ) -> dict:
49
+ """List items at a browser path (e.g., 'instruments/Analog').
50
+
51
+ BUG-2026-04-22#5 fix — the /drums folder returned 68KB+ of JSON on
52
+ single calls, blowing past tool token caps. These params give agents
53
+ a way to page and filter natively without dumping to temp files.
54
+
55
+ path: browser path (e.g., 'drums', 'samples/Packs/Foo')
56
+ limit: maximum items returned (default 500, max 5000)
57
+ offset: number of items to skip (default 0)
58
+ filter_pattern: case-insensitive substring to filter item names by
59
+ (applied server-side when possible, client-side fallback)
60
+ """
44
61
  if not path.strip():
45
62
  raise ValueError("Path cannot be empty")
46
- return _get_ableton(ctx).send_command("get_browser_items", {"path": path})
63
+ if limit < 1:
64
+ raise ValueError("limit must be >= 1")
65
+ limit = min(limit, 5000)
66
+ if offset < 0:
67
+ raise ValueError("offset must be >= 0")
68
+ params: dict = {
69
+ "path": path,
70
+ "limit": limit,
71
+ "offset": offset,
72
+ }
73
+ if filter_pattern:
74
+ params["filter_pattern"] = filter_pattern
75
+ result = _get_ableton(ctx).send_command("get_browser_items", params)
76
+
77
+ # Client-side fallback: if the remote script's handler doesn't know about
78
+ # limit/offset/filter_pattern yet (older remote-script build), apply the
79
+ # paging + filter here so the MCP contract still works. Returned payload
80
+ # keeps `truncated`/`total_before_filter` for observability.
81
+ if isinstance(result, dict) and "items" in result:
82
+ items = result.get("items") or []
83
+ total_before = len(items)
84
+ if filter_pattern:
85
+ needle = filter_pattern.lower()
86
+ items = [i for i in items if needle in str(i.get("name", "")).lower()]
87
+ total_filtered = len(items)
88
+ paged = items[offset : offset + limit]
89
+ result["items"] = paged
90
+ result["total_before_filter"] = total_before
91
+ result["total_after_filter"] = total_filtered
92
+ result["returned"] = len(paged)
93
+ result["offset"] = offset
94
+ result["limit"] = limit
95
+ result["truncated"] = (offset + limit) < total_filtered
96
+ return result
47
97
 
48
98
 
49
99
  @mcp.tool()
@@ -54,14 +104,21 @@ def search_browser(
54
104
  loadable_only: bool = False,
55
105
  max_depth: int = 8,
56
106
  max_results: int = 100,
107
+ query: Optional[str] = None,
57
108
  ) -> dict:
58
109
  """Search the browser tree under a path, optionally filtering by name.
59
110
 
60
- path: top-level category to search under. Valid categories:
61
- instruments, audio_effects, midi_effects, sounds, drums,
62
- samples, packs, user_library, plugins, max_for_live, clips
63
- max_depth: how deep to recurse into subfolders (default 8)
64
- max_results: maximum number of results to return (default 100)
111
+ BUG-2026-04-22#4 fix `query` is now accepted as an alias for
112
+ `name_filter`, aligning this tool's schema with `search_samples`.
113
+ Callers passing either keyword work.
114
+
115
+ path: top-level category to search under. Valid categories:
116
+ instruments, audio_effects, midi_effects, sounds, drums,
117
+ samples, packs, user_library, plugins, max_for_live, clips
118
+ name_filter: case-insensitive substring filter on item name
119
+ query: alias for name_filter (accepts either)
120
+ max_depth: how deep to recurse into subfolders (default 8)
121
+ max_results: maximum number of results to return (default 100)
65
122
  """
66
123
  if not path.strip():
67
124
  raise ValueError("Path cannot be empty")
@@ -69,9 +126,10 @@ def search_browser(
69
126
  raise ValueError("max_depth must be >= 1")
70
127
  if max_results < 1:
71
128
  raise ValueError("max_results must be >= 1")
129
+ effective_filter = name_filter if name_filter is not None else query
72
130
  params: dict = {"path": path}
73
- if name_filter is not None:
74
- params["name_filter"] = name_filter
131
+ if effective_filter is not None:
132
+ params["name_filter"] = effective_filter
75
133
  if loadable_only:
76
134
  params["loadable_only"] = loadable_only
77
135
  if max_depth != 8:
@@ -81,13 +139,106 @@ def search_browser(
81
139
  return _get_ableton(ctx).send_command("search_browser", params)
82
140
 
83
141
 
142
+ # Role-aware Simpler defaults — BUG-2026-04-22 #17 + #18.
143
+ # Each role maps to a list of (parameter_name, value) pairs applied after
144
+ # load via set_device_parameter. Trigger Mode polarity per BUG #9:
145
+ # 0 = Trigger (one-shot), 1 = Gate (held). Volume in dB. Root in MIDI pitch.
146
+ _SIMPLER_ROLE_DEFAULTS = {
147
+ "drum": [
148
+ ("Snap", 0),
149
+ ("Volume", 0.0),
150
+ ("Trigger Mode", 0), # Trigger / one-shot
151
+ ("Sample Pitch Coarse", 36), # C1, matches drum-pad convention
152
+ ],
153
+ "melodic": [
154
+ ("Snap", 1),
155
+ ("Volume", 0.0),
156
+ ("Trigger Mode", 1), # Gate / held
157
+ ("Sample Pitch Coarse", 60), # C3
158
+ ],
159
+ "texture": [
160
+ ("Snap", 0),
161
+ ("Volume", -6.0),
162
+ ("Trigger Mode", 1), # Gate
163
+ ("Sample Pitch Coarse", 60), # C3
164
+ ],
165
+ }
166
+
167
+
84
168
  @mcp.tool()
85
- def load_browser_item(ctx: Context, track_index: int, uri: str) -> dict:
86
- """Load a browser item (instrument/effect) onto a track by URI."""
169
+ def load_browser_item(
170
+ ctx: Context,
171
+ track_index: int,
172
+ uri: str,
173
+ role: Optional[str] = None,
174
+ ) -> dict:
175
+ """Load a browser item (instrument/effect/sample) onto a track by URI.
176
+
177
+ URI grammar — see livepilot/skills/livepilot-devices/references/
178
+ load_browser_item-uri-grammar.md for the full reference. Three
179
+ known forms produced by search_browser /
180
+ get_browser_items / get_browser_tree:
181
+ - query:Drums#FileId_29738 (pack content)
182
+ - query:Synths#Operator (native device by name)
183
+ - query:UserLibrary#Samples:Splice:Filename.wav (path-style)
184
+ Always pass URIs verbatim from search results. Never construct them
185
+ by hand — guessed names match greedily and can load the wrong item.
186
+
187
+ Context-dependent behavior (BUG-2026-04-22 #16):
188
+ - Empty track: creates a Simpler with the sample loaded.
189
+ - Track with an instrument: drops the new device after the
190
+ existing one.
191
+ - Track with a Drum Rack: the FIRST call creates a chain on
192
+ note 36; subsequent calls REPLACE that chain instead of
193
+ appending to the next pad. Use add_drum_rack_pad for
194
+ pad-by-pad kit construction.
195
+
196
+ role (optional, BUG-2026-04-22 #17 + #18): apply role-aware Simpler
197
+ defaults after load. Skips silently if no Simpler was created (e.g.,
198
+ when loading a native synth or effect).
199
+ - "drum" : Snap=0, Vol=0dB, Trigger Mode=0 (Trigger), root=C1 (36)
200
+ - "melodic" : Snap=1, Vol=0dB, Trigger Mode=1 (Gate), root=C3 (60)
201
+ - "texture" : Snap=0, Vol=-6dB, Trigger Mode=1 (Gate), root=C3 (60)
202
+ Omit role to keep Live's raw defaults (Volume=-12dB, Snap=1).
203
+
204
+ NOTE on Trigger Mode polarity (BUG-2026-04-22 #9): the value is
205
+ REVERSED from intuition. Trigger Mode=0 means Trigger (one-shot,
206
+ drum-style), Trigger Mode=1 means Gate (held, melodic-style).
207
+ """
87
208
  _validate_track_index(track_index)
88
209
  if not uri.strip():
89
210
  raise ValueError("URI cannot be empty")
90
- return _get_ableton(ctx).send_command("load_browser_item", {
211
+ if role is not None and role not in _SIMPLER_ROLE_DEFAULTS:
212
+ raise ValueError(
213
+ f"role must be one of {sorted(_SIMPLER_ROLE_DEFAULTS)}, got {role!r}"
214
+ )
215
+
216
+ ableton = _get_ableton(ctx)
217
+ result = ableton.send_command("load_browser_item", {
91
218
  "track_index": track_index,
92
219
  "uri": uri,
93
220
  })
221
+
222
+ # Post-load: apply role-aware defaults if the loaded device is a Simpler.
223
+ if role and isinstance(result, dict) and not result.get("error"):
224
+ device_index = result.get("device_index")
225
+ device_class = str(result.get("class_name") or result.get("device_name") or "")
226
+ if device_index is not None and "Simpler" in device_class:
227
+ applied = []
228
+ for name, value in _SIMPLER_ROLE_DEFAULTS[role]:
229
+ try:
230
+ ableton.send_command("set_device_parameter", {
231
+ "track_index": track_index,
232
+ "device_index": int(device_index),
233
+ "parameter_name": name,
234
+ "value": value,
235
+ })
236
+ applied.append({"parameter": name, "value": value})
237
+ except Exception as exc:
238
+ # Don't fail the whole load if one default doesn't apply
239
+ # (parameter name might not exist on every Simpler variant).
240
+ applied.append({"parameter": name, "skipped": str(exc)})
241
+ result["role"] = role
242
+ result["role_defaults_applied"] = applied
243
+
244
+ return result
@@ -290,13 +290,17 @@ def set_device_parameter(
290
290
 
291
291
 
292
292
  def _normalize_batch_entry(entry: dict) -> dict:
293
- """Accept either the legacy 'name_or_index' shape or the aligned
294
- 'parameter_index' / 'parameter_name' shape used by set_device_parameter.
295
-
296
- BUG-F4: the sibling tools had inconsistent schemas. Callers writing
297
- code against set_device_parameter hit validation errors switching
298
- to batch_set_parameters. Now both shapes are accepted and
299
- normalized to the Remote Script's expected {name_or_index, value}.
293
+ """Accept legacy 'name_or_index', aligned 'parameter_index'/'parameter_name',
294
+ or the 'index'/'name' keys that `get_device_parameters` returns natively.
295
+
296
+ BUG-F4 + BUG-2026-04-22#3: the sibling tools had inconsistent schemas.
297
+ Callers writing code against set_device_parameter hit validation errors
298
+ switching to batch_set_parameters. The 2026-04-22 bug report flagged
299
+ that `get_device_parameters` returns entries with `"index": N` but
300
+ `batch_set_parameters` rejected that key — forcing callers to rename
301
+ it. We now accept every shape and normalize to the Remote Script's
302
+ expected `{name_or_index, value}` so `get_device_parameters`'s output
303
+ can be fed straight back in.
300
304
  """
301
305
  if "value" not in entry:
302
306
  raise ValueError("Each parameter entry must include 'value'")
@@ -304,25 +308,35 @@ def _normalize_batch_entry(entry: dict) -> dict:
304
308
  has_legacy = "name_or_index" in entry
305
309
  has_index = "parameter_index" in entry
306
310
  has_name = "parameter_name" in entry
311
+ # BUG-2026-04-22#3 aliases
312
+ has_short_index = "index" in entry
313
+ has_short_name = "name" in entry
307
314
 
308
- specified = sum([has_legacy, has_index, has_name])
315
+ specified = sum([
316
+ has_legacy, has_index, has_name, has_short_index, has_short_name,
317
+ ])
309
318
  if specified == 0:
310
319
  raise ValueError(
311
320
  "Each parameter entry must include exactly one of: "
312
- "parameter_name, parameter_index, or name_or_index (legacy)"
321
+ "parameter_name, parameter_index, name, index, or name_or_index"
313
322
  )
314
323
  if specified > 1:
315
324
  raise ValueError(
316
325
  "Each parameter entry must include exactly one of "
317
- "parameter_name, parameter_index, or name_or_index — not multiple"
326
+ "parameter_name, parameter_index, name, index, or name_or_index "
327
+ "— not multiple"
318
328
  )
319
329
 
320
330
  if has_legacy:
321
331
  key = entry["name_or_index"]
322
332
  elif has_index:
323
333
  key = entry["parameter_index"]
324
- else:
334
+ elif has_name:
325
335
  key = entry["parameter_name"]
336
+ elif has_short_index:
337
+ key = entry["index"]
338
+ else:
339
+ key = entry["name"]
326
340
 
327
341
  # BUG-audit-H3: match set_device_parameter's validation so negative
328
342
  # indices are rejected at the MCP layer rather than leaking through to
@@ -543,6 +557,37 @@ def insert_rack_chain(
543
557
  })
544
558
 
545
559
 
560
+ @mcp.tool()
561
+ def rename_chain(
562
+ ctx: Context,
563
+ track_index: int,
564
+ device_index: int,
565
+ chain_index: int,
566
+ name: str,
567
+ ) -> dict:
568
+ """Rename a chain inside any Rack device — Instrument, Audio Effect, or Drum (Live 12.3+).
569
+
570
+ Works with Drum Racks (the primary use case — naming pads "Kick", "Snare",
571
+ "Clap", etc.) as well as Instrument/Audio Effect Racks.
572
+
573
+ track_index: track containing the rack
574
+ device_index: rack device index on the track
575
+ chain_index: 0-based chain to rename
576
+ name: new chain name (non-empty; Live may truncate)
577
+ """
578
+ _validate_track_index(track_index)
579
+ _validate_device_index(device_index)
580
+ _validate_chain_index(chain_index)
581
+ if not name or not name.strip():
582
+ raise ValueError("name cannot be empty")
583
+ return _get_ableton(ctx).send_command("set_chain_name", {
584
+ "track_index": track_index,
585
+ "device_index": device_index,
586
+ "chain_index": chain_index,
587
+ "name": name.strip(),
588
+ })
589
+
590
+
546
591
  @mcp.tool()
547
592
  def set_drum_chain_note(
548
593
  ctx: Context,
@@ -5,6 +5,7 @@
5
5
 
6
6
  from __future__ import annotations
7
7
 
8
+ import asyncio
8
9
  from typing import Optional
9
10
 
10
11
  from fastmcp import Context
@@ -88,27 +89,31 @@ def set_master_volume(ctx: Context, volume: float) -> dict:
88
89
 
89
90
 
90
91
  @mcp.tool()
91
- def get_track_meters(
92
+ async def get_track_meters(
92
93
  ctx: Context,
93
94
  track_index: Optional[int] = None,
94
95
  include_stereo: bool = False,
96
+ samples: int = 1,
97
+ sample_interval_ms: int = 50,
95
98
  ) -> dict:
96
99
  """Read real-time output meter levels for tracks.
97
100
 
98
101
  Returns peak level (0.0-1.0) for each track. Call while playing to
99
102
  check levels, detect clipping, or verify a track is producing sound.
100
103
 
101
- track_index: specific track (omit for all tracks)
102
- include_stereo: include left/right channel meters (adds GUI load)
103
-
104
- BUG-B3: when playback is stopped, `level` reports peak-hold from the
105
- last loud moment while `left`/`right` report instantaneous channel
106
- levels (which decay to 0). The two fields then visibly disagree, and
107
- callers debugging "is my filter killing the signal?" get false alarms.
108
- We now tag each response with `is_playing` so callers can interpret
109
- the levels correctly, and — when include_stereo=True AND playback is
110
- stopped we mark left/right as `null` instead of 0 so the semantic
111
- is explicit.
104
+ track_index: specific track (omit for all tracks)
105
+ include_stereo: include left/right channel meters (adds GUI load)
106
+ samples: number of snapshots to take (default 1). When > 1,
107
+ returns peak-over-window for `level`/`left`/`right`
108
+ (BUG-2026-04-22#7 fix single reads are unreliable
109
+ because Live samples `level` and `left/right` at
110
+ slightly different moments and they can disagree).
111
+ sample_interval_ms: ms between snapshots when samples > 1 (default 50).
112
+
113
+ BUG-B3 (still active): when playback is stopped, `level` reports
114
+ peak-hold from the last loud moment while `left`/`right` report
115
+ instantaneous channel levels (decay to 0). We tag responses with
116
+ `is_playing`; when stopped + stereo requested, left/right → null.
112
117
  """
113
118
  params: dict = {}
114
119
  if track_index is not None:
@@ -116,7 +121,53 @@ def get_track_meters(
116
121
  if include_stereo:
117
122
  params["include_stereo"] = include_stereo
118
123
  ableton = _get_ableton(ctx)
119
- result = ableton.send_command("get_track_meters", params)
124
+
125
+ # Multi-sample path for BUG-2026-04-22#7 — take N snapshots and return
126
+ # the max per track per channel. Mathematical-impossibility cases
127
+ # (level>0 but left=right=0) are resolved by sampling over time.
128
+ if samples and samples > 1:
129
+ samples = min(samples, 20) # hard cap
130
+ interval = max(0, sample_interval_ms) / 1000.0
131
+ snapshots: list[dict] = []
132
+ for i in range(samples):
133
+ snap = ableton.send_command("get_track_meters", params)
134
+ if isinstance(snap, dict):
135
+ snapshots.append(snap)
136
+ if i < samples - 1 and interval > 0:
137
+ await asyncio.sleep(interval)
138
+ if not snapshots:
139
+ return {"error": "No meter snapshots collected"}
140
+ # Take the first snapshot's structure and peak-combine across all.
141
+ result = dict(snapshots[0])
142
+ # Merge tracks field with peak-maxing
143
+ if "tracks" in result:
144
+ merged = {}
145
+ for snap in snapshots:
146
+ for t in snap.get("tracks", []):
147
+ tid = t.get("index")
148
+ if tid is None:
149
+ continue
150
+ if tid not in merged:
151
+ merged[tid] = dict(t)
152
+ else:
153
+ for fld in ("level", "left", "right"):
154
+ cur = merged[tid].get(fld) or 0
155
+ new = t.get(fld) or 0
156
+ if new > cur:
157
+ merged[tid][fld] = new
158
+ result["tracks"] = list(merged.values())
159
+ elif include_stereo or track_index is not None:
160
+ # Single-track response shape
161
+ for fld in ("level", "left", "right"):
162
+ vals = [s.get(fld) for s in snapshots if s.get(fld) is not None]
163
+ if vals:
164
+ result[fld] = max(vals)
165
+ result["samples_collected"] = len(snapshots)
166
+ result["sample_interval_ms"] = sample_interval_ms
167
+ else:
168
+ result = ableton.send_command("get_track_meters", params)
169
+ if not isinstance(result, dict):
170
+ return result
120
171
 
121
172
  # Probe playback state once so we can annotate the response
122
173
  try:
@@ -125,8 +176,6 @@ def get_track_meters(
125
176
  except Exception:
126
177
  is_playing = None # unknown — leave left/right as reported
127
178
 
128
- if not isinstance(result, dict):
129
- return result
130
179
  result["is_playing"] = is_playing
131
180
  # When stopped AND stereo was requested, mark l/r as None so they
132
181
  # don't look like a killed signal
@@ -33,15 +33,27 @@ def get_song_scale(ctx: Context) -> dict:
33
33
 
34
34
 
35
35
  @mcp.tool()
36
- def set_song_scale(ctx: Context, root_note: int, scale_name: str) -> dict:
37
- """Set the Song-level Scale Mode root + scale name (Live 12.0+).
36
+ def set_song_scale(ctx: Context, root_note, scale_name: str) -> dict:
37
+ """Set the Song-level Scale Mode root + scale name (Live 12.0+, Live 12.4 compat).
38
38
 
39
- root_note: 0-11 (C=0, C#=1, ... B=11)
40
- scale_name: must match one of Live's built-in scale names.
39
+ root_note: int 0-11 (C=0, C#=1, ... B=11) OR note-name string like
40
+ "C#", "F", "Bb". Both are accepted — BUG-2026-04-22#2 fix.
41
+ scale_name: case-insensitive — matches Live's built-in scale names.
41
42
  Call list_available_scales() first if unsure.
43
+
44
+ Live 12.4 note: Ableton dropped `Song.scale_names` from the Python LOM,
45
+ which made this tool and list_available_scales raise an INTERNAL error.
46
+ The remote script now falls back to the documented built-in scale list
47
+ when the attribute is missing — so both tools work on 12.4+ again.
42
48
  """
43
- if not 0 <= root_note <= 11:
44
- raise ValueError("root_note must be 0-11")
49
+ if isinstance(root_note, str):
50
+ if not root_note.strip():
51
+ raise ValueError("root_note string cannot be empty")
52
+ elif isinstance(root_note, int):
53
+ if not 0 <= root_note <= 11:
54
+ raise ValueError("root_note must be 0-11 (int) or a note name (str)")
55
+ else:
56
+ raise ValueError("root_note must be an int 0-11 or a note-name string")
45
57
  if not scale_name.strip():
46
58
  raise ValueError("scale_name cannot be empty")
47
59
  return _get_ableton(ctx).send_command("set_song_scale", {
@@ -17,20 +17,30 @@ def _get_ableton(ctx: Context):
17
17
  return ctx.lifespan_context["ableton"]
18
18
 
19
19
 
20
- def _validate_track_index(track_index: int, allow_return: bool = True):
20
+ MASTER_TRACK_INDEX = -1000 # mirrors remote_script/LivePilot/utils.py
21
+
22
+
23
+ def _validate_track_index(track_index: int, allow_return: bool = True, allow_master: bool = True):
21
24
  """Validate track index.
22
25
 
23
- Regular tracks: >= 0. Return tracks: -1 (A), -2 (B), etc.
26
+ Regular tracks: >= 0. Return tracks: -1 (A), -2 (B), etc. Master: -1000.
24
27
  Set allow_return=False for operations that only work on regular tracks
25
28
  (e.g., create_scene, set_group_fold).
29
+ Set allow_master=False for operations that don't make sense on master
30
+ (e.g., delete_track, set_track_arm).
26
31
  """
32
+ if track_index == MASTER_TRACK_INDEX:
33
+ if not allow_master:
34
+ raise ValueError("track_index=-1000 (master) is not supported for this operation")
35
+ return
27
36
  if track_index < 0:
28
37
  if not allow_return:
29
38
  raise ValueError("track_index must be >= 0 (return tracks not supported for this operation)")
30
39
  if track_index < -99:
31
40
  raise ValueError(
32
41
  "track_index must be >= 0 for regular tracks, "
33
- "or -1..-99 for return tracks (-1=A, -2=B)"
42
+ "-1..-99 for return tracks (-1=A, -2=B), "
43
+ "or -1000 for master"
34
44
  )
35
45
 
36
46
 
@@ -41,11 +51,89 @@ def _validate_color_index(color_index: int):
41
51
 
42
52
  @mcp.tool()
43
53
  def get_track_info(ctx: Context, track_index: int) -> dict:
44
- """Get detailed info about a track: clips, devices, mixer state."""
54
+ """Get detailed info about a track: clips, devices, mixer state.
55
+
56
+ BUG-2026-04-22#11 FIX: track_index=-1000 (the master-track convention
57
+ used by set_track_volume, find_and_load_device, etc.) now dispatches
58
+ to the get_master_track endpoint instead of rejecting. This makes
59
+ -1000 work consistently across every track-addressing tool.
60
+ """
45
61
  _validate_track_index(track_index)
62
+ if track_index == MASTER_TRACK_INDEX:
63
+ return _get_ableton(ctx).send_command("get_master_track")
46
64
  return _get_ableton(ctx).send_command("get_track_info", {"track_index": track_index})
47
65
 
48
66
 
67
+ @mcp.tool()
68
+ def verify_device_alive(
69
+ ctx: Context,
70
+ track_index: int,
71
+ device_index: int,
72
+ ) -> dict:
73
+ """Check whether a loaded device is alive (BUG-2026-04-22 #19).
74
+
75
+ Static health check based on get_device_info — no test note required.
76
+ A device is considered DEAD when:
77
+ - class_name contains "PluginDevice" (AU/VST) AND parameter_count <= 1
78
+ (the shell loaded but the DSP engine crashed / wasn't activated)
79
+ - health_flags contains "opaque_or_failed_plugin"
80
+
81
+ Returns: {alive: bool, reason: str, parameter_count: int, class_name: str,
82
+ health_flags: list, recommendation: str | None}
83
+
84
+ The `recommendation` is a one-liner like "delete and load native
85
+ alternative" when the device is dead. None when alive.
86
+ """
87
+ _validate_track_index(track_index)
88
+ info = _get_ableton(ctx).send_command(
89
+ "get_device_info", {"track_index": track_index, "device_index": device_index},
90
+ )
91
+ if not isinstance(info, dict):
92
+ return {"alive": False, "reason": f"get_device_info returned non-dict: {info!r}"}
93
+
94
+ class_name = str(info.get("class_name", ""))
95
+ parameter_count = int(info.get("parameter_count", 0))
96
+ health_flags = list(info.get("health_flags", []))
97
+
98
+ if "PluginDevice" in class_name and parameter_count <= 1:
99
+ return {
100
+ "alive": False,
101
+ "reason": (
102
+ f"plugin_shell_no_dsp — class_name={class_name!r}, "
103
+ f"parameter_count={parameter_count}. The plugin host loaded "
104
+ f"the shell but the DSP engine did not activate (common "
105
+ f"after a crash or unauthorized AU/VST)."
106
+ ),
107
+ "parameter_count": parameter_count,
108
+ "class_name": class_name,
109
+ "health_flags": health_flags,
110
+ "recommendation": (
111
+ "Delete this device and load a native Ableton alternative "
112
+ "(Wavetable / Operator / Drift for synth, Reverb / Delay / "
113
+ "Compressor for FX)."
114
+ ),
115
+ }
116
+
117
+ if "opaque_or_failed_plugin" in health_flags:
118
+ return {
119
+ "alive": False,
120
+ "reason": "health_flags reports opaque_or_failed_plugin",
121
+ "parameter_count": parameter_count,
122
+ "class_name": class_name,
123
+ "health_flags": health_flags,
124
+ "recommendation": "Delete and replace with a native alternative.",
125
+ }
126
+
127
+ return {
128
+ "alive": True,
129
+ "reason": "passes static health checks",
130
+ "parameter_count": parameter_count,
131
+ "class_name": class_name,
132
+ "health_flags": health_flags,
133
+ "recommendation": None,
134
+ }
135
+
136
+
49
137
  @mcp.tool()
50
138
  def create_midi_track(
51
139
  ctx: Context,
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "livepilot",
3
- "version": "1.14.1",
3
+ "version": "1.16.0",
4
4
  "mcpName": "io.github.dreamrec/livepilot",
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)",
5
+ "description": "Agentic production system for Ableton Live 12 — 421 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.1"
8
+ __version__ = "1.16.0"
9
9
 
10
10
  from _Framework.ControlSurface import ControlSurface
11
11
  from . import router
@@ -26,6 +26,7 @@ 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