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,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
+ }