livepilot 1.4.4 → 1.5.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 +151 -136
- package/README.md +136 -61
- package/m4l_device/BUILD_GUIDE.md +161 -0
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/LivePilot_Analyzer.maxpat +680 -0
- package/m4l_device/livepilot_bridge.js +942 -0
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/connection.py +22 -16
- package/mcp_server/m4l_bridge.py +285 -0
- package/mcp_server/server.py +28 -3
- package/mcp_server/tools/analyzer.py +508 -0
- package/mcp_server/tools/clips.py +16 -12
- package/mcp_server/tools/devices.py +2 -2
- package/mcp_server/tools/mixing.py +50 -14
- package/mcp_server/tools/tracks.py +2 -2
- package/package.json +2 -3
- package/plugin/plugin.json +2 -2
- package/plugin/skills/livepilot-core/SKILL.md +52 -11
- package/plugin/skills/livepilot-core/references/overview.md +51 -5
- package/remote_script/LivePilot/__init__.py +1 -1
- package/remote_script/LivePilot/mixing.py +90 -1
- package/.mcp.json +0 -9
- package/plugin/.mcp.json +0 -8
- package/plugin/skills/livepilot-core/references/device-atlas/plugins-synths.md +0 -2012
- package/plugin/skills/livepilot-core/references/device-atlas/presets-by-vibe.md +0 -727
- package/plugin/skills/livepilot-core/references/device-atlas/samples-and-irs.md +0 -598
- package/plugin/skills/livepilot-core/references/device-atlas/synths-m4l.md +0 -730
- package/plugin/skills/livepilot-core/references/device-atlas/utility-and-workflow.md +0 -843
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
"""Analyzer MCP tools — real-time spectral analysis and deep LOM access.
|
|
2
|
+
|
|
3
|
+
20 tools requiring the LivePilot Analyzer M4L device on the master track.
|
|
4
|
+
These tools are optional — all 107 core tools work without the device.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from fastmcp import Context
|
|
10
|
+
|
|
11
|
+
from ..server import mcp
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _get_spectral(ctx: Context):
|
|
15
|
+
"""Get SpectralCache from lifespan context."""
|
|
16
|
+
cache = ctx.lifespan_context.get("spectral")
|
|
17
|
+
if not cache:
|
|
18
|
+
raise RuntimeError("Spectral cache not initialized")
|
|
19
|
+
return cache
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _get_m4l(ctx: Context):
|
|
23
|
+
"""Get M4LBridge from lifespan context."""
|
|
24
|
+
bridge = ctx.lifespan_context.get("m4l")
|
|
25
|
+
if not bridge:
|
|
26
|
+
raise RuntimeError("M4L bridge not initialized")
|
|
27
|
+
return bridge
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _require_analyzer(cache) -> None:
|
|
31
|
+
"""Raise a helpful error if the analyzer is not connected."""
|
|
32
|
+
if not cache.is_connected:
|
|
33
|
+
raise ValueError(
|
|
34
|
+
"LivePilot Analyzer not detected. "
|
|
35
|
+
"Drag 'LivePilot Analyzer' onto the master track from "
|
|
36
|
+
"Audio Effects > Max Audio Effect."
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@mcp.tool()
|
|
41
|
+
def get_master_spectrum(ctx: Context) -> dict:
|
|
42
|
+
"""Get 8-band frequency analysis of the master bus.
|
|
43
|
+
|
|
44
|
+
Returns band energies: sub (20-60Hz), low (60-200Hz), low_mid (200-500Hz),
|
|
45
|
+
mid (500-2kHz), high_mid (2-4kHz), high (4-8kHz), presence (8-12kHz),
|
|
46
|
+
air (12-20kHz). Values 0.0-1.0.
|
|
47
|
+
|
|
48
|
+
Also returns detected key/scale if enough audio has been analyzed.
|
|
49
|
+
Requires LivePilot Analyzer on master track.
|
|
50
|
+
"""
|
|
51
|
+
cache = _get_spectral(ctx)
|
|
52
|
+
_require_analyzer(cache)
|
|
53
|
+
|
|
54
|
+
result = {}
|
|
55
|
+
spectrum = cache.get("spectrum")
|
|
56
|
+
if spectrum:
|
|
57
|
+
result["bands"] = spectrum["value"]
|
|
58
|
+
result["age_ms"] = spectrum["age_ms"]
|
|
59
|
+
|
|
60
|
+
key_data = cache.get("key")
|
|
61
|
+
if key_data:
|
|
62
|
+
result["detected_key"] = key_data["value"]
|
|
63
|
+
|
|
64
|
+
return result
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@mcp.tool()
|
|
68
|
+
def get_master_rms(ctx: Context) -> dict:
|
|
69
|
+
"""Get real-time RMS and peak levels from the master bus.
|
|
70
|
+
|
|
71
|
+
More accurate than LOM meters — includes true RMS (not just peak hold).
|
|
72
|
+
Requires LivePilot Analyzer on master track.
|
|
73
|
+
"""
|
|
74
|
+
cache = _get_spectral(ctx)
|
|
75
|
+
_require_analyzer(cache)
|
|
76
|
+
|
|
77
|
+
result = {}
|
|
78
|
+
rms = cache.get("rms")
|
|
79
|
+
if rms:
|
|
80
|
+
result["rms"] = rms["value"]
|
|
81
|
+
result["age_ms"] = rms["age_ms"]
|
|
82
|
+
|
|
83
|
+
peak = cache.get("peak")
|
|
84
|
+
if peak:
|
|
85
|
+
result["peak"] = peak["value"]
|
|
86
|
+
|
|
87
|
+
pitch = cache.get("pitch")
|
|
88
|
+
if pitch:
|
|
89
|
+
result["pitch"] = pitch["value"]
|
|
90
|
+
|
|
91
|
+
return result
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@mcp.tool()
|
|
95
|
+
async def get_detected_key(ctx: Context) -> dict:
|
|
96
|
+
"""Get the detected musical key and scale of the current session.
|
|
97
|
+
|
|
98
|
+
Uses the Krumhansl-Schmuckler key-finding algorithm on accumulated
|
|
99
|
+
pitch data from the master bus. Needs 4-8 bars of audio to be reliable.
|
|
100
|
+
Returns key (C, C#, D, etc.), scale (major/minor), and confidence
|
|
101
|
+
(number of pitch samples collected).
|
|
102
|
+
Requires LivePilot Analyzer on master track.
|
|
103
|
+
"""
|
|
104
|
+
cache = _get_spectral(ctx)
|
|
105
|
+
_require_analyzer(cache)
|
|
106
|
+
|
|
107
|
+
# First check the streaming cache for a recent key detection
|
|
108
|
+
key_data = cache.get("key")
|
|
109
|
+
if key_data:
|
|
110
|
+
return key_data["value"]
|
|
111
|
+
|
|
112
|
+
# Fall back to querying the bridge directly (key detection runs in JS
|
|
113
|
+
# and may not be forwarded via OSC streaming)
|
|
114
|
+
bridge = _get_m4l(ctx)
|
|
115
|
+
result = await bridge.send_command("get_key")
|
|
116
|
+
if "error" in result:
|
|
117
|
+
return result
|
|
118
|
+
if not result.get("key"):
|
|
119
|
+
return {"error": "Not enough audio analyzed yet. Play 4-8 bars for key detection."}
|
|
120
|
+
return result
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@mcp.tool()
|
|
124
|
+
async def get_hidden_parameters(
|
|
125
|
+
ctx: Context,
|
|
126
|
+
track_index: int,
|
|
127
|
+
device_index: int,
|
|
128
|
+
) -> dict:
|
|
129
|
+
"""Get ALL parameters for a device, including hidden ones not accessible
|
|
130
|
+
via the standard ControlSurface API.
|
|
131
|
+
|
|
132
|
+
Returns parameter name, value, min, max, default, automation state,
|
|
133
|
+
and value string for every parameter — even non-automatable ones.
|
|
134
|
+
Requires LivePilot Analyzer on master track.
|
|
135
|
+
"""
|
|
136
|
+
cache = _get_spectral(ctx)
|
|
137
|
+
_require_analyzer(cache)
|
|
138
|
+
bridge = _get_m4l(ctx)
|
|
139
|
+
return await bridge.send_command("get_hidden_params", track_index, device_index)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@mcp.tool()
|
|
143
|
+
async def get_automation_state(
|
|
144
|
+
ctx: Context,
|
|
145
|
+
track_index: int,
|
|
146
|
+
device_index: int,
|
|
147
|
+
) -> dict:
|
|
148
|
+
"""Get automation state for all parameters on a device.
|
|
149
|
+
|
|
150
|
+
Returns only parameters that HAVE automation:
|
|
151
|
+
- state 1 = automation active (envelope is playing)
|
|
152
|
+
- state 2 = automation overridden (user moved knob manually)
|
|
153
|
+
|
|
154
|
+
Use this before writing automation to avoid overwriting existing curves.
|
|
155
|
+
Requires LivePilot Analyzer on master track.
|
|
156
|
+
"""
|
|
157
|
+
cache = _get_spectral(ctx)
|
|
158
|
+
_require_analyzer(cache)
|
|
159
|
+
bridge = _get_m4l(ctx)
|
|
160
|
+
return await bridge.send_command("get_auto_state", track_index, device_index)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@mcp.tool()
|
|
164
|
+
async def walk_device_tree(
|
|
165
|
+
ctx: Context,
|
|
166
|
+
track_index: int,
|
|
167
|
+
device_index: int = 0,
|
|
168
|
+
) -> dict:
|
|
169
|
+
"""Walk the full device chain tree including nested racks, drum pads,
|
|
170
|
+
and grouped devices. Returns the complete hierarchy up to 6 levels deep.
|
|
171
|
+
|
|
172
|
+
Use this to see inside Instrument Racks, Effect Racks, and Drum Racks
|
|
173
|
+
that the standard get_device_info can't penetrate.
|
|
174
|
+
Requires LivePilot Analyzer on master track.
|
|
175
|
+
"""
|
|
176
|
+
cache = _get_spectral(ctx)
|
|
177
|
+
_require_analyzer(cache)
|
|
178
|
+
bridge = _get_m4l(ctx)
|
|
179
|
+
return await bridge.send_command("walk_rack", track_index, device_index)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ── Phase 2: Sample Operations ─────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@mcp.tool()
|
|
186
|
+
async def get_clip_file_path(
|
|
187
|
+
ctx: Context,
|
|
188
|
+
track_index: int,
|
|
189
|
+
clip_index: int,
|
|
190
|
+
) -> dict:
|
|
191
|
+
"""Get the audio file path of a clip on disk.
|
|
192
|
+
|
|
193
|
+
Returns the absolute path to the audio file, clip name, and length.
|
|
194
|
+
Only works on audio clips — MIDI clips have no file path.
|
|
195
|
+
Use this to get a path for replace_simpler_sample.
|
|
196
|
+
Requires LivePilot Analyzer on master track.
|
|
197
|
+
"""
|
|
198
|
+
cache = _get_spectral(ctx)
|
|
199
|
+
_require_analyzer(cache)
|
|
200
|
+
bridge = _get_m4l(ctx)
|
|
201
|
+
return await bridge.send_command("get_clip_file_path", track_index, clip_index)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@mcp.tool()
|
|
205
|
+
async def replace_simpler_sample(
|
|
206
|
+
ctx: Context,
|
|
207
|
+
track_index: int,
|
|
208
|
+
device_index: int,
|
|
209
|
+
file_path: str,
|
|
210
|
+
) -> dict:
|
|
211
|
+
"""Load an audio file into a Simpler device by absolute file path.
|
|
212
|
+
|
|
213
|
+
Replaces the currently loaded sample. The Simpler must already have
|
|
214
|
+
a sample loaded — this replaces it, it cannot load into an empty Simpler.
|
|
215
|
+
If the Simpler is empty (freshly created with no sample), load a sample
|
|
216
|
+
manually first or use find_and_load_device to load a preset that already
|
|
217
|
+
contains a sample.
|
|
218
|
+
|
|
219
|
+
Use get_clip_file_path to get the path of a resampled clip, then pass
|
|
220
|
+
it here to load it into Simpler for slicing.
|
|
221
|
+
Requires LivePilot Analyzer on master track.
|
|
222
|
+
"""
|
|
223
|
+
cache = _get_spectral(ctx)
|
|
224
|
+
_require_analyzer(cache)
|
|
225
|
+
bridge = _get_m4l(ctx)
|
|
226
|
+
result = await bridge.send_command(
|
|
227
|
+
"replace_simpler_sample", track_index, device_index, file_path
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
# Validate the response — the bridge may report success even when the
|
|
231
|
+
# sample silently failed to load (e.g., empty Simpler, bad path)
|
|
232
|
+
if "error" in result:
|
|
233
|
+
return result
|
|
234
|
+
if not result.get("sample_loaded"):
|
|
235
|
+
return {
|
|
236
|
+
"error": "Sample may not have loaded. Ensure the Simpler already "
|
|
237
|
+
"has a sample loaded — replace_sample silently fails on empty Simplers."
|
|
238
|
+
}
|
|
239
|
+
return result
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@mcp.tool()
|
|
243
|
+
async def load_sample_to_simpler(
|
|
244
|
+
ctx: Context,
|
|
245
|
+
track_index: int,
|
|
246
|
+
file_path: str,
|
|
247
|
+
device_index: int = 0,
|
|
248
|
+
) -> dict:
|
|
249
|
+
"""Load an audio file into a NEW Simpler device on a track.
|
|
250
|
+
|
|
251
|
+
This is the full workflow for programmatic sample loading:
|
|
252
|
+
1. Loads a dummy sample via the browser (creates Simpler with a sample)
|
|
253
|
+
2. Replaces the dummy with your audio file
|
|
254
|
+
3. Returns the Simpler ready for slicing/warping
|
|
255
|
+
|
|
256
|
+
Use this instead of replace_simpler_sample when the track has no Simpler
|
|
257
|
+
or the Simpler is empty. Works with any audio file path.
|
|
258
|
+
Requires LivePilot Analyzer on master track.
|
|
259
|
+
"""
|
|
260
|
+
cache = _get_spectral(ctx)
|
|
261
|
+
_require_analyzer(cache)
|
|
262
|
+
bridge = _get_m4l(ctx)
|
|
263
|
+
|
|
264
|
+
# Step 1: Load a sample from the browser to create Simpler with content
|
|
265
|
+
ableton = ctx.lifespan_context["ableton"]
|
|
266
|
+
search = ableton.send_command("search_browser", {
|
|
267
|
+
"path": "samples",
|
|
268
|
+
"name_filter": "kick",
|
|
269
|
+
"loadable_only": True,
|
|
270
|
+
"max_results": 1,
|
|
271
|
+
})
|
|
272
|
+
results = search.get("results", [])
|
|
273
|
+
if not results:
|
|
274
|
+
return {"error": "No samples found in browser to bootstrap Simpler"}
|
|
275
|
+
|
|
276
|
+
# Load the dummy sample — Ableton auto-creates Simpler
|
|
277
|
+
uri = results[0]["uri"]
|
|
278
|
+
ableton.send_command("load_browser_item", {
|
|
279
|
+
"track_index": track_index,
|
|
280
|
+
"uri": uri,
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
# Step 2: Replace with the desired sample via M4L bridge
|
|
284
|
+
result = await bridge.send_command(
|
|
285
|
+
"replace_simpler_sample", track_index, device_index, file_path
|
|
286
|
+
)
|
|
287
|
+
if "error" in result:
|
|
288
|
+
return result
|
|
289
|
+
if not result.get("sample_loaded"):
|
|
290
|
+
return {"error": "Sample replacement failed after bootstrap"}
|
|
291
|
+
|
|
292
|
+
result["method"] = "bootstrap_and_replace"
|
|
293
|
+
return result
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@mcp.tool()
|
|
297
|
+
async def get_simpler_slices(
|
|
298
|
+
ctx: Context,
|
|
299
|
+
track_index: int,
|
|
300
|
+
device_index: int = 0,
|
|
301
|
+
) -> dict:
|
|
302
|
+
"""Get slice point positions from a Simpler device.
|
|
303
|
+
|
|
304
|
+
Returns each slice's position in frames and seconds, plus sample metadata
|
|
305
|
+
(sample rate, length, playback mode). Use this to understand the rhythmic
|
|
306
|
+
structure of a sliced sample and program MIDI patterns targeting slices.
|
|
307
|
+
Requires LivePilot Analyzer on master track.
|
|
308
|
+
"""
|
|
309
|
+
cache = _get_spectral(ctx)
|
|
310
|
+
_require_analyzer(cache)
|
|
311
|
+
bridge = _get_m4l(ctx)
|
|
312
|
+
return await bridge.send_command("get_simpler_slices", track_index, device_index)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@mcp.tool()
|
|
316
|
+
async def crop_simpler(
|
|
317
|
+
ctx: Context,
|
|
318
|
+
track_index: int,
|
|
319
|
+
device_index: int = 0,
|
|
320
|
+
) -> dict:
|
|
321
|
+
"""Crop a Simpler's sample to the currently active region.
|
|
322
|
+
|
|
323
|
+
Destructive — removes audio outside the region. Use undo to revert.
|
|
324
|
+
Requires LivePilot Analyzer on master track.
|
|
325
|
+
"""
|
|
326
|
+
cache = _get_spectral(ctx)
|
|
327
|
+
_require_analyzer(cache)
|
|
328
|
+
bridge = _get_m4l(ctx)
|
|
329
|
+
return await bridge.send_command("crop_simpler", track_index, device_index)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
@mcp.tool()
|
|
333
|
+
async def reverse_simpler(
|
|
334
|
+
ctx: Context,
|
|
335
|
+
track_index: int,
|
|
336
|
+
device_index: int = 0,
|
|
337
|
+
) -> dict:
|
|
338
|
+
"""Reverse the sample loaded in a Simpler device.
|
|
339
|
+
|
|
340
|
+
Can be called again to un-reverse.
|
|
341
|
+
Requires LivePilot Analyzer on master track.
|
|
342
|
+
"""
|
|
343
|
+
cache = _get_spectral(ctx)
|
|
344
|
+
_require_analyzer(cache)
|
|
345
|
+
bridge = _get_m4l(ctx)
|
|
346
|
+
return await bridge.send_command("reverse_simpler", track_index, device_index)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
@mcp.tool()
|
|
350
|
+
async def warp_simpler(
|
|
351
|
+
ctx: Context,
|
|
352
|
+
track_index: int,
|
|
353
|
+
device_index: int = 0,
|
|
354
|
+
beats: int = 4,
|
|
355
|
+
) -> dict:
|
|
356
|
+
"""Warp a Simpler's sample to fit the specified number of beats.
|
|
357
|
+
|
|
358
|
+
The sample will time-stretch to match the project tempo at the given
|
|
359
|
+
beat count. E.g., beats=4 makes it exactly 1 bar at current tempo.
|
|
360
|
+
Requires LivePilot Analyzer on master track.
|
|
361
|
+
"""
|
|
362
|
+
cache = _get_spectral(ctx)
|
|
363
|
+
_require_analyzer(cache)
|
|
364
|
+
bridge = _get_m4l(ctx)
|
|
365
|
+
return await bridge.send_command("warp_simpler", track_index, device_index, beats)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
# ── Phase 2: Warp Markers ──────────────────────────────────────────────
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
@mcp.tool()
|
|
372
|
+
async def get_warp_markers(
|
|
373
|
+
ctx: Context,
|
|
374
|
+
track_index: int,
|
|
375
|
+
clip_index: int,
|
|
376
|
+
) -> dict:
|
|
377
|
+
"""Get all warp markers from an audio clip.
|
|
378
|
+
|
|
379
|
+
Returns each marker's beat_time (position in arrangement) and
|
|
380
|
+
sample_time (position in the original audio file). Use this to
|
|
381
|
+
understand timing, extract groove templates, or prepare for manipulation.
|
|
382
|
+
Only works on audio clips. Requires LivePilot Analyzer on master track.
|
|
383
|
+
"""
|
|
384
|
+
cache = _get_spectral(ctx)
|
|
385
|
+
_require_analyzer(cache)
|
|
386
|
+
bridge = _get_m4l(ctx)
|
|
387
|
+
return await bridge.send_command("get_warp_markers", track_index, clip_index)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
@mcp.tool()
|
|
391
|
+
async def add_warp_marker(
|
|
392
|
+
ctx: Context,
|
|
393
|
+
track_index: int,
|
|
394
|
+
clip_index: int,
|
|
395
|
+
beat_time: float,
|
|
396
|
+
) -> dict:
|
|
397
|
+
"""Add a warp marker to an audio clip at the specified beat position.
|
|
398
|
+
|
|
399
|
+
Warp markers pin audio to beats, enabling time-stretching of surrounding
|
|
400
|
+
regions. Add at downbeats to lock timing, then move them for tempo changes.
|
|
401
|
+
Only works on audio clips. Requires LivePilot Analyzer on master track.
|
|
402
|
+
"""
|
|
403
|
+
cache = _get_spectral(ctx)
|
|
404
|
+
_require_analyzer(cache)
|
|
405
|
+
bridge = _get_m4l(ctx)
|
|
406
|
+
return await bridge.send_command(
|
|
407
|
+
"add_warp_marker", track_index, clip_index, beat_time
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
@mcp.tool()
|
|
412
|
+
async def move_warp_marker(
|
|
413
|
+
ctx: Context,
|
|
414
|
+
track_index: int,
|
|
415
|
+
clip_index: int,
|
|
416
|
+
old_beat_time: float,
|
|
417
|
+
new_beat_time: float,
|
|
418
|
+
) -> dict:
|
|
419
|
+
"""Move a warp marker from one beat position to another.
|
|
420
|
+
|
|
421
|
+
Changes the tempo of the audio segment between this marker and its
|
|
422
|
+
neighbors. Moving later = slower, moving earlier = faster. Use for
|
|
423
|
+
tape-stop effects, tempo ramps, and groove manipulation.
|
|
424
|
+
Only works on audio clips. Requires LivePilot Analyzer on master track.
|
|
425
|
+
"""
|
|
426
|
+
cache = _get_spectral(ctx)
|
|
427
|
+
_require_analyzer(cache)
|
|
428
|
+
bridge = _get_m4l(ctx)
|
|
429
|
+
return await bridge.send_command(
|
|
430
|
+
"move_warp_marker", track_index, clip_index, old_beat_time, new_beat_time
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
@mcp.tool()
|
|
435
|
+
async def remove_warp_marker(
|
|
436
|
+
ctx: Context,
|
|
437
|
+
track_index: int,
|
|
438
|
+
clip_index: int,
|
|
439
|
+
beat_time: float,
|
|
440
|
+
) -> dict:
|
|
441
|
+
"""Remove a warp marker from an audio clip at the specified beat.
|
|
442
|
+
|
|
443
|
+
Only works on audio clips. Requires LivePilot Analyzer on master track.
|
|
444
|
+
"""
|
|
445
|
+
cache = _get_spectral(ctx)
|
|
446
|
+
_require_analyzer(cache)
|
|
447
|
+
bridge = _get_m4l(ctx)
|
|
448
|
+
return await bridge.send_command(
|
|
449
|
+
"remove_warp_marker", track_index, clip_index, beat_time
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
# ── Phase 2: Clip & Display ────────────────────────────────────────────
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
@mcp.tool()
|
|
457
|
+
async def scrub_clip(
|
|
458
|
+
ctx: Context,
|
|
459
|
+
track_index: int,
|
|
460
|
+
clip_index: int,
|
|
461
|
+
beat_time: float,
|
|
462
|
+
) -> dict:
|
|
463
|
+
"""Scrub/preview a clip at a specific beat position.
|
|
464
|
+
|
|
465
|
+
Plays audio from that position until stop_scrub is called. Use to
|
|
466
|
+
audition sections, preview slices, or find the right warp marker spot.
|
|
467
|
+
Requires LivePilot Analyzer on master track.
|
|
468
|
+
"""
|
|
469
|
+
cache = _get_spectral(ctx)
|
|
470
|
+
_require_analyzer(cache)
|
|
471
|
+
bridge = _get_m4l(ctx)
|
|
472
|
+
return await bridge.send_command(
|
|
473
|
+
"scrub_clip", track_index, clip_index, beat_time
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
@mcp.tool()
|
|
478
|
+
async def stop_scrub(
|
|
479
|
+
ctx: Context,
|
|
480
|
+
track_index: int,
|
|
481
|
+
clip_index: int,
|
|
482
|
+
) -> dict:
|
|
483
|
+
"""Stop scrubbing a clip. Call after scrub_clip to stop preview.
|
|
484
|
+
|
|
485
|
+
Requires LivePilot Analyzer on master track.
|
|
486
|
+
"""
|
|
487
|
+
cache = _get_spectral(ctx)
|
|
488
|
+
_require_analyzer(cache)
|
|
489
|
+
bridge = _get_m4l(ctx)
|
|
490
|
+
return await bridge.send_command("stop_scrub", track_index, clip_index)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
@mcp.tool()
|
|
494
|
+
async def get_display_values(
|
|
495
|
+
ctx: Context,
|
|
496
|
+
track_index: int,
|
|
497
|
+
device_index: int,
|
|
498
|
+
) -> dict:
|
|
499
|
+
"""Get human-readable display values for all device parameters.
|
|
500
|
+
|
|
501
|
+
Returns the value as shown in Live's UI (e.g., '440 Hz', '-6.0 dB',
|
|
502
|
+
'50 %') instead of raw normalized floats. Skips irrelevant parameters.
|
|
503
|
+
Requires LivePilot Analyzer on master track.
|
|
504
|
+
"""
|
|
505
|
+
cache = _get_spectral(ctx)
|
|
506
|
+
_require_analyzer(cache)
|
|
507
|
+
bridge = _get_m4l(ctx)
|
|
508
|
+
return await bridge.send_command("get_display_values", track_index, device_index)
|
|
@@ -144,27 +144,31 @@ def set_clip_loop(
|
|
|
144
144
|
ctx: Context,
|
|
145
145
|
track_index: int,
|
|
146
146
|
clip_index: int,
|
|
147
|
-
enabled: bool,
|
|
148
|
-
|
|
149
|
-
|
|
147
|
+
enabled: Optional[bool] = None,
|
|
148
|
+
loop_start: Optional[float] = None,
|
|
149
|
+
loop_end: Optional[float] = None,
|
|
150
150
|
) -> dict:
|
|
151
|
-
"""Enable/disable clip looping and optionally set loop start/end (in beats).
|
|
151
|
+
"""Enable/disable clip looping and optionally set loop start/end (in beats).
|
|
152
|
+
All parameters are optional but at least one must be provided."""
|
|
152
153
|
_validate_track_index(track_index)
|
|
153
154
|
_validate_clip_index(clip_index)
|
|
155
|
+
if enabled is None and loop_start is None and loop_end is None:
|
|
156
|
+
raise ValueError("At least one of enabled, loop_start, or loop_end must be provided")
|
|
154
157
|
params = {
|
|
155
158
|
"track_index": track_index,
|
|
156
159
|
"clip_index": clip_index,
|
|
157
|
-
"enabled": enabled,
|
|
158
160
|
}
|
|
159
|
-
if
|
|
160
|
-
|
|
161
|
+
if enabled is not None:
|
|
162
|
+
params["enabled"] = enabled
|
|
163
|
+
if loop_start is not None:
|
|
164
|
+
if loop_start < 0:
|
|
161
165
|
raise ValueError("Loop start must be >= 0")
|
|
162
|
-
params["start"] =
|
|
163
|
-
if
|
|
164
|
-
if
|
|
166
|
+
params["start"] = loop_start
|
|
167
|
+
if loop_end is not None:
|
|
168
|
+
if loop_end <= 0:
|
|
165
169
|
raise ValueError("Loop end must be > 0")
|
|
166
|
-
params["end"] =
|
|
167
|
-
if
|
|
170
|
+
params["end"] = loop_end
|
|
171
|
+
if loop_start is not None and loop_end is not None and loop_start >= loop_end:
|
|
168
172
|
raise ValueError("Loop start must be less than loop end")
|
|
169
173
|
return _get_ableton(ctx).send_command("set_clip_loop", params)
|
|
170
174
|
|
|
@@ -106,9 +106,9 @@ def batch_set_parameters(
|
|
|
106
106
|
ctx: Context,
|
|
107
107
|
track_index: int,
|
|
108
108
|
device_index: int,
|
|
109
|
-
parameters:
|
|
109
|
+
parameters: Any,
|
|
110
110
|
) -> dict:
|
|
111
|
-
"""Set multiple device parameters in one call. parameters is a
|
|
111
|
+
"""Set multiple device parameters in one call. parameters is a JSON array of objects: [{"name_or_index": "Dry/Wet", "value": 0.5}, ...].
|
|
112
112
|
track_index: 0+ for regular tracks, -1/-2/... for return tracks (A/B/...), -1000 for master."""
|
|
113
113
|
_validate_track_index(track_index)
|
|
114
114
|
_validate_device_index(device_index)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
"""Mixing MCP tools — volume, pan, sends, routing, master.
|
|
1
|
+
"""Mixing MCP tools — volume, pan, sends, routing, master, metering.
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
11 tools matching the Remote Script mixing domain.
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
6
|
from __future__ import annotations
|
|
@@ -87,6 +87,42 @@ def set_master_volume(ctx: Context, volume: float) -> dict:
|
|
|
87
87
|
return _get_ableton(ctx).send_command("set_master_volume", {"volume": volume})
|
|
88
88
|
|
|
89
89
|
|
|
90
|
+
@mcp.tool()
|
|
91
|
+
def get_track_meters(
|
|
92
|
+
ctx: Context,
|
|
93
|
+
track_index: Optional[int] = None,
|
|
94
|
+
include_stereo: bool = False,
|
|
95
|
+
) -> dict:
|
|
96
|
+
"""Read real-time output meter levels for tracks.
|
|
97
|
+
|
|
98
|
+
Returns peak level (0.0-1.0) for each track. Call while playing to
|
|
99
|
+
check levels, detect clipping, or verify a track is producing sound.
|
|
100
|
+
|
|
101
|
+
track_index: specific track (omit for all tracks)
|
|
102
|
+
include_stereo: include left/right channel meters (adds GUI load)
|
|
103
|
+
"""
|
|
104
|
+
params: dict = {}
|
|
105
|
+
if track_index is not None:
|
|
106
|
+
params["track_index"] = track_index
|
|
107
|
+
if include_stereo:
|
|
108
|
+
params["include_stereo"] = include_stereo
|
|
109
|
+
return _get_ableton(ctx).send_command("get_track_meters", params)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@mcp.tool()
|
|
113
|
+
def get_master_meters(ctx: Context) -> dict:
|
|
114
|
+
"""Read real-time output meter levels for the master track (left, right, peak)."""
|
|
115
|
+
return _get_ableton(ctx).send_command("get_master_meters")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@mcp.tool()
|
|
119
|
+
def get_mix_snapshot(ctx: Context) -> dict:
|
|
120
|
+
"""Get a complete mix snapshot: all track meters, volumes, pans, mute/solo,
|
|
121
|
+
return tracks, and master levels. One call to assess the full mix state.
|
|
122
|
+
Call while playing for meaningful meter readings."""
|
|
123
|
+
return _get_ableton(ctx).send_command("get_mix_snapshot")
|
|
124
|
+
|
|
125
|
+
|
|
90
126
|
@mcp.tool()
|
|
91
127
|
def get_track_routing(ctx: Context, track_index: int) -> dict:
|
|
92
128
|
"""Get input/output routing info for a track. Use negative track_index for return tracks (-1=A, -2=B)."""
|
|
@@ -100,22 +136,22 @@ def get_track_routing(ctx: Context, track_index: int) -> dict:
|
|
|
100
136
|
def set_track_routing(
|
|
101
137
|
ctx: Context,
|
|
102
138
|
track_index: int,
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
139
|
+
input_routing_type: Optional[str] = None,
|
|
140
|
+
input_routing_channel: Optional[str] = None,
|
|
141
|
+
output_routing_type: Optional[str] = None,
|
|
142
|
+
output_routing_channel: Optional[str] = None,
|
|
107
143
|
) -> dict:
|
|
108
144
|
"""Set input/output routing for a track by display name. Use negative track_index for return tracks (-1=A, -2=B)."""
|
|
109
145
|
_validate_track_index(track_index)
|
|
110
146
|
params = {"track_index": track_index}
|
|
111
|
-
if
|
|
112
|
-
params["input_type"] =
|
|
113
|
-
if
|
|
114
|
-
params["input_channel"] =
|
|
115
|
-
if
|
|
116
|
-
params["output_type"] =
|
|
117
|
-
if
|
|
118
|
-
params["output_channel"] =
|
|
147
|
+
if input_routing_type is not None:
|
|
148
|
+
params["input_type"] = input_routing_type
|
|
149
|
+
if input_routing_channel is not None:
|
|
150
|
+
params["input_channel"] = input_routing_channel
|
|
151
|
+
if output_routing_type is not None:
|
|
152
|
+
params["output_type"] = output_routing_type
|
|
153
|
+
if output_routing_channel is not None:
|
|
154
|
+
params["output_channel"] = output_routing_channel
|
|
119
155
|
if len(params) == 1:
|
|
120
156
|
raise ValueError("At least one routing parameter must be provided")
|
|
121
157
|
return _get_ableton(ctx).send_command("set_track_routing", params)
|
|
@@ -127,12 +127,12 @@ def set_track_mute(ctx: Context, track_index: int, muted: bool) -> dict:
|
|
|
127
127
|
|
|
128
128
|
|
|
129
129
|
@mcp.tool()
|
|
130
|
-
def set_track_solo(ctx: Context, track_index: int,
|
|
130
|
+
def set_track_solo(ctx: Context, track_index: int, solo: bool) -> dict:
|
|
131
131
|
"""Solo or unsolo a track."""
|
|
132
132
|
_validate_track_index(track_index)
|
|
133
133
|
return _get_ableton(ctx).send_command("set_track_solo", {
|
|
134
134
|
"track_index": track_index,
|
|
135
|
-
"solo":
|
|
135
|
+
"solo": solo,
|
|
136
136
|
})
|
|
137
137
|
|
|
138
138
|
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "livepilot",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"mcpName": "io.github.dreamrec/livepilot",
|
|
5
|
-
"description": "AI copilot for Ableton Live 12 —
|
|
5
|
+
"description": "AI copilot for Ableton Live 12 — 127 tools, device atlas (280+ devices), real-time audio analysis, and technique memory",
|
|
6
6
|
"author": "Pilot Studio",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"type": "commonjs",
|
|
@@ -27,7 +27,6 @@
|
|
|
27
27
|
"midi",
|
|
28
28
|
"daw",
|
|
29
29
|
"ai",
|
|
30
|
-
"claude",
|
|
31
30
|
"sound-design",
|
|
32
31
|
"mixing",
|
|
33
32
|
"arrangement"
|