livepilot 1.1.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 (63) hide show
  1. package/.claude/settings.local.json +10 -0
  2. package/.mcpregistry_github_token +1 -0
  3. package/.mcpregistry_registry_token +1 -0
  4. package/.playwright-mcp/console-2026-03-17T15-47-29-021Z.log +10 -0
  5. package/.playwright-mcp/console-2026-03-17T15-51-09-247Z.log +10 -0
  6. package/.playwright-mcp/console-2026-03-17T15-52-22-831Z.log +12 -0
  7. package/.playwright-mcp/console-2026-03-17T15-52-29-709Z.log +10 -0
  8. package/.playwright-mcp/console-2026-03-17T15-53-20-147Z.log +1 -0
  9. package/.playwright-mcp/glama-snapshot.md +2140 -0
  10. package/.playwright-mcp/page-2026-03-17T15-49-02-625Z.png +0 -0
  11. package/.playwright-mcp/page-2026-03-17T15-52-15-149Z.png +0 -0
  12. package/.playwright-mcp/page-2026-03-17T15-52-57-333Z.png +0 -0
  13. package/CHANGELOG.md +33 -0
  14. package/LICENSE +21 -0
  15. package/README.md +296 -0
  16. package/bin/livepilot.js +376 -0
  17. package/installer/install.js +95 -0
  18. package/installer/paths.js +79 -0
  19. package/mcp_server/__init__.py +2 -0
  20. package/mcp_server/__main__.py +5 -0
  21. package/mcp_server/connection.py +207 -0
  22. package/mcp_server/server.py +40 -0
  23. package/mcp_server/tools/__init__.py +1 -0
  24. package/mcp_server/tools/arrangement.py +399 -0
  25. package/mcp_server/tools/browser.py +78 -0
  26. package/mcp_server/tools/clips.py +187 -0
  27. package/mcp_server/tools/devices.py +238 -0
  28. package/mcp_server/tools/mixing.py +113 -0
  29. package/mcp_server/tools/notes.py +266 -0
  30. package/mcp_server/tools/scenes.py +63 -0
  31. package/mcp_server/tools/tracks.py +148 -0
  32. package/mcp_server/tools/transport.py +113 -0
  33. package/package.json +38 -0
  34. package/plugin/.mcp.json +8 -0
  35. package/plugin/agents/livepilot-producer/AGENT.md +61 -0
  36. package/plugin/commands/beat.md +18 -0
  37. package/plugin/commands/mix.md +15 -0
  38. package/plugin/commands/session.md +13 -0
  39. package/plugin/commands/sounddesign.md +16 -0
  40. package/plugin/plugin.json +18 -0
  41. package/plugin/skills/livepilot-core/SKILL.md +160 -0
  42. package/plugin/skills/livepilot-core/references/ableton-workflow-patterns.md +831 -0
  43. package/plugin/skills/livepilot-core/references/m4l-devices.md +352 -0
  44. package/plugin/skills/livepilot-core/references/midi-recipes.md +402 -0
  45. package/plugin/skills/livepilot-core/references/mixing-patterns.md +578 -0
  46. package/plugin/skills/livepilot-core/references/overview.md +191 -0
  47. package/plugin/skills/livepilot-core/references/sound-design.md +392 -0
  48. package/remote_script/LivePilot/__init__.py +42 -0
  49. package/remote_script/LivePilot/arrangement.py +678 -0
  50. package/remote_script/LivePilot/browser.py +325 -0
  51. package/remote_script/LivePilot/clips.py +172 -0
  52. package/remote_script/LivePilot/devices.py +466 -0
  53. package/remote_script/LivePilot/diagnostics.py +198 -0
  54. package/remote_script/LivePilot/mixing.py +194 -0
  55. package/remote_script/LivePilot/notes.py +339 -0
  56. package/remote_script/LivePilot/router.py +74 -0
  57. package/remote_script/LivePilot/scenes.py +75 -0
  58. package/remote_script/LivePilot/server.py +286 -0
  59. package/remote_script/LivePilot/tracks.py +229 -0
  60. package/remote_script/LivePilot/transport.py +147 -0
  61. package/remote_script/LivePilot/utils.py +112 -0
  62. package/requirements.txt +2 -0
  63. package/server.json +20 -0
@@ -0,0 +1,207 @@
1
+ """TCP client for communicating with Ableton Live's Remote Script."""
2
+
3
+ import json
4
+ import os
5
+ import socket
6
+ import time
7
+ import uuid
8
+ from collections import deque
9
+
10
+ CONNECT_TIMEOUT = 5
11
+ RECV_TIMEOUT = 20
12
+
13
+
14
+ class AbletonConnectionError(Exception):
15
+ """Raised when communication with Ableton Live fails."""
16
+ pass
17
+
18
+
19
+ # Error messages with user-friendly context
20
+ _ERROR_HINTS = {
21
+ "INDEX_ERROR": "Check that the track, clip, device, or scene index exists. "
22
+ "Use get_session_info to see current indices.",
23
+ "NOT_FOUND": "The requested item could not be found in the Live session. "
24
+ "Verify names and indices with get_session_info or get_track_info.",
25
+ "INVALID_PARAM": "A parameter value was out of range or the wrong type. "
26
+ "Use get_device_parameters to check valid ranges.",
27
+ "STATE_ERROR": "The operation isn't valid in the current state. "
28
+ "For example, you can't add notes to a clip that doesn't exist yet.",
29
+ "TIMEOUT": "Ableton took too long to respond. This can happen with heavy sessions. "
30
+ "Try again, or check if Ableton is unresponsive.",
31
+ }
32
+
33
+
34
+ def _friendly_error(code: str, message: str, command_type: str) -> str:
35
+ """Format an error from the Remote Script into a user-friendly message."""
36
+ hint = _ERROR_HINTS.get(code, "")
37
+ parts = [f"[{code}] {message}"]
38
+ if hint:
39
+ parts.append(hint)
40
+ return " ".join(parts)
41
+
42
+
43
+ class AbletonConnection:
44
+ """TCP client that sends JSON commands to the LivePilot Remote Script."""
45
+
46
+ MAX_LOG_ENTRIES = 50
47
+
48
+ def __init__(self, host: str | None = None, port: int | None = None):
49
+ self.host = host or os.environ.get("LIVE_MCP_HOST", "127.0.0.1")
50
+ self.port = port or int(os.environ.get("LIVE_MCP_PORT", "9878"))
51
+ self._socket: socket.socket | None = None
52
+ self._recv_buf: bytes = b""
53
+ self._command_log: deque[dict] = deque(maxlen=self.MAX_LOG_ENTRIES)
54
+
55
+ # ------------------------------------------------------------------
56
+ # Connection lifecycle
57
+ # ------------------------------------------------------------------
58
+
59
+ def connect(self) -> None:
60
+ """Open a TCP connection to the Remote Script."""
61
+ try:
62
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
63
+ sock.settimeout(CONNECT_TIMEOUT)
64
+ sock.connect((self.host, self.port))
65
+ sock.settimeout(RECV_TIMEOUT)
66
+ self._socket = sock
67
+ except ConnectionRefusedError:
68
+ self._socket = None
69
+ raise AbletonConnectionError(
70
+ f"Cannot reach Ableton Live on {self.host}:{self.port}. "
71
+ "Make sure Ableton Live is running and the LivePilot Remote Script "
72
+ "is enabled in Preferences > Link, Tempo & MIDI > Control Surface. "
73
+ "Run 'npx livepilot --doctor' for a full diagnostic."
74
+ )
75
+ except OSError as exc:
76
+ self._socket = None
77
+ raise AbletonConnectionError(
78
+ f"Could not connect to Ableton Live at {self.host}:{self.port} — {exc}"
79
+ ) from exc
80
+
81
+ def disconnect(self) -> None:
82
+ """Close the TCP connection."""
83
+ if self._socket is not None:
84
+ try:
85
+ self._socket.close()
86
+ except OSError:
87
+ pass
88
+ self._socket = None
89
+
90
+ def is_connected(self) -> bool:
91
+ """Return True if a socket is currently held."""
92
+ return self._socket is not None
93
+
94
+ # ------------------------------------------------------------------
95
+ # High-level API
96
+ # ------------------------------------------------------------------
97
+
98
+ def ping(self) -> bool:
99
+ """Send a ping and return True if a pong is received."""
100
+ try:
101
+ resp = self._send_raw({"type": "ping"})
102
+ return resp.get("result", {}).get("pong") is True
103
+ except Exception:
104
+ return False
105
+
106
+ def send_command(self, command_type: str, params: dict | None = None) -> dict:
107
+ """Send a command to Ableton and return the result dict.
108
+
109
+ Retries once on socket errors with a fresh connection.
110
+ """
111
+ # Ensure we have a connection
112
+ if not self.is_connected():
113
+ self.connect()
114
+
115
+ command: dict = {"type": command_type}
116
+ if params:
117
+ command["params"] = params
118
+
119
+ try:
120
+ response = self._send_raw(command)
121
+ except (OSError, AbletonConnectionError):
122
+ # Retry once with a fresh connection
123
+ self.disconnect()
124
+ self.connect()
125
+ response = self._send_raw(command)
126
+
127
+ # Log the command and response
128
+ log_entry = {
129
+ "command": command_type,
130
+ "params": params,
131
+ "timestamp": time.time(),
132
+ "ok": response.get("ok", True),
133
+ }
134
+
135
+ # Handle error responses — Remote Script uses {"ok": false, "error": {"code": ..., "message": ...}}
136
+ if response.get("ok") is False:
137
+ err = response.get("error", {})
138
+ code = err.get("code", "INTERNAL") if isinstance(err, dict) else "INTERNAL"
139
+ message = err.get("message", str(err)) if isinstance(err, dict) else str(err)
140
+ log_entry["error"] = code
141
+ self._command_log.append(log_entry)
142
+ friendly = _friendly_error(code, message, command_type)
143
+ raise AbletonConnectionError(friendly)
144
+
145
+ self._command_log.append(log_entry)
146
+ return response.get("result", {})
147
+
148
+ # ------------------------------------------------------------------
149
+ # Command log
150
+ # ------------------------------------------------------------------
151
+
152
+ def get_recent_commands(self, limit: int = 20) -> list[dict]:
153
+ """Return the most recent commands sent to Ableton (newest first)."""
154
+ entries = list(self._command_log)
155
+ entries.reverse()
156
+ return entries[:limit]
157
+
158
+ # ------------------------------------------------------------------
159
+ # Low-level transport
160
+ # ------------------------------------------------------------------
161
+
162
+ def _send_raw(self, command: dict) -> dict:
163
+ """Send a JSON command (with request_id) and read the response."""
164
+ if self._socket is None:
165
+ raise AbletonConnectionError("Not connected to Ableton Live")
166
+
167
+ # Don't mutate the caller's dict
168
+ envelope = {**command, "id": str(uuid.uuid4())[:8]}
169
+ payload = json.dumps(envelope) + "\n"
170
+
171
+ try:
172
+ self._socket.sendall(payload.encode("utf-8"))
173
+ except OSError as exc:
174
+ self.disconnect()
175
+ raise AbletonConnectionError(f"Failed to send command: {exc}") from exc
176
+
177
+ # Read until newline, preserving any trailing bytes in _recv_buf
178
+ buf = getattr(self, "_recv_buf", b"")
179
+ try:
180
+ while b"\n" not in buf:
181
+ chunk = self._socket.recv(4096)
182
+ if not chunk:
183
+ self._recv_buf = b""
184
+ self.disconnect()
185
+ raise AbletonConnectionError("Connection closed by Ableton")
186
+ buf += chunk
187
+ except socket.timeout as exc:
188
+ self._recv_buf = buf
189
+ self.disconnect()
190
+ raise AbletonConnectionError(
191
+ f"Timeout waiting for response from Ableton ({RECV_TIMEOUT}s)"
192
+ ) from exc
193
+ except OSError as exc:
194
+ self._recv_buf = b""
195
+ self.disconnect()
196
+ raise AbletonConnectionError(
197
+ f"Socket error reading response: {exc}"
198
+ ) from exc
199
+
200
+ line, remainder = buf.split(b"\n", 1)
201
+ self._recv_buf = remainder
202
+ try:
203
+ return json.loads(line)
204
+ except json.JSONDecodeError as exc:
205
+ raise AbletonConnectionError(
206
+ f"Invalid JSON from Ableton: {line[:200]}"
207
+ ) from exc
@@ -0,0 +1,40 @@
1
+ """FastMCP entry point for LivePilot."""
2
+
3
+ from contextlib import asynccontextmanager
4
+
5
+ from fastmcp import FastMCP, Context # noqa: F401
6
+
7
+ from .connection import AbletonConnection
8
+
9
+
10
+ @asynccontextmanager
11
+ async def lifespan(server):
12
+ """Create and yield the shared AbletonConnection for all tools."""
13
+ ableton = AbletonConnection()
14
+ try:
15
+ yield {"ableton": ableton}
16
+ finally:
17
+ ableton.disconnect()
18
+
19
+
20
+ mcp = FastMCP("LivePilot", lifespan=lifespan)
21
+
22
+ # Import tool modules so they register with `mcp`
23
+ from .tools import transport # noqa: F401, E402
24
+ from .tools import tracks # noqa: F401, E402
25
+ from .tools import clips # noqa: F401, E402
26
+ from .tools import notes # noqa: F401, E402
27
+ from .tools import devices # noqa: F401, E402
28
+ from .tools import scenes # noqa: F401, E402
29
+ from .tools import mixing # noqa: F401, E402
30
+ from .tools import browser # noqa: F401, E402
31
+ from .tools import arrangement # noqa: F401, E402
32
+
33
+
34
+ def main():
35
+ """Run the MCP server over stdio."""
36
+ mcp.run(transport="stdio")
37
+
38
+
39
+ if __name__ == "__main__":
40
+ main()
@@ -0,0 +1 @@
1
+ """MCP tool modules for LivePilot."""
@@ -0,0 +1,399 @@
1
+ """Arrangement MCP tools — clips, recording, cue points, navigation.
2
+
3
+ 19 tools matching the Remote Script arrangement domain.
4
+ """
5
+
6
+ import json as _json
7
+
8
+ from fastmcp import Context
9
+
10
+ from ..server import mcp
11
+
12
+
13
+ def _get_ableton(ctx: Context):
14
+ """Extract AbletonConnection from lifespan context."""
15
+ return ctx.lifespan_context["ableton"]
16
+
17
+
18
+ def _validate_track_index(track_index: int):
19
+ if track_index < 0:
20
+ raise ValueError("track_index must be >= 0")
21
+
22
+
23
+ def _validate_clip_index(clip_index: int):
24
+ if clip_index < 0:
25
+ raise ValueError("clip_index must be >= 0")
26
+
27
+
28
+ @mcp.tool()
29
+ def get_arrangement_clips(ctx: Context, track_index: int) -> dict:
30
+ """Get all arrangement clips on a track."""
31
+ _validate_track_index(track_index)
32
+ return _get_ableton(ctx).send_command("get_arrangement_clips", {
33
+ "track_index": track_index,
34
+ })
35
+
36
+
37
+ @mcp.tool()
38
+ def jump_to_time(ctx: Context, beat_time: float) -> dict:
39
+ """Jump to a specific beat time in the arrangement."""
40
+ if beat_time < 0:
41
+ raise ValueError("beat_time must be >= 0")
42
+ return _get_ableton(ctx).send_command("jump_to_time", {"beat_time": beat_time})
43
+
44
+
45
+ @mcp.tool()
46
+ def capture_midi(ctx: Context) -> dict:
47
+ """Capture recently played MIDI notes into a new clip."""
48
+ return _get_ableton(ctx).send_command("capture_midi")
49
+
50
+
51
+ @mcp.tool()
52
+ def start_recording(ctx: Context, arrangement: bool = False) -> dict:
53
+ """Start recording. arrangement=True for arrangement, False for session."""
54
+ return _get_ableton(ctx).send_command("start_recording", {
55
+ "arrangement": arrangement,
56
+ })
57
+
58
+
59
+ @mcp.tool()
60
+ def stop_recording(ctx: Context) -> dict:
61
+ """Stop all recording (both session and arrangement)."""
62
+ return _get_ableton(ctx).send_command("stop_recording")
63
+
64
+
65
+ @mcp.tool()
66
+ def get_cue_points(ctx: Context) -> dict:
67
+ """Get all cue points in the arrangement."""
68
+ return _get_ableton(ctx).send_command("get_cue_points")
69
+
70
+
71
+ @mcp.tool()
72
+ def jump_to_cue(ctx: Context, cue_index: int) -> dict:
73
+ """Jump to a cue point by index."""
74
+ if cue_index < 0:
75
+ raise ValueError("cue_index must be >= 0")
76
+ return _get_ableton(ctx).send_command("jump_to_cue", {"cue_index": cue_index})
77
+
78
+
79
+ @mcp.tool()
80
+ def toggle_cue_point(ctx: Context) -> dict:
81
+ """Set or delete a cue point at the current playback position."""
82
+ return _get_ableton(ctx).send_command("toggle_cue_point")
83
+
84
+
85
+ @mcp.tool()
86
+ def create_arrangement_clip(
87
+ ctx: Context,
88
+ track_index: int,
89
+ clip_slot_index: int,
90
+ start_time: float,
91
+ length: float,
92
+ loop_length: float | None = None,
93
+ name: str = "",
94
+ color_index: int | None = None,
95
+ ) -> dict:
96
+ """Duplicate a session clip into Arrangement View at a specific beat position.
97
+
98
+ clip_slot_index: which session clip slot to use as the source pattern
99
+ start_time: beat position in arrangement (0.0 = song start, 4.0 = bar 2)
100
+ length: total clip length in beats on the timeline
101
+ loop_length: pattern length to loop within the clip (e.g. 8.0 for an
102
+ 8-beat pattern inside a 128-beat section). Defaults to
103
+ the source clip's length.
104
+ name: optional clip display name
105
+ color_index: optional 0-69 Ableton color
106
+
107
+ Returns clip_index in the track's arrangement_clips list.
108
+ """
109
+ _validate_track_index(track_index)
110
+ if clip_slot_index < 0:
111
+ raise ValueError("clip_slot_index must be >= 0")
112
+ if start_time < 0:
113
+ raise ValueError("start_time must be >= 0")
114
+ if length <= 0:
115
+ raise ValueError("length must be > 0")
116
+ params: dict = {
117
+ "track_index": track_index,
118
+ "clip_slot_index": clip_slot_index,
119
+ "start_time": start_time,
120
+ "length": length,
121
+ }
122
+ if loop_length is not None:
123
+ params["loop_length"] = loop_length
124
+ if name:
125
+ params["name"] = name
126
+ if color_index is not None:
127
+ if not 0 <= color_index <= 69:
128
+ raise ValueError("color_index must be 0-69")
129
+ params["color_index"] = color_index
130
+ return _get_ableton(ctx).send_command("create_arrangement_clip", params)
131
+
132
+
133
+ @mcp.tool()
134
+ def add_arrangement_notes(
135
+ ctx: Context,
136
+ track_index: int,
137
+ clip_index: int,
138
+ notes: list | str,
139
+ ) -> dict:
140
+ """Add MIDI notes to an arrangement clip.
141
+
142
+ clip_index: index in track.arrangement_clips (returned by create_arrangement_clip
143
+ or get_arrangement_clips)
144
+ notes: list of dicts with: pitch (0-127), start_time (beats, relative to
145
+ clip start), duration (beats), velocity (1-127), mute (bool)
146
+
147
+ start_time in notes is relative to the clip start, not the song timeline.
148
+ """
149
+ _validate_track_index(track_index)
150
+ if isinstance(notes, str):
151
+ notes = _json.loads(notes)
152
+ return _get_ableton(ctx).send_command("add_arrangement_notes", {
153
+ "track_index": track_index,
154
+ "clip_index": clip_index,
155
+ "notes": notes,
156
+ })
157
+
158
+
159
+ @mcp.tool()
160
+ def set_arrangement_automation(
161
+ ctx: Context,
162
+ track_index: int,
163
+ clip_index: int,
164
+ parameter_type: str,
165
+ points: list | str,
166
+ device_index: int | None = None,
167
+ parameter_index: int | None = None,
168
+ send_index: int | None = None,
169
+ ) -> dict:
170
+ """Write automation envelope points into an arrangement clip.
171
+
172
+ parameter_type: "device", "volume", "panning", or "send"
173
+ points: list of {time, value, duration?} dicts — time is relative
174
+ to clip start (0.0 = first beat of clip), value is the
175
+ parameter's native range (0.0-1.0 for most, check
176
+ get_device_parameters for exact min/max).
177
+ duration defaults to 0.125 beats (step automation).
178
+ For smooth ramps, use many closely-spaced points.
179
+
180
+ For parameter_type="device": device_index + parameter_index required.
181
+ For parameter_type="send": send_index required (0=A, 1=B, ...).
182
+ """
183
+ _validate_track_index(track_index)
184
+ if parameter_type not in ("device", "volume", "panning", "send"):
185
+ raise ValueError("parameter_type must be 'device', 'volume', 'panning', or 'send'")
186
+ if parameter_type == "device":
187
+ if device_index is None or parameter_index is None:
188
+ raise ValueError("device_index and parameter_index required for parameter_type='device'")
189
+ if parameter_type == "send" and send_index is None:
190
+ raise ValueError("send_index required for parameter_type='send'")
191
+ if isinstance(points, str):
192
+ points = _json.loads(points)
193
+ if not points:
194
+ raise ValueError("points list cannot be empty")
195
+ params: dict = {
196
+ "track_index": track_index,
197
+ "clip_index": clip_index,
198
+ "parameter_type": parameter_type,
199
+ "points": points,
200
+ }
201
+ if device_index is not None:
202
+ params["device_index"] = device_index
203
+ if parameter_index is not None:
204
+ params["parameter_index"] = parameter_index
205
+ if send_index is not None:
206
+ params["send_index"] = send_index
207
+ return _get_ableton(ctx).send_command("set_arrangement_automation", params)
208
+
209
+
210
+ @mcp.tool()
211
+ def transpose_arrangement_notes(
212
+ ctx: Context,
213
+ track_index: int,
214
+ clip_index: int,
215
+ semitones: int,
216
+ from_time: float = 0.0,
217
+ time_span: float | None = None,
218
+ ) -> dict:
219
+ """Transpose notes in an arrangement clip by semitones (positive=up, negative=down).
220
+
221
+ clip_index: index in track.arrangement_clips (from get_arrangement_clips)
222
+ semitones: number of semitones to shift (-127 to 127)
223
+ from_time: start of note range (beats, relative to clip start)
224
+ time_span: length of note range in beats (defaults to full clip)
225
+ """
226
+ _validate_track_index(track_index)
227
+ if not -127 <= semitones <= 127:
228
+ raise ValueError("semitones must be between -127 and 127")
229
+ params: dict = {
230
+ "track_index": track_index,
231
+ "clip_index": clip_index,
232
+ "semitones": semitones,
233
+ "from_time": from_time,
234
+ }
235
+ if time_span is not None:
236
+ if time_span <= 0:
237
+ raise ValueError("time_span must be > 0")
238
+ params["time_span"] = time_span
239
+ return _get_ableton(ctx).send_command("transpose_arrangement_notes", params)
240
+
241
+
242
+ @mcp.tool()
243
+ def set_arrangement_clip_name(
244
+ ctx: Context,
245
+ track_index: int,
246
+ clip_index: int,
247
+ name: str,
248
+ ) -> dict:
249
+ """Rename an arrangement clip by its index in the track's arrangement_clips list."""
250
+ _validate_track_index(track_index)
251
+ if not name.strip():
252
+ raise ValueError("name cannot be empty")
253
+ return _get_ableton(ctx).send_command("set_arrangement_clip_name", {
254
+ "track_index": track_index,
255
+ "clip_index": clip_index,
256
+ "name": name,
257
+ })
258
+
259
+
260
+ @mcp.tool()
261
+ def back_to_arranger(ctx: Context) -> dict:
262
+ """Switch playback from session clips back to the arrangement timeline."""
263
+ return _get_ableton(ctx).send_command("back_to_arranger")
264
+
265
+
266
+ @mcp.tool()
267
+ def get_arrangement_notes(
268
+ ctx: Context,
269
+ track_index: int,
270
+ clip_index: int,
271
+ from_pitch: int = 0,
272
+ pitch_span: int = 128,
273
+ from_time: float = 0.0,
274
+ time_span: float | None = None,
275
+ ) -> dict:
276
+ """Get MIDI notes from an arrangement clip. Returns note_id, pitch, start_time,
277
+ duration, velocity, mute, probability. Times are relative to clip start."""
278
+ _validate_track_index(track_index)
279
+ _validate_clip_index(clip_index)
280
+ if not 0 <= from_pitch <= 127:
281
+ raise ValueError("from_pitch must be between 0 and 127")
282
+ if pitch_span < 1 or pitch_span > 128:
283
+ raise ValueError("pitch_span must be between 1 and 128")
284
+ params: dict = {
285
+ "track_index": track_index,
286
+ "clip_index": clip_index,
287
+ "from_pitch": from_pitch,
288
+ "pitch_span": pitch_span,
289
+ "from_time": from_time,
290
+ }
291
+ if time_span is not None:
292
+ if time_span <= 0:
293
+ raise ValueError("time_span must be > 0")
294
+ params["time_span"] = time_span
295
+ return _get_ableton(ctx).send_command("get_arrangement_notes", params)
296
+
297
+
298
+ @mcp.tool()
299
+ def remove_arrangement_notes(
300
+ ctx: Context,
301
+ track_index: int,
302
+ clip_index: int,
303
+ from_pitch: int = 0,
304
+ pitch_span: int = 128,
305
+ from_time: float = 0.0,
306
+ time_span: float | None = None,
307
+ ) -> dict:
308
+ """Remove all MIDI notes in a pitch/time region of an arrangement clip. Defaults remove ALL notes."""
309
+ _validate_track_index(track_index)
310
+ _validate_clip_index(clip_index)
311
+ params: dict = {
312
+ "track_index": track_index,
313
+ "clip_index": clip_index,
314
+ "from_pitch": from_pitch,
315
+ "pitch_span": pitch_span,
316
+ "from_time": from_time,
317
+ }
318
+ if time_span is not None:
319
+ if time_span <= 0:
320
+ raise ValueError("time_span must be > 0")
321
+ params["time_span"] = time_span
322
+ return _get_ableton(ctx).send_command("remove_arrangement_notes", params)
323
+
324
+
325
+ @mcp.tool()
326
+ def remove_arrangement_notes_by_id(
327
+ ctx: Context,
328
+ track_index: int,
329
+ clip_index: int,
330
+ note_ids: list | str,
331
+ ) -> dict:
332
+ """Remove specific MIDI notes from an arrangement clip by their IDs."""
333
+ _validate_track_index(track_index)
334
+ _validate_clip_index(clip_index)
335
+ if isinstance(note_ids, str):
336
+ note_ids = _json.loads(note_ids)
337
+ if not note_ids:
338
+ raise ValueError("note_ids list cannot be empty")
339
+ return _get_ableton(ctx).send_command("remove_arrangement_notes_by_id", {
340
+ "track_index": track_index,
341
+ "clip_index": clip_index,
342
+ "note_ids": note_ids,
343
+ })
344
+
345
+
346
+ @mcp.tool()
347
+ def modify_arrangement_notes(
348
+ ctx: Context,
349
+ track_index: int,
350
+ clip_index: int,
351
+ modifications: list | str,
352
+ ) -> dict:
353
+ """Modify existing MIDI notes in an arrangement clip by ID. modifications is a JSON array:
354
+ [{note_id, pitch?, start_time?, duration?, velocity?, probability?}]."""
355
+ _validate_track_index(track_index)
356
+ _validate_clip_index(clip_index)
357
+ if isinstance(modifications, str):
358
+ modifications = _json.loads(modifications)
359
+ if not modifications:
360
+ raise ValueError("modifications list cannot be empty")
361
+ for mod in modifications:
362
+ if "note_id" not in mod:
363
+ raise ValueError("Each modification must have a 'note_id' field")
364
+ if "pitch" in mod and not 0 <= int(mod["pitch"]) <= 127:
365
+ raise ValueError("pitch must be between 0 and 127")
366
+ if "duration" in mod and float(mod["duration"]) <= 0:
367
+ raise ValueError("duration must be > 0")
368
+ if "velocity" in mod and not 0.0 <= float(mod["velocity"]) <= 127.0:
369
+ raise ValueError("velocity must be between 0.0 and 127.0")
370
+ if "probability" in mod and not 0.0 <= float(mod["probability"]) <= 1.0:
371
+ raise ValueError("probability must be between 0.0 and 1.0")
372
+ return _get_ableton(ctx).send_command("modify_arrangement_notes", {
373
+ "track_index": track_index,
374
+ "clip_index": clip_index,
375
+ "modifications": modifications,
376
+ })
377
+
378
+
379
+ @mcp.tool()
380
+ def duplicate_arrangement_notes(
381
+ ctx: Context,
382
+ track_index: int,
383
+ clip_index: int,
384
+ note_ids: list | str,
385
+ time_offset: float = 0.0,
386
+ ) -> dict:
387
+ """Duplicate specific notes in an arrangement clip by ID, with optional time offset (beats)."""
388
+ _validate_track_index(track_index)
389
+ _validate_clip_index(clip_index)
390
+ if isinstance(note_ids, str):
391
+ note_ids = _json.loads(note_ids)
392
+ if not note_ids:
393
+ raise ValueError("note_ids list cannot be empty")
394
+ return _get_ableton(ctx).send_command("duplicate_arrangement_notes", {
395
+ "track_index": track_index,
396
+ "clip_index": clip_index,
397
+ "note_ids": note_ids,
398
+ "time_offset": time_offset,
399
+ })