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.
- package/.claude/settings.local.json +10 -0
- package/.mcpregistry_github_token +1 -0
- package/.mcpregistry_registry_token +1 -0
- package/.playwright-mcp/console-2026-03-17T15-47-29-021Z.log +10 -0
- package/.playwright-mcp/console-2026-03-17T15-51-09-247Z.log +10 -0
- package/.playwright-mcp/console-2026-03-17T15-52-22-831Z.log +12 -0
- package/.playwright-mcp/console-2026-03-17T15-52-29-709Z.log +10 -0
- package/.playwright-mcp/console-2026-03-17T15-53-20-147Z.log +1 -0
- package/.playwright-mcp/glama-snapshot.md +2140 -0
- package/.playwright-mcp/page-2026-03-17T15-49-02-625Z.png +0 -0
- package/.playwright-mcp/page-2026-03-17T15-52-15-149Z.png +0 -0
- package/.playwright-mcp/page-2026-03-17T15-52-57-333Z.png +0 -0
- package/CHANGELOG.md +33 -0
- package/LICENSE +21 -0
- package/README.md +296 -0
- package/bin/livepilot.js +376 -0
- package/installer/install.js +95 -0
- package/installer/paths.js +79 -0
- package/mcp_server/__init__.py +2 -0
- package/mcp_server/__main__.py +5 -0
- package/mcp_server/connection.py +207 -0
- package/mcp_server/server.py +40 -0
- package/mcp_server/tools/__init__.py +1 -0
- package/mcp_server/tools/arrangement.py +399 -0
- package/mcp_server/tools/browser.py +78 -0
- package/mcp_server/tools/clips.py +187 -0
- package/mcp_server/tools/devices.py +238 -0
- package/mcp_server/tools/mixing.py +113 -0
- package/mcp_server/tools/notes.py +266 -0
- package/mcp_server/tools/scenes.py +63 -0
- package/mcp_server/tools/tracks.py +148 -0
- package/mcp_server/tools/transport.py +113 -0
- package/package.json +38 -0
- package/plugin/.mcp.json +8 -0
- package/plugin/agents/livepilot-producer/AGENT.md +61 -0
- package/plugin/commands/beat.md +18 -0
- package/plugin/commands/mix.md +15 -0
- package/plugin/commands/session.md +13 -0
- package/plugin/commands/sounddesign.md +16 -0
- package/plugin/plugin.json +18 -0
- package/plugin/skills/livepilot-core/SKILL.md +160 -0
- package/plugin/skills/livepilot-core/references/ableton-workflow-patterns.md +831 -0
- package/plugin/skills/livepilot-core/references/m4l-devices.md +352 -0
- package/plugin/skills/livepilot-core/references/midi-recipes.md +402 -0
- package/plugin/skills/livepilot-core/references/mixing-patterns.md +578 -0
- package/plugin/skills/livepilot-core/references/overview.md +191 -0
- package/plugin/skills/livepilot-core/references/sound-design.md +392 -0
- package/remote_script/LivePilot/__init__.py +42 -0
- package/remote_script/LivePilot/arrangement.py +678 -0
- package/remote_script/LivePilot/browser.py +325 -0
- package/remote_script/LivePilot/clips.py +172 -0
- package/remote_script/LivePilot/devices.py +466 -0
- package/remote_script/LivePilot/diagnostics.py +198 -0
- package/remote_script/LivePilot/mixing.py +194 -0
- package/remote_script/LivePilot/notes.py +339 -0
- package/remote_script/LivePilot/router.py +74 -0
- package/remote_script/LivePilot/scenes.py +75 -0
- package/remote_script/LivePilot/server.py +286 -0
- package/remote_script/LivePilot/tracks.py +229 -0
- package/remote_script/LivePilot/transport.py +147 -0
- package/remote_script/LivePilot/utils.py +112 -0
- package/requirements.txt +2 -0
- 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
|
+
})
|