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,339 @@
1
+ """
2
+ LivePilot - Notes domain handlers (8 commands).
3
+
4
+ Uses Live 12's modern note API: add_new_notes, get_notes_extended,
5
+ remove_notes_extended, remove_notes_by_id, apply_note_modifications.
6
+ """
7
+
8
+ from .router import register
9
+ from .utils import get_clip, get_track
10
+
11
+
12
+ @register("add_notes")
13
+ def add_notes(song, params):
14
+ """Add MIDI notes to a clip using Live 12's modern API."""
15
+ track_index = int(params["track_index"])
16
+ clip_index = int(params["clip_index"])
17
+ notes = params["notes"]
18
+ if not notes:
19
+ raise ValueError("notes list cannot be empty")
20
+
21
+ clip = get_clip(song, track_index, clip_index)
22
+ import Live
23
+ song.begin_undo_step()
24
+ try:
25
+ note_specs = []
26
+ for note in notes:
27
+ kwargs = dict(
28
+ pitch=int(note["pitch"]),
29
+ start_time=float(note["start_time"]),
30
+ duration=float(note["duration"]),
31
+ velocity=float(note.get("velocity", 100)),
32
+ mute=bool(note.get("mute", False)),
33
+ )
34
+ if "probability" in note:
35
+ kwargs["probability"] = float(note["probability"])
36
+ if "velocity_deviation" in note:
37
+ kwargs["velocity_deviation"] = float(note["velocity_deviation"])
38
+ if "release_velocity" in note:
39
+ kwargs["release_velocity"] = float(note["release_velocity"])
40
+ spec = Live.Clip.MidiNoteSpecification(**kwargs)
41
+ note_specs.append(spec)
42
+ clip.add_new_notes(tuple(note_specs))
43
+ finally:
44
+ song.end_undo_step()
45
+
46
+ return {
47
+ "track_index": track_index,
48
+ "clip_index": clip_index,
49
+ "notes_added": len(notes),
50
+ }
51
+
52
+
53
+ @register("get_notes")
54
+ def get_notes(song, params):
55
+ """Get MIDI notes from a clip region."""
56
+ track_index = int(params["track_index"])
57
+ clip_index = int(params["clip_index"])
58
+ clip = get_clip(song, track_index, clip_index)
59
+
60
+ from_pitch = int(params.get("from_pitch", 0))
61
+ pitch_span = int(params.get("pitch_span", 128))
62
+ from_time = float(params.get("from_time", 0.0))
63
+ # Default to clip length, but use a large span if clip has no length yet
64
+ default_span = clip.length if clip.length > 0 else 32768.0
65
+ time_span = float(params.get("time_span", default_span))
66
+
67
+ raw_notes = clip.get_notes_extended(from_pitch, pitch_span, from_time, time_span)
68
+
69
+ result = []
70
+ for note in raw_notes:
71
+ result.append({
72
+ "note_id": note.note_id,
73
+ "pitch": note.pitch,
74
+ "start_time": note.start_time,
75
+ "duration": note.duration,
76
+ "velocity": note.velocity,
77
+ "mute": note.mute,
78
+ "probability": note.probability,
79
+ "velocity_deviation": note.velocity_deviation,
80
+ "release_velocity": note.release_velocity,
81
+ })
82
+
83
+ return {
84
+ "track_index": track_index,
85
+ "clip_index": clip_index,
86
+ "notes": result,
87
+ }
88
+
89
+
90
+ @register("remove_notes")
91
+ def remove_notes(song, params):
92
+ """Remove MIDI notes from a clip region."""
93
+ track_index = int(params["track_index"])
94
+ clip_index = int(params["clip_index"])
95
+ clip = get_clip(song, track_index, clip_index)
96
+
97
+ from_pitch = int(params.get("from_pitch", 0))
98
+ pitch_span = int(params.get("pitch_span", 128))
99
+ from_time = float(params.get("from_time", 0.0))
100
+ default_span = clip.length if clip.length > 0 else 32768.0
101
+ time_span = float(params.get("time_span", default_span))
102
+
103
+ song.begin_undo_step()
104
+ try:
105
+ clip.remove_notes_extended(from_pitch, pitch_span, from_time, time_span)
106
+ finally:
107
+ song.end_undo_step()
108
+
109
+ return {
110
+ "track_index": track_index,
111
+ "clip_index": clip_index,
112
+ "removed": True,
113
+ }
114
+
115
+
116
+ @register("remove_notes_by_id")
117
+ def remove_notes_by_id(song, params):
118
+ """Remove specific MIDI notes by their IDs."""
119
+ track_index = int(params["track_index"])
120
+ clip_index = int(params["clip_index"])
121
+ note_ids = params["note_ids"]
122
+ if not note_ids:
123
+ raise ValueError("note_ids list cannot be empty")
124
+
125
+ clip = get_clip(song, track_index, clip_index)
126
+ song.begin_undo_step()
127
+ try:
128
+ clip.remove_notes_by_id(tuple(int(nid) for nid in note_ids))
129
+ finally:
130
+ song.end_undo_step()
131
+
132
+ return {
133
+ "track_index": track_index,
134
+ "clip_index": clip_index,
135
+ "removed_count": len(note_ids),
136
+ }
137
+
138
+
139
+ @register("modify_notes")
140
+ def modify_notes(song, params):
141
+ """Modify existing MIDI notes by ID."""
142
+ track_index = int(params["track_index"])
143
+ clip_index = int(params["clip_index"])
144
+ modifications = params["modifications"]
145
+ if not modifications:
146
+ raise ValueError("modifications list cannot be empty")
147
+
148
+ clip = get_clip(song, track_index, clip_index)
149
+
150
+ # Get all notes — returns a C++ NoteVector that must be passed back intact
151
+ all_notes = clip.get_notes_extended(0, 128, 0.0, clip.length + 1.0)
152
+
153
+ # Build a lookup by note_id
154
+ note_map = {}
155
+ for note in all_notes:
156
+ note_map[note.note_id] = note
157
+
158
+ # Apply modifications in-place on the original NoteVector's objects
159
+ modified_count = 0
160
+ for mod in modifications:
161
+ note_id = int(mod["note_id"])
162
+ if note_id not in note_map:
163
+ raise ValueError("Note ID %d not found in clip" % note_id)
164
+ note = note_map[note_id]
165
+ if "pitch" in mod:
166
+ note.pitch = int(mod["pitch"])
167
+ if "start_time" in mod:
168
+ note.start_time = float(mod["start_time"])
169
+ if "duration" in mod:
170
+ note.duration = float(mod["duration"])
171
+ if "velocity" in mod:
172
+ note.velocity = float(mod["velocity"])
173
+ if "probability" in mod:
174
+ note.probability = float(mod["probability"])
175
+ modified_count += 1
176
+
177
+ # Pass the original NoteVector back — Boost.Python requires the C++ type
178
+ song.begin_undo_step()
179
+ try:
180
+ clip.apply_note_modifications(all_notes)
181
+ finally:
182
+ song.end_undo_step()
183
+
184
+ return {
185
+ "track_index": track_index,
186
+ "clip_index": clip_index,
187
+ "modified_count": modified_count,
188
+ }
189
+
190
+
191
+ @register("duplicate_notes")
192
+ def duplicate_notes(song, params):
193
+ """Duplicate specific notes by ID, with optional time offset."""
194
+ track_index = int(params["track_index"])
195
+ clip_index = int(params["clip_index"])
196
+ note_ids = params["note_ids"]
197
+ time_offset = float(params.get("time_offset", 0.0))
198
+ if not note_ids:
199
+ raise ValueError("note_ids list cannot be empty")
200
+
201
+ clip = get_clip(song, track_index, clip_index)
202
+ note_id_set = set(int(nid) for nid in note_ids)
203
+
204
+ # Get all notes and filter to the requested IDs
205
+ all_notes = clip.get_notes_extended(0, 128, 0.0, clip.length + 1.0)
206
+ source_notes = []
207
+ for note in all_notes:
208
+ if note.note_id in note_id_set:
209
+ source_notes.append({
210
+ "pitch": note.pitch,
211
+ "start_time": note.start_time + time_offset,
212
+ "duration": note.duration,
213
+ "velocity": note.velocity,
214
+ "mute": note.mute,
215
+ "probability": note.probability,
216
+ "velocity_deviation": note.velocity_deviation,
217
+ "release_velocity": note.release_velocity,
218
+ })
219
+
220
+ if not source_notes:
221
+ raise ValueError("No matching notes found for the given IDs")
222
+
223
+ # Add the duplicated notes with all attributes preserved
224
+ import Live
225
+ song.begin_undo_step()
226
+ try:
227
+ note_specs = []
228
+ for note in source_notes:
229
+ kwargs = dict(
230
+ pitch=int(note["pitch"]),
231
+ start_time=float(note["start_time"]),
232
+ duration=float(note["duration"]),
233
+ velocity=float(note["velocity"]),
234
+ mute=bool(note["mute"]),
235
+ )
236
+ if note.get("probability") is not None:
237
+ kwargs["probability"] = float(note["probability"])
238
+ if note.get("velocity_deviation") is not None:
239
+ kwargs["velocity_deviation"] = float(note["velocity_deviation"])
240
+ if note.get("release_velocity") is not None:
241
+ kwargs["release_velocity"] = float(note["release_velocity"])
242
+ spec = Live.Clip.MidiNoteSpecification(**kwargs)
243
+ note_specs.append(spec)
244
+ clip.add_new_notes(tuple(note_specs))
245
+ finally:
246
+ song.end_undo_step()
247
+
248
+ return {
249
+ "track_index": track_index,
250
+ "clip_index": clip_index,
251
+ "duplicated_count": len(source_notes),
252
+ }
253
+
254
+
255
+ @register("transpose_notes")
256
+ def transpose_notes(song, params):
257
+ """Transpose notes in a time range by a number of semitones."""
258
+ track_index = int(params["track_index"])
259
+ clip_index = int(params["clip_index"])
260
+ semitones = int(params["semitones"])
261
+ arrangement = bool(params.get("arrangement", False))
262
+
263
+ if arrangement:
264
+ track = get_track(song, track_index)
265
+ arr_clips = list(track.arrangement_clips)
266
+ if clip_index < 0 or clip_index >= len(arr_clips):
267
+ raise IndexError(
268
+ "Arrangement clip index %d out of range (0..%d)"
269
+ % (clip_index, len(arr_clips) - 1)
270
+ )
271
+ clip = arr_clips[clip_index]
272
+ else:
273
+ clip = get_clip(song, track_index, clip_index)
274
+
275
+ from_time = float(params.get("from_time", 0.0))
276
+ time_span = float(params.get("time_span", clip.length))
277
+
278
+ # Get notes — returns C++ NoteVector that must be passed back intact
279
+ all_notes = clip.get_notes_extended(0, 128, from_time, time_span)
280
+
281
+ # Modify pitch in-place, skip notes that would go out of MIDI range
282
+ transposed_count = 0
283
+ skipped_count = 0
284
+ total_in_range = 0
285
+ for note in all_notes:
286
+ total_in_range += 1
287
+ new_pitch = note.pitch + semitones
288
+ if new_pitch < 0 or new_pitch > 127:
289
+ skipped_count += 1
290
+ continue
291
+ note.pitch = new_pitch
292
+ transposed_count += 1
293
+
294
+ if transposed_count > 0:
295
+ # Pass the original NoteVector back — Boost.Python requires the C++ type
296
+ song.begin_undo_step()
297
+ try:
298
+ clip.apply_note_modifications(all_notes)
299
+ finally:
300
+ song.end_undo_step()
301
+
302
+ result = {
303
+ "track_index": track_index,
304
+ "clip_index": clip_index,
305
+ "transposed_count": transposed_count,
306
+ "semitones": semitones,
307
+ }
308
+ if skipped_count > 0:
309
+ result["skipped_out_of_range"] = skipped_count
310
+ result["warning"] = (
311
+ "%d note(s) skipped — transposing by %+d semitones would "
312
+ "exceed MIDI range (0-127)" % (skipped_count, semitones)
313
+ )
314
+ return result
315
+
316
+
317
+ @register("quantize_clip")
318
+ def quantize_clip(song, params):
319
+ """Quantize a clip to a grid.
320
+
321
+ grid is a RecordQuantization enum integer:
322
+ 0=None, 1=1/4, 2=1/8, 3=1/8T, 4=1/8+T,
323
+ 5=1/16, 6=1/16T, 7=1/16+T, 8=1/32
324
+ """
325
+ track_index = int(params["track_index"])
326
+ clip_index = int(params["clip_index"])
327
+ grid = int(params["grid"])
328
+ amount = float(params.get("amount", 1.0))
329
+
330
+ clip = get_clip(song, track_index, clip_index)
331
+ clip.quantize(grid, amount)
332
+
333
+ return {
334
+ "track_index": track_index,
335
+ "clip_index": clip_index,
336
+ "grid": grid,
337
+ "amount": amount,
338
+ "quantized": True,
339
+ }
@@ -0,0 +1,74 @@
1
+ """
2
+ LivePilot - Command dispatch registry.
3
+
4
+ Handlers register themselves via the @register decorator.
5
+ dispatch() is called on the main thread to route commands.
6
+ """
7
+
8
+ from .utils import (
9
+ success_response,
10
+ error_response,
11
+ INDEX_ERROR,
12
+ INVALID_PARAM,
13
+ NOT_FOUND,
14
+ INTERNAL,
15
+ )
16
+
17
+ # ── Handler registry ─────────────────────────────────────────────────────────
18
+
19
+ _handlers = {}
20
+
21
+
22
+ def register(command_type):
23
+ """Decorator that registers a handler function for *command_type*."""
24
+ def decorator(fn):
25
+ _handlers[command_type] = fn
26
+ return fn
27
+ return decorator
28
+
29
+
30
+ # ── Dispatcher ───────────────────────────────────────────────────────────────
31
+
32
+ def dispatch(song, command):
33
+ """Route a parsed command dict to the appropriate handler.
34
+
35
+ Parameters
36
+ ----------
37
+ song : Live.Song.Song
38
+ The current song instance.
39
+ command : dict
40
+ Must contain ``id`` (str), ``type`` (str), and optionally ``params`` (dict).
41
+
42
+ Returns
43
+ -------
44
+ dict
45
+ A success or error response envelope.
46
+ """
47
+ request_id = command.get("id", "unknown")
48
+ cmd_type = command.get("type")
49
+ params = command.get("params", {})
50
+
51
+ if cmd_type is None:
52
+ return error_response(request_id, "Missing 'type' field", INVALID_PARAM)
53
+
54
+ # Built-in ping — no handler registration needed.
55
+ if cmd_type == "ping":
56
+ return success_response(request_id, {"pong": True})
57
+
58
+ handler = _handlers.get(cmd_type)
59
+ if handler is None:
60
+ return error_response(
61
+ request_id,
62
+ "Unknown command type: %s" % cmd_type,
63
+ NOT_FOUND,
64
+ )
65
+
66
+ try:
67
+ result = handler(song, params)
68
+ return success_response(request_id, result)
69
+ except IndexError as exc:
70
+ return error_response(request_id, str(exc), INDEX_ERROR)
71
+ except ValueError as exc:
72
+ return error_response(request_id, str(exc), INVALID_PARAM)
73
+ except Exception as exc:
74
+ return error_response(request_id, str(exc), INTERNAL)
@@ -0,0 +1,99 @@
1
+ """
2
+ LivePilot - Scene domain handlers (8 commands).
3
+ """
4
+
5
+ from .router import register
6
+ from .utils import get_scene
7
+
8
+
9
+ @register("get_scenes_info")
10
+ def get_scenes_info(song, params):
11
+ """Return info for all scenes."""
12
+ scenes = []
13
+ for i, scene in enumerate(song.scenes):
14
+ scenes.append({
15
+ "index": i,
16
+ "name": scene.name,
17
+ "tempo": scene.tempo if scene.tempo > 0 else None,
18
+ "color_index": scene.color_index,
19
+ })
20
+ return {"scenes": scenes}
21
+
22
+
23
+ @register("create_scene")
24
+ def create_scene(song, params):
25
+ """Create a new scene at the given index."""
26
+ index = int(params.get("index", -1))
27
+ song.create_scene(index)
28
+ if index == -1:
29
+ new_index = len(list(song.scenes)) - 1
30
+ else:
31
+ new_index = index
32
+ scene = list(song.scenes)[new_index]
33
+ return {
34
+ "index": new_index,
35
+ "name": scene.name,
36
+ "color_index": scene.color_index,
37
+ }
38
+
39
+
40
+ @register("delete_scene")
41
+ def delete_scene(song, params):
42
+ """Delete a scene by index."""
43
+ scene_index = int(params["scene_index"])
44
+ get_scene(song, scene_index)
45
+ song.delete_scene(scene_index)
46
+ return {"deleted": scene_index}
47
+
48
+
49
+ @register("duplicate_scene")
50
+ def duplicate_scene(song, params):
51
+ """Duplicate a scene by index."""
52
+ scene_index = int(params["scene_index"])
53
+ get_scene(song, scene_index)
54
+ song.duplicate_scene(scene_index)
55
+ new_index = scene_index + 1
56
+ scene = list(song.scenes)[new_index]
57
+ return {"index": new_index, "name": scene.name}
58
+
59
+
60
+ @register("fire_scene")
61
+ def fire_scene(song, params):
62
+ """Fire (launch) a scene."""
63
+ scene_index = int(params["scene_index"])
64
+ scene = get_scene(song, scene_index)
65
+ scene.fire()
66
+ return {"index": scene_index, "fired": True}
67
+
68
+
69
+ @register("set_scene_name")
70
+ def set_scene_name(song, params):
71
+ """Rename a scene."""
72
+ scene_index = int(params["scene_index"])
73
+ scene = get_scene(song, scene_index)
74
+ scene.name = str(params["name"])
75
+ return {"index": scene_index, "name": scene.name}
76
+
77
+
78
+ @register("set_scene_color")
79
+ def set_scene_color(song, params):
80
+ """Set a scene's color."""
81
+ scene_index = int(params["scene_index"])
82
+ scene = get_scene(song, scene_index)
83
+ scene.color_index = int(params["color_index"])
84
+ return {"index": scene_index, "color_index": scene.color_index}
85
+
86
+
87
+ @register("set_scene_tempo")
88
+ def set_scene_tempo(song, params):
89
+ """Set a scene's tempo (BPM, 20-999)."""
90
+ scene_index = int(params["scene_index"])
91
+ scene = get_scene(song, scene_index)
92
+ tempo = float(params["tempo"])
93
+ if tempo < 20 or tempo > 999:
94
+ raise ValueError("Tempo must be between 20 and 999 BPM")
95
+ scene.tempo = tempo
96
+ return {
97
+ "index": scene_index,
98
+ "tempo": scene.tempo,
99
+ }