livepilot 1.4.1 → 1.4.4
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/.mcp.json +9 -0
- package/CHANGELOG.md +29 -0
- package/README.md +31 -29
- package/mcp_server/memory/technique_store.py +14 -19
- package/mcp_server/tools/arrangement.py +1 -0
- package/mcp_server/tools/browser.py +4 -0
- package/mcp_server/tools/clips.py +1 -0
- package/mcp_server/tools/notes.py +1 -0
- package/mcp_server/tools/tracks.py +1 -0
- package/package.json +1 -1
- package/plugin/plugin.json +1 -1
- 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/plugins-synths.md +2012 -0
- package/plugin/skills/livepilot-core/references/device-atlas/presets-by-vibe.md +727 -0
- package/plugin/skills/livepilot-core/references/device-atlas/samples-and-irs.md +598 -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-m4l.md +730 -0
- package/plugin/skills/livepilot-core/references/device-atlas/synths-native.md +953 -0
- package/plugin/skills/livepilot-core/references/device-atlas/utility-and-workflow.md +843 -0
- package/remote_script/LivePilot/arrangement.py +2 -1
- package/remote_script/LivePilot/browser.py +91 -8
- package/remote_script/LivePilot/devices.py +164 -34
- package/remote_script/LivePilot/server.py +9 -2
- package/remote_script/LivePilot/transport.py +4 -2
- package/remote_script/LivePilot/utils.py +7 -2
|
@@ -606,7 +606,8 @@ def jump_to_time(song, params):
|
|
|
606
606
|
if beat_time < 0:
|
|
607
607
|
raise ValueError("beat_time must be >= 0")
|
|
608
608
|
song.current_song_time = beat_time
|
|
609
|
-
return
|
|
609
|
+
# Echo requested value — getter may return stale state before update propagates
|
|
610
|
+
return {"current_song_time": beat_time}
|
|
610
611
|
|
|
611
612
|
|
|
612
613
|
@register("capture_midi")
|
|
@@ -14,8 +14,13 @@ def _get_browser():
|
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
def _get_categories(browser):
|
|
17
|
-
"""Return a dict of browser category name -> browser item.
|
|
18
|
-
|
|
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 = {
|
|
19
24
|
"instruments": browser.instruments,
|
|
20
25
|
"audio_effects": browser.audio_effects,
|
|
21
26
|
"midi_effects": browser.midi_effects,
|
|
@@ -25,6 +30,15 @@ def _get_categories(browser):
|
|
|
25
30
|
"packs": browser.packs,
|
|
26
31
|
"user_library": browser.user_library,
|
|
27
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
|
|
28
42
|
|
|
29
43
|
|
|
30
44
|
def _navigate_path(browser, path):
|
|
@@ -180,11 +194,32 @@ def load_browser_item(song, params):
|
|
|
180
194
|
track = get_track(song, track_index)
|
|
181
195
|
browser = _get_browser()
|
|
182
196
|
|
|
183
|
-
#
|
|
184
|
-
|
|
185
|
-
"
|
|
186
|
-
"
|
|
187
|
-
|
|
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
|
+
|
|
188
223
|
categories = []
|
|
189
224
|
for attr in category_attrs:
|
|
190
225
|
try:
|
|
@@ -193,7 +228,7 @@ def load_browser_item(song, params):
|
|
|
193
228
|
pass
|
|
194
229
|
|
|
195
230
|
_iterations = [0]
|
|
196
|
-
MAX_ITERATIONS =
|
|
231
|
+
MAX_ITERATIONS = 50000
|
|
197
232
|
|
|
198
233
|
# ── Strategy 1: match by URI directly ────────────────────────────
|
|
199
234
|
def find_by_uri(parent, target_uri, depth=0):
|
|
@@ -234,6 +269,54 @@ def load_browser_item(song, params):
|
|
|
234
269
|
device_name = uri
|
|
235
270
|
if "#" in uri:
|
|
236
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
|
+
|
|
237
320
|
for sep in (":", "/"):
|
|
238
321
|
if sep in device_name:
|
|
239
322
|
device_name = device_name.rsplit(sep, 1)[1]
|
|
@@ -71,14 +71,24 @@ def set_device_parameter(song, params):
|
|
|
71
71
|
|
|
72
72
|
if parameter_name is not None:
|
|
73
73
|
param = None
|
|
74
|
+
# Try exact match first
|
|
74
75
|
for p in device.parameters:
|
|
75
76
|
if p.name == parameter_name:
|
|
76
77
|
param = p
|
|
77
78
|
break
|
|
79
|
+
# Fallback: case-insensitive match
|
|
78
80
|
if param is None:
|
|
81
|
+
target_lower = parameter_name.lower()
|
|
82
|
+
for p in device.parameters:
|
|
83
|
+
if p.name.lower() == target_lower:
|
|
84
|
+
param = p
|
|
85
|
+
break
|
|
86
|
+
if param is None:
|
|
87
|
+
available = [p.name for p in list(device.parameters)[:20]]
|
|
79
88
|
raise ValueError(
|
|
80
|
-
"Parameter '%s' not found on device '%s'"
|
|
81
|
-
|
|
89
|
+
"Parameter '%s' not found on device '%s'. "
|
|
90
|
+
"Available (first 20): %s"
|
|
91
|
+
% (parameter_name, device.name, ", ".join(available))
|
|
82
92
|
)
|
|
83
93
|
elif parameter_index is not None:
|
|
84
94
|
parameter_index = int(parameter_index)
|
|
@@ -123,14 +133,26 @@ def batch_set_parameters(song, params):
|
|
|
123
133
|
param = dev_params[idx]
|
|
124
134
|
else:
|
|
125
135
|
param = None
|
|
136
|
+
target = str(name_or_index)
|
|
137
|
+
# Try exact match first
|
|
126
138
|
for p in dev_params:
|
|
127
|
-
if p.name ==
|
|
139
|
+
if p.name == target:
|
|
128
140
|
param = p
|
|
129
141
|
break
|
|
142
|
+
# Fallback: case-insensitive match
|
|
143
|
+
if param is None:
|
|
144
|
+
target_lower = target.lower()
|
|
145
|
+
for p in dev_params:
|
|
146
|
+
if p.name.lower() == target_lower:
|
|
147
|
+
param = p
|
|
148
|
+
break
|
|
130
149
|
if param is None:
|
|
150
|
+
# List similar parameter names for debugging
|
|
151
|
+
available = [p.name for p in dev_params[:20]]
|
|
131
152
|
raise ValueError(
|
|
132
|
-
"Parameter '%s' not found on device '%s'"
|
|
133
|
-
|
|
153
|
+
"Parameter '%s' not found on device '%s'. "
|
|
154
|
+
"Available (first 20): %s"
|
|
155
|
+
% (name_or_index, device.name, ", ".join(available))
|
|
134
156
|
)
|
|
135
157
|
|
|
136
158
|
param.value = value
|
|
@@ -187,11 +209,31 @@ def load_device_by_uri(song, params):
|
|
|
187
209
|
track = get_track(song, track_index)
|
|
188
210
|
browser = _get_browser()
|
|
189
211
|
|
|
190
|
-
#
|
|
191
|
-
|
|
192
|
-
"
|
|
193
|
-
"
|
|
194
|
-
|
|
212
|
+
# Parse category hint from URI (e.g., "query:Drums#..." -> prioritize drums)
|
|
213
|
+
_category_map = {
|
|
214
|
+
"drums": "drums", "samples": "samples", "instruments": "instruments",
|
|
215
|
+
"audiofx": "audio_effects", "audio_effects": "audio_effects",
|
|
216
|
+
"midifx": "midi_effects", "midi_effects": "midi_effects",
|
|
217
|
+
"sounds": "sounds", "packs": "packs",
|
|
218
|
+
"userlibrary": "user_library", "user_library": "user_library",
|
|
219
|
+
}
|
|
220
|
+
priority_attr = None
|
|
221
|
+
if ":" in uri:
|
|
222
|
+
# Extract category from "query:Drums#..." or "query:UserLibrary#..."
|
|
223
|
+
after_colon = uri.split(":", 1)[1]
|
|
224
|
+
cat_hint = after_colon.split("#", 1)[0].lower().replace(" ", "_")
|
|
225
|
+
priority_attr = _category_map.get(cat_hint)
|
|
226
|
+
|
|
227
|
+
# Build category search order — prioritize the category from the URI
|
|
228
|
+
category_attrs = [
|
|
229
|
+
"user_library", "plugins", "max_for_live", "samples",
|
|
230
|
+
"instruments", "audio_effects", "midi_effects", "packs",
|
|
231
|
+
"sounds", "drums",
|
|
232
|
+
]
|
|
233
|
+
if priority_attr and priority_attr in category_attrs:
|
|
234
|
+
category_attrs.remove(priority_attr)
|
|
235
|
+
category_attrs.insert(0, priority_attr)
|
|
236
|
+
|
|
195
237
|
categories = []
|
|
196
238
|
for attr in category_attrs:
|
|
197
239
|
try:
|
|
@@ -200,7 +242,7 @@ def load_device_by_uri(song, params):
|
|
|
200
242
|
pass
|
|
201
243
|
|
|
202
244
|
_iterations = [0]
|
|
203
|
-
MAX_ITERATIONS =
|
|
245
|
+
MAX_ITERATIONS = 50000
|
|
204
246
|
|
|
205
247
|
# ── Strategy 1: match by URI directly ────────────────────────────
|
|
206
248
|
def find_by_uri(parent, target_uri, depth=0):
|
|
@@ -235,6 +277,45 @@ def load_device_by_uri(song, params):
|
|
|
235
277
|
device_name = uri
|
|
236
278
|
if "#" in uri:
|
|
237
279
|
device_name = uri.split("#", 1)[1]
|
|
280
|
+
# For Sounds URIs like "Pad:FileId_6343", the FileId is an internal
|
|
281
|
+
# identifier useless for name search — retry URI match with deep limit.
|
|
282
|
+
if "FileId_" in device_name:
|
|
283
|
+
_iterations[0] = 0
|
|
284
|
+
DEEP_MAX = 200000
|
|
285
|
+
def find_by_uri_deep(parent, target_uri, depth=0):
|
|
286
|
+
if depth > 12 or _iterations[0] > DEEP_MAX:
|
|
287
|
+
return None
|
|
288
|
+
try:
|
|
289
|
+
children = list(parent.children)
|
|
290
|
+
except AttributeError:
|
|
291
|
+
return None
|
|
292
|
+
for child in children:
|
|
293
|
+
_iterations[0] += 1
|
|
294
|
+
if _iterations[0] > DEEP_MAX:
|
|
295
|
+
return None
|
|
296
|
+
try:
|
|
297
|
+
if child.uri == target_uri and child.is_loadable:
|
|
298
|
+
return child
|
|
299
|
+
except AttributeError:
|
|
300
|
+
pass
|
|
301
|
+
result = find_by_uri_deep(child, target_uri, depth + 1)
|
|
302
|
+
if result is not None:
|
|
303
|
+
return result
|
|
304
|
+
return None
|
|
305
|
+
|
|
306
|
+
for category in categories:
|
|
307
|
+
_iterations[0] = 0
|
|
308
|
+
found = find_by_uri_deep(category, uri)
|
|
309
|
+
if found is not None:
|
|
310
|
+
song.view.selected_track = track
|
|
311
|
+
browser.load_item(found)
|
|
312
|
+
return {"loaded": found.name, "track_index": track_index}
|
|
313
|
+
|
|
314
|
+
raise ValueError(
|
|
315
|
+
"Item '%s' not found in browser (FileId URI — try "
|
|
316
|
+
"find_and_load_device with the exact name instead)" % uri
|
|
317
|
+
)
|
|
318
|
+
|
|
238
319
|
for sep in (":", "/"):
|
|
239
320
|
if sep in device_name:
|
|
240
321
|
device_name = device_name.rsplit(sep, 1)[1]
|
|
@@ -296,36 +377,56 @@ def find_and_load_device(song, params):
|
|
|
296
377
|
track = get_track(song, track_index)
|
|
297
378
|
browser = _get_browser()
|
|
298
379
|
|
|
299
|
-
MAX_ITERATIONS =
|
|
380
|
+
MAX_ITERATIONS = 50000
|
|
300
381
|
iterations = 0
|
|
301
382
|
|
|
302
|
-
def
|
|
303
|
-
"""
|
|
383
|
+
def _name_matches(child_name, target, exact_only):
|
|
384
|
+
"""Check if a browser item name matches the search target."""
|
|
385
|
+
child_lower = child_name.lower()
|
|
386
|
+
# Strip extension for comparison
|
|
387
|
+
child_base = child_lower
|
|
388
|
+
for ext in (".amxd", ".adv", ".adg", ".aupreset", ".als"):
|
|
389
|
+
if child_base.endswith(ext):
|
|
390
|
+
child_base = child_base[:-len(ext)]
|
|
391
|
+
break
|
|
392
|
+
if exact_only:
|
|
393
|
+
return child_base == target
|
|
394
|
+
else:
|
|
395
|
+
return child_base == target or target in child_lower
|
|
396
|
+
|
|
397
|
+
def search_breadth_first(category, exact_only=False):
|
|
398
|
+
"""Breadth-first search: check all top-level items first, then recurse.
|
|
399
|
+
This ensures raw 'Operator' is found before 'Hello Operator.adg' buried
|
|
400
|
+
in a user_library subfolder."""
|
|
304
401
|
nonlocal iterations
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
402
|
+
# Queue of (item, depth) tuples
|
|
403
|
+
queue = [(category, 0)]
|
|
404
|
+
while queue:
|
|
405
|
+
item, depth = queue.pop(0)
|
|
406
|
+
if depth > 8:
|
|
407
|
+
continue
|
|
408
|
+
try:
|
|
409
|
+
children = list(item.children)
|
|
410
|
+
except AttributeError:
|
|
411
|
+
continue
|
|
412
|
+
for child in children:
|
|
413
|
+
iterations += 1
|
|
414
|
+
if iterations > MAX_ITERATIONS:
|
|
415
|
+
return None
|
|
416
|
+
if _name_matches(child.name, device_name, exact_only) and child.is_loadable:
|
|
417
|
+
return child
|
|
418
|
+
# Queue children for later (breadth-first)
|
|
419
|
+
if child.is_folder:
|
|
420
|
+
queue.append((child, depth + 1))
|
|
322
421
|
return None
|
|
323
422
|
|
|
324
423
|
# Search device categories only — never samples (avoids "Castanet Reverb.aif"
|
|
325
|
-
# matching before the actual Reverb device).
|
|
424
|
+
# matching before the actual Reverb device).
|
|
425
|
+
# plugins + max_for_live included for AU/VST/AUv3 and M4L devices.
|
|
326
426
|
category_attrs = (
|
|
327
427
|
"audio_effects", "instruments", "midi_effects",
|
|
328
|
-
"
|
|
428
|
+
"plugins", "max_for_live", "user_library",
|
|
429
|
+
"drums", "sounds", "packs",
|
|
329
430
|
)
|
|
330
431
|
categories = []
|
|
331
432
|
for attr in category_attrs:
|
|
@@ -334,9 +435,38 @@ def find_and_load_device(song, params):
|
|
|
334
435
|
except AttributeError:
|
|
335
436
|
pass
|
|
336
437
|
|
|
438
|
+
# Pass 0: FAST — check only top-level children of each category (no recursion).
|
|
439
|
+
# Raw devices like "Operator", "Analog", "Compressor" are always top-level.
|
|
440
|
+
# This is O(N) where N = number of top-level items (~50), not O(thousands).
|
|
441
|
+
for category in categories:
|
|
442
|
+
try:
|
|
443
|
+
for child in category.children:
|
|
444
|
+
if _name_matches(child.name, device_name, True) and child.is_loadable:
|
|
445
|
+
song.view.selected_track = track
|
|
446
|
+
browser.load_item(child)
|
|
447
|
+
return {
|
|
448
|
+
"loaded": child.name,
|
|
449
|
+
"track_index": track_index,
|
|
450
|
+
}
|
|
451
|
+
except AttributeError:
|
|
452
|
+
pass
|
|
453
|
+
|
|
454
|
+
# Pass 1: exact name match with recursion (for items nested in folders)
|
|
455
|
+
for category in categories:
|
|
456
|
+
iterations = 0
|
|
457
|
+
found = search_breadth_first(category, exact_only=True)
|
|
458
|
+
if found is not None:
|
|
459
|
+
song.view.selected_track = track
|
|
460
|
+
browser.load_item(found)
|
|
461
|
+
return {
|
|
462
|
+
"loaded": found.name,
|
|
463
|
+
"track_index": track_index,
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
# Pass 2: partial name match (for M4L devices like "trnr.Kickster")
|
|
337
467
|
for category in categories:
|
|
338
468
|
iterations = 0
|
|
339
|
-
found =
|
|
469
|
+
found = search_breadth_first(category, exact_only=False)
|
|
340
470
|
if found is not None:
|
|
341
471
|
song.view.selected_track = track
|
|
342
472
|
browser.load_item(found)
|
|
@@ -24,9 +24,11 @@ WRITE_COMMANDS = frozenset([
|
|
|
24
24
|
"create_midi_track", "create_audio_track", "create_return_track",
|
|
25
25
|
"delete_track", "duplicate_track", "set_track_name", "set_track_color",
|
|
26
26
|
"set_track_mute", "set_track_solo", "set_track_arm", "stop_track_clips",
|
|
27
|
+
"set_group_fold", "set_track_input_monitoring",
|
|
27
28
|
# clips
|
|
28
29
|
"create_clip", "delete_clip", "duplicate_clip", "fire_clip", "stop_clip",
|
|
29
30
|
"set_clip_name", "set_clip_color", "set_clip_loop", "set_clip_launch",
|
|
31
|
+
"set_clip_warp_mode",
|
|
30
32
|
# notes
|
|
31
33
|
"add_notes", "remove_notes", "remove_notes_by_id", "modify_notes",
|
|
32
34
|
"duplicate_notes", "transpose_notes", "quantize_clip",
|
|
@@ -36,7 +38,7 @@ WRITE_COMMANDS = frozenset([
|
|
|
36
38
|
"set_chain_volume", "set_simpler_playback_mode",
|
|
37
39
|
# scenes
|
|
38
40
|
"create_scene", "delete_scene", "duplicate_scene", "fire_scene",
|
|
39
|
-
"set_scene_name",
|
|
41
|
+
"set_scene_name", "set_scene_color", "set_scene_tempo",
|
|
40
42
|
# mixing
|
|
41
43
|
"set_track_volume", "set_track_pan", "set_track_send",
|
|
42
44
|
"set_master_volume", "set_track_routing",
|
|
@@ -44,7 +46,12 @@ WRITE_COMMANDS = frozenset([
|
|
|
44
46
|
"load_browser_item",
|
|
45
47
|
# arrangement
|
|
46
48
|
"jump_to_time", "jump_to_cue", "capture_midi", "start_recording",
|
|
47
|
-
"stop_recording", "toggle_cue_point",
|
|
49
|
+
"stop_recording", "toggle_cue_point", "back_to_arranger",
|
|
50
|
+
"create_arrangement_clip", "add_arrangement_notes",
|
|
51
|
+
"remove_arrangement_notes", "remove_arrangement_notes_by_id",
|
|
52
|
+
"modify_arrangement_notes", "duplicate_arrangement_notes",
|
|
53
|
+
"transpose_arrangement_notes", "set_arrangement_automation",
|
|
54
|
+
"set_arrangement_clip_name",
|
|
48
55
|
])
|
|
49
56
|
|
|
50
57
|
|
|
@@ -127,9 +127,11 @@ def set_session_loop(song, params):
|
|
|
127
127
|
if "loop_length" in params:
|
|
128
128
|
song.loop_length = float(params["loop_length"])
|
|
129
129
|
# Set enabled LAST so it sticks
|
|
130
|
-
|
|
130
|
+
enabled = bool(params["enabled"])
|
|
131
|
+
song.loop = enabled
|
|
132
|
+
# Echo requested value — song.loop getter may return stale state
|
|
131
133
|
return {
|
|
132
|
-
"loop":
|
|
134
|
+
"loop": enabled,
|
|
133
135
|
"loop_start": song.loop_start,
|
|
134
136
|
"loop_length": song.loop_length,
|
|
135
137
|
}
|
|
@@ -92,10 +92,15 @@ def get_clip(song, track_index, clip_index):
|
|
|
92
92
|
def get_device(track, device_index):
|
|
93
93
|
"""Return a device from a track by index."""
|
|
94
94
|
devices = list(track.devices)
|
|
95
|
+
if not devices:
|
|
96
|
+
raise IndexError(
|
|
97
|
+
"Track '%s' has no devices — load an instrument or effect first"
|
|
98
|
+
% track.name
|
|
99
|
+
)
|
|
95
100
|
if device_index < 0 or device_index >= len(devices):
|
|
96
101
|
raise IndexError(
|
|
97
|
-
"Device index %d out of range (0..%d)"
|
|
98
|
-
% (device_index, len(devices) - 1)
|
|
102
|
+
"Device index %d out of range (0..%d) on track '%s'"
|
|
103
|
+
% (device_index, len(devices) - 1, track.name)
|
|
99
104
|
)
|
|
100
105
|
return devices[device_index]
|
|
101
106
|
|