livepilot 1.1.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/.claude/settings.local.json +10 -0
- package/.mcpregistry_github_token +1 -0
- package/.mcpregistry_registry_token +1 -0
- package/.playwright-mcp/console-2026-03-17T15-47-29-021Z.log +10 -0
- package/.playwright-mcp/console-2026-03-17T15-51-09-247Z.log +10 -0
- package/.playwright-mcp/console-2026-03-17T15-52-22-831Z.log +12 -0
- package/.playwright-mcp/console-2026-03-17T15-52-29-709Z.log +10 -0
- package/.playwright-mcp/console-2026-03-17T15-53-20-147Z.log +1 -0
- package/.playwright-mcp/glama-snapshot.md +2140 -0
- package/.playwright-mcp/page-2026-03-17T15-49-02-625Z.png +0 -0
- package/.playwright-mcp/page-2026-03-17T15-52-15-149Z.png +0 -0
- package/.playwright-mcp/page-2026-03-17T15-52-57-333Z.png +0 -0
- package/CHANGELOG.md +33 -0
- package/LICENSE +21 -0
- package/README.md +296 -0
- package/bin/livepilot.js +376 -0
- package/installer/install.js +95 -0
- package/installer/paths.js +79 -0
- package/mcp_server/__init__.py +2 -0
- package/mcp_server/__main__.py +5 -0
- package/mcp_server/connection.py +207 -0
- package/mcp_server/server.py +40 -0
- package/mcp_server/tools/__init__.py +1 -0
- package/mcp_server/tools/arrangement.py +399 -0
- package/mcp_server/tools/browser.py +78 -0
- package/mcp_server/tools/clips.py +187 -0
- package/mcp_server/tools/devices.py +238 -0
- package/mcp_server/tools/mixing.py +113 -0
- package/mcp_server/tools/notes.py +266 -0
- package/mcp_server/tools/scenes.py +63 -0
- package/mcp_server/tools/tracks.py +148 -0
- package/mcp_server/tools/transport.py +113 -0
- package/package.json +38 -0
- package/plugin/.mcp.json +8 -0
- package/plugin/agents/livepilot-producer/AGENT.md +61 -0
- package/plugin/commands/beat.md +18 -0
- package/plugin/commands/mix.md +15 -0
- package/plugin/commands/session.md +13 -0
- package/plugin/commands/sounddesign.md +16 -0
- package/plugin/plugin.json +18 -0
- package/plugin/skills/livepilot-core/SKILL.md +160 -0
- package/plugin/skills/livepilot-core/references/ableton-workflow-patterns.md +831 -0
- package/plugin/skills/livepilot-core/references/m4l-devices.md +352 -0
- package/plugin/skills/livepilot-core/references/midi-recipes.md +402 -0
- package/plugin/skills/livepilot-core/references/mixing-patterns.md +578 -0
- package/plugin/skills/livepilot-core/references/overview.md +191 -0
- package/plugin/skills/livepilot-core/references/sound-design.md +392 -0
- package/remote_script/LivePilot/__init__.py +42 -0
- package/remote_script/LivePilot/arrangement.py +678 -0
- package/remote_script/LivePilot/browser.py +325 -0
- package/remote_script/LivePilot/clips.py +172 -0
- package/remote_script/LivePilot/devices.py +466 -0
- package/remote_script/LivePilot/diagnostics.py +198 -0
- package/remote_script/LivePilot/mixing.py +194 -0
- package/remote_script/LivePilot/notes.py +339 -0
- package/remote_script/LivePilot/router.py +74 -0
- package/remote_script/LivePilot/scenes.py +75 -0
- package/remote_script/LivePilot/server.py +286 -0
- package/remote_script/LivePilot/tracks.py +229 -0
- package/remote_script/LivePilot/transport.py +147 -0
- package/remote_script/LivePilot/utils.py +112 -0
- package/requirements.txt +2 -0
- package/server.json +20 -0
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LivePilot - Device domain handlers (11 commands).
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import Live
|
|
6
|
+
|
|
7
|
+
from .router import register
|
|
8
|
+
from .utils import get_track, get_device
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _get_browser():
|
|
12
|
+
"""Get the browser from the Application object (not Song)."""
|
|
13
|
+
return Live.Application.get_application().browser
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@register("get_device_info")
|
|
17
|
+
def get_device_info(song, params):
|
|
18
|
+
"""Return detailed info for a single device."""
|
|
19
|
+
track_index = int(params["track_index"])
|
|
20
|
+
device_index = int(params["device_index"])
|
|
21
|
+
track = get_track(song, track_index)
|
|
22
|
+
device = get_device(track, device_index)
|
|
23
|
+
|
|
24
|
+
result = {
|
|
25
|
+
"name": device.name,
|
|
26
|
+
"class_name": device.class_name,
|
|
27
|
+
"is_active": device.is_active,
|
|
28
|
+
"can_have_chains": device.can_have_chains,
|
|
29
|
+
"parameter_count": len(list(device.parameters)),
|
|
30
|
+
}
|
|
31
|
+
try:
|
|
32
|
+
result["type"] = device.type
|
|
33
|
+
except Exception:
|
|
34
|
+
result["type"] = None
|
|
35
|
+
return result
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@register("get_device_parameters")
|
|
39
|
+
def get_device_parameters(song, params):
|
|
40
|
+
"""Return all parameters for a device."""
|
|
41
|
+
track_index = int(params["track_index"])
|
|
42
|
+
device_index = int(params["device_index"])
|
|
43
|
+
track = get_track(song, track_index)
|
|
44
|
+
device = get_device(track, device_index)
|
|
45
|
+
|
|
46
|
+
parameters = []
|
|
47
|
+
for i, param in enumerate(device.parameters):
|
|
48
|
+
parameters.append({
|
|
49
|
+
"index": i,
|
|
50
|
+
"name": param.name,
|
|
51
|
+
"value": param.value,
|
|
52
|
+
"min": param.min,
|
|
53
|
+
"max": param.max,
|
|
54
|
+
"is_quantized": param.is_quantized,
|
|
55
|
+
"value_string": param.str_for_value(param.value),
|
|
56
|
+
})
|
|
57
|
+
return {"parameters": parameters}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@register("set_device_parameter")
|
|
61
|
+
def set_device_parameter(song, params):
|
|
62
|
+
"""Set a single device parameter by name or index."""
|
|
63
|
+
track_index = int(params["track_index"])
|
|
64
|
+
device_index = int(params["device_index"])
|
|
65
|
+
value = float(params["value"])
|
|
66
|
+
track = get_track(song, track_index)
|
|
67
|
+
device = get_device(track, device_index)
|
|
68
|
+
|
|
69
|
+
parameter_name = params.get("parameter_name", None)
|
|
70
|
+
parameter_index = params.get("parameter_index", None)
|
|
71
|
+
|
|
72
|
+
if parameter_name is not None:
|
|
73
|
+
param = None
|
|
74
|
+
for p in device.parameters:
|
|
75
|
+
if p.name == parameter_name:
|
|
76
|
+
param = p
|
|
77
|
+
break
|
|
78
|
+
if param is None:
|
|
79
|
+
raise ValueError(
|
|
80
|
+
"Parameter '%s' not found on device '%s'"
|
|
81
|
+
% (parameter_name, device.name)
|
|
82
|
+
)
|
|
83
|
+
elif parameter_index is not None:
|
|
84
|
+
parameter_index = int(parameter_index)
|
|
85
|
+
dev_params = list(device.parameters)
|
|
86
|
+
if parameter_index < 0 or parameter_index >= len(dev_params):
|
|
87
|
+
raise IndexError(
|
|
88
|
+
"Parameter index %d out of range (0..%d)"
|
|
89
|
+
% (parameter_index, len(dev_params) - 1)
|
|
90
|
+
)
|
|
91
|
+
param = dev_params[parameter_index]
|
|
92
|
+
else:
|
|
93
|
+
raise ValueError("Must provide parameter_name or parameter_index")
|
|
94
|
+
|
|
95
|
+
param.value = value
|
|
96
|
+
return {"name": param.name, "value": param.value}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@register("batch_set_parameters")
|
|
100
|
+
def batch_set_parameters(song, params):
|
|
101
|
+
"""Set multiple device parameters in one call."""
|
|
102
|
+
track_index = int(params["track_index"])
|
|
103
|
+
device_index = int(params["device_index"])
|
|
104
|
+
parameters = params["parameters"]
|
|
105
|
+
track = get_track(song, track_index)
|
|
106
|
+
device = get_device(track, device_index)
|
|
107
|
+
|
|
108
|
+
dev_params = list(device.parameters)
|
|
109
|
+
results = []
|
|
110
|
+
for entry in parameters:
|
|
111
|
+
value = float(entry["value"])
|
|
112
|
+
name_or_index = entry.get("name_or_index")
|
|
113
|
+
|
|
114
|
+
if isinstance(name_or_index, int) or (
|
|
115
|
+
isinstance(name_or_index, str) and name_or_index.isdigit()
|
|
116
|
+
):
|
|
117
|
+
idx = int(name_or_index)
|
|
118
|
+
if idx < 0 or idx >= len(dev_params):
|
|
119
|
+
raise IndexError(
|
|
120
|
+
"Parameter index %d out of range (0..%d)"
|
|
121
|
+
% (idx, len(dev_params) - 1)
|
|
122
|
+
)
|
|
123
|
+
param = dev_params[idx]
|
|
124
|
+
else:
|
|
125
|
+
param = None
|
|
126
|
+
for p in dev_params:
|
|
127
|
+
if p.name == name_or_index:
|
|
128
|
+
param = p
|
|
129
|
+
break
|
|
130
|
+
if param is None:
|
|
131
|
+
raise ValueError(
|
|
132
|
+
"Parameter '%s' not found on device '%s'"
|
|
133
|
+
% (name_or_index, device.name)
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
param.value = value
|
|
137
|
+
results.append({"name": param.name, "value": param.value})
|
|
138
|
+
|
|
139
|
+
return {"parameters": results}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@register("toggle_device")
|
|
143
|
+
def toggle_device(song, params):
|
|
144
|
+
"""Enable or disable a device."""
|
|
145
|
+
track_index = int(params["track_index"])
|
|
146
|
+
device_index = int(params["device_index"])
|
|
147
|
+
active = bool(params["active"])
|
|
148
|
+
track = get_track(song, track_index)
|
|
149
|
+
device = get_device(track, device_index)
|
|
150
|
+
|
|
151
|
+
# Find the "Device On" parameter by name (safer than assuming index 0)
|
|
152
|
+
on_param = None
|
|
153
|
+
for p in device.parameters:
|
|
154
|
+
if p.name == "Device On":
|
|
155
|
+
on_param = p
|
|
156
|
+
break
|
|
157
|
+
if on_param is None:
|
|
158
|
+
# Fallback to parameter 0 for devices that don't use "Device On"
|
|
159
|
+
on_param = device.parameters[0]
|
|
160
|
+
|
|
161
|
+
on_param.value = 1.0 if active else 0.0
|
|
162
|
+
return {"name": device.name, "is_active": on_param.value > 0.5}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@register("delete_device")
|
|
166
|
+
def delete_device(song, params):
|
|
167
|
+
"""Delete a device from a track."""
|
|
168
|
+
track_index = int(params["track_index"])
|
|
169
|
+
device_index = int(params["device_index"])
|
|
170
|
+
track = get_track(song, track_index)
|
|
171
|
+
# Validate device exists
|
|
172
|
+
get_device(track, device_index)
|
|
173
|
+
track.delete_device(device_index)
|
|
174
|
+
return {"deleted": device_index}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@register("load_device_by_uri")
|
|
178
|
+
def load_device_by_uri(song, params):
|
|
179
|
+
"""Load a device onto a track using a browser URI.
|
|
180
|
+
|
|
181
|
+
First tries URI-based matching (exact child.uri comparison).
|
|
182
|
+
Falls back to name extraction from the URI's last path segment.
|
|
183
|
+
Searches all browser categories including user_library and samples.
|
|
184
|
+
"""
|
|
185
|
+
track_index = int(params["track_index"])
|
|
186
|
+
uri = str(params["uri"])
|
|
187
|
+
track = get_track(song, track_index)
|
|
188
|
+
browser = _get_browser()
|
|
189
|
+
|
|
190
|
+
# All categories to search
|
|
191
|
+
category_attrs = (
|
|
192
|
+
"user_library", "samples", "instruments", "audio_effects",
|
|
193
|
+
"midi_effects", "packs", "sounds", "drums",
|
|
194
|
+
)
|
|
195
|
+
categories = []
|
|
196
|
+
for attr in category_attrs:
|
|
197
|
+
try:
|
|
198
|
+
categories.append(getattr(browser, attr))
|
|
199
|
+
except Exception:
|
|
200
|
+
pass
|
|
201
|
+
|
|
202
|
+
_iterations = [0]
|
|
203
|
+
MAX_ITERATIONS = 10000
|
|
204
|
+
|
|
205
|
+
# ── Strategy 1: match by URI directly ────────────────────────────
|
|
206
|
+
def find_by_uri(parent, target_uri, depth=0):
|
|
207
|
+
if depth > 8 or _iterations[0] > MAX_ITERATIONS:
|
|
208
|
+
return None
|
|
209
|
+
try:
|
|
210
|
+
children = list(parent.children)
|
|
211
|
+
except Exception:
|
|
212
|
+
return None
|
|
213
|
+
for child in children:
|
|
214
|
+
_iterations[0] += 1
|
|
215
|
+
if _iterations[0] > MAX_ITERATIONS:
|
|
216
|
+
return None
|
|
217
|
+
try:
|
|
218
|
+
if child.uri == target_uri and child.is_loadable:
|
|
219
|
+
return child
|
|
220
|
+
except Exception:
|
|
221
|
+
pass
|
|
222
|
+
result = find_by_uri(child, target_uri, depth + 1)
|
|
223
|
+
if result is not None:
|
|
224
|
+
return result
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
for category in categories:
|
|
228
|
+
_iterations[0] = 0
|
|
229
|
+
found = find_by_uri(category, uri)
|
|
230
|
+
if found is not None:
|
|
231
|
+
song.view.selected_track = track
|
|
232
|
+
browser.load_item(found)
|
|
233
|
+
return {"loaded": found.name, "track_index": track_index}
|
|
234
|
+
|
|
235
|
+
# ── Strategy 2: extract name from URI, search by name ────────────
|
|
236
|
+
device_name = uri
|
|
237
|
+
if "#" in uri:
|
|
238
|
+
device_name = uri.split("#", 1)[1]
|
|
239
|
+
for sep in (":", "/"):
|
|
240
|
+
if sep in device_name:
|
|
241
|
+
device_name = device_name.rsplit(sep, 1)[1]
|
|
242
|
+
# URL-decode
|
|
243
|
+
try:
|
|
244
|
+
from urllib.parse import unquote
|
|
245
|
+
device_name = unquote(device_name)
|
|
246
|
+
except ImportError:
|
|
247
|
+
device_name = device_name.replace("%20", " ")
|
|
248
|
+
# Strip file extensions
|
|
249
|
+
for ext in (".amxd", ".adv", ".adg", ".aupreset", ".als", ".wav", ".aif", ".aiff", ".mp3"):
|
|
250
|
+
if device_name.lower().endswith(ext):
|
|
251
|
+
device_name = device_name[:-len(ext)]
|
|
252
|
+
break
|
|
253
|
+
|
|
254
|
+
target = device_name.lower()
|
|
255
|
+
|
|
256
|
+
def find_by_name(parent, depth=0):
|
|
257
|
+
if depth > 8 or _iterations[0] > MAX_ITERATIONS:
|
|
258
|
+
return None
|
|
259
|
+
try:
|
|
260
|
+
children = list(parent.children)
|
|
261
|
+
except Exception:
|
|
262
|
+
return None
|
|
263
|
+
for child in children:
|
|
264
|
+
_iterations[0] += 1
|
|
265
|
+
if _iterations[0] > MAX_ITERATIONS:
|
|
266
|
+
return None
|
|
267
|
+
child_lower = child.name.lower()
|
|
268
|
+
if (child_lower == target or target in child_lower) and child.is_loadable:
|
|
269
|
+
return child
|
|
270
|
+
result = find_by_name(child, depth + 1)
|
|
271
|
+
if result is not None:
|
|
272
|
+
return result
|
|
273
|
+
return None
|
|
274
|
+
|
|
275
|
+
for category in categories:
|
|
276
|
+
_iterations[0] = 0
|
|
277
|
+
found = find_by_name(category)
|
|
278
|
+
if found is not None:
|
|
279
|
+
song.view.selected_track = track
|
|
280
|
+
browser.load_item(found)
|
|
281
|
+
return {"loaded": found.name, "track_index": track_index}
|
|
282
|
+
|
|
283
|
+
raise ValueError(
|
|
284
|
+
"Device '%s' not found in browser" % device_name
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@register("find_and_load_device")
|
|
289
|
+
def find_and_load_device(song, params):
|
|
290
|
+
"""Find a device by name in the browser and load it onto a track.
|
|
291
|
+
|
|
292
|
+
Searches all browser categories including user_library for M4L devices.
|
|
293
|
+
Supports partial matching: 'Kickster' matches 'trnr.Kickster'.
|
|
294
|
+
"""
|
|
295
|
+
track_index = int(params["track_index"])
|
|
296
|
+
device_name = str(params["device_name"]).lower()
|
|
297
|
+
track = get_track(song, track_index)
|
|
298
|
+
browser = _get_browser()
|
|
299
|
+
|
|
300
|
+
MAX_ITERATIONS = 10000
|
|
301
|
+
iterations = 0
|
|
302
|
+
|
|
303
|
+
def search_children(item, depth=0):
|
|
304
|
+
"""Recursively search browser children up to depth 8."""
|
|
305
|
+
nonlocal iterations
|
|
306
|
+
if depth > 8:
|
|
307
|
+
return None
|
|
308
|
+
try:
|
|
309
|
+
children = list(item.children)
|
|
310
|
+
except Exception:
|
|
311
|
+
return None
|
|
312
|
+
for child in children:
|
|
313
|
+
iterations += 1
|
|
314
|
+
if iterations > MAX_ITERATIONS:
|
|
315
|
+
return None
|
|
316
|
+
child_lower = child.name.lower()
|
|
317
|
+
# Exact match or partial match (e.g. "kickster" in "trnr.kickster")
|
|
318
|
+
if (child_lower == device_name or device_name in child_lower) and child.is_loadable:
|
|
319
|
+
return child
|
|
320
|
+
result = search_children(child, depth + 1)
|
|
321
|
+
if result is not None:
|
|
322
|
+
return result
|
|
323
|
+
return None
|
|
324
|
+
|
|
325
|
+
# Search ALL categories — user_library first (M4L devices live there)
|
|
326
|
+
category_attrs = (
|
|
327
|
+
"user_library", "samples", "instruments", "audio_effects",
|
|
328
|
+
"midi_effects", "packs", "sounds", "drums",
|
|
329
|
+
)
|
|
330
|
+
categories = []
|
|
331
|
+
for attr in category_attrs:
|
|
332
|
+
try:
|
|
333
|
+
categories.append(getattr(browser, attr))
|
|
334
|
+
except Exception:
|
|
335
|
+
pass
|
|
336
|
+
|
|
337
|
+
for category in categories:
|
|
338
|
+
iterations = 0 # Reset per category so each gets a fair search budget
|
|
339
|
+
found = search_children(category)
|
|
340
|
+
if found is not None:
|
|
341
|
+
song.view.selected_track = track
|
|
342
|
+
browser.load_item(found)
|
|
343
|
+
return {
|
|
344
|
+
"loaded": found.name,
|
|
345
|
+
"track_index": track_index,
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
raise ValueError(
|
|
349
|
+
"Device '%s' not found in browser. Check spelling or use "
|
|
350
|
+
"search_browser to find the exact name." % params["device_name"]
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
@register("set_simpler_playback_mode")
|
|
355
|
+
def set_simpler_playback_mode(song, params):
|
|
356
|
+
"""Set Simpler's playback mode (Classic/One-Shot/Slice).
|
|
357
|
+
|
|
358
|
+
playback_mode: 0=Classic, 1=One-Shot, 2=Slice
|
|
359
|
+
slice_by (optional, only for Slice mode): 0=Transient, 1=Beat, 2=Region, 3=Manual
|
|
360
|
+
sensitivity (optional, 0.0-1.0, only for Transient slicing)
|
|
361
|
+
"""
|
|
362
|
+
track_index = int(params["track_index"])
|
|
363
|
+
device_index = int(params["device_index"])
|
|
364
|
+
playback_mode = int(params["playback_mode"])
|
|
365
|
+
track = get_track(song, track_index)
|
|
366
|
+
device = get_device(track, device_index)
|
|
367
|
+
|
|
368
|
+
if device.class_name != "OriginalSimpler":
|
|
369
|
+
raise ValueError(
|
|
370
|
+
"Device '%s' is %s, not Simpler"
|
|
371
|
+
% (device.name, device.class_name)
|
|
372
|
+
)
|
|
373
|
+
if playback_mode not in (0, 1, 2):
|
|
374
|
+
raise ValueError("playback_mode must be 0 (Classic), 1 (One-Shot), or 2 (Slice)")
|
|
375
|
+
|
|
376
|
+
device.playback_mode = playback_mode
|
|
377
|
+
|
|
378
|
+
result = {
|
|
379
|
+
"track_index": track_index,
|
|
380
|
+
"device_index": device_index,
|
|
381
|
+
"playback_mode": playback_mode,
|
|
382
|
+
"mode_name": ["Classic", "One-Shot", "Slice"][playback_mode],
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
# Set slicing style if in Slice mode
|
|
386
|
+
if playback_mode == 2:
|
|
387
|
+
slice_by = params.get("slice_by", None)
|
|
388
|
+
if slice_by is not None:
|
|
389
|
+
slice_by = int(slice_by)
|
|
390
|
+
if slice_by not in (0, 1, 2, 3):
|
|
391
|
+
raise ValueError(
|
|
392
|
+
"slice_by must be 0 (Transient), 1 (Beat), 2 (Region), or 3 (Manual)"
|
|
393
|
+
)
|
|
394
|
+
device.slicing_style = slice_by
|
|
395
|
+
result["slice_by"] = slice_by
|
|
396
|
+
result["slice_by_name"] = ["Transient", "Beat", "Region", "Manual"][slice_by]
|
|
397
|
+
|
|
398
|
+
sensitivity = params.get("sensitivity", None)
|
|
399
|
+
if sensitivity is not None:
|
|
400
|
+
sensitivity = float(sensitivity)
|
|
401
|
+
device.slicing_sensitivity = max(0.0, min(1.0, sensitivity))
|
|
402
|
+
result["sensitivity"] = device.slicing_sensitivity
|
|
403
|
+
|
|
404
|
+
return result
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
@register("get_rack_chains")
|
|
408
|
+
def get_rack_chains(song, params):
|
|
409
|
+
"""Return chain info for a rack device."""
|
|
410
|
+
track_index = int(params["track_index"])
|
|
411
|
+
device_index = int(params["device_index"])
|
|
412
|
+
track = get_track(song, track_index)
|
|
413
|
+
device = get_device(track, device_index)
|
|
414
|
+
|
|
415
|
+
if not device.can_have_chains:
|
|
416
|
+
raise ValueError(
|
|
417
|
+
"Device '%s' is not a rack and cannot have chains" % device.name
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
chains = []
|
|
421
|
+
for i, chain in enumerate(device.chains):
|
|
422
|
+
chain_info = {
|
|
423
|
+
"index": i,
|
|
424
|
+
"name": chain.name,
|
|
425
|
+
"volume": chain.mixer_device.volume.value,
|
|
426
|
+
"pan": chain.mixer_device.panning.value,
|
|
427
|
+
"mute": chain.mute,
|
|
428
|
+
"solo": chain.solo,
|
|
429
|
+
}
|
|
430
|
+
chains.append(chain_info)
|
|
431
|
+
return {"chains": chains}
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
@register("set_chain_volume")
|
|
435
|
+
def set_chain_volume(song, params):
|
|
436
|
+
"""Set volume and/or pan for a rack chain."""
|
|
437
|
+
track_index = int(params["track_index"])
|
|
438
|
+
device_index = int(params["device_index"])
|
|
439
|
+
chain_index = int(params["chain_index"])
|
|
440
|
+
track = get_track(song, track_index)
|
|
441
|
+
device = get_device(track, device_index)
|
|
442
|
+
|
|
443
|
+
if not device.can_have_chains:
|
|
444
|
+
raise ValueError(
|
|
445
|
+
"Device '%s' is not a rack and cannot have chains" % device.name
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
chains = list(device.chains)
|
|
449
|
+
if chain_index < 0 or chain_index >= len(chains):
|
|
450
|
+
raise IndexError(
|
|
451
|
+
"Chain index %d out of range (0..%d)"
|
|
452
|
+
% (chain_index, len(chains) - 1)
|
|
453
|
+
)
|
|
454
|
+
chain = chains[chain_index]
|
|
455
|
+
|
|
456
|
+
if "volume" in params:
|
|
457
|
+
chain.mixer_device.volume.value = float(params["volume"])
|
|
458
|
+
if "pan" in params:
|
|
459
|
+
chain.mixer_device.panning.value = float(params["pan"])
|
|
460
|
+
|
|
461
|
+
return {
|
|
462
|
+
"index": chain_index,
|
|
463
|
+
"name": chain.name,
|
|
464
|
+
"volume": chain.mixer_device.volume.value,
|
|
465
|
+
"pan": chain.mixer_device.panning.value,
|
|
466
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LivePilot - Session diagnostics handler (1 command).
|
|
3
|
+
|
|
4
|
+
Analyzes the current session and flags potential issues:
|
|
5
|
+
armed tracks, mute/solo leftovers, empty clips, unnamed tracks,
|
|
6
|
+
empty scenes, and device-less instrument tracks.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .router import register
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# Default track names that indicate the user hasn't renamed them.
|
|
13
|
+
# Ableton auto-names tracks like "1-MIDI", "2-Audio", "3-MIDI", etc.
|
|
14
|
+
_DEFAULT_NAME_PATTERNS = frozenset([
|
|
15
|
+
"MIDI", "Audio", "Inst", "Return",
|
|
16
|
+
])
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _looks_default_name(name):
|
|
20
|
+
"""Check if a track name looks like an Ableton default."""
|
|
21
|
+
stripped = name.strip()
|
|
22
|
+
# Pattern: "N-Type" or just "Type" (e.g., "1-MIDI", "MIDI", "2-Audio")
|
|
23
|
+
for part in stripped.split("-"):
|
|
24
|
+
part = part.strip()
|
|
25
|
+
if part.isdigit():
|
|
26
|
+
continue
|
|
27
|
+
if part in _DEFAULT_NAME_PATTERNS:
|
|
28
|
+
return True
|
|
29
|
+
return False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@register("get_session_diagnostics")
|
|
33
|
+
def get_session_diagnostics(song, params):
|
|
34
|
+
"""Analyze the session and return a diagnostic report."""
|
|
35
|
+
issues = []
|
|
36
|
+
stats = {
|
|
37
|
+
"track_count": 0,
|
|
38
|
+
"return_track_count": 0,
|
|
39
|
+
"scene_count": 0,
|
|
40
|
+
"total_clips": 0,
|
|
41
|
+
"empty_scenes": 0,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
tracks = list(song.tracks)
|
|
45
|
+
scenes = list(song.scenes)
|
|
46
|
+
return_tracks = list(song.return_tracks)
|
|
47
|
+
|
|
48
|
+
stats["track_count"] = len(tracks)
|
|
49
|
+
stats["return_track_count"] = len(return_tracks)
|
|
50
|
+
stats["scene_count"] = len(scenes)
|
|
51
|
+
|
|
52
|
+
# ── Track-level checks ─────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
armed_tracks = []
|
|
55
|
+
soloed_tracks = []
|
|
56
|
+
muted_tracks = []
|
|
57
|
+
unnamed_tracks = []
|
|
58
|
+
empty_tracks = [] # no clips at all
|
|
59
|
+
no_device_midi_tracks = [] # MIDI tracks with no instruments
|
|
60
|
+
track_slots = [] # cached clip_slots per track (avoid re-evaluating LOM tuple)
|
|
61
|
+
|
|
62
|
+
for i, track in enumerate(tracks):
|
|
63
|
+
# Armed check
|
|
64
|
+
if track.arm:
|
|
65
|
+
armed_tracks.append({"index": i, "name": track.name})
|
|
66
|
+
|
|
67
|
+
# Solo check
|
|
68
|
+
if track.solo:
|
|
69
|
+
soloed_tracks.append({"index": i, "name": track.name})
|
|
70
|
+
|
|
71
|
+
# Muted tracks (informational, only flag if many)
|
|
72
|
+
if track.mute:
|
|
73
|
+
muted_tracks.append({"index": i, "name": track.name})
|
|
74
|
+
|
|
75
|
+
# Unnamed / default name check
|
|
76
|
+
if _looks_default_name(track.name):
|
|
77
|
+
unnamed_tracks.append({"index": i, "name": track.name})
|
|
78
|
+
|
|
79
|
+
# Cache clip_slots once per track
|
|
80
|
+
slots = list(track.clip_slots)
|
|
81
|
+
track_slots.append(slots)
|
|
82
|
+
|
|
83
|
+
# Clip count
|
|
84
|
+
clip_count = 0
|
|
85
|
+
for slot in slots:
|
|
86
|
+
if slot.has_clip:
|
|
87
|
+
clip_count += 1
|
|
88
|
+
stats["total_clips"] += clip_count
|
|
89
|
+
|
|
90
|
+
if clip_count == 0:
|
|
91
|
+
empty_tracks.append({"index": i, "name": track.name})
|
|
92
|
+
|
|
93
|
+
# MIDI track with no devices (no instrument loaded)
|
|
94
|
+
if track.has_midi_input and len(list(track.devices)) == 0:
|
|
95
|
+
no_device_midi_tracks.append({"index": i, "name": track.name})
|
|
96
|
+
|
|
97
|
+
# Build issues from checks
|
|
98
|
+
if armed_tracks:
|
|
99
|
+
issues.append({
|
|
100
|
+
"type": "armed_tracks",
|
|
101
|
+
"severity": "warning",
|
|
102
|
+
"message": "%d track(s) left armed" % len(armed_tracks),
|
|
103
|
+
"details": armed_tracks,
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
if soloed_tracks:
|
|
107
|
+
issues.append({
|
|
108
|
+
"type": "soloed_tracks",
|
|
109
|
+
"severity": "warning",
|
|
110
|
+
"message": "%d track(s) soloed — other tracks are silenced" % len(soloed_tracks),
|
|
111
|
+
"details": soloed_tracks,
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
if len(muted_tracks) > len(tracks) * 0.5 and len(muted_tracks) > 2:
|
|
115
|
+
issues.append({
|
|
116
|
+
"type": "many_muted",
|
|
117
|
+
"severity": "info",
|
|
118
|
+
"message": "%d of %d tracks muted — consider cleaning up unused tracks" % (
|
|
119
|
+
len(muted_tracks), len(tracks)
|
|
120
|
+
),
|
|
121
|
+
"details": muted_tracks,
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
if unnamed_tracks:
|
|
125
|
+
issues.append({
|
|
126
|
+
"type": "unnamed_tracks",
|
|
127
|
+
"severity": "info",
|
|
128
|
+
"message": "%d track(s) have default names" % len(unnamed_tracks),
|
|
129
|
+
"details": unnamed_tracks,
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
if empty_tracks:
|
|
133
|
+
issues.append({
|
|
134
|
+
"type": "empty_tracks",
|
|
135
|
+
"severity": "info",
|
|
136
|
+
"message": "%d track(s) have no clips" % len(empty_tracks),
|
|
137
|
+
"details": empty_tracks,
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
if no_device_midi_tracks:
|
|
141
|
+
issues.append({
|
|
142
|
+
"type": "no_instrument",
|
|
143
|
+
"severity": "warning",
|
|
144
|
+
"message": "%d MIDI track(s) have no instrument loaded" % len(no_device_midi_tracks),
|
|
145
|
+
"details": no_device_midi_tracks,
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
# ── Scene-level checks ──────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
empty_scenes = []
|
|
151
|
+
for i, scene in enumerate(scenes):
|
|
152
|
+
has_any_clip = False
|
|
153
|
+
for slots in track_slots:
|
|
154
|
+
if i < len(slots) and slots[i].has_clip:
|
|
155
|
+
has_any_clip = True
|
|
156
|
+
break
|
|
157
|
+
if not has_any_clip:
|
|
158
|
+
empty_scenes.append({"index": i, "name": scene.name})
|
|
159
|
+
|
|
160
|
+
stats["empty_scenes"] = len(empty_scenes)
|
|
161
|
+
|
|
162
|
+
if empty_scenes and len(empty_scenes) > 1:
|
|
163
|
+
issues.append({
|
|
164
|
+
"type": "empty_scenes",
|
|
165
|
+
"severity": "info",
|
|
166
|
+
"message": "%d scene(s) have no clips across any track" % len(empty_scenes),
|
|
167
|
+
"details": empty_scenes[:10], # Cap at 10 to avoid huge payloads
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
# ── Return track checks ─────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
soloed_returns = []
|
|
173
|
+
for i, track in enumerate(return_tracks):
|
|
174
|
+
if track.solo:
|
|
175
|
+
soloed_returns.append({"index": i, "name": track.name})
|
|
176
|
+
|
|
177
|
+
if soloed_returns:
|
|
178
|
+
issues.append({
|
|
179
|
+
"type": "soloed_returns",
|
|
180
|
+
"severity": "warning",
|
|
181
|
+
"message": "%d return track(s) soloed" % len(soloed_returns),
|
|
182
|
+
"details": soloed_returns,
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
# ── Summary ─────────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
severity_counts = {"warning": 0, "info": 0}
|
|
188
|
+
for issue in issues:
|
|
189
|
+
severity_counts[issue["severity"]] = severity_counts.get(issue["severity"], 0) + 1
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
"healthy": len(issues) == 0,
|
|
193
|
+
"issue_count": len(issues),
|
|
194
|
+
"warnings": severity_counts.get("warning", 0),
|
|
195
|
+
"info": severity_counts.get("info", 0),
|
|
196
|
+
"issues": issues,
|
|
197
|
+
"stats": stats,
|
|
198
|
+
}
|