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.
- package/.claude-plugin/marketplace.json +1 -1
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +92 -0
- package/CODE_OF_CONDUCT.md +27 -0
- package/CONTRIBUTING.md +131 -0
- package/README.md +112 -387
- package/SECURITY.md +48 -0
- package/bin/livepilot.js +42 -2
- package/livepilot/.Codex-plugin/plugin.json +8 -0
- package/livepilot/.claude-plugin/plugin.json +1 -1
- package/livepilot/commands/beat.md +18 -6
- package/livepilot/commands/sounddesign.md +6 -5
- package/livepilot/skills/livepilot-core/SKILL.md +36 -13
- package/livepilot/skills/livepilot-core/references/overview.md +1 -1
- package/livepilot/skills/livepilot-release/SKILL.md +11 -8
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/LivePilot_Analyzer.maxpat +378 -4
- package/m4l_device/capture_2026_04_07_192216.wav +0 -0
- package/m4l_device/livepilot_bridge.js +487 -184
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/connection.py +40 -1
- package/mcp_server/curves.py +5 -1
- package/mcp_server/m4l_bridge.py +93 -26
- package/mcp_server/server.py +26 -31
- package/mcp_server/tools/_perception_engine.py +26 -3
- package/mcp_server/tools/_theory_engine.py +36 -5
- package/mcp_server/tools/analyzer.py +74 -18
- package/mcp_server/tools/arrangement.py +20 -5
- package/mcp_server/tools/automation.py +56 -1
- package/mcp_server/tools/devices.py +201 -13
- package/mcp_server/tools/generative.py +8 -1
- package/mcp_server/tools/harmony.py +16 -3
- package/mcp_server/tools/midi_io.py +22 -4
- package/mcp_server/tools/notes.py +4 -1
- package/mcp_server/tools/perception.py +27 -1
- package/mcp_server/tools/scenes.py +4 -1
- package/mcp_server/tools/theory.py +23 -8
- package/mcp_server/tools/transport.py +16 -6
- package/package.json +1 -1
- package/remote_script/LivePilot/__init__.py +1 -1
- package/remote_script/LivePilot/arrangement.py +25 -134
- package/remote_script/LivePilot/browser.py +19 -8
- package/remote_script/LivePilot/clip_automation.py +5 -4
- package/remote_script/LivePilot/clips.py +14 -6
- package/remote_script/LivePilot/devices.py +6 -3
- package/remote_script/LivePilot/diagnostics.py +81 -5
- package/remote_script/LivePilot/router.py +22 -0
- package/remote_script/LivePilot/server.py +41 -17
- package/remote_script/LivePilot/tracks.py +7 -2
- package/remote_script/LivePilot/transport.py +3 -3
- 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
|
|
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
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
283
|
-
"
|
|
284
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
183
|
+
if not (-99 <= track_index <= -1):
|
|
34
184
|
raise ValueError(
|
|
35
185
|
"track_index must be >= 0 for regular tracks, "
|
|
36
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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))
|