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,596 @@
|
|
|
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 AttributeError:
|
|
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
|
+
# Try exact match first
|
|
75
|
+
for p in device.parameters:
|
|
76
|
+
if p.name == parameter_name:
|
|
77
|
+
param = p
|
|
78
|
+
break
|
|
79
|
+
# Fallback: case-insensitive match
|
|
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]]
|
|
88
|
+
raise ValueError(
|
|
89
|
+
"Parameter '%s' not found on device '%s'. "
|
|
90
|
+
"Available (first 20): %s"
|
|
91
|
+
% (parameter_name, device.name, ", ".join(available))
|
|
92
|
+
)
|
|
93
|
+
elif parameter_index is not None:
|
|
94
|
+
parameter_index = int(parameter_index)
|
|
95
|
+
dev_params = list(device.parameters)
|
|
96
|
+
if parameter_index < 0 or parameter_index >= len(dev_params):
|
|
97
|
+
raise IndexError(
|
|
98
|
+
"Parameter index %d out of range (0..%d)"
|
|
99
|
+
% (parameter_index, len(dev_params) - 1)
|
|
100
|
+
)
|
|
101
|
+
param = dev_params[parameter_index]
|
|
102
|
+
else:
|
|
103
|
+
raise ValueError("Must provide parameter_name or parameter_index")
|
|
104
|
+
|
|
105
|
+
param.value = value
|
|
106
|
+
return {"name": param.name, "value": param.value}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@register("batch_set_parameters")
|
|
110
|
+
def batch_set_parameters(song, params):
|
|
111
|
+
"""Set multiple device parameters in one call."""
|
|
112
|
+
track_index = int(params["track_index"])
|
|
113
|
+
device_index = int(params["device_index"])
|
|
114
|
+
parameters = params["parameters"]
|
|
115
|
+
track = get_track(song, track_index)
|
|
116
|
+
device = get_device(track, device_index)
|
|
117
|
+
|
|
118
|
+
dev_params = list(device.parameters)
|
|
119
|
+
results = []
|
|
120
|
+
for entry in parameters:
|
|
121
|
+
value = float(entry["value"])
|
|
122
|
+
name_or_index = entry.get("name_or_index")
|
|
123
|
+
|
|
124
|
+
if isinstance(name_or_index, int) or (
|
|
125
|
+
isinstance(name_or_index, str) and name_or_index.isdigit()
|
|
126
|
+
):
|
|
127
|
+
idx = int(name_or_index)
|
|
128
|
+
if idx < 0 or idx >= len(dev_params):
|
|
129
|
+
raise IndexError(
|
|
130
|
+
"Parameter index %d out of range (0..%d)"
|
|
131
|
+
% (idx, len(dev_params) - 1)
|
|
132
|
+
)
|
|
133
|
+
param = dev_params[idx]
|
|
134
|
+
else:
|
|
135
|
+
param = None
|
|
136
|
+
target = str(name_or_index)
|
|
137
|
+
# Try exact match first
|
|
138
|
+
for p in dev_params:
|
|
139
|
+
if p.name == target:
|
|
140
|
+
param = p
|
|
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
|
|
149
|
+
if param is None:
|
|
150
|
+
# List similar parameter names for debugging
|
|
151
|
+
available = [p.name for p in dev_params[:20]]
|
|
152
|
+
raise ValueError(
|
|
153
|
+
"Parameter '%s' not found on device '%s'. "
|
|
154
|
+
"Available (first 20): %s"
|
|
155
|
+
% (name_or_index, device.name, ", ".join(available))
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
param.value = value
|
|
159
|
+
results.append({"name": param.name, "value": param.value})
|
|
160
|
+
|
|
161
|
+
return {"parameters": results}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@register("toggle_device")
|
|
165
|
+
def toggle_device(song, params):
|
|
166
|
+
"""Enable or disable a device."""
|
|
167
|
+
track_index = int(params["track_index"])
|
|
168
|
+
device_index = int(params["device_index"])
|
|
169
|
+
active = bool(params["active"])
|
|
170
|
+
track = get_track(song, track_index)
|
|
171
|
+
device = get_device(track, device_index)
|
|
172
|
+
|
|
173
|
+
# Find the "Device On" parameter by name (safer than assuming index 0)
|
|
174
|
+
on_param = None
|
|
175
|
+
for p in device.parameters:
|
|
176
|
+
if p.name == "Device On":
|
|
177
|
+
on_param = p
|
|
178
|
+
break
|
|
179
|
+
if on_param is None:
|
|
180
|
+
# Fallback to parameter 0 for devices that don't use "Device On"
|
|
181
|
+
on_param = device.parameters[0]
|
|
182
|
+
|
|
183
|
+
on_param.value = 1.0 if active else 0.0
|
|
184
|
+
return {"name": device.name, "is_active": on_param.value > 0.5}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@register("delete_device")
|
|
188
|
+
def delete_device(song, params):
|
|
189
|
+
"""Delete a device from a track."""
|
|
190
|
+
track_index = int(params["track_index"])
|
|
191
|
+
device_index = int(params["device_index"])
|
|
192
|
+
track = get_track(song, track_index)
|
|
193
|
+
# Validate device exists
|
|
194
|
+
get_device(track, device_index)
|
|
195
|
+
track.delete_device(device_index)
|
|
196
|
+
return {"deleted": device_index}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@register("load_device_by_uri")
|
|
200
|
+
def load_device_by_uri(song, params):
|
|
201
|
+
"""Load a device onto a track using a browser URI.
|
|
202
|
+
|
|
203
|
+
First tries URI-based matching (exact child.uri comparison).
|
|
204
|
+
Falls back to name extraction from the URI's last path segment.
|
|
205
|
+
Searches all browser categories including user_library and samples.
|
|
206
|
+
"""
|
|
207
|
+
track_index = int(params["track_index"])
|
|
208
|
+
uri = str(params["uri"])
|
|
209
|
+
track = get_track(song, track_index)
|
|
210
|
+
browser = _get_browser()
|
|
211
|
+
|
|
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
|
+
|
|
237
|
+
categories = []
|
|
238
|
+
for attr in category_attrs:
|
|
239
|
+
try:
|
|
240
|
+
categories.append(getattr(browser, attr))
|
|
241
|
+
except AttributeError:
|
|
242
|
+
pass
|
|
243
|
+
|
|
244
|
+
_iterations = [0]
|
|
245
|
+
MAX_ITERATIONS = 50000
|
|
246
|
+
|
|
247
|
+
# ── Strategy 1: match by URI directly ────────────────────────────
|
|
248
|
+
def find_by_uri(parent, target_uri, depth=0):
|
|
249
|
+
if depth > 8 or _iterations[0] > MAX_ITERATIONS:
|
|
250
|
+
return None
|
|
251
|
+
try:
|
|
252
|
+
children = list(parent.children)
|
|
253
|
+
except AttributeError:
|
|
254
|
+
return None
|
|
255
|
+
for child in children:
|
|
256
|
+
_iterations[0] += 1
|
|
257
|
+
if _iterations[0] > MAX_ITERATIONS:
|
|
258
|
+
return None
|
|
259
|
+
try:
|
|
260
|
+
if child.uri == target_uri and child.is_loadable:
|
|
261
|
+
return child
|
|
262
|
+
except AttributeError:
|
|
263
|
+
pass
|
|
264
|
+
result = find_by_uri(child, target_uri, depth + 1)
|
|
265
|
+
if result is not None:
|
|
266
|
+
return result
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
for category in categories:
|
|
270
|
+
found = find_by_uri(category, uri)
|
|
271
|
+
if found is not None:
|
|
272
|
+
song.view.selected_track = track
|
|
273
|
+
browser.load_item(found)
|
|
274
|
+
return {"loaded": found.name, "track_index": track_index}
|
|
275
|
+
|
|
276
|
+
# ── Strategy 2: extract name from URI, search by name ────────────
|
|
277
|
+
device_name = uri
|
|
278
|
+
if "#" in uri:
|
|
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
|
+
|
|
319
|
+
for sep in (":", "/"):
|
|
320
|
+
if sep in device_name:
|
|
321
|
+
device_name = device_name.rsplit(sep, 1)[1]
|
|
322
|
+
# URL-decode
|
|
323
|
+
try:
|
|
324
|
+
from urllib.parse import unquote
|
|
325
|
+
device_name = unquote(device_name)
|
|
326
|
+
except ImportError:
|
|
327
|
+
device_name = device_name.replace("%20", " ")
|
|
328
|
+
# Strip file extensions
|
|
329
|
+
for ext in (".amxd", ".adv", ".adg", ".aupreset", ".als", ".wav", ".aif", ".aiff", ".mp3"):
|
|
330
|
+
if device_name.lower().endswith(ext):
|
|
331
|
+
device_name = device_name[:-len(ext)]
|
|
332
|
+
break
|
|
333
|
+
|
|
334
|
+
target = device_name.lower()
|
|
335
|
+
_iterations[0] = 0
|
|
336
|
+
|
|
337
|
+
def find_by_name(parent, depth=0):
|
|
338
|
+
if depth > 8 or _iterations[0] > MAX_ITERATIONS:
|
|
339
|
+
return None
|
|
340
|
+
try:
|
|
341
|
+
children = list(parent.children)
|
|
342
|
+
except AttributeError:
|
|
343
|
+
return None
|
|
344
|
+
for child in children:
|
|
345
|
+
_iterations[0] += 1
|
|
346
|
+
if _iterations[0] > MAX_ITERATIONS:
|
|
347
|
+
return None
|
|
348
|
+
child_lower = child.name.lower()
|
|
349
|
+
if (child_lower == target or target in child_lower) and child.is_loadable:
|
|
350
|
+
return child
|
|
351
|
+
result = find_by_name(child, depth + 1)
|
|
352
|
+
if result is not None:
|
|
353
|
+
return result
|
|
354
|
+
return None
|
|
355
|
+
|
|
356
|
+
for category in categories:
|
|
357
|
+
found = find_by_name(category)
|
|
358
|
+
if found is not None:
|
|
359
|
+
song.view.selected_track = track
|
|
360
|
+
browser.load_item(found)
|
|
361
|
+
return {"loaded": found.name, "track_index": track_index}
|
|
362
|
+
|
|
363
|
+
raise ValueError(
|
|
364
|
+
"Device '%s' not found in browser" % device_name
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
@register("find_and_load_device")
|
|
369
|
+
def find_and_load_device(song, params):
|
|
370
|
+
"""Find a device by name in the browser and load it onto a track.
|
|
371
|
+
|
|
372
|
+
Searches all browser categories including user_library for M4L devices.
|
|
373
|
+
Supports partial matching: 'Kickster' matches 'trnr.Kickster'.
|
|
374
|
+
"""
|
|
375
|
+
track_index = int(params["track_index"])
|
|
376
|
+
device_name = str(params["device_name"]).lower()
|
|
377
|
+
track = get_track(song, track_index)
|
|
378
|
+
browser = _get_browser()
|
|
379
|
+
|
|
380
|
+
MAX_ITERATIONS = 50000
|
|
381
|
+
iterations = 0
|
|
382
|
+
|
|
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."""
|
|
401
|
+
nonlocal iterations
|
|
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))
|
|
421
|
+
return None
|
|
422
|
+
|
|
423
|
+
# Search device categories only — never samples (avoids "Castanet Reverb.aif"
|
|
424
|
+
# matching before the actual Reverb device).
|
|
425
|
+
# plugins + max_for_live included for AU/VST/AUv3 and M4L devices.
|
|
426
|
+
category_attrs = (
|
|
427
|
+
"audio_effects", "instruments", "midi_effects",
|
|
428
|
+
"plugins", "max_for_live", "user_library",
|
|
429
|
+
"drums", "sounds", "packs",
|
|
430
|
+
)
|
|
431
|
+
categories = []
|
|
432
|
+
for attr in category_attrs:
|
|
433
|
+
try:
|
|
434
|
+
categories.append(getattr(browser, attr))
|
|
435
|
+
except AttributeError:
|
|
436
|
+
pass
|
|
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")
|
|
467
|
+
for category in categories:
|
|
468
|
+
iterations = 0
|
|
469
|
+
found = search_breadth_first(category, exact_only=False)
|
|
470
|
+
if found is not None:
|
|
471
|
+
song.view.selected_track = track
|
|
472
|
+
browser.load_item(found)
|
|
473
|
+
return {
|
|
474
|
+
"loaded": found.name,
|
|
475
|
+
"track_index": track_index,
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
raise ValueError(
|
|
479
|
+
"Device '%s' not found in browser. Check spelling or use "
|
|
480
|
+
"search_browser to find the exact name." % params["device_name"]
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
@register("set_simpler_playback_mode")
|
|
485
|
+
def set_simpler_playback_mode(song, params):
|
|
486
|
+
"""Set Simpler's playback mode (Classic/One-Shot/Slice).
|
|
487
|
+
|
|
488
|
+
playback_mode: 0=Classic, 1=One-Shot, 2=Slice
|
|
489
|
+
slice_by (optional, only for Slice mode): 0=Transient, 1=Beat, 2=Region, 3=Manual
|
|
490
|
+
sensitivity (optional, 0.0-1.0, only for Transient slicing)
|
|
491
|
+
"""
|
|
492
|
+
track_index = int(params["track_index"])
|
|
493
|
+
device_index = int(params["device_index"])
|
|
494
|
+
playback_mode = int(params["playback_mode"])
|
|
495
|
+
track = get_track(song, track_index)
|
|
496
|
+
device = get_device(track, device_index)
|
|
497
|
+
|
|
498
|
+
if device.class_name != "OriginalSimpler":
|
|
499
|
+
raise ValueError(
|
|
500
|
+
"Device '%s' is %s, not Simpler"
|
|
501
|
+
% (device.name, device.class_name)
|
|
502
|
+
)
|
|
503
|
+
if playback_mode not in (0, 1, 2):
|
|
504
|
+
raise ValueError("playback_mode must be 0 (Classic), 1 (One-Shot), or 2 (Slice)")
|
|
505
|
+
|
|
506
|
+
device.playback_mode = playback_mode
|
|
507
|
+
|
|
508
|
+
result = {
|
|
509
|
+
"track_index": track_index,
|
|
510
|
+
"device_index": device_index,
|
|
511
|
+
"playback_mode": playback_mode,
|
|
512
|
+
"mode_name": ["Classic", "One-Shot", "Slice"][playback_mode],
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
# Set slicing style if in Slice mode
|
|
516
|
+
if playback_mode == 2:
|
|
517
|
+
slice_by = params.get("slice_by", None)
|
|
518
|
+
if slice_by is not None:
|
|
519
|
+
slice_by = int(slice_by)
|
|
520
|
+
if slice_by not in (0, 1, 2, 3):
|
|
521
|
+
raise ValueError(
|
|
522
|
+
"slice_by must be 0 (Transient), 1 (Beat), 2 (Region), or 3 (Manual)"
|
|
523
|
+
)
|
|
524
|
+
device.slicing_style = slice_by
|
|
525
|
+
result["slice_by"] = slice_by
|
|
526
|
+
result["slice_by_name"] = ["Transient", "Beat", "Region", "Manual"][slice_by]
|
|
527
|
+
|
|
528
|
+
sensitivity = params.get("sensitivity", None)
|
|
529
|
+
if sensitivity is not None:
|
|
530
|
+
sensitivity = float(sensitivity)
|
|
531
|
+
device.slicing_sensitivity = max(0.0, min(1.0, sensitivity))
|
|
532
|
+
result["sensitivity"] = device.slicing_sensitivity
|
|
533
|
+
|
|
534
|
+
return result
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
@register("get_rack_chains")
|
|
538
|
+
def get_rack_chains(song, params):
|
|
539
|
+
"""Return chain info for a rack device."""
|
|
540
|
+
track_index = int(params["track_index"])
|
|
541
|
+
device_index = int(params["device_index"])
|
|
542
|
+
track = get_track(song, track_index)
|
|
543
|
+
device = get_device(track, device_index)
|
|
544
|
+
|
|
545
|
+
if not device.can_have_chains:
|
|
546
|
+
raise ValueError(
|
|
547
|
+
"Device '%s' is not a rack and cannot have chains" % device.name
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
chains = []
|
|
551
|
+
for i, chain in enumerate(device.chains):
|
|
552
|
+
chain_info = {
|
|
553
|
+
"index": i,
|
|
554
|
+
"name": chain.name,
|
|
555
|
+
"volume": chain.mixer_device.volume.value,
|
|
556
|
+
"pan": chain.mixer_device.panning.value,
|
|
557
|
+
"mute": chain.mute,
|
|
558
|
+
"solo": chain.solo,
|
|
559
|
+
}
|
|
560
|
+
chains.append(chain_info)
|
|
561
|
+
return {"chains": chains}
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
@register("set_chain_volume")
|
|
565
|
+
def set_chain_volume(song, params):
|
|
566
|
+
"""Set volume and/or pan for a rack chain."""
|
|
567
|
+
track_index = int(params["track_index"])
|
|
568
|
+
device_index = int(params["device_index"])
|
|
569
|
+
chain_index = int(params["chain_index"])
|
|
570
|
+
track = get_track(song, track_index)
|
|
571
|
+
device = get_device(track, device_index)
|
|
572
|
+
|
|
573
|
+
if not device.can_have_chains:
|
|
574
|
+
raise ValueError(
|
|
575
|
+
"Device '%s' is not a rack and cannot have chains" % device.name
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
chains = list(device.chains)
|
|
579
|
+
if chain_index < 0 or chain_index >= len(chains):
|
|
580
|
+
raise IndexError(
|
|
581
|
+
"Chain index %d out of range (0..%d)"
|
|
582
|
+
% (chain_index, len(chains) - 1)
|
|
583
|
+
)
|
|
584
|
+
chain = chains[chain_index]
|
|
585
|
+
|
|
586
|
+
if "volume" in params:
|
|
587
|
+
chain.mixer_device.volume.value = float(params["volume"])
|
|
588
|
+
if "pan" in params:
|
|
589
|
+
chain.mixer_device.panning.value = float(params["pan"])
|
|
590
|
+
|
|
591
|
+
return {
|
|
592
|
+
"index": chain_index,
|
|
593
|
+
"name": chain.name,
|
|
594
|
+
"volume": chain.mixer_device.volume.value,
|
|
595
|
+
"pan": chain.mixer_device.panning.value,
|
|
596
|
+
}
|