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,121 @@
1
+ """Mixing MCP tools — volume, pan, sends, routing, master.
2
+
3
+ 8 tools matching the Remote Script mixing domain.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Optional
9
+
10
+ from fastmcp import Context
11
+
12
+ from ..server import mcp
13
+
14
+
15
+ def _get_ableton(ctx: Context):
16
+ """Extract AbletonConnection from lifespan context."""
17
+ return ctx.lifespan_context["ableton"]
18
+
19
+
20
+ def _validate_track_index(track_index: int):
21
+ if track_index < -100:
22
+ raise ValueError(
23
+ "track_index must be >= 0 for regular tracks, "
24
+ "or negative for return tracks (-1=A, -2=B)"
25
+ )
26
+ # Negative values -1..-99 are valid return track indices
27
+
28
+
29
+ @mcp.tool()
30
+ def set_track_volume(ctx: Context, track_index: int, volume: float) -> dict:
31
+ """Set a track's volume (0.0-1.0). Use negative track_index for return tracks (-1=A, -2=B)."""
32
+ _validate_track_index(track_index)
33
+ if not 0.0 <= volume <= 1.0:
34
+ raise ValueError("Volume must be between 0.0 and 1.0")
35
+ return _get_ableton(ctx).send_command("set_track_volume", {
36
+ "track_index": track_index,
37
+ "volume": volume,
38
+ })
39
+
40
+
41
+ @mcp.tool()
42
+ def set_track_pan(ctx: Context, track_index: int, pan: float) -> dict:
43
+ """Set a track's panning (-1.0 left to 1.0 right). Use negative track_index for return tracks (-1=A, -2=B)."""
44
+ _validate_track_index(track_index)
45
+ if not -1.0 <= pan <= 1.0:
46
+ raise ValueError("Pan must be between -1.0 and 1.0")
47
+ return _get_ableton(ctx).send_command("set_track_pan", {
48
+ "track_index": track_index,
49
+ "pan": pan,
50
+ })
51
+
52
+
53
+ @mcp.tool()
54
+ def set_track_send(
55
+ ctx: Context, track_index: int, send_index: int, value: float
56
+ ) -> dict:
57
+ """Set a send level on a track (0.0-1.0)."""
58
+ _validate_track_index(track_index)
59
+ if send_index < 0:
60
+ raise ValueError("send_index must be >= 0")
61
+ if not 0.0 <= value <= 1.0:
62
+ raise ValueError("Send value must be between 0.0 and 1.0")
63
+ return _get_ableton(ctx).send_command("set_track_send", {
64
+ "track_index": track_index,
65
+ "send_index": send_index,
66
+ "value": value,
67
+ })
68
+
69
+
70
+ @mcp.tool()
71
+ def get_return_tracks(ctx: Context) -> dict:
72
+ """Get info about all return tracks: name, volume, panning."""
73
+ return _get_ableton(ctx).send_command("get_return_tracks")
74
+
75
+
76
+ @mcp.tool()
77
+ def get_master_track(ctx: Context) -> dict:
78
+ """Get master track info: volume, panning, devices."""
79
+ return _get_ableton(ctx).send_command("get_master_track")
80
+
81
+
82
+ @mcp.tool()
83
+ def set_master_volume(ctx: Context, volume: float) -> dict:
84
+ """Set the master track volume (0.0-1.0)."""
85
+ if not 0.0 <= volume <= 1.0:
86
+ raise ValueError("Volume must be between 0.0 and 1.0")
87
+ return _get_ableton(ctx).send_command("set_master_volume", {"volume": volume})
88
+
89
+
90
+ @mcp.tool()
91
+ def get_track_routing(ctx: Context, track_index: int) -> dict:
92
+ """Get input/output routing info for a track. Use negative track_index for return tracks (-1=A, -2=B)."""
93
+ _validate_track_index(track_index)
94
+ return _get_ableton(ctx).send_command("get_track_routing", {
95
+ "track_index": track_index,
96
+ })
97
+
98
+
99
+ @mcp.tool()
100
+ def set_track_routing(
101
+ ctx: Context,
102
+ track_index: int,
103
+ input_type: Optional[str] = None,
104
+ input_channel: Optional[str] = None,
105
+ output_type: Optional[str] = None,
106
+ output_channel: Optional[str] = None,
107
+ ) -> dict:
108
+ """Set input/output routing for a track by display name. Use negative track_index for return tracks (-1=A, -2=B)."""
109
+ _validate_track_index(track_index)
110
+ params = {"track_index": track_index}
111
+ if input_type is not None:
112
+ params["input_type"] = input_type
113
+ if input_channel is not None:
114
+ params["input_channel"] = input_channel
115
+ if output_type is not None:
116
+ params["output_type"] = output_type
117
+ if output_channel is not None:
118
+ params["output_channel"] = output_channel
119
+ if len(params) == 1:
120
+ raise ValueError("At least one routing parameter must be provided")
121
+ return _get_ableton(ctx).send_command("set_track_routing", params)
@@ -0,0 +1,269 @@
1
+ """Notes MCP tools — add, get, remove, modify, duplicate, transpose, quantize.
2
+
3
+ 8 tools matching the Remote Script notes domain.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from typing import Any, Optional
10
+
11
+ from fastmcp import Context
12
+
13
+ from ..server import mcp
14
+
15
+
16
+ def _get_ableton(ctx: Context):
17
+ """Extract AbletonConnection from lifespan context."""
18
+ return ctx.lifespan_context["ableton"]
19
+
20
+
21
+ def _ensure_list(value: Any) -> list:
22
+ """Parse JSON strings into lists. MCP clients may serialize list params as strings."""
23
+ if isinstance(value, str):
24
+ return json.loads(value)
25
+ return value
26
+
27
+
28
+ def _validate_track_index(track_index: int):
29
+ """Validate track index. Must be >= 0 for regular tracks."""
30
+ if track_index < 0:
31
+ raise ValueError("track_index must be >= 0")
32
+
33
+
34
+ def _validate_clip_index(clip_index: int):
35
+ if clip_index < 0:
36
+ raise ValueError("clip_index must be >= 0")
37
+
38
+
39
+ def _validate_note(note: dict):
40
+ """Validate a single note specification dict."""
41
+ pitch = note.get("pitch")
42
+ if pitch is None:
43
+ raise ValueError("Each note must have a 'pitch' field")
44
+ if not 0 <= int(pitch) <= 127:
45
+ raise ValueError("pitch must be between 0 and 127")
46
+
47
+ if "start_time" not in note:
48
+ raise ValueError("Each note must have a 'start_time' field")
49
+
50
+ duration = note.get("duration")
51
+ if duration is None:
52
+ raise ValueError("Each note must have a 'duration' field")
53
+ if float(duration) <= 0:
54
+ raise ValueError("duration must be > 0")
55
+
56
+ velocity = note.get("velocity")
57
+ if velocity is not None:
58
+ if not 0.0 <= float(velocity) <= 127.0:
59
+ raise ValueError("velocity must be between 0.0 and 127.0")
60
+
61
+ probability = note.get("probability")
62
+ if probability is not None:
63
+ if not 0.0 <= float(probability) <= 1.0:
64
+ raise ValueError("probability must be between 0.0 and 1.0")
65
+
66
+ velocity_deviation = note.get("velocity_deviation")
67
+ if velocity_deviation is not None:
68
+ if not -127.0 <= float(velocity_deviation) <= 127.0:
69
+ raise ValueError("velocity_deviation must be between -127.0 and 127.0")
70
+
71
+ release_velocity = note.get("release_velocity")
72
+ if release_velocity is not None:
73
+ if not 0.0 <= float(release_velocity) <= 127.0:
74
+ raise ValueError("release_velocity must be between 0.0 and 127.0")
75
+
76
+
77
+ @mcp.tool()
78
+ def add_notes(ctx: Context, track_index: int, clip_index: int, notes: Any) -> dict:
79
+ """Add MIDI notes to a clip. notes is a JSON array: [{pitch, start_time, duration, velocity?, probability?, velocity_deviation?, release_velocity?}]."""
80
+ _validate_track_index(track_index)
81
+ _validate_clip_index(clip_index)
82
+ notes = _ensure_list(notes)
83
+ if not notes:
84
+ raise ValueError("notes list cannot be empty")
85
+ for note in notes:
86
+ _validate_note(note)
87
+ return _get_ableton(ctx).send_command("add_notes", {
88
+ "track_index": track_index,
89
+ "clip_index": clip_index,
90
+ "notes": notes,
91
+ })
92
+
93
+
94
+ @mcp.tool()
95
+ def get_notes(
96
+ ctx: Context,
97
+ track_index: int,
98
+ clip_index: int,
99
+ from_pitch: int = 0,
100
+ pitch_span: int = 128,
101
+ from_time: float = 0.0,
102
+ time_span: Optional[float] = None,
103
+ ) -> dict:
104
+ """Get MIDI notes from a clip region. Returns note_id, pitch, start_time, duration, velocity, mute, probability."""
105
+ _validate_track_index(track_index)
106
+ _validate_clip_index(clip_index)
107
+ if not 0 <= from_pitch <= 127:
108
+ raise ValueError("from_pitch must be between 0 and 127")
109
+ if pitch_span < 1 or pitch_span > 128:
110
+ raise ValueError("pitch_span must be between 1 and 128")
111
+ params = {
112
+ "track_index": track_index,
113
+ "clip_index": clip_index,
114
+ "from_pitch": from_pitch,
115
+ "pitch_span": pitch_span,
116
+ "from_time": from_time,
117
+ }
118
+ if time_span is not None:
119
+ if time_span <= 0:
120
+ raise ValueError("time_span must be > 0")
121
+ params["time_span"] = time_span
122
+ return _get_ableton(ctx).send_command("get_notes", params)
123
+
124
+
125
+ @mcp.tool()
126
+ def remove_notes(
127
+ ctx: Context,
128
+ track_index: int,
129
+ clip_index: int,
130
+ from_pitch: int = 0,
131
+ pitch_span: int = 128,
132
+ from_time: float = 0.0,
133
+ time_span: Optional[float] = None,
134
+ ) -> dict:
135
+ """Remove all MIDI notes in a pitch/time region. Use undo to revert. Defaults remove ALL notes in the clip."""
136
+ _validate_track_index(track_index)
137
+ _validate_clip_index(clip_index)
138
+ params = {
139
+ "track_index": track_index,
140
+ "clip_index": clip_index,
141
+ "from_pitch": from_pitch,
142
+ "pitch_span": pitch_span,
143
+ "from_time": from_time,
144
+ }
145
+ if time_span is not None:
146
+ if time_span <= 0:
147
+ raise ValueError("time_span must be > 0")
148
+ params["time_span"] = time_span
149
+ return _get_ableton(ctx).send_command("remove_notes", params)
150
+
151
+
152
+ @mcp.tool()
153
+ def remove_notes_by_id(ctx: Context, track_index: int, clip_index: int, note_ids: Any) -> dict:
154
+ """Remove specific MIDI notes by their IDs (JSON array of ints). Use undo to revert."""
155
+ _validate_track_index(track_index)
156
+ _validate_clip_index(clip_index)
157
+ note_ids = _ensure_list(note_ids)
158
+ if not note_ids:
159
+ raise ValueError("note_ids list cannot be empty")
160
+ return _get_ableton(ctx).send_command("remove_notes_by_id", {
161
+ "track_index": track_index,
162
+ "clip_index": clip_index,
163
+ "note_ids": note_ids,
164
+ })
165
+
166
+
167
+ @mcp.tool()
168
+ def modify_notes(ctx: Context, track_index: int, clip_index: int, modifications: Any) -> dict:
169
+ """Modify existing MIDI notes by ID. modifications is a JSON array: [{note_id, pitch?, start_time?, duration?, velocity?, probability?}]."""
170
+ _validate_track_index(track_index)
171
+ _validate_clip_index(clip_index)
172
+ modifications = _ensure_list(modifications)
173
+ if not modifications:
174
+ raise ValueError("modifications list cannot be empty")
175
+ for mod in modifications:
176
+ if "note_id" not in mod:
177
+ raise ValueError("Each modification must have a 'note_id' field")
178
+ if "pitch" in mod and not 0 <= int(mod["pitch"]) <= 127:
179
+ raise ValueError("pitch must be between 0 and 127")
180
+ if "duration" in mod and float(mod["duration"]) <= 0:
181
+ raise ValueError("duration must be > 0")
182
+ if "velocity" in mod and not 0.0 <= float(mod["velocity"]) <= 127.0:
183
+ raise ValueError("velocity must be between 0.0 and 127.0")
184
+ if "probability" in mod and not 0.0 <= float(mod["probability"]) <= 1.0:
185
+ raise ValueError("probability must be between 0.0 and 1.0")
186
+ return _get_ableton(ctx).send_command("modify_notes", {
187
+ "track_index": track_index,
188
+ "clip_index": clip_index,
189
+ "modifications": modifications,
190
+ })
191
+
192
+
193
+ @mcp.tool()
194
+ def duplicate_notes(
195
+ ctx: Context,
196
+ track_index: int,
197
+ clip_index: int,
198
+ note_ids: Any,
199
+ time_offset: float = 0.0,
200
+ ) -> dict:
201
+ """Duplicate specific notes by ID (JSON array of ints), with optional time offset (in beats)."""
202
+ _validate_track_index(track_index)
203
+ _validate_clip_index(clip_index)
204
+ note_ids = _ensure_list(note_ids)
205
+ if not note_ids:
206
+ raise ValueError("note_ids list cannot be empty")
207
+ return _get_ableton(ctx).send_command("duplicate_notes", {
208
+ "track_index": track_index,
209
+ "clip_index": clip_index,
210
+ "note_ids": note_ids,
211
+ "time_offset": time_offset,
212
+ })
213
+
214
+
215
+ @mcp.tool()
216
+ def transpose_notes(
217
+ ctx: Context,
218
+ track_index: int,
219
+ clip_index: int,
220
+ semitones: int,
221
+ from_time: float = 0.0,
222
+ time_span: Optional[float] = None,
223
+ arrangement: bool = False,
224
+ ) -> dict:
225
+ """Transpose notes in a time range by semitones (positive=up, negative=down).
226
+
227
+ Set arrangement=True to target an arrangement clip by its index in
228
+ track.arrangement_clips instead of a session clip slot.
229
+ """
230
+ _validate_track_index(track_index)
231
+ _validate_clip_index(clip_index)
232
+ if not -127 <= semitones <= 127:
233
+ raise ValueError("semitones must be between -127 and 127")
234
+ params = {
235
+ "track_index": track_index,
236
+ "clip_index": clip_index,
237
+ "semitones": semitones,
238
+ "from_time": from_time,
239
+ }
240
+ if arrangement:
241
+ params["arrangement"] = True
242
+ if time_span is not None:
243
+ if time_span <= 0:
244
+ raise ValueError("time_span must be > 0")
245
+ params["time_span"] = time_span
246
+ return _get_ableton(ctx).send_command("transpose_notes", params)
247
+
248
+
249
+ @mcp.tool()
250
+ def quantize_clip(
251
+ ctx: Context,
252
+ track_index: int,
253
+ clip_index: int,
254
+ grid: int,
255
+ amount: float = 1.0,
256
+ ) -> dict:
257
+ """Quantize a clip's notes to a grid. grid is a RecordQuantization enum: 0=None, 1=1/4, 2=1/8, 3=1/8T, 4=1/8+T, 5=1/16, 6=1/16T, 7=1/16+T, 8=1/32. amount 0.0-1.0."""
258
+ _validate_track_index(track_index)
259
+ _validate_clip_index(clip_index)
260
+ if not 0 <= grid <= 8:
261
+ raise ValueError("grid must be between 0 and 8 (RecordQuantization enum)")
262
+ if not 0.0 <= amount <= 1.0:
263
+ raise ValueError("amount must be between 0.0 and 1.0")
264
+ return _get_ableton(ctx).send_command("quantize_clip", {
265
+ "track_index": track_index,
266
+ "clip_index": clip_index,
267
+ "grid": grid,
268
+ "amount": amount,
269
+ })
@@ -0,0 +1,89 @@
1
+ """Scene MCP tools — list, create, delete, duplicate, fire, rename, color, tempo.
2
+
3
+ 8 tools matching the Remote Script scenes domain.
4
+ """
5
+
6
+ from fastmcp import Context
7
+
8
+ from ..server import mcp
9
+
10
+
11
+ def _get_ableton(ctx: Context):
12
+ """Extract AbletonConnection from lifespan context."""
13
+ return ctx.lifespan_context["ableton"]
14
+
15
+
16
+ def _validate_scene_index(scene_index: int):
17
+ if scene_index < 0:
18
+ raise ValueError("scene_index must be >= 0")
19
+
20
+
21
+ @mcp.tool()
22
+ def get_scenes_info(ctx: Context) -> dict:
23
+ """Get info for all scenes: name, tempo, color."""
24
+ return _get_ableton(ctx).send_command("get_scenes_info")
25
+
26
+
27
+ @mcp.tool()
28
+ def create_scene(ctx: Context, index: int = -1) -> dict:
29
+ """Create a new scene. index=-1 appends at end."""
30
+ return _get_ableton(ctx).send_command("create_scene", {"index": index})
31
+
32
+
33
+ @mcp.tool()
34
+ def delete_scene(ctx: Context, scene_index: int) -> dict:
35
+ """Delete a scene by index. Use undo to revert if needed."""
36
+ _validate_scene_index(scene_index)
37
+ return _get_ableton(ctx).send_command("delete_scene", {"scene_index": scene_index})
38
+
39
+
40
+ @mcp.tool()
41
+ def duplicate_scene(ctx: Context, scene_index: int) -> dict:
42
+ """Duplicate a scene (copies all clip slots)."""
43
+ _validate_scene_index(scene_index)
44
+ return _get_ableton(ctx).send_command("duplicate_scene", {"scene_index": scene_index})
45
+
46
+
47
+ @mcp.tool()
48
+ def fire_scene(ctx: Context, scene_index: int) -> dict:
49
+ """Fire (launch) a scene, triggering all its clips."""
50
+ _validate_scene_index(scene_index)
51
+ return _get_ableton(ctx).send_command("fire_scene", {"scene_index": scene_index})
52
+
53
+
54
+ @mcp.tool()
55
+ def set_scene_name(ctx: Context, scene_index: int, name: str) -> dict:
56
+ """Rename a scene. Pass empty string to clear the name."""
57
+ _validate_scene_index(scene_index)
58
+ return _get_ableton(ctx).send_command("set_scene_name", {
59
+ "scene_index": scene_index,
60
+ "name": name,
61
+ })
62
+
63
+
64
+ def _validate_color_index(color_index: int):
65
+ if not 0 <= color_index <= 69:
66
+ raise ValueError("color_index must be between 0 and 69")
67
+
68
+
69
+ @mcp.tool()
70
+ def set_scene_color(ctx: Context, scene_index: int, color_index: int) -> dict:
71
+ """Set scene color (0-69, Ableton's color palette)."""
72
+ _validate_scene_index(scene_index)
73
+ _validate_color_index(color_index)
74
+ return _get_ableton(ctx).send_command("set_scene_color", {
75
+ "scene_index": scene_index,
76
+ "color_index": color_index,
77
+ })
78
+
79
+
80
+ @mcp.tool()
81
+ def set_scene_tempo(ctx: Context, scene_index: int, tempo: float) -> dict:
82
+ """Set scene tempo in BPM (20-999). Fires when the scene launches."""
83
+ _validate_scene_index(scene_index)
84
+ if tempo < 20 or tempo > 999:
85
+ raise ValueError("Tempo must be between 20.0 and 999.0 BPM")
86
+ return _get_ableton(ctx).send_command("set_scene_tempo", {
87
+ "scene_index": scene_index,
88
+ "tempo": tempo,
89
+ })
@@ -0,0 +1,175 @@
1
+ """Track MCP tools — create, delete, rename, mute, solo, arm, group fold, monitor.
2
+
3
+ 14 tools matching the Remote Script tracks domain.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Optional
9
+
10
+ from fastmcp import Context
11
+
12
+ from ..server import mcp
13
+
14
+
15
+ def _get_ableton(ctx: Context):
16
+ """Extract AbletonConnection from lifespan context."""
17
+ return ctx.lifespan_context["ableton"]
18
+
19
+
20
+ def _validate_track_index(track_index: int):
21
+ """Validate track index. Must be >= 0 for regular tracks."""
22
+ if track_index < 0:
23
+ raise ValueError("track_index must be >= 0")
24
+
25
+
26
+ def _validate_color_index(color_index: int):
27
+ if not 0 <= color_index <= 69:
28
+ raise ValueError("color_index must be between 0 and 69")
29
+
30
+
31
+ @mcp.tool()
32
+ def get_track_info(ctx: Context, track_index: int) -> dict:
33
+ """Get detailed info about a track: clips, devices, mixer state."""
34
+ _validate_track_index(track_index)
35
+ return _get_ableton(ctx).send_command("get_track_info", {"track_index": track_index})
36
+
37
+
38
+ @mcp.tool()
39
+ def create_midi_track(
40
+ ctx: Context,
41
+ index: int = -1,
42
+ name: Optional[str] = None,
43
+ color: Optional[int] = None,
44
+ ) -> dict:
45
+ """Create a new MIDI track. index=-1 appends at end."""
46
+ params = {"index": index}
47
+ if name is not None:
48
+ if not name.strip():
49
+ raise ValueError("Track name cannot be empty")
50
+ params["name"] = name
51
+ if color is not None:
52
+ _validate_color_index(color)
53
+ params["color_index"] = color
54
+ return _get_ableton(ctx).send_command("create_midi_track", params)
55
+
56
+
57
+ @mcp.tool()
58
+ def create_audio_track(
59
+ ctx: Context,
60
+ index: int = -1,
61
+ name: Optional[str] = None,
62
+ color: Optional[int] = None,
63
+ ) -> dict:
64
+ """Create a new audio track. index=-1 appends at end."""
65
+ params = {"index": index}
66
+ if name is not None:
67
+ if not name.strip():
68
+ raise ValueError("Track name cannot be empty")
69
+ params["name"] = name
70
+ if color is not None:
71
+ _validate_color_index(color)
72
+ params["color_index"] = color
73
+ return _get_ableton(ctx).send_command("create_audio_track", params)
74
+
75
+
76
+ @mcp.tool()
77
+ def create_return_track(ctx: Context) -> dict:
78
+ """Create a new return track."""
79
+ return _get_ableton(ctx).send_command("create_return_track")
80
+
81
+
82
+ @mcp.tool()
83
+ def delete_track(ctx: Context, track_index: int) -> dict:
84
+ """Delete a track by index. Use undo to revert if needed."""
85
+ _validate_track_index(track_index)
86
+ return _get_ableton(ctx).send_command("delete_track", {"track_index": track_index})
87
+
88
+
89
+ @mcp.tool()
90
+ def duplicate_track(ctx: Context, track_index: int) -> dict:
91
+ """Duplicate a track (copies all clips, devices, and settings)."""
92
+ _validate_track_index(track_index)
93
+ return _get_ableton(ctx).send_command("duplicate_track", {"track_index": track_index})
94
+
95
+
96
+ @mcp.tool()
97
+ def set_track_name(ctx: Context, track_index: int, name: str) -> dict:
98
+ """Rename a track."""
99
+ _validate_track_index(track_index)
100
+ if not name.strip():
101
+ raise ValueError("Track name cannot be empty")
102
+ return _get_ableton(ctx).send_command("set_track_name", {
103
+ "track_index": track_index,
104
+ "name": name,
105
+ })
106
+
107
+
108
+ @mcp.tool()
109
+ def set_track_color(ctx: Context, track_index: int, color_index: int) -> dict:
110
+ """Set track color (0-69, Ableton's color palette)."""
111
+ _validate_track_index(track_index)
112
+ _validate_color_index(color_index)
113
+ return _get_ableton(ctx).send_command("set_track_color", {
114
+ "track_index": track_index,
115
+ "color_index": color_index,
116
+ })
117
+
118
+
119
+ @mcp.tool()
120
+ def set_track_mute(ctx: Context, track_index: int, muted: bool) -> dict:
121
+ """Mute or unmute a track."""
122
+ _validate_track_index(track_index)
123
+ return _get_ableton(ctx).send_command("set_track_mute", {
124
+ "track_index": track_index,
125
+ "mute": muted,
126
+ })
127
+
128
+
129
+ @mcp.tool()
130
+ def set_track_solo(ctx: Context, track_index: int, soloed: bool) -> dict:
131
+ """Solo or unsolo a track."""
132
+ _validate_track_index(track_index)
133
+ return _get_ableton(ctx).send_command("set_track_solo", {
134
+ "track_index": track_index,
135
+ "solo": soloed,
136
+ })
137
+
138
+
139
+ @mcp.tool()
140
+ def set_track_arm(ctx: Context, track_index: int, armed: bool) -> dict:
141
+ """Arm or disarm a track for recording."""
142
+ _validate_track_index(track_index)
143
+ return _get_ableton(ctx).send_command("set_track_arm", {
144
+ "track_index": track_index,
145
+ "arm": armed,
146
+ })
147
+
148
+
149
+ @mcp.tool()
150
+ def stop_track_clips(ctx: Context, track_index: int) -> dict:
151
+ """Stop all playing clips on a track."""
152
+ _validate_track_index(track_index)
153
+ return _get_ableton(ctx).send_command("stop_track_clips", {"track_index": track_index})
154
+
155
+
156
+ @mcp.tool()
157
+ def set_group_fold(ctx: Context, track_index: int, folded: bool) -> dict:
158
+ """Fold or unfold a group track to show/hide its children."""
159
+ _validate_track_index(track_index)
160
+ return _get_ableton(ctx).send_command("set_group_fold", {
161
+ "track_index": track_index,
162
+ "folded": folded,
163
+ })
164
+
165
+
166
+ @mcp.tool()
167
+ def set_track_input_monitoring(ctx: Context, track_index: int, state: int) -> dict:
168
+ """Set input monitoring (0=In, 1=Auto, 2=Off). Only for regular tracks, not return tracks."""
169
+ _validate_track_index(track_index)
170
+ if state not in (0, 1, 2):
171
+ raise ValueError("Monitoring state must be 0=In, 1=Auto, or 2=Off")
172
+ return _get_ableton(ctx).send_command("set_track_input_monitoring", {
173
+ "track_index": track_index,
174
+ "state": state,
175
+ })