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.
Files changed (64) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/LICENSE +21 -0
  3. package/README.md +409 -0
  4. package/bin/livepilot.js +390 -0
  5. package/installer/install.js +95 -0
  6. package/installer/paths.js +79 -0
  7. package/mcp_server/__init__.py +2 -0
  8. package/mcp_server/__main__.py +5 -0
  9. package/mcp_server/connection.py +210 -0
  10. package/mcp_server/memory/__init__.py +5 -0
  11. package/mcp_server/memory/technique_store.py +296 -0
  12. package/mcp_server/server.py +87 -0
  13. package/mcp_server/tools/__init__.py +1 -0
  14. package/mcp_server/tools/arrangement.py +407 -0
  15. package/mcp_server/tools/browser.py +86 -0
  16. package/mcp_server/tools/clips.py +218 -0
  17. package/mcp_server/tools/devices.py +256 -0
  18. package/mcp_server/tools/memory.py +198 -0
  19. package/mcp_server/tools/mixing.py +121 -0
  20. package/mcp_server/tools/notes.py +269 -0
  21. package/mcp_server/tools/scenes.py +89 -0
  22. package/mcp_server/tools/tracks.py +175 -0
  23. package/mcp_server/tools/transport.py +117 -0
  24. package/package.json +37 -0
  25. package/plugin/agents/livepilot-producer/AGENT.md +62 -0
  26. package/plugin/commands/beat.md +18 -0
  27. package/plugin/commands/memory.md +22 -0
  28. package/plugin/commands/mix.md +15 -0
  29. package/plugin/commands/session.md +13 -0
  30. package/plugin/commands/sounddesign.md +16 -0
  31. package/plugin/plugin.json +19 -0
  32. package/plugin/skills/livepilot-core/SKILL.md +208 -0
  33. package/plugin/skills/livepilot-core/references/ableton-workflow-patterns.md +831 -0
  34. package/plugin/skills/livepilot-core/references/device-atlas/00-index.md +110 -0
  35. package/plugin/skills/livepilot-core/references/device-atlas/distortion-and-character.md +687 -0
  36. package/plugin/skills/livepilot-core/references/device-atlas/drums-and-percussion.md +753 -0
  37. package/plugin/skills/livepilot-core/references/device-atlas/dynamics-and-punch.md +525 -0
  38. package/plugin/skills/livepilot-core/references/device-atlas/eq-and-filtering.md +402 -0
  39. package/plugin/skills/livepilot-core/references/device-atlas/midi-tools.md +963 -0
  40. package/plugin/skills/livepilot-core/references/device-atlas/movement-and-modulation.md +874 -0
  41. package/plugin/skills/livepilot-core/references/device-atlas/space-and-depth.md +571 -0
  42. package/plugin/skills/livepilot-core/references/device-atlas/spectral-and-weird.md +714 -0
  43. package/plugin/skills/livepilot-core/references/device-atlas/synths-native.md +953 -0
  44. package/plugin/skills/livepilot-core/references/m4l-devices.md +352 -0
  45. package/plugin/skills/livepilot-core/references/memory-guide.md +107 -0
  46. package/plugin/skills/livepilot-core/references/midi-recipes.md +402 -0
  47. package/plugin/skills/livepilot-core/references/mixing-patterns.md +578 -0
  48. package/plugin/skills/livepilot-core/references/overview.md +209 -0
  49. package/plugin/skills/livepilot-core/references/sound-design.md +392 -0
  50. package/remote_script/LivePilot/__init__.py +42 -0
  51. package/remote_script/LivePilot/arrangement.py +693 -0
  52. package/remote_script/LivePilot/browser.py +424 -0
  53. package/remote_script/LivePilot/clips.py +211 -0
  54. package/remote_script/LivePilot/devices.py +596 -0
  55. package/remote_script/LivePilot/diagnostics.py +198 -0
  56. package/remote_script/LivePilot/mixing.py +194 -0
  57. package/remote_script/LivePilot/notes.py +339 -0
  58. package/remote_script/LivePilot/router.py +74 -0
  59. package/remote_script/LivePilot/scenes.py +99 -0
  60. package/remote_script/LivePilot/server.py +293 -0
  61. package/remote_script/LivePilot/tracks.py +268 -0
  62. package/remote_script/LivePilot/transport.py +151 -0
  63. package/remote_script/LivePilot/utils.py +123 -0
  64. 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
+ }