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,78 @@
1
+ """Browser MCP tools — browse, search, and load instruments/effects.
2
+
3
+ 4 tools matching the Remote Script browser 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_track_index(track_index: int):
17
+ if track_index < 0:
18
+ raise ValueError("track_index must be >= 0")
19
+
20
+
21
+ @mcp.tool()
22
+ def get_browser_tree(ctx: Context, category_type: str = "all") -> dict:
23
+ """Get an overview of browser categories and their children."""
24
+ return _get_ableton(ctx).send_command("get_browser_tree", {
25
+ "category_type": category_type,
26
+ })
27
+
28
+
29
+ @mcp.tool()
30
+ def get_browser_items(ctx: Context, path: str) -> dict:
31
+ """List items at a browser path (e.g., 'instruments/Analog')."""
32
+ if not path.strip():
33
+ raise ValueError("Path cannot be empty")
34
+ return _get_ableton(ctx).send_command("get_browser_items", {"path": path})
35
+
36
+
37
+ @mcp.tool()
38
+ def search_browser(
39
+ ctx: Context,
40
+ path: str,
41
+ name_filter: str | None = None,
42
+ loadable_only: bool = False,
43
+ max_depth: int = 8,
44
+ max_results: int = 100,
45
+ ) -> dict:
46
+ """Search the browser tree under a path, optionally filtering by name.
47
+
48
+ max_depth: how deep to recurse into subfolders (default 8)
49
+ max_results: maximum number of results to return (default 100)
50
+ """
51
+ if not path.strip():
52
+ raise ValueError("Path cannot be empty")
53
+ if max_depth < 1:
54
+ raise ValueError("max_depth must be >= 1")
55
+ if max_results < 1:
56
+ raise ValueError("max_results must be >= 1")
57
+ params: dict = {"path": path}
58
+ if name_filter is not None:
59
+ params["name_filter"] = name_filter
60
+ if loadable_only:
61
+ params["loadable_only"] = loadable_only
62
+ if max_depth != 8:
63
+ params["max_depth"] = max_depth
64
+ if max_results != 100:
65
+ params["max_results"] = max_results
66
+ return _get_ableton(ctx).send_command("search_browser", params)
67
+
68
+
69
+ @mcp.tool()
70
+ def load_browser_item(ctx: Context, track_index: int, uri: str) -> dict:
71
+ """Load a browser item (instrument/effect) onto a track by URI."""
72
+ _validate_track_index(track_index)
73
+ if not uri.strip():
74
+ raise ValueError("URI cannot be empty")
75
+ return _get_ableton(ctx).send_command("load_browser_item", {
76
+ "track_index": track_index,
77
+ "uri": uri,
78
+ })
@@ -0,0 +1,187 @@
1
+ """Clip MCP tools — info, create, delete, duplicate, fire, stop, properties.
2
+
3
+ 10 tools matching the Remote Script clips 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_track_index(track_index: int):
17
+ if track_index < 0:
18
+ raise ValueError("track_index must be >= 0")
19
+
20
+
21
+ def _validate_clip_index(clip_index: int):
22
+ if clip_index < 0:
23
+ raise ValueError("clip_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_clip_info(ctx: Context, track_index: int, clip_index: int) -> dict:
33
+ """Get detailed info about a clip: name, length, loop, launch settings."""
34
+ _validate_track_index(track_index)
35
+ _validate_clip_index(clip_index)
36
+ return _get_ableton(ctx).send_command("get_clip_info", {
37
+ "track_index": track_index,
38
+ "clip_index": clip_index,
39
+ })
40
+
41
+
42
+ @mcp.tool()
43
+ def create_clip(ctx: Context, track_index: int, clip_index: int, length: float) -> dict:
44
+ """Create an empty MIDI clip in a clip slot (length in beats)."""
45
+ _validate_track_index(track_index)
46
+ _validate_clip_index(clip_index)
47
+ if length <= 0:
48
+ raise ValueError("length must be > 0")
49
+ return _get_ableton(ctx).send_command("create_clip", {
50
+ "track_index": track_index,
51
+ "clip_index": clip_index,
52
+ "length": length,
53
+ })
54
+
55
+
56
+ @mcp.tool()
57
+ def delete_clip(ctx: Context, track_index: int, clip_index: int) -> dict:
58
+ """Delete a clip from a clip slot. This removes all notes and automation. Use undo to revert."""
59
+ _validate_track_index(track_index)
60
+ _validate_clip_index(clip_index)
61
+ return _get_ableton(ctx).send_command("delete_clip", {
62
+ "track_index": track_index,
63
+ "clip_index": clip_index,
64
+ })
65
+
66
+
67
+ @mcp.tool()
68
+ def duplicate_clip(
69
+ ctx: Context,
70
+ track_index: int,
71
+ clip_index: int,
72
+ target_track: int,
73
+ target_clip: int,
74
+ ) -> dict:
75
+ """Duplicate a clip from one slot to another."""
76
+ _validate_track_index(track_index)
77
+ _validate_clip_index(clip_index)
78
+ _validate_track_index(target_track)
79
+ _validate_clip_index(target_clip)
80
+ return _get_ableton(ctx).send_command("duplicate_clip", {
81
+ "track_index": track_index,
82
+ "clip_index": clip_index,
83
+ "target_track": target_track,
84
+ "target_clip": target_clip,
85
+ })
86
+
87
+
88
+ @mcp.tool()
89
+ def fire_clip(ctx: Context, track_index: int, clip_index: int) -> dict:
90
+ """Launch/fire a clip slot."""
91
+ _validate_track_index(track_index)
92
+ _validate_clip_index(clip_index)
93
+ return _get_ableton(ctx).send_command("fire_clip", {
94
+ "track_index": track_index,
95
+ "clip_index": clip_index,
96
+ })
97
+
98
+
99
+ @mcp.tool()
100
+ def stop_clip(ctx: Context, track_index: int, clip_index: int) -> dict:
101
+ """Stop a playing clip."""
102
+ _validate_track_index(track_index)
103
+ _validate_clip_index(clip_index)
104
+ return _get_ableton(ctx).send_command("stop_clip", {
105
+ "track_index": track_index,
106
+ "clip_index": clip_index,
107
+ })
108
+
109
+
110
+ @mcp.tool()
111
+ def set_clip_name(ctx: Context, track_index: int, clip_index: int, name: str) -> dict:
112
+ """Rename a clip."""
113
+ _validate_track_index(track_index)
114
+ _validate_clip_index(clip_index)
115
+ if not name.strip():
116
+ raise ValueError("Clip name cannot be empty")
117
+ return _get_ableton(ctx).send_command("set_clip_name", {
118
+ "track_index": track_index,
119
+ "clip_index": clip_index,
120
+ "name": name,
121
+ })
122
+
123
+
124
+ @mcp.tool()
125
+ def set_clip_color(ctx: Context, track_index: int, clip_index: int, color_index: int) -> dict:
126
+ """Set clip color (0-69, Ableton's color palette)."""
127
+ _validate_track_index(track_index)
128
+ _validate_clip_index(clip_index)
129
+ _validate_color_index(color_index)
130
+ return _get_ableton(ctx).send_command("set_clip_color", {
131
+ "track_index": track_index,
132
+ "clip_index": clip_index,
133
+ "color_index": color_index,
134
+ })
135
+
136
+
137
+ @mcp.tool()
138
+ def set_clip_loop(
139
+ ctx: Context,
140
+ track_index: int,
141
+ clip_index: int,
142
+ enabled: bool,
143
+ start: float | None = None,
144
+ end: float | None = None,
145
+ ) -> dict:
146
+ """Enable/disable clip looping and optionally set loop start/end (in beats)."""
147
+ _validate_track_index(track_index)
148
+ _validate_clip_index(clip_index)
149
+ params = {
150
+ "track_index": track_index,
151
+ "clip_index": clip_index,
152
+ "enabled": enabled,
153
+ }
154
+ if start is not None:
155
+ if start < 0:
156
+ raise ValueError("Loop start must be >= 0")
157
+ params["start"] = start
158
+ if end is not None:
159
+ if end <= 0:
160
+ raise ValueError("Loop end must be > 0")
161
+ params["end"] = end
162
+ if start is not None and end is not None and start >= end:
163
+ raise ValueError("Loop start must be less than loop end")
164
+ return _get_ableton(ctx).send_command("set_clip_loop", params)
165
+
166
+
167
+ @mcp.tool()
168
+ def set_clip_launch(
169
+ ctx: Context,
170
+ track_index: int,
171
+ clip_index: int,
172
+ mode: int,
173
+ quantization: int | None = None,
174
+ ) -> dict:
175
+ """Set clip launch mode (0=Trigger, 1=Gate, 2=Toggle, 3=Repeat) and optional quantization."""
176
+ _validate_track_index(track_index)
177
+ _validate_clip_index(clip_index)
178
+ if not 0 <= mode <= 3:
179
+ raise ValueError("Launch mode must be 0-3 (Trigger, Gate, Toggle, Repeat)")
180
+ params = {
181
+ "track_index": track_index,
182
+ "clip_index": clip_index,
183
+ "mode": mode,
184
+ }
185
+ if quantization is not None:
186
+ params["quantization"] = quantization
187
+ return _get_ableton(ctx).send_command("set_clip_launch", params)
@@ -0,0 +1,238 @@
1
+ """Device MCP tools — parameters, racks, browser loading.
2
+
3
+ 12 tools matching the Remote Script devices domain.
4
+ """
5
+
6
+ import json
7
+ from typing import Any
8
+
9
+ from fastmcp import Context
10
+
11
+ from ..server import mcp
12
+
13
+
14
+ def _ensure_list(value: Any) -> list:
15
+ """Parse JSON strings into lists. MCP clients may serialize list params as strings."""
16
+ if isinstance(value, str):
17
+ return json.loads(value)
18
+ return value
19
+
20
+
21
+ def _get_ableton(ctx: Context):
22
+ """Extract AbletonConnection from lifespan context."""
23
+ return ctx.lifespan_context["ableton"]
24
+
25
+
26
+ def _validate_track_index(track_index: int):
27
+ if track_index < 0:
28
+ raise ValueError("track_index must be >= 0")
29
+
30
+
31
+ def _validate_device_index(device_index: int):
32
+ if device_index < 0:
33
+ raise ValueError("device_index must be >= 0")
34
+
35
+
36
+ def _validate_chain_index(chain_index: int):
37
+ if chain_index < 0:
38
+ raise ValueError("chain_index must be >= 0")
39
+
40
+
41
+ @mcp.tool()
42
+ def get_device_info(ctx: Context, track_index: int, device_index: int) -> dict:
43
+ """Get info about a device: name, class, type, active state, parameter count."""
44
+ _validate_track_index(track_index)
45
+ _validate_device_index(device_index)
46
+ return _get_ableton(ctx).send_command("get_device_info", {
47
+ "track_index": track_index,
48
+ "device_index": device_index,
49
+ })
50
+
51
+
52
+ @mcp.tool()
53
+ def get_device_parameters(ctx: Context, track_index: int, device_index: int) -> dict:
54
+ """Get all parameters for a device with names, values, and ranges."""
55
+ _validate_track_index(track_index)
56
+ _validate_device_index(device_index)
57
+ return _get_ableton(ctx).send_command("get_device_parameters", {
58
+ "track_index": track_index,
59
+ "device_index": device_index,
60
+ })
61
+
62
+
63
+ @mcp.tool()
64
+ def set_device_parameter(
65
+ ctx: Context,
66
+ track_index: int,
67
+ device_index: int,
68
+ value: float,
69
+ parameter_name: str | None = None,
70
+ parameter_index: int | None = None,
71
+ ) -> dict:
72
+ """Set a device parameter by name or index."""
73
+ _validate_track_index(track_index)
74
+ _validate_device_index(device_index)
75
+ if parameter_name is None and parameter_index is None:
76
+ raise ValueError("Must provide parameter_name or parameter_index")
77
+ if parameter_index is not None and parameter_index < 0:
78
+ raise ValueError("parameter_index must be >= 0")
79
+ params = {
80
+ "track_index": track_index,
81
+ "device_index": device_index,
82
+ "value": value,
83
+ }
84
+ if parameter_name is not None:
85
+ params["parameter_name"] = parameter_name
86
+ if parameter_index is not None:
87
+ params["parameter_index"] = parameter_index
88
+ return _get_ableton(ctx).send_command("set_device_parameter", params)
89
+
90
+
91
+ @mcp.tool()
92
+ def batch_set_parameters(
93
+ ctx: Context,
94
+ track_index: int,
95
+ device_index: int,
96
+ parameters: Any,
97
+ ) -> dict:
98
+ """Set multiple device parameters in one call. parameters is a JSON array: [{name_or_index, value}]."""
99
+ _validate_track_index(track_index)
100
+ _validate_device_index(device_index)
101
+ parameters = _ensure_list(parameters)
102
+ if not parameters:
103
+ raise ValueError("parameters list cannot be empty")
104
+ for entry in parameters:
105
+ if "name_or_index" not in entry or "value" not in entry:
106
+ raise ValueError("Each parameter must have 'name_or_index' and 'value'")
107
+ return _get_ableton(ctx).send_command("batch_set_parameters", {
108
+ "track_index": track_index,
109
+ "device_index": device_index,
110
+ "parameters": parameters,
111
+ })
112
+
113
+
114
+ @mcp.tool()
115
+ def toggle_device(ctx: Context, track_index: int, device_index: int, active: bool) -> dict:
116
+ """Enable or disable a device."""
117
+ _validate_track_index(track_index)
118
+ _validate_device_index(device_index)
119
+ return _get_ableton(ctx).send_command("toggle_device", {
120
+ "track_index": track_index,
121
+ "device_index": device_index,
122
+ "active": active,
123
+ })
124
+
125
+
126
+ @mcp.tool()
127
+ def delete_device(ctx: Context, track_index: int, device_index: int) -> dict:
128
+ """Delete a device from a track. Use undo to revert if needed."""
129
+ _validate_track_index(track_index)
130
+ _validate_device_index(device_index)
131
+ return _get_ableton(ctx).send_command("delete_device", {
132
+ "track_index": track_index,
133
+ "device_index": device_index,
134
+ })
135
+
136
+
137
+ @mcp.tool()
138
+ def load_device_by_uri(ctx: Context, track_index: int, uri: str) -> dict:
139
+ """Load a device onto a track using a browser URI string."""
140
+ _validate_track_index(track_index)
141
+ if not uri.strip():
142
+ raise ValueError("URI cannot be empty")
143
+ return _get_ableton(ctx).send_command("load_device_by_uri", {
144
+ "track_index": track_index,
145
+ "uri": uri,
146
+ })
147
+
148
+
149
+ @mcp.tool()
150
+ def find_and_load_device(ctx: Context, track_index: int, device_name: str) -> dict:
151
+ """Search the browser for a device by name and load it onto a track."""
152
+ _validate_track_index(track_index)
153
+ if not device_name.strip():
154
+ raise ValueError("device_name cannot be empty")
155
+ return _get_ableton(ctx).send_command("find_and_load_device", {
156
+ "track_index": track_index,
157
+ "device_name": device_name,
158
+ })
159
+
160
+
161
+ @mcp.tool()
162
+ def set_simpler_playback_mode(
163
+ ctx: Context,
164
+ track_index: int,
165
+ device_index: int,
166
+ playback_mode: int,
167
+ slice_by: int | None = None,
168
+ sensitivity: float | None = None,
169
+ ) -> dict:
170
+ """Set Simpler's playback mode. playback_mode: 0=Classic, 1=One-Shot, 2=Slice. slice_by (Slice only): 0=Transient, 1=Beat, 2=Region, 3=Manual. sensitivity (0.0-1.0, Transient only)."""
171
+ _validate_track_index(track_index)
172
+ _validate_device_index(device_index)
173
+ if playback_mode not in (0, 1, 2):
174
+ raise ValueError("playback_mode must be 0 (Classic), 1 (One-Shot), or 2 (Slice)")
175
+ params = {
176
+ "track_index": track_index,
177
+ "device_index": device_index,
178
+ "playback_mode": playback_mode,
179
+ }
180
+ if slice_by is not None:
181
+ params["slice_by"] = slice_by
182
+ if sensitivity is not None:
183
+ params["sensitivity"] = sensitivity
184
+ return _get_ableton(ctx).send_command("set_simpler_playback_mode", params)
185
+
186
+
187
+ @mcp.tool()
188
+ def get_rack_chains(ctx: Context, track_index: int, device_index: int) -> dict:
189
+ """Get all chains in a rack device with volume, pan, mute, solo."""
190
+ _validate_track_index(track_index)
191
+ _validate_device_index(device_index)
192
+ return _get_ableton(ctx).send_command("get_rack_chains", {
193
+ "track_index": track_index,
194
+ "device_index": device_index,
195
+ })
196
+
197
+
198
+ @mcp.tool()
199
+ def set_chain_volume(
200
+ ctx: Context,
201
+ track_index: int,
202
+ device_index: int,
203
+ chain_index: int,
204
+ volume: float | None = None,
205
+ pan: float | None = None,
206
+ ) -> dict:
207
+ """Set volume and/or pan for a chain in a rack device."""
208
+ _validate_track_index(track_index)
209
+ _validate_device_index(device_index)
210
+ _validate_chain_index(chain_index)
211
+ if volume is not None and not 0.0 <= volume <= 1.0:
212
+ raise ValueError("volume must be between 0.0 and 1.0")
213
+ if pan is not None and not -1.0 <= pan <= 1.0:
214
+ raise ValueError("pan must be between -1.0 and 1.0")
215
+ if volume is None and pan is None:
216
+ raise ValueError("Must provide volume and/or pan")
217
+ params = {
218
+ "track_index": track_index,
219
+ "device_index": device_index,
220
+ "chain_index": chain_index,
221
+ }
222
+ if volume is not None:
223
+ params["volume"] = volume
224
+ if pan is not None:
225
+ params["pan"] = pan
226
+ return _get_ableton(ctx).send_command("set_chain_volume", params)
227
+
228
+
229
+ @mcp.tool()
230
+ def get_device_presets(ctx: Context, device_name: str) -> dict:
231
+ """List available presets for an Ableton device (e.g. 'Corpus', 'Drum Buss', 'Wavetable').
232
+ Searches audio_effects, instruments, and midi_effects categories.
233
+ Returns preset names and URIs that can be loaded with load_device_by_uri."""
234
+ if not device_name.strip():
235
+ raise ValueError("device_name cannot be empty")
236
+ return _get_ableton(ctx).send_command("get_device_presets", {
237
+ "device_name": device_name,
238
+ })
@@ -0,0 +1,113 @@
1
+ """Mixing MCP tools — volume, pan, sends, routing, master.
2
+
3
+ 8 tools matching the Remote Script mixing 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_track_index(track_index: int):
17
+ if track_index < 0:
18
+ raise ValueError("track_index must be >= 0")
19
+
20
+
21
+ @mcp.tool()
22
+ def set_track_volume(ctx: Context, track_index: int, volume: float) -> dict:
23
+ """Set a track's volume (0.0-1.0)."""
24
+ _validate_track_index(track_index)
25
+ if not 0.0 <= volume <= 1.0:
26
+ raise ValueError("Volume must be between 0.0 and 1.0")
27
+ return _get_ableton(ctx).send_command("set_track_volume", {
28
+ "track_index": track_index,
29
+ "volume": volume,
30
+ })
31
+
32
+
33
+ @mcp.tool()
34
+ def set_track_pan(ctx: Context, track_index: int, pan: float) -> dict:
35
+ """Set a track's panning (-1.0 left to 1.0 right)."""
36
+ _validate_track_index(track_index)
37
+ if not -1.0 <= pan <= 1.0:
38
+ raise ValueError("Pan must be between -1.0 and 1.0")
39
+ return _get_ableton(ctx).send_command("set_track_pan", {
40
+ "track_index": track_index,
41
+ "pan": pan,
42
+ })
43
+
44
+
45
+ @mcp.tool()
46
+ def set_track_send(
47
+ ctx: Context, track_index: int, send_index: int, value: float
48
+ ) -> dict:
49
+ """Set a send level on a track (0.0-1.0)."""
50
+ _validate_track_index(track_index)
51
+ if send_index < 0:
52
+ raise ValueError("send_index must be >= 0")
53
+ if not 0.0 <= value <= 1.0:
54
+ raise ValueError("Send value must be between 0.0 and 1.0")
55
+ return _get_ableton(ctx).send_command("set_track_send", {
56
+ "track_index": track_index,
57
+ "send_index": send_index,
58
+ "value": value,
59
+ })
60
+
61
+
62
+ @mcp.tool()
63
+ def get_return_tracks(ctx: Context) -> dict:
64
+ """Get info about all return tracks: name, volume, panning."""
65
+ return _get_ableton(ctx).send_command("get_return_tracks")
66
+
67
+
68
+ @mcp.tool()
69
+ def get_master_track(ctx: Context) -> dict:
70
+ """Get master track info: volume, panning, devices."""
71
+ return _get_ableton(ctx).send_command("get_master_track")
72
+
73
+
74
+ @mcp.tool()
75
+ def set_master_volume(ctx: Context, volume: float) -> dict:
76
+ """Set the master track volume (0.0-1.0)."""
77
+ if not 0.0 <= volume <= 1.0:
78
+ raise ValueError("Volume must be between 0.0 and 1.0")
79
+ return _get_ableton(ctx).send_command("set_master_volume", {"volume": volume})
80
+
81
+
82
+ @mcp.tool()
83
+ def get_track_routing(ctx: Context, track_index: int) -> dict:
84
+ """Get input/output routing info for a track."""
85
+ _validate_track_index(track_index)
86
+ return _get_ableton(ctx).send_command("get_track_routing", {
87
+ "track_index": track_index,
88
+ })
89
+
90
+
91
+ @mcp.tool()
92
+ def set_track_routing(
93
+ ctx: Context,
94
+ track_index: int,
95
+ input_type: str | None = None,
96
+ input_channel: str | None = None,
97
+ output_type: str | None = None,
98
+ output_channel: str | None = None,
99
+ ) -> dict:
100
+ """Set input/output routing for a track by display name."""
101
+ _validate_track_index(track_index)
102
+ params = {"track_index": track_index}
103
+ if input_type is not None:
104
+ params["input_type"] = input_type
105
+ if input_channel is not None:
106
+ params["input_channel"] = input_channel
107
+ if output_type is not None:
108
+ params["output_type"] = output_type
109
+ if output_channel is not None:
110
+ params["output_channel"] = output_channel
111
+ if len(params) == 1:
112
+ raise ValueError("At least one routing parameter must be provided")
113
+ return _get_ableton(ctx).send_command("set_track_routing", params)