livepilot 1.9.8 → 1.9.11

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 (40) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/AGENTS.md +1 -1
  3. package/CHANGELOG.md +45 -0
  4. package/CODE_OF_CONDUCT.md +27 -0
  5. package/CONTRIBUTING.md +131 -0
  6. package/README.md +119 -395
  7. package/SECURITY.md +48 -0
  8. package/bin/livepilot.js +41 -1
  9. package/livepilot/.Codex-plugin/plugin.json +8 -0
  10. package/livepilot/.claude-plugin/plugin.json +1 -1
  11. package/livepilot/commands/beat.md +18 -6
  12. package/livepilot/commands/sounddesign.md +6 -5
  13. package/livepilot/skills/livepilot-core/SKILL.md +30 -7
  14. package/livepilot/skills/livepilot-core/references/overview.md +1 -1
  15. package/livepilot/skills/livepilot-release/SKILL.md +7 -4
  16. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  17. package/m4l_device/LivePilot_Analyzer.maxpat +378 -4
  18. package/m4l_device/livepilot_bridge.js +413 -184
  19. package/mcp_server/__init__.py +1 -1
  20. package/mcp_server/connection.py +35 -0
  21. package/mcp_server/m4l_bridge.py +55 -5
  22. package/mcp_server/server.py +16 -29
  23. package/mcp_server/tools/_perception_engine.py +26 -3
  24. package/mcp_server/tools/_theory_engine.py +36 -5
  25. package/mcp_server/tools/analyzer.py +62 -17
  26. package/mcp_server/tools/automation.py +4 -1
  27. package/mcp_server/tools/devices.py +199 -10
  28. package/mcp_server/tools/generative.py +4 -1
  29. package/mcp_server/tools/harmony.py +16 -3
  30. package/mcp_server/tools/notes.py +4 -1
  31. package/mcp_server/tools/perception.py +27 -1
  32. package/mcp_server/tools/scenes.py +4 -1
  33. package/mcp_server/tools/theory.py +15 -7
  34. package/package.json +1 -1
  35. package/remote_script/LivePilot/__init__.py +1 -1
  36. package/remote_script/LivePilot/arrangement.py +13 -116
  37. package/remote_script/LivePilot/diagnostics.py +81 -5
  38. package/remote_script/LivePilot/router.py +22 -0
  39. package/remote_script/LivePilot/server.py +25 -13
  40. package/remote_script/LivePilot/tracks.py +17 -2
@@ -1,2 +1,2 @@
1
1
  """LivePilot MCP Server — bridges MCP protocol to Ableton Live."""
2
- __version__ = "1.9.8"
2
+ __version__ = "1.9.11"
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import json
6
6
  import os
7
7
  import socket
8
+ import subprocess
8
9
  import threading
9
10
  import time
10
11
  import uuid
@@ -44,6 +45,33 @@ def _friendly_error(code: str, message: str, command_type: str) -> str:
44
45
  return " ".join(parts)
45
46
 
46
47
 
48
+ def _identify_other_tcp_client(host: str, port: int) -> str | None:
49
+ """Return a short description of another established client on the Live port."""
50
+ try:
51
+ out = subprocess.check_output(
52
+ ["lsof", "-nP", f"-iTCP:{port}"],
53
+ text=True,
54
+ timeout=3,
55
+ )
56
+ except (subprocess.CalledProcessError, FileNotFoundError, ValueError, OSError):
57
+ return None
58
+
59
+ target = f"->{host}:{port}"
60
+ my_pid = os.getpid()
61
+ for line in out.splitlines()[1:]:
62
+ parts = line.split()
63
+ if len(parts) < 2 or target not in line or "(ESTABLISHED)" not in line:
64
+ continue
65
+ try:
66
+ pid = int(parts[1])
67
+ except ValueError:
68
+ continue
69
+ if pid == my_pid:
70
+ continue
71
+ return f"PID {pid} ({parts[0]})"
72
+ return None
73
+
74
+
47
75
  class AbletonConnection:
48
76
  """TCP client that sends JSON commands to the LivePilot Remote Script."""
49
77
 
@@ -197,6 +225,13 @@ class AbletonConnection:
197
225
  except socket.timeout as exc:
198
226
  self._recv_buf = buf
199
227
  self.disconnect()
228
+ other_client = _identify_other_tcp_client(self.host, self.port)
229
+ if other_client:
230
+ raise AbletonConnectionError(
231
+ "Timeout waiting for response from Ableton. "
232
+ f"Another LivePilot client appears to be connected on {self.host}:{self.port} "
233
+ f"({other_client}). Disconnect the other client and retry."
234
+ ) from exc
200
235
  raise AbletonConnectionError(
201
236
  f"Timeout waiting for response from Ableton ({RECV_TIMEOUT}s)"
202
237
  ) from exc
@@ -21,6 +21,50 @@ import time
21
21
  from typing import Any, Optional
22
22
 
23
23
 
24
+ def _encode_string_arg(value: str) -> str:
25
+ """Encode a Python string arg into an ASCII-safe OSC payload.
26
+
27
+ The Max JS side decodes values with the ``b64:`` prefix back to UTF-8.
28
+ Keeping the wire payload ASCII-only avoids OSC/client issues with
29
+ non-ASCII file paths and device names.
30
+ """
31
+ encoded = base64.urlsafe_b64encode(value.encode("utf-8")).decode("ascii")
32
+ return "b64:" + encoded.rstrip("=")
33
+
34
+
35
+ def _normalize_macos_path(path: str) -> str:
36
+ """Convert Max-style HFS-ish paths into POSIX paths when possible."""
37
+ if len(path) >= 3 and path[1] == ":" and path[2] in ("/", "\\"):
38
+ return path
39
+
40
+ colon = path.find(":")
41
+ slash = path.find("/")
42
+ if colon <= 0 or (slash != -1 and colon > slash):
43
+ return path
44
+
45
+ rest = path[colon + 1:]
46
+ if ":" in rest:
47
+ rest = rest.replace(":", "/")
48
+ if not rest.startswith("/"):
49
+ rest = "/" + rest.lstrip("/\\")
50
+ return rest
51
+
52
+
53
+ def _normalize_bridge_payload(value: Any) -> Any:
54
+ """Normalize filesystem paths inside bridge payloads."""
55
+ if isinstance(value, dict):
56
+ normalized = {}
57
+ for key, item in value.items():
58
+ if key == "file_path" and isinstance(item, str):
59
+ normalized[key] = _normalize_macos_path(item)
60
+ else:
61
+ normalized[key] = _normalize_bridge_payload(item)
62
+ return normalized
63
+ if isinstance(value, list):
64
+ return [_normalize_bridge_payload(item) for item in value]
65
+ return value
66
+
67
+
24
68
  class SpectralCache:
25
69
  """Thread-safe cache for incoming spectral data from M4L.
26
70
 
@@ -226,7 +270,7 @@ class SpectralReceiver(asyncio.DatagramProtocol):
226
270
  # URL-safe base64 decode (- and _ instead of + and /)
227
271
  padded = encoded + "=" * (-len(encoded) % 4)
228
272
  decoded = base64.urlsafe_b64decode(padded).decode('utf-8')
229
- result = json.loads(decoded)
273
+ result = _normalize_bridge_payload(json.loads(decoded))
230
274
  if self._response_callback and not self._response_callback.done():
231
275
  self._response_callback.set_result(result)
232
276
  except Exception as exc:
@@ -256,7 +300,7 @@ class SpectralReceiver(asyncio.DatagramProtocol):
256
300
  try:
257
301
  padded = encoded + "=" * (-len(encoded) % 4)
258
302
  decoded = base64.urlsafe_b64decode(padded).decode('utf-8')
259
- result = json.loads(decoded)
303
+ result = _normalize_bridge_payload(json.loads(decoded))
260
304
  if self._capture_future and not self._capture_future.done():
261
305
  self._capture_future.set_result(result)
262
306
  except Exception as exc:
@@ -343,8 +387,13 @@ class M4LBridge:
343
387
  self.receiver._capture_future = None
344
388
 
345
389
  def _build_osc(self, address: str, args: tuple) -> bytes:
346
- """Build a minimal OSC message."""
347
- # Address string (null-terminated, padded to 4 bytes)
390
+ """Build a minimal OSC message.
391
+
392
+ OSC addresses are ASCII-only command names.
393
+ String arguments are encoded into an ASCII-safe ``b64:...`` payload
394
+ and decoded back to UTF-8 in the Max bridge.
395
+ """
396
+ # Address string — always ASCII (command names like "get_params")
348
397
  addr_bytes = address.encode('ascii') + b'\x00'
349
398
  while len(addr_bytes) % 4 != 0:
350
399
  addr_bytes += b'\x00'
@@ -361,7 +410,8 @@ class M4LBridge:
361
410
  arg_data += struct.pack('>f', arg)
362
411
  elif isinstance(arg, str):
363
412
  type_tags += "s"
364
- s_bytes = arg.encode('ascii') + b'\x00'
413
+ encoded = _encode_string_arg(arg)
414
+ s_bytes = encoded.encode('ascii') + b'\x00'
365
415
  while len(s_bytes) % 4 != 0:
366
416
  s_bytes += b'\x00'
367
417
  arg_data += s_bytes
@@ -3,7 +3,6 @@
3
3
  from contextlib import asynccontextmanager
4
4
  import asyncio
5
5
  import os
6
- import signal
7
6
  import subprocess
8
7
 
9
8
  from fastmcp import FastMCP, Context # noqa: F401
@@ -12,12 +11,11 @@ from .connection import AbletonConnection
12
11
  from .m4l_bridge import SpectralCache, SpectralReceiver, M4LBridge
13
12
 
14
13
 
15
- def _kill_port_holder(port: int) -> None:
16
- """Kill whichever process holds the given UDP port.
14
+ def _identify_port_holder(port: int) -> str | None:
15
+ """Identify which process holds the given UDP port (for logging only).
17
16
 
18
- Used to reclaim port 9880 when a stale duplicate server instance
19
- is hogging it (common when both project .mcp.json and plugin
20
- .mcp.json launch the server).
17
+ Returns a string like "PID 12345 (python3 mcp_server)" or None if
18
+ identification fails. Never kills or modifies the holder.
21
19
  """
22
20
  try:
23
21
  out = subprocess.check_output(
@@ -29,18 +27,17 @@ def _kill_port_holder(port: int) -> None:
29
27
  for pid_str in out.splitlines():
30
28
  pid = int(pid_str)
31
29
  if pid != my_pid:
32
- # Only kill if it looks like a Python/LivePilot process
33
30
  try:
34
31
  cmdline = subprocess.check_output(
35
32
  ["ps", "-p", str(pid), "-o", "command="],
36
33
  text=True, timeout=2,
37
34
  ).strip()
38
- if "mcp_server" in cmdline or "livepilot" in cmdline.lower():
39
- os.kill(pid, signal.SIGTERM)
35
+ return f"{pid} ({cmdline[:60]})"
40
36
  except (subprocess.CalledProcessError, FileNotFoundError):
41
- pass # Can't verify — don't kill
37
+ return str(pid)
38
+ return None
42
39
  except (subprocess.CalledProcessError, FileNotFoundError, ValueError):
43
- pass # lsof not found or no process — nothing to kill
40
+ return None
44
41
 
45
42
 
46
43
  @asynccontextmanager
@@ -60,28 +57,18 @@ async def lifespan(server):
60
57
  local_addr=('127.0.0.1', 9880),
61
58
  )
62
59
  except OSError:
63
- # Port 9880 already bound — likely a duplicate server instance
64
- # (project .mcp.json + plugin .mcp.json both launching).
65
- # Kill the stale holder so this instance gets the port.
60
+ # Port 9880 already bound — another LivePilot instance is running.
61
+ # Do NOT kill the holder; degrade gracefully instead.
66
62
  import sys
63
+ holder_info = _identify_port_holder(9880)
67
64
  print(
68
- "LivePilot: UDP port 9880 in use — reclaiming from stale instance",
65
+ "LivePilot: UDP port 9880 already in use%s — "
66
+ "analyzer/bridge tools will be unavailable. "
67
+ "Stop the other LivePilot instance to enable them."
68
+ % (f" (PID {holder_info})" if holder_info else ""),
69
69
  file=sys.stderr,
70
70
  )
71
- _kill_port_holder(9880)
72
- await asyncio.sleep(0.3)
73
- try:
74
- transport, _ = await loop.create_datagram_endpoint(
75
- lambda: receiver,
76
- local_addr=('127.0.0.1', 9880),
77
- )
78
- except OSError:
79
- print(
80
- "LivePilot: WARNING — could not bind UDP 9880, "
81
- "analyzer tools will be unavailable",
82
- file=sys.stderr,
83
- )
84
- transport = None
71
+ transport = None
85
72
 
86
73
  try:
87
74
  yield {
@@ -77,6 +77,25 @@ def _normalize_to_lufs(
77
77
  return tmp_path
78
78
 
79
79
 
80
+ # ---------------------------------------------------------------------------
81
+ # True-peak helper
82
+ # ---------------------------------------------------------------------------
83
+
84
+ def _true_peak_dbtp(data: np.ndarray, sr: int) -> float:
85
+ """Estimate EBU R128 true peak via 4x oversampling.
86
+
87
+ Uses scipy's resample_poly for phase-accurate upsampling,
88
+ then measures the absolute peak of the oversampled signal.
89
+ Returns the result in dBTP (decibels relative to true peak).
90
+ """
91
+ from scipy.signal import resample_poly
92
+
93
+ # 4x oversample each channel independently
94
+ oversampled = resample_poly(data, up=4, down=1, axis=0)
95
+ peak_linear = float(np.max(np.abs(oversampled)))
96
+ return float(20.0 * np.log10(max(peak_linear, 1e-10)))
97
+
98
+
80
99
  # ---------------------------------------------------------------------------
81
100
  # compute_loudness
82
101
  # ---------------------------------------------------------------------------
@@ -89,8 +108,8 @@ def compute_loudness(file_path: str, detail: str = "summary") -> dict[str, Any]:
89
108
  detail: "summary" (default) or "full" (includes short_term_lufs array).
90
109
 
91
110
  Returns:
92
- dict with integrated_lufs, sample_peak_dbfs, rms_dbfs, crest_factor_db,
93
- lra_lu, meets_streaming, and optionally short_term_lufs.
111
+ dict with integrated_lufs, true_peak_dbtp, sample_peak_dbfs, rms_dbfs,
112
+ crest_factor_db, lra_lu, meets_streaming, and optionally short_term_lufs.
94
113
  """
95
114
  import pyloudnorm as pyln
96
115
 
@@ -110,12 +129,15 @@ def compute_loudness(file_path: str, detail: str = "summary") -> dict[str, Any]:
110
129
  peak_linear = float(np.max(np.abs(data)))
111
130
  sample_peak_dbfs = float(20.0 * np.log10(max(peak_linear, 1e-10)))
112
131
 
132
+ # True peak via 4x oversampling (EBU R128 compliant)
133
+ true_peak_dbtp = _true_peak_dbtp(data, sr)
134
+
113
135
  # RMS dBFS
114
136
  rms_linear = float(np.sqrt(np.mean(data ** 2)))
115
137
  rms_dbfs = float(20.0 * np.log10(max(rms_linear, 1e-10)))
116
138
 
117
139
  # Crest factor
118
- crest_factor_db = sample_peak_dbfs - rms_dbfs
140
+ crest_factor_db = true_peak_dbtp - rms_dbfs
119
141
 
120
142
  # Short-term LUFS (3s window, 1s hop) — also used for LRA
121
143
  window_samples = int(sr * 3.0)
@@ -162,6 +184,7 @@ def compute_loudness(file_path: str, detail: str = "summary") -> dict[str, Any]:
162
184
 
163
185
  result: dict[str, Any] = {
164
186
  "integrated_lufs": round(integrated_lufs, 2),
187
+ "true_peak_dbtp": round(true_peak_dbtp, 2),
165
188
  "sample_peak_dbfs": round(sample_peak_dbfs, 2),
166
189
  "rms_dbfs": round(rms_dbfs, 2),
167
190
  "crest_factor_db": round(crest_factor_db, 2),
@@ -35,19 +35,20 @@ PHRYGIAN_PROFILE = MAJOR_PROFILE[4:] + MAJOR_PROFILE[:4]
35
35
  LYDIAN_PROFILE = MAJOR_PROFILE[5:] + MAJOR_PROFILE[:5]
36
36
  MIXOLYDIAN_PROFILE = MAJOR_PROFILE[7:] + MAJOR_PROFILE[:7]
37
37
  LOCRIAN_PROFILE = MAJOR_PROFILE[11:] + MAJOR_PROFILE[:11]
38
+ PHRYGIAN_DOMINANT_PROFILE = [6.35, 4.75, 1.75, 1.95, 4.9, 3.9, 1.8, 5.2, 3.6, 1.7, 3.4, 1.6]
38
39
 
39
40
  MODE_PROFILES = {
40
41
  'major': MAJOR_PROFILE, 'minor': MINOR_PROFILE,
41
42
  'dorian': DORIAN_PROFILE, 'phrygian': PHRYGIAN_PROFILE,
42
43
  'lydian': LYDIAN_PROFILE, 'mixolydian': MIXOLYDIAN_PROFILE,
43
- 'locrian': LOCRIAN_PROFILE,
44
+ 'locrian': LOCRIAN_PROFILE, 'phrygian_dominant': PHRYGIAN_DOMINANT_PROFILE,
44
45
  }
45
46
 
46
47
  SCALES = {
47
48
  'major': [0, 2, 4, 5, 7, 9, 11], 'minor': [0, 2, 3, 5, 7, 8, 10],
48
49
  'dorian': [0, 2, 3, 5, 7, 9, 10], 'phrygian': [0, 1, 3, 5, 7, 8, 10],
49
50
  'lydian': [0, 2, 4, 6, 7, 9, 11], 'mixolydian': [0, 2, 4, 5, 7, 9, 10],
50
- 'locrian': [0, 1, 3, 5, 6, 8, 10],
51
+ 'locrian': [0, 1, 3, 5, 6, 8, 10], 'phrygian_dominant': [0, 1, 4, 5, 7, 8, 10],
51
52
  }
52
53
 
53
54
  TRIAD_QUALITIES = {
@@ -58,6 +59,25 @@ TRIAD_QUALITIES = {
58
59
  'lydian': ['major', 'major', 'minor', 'diminished', 'major', 'minor', 'minor'],
59
60
  'mixolydian': ['major', 'minor', 'diminished', 'major', 'minor', 'minor', 'major'],
60
61
  'locrian': ['diminished', 'major', 'minor', 'minor', 'major', 'major', 'minor'],
62
+ 'phrygian_dominant': ['major', 'major', 'diminished', 'minor', 'diminished', 'augmented', 'minor'],
63
+ }
64
+
65
+ MODE_ALIASES = {
66
+ 'major': 'major',
67
+ 'ionian': 'major',
68
+ 'maj': 'major',
69
+ 'minor': 'minor',
70
+ 'aeolian': 'minor',
71
+ 'min': 'minor',
72
+ 'dorian': 'dorian',
73
+ 'phrygian': 'phrygian',
74
+ 'lydian': 'lydian',
75
+ 'mixolydian': 'mixolydian',
76
+ 'locrian': 'locrian',
77
+ 'phrygian dominant': 'phrygian_dominant',
78
+ 'phrygian_dominant': 'phrygian_dominant',
79
+ 'phrygian-dominant': 'phrygian_dominant',
80
+ 'hijaz': 'phrygian_dominant',
61
81
  }
62
82
 
63
83
  CHORD_PATTERNS = {
@@ -81,6 +101,15 @@ def pitch_name(midi: int) -> str:
81
101
  return NOTE_NAMES[midi % 12] + str(midi // 12 - 1)
82
102
 
83
103
 
104
+ def _normalize_mode_name(mode: str) -> str:
105
+ """Normalize user-facing mode names into canonical scale ids."""
106
+ normalized = " ".join(mode.strip().lower().replace("_", " ").replace("-", " ").split())
107
+ canonical = MODE_ALIASES.get(normalized, normalized.replace(" ", "_"))
108
+ if canonical not in SCALES:
109
+ raise ValueError(f"Unknown mode: {mode}")
110
+ return canonical
111
+
112
+
84
113
  def parse_key(key_str: str) -> dict:
85
114
  """Parse key string -> {tonic: 0-11, tonic_name: str, mode: str}.
86
115
 
@@ -100,7 +129,8 @@ def parse_key(key_str: str) -> dict:
100
129
 
101
130
  parts = s.split()
102
131
  raw_tonic = parts[0]
103
- mode = parts[1].lower() if len(parts) > 1 else 'major'
132
+ raw_mode = " ".join(parts[1:]) if len(parts) > 1 else 'major'
133
+ mode = _normalize_mode_name(raw_mode)
104
134
 
105
135
  # Normalize tonic: capitalize first letter
106
136
  tonic_name = raw_tonic[0].upper() + raw_tonic[1:]
@@ -117,17 +147,18 @@ def parse_key(key_str: str) -> dict:
117
147
 
118
148
  def get_scale_pitches(tonic: int, mode: str) -> list[int]:
119
149
  """Return pitch classes (0-11) for the scale."""
120
- intervals = SCALES.get(mode, SCALES['major'])
150
+ intervals = SCALES[_normalize_mode_name(mode)]
121
151
  return [(tonic + iv) % 12 for iv in intervals]
122
152
 
123
153
 
124
154
  def build_chord(degree: int, tonic: int, mode: str) -> dict:
125
155
  """Build triad from scale degree (0-indexed)."""
156
+ mode = _normalize_mode_name(mode)
126
157
  scale = get_scale_pitches(tonic, mode)
127
158
  root = scale[degree % 7]
128
159
  third = scale[(degree + 2) % 7]
129
160
  fifth = scale[(degree + 4) % 7]
130
- quality = TRIAD_QUALITIES.get(mode, TRIAD_QUALITIES['major'])[degree % 7]
161
+ quality = TRIAD_QUALITIES[mode][degree % 7]
131
162
  return {
132
163
  "root_pc": root,
133
164
  "pitch_classes": [root, third, fifth],
@@ -10,7 +10,7 @@ import os
10
10
 
11
11
  from fastmcp import Context
12
12
 
13
- from ..server import mcp
13
+ from ..server import mcp, _identify_port_holder
14
14
 
15
15
  CAPTURE_DIR = os.path.expanduser("~/Documents/LivePilot/captures")
16
16
 
@@ -19,7 +19,10 @@ def _get_spectral(ctx: Context):
19
19
  """Get SpectralCache from lifespan context."""
20
20
  cache = ctx.lifespan_context.get("spectral")
21
21
  if not cache:
22
- raise RuntimeError("Spectral cache not initialized")
22
+ raise ValueError("Spectral cache not initialized — restart the MCP server")
23
+ # Keep the active request context attached so analyzer error paths can
24
+ # distinguish "device missing" from "bridge disconnected".
25
+ setattr(cache, "_livepilot_ctx", ctx)
23
26
  return cache
24
27
 
25
28
 
@@ -27,13 +30,46 @@ def _get_m4l(ctx: Context):
27
30
  """Get M4LBridge from lifespan context."""
28
31
  bridge = ctx.lifespan_context.get("m4l")
29
32
  if not bridge:
30
- raise RuntimeError("M4L bridge not initialized")
33
+ raise ValueError("M4L bridge not initialized — restart the MCP server")
31
34
  return bridge
32
35
 
33
36
 
34
37
  def _require_analyzer(cache) -> None:
35
38
  """Raise a helpful error if the analyzer is not connected."""
36
39
  if not cache.is_connected:
40
+ ctx = getattr(cache, "_livepilot_ctx", None)
41
+ try:
42
+ track = (
43
+ ctx.lifespan_context["ableton"].send_command("get_master_track")
44
+ if ctx else {}
45
+ )
46
+ except Exception:
47
+ track = {}
48
+
49
+ devices = track.get("devices", []) if isinstance(track, dict) else []
50
+ analyzer_loaded = False
51
+ for device in devices:
52
+ normalized = " ".join(
53
+ str(device.get("name") or "").replace("_", " ").replace("-", " ").lower().split()
54
+ )
55
+ if normalized == "livepilot analyzer":
56
+ analyzer_loaded = True
57
+ break
58
+
59
+ if analyzer_loaded:
60
+ holder = _identify_port_holder(9880)
61
+ detail = (
62
+ "LivePilot Analyzer is loaded on the master track, but its UDP bridge is not connected. "
63
+ )
64
+ if holder:
65
+ detail += (
66
+ "UDP port 9880 is currently held by another LivePilot instance "
67
+ f"({holder}). Close the other client/server, then retry."
68
+ )
69
+ else:
70
+ detail += "Reload the analyzer device or restart the MCP server."
71
+ raise ValueError(detail)
72
+
37
73
  raise ValueError(
38
74
  "LivePilot Analyzer not detected. "
39
75
  "Drag 'LivePilot Analyzer' onto the master track from "
@@ -140,7 +176,7 @@ async def get_hidden_parameters(
140
176
  cache = _get_spectral(ctx)
141
177
  _require_analyzer(cache)
142
178
  bridge = _get_m4l(ctx)
143
- return await bridge.send_command("get_hidden_params", track_index, device_index)
179
+ return await bridge.send_command("get_hidden_params", track_index, device_index, timeout=15.0)
144
180
 
145
181
 
146
182
  @mcp.tool()
@@ -161,7 +197,7 @@ async def get_automation_state(
161
197
  cache = _get_spectral(ctx)
162
198
  _require_analyzer(cache)
163
199
  bridge = _get_m4l(ctx)
164
- return await bridge.send_command("get_auto_state", track_index, device_index)
200
+ return await bridge.send_command("get_auto_state", track_index, device_index, timeout=10.0)
165
201
 
166
202
 
167
203
  @mcp.tool()
@@ -267,25 +303,34 @@ async def load_sample_to_simpler(
267
303
 
268
304
  # Step 1: Load a sample from the browser to create Simpler with content
269
305
  ableton = ctx.lifespan_context["ableton"]
270
- search = ableton.send_command("search_browser", {
271
- "path": "samples",
272
- "name_filter": "kick",
273
- "loadable_only": True,
274
- "max_results": 1,
275
- })
306
+ try:
307
+ search = ableton.send_command("search_browser", {
308
+ "path": "samples",
309
+ "name_filter": "kick",
310
+ "loadable_only": True,
311
+ "max_results": 1,
312
+ })
313
+ except Exception as exc:
314
+ return {"error": f"Browser search failed: {exc}"}
276
315
  results = search.get("results", [])
277
316
  if not results:
278
317
  return {"error": "No samples found in browser to bootstrap Simpler"}
279
318
 
280
319
  # Load the dummy sample — Ableton auto-creates Simpler
281
320
  uri = results[0]["uri"]
282
- ableton.send_command("load_browser_item", {
283
- "track_index": track_index,
284
- "uri": uri,
285
- })
321
+ try:
322
+ ableton.send_command("load_browser_item", {
323
+ "track_index": track_index,
324
+ "uri": uri,
325
+ })
326
+ except Exception as exc:
327
+ return {"error": f"Failed to load bootstrap sample: {exc}"}
286
328
 
287
329
  # 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})
330
+ try:
331
+ track_info = ableton.send_command("get_track_info", {"track_index": track_index})
332
+ except Exception as exc:
333
+ return {"error": f"Failed to read track after loading sample: {exc}"}
289
334
  actual_device_index = len(track_info.get("devices", [])) - 1
290
335
  if actual_device_index < 0:
291
336
  actual_device_index = 0
@@ -515,7 +560,7 @@ async def get_display_values(
515
560
  cache = _get_spectral(ctx)
516
561
  _require_analyzer(cache)
517
562
  bridge = _get_m4l(ctx)
518
- return await bridge.send_command("get_display_values", track_index, device_index)
563
+ return await bridge.send_command("get_display_values", track_index, device_index, timeout=15.0)
519
564
 
520
565
 
521
566
  # ── Phase 3: Audio Capture ─────────────────────────────────────────────
@@ -22,7 +22,10 @@ def _get_ableton(ctx: Context):
22
22
  def _ensure_list(v: Any) -> list:
23
23
  if isinstance(v, str):
24
24
  import json
25
- return json.loads(v)
25
+ try:
26
+ return json.loads(v)
27
+ except json.JSONDecodeError as exc:
28
+ raise ValueError(f"Invalid JSON in parameter: {exc}") from exc
26
29
  if isinstance(v, list):
27
30
  return v
28
31
  return [v]