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,293 @@
1
+ """
2
+ LivePilot - TCP server with thread-safe command queue.
3
+
4
+ Runs a background daemon thread that accepts JSON-over-TCP connections.
5
+ Commands are forwarded to Ableton's main thread via schedule_message,
6
+ and responses are returned through per-command Queue objects.
7
+ """
8
+
9
+ import socket
10
+ import threading
11
+ import json
12
+
13
+ import queue
14
+
15
+ from . import router
16
+
17
+ # ── Commands that modify Live state (need settle delay) ──────────────────────
18
+
19
+ WRITE_COMMANDS = frozenset([
20
+ # transport
21
+ "set_tempo", "set_time_signature", "start_playback", "stop_playback",
22
+ "continue_playback", "toggle_metronome", "set_session_loop", "undo", "redo",
23
+ # tracks
24
+ "create_midi_track", "create_audio_track", "create_return_track",
25
+ "delete_track", "duplicate_track", "set_track_name", "set_track_color",
26
+ "set_track_mute", "set_track_solo", "set_track_arm", "stop_track_clips",
27
+ "set_group_fold", "set_track_input_monitoring",
28
+ # clips
29
+ "create_clip", "delete_clip", "duplicate_clip", "fire_clip", "stop_clip",
30
+ "set_clip_name", "set_clip_color", "set_clip_loop", "set_clip_launch",
31
+ "set_clip_warp_mode",
32
+ # notes
33
+ "add_notes", "remove_notes", "remove_notes_by_id", "modify_notes",
34
+ "duplicate_notes", "transpose_notes", "quantize_clip",
35
+ # devices
36
+ "set_device_parameter", "batch_set_parameters", "toggle_device",
37
+ "delete_device", "load_device_by_uri", "find_and_load_device",
38
+ "set_chain_volume", "set_simpler_playback_mode",
39
+ # scenes
40
+ "create_scene", "delete_scene", "duplicate_scene", "fire_scene",
41
+ "set_scene_name", "set_scene_color", "set_scene_tempo",
42
+ # mixing
43
+ "set_track_volume", "set_track_pan", "set_track_send",
44
+ "set_master_volume", "set_track_routing",
45
+ # browser
46
+ "load_browser_item",
47
+ # arrangement
48
+ "jump_to_time", "jump_to_cue", "capture_midi", "start_recording",
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",
55
+ ])
56
+
57
+
58
+ class LivePilotServer(object):
59
+ """TCP server that bridges JSON commands to Ableton's main thread.
60
+
61
+ Single-client by design: only one client can be connected at a time.
62
+ All commands must execute on Ableton's main thread (Live Object Model
63
+ is not thread-safe), so serialized client access prevents race conditions.
64
+ Additional connection attempts are rejected with a clear error message.
65
+ """
66
+
67
+ def __init__(self, control_surface, host="127.0.0.1", port=9878):
68
+ self._cs = control_surface
69
+ self._host = host
70
+ self._port = port
71
+ self._running = False
72
+ self._server_socket = None
73
+ self._thread = None
74
+ self._command_queue = queue.Queue()
75
+ self._client_lock = threading.Lock()
76
+ self._client_connected = False
77
+
78
+ # ── Public API ───────────────────────────────────────────────────────
79
+
80
+ def start(self):
81
+ """Start the background listener thread."""
82
+ self._running = True
83
+ self._thread = threading.Thread(target=self._server_loop)
84
+ self._thread.daemon = True
85
+ self._thread.start()
86
+ self._log("Server started on %s:%d" % (self._host, self._port))
87
+
88
+ def stop(self):
89
+ """Shutdown the server gracefully."""
90
+ self._running = False
91
+ if self._server_socket:
92
+ try:
93
+ self._server_socket.close()
94
+ except OSError:
95
+ pass
96
+ if self._thread and self._thread.is_alive():
97
+ self._thread.join(timeout=3)
98
+ self._log("Server stopped")
99
+
100
+ # ── Logging ──────────────────────────────────────────────────────────
101
+
102
+ def _log(self, message):
103
+ try:
104
+ self._cs.log_message("[LivePilot] " + str(message))
105
+ except Exception:
106
+ pass
107
+
108
+ # ── Background thread ────────────────────────────────────────────────
109
+
110
+ def _server_loop(self):
111
+ """Runs in a daemon thread. Accepts one client at a time."""
112
+ try:
113
+ self._server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
114
+ self._server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
115
+ self._server_socket.bind((self._host, self._port))
116
+ self._server_socket.listen(2)
117
+ self._server_socket.settimeout(1.0)
118
+ self._log("Listening on %s:%d" % (self._host, self._port))
119
+ except OSError as exc:
120
+ self._log("Failed to bind: %s" % exc)
121
+ return
122
+
123
+ while self._running:
124
+ try:
125
+ client, addr = self._server_socket.accept()
126
+ with self._client_lock:
127
+ if self._client_connected:
128
+ # Reject concurrent clients with an explicit message
129
+ self._log("Rejected client from %s:%d (another client is connected)" % addr)
130
+ try:
131
+ reject = json.dumps({
132
+ "id": "system",
133
+ "ok": False,
134
+ "error": {
135
+ "code": "STATE_ERROR",
136
+ "message": "Another client is already connected. "
137
+ "LivePilot accepts one client at a time. "
138
+ "Disconnect the current client first."
139
+ }
140
+ }) + "\n"
141
+ client.sendall(reject.encode("utf-8"))
142
+ except OSError:
143
+ pass
144
+ try:
145
+ client.close()
146
+ except OSError:
147
+ pass
148
+ continue
149
+ self._client_connected = True
150
+ self._log("Client connected from %s:%d" % addr)
151
+ try:
152
+ self._handle_client(client)
153
+ except OSError as exc:
154
+ self._log("Client error: %s" % exc)
155
+ finally:
156
+ with self._client_lock:
157
+ self._client_connected = False
158
+ try:
159
+ client.close()
160
+ except OSError:
161
+ pass
162
+ self._log("Client disconnected")
163
+ except socket.timeout:
164
+ continue
165
+ except OSError:
166
+ if self._running:
167
+ self._log("Accept error")
168
+ break
169
+
170
+ try:
171
+ self._server_socket.close()
172
+ except OSError:
173
+ pass
174
+
175
+ def _handle_client(self, client):
176
+ """Read newline-delimited JSON from a connected client."""
177
+ client.settimeout(1.0)
178
+ buf = ""
179
+ while self._running:
180
+ try:
181
+ data = client.recv(4096)
182
+ if not data:
183
+ break
184
+ buf += data.decode("utf-8", errors="replace")
185
+ while "\n" in buf:
186
+ line, buf = buf.split("\n", 1)
187
+ line = line.strip()
188
+ if line:
189
+ self._process_line(client, line)
190
+ except socket.timeout:
191
+ continue
192
+ except OSError as exc:
193
+ self._log("Recv error: %s" % exc)
194
+ break
195
+
196
+ def _process_line(self, client, line):
197
+ """Parse one JSON command, queue it for main thread, wait for result."""
198
+ try:
199
+ command = json.loads(line)
200
+ except (ValueError, TypeError) as exc:
201
+ resp = {
202
+ "id": "unknown",
203
+ "ok": False,
204
+ "error": {"code": "INVALID_PARAM", "message": "Bad JSON: %s" % exc},
205
+ }
206
+ self._send(client, resp)
207
+ return
208
+
209
+ request_id = command.get("id", "unknown")
210
+ cmd_type = command.get("type", "")
211
+
212
+ # Determine timeout based on read vs write
213
+ is_write = cmd_type in WRITE_COMMANDS
214
+ timeout = 15 if is_write else 10
215
+
216
+ # Per-command response queue
217
+ response_queue = queue.Queue()
218
+ self._command_queue.put((command, response_queue))
219
+
220
+ # Schedule processing on Ableton's main thread
221
+ try:
222
+ self._cs.schedule_message(0, self._process_next_command)
223
+ except AssertionError:
224
+ # Already on main thread — process directly
225
+ self._process_next_command()
226
+
227
+ # Wait for response from main thread
228
+ try:
229
+ resp = response_queue.get(timeout=timeout)
230
+ except queue.Empty:
231
+ resp = {
232
+ "id": request_id,
233
+ "ok": False,
234
+ "error": {"code": "TIMEOUT", "message": "Command timed out after %ds" % timeout},
235
+ }
236
+
237
+ self._send(client, resp)
238
+
239
+ # ── Main thread execution ────────────────────────────────────────────
240
+
241
+ def _process_next_command(self):
242
+ """Called on Ableton's main thread via schedule_message.
243
+ Processes one command from the queue."""
244
+ try:
245
+ command, response_queue = self._command_queue.get_nowait()
246
+ except queue.Empty:
247
+ return
248
+
249
+ cmd_type = command.get("type", "")
250
+ is_write = cmd_type in WRITE_COMMANDS
251
+
252
+ try:
253
+ song = self._cs.song()
254
+ result = router.dispatch(song, command)
255
+ except Exception as exc:
256
+ result = {
257
+ "id": command.get("id", "unknown"),
258
+ "ok": False,
259
+ "error": {"code": "INTERNAL", "message": str(exc)},
260
+ }
261
+
262
+ if is_write:
263
+ # Schedule response after 100ms settle delay for write operations
264
+ def send_response():
265
+ response_queue.put(result)
266
+ # Drain any remaining queued commands
267
+ self._drain_queue()
268
+ try:
269
+ self._cs.schedule_message(1, send_response) # ~100ms
270
+ except AssertionError:
271
+ send_response()
272
+ else:
273
+ response_queue.put(result)
274
+ # Drain any remaining queued commands
275
+ self._drain_queue()
276
+
277
+ def _drain_queue(self):
278
+ """Process any remaining commands in the queue."""
279
+ if not self._command_queue.empty():
280
+ try:
281
+ self._cs.schedule_message(0, self._process_next_command)
282
+ except AssertionError:
283
+ self._process_next_command()
284
+
285
+ # ── Socket I/O ───────────────────────────────────────────────────────
286
+
287
+ def _send(self, client, response):
288
+ """Send a JSON response to the client."""
289
+ from .utils import serialize_json
290
+ try:
291
+ client.sendall(serialize_json(response).encode("utf-8"))
292
+ except OSError as exc:
293
+ self._log("Send error: %s" % exc)
@@ -0,0 +1,268 @@
1
+ """
2
+ LivePilot - Track domain handlers (14 commands).
3
+ """
4
+
5
+ from .router import register
6
+ from .utils import get_track
7
+
8
+
9
+ @register("get_track_info")
10
+ def get_track_info(song, params):
11
+ """Return detailed info for a single track."""
12
+ track_index = int(params["track_index"])
13
+ track = get_track(song, track_index)
14
+
15
+ # Clip slots
16
+ clips = []
17
+ for i, slot in enumerate(track.clip_slots):
18
+ clip_info = {
19
+ "index": i,
20
+ "has_clip": slot.has_clip,
21
+ }
22
+ if slot.has_clip and slot.clip:
23
+ clip = slot.clip
24
+ clip_info.update({
25
+ "name": clip.name,
26
+ "color_index": clip.color_index,
27
+ "length": clip.length,
28
+ "is_playing": clip.is_playing,
29
+ "is_recording": clip.is_recording,
30
+ "looping": clip.looping,
31
+ "loop_start": clip.loop_start,
32
+ "loop_end": clip.loop_end,
33
+ "start_marker": clip.start_marker,
34
+ "end_marker": clip.end_marker,
35
+ })
36
+ clips.append(clip_info)
37
+
38
+ # Devices
39
+ devices = []
40
+ for i, device in enumerate(track.devices):
41
+ dev_info = {
42
+ "index": i,
43
+ "name": device.name,
44
+ "class_name": device.class_name,
45
+ "is_active": device.is_active,
46
+ }
47
+ dev_params = []
48
+ for j, param in enumerate(device.parameters):
49
+ dev_params.append({
50
+ "index": j,
51
+ "name": param.name,
52
+ "value": param.value,
53
+ "min": param.min,
54
+ "max": param.max,
55
+ "is_quantized": param.is_quantized,
56
+ })
57
+ dev_info["parameters"] = dev_params
58
+ devices.append(dev_info)
59
+
60
+ # Mixer info
61
+ mixer = {
62
+ "volume": track.mixer_device.volume.value,
63
+ "panning": track.mixer_device.panning.value,
64
+ }
65
+
66
+ # Sends
67
+ sends = []
68
+ for i, send in enumerate(track.mixer_device.sends):
69
+ sends.append({
70
+ "index": i,
71
+ "name": send.name,
72
+ "value": send.value,
73
+ "min": send.min,
74
+ "max": send.max,
75
+ })
76
+
77
+ result = {
78
+ "index": track_index,
79
+ "name": track.name,
80
+ "color_index": track.color_index,
81
+ "mute": track.mute,
82
+ "solo": track.solo,
83
+ "is_foldable": track.is_foldable,
84
+ "is_grouped": track.is_grouped,
85
+ "clip_slots": clips,
86
+ "devices": devices,
87
+ "mixer": mixer,
88
+ "sends": sends,
89
+ }
90
+
91
+ if track.is_foldable:
92
+ result["fold_state"] = bool(track.fold_state)
93
+
94
+ # Regular tracks have arm and input type; return tracks get null values
95
+ if track_index >= 0:
96
+ result["arm"] = track.arm
97
+ result["has_midi_input"] = track.has_midi_input
98
+ result["has_audio_input"] = track.has_audio_input
99
+ result["current_monitoring_state"] = track.current_monitoring_state
100
+ else:
101
+ result["arm"] = None
102
+ result["has_midi_input"] = None
103
+ result["has_audio_input"] = None
104
+ result["is_return_track"] = True
105
+
106
+ return result
107
+
108
+
109
+ @register("create_midi_track")
110
+ def create_midi_track(song, params):
111
+ """Create a new MIDI track at the given index."""
112
+ index = int(params.get("index", -1))
113
+ song.create_midi_track(index)
114
+ # The new track is at the requested index (or end if -1)
115
+ if index == -1:
116
+ new_index = len(list(song.tracks)) - 1
117
+ else:
118
+ new_index = index
119
+ track = list(song.tracks)[new_index]
120
+ if "name" in params:
121
+ track.name = str(params["name"])
122
+ if "color_index" in params:
123
+ track.color_index = int(params["color_index"])
124
+ return {"index": new_index, "name": track.name}
125
+
126
+
127
+ @register("create_audio_track")
128
+ def create_audio_track(song, params):
129
+ """Create a new audio track at the given index."""
130
+ index = int(params.get("index", -1))
131
+ song.create_audio_track(index)
132
+ if index == -1:
133
+ new_index = len(list(song.tracks)) - 1
134
+ else:
135
+ new_index = index
136
+ track = list(song.tracks)[new_index]
137
+ if "name" in params:
138
+ track.name = str(params["name"])
139
+ if "color_index" in params:
140
+ track.color_index = int(params["color_index"])
141
+ return {"index": new_index, "name": track.name}
142
+
143
+
144
+ @register("create_return_track")
145
+ def create_return_track(song, params):
146
+ """Create a new return track."""
147
+ song.create_return_track()
148
+ return_tracks = list(song.return_tracks)
149
+ new_index = len(return_tracks) - 1
150
+ return {"index": new_index, "name": return_tracks[new_index].name}
151
+
152
+
153
+ @register("delete_track")
154
+ def delete_track(song, params):
155
+ """Delete a track by index."""
156
+ track_index = int(params["track_index"])
157
+ tracks = list(song.tracks)
158
+ if track_index < 0 or track_index >= len(tracks):
159
+ raise IndexError(
160
+ "Track index %d out of range (0..%d)"
161
+ % (track_index, len(tracks) - 1)
162
+ )
163
+ song.delete_track(track_index)
164
+ return {"deleted": track_index}
165
+
166
+
167
+ @register("duplicate_track")
168
+ def duplicate_track(song, params):
169
+ """Duplicate a track by index."""
170
+ track_index = int(params["track_index"])
171
+ tracks = list(song.tracks)
172
+ if track_index < 0 or track_index >= len(tracks):
173
+ raise IndexError(
174
+ "Track index %d out of range (0..%d)"
175
+ % (track_index, len(tracks) - 1)
176
+ )
177
+ song.duplicate_track(track_index)
178
+ new_index = track_index + 1
179
+ return {"index": new_index, "name": list(song.tracks)[new_index].name}
180
+
181
+
182
+ @register("set_track_name")
183
+ def set_track_name(song, params):
184
+ """Rename a track."""
185
+ track_index = int(params["track_index"])
186
+ track = get_track(song, track_index)
187
+ track.name = str(params["name"])
188
+ return {"index": track_index, "name": track.name}
189
+
190
+
191
+ @register("set_track_color")
192
+ def set_track_color(song, params):
193
+ """Set a track's color."""
194
+ track_index = int(params["track_index"])
195
+ track = get_track(song, track_index)
196
+ track.color_index = int(params["color_index"])
197
+ return {"index": track_index, "color_index": track.color_index}
198
+
199
+
200
+ @register("set_track_mute")
201
+ def set_track_mute(song, params):
202
+ """Mute or unmute a track."""
203
+ track_index = int(params["track_index"])
204
+ track = get_track(song, track_index)
205
+ track.mute = bool(params["mute"])
206
+ return {"index": track_index, "mute": track.mute}
207
+
208
+
209
+ @register("set_track_solo")
210
+ def set_track_solo(song, params):
211
+ """Solo or unsolo a track."""
212
+ track_index = int(params["track_index"])
213
+ track = get_track(song, track_index)
214
+ track.solo = bool(params["solo"])
215
+ return {"index": track_index, "solo": track.solo}
216
+
217
+
218
+ @register("set_track_arm")
219
+ def set_track_arm(song, params):
220
+ """Arm or disarm a track for recording."""
221
+ track_index = int(params["track_index"])
222
+ if track_index < 0:
223
+ raise ValueError("Cannot arm a return track")
224
+ track = get_track(song, track_index)
225
+ track.arm = bool(params["arm"])
226
+ return {"index": track_index, "arm": track.arm}
227
+
228
+
229
+ @register("stop_track_clips")
230
+ def stop_track_clips(song, params):
231
+ """Stop all clips on a track."""
232
+ track_index = int(params["track_index"])
233
+ track = get_track(song, track_index)
234
+ track.stop_all_clips()
235
+ return {"index": track_index, "stopped": True}
236
+
237
+
238
+ @register("set_group_fold")
239
+ def set_group_fold(song, params):
240
+ """Fold or unfold a group track."""
241
+ track_index = int(params["track_index"])
242
+ track = get_track(song, track_index)
243
+ if not track.is_foldable:
244
+ raise ValueError("Track %d is not a group track" % track_index)
245
+ track.fold_state = int(bool(params["folded"]))
246
+ return {
247
+ "index": track_index,
248
+ "folded": bool(track.fold_state),
249
+ }
250
+
251
+
252
+ @register("set_track_input_monitoring")
253
+ def set_track_input_monitoring(song, params):
254
+ """Set input monitoring state for a track (0=In, 1=Auto, 2=Off)."""
255
+ track_index = int(params["track_index"])
256
+ if track_index < 0:
257
+ raise ValueError("Cannot set input monitoring on a return track")
258
+ track = get_track(song, track_index)
259
+ state = int(params["state"])
260
+ if state not in (0, 1, 2):
261
+ raise ValueError(
262
+ "Invalid monitoring state %d. Valid: 0=In, 1=Auto, 2=Off" % state
263
+ )
264
+ track.current_monitoring_state = state
265
+ return {
266
+ "index": track_index,
267
+ "monitoring_state": track.current_monitoring_state,
268
+ }