livepilot 1.0.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 +33 -0
- package/LICENSE +21 -0
- package/README.md +409 -0
- package/bin/livepilot.js +390 -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 +210 -0
- package/mcp_server/memory/__init__.py +5 -0
- package/mcp_server/memory/technique_store.py +296 -0
- package/mcp_server/server.py +87 -0
- package/mcp_server/tools/__init__.py +1 -0
- package/mcp_server/tools/arrangement.py +407 -0
- package/mcp_server/tools/browser.py +86 -0
- package/mcp_server/tools/clips.py +218 -0
- package/mcp_server/tools/devices.py +256 -0
- package/mcp_server/tools/memory.py +198 -0
- package/mcp_server/tools/mixing.py +121 -0
- package/mcp_server/tools/notes.py +269 -0
- package/mcp_server/tools/scenes.py +89 -0
- package/mcp_server/tools/tracks.py +175 -0
- package/mcp_server/tools/transport.py +117 -0
- package/package.json +37 -0
- package/plugin/agents/livepilot-producer/AGENT.md +62 -0
- package/plugin/commands/beat.md +18 -0
- package/plugin/commands/memory.md +22 -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 +19 -0
- package/plugin/skills/livepilot-core/SKILL.md +208 -0
- package/plugin/skills/livepilot-core/references/ableton-workflow-patterns.md +831 -0
- package/plugin/skills/livepilot-core/references/device-atlas/00-index.md +110 -0
- package/plugin/skills/livepilot-core/references/device-atlas/distortion-and-character.md +687 -0
- package/plugin/skills/livepilot-core/references/device-atlas/drums-and-percussion.md +753 -0
- package/plugin/skills/livepilot-core/references/device-atlas/dynamics-and-punch.md +525 -0
- package/plugin/skills/livepilot-core/references/device-atlas/eq-and-filtering.md +402 -0
- package/plugin/skills/livepilot-core/references/device-atlas/midi-tools.md +963 -0
- package/plugin/skills/livepilot-core/references/device-atlas/movement-and-modulation.md +874 -0
- package/plugin/skills/livepilot-core/references/device-atlas/space-and-depth.md +571 -0
- package/plugin/skills/livepilot-core/references/device-atlas/spectral-and-weird.md +714 -0
- package/plugin/skills/livepilot-core/references/device-atlas/synths-native.md +953 -0
- package/plugin/skills/livepilot-core/references/m4l-devices.md +352 -0
- package/plugin/skills/livepilot-core/references/memory-guide.md +107 -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 +209 -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 +693 -0
- package/remote_script/LivePilot/browser.py +424 -0
- package/remote_script/LivePilot/clips.py +211 -0
- package/remote_script/LivePilot/devices.py +596 -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 +99 -0
- package/remote_script/LivePilot/server.py +293 -0
- package/remote_script/LivePilot/tracks.py +268 -0
- package/remote_script/LivePilot/transport.py +151 -0
- package/remote_script/LivePilot/utils.py +123 -0
- package/requirements.txt +2 -0
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LivePilot - Browser domain handlers (5 commands).
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import Live
|
|
6
|
+
|
|
7
|
+
from .router import register
|
|
8
|
+
from .utils import get_track
|
|
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
|
+
def _get_categories(browser):
|
|
17
|
+
"""Return a dict of browser category name -> browser item.
|
|
18
|
+
|
|
19
|
+
Includes all documented Browser properties from the Live Object Model:
|
|
20
|
+
instruments, audio_effects, midi_effects, sounds, drums, samples,
|
|
21
|
+
packs, user_library, plugins, max_for_live, clips, current_project.
|
|
22
|
+
"""
|
|
23
|
+
categories = {
|
|
24
|
+
"instruments": browser.instruments,
|
|
25
|
+
"audio_effects": browser.audio_effects,
|
|
26
|
+
"midi_effects": browser.midi_effects,
|
|
27
|
+
"sounds": browser.sounds,
|
|
28
|
+
"drums": browser.drums,
|
|
29
|
+
"samples": browser.samples,
|
|
30
|
+
"packs": browser.packs,
|
|
31
|
+
"user_library": browser.user_library,
|
|
32
|
+
}
|
|
33
|
+
# Additional categories — may not exist on older Live versions
|
|
34
|
+
for attr in ("plugins", "max_for_live", "clips", "current_project"):
|
|
35
|
+
try:
|
|
36
|
+
val = getattr(browser, attr)
|
|
37
|
+
if val is not None:
|
|
38
|
+
categories[attr] = val
|
|
39
|
+
except AttributeError:
|
|
40
|
+
pass
|
|
41
|
+
return categories
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _navigate_path(browser, path):
|
|
45
|
+
"""Walk the browser tree by slash-separated path, return the item."""
|
|
46
|
+
categories = _get_categories(browser)
|
|
47
|
+
parts = [p.strip() for p in path.strip("/").split("/") if p.strip()]
|
|
48
|
+
if not parts:
|
|
49
|
+
raise ValueError("Path cannot be empty")
|
|
50
|
+
|
|
51
|
+
# First part must be a category name
|
|
52
|
+
first = parts[0].lower()
|
|
53
|
+
if first not in categories:
|
|
54
|
+
raise ValueError(
|
|
55
|
+
"Unknown category '%s'. Available: %s"
|
|
56
|
+
% (first, ", ".join(sorted(categories.keys())))
|
|
57
|
+
)
|
|
58
|
+
current = categories[first]
|
|
59
|
+
|
|
60
|
+
# Walk remaining parts by child name
|
|
61
|
+
for part in parts[1:]:
|
|
62
|
+
children = list(current.children)
|
|
63
|
+
matched = None
|
|
64
|
+
for child in children:
|
|
65
|
+
if child.name == part:
|
|
66
|
+
matched = child
|
|
67
|
+
break
|
|
68
|
+
if matched is None:
|
|
69
|
+
child_names = [c.name for c in children[:20]]
|
|
70
|
+
raise ValueError(
|
|
71
|
+
"Item '%s' not found in '%s'. Available: %s"
|
|
72
|
+
% (part, current.name, ", ".join(child_names))
|
|
73
|
+
)
|
|
74
|
+
current = matched
|
|
75
|
+
|
|
76
|
+
return current
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _search_recursive(item, name_filter, loadable_only, results, depth, max_depth,
|
|
80
|
+
max_results=100):
|
|
81
|
+
"""Recursively search browser children."""
|
|
82
|
+
if depth > max_depth or len(results) >= max_results:
|
|
83
|
+
return
|
|
84
|
+
for child in item.children:
|
|
85
|
+
if len(results) >= max_results:
|
|
86
|
+
return
|
|
87
|
+
match = True
|
|
88
|
+
if name_filter and name_filter.lower() not in child.name.lower():
|
|
89
|
+
match = False
|
|
90
|
+
if loadable_only and not child.is_loadable:
|
|
91
|
+
match = False
|
|
92
|
+
if match:
|
|
93
|
+
entry = {
|
|
94
|
+
"name": child.name,
|
|
95
|
+
"is_loadable": child.is_loadable,
|
|
96
|
+
}
|
|
97
|
+
try:
|
|
98
|
+
entry["uri"] = child.uri
|
|
99
|
+
except AttributeError:
|
|
100
|
+
entry["uri"] = None
|
|
101
|
+
results.append(entry)
|
|
102
|
+
if child.is_folder:
|
|
103
|
+
before = len(results)
|
|
104
|
+
_search_recursive(
|
|
105
|
+
child, name_filter, loadable_only, results, depth + 1, max_depth,
|
|
106
|
+
max_results
|
|
107
|
+
)
|
|
108
|
+
if len(results) >= max_results:
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@register("get_browser_tree")
|
|
113
|
+
def get_browser_tree(song, params):
|
|
114
|
+
"""Return an overview of the browser categories."""
|
|
115
|
+
category_type = str(params.get("category_type", "all")).lower()
|
|
116
|
+
browser = _get_browser()
|
|
117
|
+
categories = _get_categories(browser)
|
|
118
|
+
|
|
119
|
+
if category_type != "all":
|
|
120
|
+
if category_type not in categories:
|
|
121
|
+
raise ValueError(
|
|
122
|
+
"Unknown category '%s'. Available: %s"
|
|
123
|
+
% (category_type, ", ".join(sorted(categories.keys())))
|
|
124
|
+
)
|
|
125
|
+
categories = {category_type: categories[category_type]}
|
|
126
|
+
|
|
127
|
+
result = []
|
|
128
|
+
for name, item in categories.items():
|
|
129
|
+
children = list(item.children)
|
|
130
|
+
child_names = [c.name for c in children[:20]]
|
|
131
|
+
result.append({
|
|
132
|
+
"name": name,
|
|
133
|
+
"children_count": len(children),
|
|
134
|
+
"children_preview": child_names,
|
|
135
|
+
})
|
|
136
|
+
return {"categories": result}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@register("get_browser_items")
|
|
140
|
+
def get_browser_items(song, params):
|
|
141
|
+
"""List items at a browser path."""
|
|
142
|
+
path = str(params["path"])
|
|
143
|
+
browser = _get_browser()
|
|
144
|
+
item = _navigate_path(browser, path)
|
|
145
|
+
|
|
146
|
+
result = []
|
|
147
|
+
for child in item.children:
|
|
148
|
+
entry = {
|
|
149
|
+
"name": child.name,
|
|
150
|
+
"is_loadable": child.is_loadable,
|
|
151
|
+
"is_folder": child.is_folder,
|
|
152
|
+
}
|
|
153
|
+
if child.is_loadable:
|
|
154
|
+
try:
|
|
155
|
+
entry["uri"] = child.uri
|
|
156
|
+
except AttributeError:
|
|
157
|
+
entry["uri"] = None
|
|
158
|
+
result.append(entry)
|
|
159
|
+
return {"path": path, "items": result}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@register("search_browser")
|
|
163
|
+
def search_browser(song, params):
|
|
164
|
+
"""Search the browser tree by name filter."""
|
|
165
|
+
path = str(params["path"])
|
|
166
|
+
name_filter = params.get("name_filter", None)
|
|
167
|
+
loadable_only = bool(params.get("loadable_only", False))
|
|
168
|
+
max_depth = int(params.get("max_depth", 8))
|
|
169
|
+
max_results = int(params.get("max_results", 100))
|
|
170
|
+
browser = _get_browser()
|
|
171
|
+
item = _navigate_path(browser, path)
|
|
172
|
+
|
|
173
|
+
results = []
|
|
174
|
+
_search_recursive(item, name_filter, loadable_only, results, 0, max_depth,
|
|
175
|
+
max_results)
|
|
176
|
+
truncated = len(results) >= max_results
|
|
177
|
+
result = {"path": path, "results": results, "count": len(results)}
|
|
178
|
+
if truncated:
|
|
179
|
+
result["truncated"] = True
|
|
180
|
+
result["max_results"] = max_results
|
|
181
|
+
return result
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@register("load_browser_item")
|
|
185
|
+
def load_browser_item(song, params):
|
|
186
|
+
"""Load a browser item onto a track by URI.
|
|
187
|
+
|
|
188
|
+
First tries URI-based matching (exact child.uri comparison).
|
|
189
|
+
Falls back to name extraction from the URI's last path segment.
|
|
190
|
+
Searches all browser categories including user_library and samples.
|
|
191
|
+
"""
|
|
192
|
+
track_index = int(params["track_index"])
|
|
193
|
+
uri = str(params["uri"])
|
|
194
|
+
track = get_track(song, track_index)
|
|
195
|
+
browser = _get_browser()
|
|
196
|
+
|
|
197
|
+
# Parse category hint from URI (e.g., "query:Drums#..." -> prioritize drums)
|
|
198
|
+
_category_map = {
|
|
199
|
+
"drums": "drums", "samples": "samples", "instruments": "instruments",
|
|
200
|
+
"audiofx": "audio_effects", "audio_effects": "audio_effects",
|
|
201
|
+
"midifx": "midi_effects", "midi_effects": "midi_effects",
|
|
202
|
+
"sounds": "sounds", "packs": "packs",
|
|
203
|
+
"userlibrary": "user_library", "user_library": "user_library",
|
|
204
|
+
"plugins": "plugins", "max_for_live": "max_for_live",
|
|
205
|
+
}
|
|
206
|
+
priority_attr = None
|
|
207
|
+
if ":" in uri:
|
|
208
|
+
# Extract category from "query:Drums#..." or "query:UserLibrary#..."
|
|
209
|
+
after_colon = uri.split(":", 1)[1]
|
|
210
|
+
cat_hint = after_colon.split("#", 1)[0].lower().replace(" ", "_")
|
|
211
|
+
priority_attr = _category_map.get(cat_hint)
|
|
212
|
+
|
|
213
|
+
# Build category search order — prioritize the category from the URI
|
|
214
|
+
category_attrs = [
|
|
215
|
+
"user_library", "plugins", "max_for_live", "samples",
|
|
216
|
+
"instruments", "audio_effects", "midi_effects", "packs",
|
|
217
|
+
"sounds", "drums",
|
|
218
|
+
]
|
|
219
|
+
if priority_attr and priority_attr in category_attrs:
|
|
220
|
+
category_attrs.remove(priority_attr)
|
|
221
|
+
category_attrs.insert(0, priority_attr)
|
|
222
|
+
|
|
223
|
+
categories = []
|
|
224
|
+
for attr in category_attrs:
|
|
225
|
+
try:
|
|
226
|
+
categories.append(getattr(browser, attr))
|
|
227
|
+
except AttributeError:
|
|
228
|
+
pass
|
|
229
|
+
|
|
230
|
+
_iterations = [0]
|
|
231
|
+
MAX_ITERATIONS = 50000
|
|
232
|
+
|
|
233
|
+
# ── Strategy 1: match by URI directly ────────────────────────────
|
|
234
|
+
def find_by_uri(parent, target_uri, depth=0):
|
|
235
|
+
if depth > 8 or _iterations[0] > MAX_ITERATIONS:
|
|
236
|
+
return None
|
|
237
|
+
try:
|
|
238
|
+
children = list(parent.children)
|
|
239
|
+
except AttributeError:
|
|
240
|
+
return None
|
|
241
|
+
for child in children:
|
|
242
|
+
_iterations[0] += 1
|
|
243
|
+
if _iterations[0] > MAX_ITERATIONS:
|
|
244
|
+
return None
|
|
245
|
+
try:
|
|
246
|
+
if child.uri == target_uri and child.is_loadable:
|
|
247
|
+
return child
|
|
248
|
+
except AttributeError:
|
|
249
|
+
pass
|
|
250
|
+
result = find_by_uri(child, target_uri, depth + 1)
|
|
251
|
+
if result is not None:
|
|
252
|
+
return result
|
|
253
|
+
return None
|
|
254
|
+
|
|
255
|
+
for category in categories:
|
|
256
|
+
found = find_by_uri(category, uri)
|
|
257
|
+
if found is not None:
|
|
258
|
+
song.view.selected_track = track
|
|
259
|
+
browser.load_item(found)
|
|
260
|
+
device_count = len(list(track.devices))
|
|
261
|
+
return {
|
|
262
|
+
"track_index": track_index,
|
|
263
|
+
"loaded": True,
|
|
264
|
+
"name": found.name,
|
|
265
|
+
"device_count": device_count,
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
# ── Strategy 2: extract name from URI, search by name ────────────
|
|
269
|
+
device_name = uri
|
|
270
|
+
if "#" in uri:
|
|
271
|
+
device_name = uri.split("#", 1)[1]
|
|
272
|
+
# For Sounds URIs like "Pad:FileId_6343", strip the FileId part
|
|
273
|
+
# and use the subcategory or the full fragment for matching
|
|
274
|
+
if "FileId_" in device_name:
|
|
275
|
+
# URI contains an internal file ID — name-based search won't work.
|
|
276
|
+
# Try one more URI pass with a much higher iteration limit.
|
|
277
|
+
_iterations[0] = 0
|
|
278
|
+
DEEP_MAX = 200000
|
|
279
|
+
def find_by_uri_deep(parent, target_uri, depth=0):
|
|
280
|
+
if depth > 12 or _iterations[0] > DEEP_MAX:
|
|
281
|
+
return None
|
|
282
|
+
try:
|
|
283
|
+
children = list(parent.children)
|
|
284
|
+
except AttributeError:
|
|
285
|
+
return None
|
|
286
|
+
for child in children:
|
|
287
|
+
_iterations[0] += 1
|
|
288
|
+
if _iterations[0] > DEEP_MAX:
|
|
289
|
+
return None
|
|
290
|
+
try:
|
|
291
|
+
if child.uri == target_uri and child.is_loadable:
|
|
292
|
+
return child
|
|
293
|
+
except AttributeError:
|
|
294
|
+
pass
|
|
295
|
+
result = find_by_uri_deep(child, target_uri, depth + 1)
|
|
296
|
+
if result is not None:
|
|
297
|
+
return result
|
|
298
|
+
return None
|
|
299
|
+
|
|
300
|
+
for category in categories:
|
|
301
|
+
_iterations[0] = 0
|
|
302
|
+
found = find_by_uri_deep(category, uri)
|
|
303
|
+
if found is not None:
|
|
304
|
+
song.view.selected_track = track
|
|
305
|
+
browser.load_item(found)
|
|
306
|
+
device_count = len(list(track.devices))
|
|
307
|
+
return {
|
|
308
|
+
"track_index": track_index,
|
|
309
|
+
"loaded": True,
|
|
310
|
+
"name": found.name,
|
|
311
|
+
"device_count": device_count,
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
raise ValueError(
|
|
315
|
+
"Item '%s' not found in browser (FileId URI — try "
|
|
316
|
+
"search_browser to find the item, then use find_and_load_device "
|
|
317
|
+
"with the exact name instead)" % uri
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
for sep in (":", "/"):
|
|
321
|
+
if sep in device_name:
|
|
322
|
+
device_name = device_name.rsplit(sep, 1)[1]
|
|
323
|
+
# URL-decode
|
|
324
|
+
try:
|
|
325
|
+
from urllib.parse import unquote
|
|
326
|
+
device_name = unquote(device_name)
|
|
327
|
+
except ImportError:
|
|
328
|
+
device_name = device_name.replace("%20", " ")
|
|
329
|
+
# Strip file extensions
|
|
330
|
+
for ext in (".amxd", ".adv", ".adg", ".aupreset", ".als", ".wav", ".aif", ".aiff", ".mp3"):
|
|
331
|
+
if device_name.lower().endswith(ext):
|
|
332
|
+
device_name = device_name[:-len(ext)]
|
|
333
|
+
break
|
|
334
|
+
|
|
335
|
+
target = device_name.lower()
|
|
336
|
+
_iterations[0] = 0
|
|
337
|
+
|
|
338
|
+
def find_by_name(parent, depth=0):
|
|
339
|
+
if depth > 8 or _iterations[0] > MAX_ITERATIONS:
|
|
340
|
+
return None
|
|
341
|
+
try:
|
|
342
|
+
children = list(parent.children)
|
|
343
|
+
except AttributeError:
|
|
344
|
+
return None
|
|
345
|
+
for child in children:
|
|
346
|
+
_iterations[0] += 1
|
|
347
|
+
if _iterations[0] > MAX_ITERATIONS:
|
|
348
|
+
return None
|
|
349
|
+
child_lower = child.name.lower()
|
|
350
|
+
if (child_lower == target or target in child_lower) and child.is_loadable:
|
|
351
|
+
return child
|
|
352
|
+
result = find_by_name(child, depth + 1)
|
|
353
|
+
if result is not None:
|
|
354
|
+
return result
|
|
355
|
+
return None
|
|
356
|
+
|
|
357
|
+
for category in categories:
|
|
358
|
+
found = find_by_name(category)
|
|
359
|
+
if found is not None:
|
|
360
|
+
song.view.selected_track = track
|
|
361
|
+
browser.load_item(found)
|
|
362
|
+
device_count = len(list(track.devices))
|
|
363
|
+
return {
|
|
364
|
+
"track_index": track_index,
|
|
365
|
+
"loaded": True,
|
|
366
|
+
"name": found.name,
|
|
367
|
+
"device_count": device_count,
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
raise ValueError(
|
|
371
|
+
"Item '%s' not found in browser" % device_name
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
@register("get_device_presets")
|
|
376
|
+
def get_device_presets(song, params):
|
|
377
|
+
"""List available presets for a device type by searching the browser.
|
|
378
|
+
|
|
379
|
+
Searches up to 2 levels deep inside the device folder to find presets,
|
|
380
|
+
since Ableton nests them inside sub-folders like 'Default Presets'.
|
|
381
|
+
"""
|
|
382
|
+
device_name = str(params["device_name"])
|
|
383
|
+
browser = _get_browser()
|
|
384
|
+
|
|
385
|
+
categories = {
|
|
386
|
+
"audio_effects": browser.audio_effects,
|
|
387
|
+
"instruments": browser.instruments,
|
|
388
|
+
"midi_effects": browser.midi_effects,
|
|
389
|
+
}
|
|
390
|
+
results = []
|
|
391
|
+
found_category = None
|
|
392
|
+
|
|
393
|
+
def collect_presets(item, depth=0):
|
|
394
|
+
"""Recursively collect loadable presets up to depth 2."""
|
|
395
|
+
if depth > 2:
|
|
396
|
+
return
|
|
397
|
+
try:
|
|
398
|
+
children = list(item.children)
|
|
399
|
+
except AttributeError:
|
|
400
|
+
return
|
|
401
|
+
for child in children:
|
|
402
|
+
if child.is_loadable and not child.is_folder:
|
|
403
|
+
entry = {"name": child.name}
|
|
404
|
+
try:
|
|
405
|
+
entry["uri"] = child.uri
|
|
406
|
+
except AttributeError:
|
|
407
|
+
entry["uri"] = None
|
|
408
|
+
results.append(entry)
|
|
409
|
+
elif child.is_folder:
|
|
410
|
+
collect_presets(child, depth + 1)
|
|
411
|
+
|
|
412
|
+
for cat_name, cat_item in categories.items():
|
|
413
|
+
for item in cat_item.children:
|
|
414
|
+
if item.name.lower() == device_name.lower():
|
|
415
|
+
found_category = cat_name
|
|
416
|
+
collect_presets(item)
|
|
417
|
+
break
|
|
418
|
+
if found_category:
|
|
419
|
+
break
|
|
420
|
+
return {
|
|
421
|
+
"device_name": device_name,
|
|
422
|
+
"category": found_category,
|
|
423
|
+
"presets": results,
|
|
424
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LivePilot - Clip domain handlers (11 commands).
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .router import register
|
|
6
|
+
from .utils import get_clip, get_clip_slot
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@register("get_clip_info")
|
|
10
|
+
def get_clip_info(song, params):
|
|
11
|
+
"""Return detailed info for a single clip."""
|
|
12
|
+
track_index = int(params["track_index"])
|
|
13
|
+
clip_index = int(params["clip_index"])
|
|
14
|
+
clip = get_clip(song, track_index, clip_index)
|
|
15
|
+
|
|
16
|
+
result = {
|
|
17
|
+
"track_index": track_index,
|
|
18
|
+
"clip_index": clip_index,
|
|
19
|
+
"name": clip.name,
|
|
20
|
+
"color_index": clip.color_index,
|
|
21
|
+
"length": clip.length,
|
|
22
|
+
"is_playing": clip.is_playing,
|
|
23
|
+
"is_recording": clip.is_recording,
|
|
24
|
+
"is_midi_clip": clip.is_midi_clip,
|
|
25
|
+
"is_audio_clip": clip.is_audio_clip,
|
|
26
|
+
"looping": clip.looping,
|
|
27
|
+
"loop_start": clip.loop_start,
|
|
28
|
+
"loop_end": clip.loop_end,
|
|
29
|
+
"start_marker": clip.start_marker,
|
|
30
|
+
"end_marker": clip.end_marker,
|
|
31
|
+
"launch_mode": clip.launch_mode,
|
|
32
|
+
"launch_quantization": clip.launch_quantization,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
# Audio-clip-specific fields
|
|
36
|
+
if clip.is_audio_clip:
|
|
37
|
+
result["warping"] = clip.warping
|
|
38
|
+
result["warp_mode"] = clip.warp_mode
|
|
39
|
+
|
|
40
|
+
return result
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@register("create_clip")
|
|
44
|
+
def create_clip(song, params):
|
|
45
|
+
"""Create an empty MIDI clip in the given clip slot."""
|
|
46
|
+
track_index = int(params["track_index"])
|
|
47
|
+
clip_index = int(params["clip_index"])
|
|
48
|
+
length = float(params["length"])
|
|
49
|
+
if length <= 0:
|
|
50
|
+
raise ValueError("Clip length must be > 0")
|
|
51
|
+
|
|
52
|
+
clip_slot = get_clip_slot(song, track_index, clip_index)
|
|
53
|
+
clip_slot.create_clip(length)
|
|
54
|
+
clip = clip_slot.clip
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
"track_index": track_index,
|
|
58
|
+
"clip_index": clip_index,
|
|
59
|
+
"name": clip.name,
|
|
60
|
+
"length": clip.length,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@register("delete_clip")
|
|
65
|
+
def delete_clip(song, params):
|
|
66
|
+
"""Delete the clip in the given clip slot."""
|
|
67
|
+
track_index = int(params["track_index"])
|
|
68
|
+
clip_index = int(params["clip_index"])
|
|
69
|
+
clip_slot = get_clip_slot(song, track_index, clip_index)
|
|
70
|
+
clip_slot.delete_clip()
|
|
71
|
+
return {"track_index": track_index, "clip_index": clip_index, "deleted": True}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@register("duplicate_clip")
|
|
75
|
+
def duplicate_clip(song, params):
|
|
76
|
+
"""Duplicate a clip from one slot to another."""
|
|
77
|
+
track_index = int(params["track_index"])
|
|
78
|
+
clip_index = int(params["clip_index"])
|
|
79
|
+
target_track = int(params["target_track"])
|
|
80
|
+
target_clip = int(params["target_clip"])
|
|
81
|
+
|
|
82
|
+
source_slot = get_clip_slot(song, track_index, clip_index)
|
|
83
|
+
target_slot = get_clip_slot(song, target_track, target_clip)
|
|
84
|
+
source_slot.duplicate_clip_to(target_slot)
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
"source_track": track_index,
|
|
88
|
+
"source_clip": clip_index,
|
|
89
|
+
"target_track": target_track,
|
|
90
|
+
"target_clip": target_clip,
|
|
91
|
+
"duplicated": True,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@register("fire_clip")
|
|
96
|
+
def fire_clip(song, params):
|
|
97
|
+
"""Launch/fire a clip slot."""
|
|
98
|
+
track_index = int(params["track_index"])
|
|
99
|
+
clip_index = int(params["clip_index"])
|
|
100
|
+
clip_slot = get_clip_slot(song, track_index, clip_index)
|
|
101
|
+
clip_slot.fire()
|
|
102
|
+
return {"track_index": track_index, "clip_index": clip_index, "fired": True}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@register("stop_clip")
|
|
106
|
+
def stop_clip(song, params):
|
|
107
|
+
"""Stop a clip slot."""
|
|
108
|
+
track_index = int(params["track_index"])
|
|
109
|
+
clip_index = int(params["clip_index"])
|
|
110
|
+
clip_slot = get_clip_slot(song, track_index, clip_index)
|
|
111
|
+
clip_slot.stop()
|
|
112
|
+
return {"track_index": track_index, "clip_index": clip_index, "stopped": True}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@register("set_clip_name")
|
|
116
|
+
def set_clip_name(song, params):
|
|
117
|
+
"""Rename a clip."""
|
|
118
|
+
track_index = int(params["track_index"])
|
|
119
|
+
clip_index = int(params["clip_index"])
|
|
120
|
+
clip = get_clip(song, track_index, clip_index)
|
|
121
|
+
clip.name = str(params["name"])
|
|
122
|
+
return {
|
|
123
|
+
"track_index": track_index,
|
|
124
|
+
"clip_index": clip_index,
|
|
125
|
+
"name": clip.name,
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@register("set_clip_color")
|
|
130
|
+
def set_clip_color(song, params):
|
|
131
|
+
"""Set a clip's color."""
|
|
132
|
+
track_index = int(params["track_index"])
|
|
133
|
+
clip_index = int(params["clip_index"])
|
|
134
|
+
clip = get_clip(song, track_index, clip_index)
|
|
135
|
+
clip.color_index = int(params["color_index"])
|
|
136
|
+
return {
|
|
137
|
+
"track_index": track_index,
|
|
138
|
+
"clip_index": clip_index,
|
|
139
|
+
"color_index": clip.color_index,
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@register("set_clip_loop")
|
|
144
|
+
def set_clip_loop(song, params):
|
|
145
|
+
"""Enable/disable clip looping and optionally set loop start/end."""
|
|
146
|
+
track_index = int(params["track_index"])
|
|
147
|
+
clip_index = int(params["clip_index"])
|
|
148
|
+
clip = get_clip(song, track_index, clip_index)
|
|
149
|
+
|
|
150
|
+
clip.looping = bool(params["enabled"])
|
|
151
|
+
if "start" in params:
|
|
152
|
+
clip.loop_start = float(params["start"])
|
|
153
|
+
if "end" in params:
|
|
154
|
+
clip.loop_end = float(params["end"])
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
"track_index": track_index,
|
|
158
|
+
"clip_index": clip_index,
|
|
159
|
+
"looping": clip.looping,
|
|
160
|
+
"loop_start": clip.loop_start,
|
|
161
|
+
"loop_end": clip.loop_end,
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@register("set_clip_launch")
|
|
166
|
+
def set_clip_launch(song, params):
|
|
167
|
+
"""Set clip launch mode and optional quantization."""
|
|
168
|
+
track_index = int(params["track_index"])
|
|
169
|
+
clip_index = int(params["clip_index"])
|
|
170
|
+
clip = get_clip(song, track_index, clip_index)
|
|
171
|
+
|
|
172
|
+
clip.launch_mode = int(params["mode"])
|
|
173
|
+
if "quantization" in params:
|
|
174
|
+
clip.launch_quantization = int(params["quantization"])
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
"track_index": track_index,
|
|
178
|
+
"clip_index": clip_index,
|
|
179
|
+
"launch_mode": clip.launch_mode,
|
|
180
|
+
"launch_quantization": clip.launch_quantization,
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@register("set_clip_warp_mode")
|
|
185
|
+
def set_clip_warp_mode(song, params):
|
|
186
|
+
"""Set warp mode for an audio clip."""
|
|
187
|
+
track_index = int(params["track_index"])
|
|
188
|
+
clip_index = int(params["clip_index"])
|
|
189
|
+
clip = get_clip(song, track_index, clip_index)
|
|
190
|
+
|
|
191
|
+
if clip.is_midi_clip:
|
|
192
|
+
raise ValueError("Warp modes only apply to audio clips")
|
|
193
|
+
|
|
194
|
+
mode = int(params["mode"])
|
|
195
|
+
if mode not in (0, 1, 2, 3, 4, 6):
|
|
196
|
+
raise ValueError(
|
|
197
|
+
"Invalid warp mode %d. Valid: 0=Beats, 1=Tones, 2=Texture, "
|
|
198
|
+
"3=Re-Pitch, 4=Complex, 6=Complex Pro" % mode
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
if "warping" in params:
|
|
202
|
+
clip.warping = bool(params["warping"])
|
|
203
|
+
|
|
204
|
+
clip.warp_mode = mode
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
"track_index": track_index,
|
|
208
|
+
"clip_index": clip_index,
|
|
209
|
+
"warp_mode": clip.warp_mode,
|
|
210
|
+
"warping": clip.warping,
|
|
211
|
+
}
|