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.
Files changed (32) hide show
  1. package/.mcp.json +9 -0
  2. package/CHANGELOG.md +29 -0
  3. package/README.md +31 -29
  4. package/mcp_server/memory/technique_store.py +14 -19
  5. package/mcp_server/tools/arrangement.py +1 -0
  6. package/mcp_server/tools/browser.py +4 -0
  7. package/mcp_server/tools/clips.py +1 -0
  8. package/mcp_server/tools/notes.py +1 -0
  9. package/mcp_server/tools/tracks.py +1 -0
  10. package/package.json +1 -1
  11. package/plugin/plugin.json +1 -1
  12. package/plugin/skills/livepilot-core/references/device-atlas/00-index.md +110 -0
  13. package/plugin/skills/livepilot-core/references/device-atlas/distortion-and-character.md +687 -0
  14. package/plugin/skills/livepilot-core/references/device-atlas/drums-and-percussion.md +753 -0
  15. package/plugin/skills/livepilot-core/references/device-atlas/dynamics-and-punch.md +525 -0
  16. package/plugin/skills/livepilot-core/references/device-atlas/eq-and-filtering.md +402 -0
  17. package/plugin/skills/livepilot-core/references/device-atlas/midi-tools.md +963 -0
  18. package/plugin/skills/livepilot-core/references/device-atlas/movement-and-modulation.md +874 -0
  19. package/plugin/skills/livepilot-core/references/device-atlas/plugins-synths.md +2012 -0
  20. package/plugin/skills/livepilot-core/references/device-atlas/presets-by-vibe.md +727 -0
  21. package/plugin/skills/livepilot-core/references/device-atlas/samples-and-irs.md +598 -0
  22. package/plugin/skills/livepilot-core/references/device-atlas/space-and-depth.md +571 -0
  23. package/plugin/skills/livepilot-core/references/device-atlas/spectral-and-weird.md +714 -0
  24. package/plugin/skills/livepilot-core/references/device-atlas/synths-m4l.md +730 -0
  25. package/plugin/skills/livepilot-core/references/device-atlas/synths-native.md +953 -0
  26. package/plugin/skills/livepilot-core/references/device-atlas/utility-and-workflow.md +843 -0
  27. package/remote_script/LivePilot/arrangement.py +2 -1
  28. package/remote_script/LivePilot/browser.py +91 -8
  29. package/remote_script/LivePilot/devices.py +164 -34
  30. package/remote_script/LivePilot/server.py +9 -2
  31. package/remote_script/LivePilot/transport.py +4 -2
  32. 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 {"current_song_time": song.current_song_time}
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
- return {
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
- # All categories to search
184
- category_attrs = (
185
- "user_library", "samples", "instruments", "audio_effects",
186
- "midi_effects", "packs", "sounds", "drums",
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 = 10000
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
- % (parameter_name, device.name)
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 == name_or_index:
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
- % (name_or_index, device.name)
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
- # All categories to search
191
- category_attrs = (
192
- "user_library", "samples", "instruments", "audio_effects",
193
- "midi_effects", "packs", "sounds", "drums",
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 = 10000
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 = 10000
380
+ MAX_ITERATIONS = 50000
300
381
  iterations = 0
301
382
 
302
- def search_children(item, depth=0):
303
- """Recursively search browser children up to depth 8."""
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
- if depth > 8:
306
- return None
307
- try:
308
- children = list(item.children)
309
- except AttributeError:
310
- return None
311
- for child in children:
312
- iterations += 1
313
- if iterations > MAX_ITERATIONS:
314
- return None
315
- child_lower = child.name.lower()
316
- # Exact match or partial match (e.g. "kickster" in "trnr.kickster")
317
- if (child_lower == device_name or device_name in child_lower) and child.is_loadable:
318
- return child
319
- result = search_children(child, depth + 1)
320
- if result is not None:
321
- return result
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). user_library first for M4L.
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
- "user_library", "drums", "sounds", "packs",
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 = search_children(category)
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
- song.loop = bool(params["enabled"])
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": song.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