livepilot 1.9.9 → 1.9.12

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 (51) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/AGENTS.md +1 -1
  3. package/CHANGELOG.md +92 -0
  4. package/CODE_OF_CONDUCT.md +27 -0
  5. package/CONTRIBUTING.md +131 -0
  6. package/README.md +112 -387
  7. package/SECURITY.md +48 -0
  8. package/bin/livepilot.js +42 -2
  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 +36 -13
  14. package/livepilot/skills/livepilot-core/references/overview.md +1 -1
  15. package/livepilot/skills/livepilot-release/SKILL.md +11 -8
  16. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  17. package/m4l_device/LivePilot_Analyzer.maxpat +378 -4
  18. package/m4l_device/capture_2026_04_07_192216.wav +0 -0
  19. package/m4l_device/livepilot_bridge.js +487 -184
  20. package/mcp_server/__init__.py +1 -1
  21. package/mcp_server/connection.py +40 -1
  22. package/mcp_server/curves.py +5 -1
  23. package/mcp_server/m4l_bridge.py +93 -26
  24. package/mcp_server/server.py +26 -31
  25. package/mcp_server/tools/_perception_engine.py +26 -3
  26. package/mcp_server/tools/_theory_engine.py +36 -5
  27. package/mcp_server/tools/analyzer.py +74 -18
  28. package/mcp_server/tools/arrangement.py +20 -5
  29. package/mcp_server/tools/automation.py +56 -1
  30. package/mcp_server/tools/devices.py +201 -13
  31. package/mcp_server/tools/generative.py +8 -1
  32. package/mcp_server/tools/harmony.py +16 -3
  33. package/mcp_server/tools/midi_io.py +22 -4
  34. package/mcp_server/tools/notes.py +4 -1
  35. package/mcp_server/tools/perception.py +27 -1
  36. package/mcp_server/tools/scenes.py +4 -1
  37. package/mcp_server/tools/theory.py +23 -8
  38. package/mcp_server/tools/transport.py +16 -6
  39. package/package.json +1 -1
  40. package/remote_script/LivePilot/__init__.py +1 -1
  41. package/remote_script/LivePilot/arrangement.py +25 -134
  42. package/remote_script/LivePilot/browser.py +19 -8
  43. package/remote_script/LivePilot/clip_automation.py +5 -4
  44. package/remote_script/LivePilot/clips.py +14 -6
  45. package/remote_script/LivePilot/devices.py +6 -3
  46. package/remote_script/LivePilot/diagnostics.py +81 -5
  47. package/remote_script/LivePilot/router.py +22 -0
  48. package/remote_script/LivePilot/server.py +41 -17
  49. package/remote_script/LivePilot/tracks.py +7 -2
  50. package/remote_script/LivePilot/transport.py +3 -3
  51. package/requirements.txt +3 -1
@@ -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 ─────────────────────────────────────────────
@@ -545,7 +590,18 @@ async def capture_audio(
545
590
  if source not in ("master",):
546
591
  raise ValueError(f"Unsupported source '{source}'. Valid: 'master'")
547
592
 
593
+ # Sanitize filename — strip directory components to prevent path traversal
594
+ if filename:
595
+ safe_name = os.path.basename(filename)
596
+ if not safe_name or safe_name != filename:
597
+ raise ValueError(
598
+ f"Filename must not contain path separators or '..' segments: {filename!r}"
599
+ )
600
+ filename = safe_name
601
+
548
602
  bridge = _get_m4l(ctx)
603
+ # Ensure captures directory exists before sending to bridge
604
+ os.makedirs(CAPTURE_DIR, exist_ok=True)
549
605
  duration_ms = duration_seconds * 1000
550
606
  result = await bridge.send_capture(
551
607
  "capture_audio",
@@ -568,7 +624,7 @@ async def capture_stop(ctx: Context) -> dict:
568
624
  _require_analyzer(cache)
569
625
  bridge = _get_m4l(ctx)
570
626
  # Cancel the capture future so send_capture doesn't hang forever
571
- bridge.cancel_capture_future()
627
+ await bridge.cancel_capture_future()
572
628
  return await bridge.send_command("capture_stop")
573
629
 
574
630
 
@@ -162,7 +162,10 @@ def add_arrangement_notes(
162
162
  _validate_track_index(track_index)
163
163
  _validate_clip_index(clip_index)
164
164
  if isinstance(notes, str):
165
- notes = json.loads(notes)
165
+ try:
166
+ notes = json.loads(notes)
167
+ except json.JSONDecodeError as exc:
168
+ raise ValueError(f"Invalid JSON in notes parameter: {exc}") from exc
166
169
  for note in notes:
167
170
  _validate_note(note)
168
171
  return _get_ableton(ctx).send_command("add_arrangement_notes", {
@@ -206,7 +209,10 @@ def set_arrangement_automation(
206
209
  if parameter_type == "send" and send_index is None:
207
210
  raise ValueError("send_index required for parameter_type='send'")
208
211
  if isinstance(points, str):
209
- points = json.loads(points)
212
+ try:
213
+ points = json.loads(points)
214
+ except json.JSONDecodeError as exc:
215
+ raise ValueError(f"Invalid JSON in points parameter: {exc}") from exc
210
216
  if not points:
211
217
  raise ValueError("points list cannot be empty")
212
218
  params: dict = {
@@ -352,7 +358,10 @@ def remove_arrangement_notes_by_id(
352
358
  _validate_track_index(track_index)
353
359
  _validate_clip_index(clip_index)
354
360
  if isinstance(note_ids, str):
355
- note_ids = json.loads(note_ids)
361
+ try:
362
+ note_ids = json.loads(note_ids)
363
+ except json.JSONDecodeError as exc:
364
+ raise ValueError(f"Invalid JSON in note_ids parameter: {exc}") from exc
356
365
  if not note_ids:
357
366
  raise ValueError("note_ids list cannot be empty")
358
367
  return _get_ableton(ctx).send_command("remove_arrangement_notes_by_id", {
@@ -374,7 +383,10 @@ def modify_arrangement_notes(
374
383
  _validate_track_index(track_index)
375
384
  _validate_clip_index(clip_index)
376
385
  if isinstance(modifications, str):
377
- modifications = json.loads(modifications)
386
+ try:
387
+ modifications = json.loads(modifications)
388
+ except json.JSONDecodeError as exc:
389
+ raise ValueError(f"Invalid JSON in modifications parameter: {exc}") from exc
378
390
  if not modifications:
379
391
  raise ValueError("modifications list cannot be empty")
380
392
  for mod in modifications:
@@ -407,7 +419,10 @@ def duplicate_arrangement_notes(
407
419
  _validate_track_index(track_index)
408
420
  _validate_clip_index(clip_index)
409
421
  if isinstance(note_ids, str):
410
- note_ids = json.loads(note_ids)
422
+ try:
423
+ note_ids = json.loads(note_ids)
424
+ except json.JSONDecodeError as exc:
425
+ raise ValueError(f"Invalid JSON in note_ids parameter: {exc}") from exc
411
426
  if not note_ids:
412
427
  raise ValueError("note_ids list cannot be empty")
413
428
  return _get_ableton(ctx).send_command("duplicate_arrangement_notes", {
@@ -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]
@@ -40,6 +43,10 @@ def get_clip_automation(
40
43
  parameter name, and type (mixer/send/device). Use this to see
41
44
  what's already automated before writing new curves.
42
45
  """
46
+ if track_index < 0:
47
+ raise ValueError("track_index must be >= 0")
48
+ if clip_index < 0:
49
+ raise ValueError("clip_index must be >= 0")
43
50
  return _get_ableton(ctx).send_command("get_clip_automation", {
44
51
  "track_index": track_index,
45
52
  "clip_index": clip_index,
@@ -69,6 +76,17 @@ def set_clip_automation(
69
76
  Tip: Use apply_automation_shape to generate points from curves/recipes
70
77
  instead of calculating points manually.
71
78
  """
79
+ if track_index < 0:
80
+ raise ValueError("track_index must be >= 0")
81
+ if clip_index < 0:
82
+ raise ValueError("clip_index must be >= 0")
83
+ if parameter_type not in ("device", "volume", "panning", "send"):
84
+ raise ValueError("parameter_type must be 'device', 'volume', 'panning', or 'send'")
85
+ if parameter_type == "device":
86
+ if device_index is None or parameter_index is None:
87
+ raise ValueError("device_index and parameter_index required for parameter_type='device'")
88
+ if parameter_type == "send" and send_index is None:
89
+ raise ValueError("send_index required for parameter_type='send'")
72
90
  params: dict = {
73
91
  "track_index": track_index,
74
92
  "clip_index": clip_index,
@@ -99,11 +117,22 @@ def clear_clip_automation(
99
117
  If parameter_type is omitted, clears ALL envelopes.
100
118
  If provided, clears only that parameter's envelope.
101
119
  """
120
+ if track_index < 0:
121
+ raise ValueError("track_index must be >= 0")
122
+ if clip_index < 0:
123
+ raise ValueError("clip_index must be >= 0")
102
124
  params: dict = {
103
125
  "track_index": track_index,
104
126
  "clip_index": clip_index,
105
127
  }
106
128
  if parameter_type is not None:
129
+ if parameter_type not in ("device", "volume", "panning", "send"):
130
+ raise ValueError("parameter_type must be 'device', 'volume', 'panning', or 'send'")
131
+ if parameter_type == "device":
132
+ if device_index is None or parameter_index is None:
133
+ raise ValueError("device_index and parameter_index required for parameter_type='device'")
134
+ if parameter_type == "send" and send_index is None:
135
+ raise ValueError("send_index required for parameter_type='send'")
107
136
  params["parameter_type"] = parameter_type
108
137
  if device_index is not None:
109
138
  params["device_index"] = device_index
@@ -187,6 +216,19 @@ def apply_automation_shape(
187
216
  - Throws: use spike with short duration (1-2 beats)
188
217
  - Tremolo/pan: use sine with frequency in musical divisions
189
218
  """
219
+ # Validate indices and parameter_type (same rules as set_clip_automation)
220
+ if track_index < 0:
221
+ raise ValueError("track_index must be >= 0")
222
+ if clip_index < 0:
223
+ raise ValueError("clip_index must be >= 0")
224
+ if parameter_type not in ("device", "volume", "panning", "send"):
225
+ raise ValueError("parameter_type must be 'device', 'volume', 'panning', or 'send'")
226
+ if parameter_type == "device":
227
+ if device_index is None or parameter_index is None:
228
+ raise ValueError("device_index and parameter_index required for parameter_type='device'")
229
+ if parameter_type == "send" and send_index is None:
230
+ raise ValueError("send_index required for parameter_type='send'")
231
+
190
232
  # Generate the curve
191
233
  points = generate_curve(
192
234
  curve_type=curve_type,
@@ -269,6 +311,19 @@ def apply_automation_recipe(
269
311
  - vinyl_crackle: slow bit reduction movement
270
312
  - stereo_narrow: collapse to mono before drop
271
313
  """
314
+ # Validate indices and parameter_type (same rules as set_clip_automation)
315
+ if track_index < 0:
316
+ raise ValueError("track_index must be >= 0")
317
+ if clip_index < 0:
318
+ raise ValueError("clip_index must be >= 0")
319
+ if parameter_type not in ("device", "volume", "panning", "send"):
320
+ raise ValueError("parameter_type must be 'device', 'volume', 'panning', or 'send'")
321
+ if parameter_type == "device":
322
+ if device_index is None or parameter_index is None:
323
+ raise ValueError("device_index and parameter_index required for parameter_type='device'")
324
+ if parameter_type == "send" and send_index is None:
325
+ raise ValueError("send_index required for parameter_type='send'")
326
+
272
327
  points = generate_from_recipe(recipe, duration=duration, density=density)
273
328
 
274
329
  if time_offset > 0:
@@ -10,13 +10,16 @@ from typing import Any, Optional
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
 
16
16
  def _ensure_list(value: Any) -> list:
17
17
  """Parse JSON strings into lists. MCP clients may serialize list params as strings."""
18
18
  if isinstance(value, str):
19
- return json.loads(value)
19
+ try:
20
+ return json.loads(value)
21
+ except json.JSONDecodeError as exc:
22
+ raise ValueError(f"Invalid JSON in parameter: {exc}") from exc
20
23
  return value
21
24
 
22
25
 
@@ -26,16 +29,162 @@ def _get_ableton(ctx: Context):
26
29
 
27
30
 
28
31
  MASTER_TRACK_INDEX = -1000
32
+ _PLUGIN_CLASS_NAMES = {"PluginDevice", "AuPluginDevice"}
33
+ _SAMPLE_DEPENDENT_DEVICE_NAMES = {
34
+ "idensity": "Requires source audio loaded inside the plugin UI before MIDI can produce sound.",
35
+ "tardigrain": "Requires source audio loaded inside the plugin UI before MIDI can produce sound.",
36
+ "koala sampler": "Requires source audio loaded inside the plugin UI before MIDI can produce sound.",
37
+ "burns audio granular": "Requires source audio loaded inside the plugin UI before MIDI can produce sound.",
38
+ "audiolayer": "Requires samples loaded inside the plugin UI before MIDI can produce sound.",
39
+ "segments": "Requires source audio loaded inside the plugin UI before MIDI can produce sound.",
40
+ "segments (instr)": "Requires source audio loaded inside the plugin UI before MIDI can produce sound.",
41
+ }
42
+
43
+
44
+ def _sample_dependency_reason(device_name: str) -> Optional[str]:
45
+ lowered = device_name.strip().lower()
46
+ for candidate, reason in _SAMPLE_DEPENDENT_DEVICE_NAMES.items():
47
+ if candidate in lowered:
48
+ return reason
49
+ return None
50
+
51
+
52
+ def _annotate_device_info(result: dict) -> dict:
53
+ """Attach MCP-focused health hints to raw get_device_info results."""
54
+ if not isinstance(result, dict):
55
+ return result
56
+
57
+ class_name = str(result.get("class_name") or "")
58
+ device_name = str(result.get("name") or "")
59
+ parameter_count = int(result.get("parameter_count") or 0)
60
+ is_plugin = class_name in _PLUGIN_CLASS_NAMES
61
+
62
+ plugin_host_status = "not_plugin"
63
+ if is_plugin:
64
+ plugin_host_status = "host_visible" if parameter_count > 1 else "opaque_or_failed"
65
+
66
+ flags: list[str] = []
67
+ warnings: list[str] = []
68
+
69
+ sample_reason = _sample_dependency_reason(device_name)
70
+ if sample_reason:
71
+ flags.append("sample_dependent")
72
+ warnings.append(sample_reason)
73
+
74
+ if plugin_host_status == "opaque_or_failed":
75
+ flags.append("opaque_or_failed_plugin")
76
+ warnings.append(
77
+ "Ableton only sees %d host parameter(s) for this plugin. "
78
+ "If auditioning produces no audio, the plugin likely failed to initialize. "
79
+ "If audio is flowing, the plugin is usable but opaque to MCP sound design."
80
+ % parameter_count
81
+ )
82
+
83
+ annotated = dict(result)
84
+ annotated["is_plugin"] = is_plugin
85
+ annotated["plugin_host_status"] = plugin_host_status
86
+ annotated["health_flags"] = flags
87
+ annotated["mcp_sound_design_ready"] = len(flags) == 0
88
+ if warnings:
89
+ annotated["warnings"] = warnings
90
+ return annotated
91
+
92
+
93
+ def _annotate_loaded_device_result(result: dict) -> dict:
94
+ """Attach preflight warnings to load results based on loaded device names."""
95
+ if not isinstance(result, dict):
96
+ return result
97
+
98
+ loaded_name = str(result.get("loaded") or "")
99
+ sample_reason = _sample_dependency_reason(loaded_name)
100
+ if not sample_reason:
101
+ return result
102
+
103
+ annotated = dict(result)
104
+ annotated["health_flags"] = ["sample_dependent"]
105
+ annotated["warnings"] = [sample_reason]
106
+ annotated["mcp_sound_design_ready"] = False
107
+ return annotated
108
+
109
+
110
+ def _merge_unique(base: list[str], extra: list[str]) -> list[str]:
111
+ merged = list(base)
112
+ for item in extra:
113
+ if item not in merged:
114
+ merged.append(item)
115
+ return merged
116
+
117
+
118
+ def _postflight_loaded_device(ctx: Context, result: dict) -> dict:
119
+ """Attach post-load health info by inspecting the newly loaded device."""
120
+ annotated = _annotate_loaded_device_result(result)
121
+ if not isinstance(annotated, dict):
122
+ return annotated
123
+
124
+ track_index = annotated.get("track_index")
125
+ loaded_name = str(annotated.get("loaded") or "")
126
+ if track_index is None or not loaded_name:
127
+ return annotated
128
+
129
+ try:
130
+ track_info = _get_ableton(ctx).send_command("get_track_info", {
131
+ "track_index": int(track_index),
132
+ })
133
+ except Exception:
134
+ return annotated
135
+
136
+ devices = track_info.get("devices", []) if isinstance(track_info, dict) else []
137
+ if not isinstance(devices, list) or not devices:
138
+ return annotated
139
+
140
+ match = None
141
+ for device in reversed(devices):
142
+ if str(device.get("name") or "") == loaded_name:
143
+ match = device
144
+ break
145
+ if match is None:
146
+ match = devices[-1]
147
+
148
+ device_info = _annotate_device_info({
149
+ "name": match.get("name"),
150
+ "class_name": match.get("class_name"),
151
+ "is_active": match.get("is_active"),
152
+ "parameter_count": len(match.get("parameters", [])),
153
+ })
154
+
155
+ merged = dict(annotated)
156
+ merged["device_index"] = match.get("index")
157
+ merged["class_name"] = device_info.get("class_name")
158
+ merged["parameter_count"] = device_info.get("parameter_count")
159
+ merged["is_plugin"] = device_info.get("is_plugin")
160
+ merged["plugin_host_status"] = device_info.get("plugin_host_status")
161
+ merged["mcp_sound_design_ready"] = (
162
+ merged.get("mcp_sound_design_ready", True)
163
+ and device_info.get("mcp_sound_design_ready", True)
164
+ )
165
+
166
+ merged["health_flags"] = _merge_unique(
167
+ annotated.get("health_flags", []),
168
+ device_info.get("health_flags", []),
169
+ )
170
+
171
+ warnings = _merge_unique(
172
+ annotated.get("warnings", []),
173
+ device_info.get("warnings", []),
174
+ )
175
+ if warnings:
176
+ merged["warnings"] = warnings
177
+
178
+ return merged
29
179
 
30
180
 
31
181
  def _validate_track_index(track_index: int):
32
182
  if track_index < 0 and track_index != MASTER_TRACK_INDEX:
33
- if track_index < -100:
183
+ if not (-99 <= track_index <= -1):
34
184
  raise ValueError(
35
185
  "track_index must be >= 0 for regular tracks, "
36
- "negative for return tracks (-1=A, -2=B), or -1000 for master"
186
+ "-1..-99 for return tracks (-1=A, -2=B), or -1000 for master"
37
187
  )
38
- # Negative values -1..-99 are valid return track indices
39
188
 
40
189
 
41
190
  def _validate_device_index(device_index: int):
@@ -54,10 +203,11 @@ def get_device_info(ctx: Context, track_index: int, device_index: int) -> dict:
54
203
  track_index: 0+ for regular tracks, -1/-2/... for return tracks (A/B/...), -1000 for master."""
55
204
  _validate_track_index(track_index)
56
205
  _validate_device_index(device_index)
57
- return _get_ableton(ctx).send_command("get_device_info", {
206
+ result = _get_ableton(ctx).send_command("get_device_info", {
58
207
  "track_index": track_index,
59
208
  "device_index": device_index,
60
209
  })
210
+ return _annotate_device_info(result)
61
211
 
62
212
 
63
213
  @mcp.tool()
@@ -157,10 +307,11 @@ def load_device_by_uri(ctx: Context, track_index: int, uri: str) -> dict:
157
307
  _validate_track_index(track_index)
158
308
  if not uri.strip():
159
309
  raise ValueError("URI cannot be empty")
160
- return _get_ableton(ctx).send_command("load_device_by_uri", {
310
+ result = _get_ableton(ctx).send_command("load_device_by_uri", {
161
311
  "track_index": track_index,
162
312
  "uri": uri,
163
313
  })
314
+ return _postflight_loaded_device(ctx, result)
164
315
 
165
316
 
166
317
  @mcp.tool()
@@ -170,10 +321,11 @@ def find_and_load_device(ctx: Context, track_index: int, device_name: str) -> di
170
321
  _validate_track_index(track_index)
171
322
  if not device_name.strip():
172
323
  raise ValueError("device_name cannot be empty")
173
- return _get_ableton(ctx).send_command("find_and_load_device", {
324
+ result = _get_ableton(ctx).send_command("find_and_load_device", {
174
325
  "track_index": track_index,
175
326
  "device_name": device_name,
176
327
  })
328
+ return _postflight_loaded_device(ctx, result)
177
329
 
178
330
 
179
331
  @mcp.tool()
@@ -263,7 +415,7 @@ def _get_m4l(ctx: Context):
263
415
  """Get M4LBridge from lifespan context."""
264
416
  bridge = ctx.lifespan_context.get("m4l")
265
417
  if not bridge:
266
- raise RuntimeError("M4L bridge not initialized")
418
+ raise ValueError("M4L bridge not initialized — restart the MCP server")
267
419
  return bridge
268
420
 
269
421
 
@@ -271,12 +423,48 @@ def _get_spectral(ctx: Context):
271
423
  """Get SpectralCache from lifespan context."""
272
424
  cache = ctx.lifespan_context.get("spectral")
273
425
  if not cache:
274
- raise RuntimeError("Spectral cache not initialized")
426
+ raise ValueError("Spectral cache not initialized — restart the MCP server")
427
+ # Keep the active request context attached so analyzer error paths can
428
+ # distinguish "device missing" from "bridge disconnected".
429
+ setattr(cache, "_livepilot_ctx", ctx)
275
430
  return cache
276
431
 
277
432
 
278
433
  def _require_analyzer(cache) -> None:
279
434
  if not cache.is_connected:
435
+ ctx = getattr(cache, "_livepilot_ctx", None)
436
+ try:
437
+ track = (
438
+ ctx.lifespan_context["ableton"].send_command("get_master_track")
439
+ if ctx else {}
440
+ )
441
+ except Exception:
442
+ track = {}
443
+
444
+ devices = track.get("devices", []) if isinstance(track, dict) else []
445
+ analyzer_loaded = False
446
+ for device in devices:
447
+ normalized = " ".join(
448
+ str(device.get("name") or "").replace("_", " ").replace("-", " ").lower().split()
449
+ )
450
+ if normalized == "livepilot analyzer":
451
+ analyzer_loaded = True
452
+ break
453
+
454
+ if analyzer_loaded:
455
+ holder = _identify_port_holder(9880)
456
+ detail = (
457
+ "LivePilot Analyzer is loaded on the master track, but its UDP bridge is not connected. "
458
+ )
459
+ if holder:
460
+ detail += (
461
+ "UDP port 9880 is currently held by another LivePilot instance "
462
+ f"({holder}). Close the other client/server, then retry."
463
+ )
464
+ else:
465
+ detail += "Reload the analyzer device or restart the MCP server."
466
+ raise ValueError(detail)
467
+
280
468
  raise ValueError(
281
469
  "LivePilot Analyzer not detected. "
282
470
  "Drag 'LivePilot Analyzer' onto the master track."
@@ -302,7 +490,7 @@ async def get_plugin_parameters(
302
490
  cache = _get_spectral(ctx)
303
491
  _require_analyzer(cache)
304
492
  bridge = _get_m4l(ctx)
305
- return await bridge.send_command("get_plugin_params", track_index, device_index)
493
+ return await bridge.send_command("get_plugin_params", track_index, device_index, timeout=20.0)
306
494
 
307
495
 
308
496
  @mcp.tool()
@@ -326,7 +514,7 @@ async def map_plugin_parameter(
326
514
  cache = _get_spectral(ctx)
327
515
  _require_analyzer(cache)
328
516
  bridge = _get_m4l(ctx)
329
- return await bridge.send_command("map_plugin_param", track_index, device_index, parameter_index)
517
+ return await bridge.send_command("map_plugin_param", track_index, device_index, parameter_index, timeout=10.0)
330
518
 
331
519
 
332
520
  @mcp.tool()
@@ -346,4 +534,4 @@ async def get_plugin_presets(
346
534
  cache = _get_spectral(ctx)
347
535
  _require_analyzer(cache)
348
536
  bridge = _get_m4l(ctx)
349
- return await bridge.send_command("get_plugin_presets", track_index, device_index)
537
+ return await bridge.send_command("get_plugin_presets", track_index, device_index, timeout=15.0)
@@ -17,7 +17,10 @@ from . import _theory_engine as theory
17
17
 
18
18
  def _ensure_list(value: Any) -> list:
19
19
  if isinstance(value, str):
20
- return json.loads(value)
20
+ try:
21
+ return json.loads(value)
22
+ except json.JSONDecodeError as exc:
23
+ raise ValueError(f"Invalid JSON in parameter: {exc}") from exc
21
24
  return value
22
25
 
23
26
 
@@ -91,6 +94,10 @@ def layer_euclidean_rhythms(
91
94
  for layer in layers:
92
95
  p = int(layer["pulses"])
93
96
  s = int(layer["steps"])
97
+ if s < 1 or s > 64:
98
+ raise ValueError(f"steps must be between 1 and 64, got {s}")
99
+ if p < 0 or p > s:
100
+ raise ValueError(f"pulses must be between 0 and steps ({s}), got {p}")
94
101
  rot = int(layer.get("rotation", 0))
95
102
  pitch = int(layer["pitch"])
96
103
  vel = int(layer.get("velocity", 100))