livepilot 1.8.3 → 1.9.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-plugin/marketplace.json +3 -3
- package/AGENTS.md +46 -0
- package/CHANGELOG.md +41 -0
- package/README.md +26 -19
- package/bin/livepilot.js +4 -2
- package/livepilot/.claude-plugin/plugin.json +2 -2
- package/livepilot/skills/livepilot-core/SKILL.md +16 -8
- package/livepilot/skills/livepilot-core/references/overview.md +3 -3
- package/livepilot/skills/livepilot-release/SKILL.md +37 -29
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/livepilot_bridge.js +170 -1
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/m4l_bridge.py +32 -24
- package/mcp_server/memory/technique_store.py +2 -2
- package/mcp_server/server.py +16 -1
- package/mcp_server/tools/_perception_engine.py +3 -2
- package/mcp_server/tools/analyzer.py +8 -2
- package/mcp_server/tools/arrangement.py +12 -1
- package/mcp_server/tools/automation.py +4 -2
- package/mcp_server/tools/devices.py +95 -2
- package/mcp_server/tools/harmony.py +2 -2
- package/mcp_server/tools/midi_io.py +57 -22
- package/mcp_server/tools/notes.py +4 -0
- package/mcp_server/tools/scenes.py +65 -2
- package/mcp_server/tools/tracks.py +45 -2
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +2 -2
- package/remote_script/LivePilot/arrangement.py +42 -0
- package/remote_script/LivePilot/clip_automation.py +18 -30
- package/remote_script/LivePilot/scenes.py +110 -1
- package/remote_script/LivePilot/server.py +15 -2
- package/remote_script/LivePilot/tracks.py +73 -2
|
@@ -83,7 +83,7 @@ function anything() {
|
|
|
83
83
|
function dispatch(cmd, args) {
|
|
84
84
|
switch(cmd) {
|
|
85
85
|
case "ping":
|
|
86
|
-
send_response({"ok": true, "version": "1.
|
|
86
|
+
send_response({"ok": true, "version": "1.9.0"});
|
|
87
87
|
break;
|
|
88
88
|
case "get_params":
|
|
89
89
|
cmd_get_params(args);
|
|
@@ -161,6 +161,16 @@ function dispatch(cmd, args) {
|
|
|
161
161
|
case "get_display_values":
|
|
162
162
|
cmd_get_display_values(args);
|
|
163
163
|
break;
|
|
164
|
+
// ── Plugin Parameters ──
|
|
165
|
+
case "get_plugin_params":
|
|
166
|
+
cmd_get_plugin_params(args);
|
|
167
|
+
break;
|
|
168
|
+
case "map_plugin_param":
|
|
169
|
+
cmd_map_plugin_param(args);
|
|
170
|
+
break;
|
|
171
|
+
case "get_plugin_presets":
|
|
172
|
+
cmd_get_plugin_presets(args);
|
|
173
|
+
break;
|
|
164
174
|
default:
|
|
165
175
|
send_response({"error": "Unknown command: " + cmd});
|
|
166
176
|
}
|
|
@@ -1035,6 +1045,165 @@ function pad2(n) {
|
|
|
1035
1045
|
return n < 10 ? "0" + n : "" + n;
|
|
1036
1046
|
}
|
|
1037
1047
|
|
|
1048
|
+
// ── Plugin Parameters ──────────────────────────────────────────────────────
|
|
1049
|
+
|
|
1050
|
+
function cmd_get_plugin_params(args) {
|
|
1051
|
+
// Returns all parameters for a VST/AU plugin device
|
|
1052
|
+
var track_idx = parseInt(args[0]);
|
|
1053
|
+
var device_idx = parseInt(args[1]);
|
|
1054
|
+
var path = build_device_path(track_idx, device_idx);
|
|
1055
|
+
|
|
1056
|
+
cursor_a.goto(path);
|
|
1057
|
+
var class_name = cursor_a.get("class_name").toString();
|
|
1058
|
+
|
|
1059
|
+
// Check if this is a plugin device
|
|
1060
|
+
var is_plugin = (class_name === "PluginDevice" || class_name === "AuPluginDevice");
|
|
1061
|
+
if (!is_plugin) {
|
|
1062
|
+
send_response({
|
|
1063
|
+
"error": "Device is " + class_name + ", not a plugin (PluginDevice/AuPluginDevice)"
|
|
1064
|
+
});
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
var device_name = cursor_a.get("name").toString();
|
|
1069
|
+
var param_count = cursor_a.getcount("parameters");
|
|
1070
|
+
var params = [];
|
|
1071
|
+
var current = 0;
|
|
1072
|
+
var batch_size = 4;
|
|
1073
|
+
|
|
1074
|
+
function read_batch() {
|
|
1075
|
+
var end = Math.min(current + batch_size, param_count);
|
|
1076
|
+
for (var i = current; i < end; i++) {
|
|
1077
|
+
cursor_b.goto(path + " parameters " + i);
|
|
1078
|
+
params.push({
|
|
1079
|
+
index: i,
|
|
1080
|
+
name: cursor_b.get("name").toString(),
|
|
1081
|
+
value: parseFloat(cursor_b.get("value")),
|
|
1082
|
+
min: parseFloat(cursor_b.get("min")),
|
|
1083
|
+
max: parseFloat(cursor_b.get("max")),
|
|
1084
|
+
default_value: parseFloat(cursor_b.get("default_value")),
|
|
1085
|
+
is_quantized: parseInt(cursor_b.get("is_quantized")) === 1,
|
|
1086
|
+
value_string: String(cursor_b.call("str_for_value", parseFloat(cursor_b.get("value"))))
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
current = end;
|
|
1090
|
+
|
|
1091
|
+
if (current < param_count) {
|
|
1092
|
+
var next_task = new Task(read_batch);
|
|
1093
|
+
next_task.schedule(50);
|
|
1094
|
+
} else {
|
|
1095
|
+
send_response({
|
|
1096
|
+
"track": track_idx,
|
|
1097
|
+
"device": device_idx,
|
|
1098
|
+
"name": device_name,
|
|
1099
|
+
"class_name": class_name,
|
|
1100
|
+
"is_plugin": true,
|
|
1101
|
+
"parameter_count": param_count,
|
|
1102
|
+
"parameters": params
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
if (param_count > 0) {
|
|
1108
|
+
read_batch();
|
|
1109
|
+
} else {
|
|
1110
|
+
send_response({
|
|
1111
|
+
"track": track_idx,
|
|
1112
|
+
"device": device_idx,
|
|
1113
|
+
"name": device_name,
|
|
1114
|
+
"class_name": class_name,
|
|
1115
|
+
"is_plugin": true,
|
|
1116
|
+
"parameter_count": 0,
|
|
1117
|
+
"parameters": []
|
|
1118
|
+
});
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
function cmd_map_plugin_param(args) {
|
|
1123
|
+
// Add a plugin parameter to Ableton's Configure list
|
|
1124
|
+
var track_idx = parseInt(args[0]);
|
|
1125
|
+
var device_idx = parseInt(args[1]);
|
|
1126
|
+
var param_idx = parseInt(args[2]);
|
|
1127
|
+
var path = build_device_path(track_idx, device_idx);
|
|
1128
|
+
|
|
1129
|
+
cursor_a.goto(path);
|
|
1130
|
+
var param_count = cursor_a.getcount("parameters");
|
|
1131
|
+
if (param_idx < 0 || param_idx >= param_count) {
|
|
1132
|
+
send_response({"error": "Parameter index " + param_idx + " out of range (0.." + (param_count - 1) + ")"});
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// Navigate to the parameter and read its name
|
|
1137
|
+
cursor_b.goto(path + " parameters " + param_idx);
|
|
1138
|
+
var param_name = cursor_b.get("name").toString();
|
|
1139
|
+
|
|
1140
|
+
// Select the parameter — this is how Ableton's Configure mode works
|
|
1141
|
+
// via LiveAPI. The parameter becomes visible in the device's macro panel.
|
|
1142
|
+
try {
|
|
1143
|
+
cursor_a.set("selected_parameter", param_idx);
|
|
1144
|
+
cursor_a.call("store_chosen_bank");
|
|
1145
|
+
send_response({
|
|
1146
|
+
"mapped": true,
|
|
1147
|
+
"parameter_index": param_idx,
|
|
1148
|
+
"parameter_name": param_name
|
|
1149
|
+
});
|
|
1150
|
+
} catch(e) {
|
|
1151
|
+
send_response({
|
|
1152
|
+
"error": "Failed to map parameter: " + e.message,
|
|
1153
|
+
"parameter_index": param_idx,
|
|
1154
|
+
"parameter_name": param_name
|
|
1155
|
+
});
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
function cmd_get_plugin_presets(args) {
|
|
1160
|
+
// List plugin's internal presets
|
|
1161
|
+
var track_idx = parseInt(args[0]);
|
|
1162
|
+
var device_idx = parseInt(args[1]);
|
|
1163
|
+
var path = build_device_path(track_idx, device_idx);
|
|
1164
|
+
|
|
1165
|
+
cursor_a.goto(path);
|
|
1166
|
+
var class_name = cursor_a.get("class_name").toString();
|
|
1167
|
+
var device_name = cursor_a.get("name").toString();
|
|
1168
|
+
|
|
1169
|
+
var is_plugin = (class_name === "PluginDevice" || class_name === "AuPluginDevice");
|
|
1170
|
+
if (!is_plugin) {
|
|
1171
|
+
send_response({
|
|
1172
|
+
"error": "Device is " + class_name + ", not a plugin"
|
|
1173
|
+
});
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
// Read presets — the presets property returns an array of preset names
|
|
1178
|
+
var presets = [];
|
|
1179
|
+
try {
|
|
1180
|
+
var preset_count = cursor_a.getcount("presets");
|
|
1181
|
+
for (var i = 0; i < preset_count; i++) {
|
|
1182
|
+
cursor_b.goto(path + " presets " + i);
|
|
1183
|
+
presets.push({
|
|
1184
|
+
index: i,
|
|
1185
|
+
name: cursor_b.get("name").toString()
|
|
1186
|
+
});
|
|
1187
|
+
}
|
|
1188
|
+
} catch(e) {
|
|
1189
|
+
// Some plugins don't expose presets via LOM
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
// Try to get selected preset index
|
|
1193
|
+
var selected = -1;
|
|
1194
|
+
try {
|
|
1195
|
+
selected = parseInt(cursor_a.get("selected_preset_index"));
|
|
1196
|
+
} catch(e) {}
|
|
1197
|
+
|
|
1198
|
+
send_response({
|
|
1199
|
+
"track": track_idx,
|
|
1200
|
+
"device": device_idx,
|
|
1201
|
+
"name": device_name,
|
|
1202
|
+
"presets": presets,
|
|
1203
|
+
"selected_preset": selected
|
|
1204
|
+
});
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1038
1207
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
1039
1208
|
|
|
1040
1209
|
function build_track_path(track_idx) {
|
package/mcp_server/__init__.py
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""LivePilot MCP Server — bridges MCP protocol to Ableton Live."""
|
|
2
|
-
__version__ = "1.
|
|
2
|
+
__version__ = "1.9.0"
|
package/mcp_server/m4l_bridge.py
CHANGED
|
@@ -97,6 +97,7 @@ class SpectralReceiver(asyncio.DatagramProtocol):
|
|
|
97
97
|
def __init__(self, cache: SpectralCache):
|
|
98
98
|
self.cache = cache
|
|
99
99
|
self._chunks: dict[str, dict] = {} # Reassembly buffer for chunked responses
|
|
100
|
+
self._chunk_id = 0
|
|
100
101
|
self._response_callback: Optional[asyncio.Future] = None
|
|
101
102
|
self._capture_future: Optional[asyncio.Future] = None
|
|
102
103
|
|
|
@@ -106,8 +107,9 @@ class SpectralReceiver(asyncio.DatagramProtocol):
|
|
|
106
107
|
def datagram_received(self, data: bytes, addr: tuple) -> None:
|
|
107
108
|
try:
|
|
108
109
|
self._parse_osc(data)
|
|
109
|
-
except Exception:
|
|
110
|
-
|
|
110
|
+
except Exception as exc:
|
|
111
|
+
import sys
|
|
112
|
+
print(f"LivePilot: malformed OSC packet from {addr}: {exc}", file=sys.stderr)
|
|
111
113
|
|
|
112
114
|
def _parse_osc(self, data: bytes) -> None:
|
|
113
115
|
"""Parse a minimal OSC message (address + typed args)."""
|
|
@@ -227,12 +229,15 @@ class SpectralReceiver(asyncio.DatagramProtocol):
|
|
|
227
229
|
result = json.loads(decoded)
|
|
228
230
|
if self._response_callback and not self._response_callback.done():
|
|
229
231
|
self._response_callback.set_result(result)
|
|
230
|
-
except Exception:
|
|
231
|
-
|
|
232
|
+
except Exception as exc:
|
|
233
|
+
import sys
|
|
234
|
+
print(f"LivePilot: failed to decode bridge response: {exc}", file=sys.stderr)
|
|
232
235
|
|
|
233
236
|
def _handle_chunk(self, index: int, total: int, encoded: str) -> None:
|
|
234
237
|
"""Reassemble chunked responses."""
|
|
235
|
-
|
|
238
|
+
if index == 0:
|
|
239
|
+
self._chunk_id += 1
|
|
240
|
+
key = str(self._chunk_id)
|
|
236
241
|
if key not in self._chunks:
|
|
237
242
|
self._chunks[key] = {"parts": {}, "total": total}
|
|
238
243
|
|
|
@@ -254,8 +259,9 @@ class SpectralReceiver(asyncio.DatagramProtocol):
|
|
|
254
259
|
result = json.loads(decoded)
|
|
255
260
|
if self._capture_future and not self._capture_future.done():
|
|
256
261
|
self._capture_future.set_result(result)
|
|
257
|
-
except Exception:
|
|
258
|
-
|
|
262
|
+
except Exception as exc:
|
|
263
|
+
import sys
|
|
264
|
+
print(f"LivePilot: failed to decode capture response: {exc}", file=sys.stderr)
|
|
259
265
|
|
|
260
266
|
def set_response_future(self, future: asyncio.Future) -> None:
|
|
261
267
|
"""Set a future to be resolved with the next response."""
|
|
@@ -278,29 +284,31 @@ class M4LBridge:
|
|
|
278
284
|
self.receiver = receiver
|
|
279
285
|
self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
280
286
|
self._m4l_addr = ("127.0.0.1", 9881)
|
|
287
|
+
self._cmd_lock = asyncio.Lock()
|
|
281
288
|
|
|
282
289
|
async def send_command(self, command: str, *args: Any, timeout: float = 5.0) -> dict:
|
|
283
290
|
"""Send an OSC command to the M4L device and wait for the response."""
|
|
284
291
|
if not self.cache.is_connected:
|
|
285
292
|
return {"error": "LivePilot Analyzer not connected. Drop it on the master track."}
|
|
286
293
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
self.receiver
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
294
|
+
async with self._cmd_lock:
|
|
295
|
+
# Create a future for the response
|
|
296
|
+
loop = asyncio.get_running_loop()
|
|
297
|
+
future = loop.create_future()
|
|
298
|
+
if self.receiver:
|
|
299
|
+
self.receiver.set_response_future(future)
|
|
300
|
+
|
|
301
|
+
# Build and send OSC message (no leading / — Max udpreceive
|
|
302
|
+
# passes messagename with / intact to JS, breaking dispatch)
|
|
303
|
+
osc_data = self._build_osc(command, args)
|
|
304
|
+
self._sock.sendto(osc_data, self._m4l_addr)
|
|
305
|
+
|
|
306
|
+
# Wait for response with timeout
|
|
307
|
+
try:
|
|
308
|
+
result = await asyncio.wait_for(future, timeout=timeout)
|
|
309
|
+
return result
|
|
310
|
+
except asyncio.TimeoutError:
|
|
311
|
+
return {"error": "M4L bridge timeout — device may be busy or removed"}
|
|
304
312
|
|
|
305
313
|
async def send_capture(self, command: str, *args: Any, timeout: float = 35.0) -> dict:
|
|
306
314
|
"""Send a capture command to the M4L device and wait for /capture_complete."""
|
|
@@ -114,7 +114,7 @@ class TechniqueStore:
|
|
|
114
114
|
) -> list[dict]:
|
|
115
115
|
"""Search techniques. Returns summaries (no payload)."""
|
|
116
116
|
with self._lock:
|
|
117
|
-
results =
|
|
117
|
+
results = copy.deepcopy(self._data["techniques"])
|
|
118
118
|
|
|
119
119
|
# filter by type
|
|
120
120
|
if type_filter:
|
|
@@ -156,7 +156,7 @@ class TechniqueStore:
|
|
|
156
156
|
)
|
|
157
157
|
|
|
158
158
|
with self._lock:
|
|
159
|
-
results =
|
|
159
|
+
results = copy.deepcopy(self._data["techniques"])
|
|
160
160
|
|
|
161
161
|
if type_filter:
|
|
162
162
|
results = [t for t in results if t["type"] == type_filter]
|
package/mcp_server/server.py
CHANGED
|
@@ -29,7 +29,16 @@ def _kill_port_holder(port: int) -> None:
|
|
|
29
29
|
for pid_str in out.splitlines():
|
|
30
30
|
pid = int(pid_str)
|
|
31
31
|
if pid != my_pid:
|
|
32
|
-
|
|
32
|
+
# Only kill if it looks like a Python/LivePilot process
|
|
33
|
+
try:
|
|
34
|
+
cmdline = subprocess.check_output(
|
|
35
|
+
["ps", "-p", str(pid), "-o", "command="],
|
|
36
|
+
text=True, timeout=2,
|
|
37
|
+
).strip()
|
|
38
|
+
if "mcp_server" in cmdline or "livepilot" in cmdline.lower():
|
|
39
|
+
os.kill(pid, signal.SIGTERM)
|
|
40
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
41
|
+
pass # Can't verify — don't kill
|
|
33
42
|
except (subprocess.CalledProcessError, FileNotFoundError, ValueError):
|
|
34
43
|
pass # lsof not found or no process — nothing to kill
|
|
35
44
|
|
|
@@ -139,6 +148,12 @@ def _get_all_tools():
|
|
|
139
148
|
# FastMCP 3.x: mcp._local_provider._components (dict of key -> Tool)
|
|
140
149
|
if hasattr(mcp, "_local_provider") and hasattr(mcp._local_provider, "_components"):
|
|
141
150
|
return list(mcp._local_provider._components.values())
|
|
151
|
+
import sys
|
|
152
|
+
print(
|
|
153
|
+
"LivePilot: WARNING — could not access FastMCP tool registry, "
|
|
154
|
+
"string-to-number schema coercion will not work",
|
|
155
|
+
file=sys.stderr,
|
|
156
|
+
)
|
|
142
157
|
return []
|
|
143
158
|
|
|
144
159
|
|
|
@@ -63,7 +63,8 @@ def _normalize_to_lufs(
|
|
|
63
63
|
gain_linear = 10 ** (gain_db / 20.0)
|
|
64
64
|
data, sr = _load_audio(file_path)
|
|
65
65
|
normalized = np.clip(data * gain_linear, -1.0, 1.0)
|
|
66
|
-
tmp_path = tempfile.
|
|
66
|
+
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".wav")
|
|
67
|
+
os.close(tmp_fd)
|
|
67
68
|
try:
|
|
68
69
|
sf.write(tmp_path, normalized, sr)
|
|
69
70
|
except Exception:
|
|
@@ -155,7 +156,7 @@ def compute_loudness(file_path: str, detail: str = "summary") -> dict[str, Any]:
|
|
|
155
156
|
|
|
156
157
|
# Streaming compliance
|
|
157
158
|
meets_streaming = {
|
|
158
|
-
name: integrated_lufs
|
|
159
|
+
name: abs(integrated_lufs - target) <= 1.0 # ±1 LU tolerance
|
|
159
160
|
for name, target in STREAMING_TARGETS.items()
|
|
160
161
|
}
|
|
161
162
|
|
|
@@ -284,9 +284,15 @@ async def load_sample_to_simpler(
|
|
|
284
284
|
"uri": uri,
|
|
285
285
|
})
|
|
286
286
|
|
|
287
|
-
# Step 2:
|
|
287
|
+
# Step 2: Find the newly created device (it's at the end of the chain)
|
|
288
|
+
track_info = ableton.send_command("get_track_info", {"track_index": track_index})
|
|
289
|
+
actual_device_index = len(track_info.get("devices", [])) - 1
|
|
290
|
+
if actual_device_index < 0:
|
|
291
|
+
actual_device_index = 0
|
|
292
|
+
|
|
293
|
+
# Step 3: Replace with the desired sample via M4L bridge
|
|
288
294
|
result = await bridge.send_command(
|
|
289
|
-
"replace_simpler_sample", track_index,
|
|
295
|
+
"replace_simpler_sample", track_index, actual_device_index, file_path
|
|
290
296
|
)
|
|
291
297
|
if "error" in result:
|
|
292
298
|
return result
|
|
@@ -11,6 +11,7 @@ from typing import Any, Optional
|
|
|
11
11
|
from fastmcp import Context
|
|
12
12
|
|
|
13
13
|
from ..server import mcp
|
|
14
|
+
from .notes import _validate_note
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
def _get_ableton(ctx: Context):
|
|
@@ -104,7 +105,13 @@ def create_arrangement_clip(
|
|
|
104
105
|
length: total clip length in beats on the timeline
|
|
105
106
|
loop_length: pattern length to loop within the clip (e.g. 8.0 for an
|
|
106
107
|
8-beat pattern inside a 128-beat section). Defaults to
|
|
107
|
-
the source clip's length.
|
|
108
|
+
the source clip's length. Must be > 0.
|
|
109
|
+
|
|
110
|
+
When loop_length < source clip length, overlapping copies are placed
|
|
111
|
+
every loop_length beats. Ableton's "later clip takes priority" rule
|
|
112
|
+
ensures correct playback. Each copy's internal loop region is set to
|
|
113
|
+
loop_length beats. For best results, use loop_length >= source length.
|
|
114
|
+
|
|
108
115
|
name: optional clip display name
|
|
109
116
|
color_index: optional 0-69 Ableton color
|
|
110
117
|
|
|
@@ -124,6 +131,8 @@ def create_arrangement_clip(
|
|
|
124
131
|
"length": length,
|
|
125
132
|
}
|
|
126
133
|
if loop_length is not None:
|
|
134
|
+
if loop_length <= 0:
|
|
135
|
+
raise ValueError("loop_length must be > 0")
|
|
127
136
|
params["loop_length"] = loop_length
|
|
128
137
|
if name:
|
|
129
138
|
params["name"] = name
|
|
@@ -154,6 +163,8 @@ def add_arrangement_notes(
|
|
|
154
163
|
_validate_clip_index(clip_index)
|
|
155
164
|
if isinstance(notes, str):
|
|
156
165
|
notes = json.loads(notes)
|
|
166
|
+
for note in notes:
|
|
167
|
+
_validate_note(note)
|
|
157
168
|
return _get_ableton(ctx).send_command("add_arrangement_notes", {
|
|
158
169
|
"track_index": track_index,
|
|
159
170
|
"clip_index": clip_index,
|
|
@@ -23,7 +23,9 @@ def _ensure_list(v: Any) -> list:
|
|
|
23
23
|
if isinstance(v, str):
|
|
24
24
|
import json
|
|
25
25
|
return json.loads(v)
|
|
26
|
-
|
|
26
|
+
if isinstance(v, list):
|
|
27
|
+
return v
|
|
28
|
+
return [v]
|
|
27
29
|
|
|
28
30
|
|
|
29
31
|
@mcp.tool()
|
|
@@ -482,7 +484,7 @@ def analyze_for_automation(
|
|
|
482
484
|
"track_index": track_index,
|
|
483
485
|
"track_name": track_info.get("name", ""),
|
|
484
486
|
"device_count": len(devices),
|
|
485
|
-
"current_level": meters.get("tracks"
|
|
487
|
+
"current_level": (meters.get("tracks") or [{}])[0].get("level", 0) if meters.get("tracks") else 0,
|
|
486
488
|
"spectrum": spectrum,
|
|
487
489
|
"suggestions": suggestions,
|
|
488
490
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
"""Device MCP tools — parameters, racks, browser loading.
|
|
1
|
+
"""Device MCP tools — parameters, racks, browser loading, plugin deep control.
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
15 tools matching the Remote Script devices domain + M4L bridge.
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
6
|
from __future__ import annotations
|
|
@@ -254,3 +254,96 @@ def get_device_presets(ctx: Context, device_name: str) -> dict:
|
|
|
254
254
|
return _get_ableton(ctx).send_command("get_device_presets", {
|
|
255
255
|
"device_name": device_name,
|
|
256
256
|
})
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# ── Plugin Deep Control (M4L Bridge) ────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _get_m4l(ctx: Context):
|
|
263
|
+
"""Get M4LBridge from lifespan context."""
|
|
264
|
+
bridge = ctx.lifespan_context.get("m4l")
|
|
265
|
+
if not bridge:
|
|
266
|
+
raise RuntimeError("M4L bridge not initialized")
|
|
267
|
+
return bridge
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _get_spectral(ctx: Context):
|
|
271
|
+
"""Get SpectralCache from lifespan context."""
|
|
272
|
+
cache = ctx.lifespan_context.get("spectral")
|
|
273
|
+
if not cache:
|
|
274
|
+
raise RuntimeError("Spectral cache not initialized")
|
|
275
|
+
return cache
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _require_analyzer(cache) -> None:
|
|
279
|
+
if not cache.is_connected:
|
|
280
|
+
raise ValueError(
|
|
281
|
+
"LivePilot Analyzer not detected. "
|
|
282
|
+
"Drag 'LivePilot Analyzer' onto the master track."
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@mcp.tool()
|
|
287
|
+
async def get_plugin_parameters(
|
|
288
|
+
ctx: Context,
|
|
289
|
+
track_index: int,
|
|
290
|
+
device_index: int,
|
|
291
|
+
) -> dict:
|
|
292
|
+
"""Get ALL parameters from a VST/AU plugin including unconfigured ones.
|
|
293
|
+
|
|
294
|
+
Returns every parameter the plugin exposes — not just the 128
|
|
295
|
+
that Ableton's Configure panel shows. Includes name, value, min,
|
|
296
|
+
max, default, and display string for each.
|
|
297
|
+
Only works on PluginDevice/AuPluginDevice types.
|
|
298
|
+
Requires LivePilot Analyzer on master track.
|
|
299
|
+
"""
|
|
300
|
+
_validate_track_index(track_index)
|
|
301
|
+
_validate_device_index(device_index)
|
|
302
|
+
cache = _get_spectral(ctx)
|
|
303
|
+
_require_analyzer(cache)
|
|
304
|
+
bridge = _get_m4l(ctx)
|
|
305
|
+
return await bridge.send_command("get_plugin_params", track_index, device_index)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@mcp.tool()
|
|
309
|
+
async def map_plugin_parameter(
|
|
310
|
+
ctx: Context,
|
|
311
|
+
track_index: int,
|
|
312
|
+
device_index: int,
|
|
313
|
+
parameter_index: int,
|
|
314
|
+
) -> dict:
|
|
315
|
+
"""Add a plugin parameter to Ableton's Configure list for automation.
|
|
316
|
+
|
|
317
|
+
After mapping, the parameter becomes visible in the device's macro
|
|
318
|
+
panel and can be automated with set_device_parameter or
|
|
319
|
+
set_clip_automation like any native parameter.
|
|
320
|
+
Requires LivePilot Analyzer on master track.
|
|
321
|
+
"""
|
|
322
|
+
_validate_track_index(track_index)
|
|
323
|
+
_validate_device_index(device_index)
|
|
324
|
+
if parameter_index < 0:
|
|
325
|
+
raise ValueError("parameter_index must be >= 0")
|
|
326
|
+
cache = _get_spectral(ctx)
|
|
327
|
+
_require_analyzer(cache)
|
|
328
|
+
bridge = _get_m4l(ctx)
|
|
329
|
+
return await bridge.send_command("map_plugin_param", track_index, device_index, parameter_index)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
@mcp.tool()
|
|
333
|
+
async def get_plugin_presets(
|
|
334
|
+
ctx: Context,
|
|
335
|
+
track_index: int,
|
|
336
|
+
device_index: int,
|
|
337
|
+
) -> dict:
|
|
338
|
+
"""List a VST/AU plugin's internal presets and banks.
|
|
339
|
+
|
|
340
|
+
Returns preset names and the currently selected preset index.
|
|
341
|
+
Only works on PluginDevice/AuPluginDevice types.
|
|
342
|
+
Requires LivePilot Analyzer on master track.
|
|
343
|
+
"""
|
|
344
|
+
_validate_track_index(track_index)
|
|
345
|
+
_validate_device_index(device_index)
|
|
346
|
+
cache = _get_spectral(ctx)
|
|
347
|
+
_require_analyzer(cache)
|
|
348
|
+
bridge = _get_m4l(ctx)
|
|
349
|
+
return await bridge.send_command("get_plugin_presets", track_index, device_index)
|
|
@@ -226,10 +226,10 @@ def suggest_chromatic_mediants(
|
|
|
226
226
|
|
|
227
227
|
mediants = harmony.get_chromatic_mediants(root_pc, quality)
|
|
228
228
|
|
|
229
|
-
chord_pcs =
|
|
229
|
+
chord_pcs = {p % 12 for p in harmony.chord_to_midi(root_pc, quality)}
|
|
230
230
|
formatted = {}
|
|
231
231
|
for key, (r, q) in mediants.items():
|
|
232
|
-
mediant_pcs =
|
|
232
|
+
mediant_pcs = {p % 12 for p in harmony.chord_to_midi(r, q)}
|
|
233
233
|
common = len(chord_pcs & mediant_pcs)
|
|
234
234
|
formatted[key] = {
|
|
235
235
|
"chord": harmony.chord_to_str(r, q),
|