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
@@ -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.arm:
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.solo:
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.mute:
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.has_midi_input and len(list(track.devices)) == 0:
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.solo:
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._log("Client connected from %s:%d" % addr)
162
- try:
163
- self._handle_client(client)
164
- except OSError as exc:
165
- self._log("Client error: %s" % exc)
166
- finally:
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
- # flatten() is a method on the track, not the song
348
- track.flatten()
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 {