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.
@@ -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.8.3"});
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) {
@@ -1,2 +1,2 @@
1
1
  """LivePilot MCP Server — bridges MCP protocol to Ableton Live."""
2
- __version__ = "1.8.3"
2
+ __version__ = "1.9.0"
@@ -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
- pass # Malformed packet, ignore
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
- pass
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
- key = str(total) # Simple key — assumes one response at a time
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
- pass
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
- # Create a future for the response
288
- loop = asyncio.get_running_loop()
289
- future = loop.create_future()
290
- if self.receiver:
291
- self.receiver.set_response_future(future)
292
-
293
- # Build and send OSC message (no leading / — Max udpreceive
294
- # passes messagename with / intact to JS, breaking dispatch)
295
- osc_data = self._build_osc(command, args)
296
- self._sock.sendto(osc_data, self._m4l_addr)
297
-
298
- # Wait for response with timeout
299
- try:
300
- result = await asyncio.wait_for(future, timeout=timeout)
301
- return result
302
- except asyncio.TimeoutError:
303
- return {"error": "M4L bridge timeout — device may be busy or removed"}
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 = list(self._data["techniques"])
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 = list(self._data["techniques"])
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]
@@ -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
- os.kill(pid, signal.SIGTERM)
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.mktemp(suffix=".wav")
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 >= (target - 1.0) # ±1 LU tolerance
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: Replace with the desired sample via M4L bridge
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, device_index, file_path
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
- return list(v)
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", [{}])[0].get("level", 0),
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
- 12 tools matching the Remote Script devices domain.
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 = set(harmony.chord_to_midi(root_pc, quality))
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 = set(harmony.chord_to_midi(r, q))
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),