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.
- package/CHANGELOG.md +176 -1
- package/README.md +6 -6
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/device_atlas.json +91219 -7161
- package/mcp_server/atlas/tools.py +30 -2
- package/mcp_server/runtime/live_version.py +4 -2
- package/mcp_server/runtime/remote_commands.py +5 -0
- package/mcp_server/sample_engine/tools.py +692 -60
- package/mcp_server/splice_client/client.py +511 -65
- package/mcp_server/splice_client/http_bridge.py +361 -0
- package/mcp_server/splice_client/models.py +266 -2
- package/mcp_server/splice_client/quota.py +229 -0
- package/mcp_server/tools/_analyzer_engine/__init__.py +4 -0
- package/mcp_server/tools/_analyzer_engine/sample.py +73 -0
- package/mcp_server/tools/analyzer.py +666 -6
- package/mcp_server/tools/browser.py +164 -13
- package/mcp_server/tools/devices.py +56 -11
- package/mcp_server/tools/mixing.py +64 -15
- package/mcp_server/tools/scales.py +18 -6
- package/mcp_server/tools/tracks.py +92 -4
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +2 -1
- package/remote_script/LivePilot/_clip_helpers.py +86 -0
- package/remote_script/LivePilot/_drum_helpers.py +40 -0
- package/remote_script/LivePilot/_scale_helpers.py +87 -0
- package/remote_script/LivePilot/arrangement.py +44 -15
- package/remote_script/LivePilot/clips.py +182 -2
- package/remote_script/LivePilot/devices.py +82 -2
- package/remote_script/LivePilot/notes.py +17 -2
- package/remote_script/LivePilot/scales.py +31 -16
- package/remote_script/LivePilot/simpler_sample.py +186 -0
- 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(
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
74
|
-
params["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(
|
|
86
|
-
|
|
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
|
-
|
|
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
|
|
294
|
-
'
|
|
295
|
-
|
|
296
|
-
BUG-F4: the sibling tools had inconsistent schemas.
|
|
297
|
-
code against set_device_parameter hit validation errors
|
|
298
|
-
to batch_set_parameters.
|
|
299
|
-
|
|
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([
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
102
|
-
include_stereo:
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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.
|
|
3
|
+
"version": "1.16.0",
|
|
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 — 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.
|
|
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
|
|