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.
- package/CHANGELOG.md +33 -0
- package/LICENSE +21 -0
- package/README.md +409 -0
- package/bin/livepilot.js +390 -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 +210 -0
- package/mcp_server/memory/__init__.py +5 -0
- package/mcp_server/memory/technique_store.py +296 -0
- package/mcp_server/server.py +87 -0
- package/mcp_server/tools/__init__.py +1 -0
- package/mcp_server/tools/arrangement.py +407 -0
- package/mcp_server/tools/browser.py +86 -0
- package/mcp_server/tools/clips.py +218 -0
- package/mcp_server/tools/devices.py +256 -0
- package/mcp_server/tools/memory.py +198 -0
- package/mcp_server/tools/mixing.py +121 -0
- package/mcp_server/tools/notes.py +269 -0
- package/mcp_server/tools/scenes.py +89 -0
- package/mcp_server/tools/tracks.py +175 -0
- package/mcp_server/tools/transport.py +117 -0
- package/package.json +37 -0
- package/plugin/agents/livepilot-producer/AGENT.md +62 -0
- package/plugin/commands/beat.md +18 -0
- package/plugin/commands/memory.md +22 -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 +19 -0
- package/plugin/skills/livepilot-core/SKILL.md +208 -0
- package/plugin/skills/livepilot-core/references/ableton-workflow-patterns.md +831 -0
- package/plugin/skills/livepilot-core/references/device-atlas/00-index.md +110 -0
- package/plugin/skills/livepilot-core/references/device-atlas/distortion-and-character.md +687 -0
- package/plugin/skills/livepilot-core/references/device-atlas/drums-and-percussion.md +753 -0
- package/plugin/skills/livepilot-core/references/device-atlas/dynamics-and-punch.md +525 -0
- package/plugin/skills/livepilot-core/references/device-atlas/eq-and-filtering.md +402 -0
- package/plugin/skills/livepilot-core/references/device-atlas/midi-tools.md +963 -0
- package/plugin/skills/livepilot-core/references/device-atlas/movement-and-modulation.md +874 -0
- package/plugin/skills/livepilot-core/references/device-atlas/space-and-depth.md +571 -0
- package/plugin/skills/livepilot-core/references/device-atlas/spectral-and-weird.md +714 -0
- package/plugin/skills/livepilot-core/references/device-atlas/synths-native.md +953 -0
- package/plugin/skills/livepilot-core/references/m4l-devices.md +352 -0
- package/plugin/skills/livepilot-core/references/memory-guide.md +107 -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 +209 -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 +693 -0
- package/remote_script/LivePilot/browser.py +424 -0
- package/remote_script/LivePilot/clips.py +211 -0
- package/remote_script/LivePilot/devices.py +596 -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 +99 -0
- package/remote_script/LivePilot/server.py +293 -0
- package/remote_script/LivePilot/tracks.py +268 -0
- package/remote_script/LivePilot/transport.py +151 -0
- package/remote_script/LivePilot/utils.py +123 -0
- package/requirements.txt +2 -0
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
"""Arrangement MCP tools — clips, recording, cue points, navigation.
|
|
2
|
+
|
|
3
|
+
19 tools matching the Remote Script arrangement 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 _validate_track_index(track_index: int):
|
|
22
|
+
"""Validate track index. Must be >= 0 for regular tracks."""
|
|
23
|
+
if track_index < 0:
|
|
24
|
+
raise ValueError("track_index must be >= 0")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _validate_clip_index(clip_index: int):
|
|
28
|
+
if clip_index < 0:
|
|
29
|
+
raise ValueError("clip_index must be >= 0")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@mcp.tool()
|
|
33
|
+
def get_arrangement_clips(ctx: Context, track_index: int) -> dict:
|
|
34
|
+
"""Get all arrangement clips on a track."""
|
|
35
|
+
_validate_track_index(track_index)
|
|
36
|
+
return _get_ableton(ctx).send_command("get_arrangement_clips", {
|
|
37
|
+
"track_index": track_index,
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@mcp.tool()
|
|
42
|
+
def jump_to_time(ctx: Context, beat_time: float) -> dict:
|
|
43
|
+
"""Jump to a specific beat time in the arrangement."""
|
|
44
|
+
if beat_time < 0:
|
|
45
|
+
raise ValueError("beat_time must be >= 0")
|
|
46
|
+
return _get_ableton(ctx).send_command("jump_to_time", {"beat_time": beat_time})
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@mcp.tool()
|
|
50
|
+
def capture_midi(ctx: Context) -> dict:
|
|
51
|
+
"""Capture recently played MIDI notes into a new clip."""
|
|
52
|
+
return _get_ableton(ctx).send_command("capture_midi")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@mcp.tool()
|
|
56
|
+
def start_recording(ctx: Context, arrangement: bool = False) -> dict:
|
|
57
|
+
"""Start recording. arrangement=True for arrangement, False for session."""
|
|
58
|
+
return _get_ableton(ctx).send_command("start_recording", {
|
|
59
|
+
"arrangement": arrangement,
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@mcp.tool()
|
|
64
|
+
def stop_recording(ctx: Context) -> dict:
|
|
65
|
+
"""Stop all recording (both session and arrangement)."""
|
|
66
|
+
return _get_ableton(ctx).send_command("stop_recording")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@mcp.tool()
|
|
70
|
+
def get_cue_points(ctx: Context) -> dict:
|
|
71
|
+
"""Get all cue points in the arrangement."""
|
|
72
|
+
return _get_ableton(ctx).send_command("get_cue_points")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@mcp.tool()
|
|
76
|
+
def jump_to_cue(ctx: Context, cue_index: int) -> dict:
|
|
77
|
+
"""Jump to a cue point by index."""
|
|
78
|
+
if cue_index < 0:
|
|
79
|
+
raise ValueError("cue_index must be >= 0")
|
|
80
|
+
return _get_ableton(ctx).send_command("jump_to_cue", {"cue_index": cue_index})
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@mcp.tool()
|
|
84
|
+
def toggle_cue_point(ctx: Context) -> dict:
|
|
85
|
+
"""Set or delete a cue point at the current playback position."""
|
|
86
|
+
return _get_ableton(ctx).send_command("toggle_cue_point")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@mcp.tool()
|
|
90
|
+
def create_arrangement_clip(
|
|
91
|
+
ctx: Context,
|
|
92
|
+
track_index: int,
|
|
93
|
+
clip_slot_index: int,
|
|
94
|
+
start_time: float,
|
|
95
|
+
length: float,
|
|
96
|
+
loop_length: Optional[float] = None,
|
|
97
|
+
name: str = "",
|
|
98
|
+
color_index: Optional[int] = None,
|
|
99
|
+
) -> dict:
|
|
100
|
+
"""Duplicate a session clip into Arrangement View at a specific beat position.
|
|
101
|
+
|
|
102
|
+
clip_slot_index: which session clip slot to use as the source pattern
|
|
103
|
+
start_time: beat position in arrangement (0.0 = song start, 4.0 = bar 2)
|
|
104
|
+
length: total clip length in beats on the timeline
|
|
105
|
+
loop_length: pattern length to loop within the clip (e.g. 8.0 for an
|
|
106
|
+
8-beat pattern inside a 128-beat section). Defaults to
|
|
107
|
+
the source clip's length.
|
|
108
|
+
name: optional clip display name
|
|
109
|
+
color_index: optional 0-69 Ableton color
|
|
110
|
+
|
|
111
|
+
Returns clip_index in the track's arrangement_clips list.
|
|
112
|
+
"""
|
|
113
|
+
_validate_track_index(track_index)
|
|
114
|
+
if clip_slot_index < 0:
|
|
115
|
+
raise ValueError("clip_slot_index must be >= 0")
|
|
116
|
+
if start_time < 0:
|
|
117
|
+
raise ValueError("start_time must be >= 0")
|
|
118
|
+
if length <= 0:
|
|
119
|
+
raise ValueError("length must be > 0")
|
|
120
|
+
params: dict = {
|
|
121
|
+
"track_index": track_index,
|
|
122
|
+
"clip_slot_index": clip_slot_index,
|
|
123
|
+
"start_time": start_time,
|
|
124
|
+
"length": length,
|
|
125
|
+
}
|
|
126
|
+
if loop_length is not None:
|
|
127
|
+
params["loop_length"] = loop_length
|
|
128
|
+
if name:
|
|
129
|
+
params["name"] = name
|
|
130
|
+
if color_index is not None:
|
|
131
|
+
if not 0 <= color_index <= 69:
|
|
132
|
+
raise ValueError("color_index must be 0-69")
|
|
133
|
+
params["color_index"] = color_index
|
|
134
|
+
return _get_ableton(ctx).send_command("create_arrangement_clip", params)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@mcp.tool()
|
|
138
|
+
def add_arrangement_notes(
|
|
139
|
+
ctx: Context,
|
|
140
|
+
track_index: int,
|
|
141
|
+
clip_index: int,
|
|
142
|
+
notes: list | str,
|
|
143
|
+
) -> dict:
|
|
144
|
+
"""Add MIDI notes to an arrangement clip.
|
|
145
|
+
|
|
146
|
+
clip_index: index in track.arrangement_clips (returned by create_arrangement_clip
|
|
147
|
+
or get_arrangement_clips)
|
|
148
|
+
notes: list of dicts with: pitch (0-127), start_time (beats, relative to
|
|
149
|
+
clip start), duration (beats), velocity (1-127), mute (bool)
|
|
150
|
+
|
|
151
|
+
start_time in notes is relative to the clip start, not the song timeline.
|
|
152
|
+
"""
|
|
153
|
+
_validate_track_index(track_index)
|
|
154
|
+
_validate_clip_index(clip_index)
|
|
155
|
+
if isinstance(notes, str):
|
|
156
|
+
notes = json.loads(notes)
|
|
157
|
+
return _get_ableton(ctx).send_command("add_arrangement_notes", {
|
|
158
|
+
"track_index": track_index,
|
|
159
|
+
"clip_index": clip_index,
|
|
160
|
+
"notes": notes,
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@mcp.tool()
|
|
165
|
+
def set_arrangement_automation(
|
|
166
|
+
ctx: Context,
|
|
167
|
+
track_index: int,
|
|
168
|
+
clip_index: int,
|
|
169
|
+
parameter_type: str,
|
|
170
|
+
points: list | str,
|
|
171
|
+
device_index: Optional[int] = None,
|
|
172
|
+
parameter_index: Optional[int] = None,
|
|
173
|
+
send_index: Optional[int] = None,
|
|
174
|
+
) -> dict:
|
|
175
|
+
"""Write automation envelope points into an arrangement clip.
|
|
176
|
+
|
|
177
|
+
parameter_type: "device", "volume", "panning", or "send"
|
|
178
|
+
points: list of {time, value, duration?} dicts — time is relative
|
|
179
|
+
to clip start (0.0 = first beat of clip), value is the
|
|
180
|
+
parameter's native range (0.0-1.0 for most, check
|
|
181
|
+
get_device_parameters for exact min/max).
|
|
182
|
+
duration defaults to 0.125 beats (step automation).
|
|
183
|
+
For smooth ramps, use many closely-spaced points.
|
|
184
|
+
|
|
185
|
+
For parameter_type="device": device_index + parameter_index required.
|
|
186
|
+
For parameter_type="send": send_index required (0=A, 1=B, ...).
|
|
187
|
+
"""
|
|
188
|
+
_validate_track_index(track_index)
|
|
189
|
+
_validate_clip_index(clip_index)
|
|
190
|
+
if parameter_type not in ("device", "volume", "panning", "send"):
|
|
191
|
+
raise ValueError("parameter_type must be 'device', 'volume', 'panning', or 'send'")
|
|
192
|
+
if parameter_type == "device":
|
|
193
|
+
if device_index is None or parameter_index is None:
|
|
194
|
+
raise ValueError("device_index and parameter_index required for parameter_type='device'")
|
|
195
|
+
if parameter_type == "send" and send_index is None:
|
|
196
|
+
raise ValueError("send_index required for parameter_type='send'")
|
|
197
|
+
if isinstance(points, str):
|
|
198
|
+
points = json.loads(points)
|
|
199
|
+
if not points:
|
|
200
|
+
raise ValueError("points list cannot be empty")
|
|
201
|
+
params: dict = {
|
|
202
|
+
"track_index": track_index,
|
|
203
|
+
"clip_index": clip_index,
|
|
204
|
+
"parameter_type": parameter_type,
|
|
205
|
+
"points": points,
|
|
206
|
+
}
|
|
207
|
+
if device_index is not None:
|
|
208
|
+
params["device_index"] = device_index
|
|
209
|
+
if parameter_index is not None:
|
|
210
|
+
params["parameter_index"] = parameter_index
|
|
211
|
+
if send_index is not None:
|
|
212
|
+
params["send_index"] = send_index
|
|
213
|
+
return _get_ableton(ctx).send_command("set_arrangement_automation", params)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@mcp.tool()
|
|
217
|
+
def transpose_arrangement_notes(
|
|
218
|
+
ctx: Context,
|
|
219
|
+
track_index: int,
|
|
220
|
+
clip_index: int,
|
|
221
|
+
semitones: int,
|
|
222
|
+
from_time: float = 0.0,
|
|
223
|
+
time_span: Optional[float] = None,
|
|
224
|
+
) -> dict:
|
|
225
|
+
"""Transpose notes in an arrangement clip by semitones (positive=up, negative=down).
|
|
226
|
+
|
|
227
|
+
clip_index: index in track.arrangement_clips (from get_arrangement_clips)
|
|
228
|
+
semitones: number of semitones to shift (-127 to 127)
|
|
229
|
+
from_time: start of note range (beats, relative to clip start)
|
|
230
|
+
time_span: length of note range in beats (defaults to full clip)
|
|
231
|
+
"""
|
|
232
|
+
_validate_track_index(track_index)
|
|
233
|
+
_validate_clip_index(clip_index)
|
|
234
|
+
if not -127 <= semitones <= 127:
|
|
235
|
+
raise ValueError("semitones must be between -127 and 127")
|
|
236
|
+
params: dict = {
|
|
237
|
+
"track_index": track_index,
|
|
238
|
+
"clip_index": clip_index,
|
|
239
|
+
"semitones": semitones,
|
|
240
|
+
"from_time": from_time,
|
|
241
|
+
}
|
|
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_arrangement_notes", params)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@mcp.tool()
|
|
250
|
+
def set_arrangement_clip_name(
|
|
251
|
+
ctx: Context,
|
|
252
|
+
track_index: int,
|
|
253
|
+
clip_index: int,
|
|
254
|
+
name: str,
|
|
255
|
+
) -> dict:
|
|
256
|
+
"""Rename an arrangement clip by its index in the track's arrangement_clips list."""
|
|
257
|
+
_validate_track_index(track_index)
|
|
258
|
+
_validate_clip_index(clip_index)
|
|
259
|
+
if not name.strip():
|
|
260
|
+
raise ValueError("name cannot be empty")
|
|
261
|
+
return _get_ableton(ctx).send_command("set_arrangement_clip_name", {
|
|
262
|
+
"track_index": track_index,
|
|
263
|
+
"clip_index": clip_index,
|
|
264
|
+
"name": name,
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
@mcp.tool()
|
|
269
|
+
def back_to_arranger(ctx: Context) -> dict:
|
|
270
|
+
"""Switch playback from session clips back to the arrangement timeline."""
|
|
271
|
+
return _get_ableton(ctx).send_command("back_to_arranger")
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@mcp.tool()
|
|
275
|
+
def get_arrangement_notes(
|
|
276
|
+
ctx: Context,
|
|
277
|
+
track_index: int,
|
|
278
|
+
clip_index: int,
|
|
279
|
+
from_pitch: int = 0,
|
|
280
|
+
pitch_span: int = 128,
|
|
281
|
+
from_time: float = 0.0,
|
|
282
|
+
time_span: Optional[float] = None,
|
|
283
|
+
) -> dict:
|
|
284
|
+
"""Get MIDI notes from an arrangement clip. Returns note_id, pitch, start_time,
|
|
285
|
+
duration, velocity, mute, probability. Times are relative to clip start."""
|
|
286
|
+
_validate_track_index(track_index)
|
|
287
|
+
_validate_clip_index(clip_index)
|
|
288
|
+
if not 0 <= from_pitch <= 127:
|
|
289
|
+
raise ValueError("from_pitch must be between 0 and 127")
|
|
290
|
+
if pitch_span < 1 or pitch_span > 128:
|
|
291
|
+
raise ValueError("pitch_span must be between 1 and 128")
|
|
292
|
+
params: dict = {
|
|
293
|
+
"track_index": track_index,
|
|
294
|
+
"clip_index": clip_index,
|
|
295
|
+
"from_pitch": from_pitch,
|
|
296
|
+
"pitch_span": pitch_span,
|
|
297
|
+
"from_time": from_time,
|
|
298
|
+
}
|
|
299
|
+
if time_span is not None:
|
|
300
|
+
if time_span <= 0:
|
|
301
|
+
raise ValueError("time_span must be > 0")
|
|
302
|
+
params["time_span"] = time_span
|
|
303
|
+
return _get_ableton(ctx).send_command("get_arrangement_notes", params)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
@mcp.tool()
|
|
307
|
+
def remove_arrangement_notes(
|
|
308
|
+
ctx: Context,
|
|
309
|
+
track_index: int,
|
|
310
|
+
clip_index: int,
|
|
311
|
+
from_pitch: int = 0,
|
|
312
|
+
pitch_span: int = 128,
|
|
313
|
+
from_time: float = 0.0,
|
|
314
|
+
time_span: Optional[float] = None,
|
|
315
|
+
) -> dict:
|
|
316
|
+
"""Remove all MIDI notes in a pitch/time region of an arrangement clip. Defaults remove ALL notes."""
|
|
317
|
+
_validate_track_index(track_index)
|
|
318
|
+
_validate_clip_index(clip_index)
|
|
319
|
+
params: dict = {
|
|
320
|
+
"track_index": track_index,
|
|
321
|
+
"clip_index": clip_index,
|
|
322
|
+
"from_pitch": from_pitch,
|
|
323
|
+
"pitch_span": pitch_span,
|
|
324
|
+
"from_time": from_time,
|
|
325
|
+
}
|
|
326
|
+
if time_span is not None:
|
|
327
|
+
if time_span <= 0:
|
|
328
|
+
raise ValueError("time_span must be > 0")
|
|
329
|
+
params["time_span"] = time_span
|
|
330
|
+
return _get_ableton(ctx).send_command("remove_arrangement_notes", params)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
@mcp.tool()
|
|
334
|
+
def remove_arrangement_notes_by_id(
|
|
335
|
+
ctx: Context,
|
|
336
|
+
track_index: int,
|
|
337
|
+
clip_index: int,
|
|
338
|
+
note_ids: list | str,
|
|
339
|
+
) -> dict:
|
|
340
|
+
"""Remove specific MIDI notes from an arrangement clip by their IDs."""
|
|
341
|
+
_validate_track_index(track_index)
|
|
342
|
+
_validate_clip_index(clip_index)
|
|
343
|
+
if isinstance(note_ids, str):
|
|
344
|
+
note_ids = json.loads(note_ids)
|
|
345
|
+
if not note_ids:
|
|
346
|
+
raise ValueError("note_ids list cannot be empty")
|
|
347
|
+
return _get_ableton(ctx).send_command("remove_arrangement_notes_by_id", {
|
|
348
|
+
"track_index": track_index,
|
|
349
|
+
"clip_index": clip_index,
|
|
350
|
+
"note_ids": note_ids,
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
@mcp.tool()
|
|
355
|
+
def modify_arrangement_notes(
|
|
356
|
+
ctx: Context,
|
|
357
|
+
track_index: int,
|
|
358
|
+
clip_index: int,
|
|
359
|
+
modifications: list | str,
|
|
360
|
+
) -> dict:
|
|
361
|
+
"""Modify existing MIDI notes in an arrangement clip by ID. modifications is a JSON array:
|
|
362
|
+
[{note_id, pitch?, start_time?, duration?, velocity?, probability?}]."""
|
|
363
|
+
_validate_track_index(track_index)
|
|
364
|
+
_validate_clip_index(clip_index)
|
|
365
|
+
if isinstance(modifications, str):
|
|
366
|
+
modifications = json.loads(modifications)
|
|
367
|
+
if not modifications:
|
|
368
|
+
raise ValueError("modifications list cannot be empty")
|
|
369
|
+
for mod in modifications:
|
|
370
|
+
if "note_id" not in mod:
|
|
371
|
+
raise ValueError("Each modification must have a 'note_id' field")
|
|
372
|
+
if "pitch" in mod and not 0 <= int(mod["pitch"]) <= 127:
|
|
373
|
+
raise ValueError("pitch must be between 0 and 127")
|
|
374
|
+
if "duration" in mod and float(mod["duration"]) <= 0:
|
|
375
|
+
raise ValueError("duration must be > 0")
|
|
376
|
+
if "velocity" in mod and not 0.0 <= float(mod["velocity"]) <= 127.0:
|
|
377
|
+
raise ValueError("velocity must be between 0.0 and 127.0")
|
|
378
|
+
if "probability" in mod and not 0.0 <= float(mod["probability"]) <= 1.0:
|
|
379
|
+
raise ValueError("probability must be between 0.0 and 1.0")
|
|
380
|
+
return _get_ableton(ctx).send_command("modify_arrangement_notes", {
|
|
381
|
+
"track_index": track_index,
|
|
382
|
+
"clip_index": clip_index,
|
|
383
|
+
"modifications": modifications,
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
@mcp.tool()
|
|
388
|
+
def duplicate_arrangement_notes(
|
|
389
|
+
ctx: Context,
|
|
390
|
+
track_index: int,
|
|
391
|
+
clip_index: int,
|
|
392
|
+
note_ids: list | str,
|
|
393
|
+
time_offset: float = 0.0,
|
|
394
|
+
) -> dict:
|
|
395
|
+
"""Duplicate specific notes in an arrangement clip by ID, with optional time offset (beats)."""
|
|
396
|
+
_validate_track_index(track_index)
|
|
397
|
+
_validate_clip_index(clip_index)
|
|
398
|
+
if isinstance(note_ids, str):
|
|
399
|
+
note_ids = json.loads(note_ids)
|
|
400
|
+
if not note_ids:
|
|
401
|
+
raise ValueError("note_ids list cannot be empty")
|
|
402
|
+
return _get_ableton(ctx).send_command("duplicate_arrangement_notes", {
|
|
403
|
+
"track_index": track_index,
|
|
404
|
+
"clip_index": clip_index,
|
|
405
|
+
"note_ids": note_ids,
|
|
406
|
+
"time_offset": time_offset,
|
|
407
|
+
})
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Browser MCP tools — browse, search, and load instruments/effects.
|
|
2
|
+
|
|
3
|
+
4 tools matching the Remote Script browser 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
|
+
@mcp.tool()
|
|
27
|
+
def get_browser_tree(ctx: Context, category_type: str = "all") -> dict:
|
|
28
|
+
"""Get an overview of browser categories and their children."""
|
|
29
|
+
return _get_ableton(ctx).send_command("get_browser_tree", {
|
|
30
|
+
"category_type": category_type,
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@mcp.tool()
|
|
35
|
+
def get_browser_items(ctx: Context, path: str) -> dict:
|
|
36
|
+
"""List items at a browser path (e.g., 'instruments/Analog')."""
|
|
37
|
+
if not path.strip():
|
|
38
|
+
raise ValueError("Path cannot be empty")
|
|
39
|
+
return _get_ableton(ctx).send_command("get_browser_items", {"path": path})
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@mcp.tool()
|
|
43
|
+
def search_browser(
|
|
44
|
+
ctx: Context,
|
|
45
|
+
path: str,
|
|
46
|
+
name_filter: Optional[str] = None,
|
|
47
|
+
loadable_only: bool = False,
|
|
48
|
+
max_depth: int = 8,
|
|
49
|
+
max_results: int = 100,
|
|
50
|
+
) -> dict:
|
|
51
|
+
"""Search the browser tree under a path, optionally filtering by name.
|
|
52
|
+
|
|
53
|
+
path: top-level category to search under. Valid categories:
|
|
54
|
+
instruments, audio_effects, midi_effects, sounds, drums,
|
|
55
|
+
samples, packs, user_library, plugins, max_for_live, clips
|
|
56
|
+
max_depth: how deep to recurse into subfolders (default 8)
|
|
57
|
+
max_results: maximum number of results to return (default 100)
|
|
58
|
+
"""
|
|
59
|
+
if not path.strip():
|
|
60
|
+
raise ValueError("Path cannot be empty")
|
|
61
|
+
if max_depth < 1:
|
|
62
|
+
raise ValueError("max_depth must be >= 1")
|
|
63
|
+
if max_results < 1:
|
|
64
|
+
raise ValueError("max_results must be >= 1")
|
|
65
|
+
params: dict = {"path": path}
|
|
66
|
+
if name_filter is not None:
|
|
67
|
+
params["name_filter"] = name_filter
|
|
68
|
+
if loadable_only:
|
|
69
|
+
params["loadable_only"] = loadable_only
|
|
70
|
+
if max_depth != 8:
|
|
71
|
+
params["max_depth"] = max_depth
|
|
72
|
+
if max_results != 100:
|
|
73
|
+
params["max_results"] = max_results
|
|
74
|
+
return _get_ableton(ctx).send_command("search_browser", params)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@mcp.tool()
|
|
78
|
+
def load_browser_item(ctx: Context, track_index: int, uri: str) -> dict:
|
|
79
|
+
"""Load a browser item (instrument/effect) onto a track by URI."""
|
|
80
|
+
_validate_track_index(track_index)
|
|
81
|
+
if not uri.strip():
|
|
82
|
+
raise ValueError("URI cannot be empty")
|
|
83
|
+
return _get_ableton(ctx).send_command("load_browser_item", {
|
|
84
|
+
"track_index": track_index,
|
|
85
|
+
"uri": uri,
|
|
86
|
+
})
|