livepilot 1.10.8 → 1.12.2

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 (49) hide show
  1. package/CHANGELOG.md +373 -0
  2. package/README.md +16 -16
  3. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  4. package/m4l_device/livepilot_bridge.js +1 -1
  5. package/mcp_server/__init__.py +1 -1
  6. package/mcp_server/evaluation/fabric.py +62 -1
  7. package/mcp_server/m4l_bridge.py +503 -18
  8. package/mcp_server/project_brain/automation_graph.py +23 -1
  9. package/mcp_server/project_brain/builder.py +2 -0
  10. package/mcp_server/project_brain/models.py +20 -1
  11. package/mcp_server/project_brain/tools.py +10 -3
  12. package/mcp_server/runtime/execution_router.py +7 -0
  13. package/mcp_server/runtime/mcp_dispatch.py +32 -0
  14. package/mcp_server/runtime/remote_commands.py +54 -0
  15. package/mcp_server/sample_engine/slice_classifier.py +169 -0
  16. package/mcp_server/semantic_moves/tools.py +139 -31
  17. package/mcp_server/server.py +151 -17
  18. package/mcp_server/session_continuity/models.py +13 -0
  19. package/mcp_server/session_continuity/tools.py +2 -0
  20. package/mcp_server/session_continuity/tracker.py +93 -0
  21. package/mcp_server/tools/_analyzer_engine/__init__.py +39 -0
  22. package/mcp_server/tools/_analyzer_engine/context.py +103 -0
  23. package/mcp_server/tools/_analyzer_engine/flucoma.py +23 -0
  24. package/mcp_server/tools/_analyzer_engine/sample.py +122 -0
  25. package/mcp_server/tools/_motif_engine.py +19 -4
  26. package/mcp_server/tools/analyzer.py +204 -180
  27. package/mcp_server/tools/clips.py +304 -1
  28. package/mcp_server/tools/devices.py +517 -5
  29. package/mcp_server/tools/diagnostics.py +42 -0
  30. package/mcp_server/tools/follow_actions.py +202 -0
  31. package/mcp_server/tools/grooves.py +142 -0
  32. package/mcp_server/tools/miditool.py +280 -0
  33. package/mcp_server/tools/scales.py +126 -0
  34. package/mcp_server/tools/take_lanes.py +135 -0
  35. package/mcp_server/tools/tracks.py +46 -3
  36. package/mcp_server/tools/transport.py +120 -4
  37. package/package.json +2 -2
  38. package/remote_script/LivePilot/__init__.py +15 -4
  39. package/remote_script/LivePilot/clips.py +62 -0
  40. package/remote_script/LivePilot/devices.py +444 -0
  41. package/remote_script/LivePilot/diagnostics.py +52 -1
  42. package/remote_script/LivePilot/follow_actions.py +235 -0
  43. package/remote_script/LivePilot/grooves.py +185 -0
  44. package/remote_script/LivePilot/scales.py +138 -0
  45. package/remote_script/LivePilot/take_lanes.py +175 -0
  46. package/remote_script/LivePilot/tracks.py +59 -1
  47. package/remote_script/LivePilot/transport.py +90 -1
  48. package/remote_script/LivePilot/version_detect.py +9 -0
  49. package/server.json +3 -3
@@ -0,0 +1,175 @@
1
+ """
2
+ LivePilot — Take Lanes handlers (Live 12.0 UI / 12.2 API).
3
+
4
+ Take lanes are alternative clip rows stacked under an arrangement
5
+ track — they let you audition or comp multiple passes of the same
6
+ part without occupying extra tracks. Live 12.0 introduced them in
7
+ the UI; the scripting API that lets us create/rename them and
8
+ attach clips landed in Live 12.2.
9
+
10
+ Read-only introspection (``get_take_lanes`` / ``get_take_lane_clips``)
11
+ works on any Live 12.x since it's pure attribute traversal — we only
12
+ feature-gate the mutation ops behind ``take_lanes_api`` (12.2.0).
13
+
14
+ The ``track.take_lanes`` list grows through ``track.create_take_lane()``
15
+ (when exposed) and each TakeLane exposes ``name``, ``is_frozen``,
16
+ ``clips``, and — in 12.2+ — ``create_audio_clip`` / ``create_midi_clip``.
17
+ """
18
+
19
+ from .router import register
20
+ from .utils import get_track
21
+
22
+
23
+ def _get_take_lane(track, lane_index):
24
+ """Resolve a lane_index to a TakeLane object, raising IndexError on miss."""
25
+ lanes = list(getattr(track, "take_lanes", []))
26
+ idx = int(lane_index)
27
+ if not 0 <= idx < len(lanes):
28
+ raise IndexError(
29
+ "lane_index %d out of range (0..%d). Track has %d take lanes."
30
+ % (idx, len(lanes) - 1 if lanes else -1, len(lanes))
31
+ )
32
+ return lanes[idx]
33
+
34
+
35
+ @register("get_take_lanes")
36
+ def get_take_lanes(song, params):
37
+ """List all take lanes on a track.
38
+
39
+ Pure introspection — works on Live 12.0+ even when the creation
40
+ API isn't exposed. Returns an empty list if the track doesn't
41
+ expose ``take_lanes`` at all.
42
+ """
43
+ track = get_track(song, int(params["track_index"]))
44
+ lanes = getattr(track, "take_lanes", None)
45
+ if lanes is None:
46
+ return {"lanes": []}
47
+ out = []
48
+ for i, lane in enumerate(lanes):
49
+ out.append({
50
+ "index": i,
51
+ "name": str(getattr(lane, "name", "")),
52
+ "is_frozen": bool(getattr(lane, "is_frozen", False)),
53
+ "clip_count": len(list(getattr(lane, "clips", []))),
54
+ })
55
+ return {"lanes": out}
56
+
57
+
58
+ @register("create_take_lane")
59
+ def create_take_lane(song, params):
60
+ """Create a new take lane on a track (Live 12.2+).
61
+
62
+ Returns the new lane's index and name. Raises if the Live version
63
+ predates 12.2 or if the specific build doesn't expose
64
+ ``Track.create_take_lane`` — some pre-release 12.2 builds shipped
65
+ the TakeLane read surface without the create method.
66
+ """
67
+ from .version_detect import has_feature
68
+ if not has_feature("take_lanes_api"):
69
+ raise RuntimeError("Take lane creation requires Live 12.2+.")
70
+ track = get_track(song, int(params["track_index"]))
71
+ if not hasattr(track, "create_take_lane"):
72
+ raise RuntimeError(
73
+ "Track.create_take_lane is not available in this Live version."
74
+ )
75
+ track.create_take_lane()
76
+ lanes = list(track.take_lanes)
77
+ new_index = len(lanes) - 1
78
+ lane = lanes[new_index]
79
+ return {
80
+ "lane_index": new_index,
81
+ "name": str(getattr(lane, "name", "")),
82
+ }
83
+
84
+
85
+ @register("set_take_lane_name")
86
+ def set_take_lane_name(song, params):
87
+ """Rename an existing take lane (Live 12.2+)."""
88
+ from .version_detect import has_feature
89
+ if not has_feature("take_lanes_api"):
90
+ raise RuntimeError("Take lane rename requires Live 12.2+.")
91
+ track = get_track(song, int(params["track_index"]))
92
+ lane = _get_take_lane(track, params["lane_index"])
93
+ lane.name = str(params["name"])
94
+ return {"name": str(lane.name)}
95
+
96
+
97
+ @register("create_audio_clip_on_take_lane")
98
+ def create_audio_clip_on_take_lane(song, params):
99
+ """Create an arrangement audio clip on a specific take lane (Live 12.2+).
100
+
101
+ start_time / length are in beats. length must be > 0. The track
102
+ must be an audio track — Live will raise if called on a MIDI track.
103
+ """
104
+ from .version_detect import has_feature
105
+ if not has_feature("take_lanes_api"):
106
+ raise RuntimeError("Take lane clip creation requires Live 12.2+.")
107
+ track = get_track(song, int(params["track_index"]))
108
+ lane = _get_take_lane(track, params["lane_index"])
109
+ if not hasattr(lane, "create_audio_clip"):
110
+ raise RuntimeError(
111
+ "TakeLane.create_audio_clip is not available in this Live version."
112
+ )
113
+ start = float(params["start_time"])
114
+ length = float(params["length"])
115
+ if length <= 0:
116
+ raise ValueError("length must be > 0")
117
+ lane.create_audio_clip(start, length)
118
+ return {
119
+ "ok": True,
120
+ "track_index": int(params["track_index"]),
121
+ "lane_index": int(params["lane_index"]),
122
+ "start_time": start,
123
+ "length": length,
124
+ }
125
+
126
+
127
+ @register("create_midi_clip_on_take_lane")
128
+ def create_midi_clip_on_take_lane(song, params):
129
+ """Create an arrangement MIDI clip on a specific take lane (Live 12.2+).
130
+
131
+ start_time / length are in beats. length must be > 0. The track
132
+ must be a MIDI track — Live will raise if called on an audio track.
133
+ """
134
+ from .version_detect import has_feature
135
+ if not has_feature("take_lanes_api"):
136
+ raise RuntimeError("Take lane clip creation requires Live 12.2+.")
137
+ track = get_track(song, int(params["track_index"]))
138
+ lane = _get_take_lane(track, params["lane_index"])
139
+ if not hasattr(lane, "create_midi_clip"):
140
+ raise RuntimeError(
141
+ "TakeLane.create_midi_clip is not available in this Live version."
142
+ )
143
+ start = float(params["start_time"])
144
+ length = float(params["length"])
145
+ if length <= 0:
146
+ raise ValueError("length must be > 0")
147
+ lane.create_midi_clip(start, length)
148
+ return {
149
+ "ok": True,
150
+ "track_index": int(params["track_index"]),
151
+ "lane_index": int(params["lane_index"]),
152
+ "start_time": start,
153
+ "length": length,
154
+ }
155
+
156
+
157
+ @register("get_take_lane_clips")
158
+ def get_take_lane_clips(song, params):
159
+ """List the arrangement clips on a specific take lane.
160
+
161
+ Pure introspection — no version gate. Returns ``{clips: [...]}``
162
+ where each clip entry has name, start_time, length, and
163
+ is_midi_clip.
164
+ """
165
+ track = get_track(song, int(params["track_index"]))
166
+ lane = _get_take_lane(track, params["lane_index"])
167
+ out = []
168
+ for clip in getattr(lane, "clips", []):
169
+ out.append({
170
+ "name": str(getattr(clip, "name", "")),
171
+ "start_time": float(getattr(clip, "start_time", 0.0)),
172
+ "length": float(getattr(clip, "length", 0.0)),
173
+ "is_midi_clip": bool(getattr(clip, "is_midi_clip", False)),
174
+ })
175
+ return {"clips": out}
@@ -1,5 +1,5 @@
1
1
  """
2
- LivePilot - Track domain handlers (17 commands).
2
+ LivePilot - Track domain handlers (20 commands).
3
3
  """
4
4
 
5
5
  from .router import register
@@ -404,3 +404,61 @@ def flatten_track(song, params):
404
404
  "track_index": track_index,
405
405
  "flattened": True,
406
406
  }
407
+
408
+
409
+ # ── Track long-tail primitives ──────────────────────────────────────────
410
+
411
+
412
+ @register("jump_in_session_clip")
413
+ def jump_in_session_clip(song, params):
414
+ """Jump playhead within a running session clip, in beats from start."""
415
+ track = get_track(song, int(params["track_index"]))
416
+ beats = float(params["beats"])
417
+ if not hasattr(track, "jump_in_running_session_clip"):
418
+ raise RuntimeError("jump_in_running_session_clip not exposed")
419
+ track.jump_in_running_session_clip(beats)
420
+ return {"track_index": int(params["track_index"]), "beats": beats}
421
+
422
+
423
+ @register("get_track_performance_impact")
424
+ def get_track_performance_impact(song, params):
425
+ """Read a track's CPU performance impact metric."""
426
+ track = get_track(song, int(params["track_index"]))
427
+ val = float(getattr(track, "performance_impact", 0.0))
428
+ return {"performance_impact": val}
429
+
430
+
431
+ @register("get_appointed_device")
432
+ def get_appointed_device(song, params):
433
+ """Return the Blue Hand (appointed/focused) device location.
434
+
435
+ Maps the Device object back to (track_index, device_index) by
436
+ scanning all tracks. Normalized track indices: 0..N-1 for regular
437
+ tracks, -1=A, -2=B, etc. for returns, -1000 for the master track.
438
+ """
439
+ dev = getattr(song, "appointed_device", None)
440
+ if dev is None:
441
+ return {"track_index": -1, "device_index": -1,
442
+ "track_name": "", "device_name": ""}
443
+ tracks = list(song.tracks)
444
+ returns = list(song.return_tracks)
445
+ master = song.master_track
446
+ for ti, track in enumerate(tracks):
447
+ for di, d in enumerate(track.devices):
448
+ if d == dev:
449
+ return {"track_index": ti, "device_index": di,
450
+ "track_name": str(track.name),
451
+ "device_name": str(d.name)}
452
+ for ti, track in enumerate(returns):
453
+ for di, d in enumerate(track.devices):
454
+ if d == dev:
455
+ return {"track_index": -1 - ti, "device_index": di,
456
+ "track_name": str(track.name),
457
+ "device_name": str(d.name)}
458
+ for di, d in enumerate(master.devices):
459
+ if d == dev:
460
+ return {"track_index": -1000, "device_index": di,
461
+ "track_name": str(master.name),
462
+ "device_name": str(d.name)}
463
+ return {"track_index": -1, "device_index": -1,
464
+ "track_name": "", "device_name": str(getattr(dev, "name", ""))}
@@ -1,5 +1,5 @@
1
1
  """
2
- LivePilot - Transport domain handlers (10 commands).
2
+ LivePilot - Transport domain handlers (19 commands).
3
3
  """
4
4
 
5
5
  from .router import register
@@ -152,3 +152,92 @@ def redo(song, params):
152
152
  """Redo the last undone action."""
153
153
  song.redo()
154
154
  return {"redone": True}
155
+
156
+
157
+ # ── Song / Transport long-tail primitives ─────────────────────────────
158
+
159
+
160
+ @register("tap_tempo")
161
+ def tap_tempo(song, params):
162
+ """Tap the tempo (one tap); Live averages consecutive taps."""
163
+ song.tap_tempo()
164
+ return {"tempo": float(song.tempo)}
165
+
166
+
167
+ @register("nudge_tempo")
168
+ def nudge_tempo(song, params):
169
+ """Nudge tempo up or down by Live's internal nudge delta."""
170
+ direction = str(params["direction"]).lower()
171
+ if direction == "up":
172
+ song.nudge_up()
173
+ elif direction == "down":
174
+ song.nudge_down()
175
+ else:
176
+ raise ValueError("direction must be 'up' or 'down'")
177
+ return {"tempo": float(song.tempo)}
178
+
179
+
180
+ @register("set_exclusive_arm")
181
+ def set_exclusive_arm(song, params):
182
+ """Enable/disable exclusive arm (only one track armed at a time)."""
183
+ song.exclusive_arm = bool(params["enabled"])
184
+ return {"exclusive_arm": bool(song.exclusive_arm)}
185
+
186
+
187
+ @register("set_exclusive_solo")
188
+ def set_exclusive_solo(song, params):
189
+ """Enable/disable exclusive solo mode."""
190
+ song.exclusive_solo = bool(params["enabled"])
191
+ return {"exclusive_solo": bool(song.exclusive_solo)}
192
+
193
+
194
+ @register("capture_and_insert_scene")
195
+ def capture_and_insert_scene(song, params):
196
+ """Capture currently-playing clips and insert them as a new scene."""
197
+ before_count = len(list(song.scenes))
198
+ song.capture_and_insert_scene()
199
+ scenes = list(song.scenes)
200
+ new_idx = len(scenes) - 1
201
+ # capture_and_insert_scene inserts at a specific position — find the new one.
202
+ # Safest: if count grew by 1, the new scene is at the end; otherwise return -1.
203
+ if len(scenes) > before_count:
204
+ return {"scene_index": new_idx, "scene_name": str(scenes[new_idx].name)}
205
+ return {"scene_index": -1, "scene_name": ""}
206
+
207
+
208
+ @register("set_count_in_duration")
209
+ def set_count_in_duration(song, params):
210
+ """Set pre-record count-in (0-4 bars)."""
211
+ bars = int(params["bars"])
212
+ if not 0 <= bars <= 4:
213
+ raise ValueError("bars must be 0-4")
214
+ song.count_in_duration = bars
215
+ return {"count_in_duration": int(song.count_in_duration)}
216
+
217
+
218
+ @register("get_link_state")
219
+ def get_link_state(song, params):
220
+ """Read Ableton Link + count-in state."""
221
+ return {
222
+ "enabled": bool(getattr(song, "is_ableton_link_enabled", False)),
223
+ "start_stop_sync": bool(getattr(song, "is_ableton_link_start_stop_sync_enabled", False)),
224
+ "tempo_follower": bool(getattr(song, "tempo_follower_enabled", False)),
225
+ "is_counting_in": bool(getattr(song, "is_counting_in", False)),
226
+ }
227
+
228
+
229
+ @register("set_link_enabled")
230
+ def set_link_enabled(song, params):
231
+ """Enable or disable Ableton Link."""
232
+ song.is_ableton_link_enabled = bool(params["enabled"])
233
+ return {"enabled": bool(song.is_ableton_link_enabled)}
234
+
235
+
236
+ @register("force_link_beat_time")
237
+ def force_link_beat_time(song, params):
238
+ """Force Link to a specific beat time (if supported)."""
239
+ if not hasattr(song, "force_link_beat_time"):
240
+ raise RuntimeError("force_link_beat_time is not exposed in this Live version.")
241
+ beat_time = float(params["beat_time"])
242
+ song.force_link_beat_time(beat_time)
243
+ return {"ok": True, "beat_time": beat_time}
@@ -10,17 +10,26 @@ import Live
10
10
  # ── Feature version requirements ────────────────────────────────────────
11
11
 
12
12
  FEATURES = {
13
+ "song_scale_api": (12, 0, 0),
14
+ "clip_follow_action_v2": (12, 0, 0),
15
+ "miditool_api": (12, 0, 0),
13
16
  "create_midi_clip_arrangement": (12, 1, 10),
14
17
  "looper_export": (12, 1, 0),
15
18
  "tuning_system": (12, 1, 0),
16
19
  "display_value": (12, 2, 0),
17
20
  "clip_start_time_observable": (12, 2, 0),
18
21
  "take_lanes_api": (12, 2, 0),
22
+ "scene_follow_actions": (12, 2, 0),
19
23
  "insert_device": (12, 3, 0),
20
24
  "insert_chain": (12, 3, 0),
21
25
  "drum_chain_in_note": (12, 3, 0),
22
26
  "stem_separation": (12, 3, 0),
27
+ "device_ab_compare": (12, 3, 0),
23
28
  "replace_sample_native": (12, 4, 0),
29
+ "groove_pool_api": (11, 0, 0),
30
+ "rack_variations_api": (11, 0, 0),
31
+ "simpler_slice_crud": (11, 0, 0),
32
+ "wavetable_mod_matrix": (11, 0, 0),
24
33
  }
25
34
 
26
35
 
package/server.json CHANGED
@@ -1,17 +1,17 @@
1
1
  {
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
3
  "name": "io.github.dreamrec/livepilot",
4
- "description": "323-tool agentic MCP production system for Ableton Live 12 — device atlas, sample engine, composer",
4
+ "description": "398-tool agentic MCP production system for Ableton Live 12 — device atlas, sample engine, composer",
5
5
  "repository": {
6
6
  "url": "https://github.com/dreamrec/LivePilot",
7
7
  "source": "github"
8
8
  },
9
- "version": "1.10.8",
9
+ "version": "1.12.2",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "livepilot",
14
- "version": "1.10.8",
14
+ "version": "1.12.2",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  }