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.
- package/.claude-plugin/marketplace.json +1 -1
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +45 -0
- package/CODE_OF_CONDUCT.md +27 -0
- package/CONTRIBUTING.md +131 -0
- package/README.md +119 -395
- package/SECURITY.md +48 -0
- package/bin/livepilot.js +41 -1
- 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 +30 -7
- package/livepilot/skills/livepilot-core/references/overview.md +1 -1
- package/livepilot/skills/livepilot-release/SKILL.md +7 -4
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/LivePilot_Analyzer.maxpat +378 -4
- package/m4l_device/livepilot_bridge.js +413 -184
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/connection.py +35 -0
- package/mcp_server/m4l_bridge.py +55 -5
- package/mcp_server/server.py +16 -29
- package/mcp_server/tools/_perception_engine.py +26 -3
- package/mcp_server/tools/_theory_engine.py +36 -5
- package/mcp_server/tools/analyzer.py +62 -17
- package/mcp_server/tools/automation.py +4 -1
- package/mcp_server/tools/devices.py +199 -10
- package/mcp_server/tools/generative.py +4 -1
- package/mcp_server/tools/harmony.py +16 -3
- 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 +15 -7
- package/package.json +1 -1
- package/remote_script/LivePilot/__init__.py +1 -1
- package/remote_script/LivePilot/arrangement.py +13 -116
- package/remote_script/LivePilot/diagnostics.py +81 -5
- package/remote_script/LivePilot/router.py +22 -0
- package/remote_script/LivePilot/server.py +25 -13
- package/remote_script/LivePilot/tracks.py +17 -2
|
@@ -14,6 +14,16 @@ from .router import register
|
|
|
14
14
|
_DEFAULT_NAME_PATTERNS = frozenset([
|
|
15
15
|
"MIDI", "Audio", "Inst", "Return",
|
|
16
16
|
])
|
|
17
|
+
_PLUGIN_CLASS_NAMES = frozenset(["PluginDevice", "AuPluginDevice"])
|
|
18
|
+
_SAMPLE_DEPENDENT_DEVICE_NAMES = frozenset([
|
|
19
|
+
"idensity",
|
|
20
|
+
"tardigrain",
|
|
21
|
+
"koala sampler",
|
|
22
|
+
"burns audio granular",
|
|
23
|
+
"audiolayer",
|
|
24
|
+
"segments",
|
|
25
|
+
"segments (instr)",
|
|
26
|
+
])
|
|
17
27
|
|
|
18
28
|
|
|
19
29
|
def _looks_default_name(name):
|
|
@@ -29,6 +39,22 @@ def _looks_default_name(name):
|
|
|
29
39
|
return False
|
|
30
40
|
|
|
31
41
|
|
|
42
|
+
def _sample_dependent_device(name):
|
|
43
|
+
lowered = name.strip().lower()
|
|
44
|
+
for candidate in _SAMPLE_DEPENDENT_DEVICE_NAMES:
|
|
45
|
+
if candidate in lowered:
|
|
46
|
+
return True
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _safe_attr(obj, name, default=None):
|
|
51
|
+
"""Read a Live attribute defensively — some track types omit properties."""
|
|
52
|
+
try:
|
|
53
|
+
return getattr(obj, name)
|
|
54
|
+
except Exception:
|
|
55
|
+
return default
|
|
56
|
+
|
|
57
|
+
|
|
32
58
|
@register("get_session_diagnostics")
|
|
33
59
|
def get_session_diagnostics(song, params):
|
|
34
60
|
"""Analyze the session and return a diagnostic report."""
|
|
@@ -57,19 +83,21 @@ def get_session_diagnostics(song, params):
|
|
|
57
83
|
unnamed_tracks = []
|
|
58
84
|
empty_tracks = [] # no clips at all
|
|
59
85
|
no_device_midi_tracks = [] # MIDI tracks with no instruments
|
|
86
|
+
opaque_or_failed_plugins = []
|
|
87
|
+
sample_dependent_devices = []
|
|
60
88
|
track_slots = [] # cached clip_slots per track (avoid re-evaluating LOM tuple)
|
|
61
89
|
|
|
62
90
|
for i, track in enumerate(tracks):
|
|
63
91
|
# Armed check
|
|
64
|
-
if track
|
|
92
|
+
if _safe_attr(track, "arm", False):
|
|
65
93
|
armed_tracks.append({"index": i, "name": track.name})
|
|
66
94
|
|
|
67
95
|
# Solo check
|
|
68
|
-
if track
|
|
96
|
+
if _safe_attr(track, "solo", False):
|
|
69
97
|
soloed_tracks.append({"index": i, "name": track.name})
|
|
70
98
|
|
|
71
99
|
# Muted tracks (informational, only flag if many)
|
|
72
|
-
if track
|
|
100
|
+
if _safe_attr(track, "mute", False):
|
|
73
101
|
muted_tracks.append({"index": i, "name": track.name})
|
|
74
102
|
|
|
75
103
|
# Unnamed / default name check
|
|
@@ -91,9 +119,33 @@ def get_session_diagnostics(song, params):
|
|
|
91
119
|
empty_tracks.append({"index": i, "name": track.name})
|
|
92
120
|
|
|
93
121
|
# MIDI track with no devices (no instrument loaded)
|
|
94
|
-
if track
|
|
122
|
+
if _safe_attr(track, "has_midi_input", False) and len(list(track.devices)) == 0:
|
|
95
123
|
no_device_midi_tracks.append({"index": i, "name": track.name})
|
|
96
124
|
|
|
125
|
+
for j, device in enumerate(track.devices):
|
|
126
|
+
class_name = getattr(device, "class_name", "")
|
|
127
|
+
try:
|
|
128
|
+
parameter_count = len(list(device.parameters))
|
|
129
|
+
except Exception:
|
|
130
|
+
parameter_count = None
|
|
131
|
+
|
|
132
|
+
if class_name in _PLUGIN_CLASS_NAMES and parameter_count is not None and parameter_count <= 1:
|
|
133
|
+
opaque_or_failed_plugins.append({
|
|
134
|
+
"track_index": i,
|
|
135
|
+
"track_name": track.name,
|
|
136
|
+
"device_index": j,
|
|
137
|
+
"device_name": device.name,
|
|
138
|
+
"parameter_count": parameter_count,
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
if _sample_dependent_device(device.name):
|
|
142
|
+
sample_dependent_devices.append({
|
|
143
|
+
"track_index": i,
|
|
144
|
+
"track_name": track.name,
|
|
145
|
+
"device_index": j,
|
|
146
|
+
"device_name": device.name,
|
|
147
|
+
})
|
|
148
|
+
|
|
97
149
|
# Build issues from checks
|
|
98
150
|
if armed_tracks:
|
|
99
151
|
issues.append({
|
|
@@ -145,6 +197,30 @@ def get_session_diagnostics(song, params):
|
|
|
145
197
|
"details": no_device_midi_tracks,
|
|
146
198
|
})
|
|
147
199
|
|
|
200
|
+
if opaque_or_failed_plugins:
|
|
201
|
+
issues.append({
|
|
202
|
+
"type": "opaque_or_failed_plugins",
|
|
203
|
+
"severity": "warning",
|
|
204
|
+
"message": (
|
|
205
|
+
"%d plugin(s) expose <=1 host parameter. If silent after auditioning, "
|
|
206
|
+
"they likely failed to initialize. If audio is flowing, they are opaque to MCP control."
|
|
207
|
+
% len(opaque_or_failed_plugins)
|
|
208
|
+
),
|
|
209
|
+
"details": opaque_or_failed_plugins,
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
if sample_dependent_devices:
|
|
213
|
+
issues.append({
|
|
214
|
+
"type": "sample_dependent_devices",
|
|
215
|
+
"severity": "info",
|
|
216
|
+
"message": (
|
|
217
|
+
"%d likely sample-dependent device(s) loaded — they may stay silent until source audio is loaded "
|
|
218
|
+
"inside the plugin UI."
|
|
219
|
+
% len(sample_dependent_devices)
|
|
220
|
+
),
|
|
221
|
+
"details": sample_dependent_devices,
|
|
222
|
+
})
|
|
223
|
+
|
|
148
224
|
# ── Scene-level checks ──────────────────────────────────────────────
|
|
149
225
|
|
|
150
226
|
empty_scenes = []
|
|
@@ -171,7 +247,7 @@ def get_session_diagnostics(song, params):
|
|
|
171
247
|
|
|
172
248
|
soloed_returns = []
|
|
173
249
|
for i, track in enumerate(return_tracks):
|
|
174
|
-
if track
|
|
250
|
+
if _safe_attr(track, "solo", False):
|
|
175
251
|
soloed_returns.append({"index": i, "name": track.name})
|
|
176
252
|
|
|
177
253
|
if soloed_returns:
|
|
@@ -51,6 +51,15 @@ def dispatch(song, command):
|
|
|
51
51
|
if cmd_type is None:
|
|
52
52
|
return error_response(request_id, "Missing 'type' field", INVALID_PARAM)
|
|
53
53
|
|
|
54
|
+
if params is None:
|
|
55
|
+
params = {}
|
|
56
|
+
elif not isinstance(params, dict):
|
|
57
|
+
return error_response(
|
|
58
|
+
request_id,
|
|
59
|
+
"'params' must be an object/dict",
|
|
60
|
+
INVALID_PARAM,
|
|
61
|
+
)
|
|
62
|
+
|
|
54
63
|
# Built-in ping — no handler registration needed.
|
|
55
64
|
if cmd_type == "ping":
|
|
56
65
|
return success_response(request_id, {"pong": True})
|
|
@@ -65,7 +74,20 @@ def dispatch(song, command):
|
|
|
65
74
|
|
|
66
75
|
try:
|
|
67
76
|
result = handler(song, params)
|
|
77
|
+
if isinstance(result, dict) and "error" in result:
|
|
78
|
+
return error_response(
|
|
79
|
+
request_id,
|
|
80
|
+
result["error"],
|
|
81
|
+
result.get("code", INTERNAL),
|
|
82
|
+
)
|
|
68
83
|
return success_response(request_id, result)
|
|
84
|
+
except KeyError as exc:
|
|
85
|
+
# Missing required parameter — report as INVALID_PARAM, not INTERNAL
|
|
86
|
+
return error_response(
|
|
87
|
+
request_id,
|
|
88
|
+
"Missing required parameter: %s" % str(exc),
|
|
89
|
+
INVALID_PARAM,
|
|
90
|
+
)
|
|
69
91
|
except IndexError as exc:
|
|
70
92
|
return error_response(request_id, str(exc), INDEX_ERROR)
|
|
71
93
|
except ValueError as exc:
|
|
@@ -82,6 +82,7 @@ class LivePilotServer(object):
|
|
|
82
82
|
self._running = False
|
|
83
83
|
self._server_socket = None
|
|
84
84
|
self._thread = None
|
|
85
|
+
self._client_thread = None
|
|
85
86
|
self._command_queue = queue.Queue()
|
|
86
87
|
self._client_lock = threading.Lock()
|
|
87
88
|
self._client_connected = False
|
|
@@ -106,6 +107,8 @@ class LivePilotServer(object):
|
|
|
106
107
|
pass
|
|
107
108
|
if self._thread and self._thread.is_alive():
|
|
108
109
|
self._thread.join(timeout=3)
|
|
110
|
+
if self._client_thread and self._client_thread.is_alive():
|
|
111
|
+
self._client_thread.join(timeout=3)
|
|
109
112
|
self._log("Server stopped")
|
|
110
113
|
|
|
111
114
|
# ── Logging ──────────────────────────────────────────────────────────
|
|
@@ -158,19 +161,12 @@ class LivePilotServer(object):
|
|
|
158
161
|
pass
|
|
159
162
|
continue
|
|
160
163
|
self._client_connected = True
|
|
161
|
-
self.
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
with self._client_lock:
|
|
168
|
-
self._client_connected = False
|
|
169
|
-
try:
|
|
170
|
-
client.close()
|
|
171
|
-
except OSError:
|
|
172
|
-
pass
|
|
173
|
-
self._log("Client disconnected")
|
|
164
|
+
self._client_thread = threading.Thread(
|
|
165
|
+
target=self._run_client_session,
|
|
166
|
+
args=(client, addr),
|
|
167
|
+
)
|
|
168
|
+
self._client_thread.daemon = True
|
|
169
|
+
self._client_thread.start()
|
|
174
170
|
except socket.timeout:
|
|
175
171
|
continue
|
|
176
172
|
except OSError:
|
|
@@ -183,6 +179,22 @@ class LivePilotServer(object):
|
|
|
183
179
|
except OSError:
|
|
184
180
|
pass
|
|
185
181
|
|
|
182
|
+
def _run_client_session(self, client, addr):
|
|
183
|
+
"""Handle one active client without blocking new connection rejects."""
|
|
184
|
+
self._log("Client connected from %s:%d" % addr)
|
|
185
|
+
try:
|
|
186
|
+
self._handle_client(client)
|
|
187
|
+
except OSError as exc:
|
|
188
|
+
self._log("Client error: %s" % exc)
|
|
189
|
+
finally:
|
|
190
|
+
with self._client_lock:
|
|
191
|
+
self._client_connected = False
|
|
192
|
+
try:
|
|
193
|
+
client.close()
|
|
194
|
+
except OSError:
|
|
195
|
+
pass
|
|
196
|
+
self._log("Client disconnected")
|
|
197
|
+
|
|
186
198
|
def _handle_client(self, client):
|
|
187
199
|
"""Read newline-delimited JSON from a connected client."""
|
|
188
200
|
client.settimeout(1.0)
|
|
@@ -344,8 +344,23 @@ def flatten_track(song, params):
|
|
|
344
344
|
)
|
|
345
345
|
song.begin_undo_step()
|
|
346
346
|
try:
|
|
347
|
-
|
|
348
|
-
|
|
347
|
+
flattened = False
|
|
348
|
+
try:
|
|
349
|
+
track.flatten()
|
|
350
|
+
flattened = True
|
|
351
|
+
except AttributeError:
|
|
352
|
+
pass
|
|
353
|
+
if not flattened:
|
|
354
|
+
try:
|
|
355
|
+
song.flatten_track(track_index)
|
|
356
|
+
flattened = True
|
|
357
|
+
except AttributeError:
|
|
358
|
+
pass
|
|
359
|
+
if not flattened:
|
|
360
|
+
raise ValueError(
|
|
361
|
+
"flatten() not available via ControlSurface API. "
|
|
362
|
+
"Use Ableton's Flatten command manually."
|
|
363
|
+
)
|
|
349
364
|
finally:
|
|
350
365
|
song.end_undo_step()
|
|
351
366
|
return {
|