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
@@ -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
+ try:
191
+ client.close()
192
+ except OSError:
193
+ pass
194
+ with self._client_lock:
195
+ self._client_connected = False
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)
@@ -193,6 +205,9 @@ class LivePilotServer(object):
193
205
  if not data:
194
206
  break
195
207
  buf += data.decode("utf-8", errors="replace")
208
+ if len(buf) > 4 * 1024 * 1024: # 4 MB
209
+ self._log("Client buffer overflow — disconnecting")
210
+ break
196
211
  while "\n" in buf:
197
212
  line, buf = buf.split("\n", 1)
198
213
  line = line.strip()
@@ -237,8 +252,14 @@ class LivePilotServer(object):
237
252
  try:
238
253
  self._cs.schedule_message(0, self._process_next_command)
239
254
  except AssertionError:
240
- # Already on main thread process directly
241
- self._process_next_command()
255
+ # ControlSurface is disconnectingreturn error instead of
256
+ # running LOM calls on the TCP thread (which would be unsafe)
257
+ response_queue.put({
258
+ "id": request_id,
259
+ "ok": False,
260
+ "error": {"code": "STATE_ERROR", "message": "Script is disconnecting"},
261
+ })
262
+ return
242
263
 
243
264
  # Wait for response from main thread
244
265
  try:
@@ -284,7 +305,8 @@ class LivePilotServer(object):
284
305
  try:
285
306
  self._cs.schedule_message(1, send_response) # ~100ms
286
307
  except AssertionError:
287
- send_response()
308
+ # ControlSurface disconnecting — send result immediately
309
+ response_queue.put(result)
288
310
  else:
289
311
  response_queue.put(result)
290
312
  # Drain any remaining queued commands
@@ -296,7 +318,9 @@ class LivePilotServer(object):
296
318
  try:
297
319
  self._cs.schedule_message(0, self._process_next_command)
298
320
  except AssertionError:
299
- self._process_next_command()
321
+ # ControlSurface disconnecting — drop remaining commands
322
+ # rather than running LOM calls on the wrong thread
323
+ pass
300
324
 
301
325
  # ── Socket I/O ───────────────────────────────────────────────────────
302
326
 
@@ -175,9 +175,14 @@ def duplicate_track(song, params):
175
175
  "Track index %d out of range (0..%d)"
176
176
  % (track_index, len(tracks) - 1)
177
177
  )
178
+ count_before = len(tracks)
178
179
  song.duplicate_track(track_index)
179
- new_index = track_index + 1
180
- return {"index": new_index, "name": list(song.tracks)[new_index].name}
180
+ all_tracks = list(song.tracks)
181
+ # For group tracks, Ableton duplicates the group + all children.
182
+ # The duplicate block starts right after the original group's last child.
183
+ added = len(all_tracks) - count_before
184
+ new_index = track_index + added
185
+ return {"index": new_index, "name": all_tracks[new_index].name}
181
186
 
182
187
 
183
188
  @register("set_track_name")
@@ -53,9 +53,9 @@ def get_session_info(song, params):
53
53
  "metronome": song.metronome,
54
54
  "record_mode": song.record_mode,
55
55
  "session_record": song.session_record,
56
- "track_count": len(list(song.tracks)),
57
- "return_track_count": len(list(song.return_tracks)),
58
- "scene_count": len(list(song.scenes)),
56
+ "track_count": len(tracks_info),
57
+ "return_track_count": len(return_tracks_info),
58
+ "scene_count": len(scenes_info),
59
59
  "tracks": tracks_info,
60
60
  "return_tracks": return_tracks_info,
61
61
  "scenes": scenes_info,
package/requirements.txt CHANGED
@@ -3,9 +3,11 @@ numpy>=1.24.0
3
3
  fastmcp>=3.0.0,<4.0.0
4
4
  midiutil>=1.2.1
5
5
  pretty_midi>=0.2.10
6
- opycleid>=0.5.1
7
6
  # v1.8 Perception Layer (offline analysis)
8
7
  pyloudnorm>=0.1.0
9
8
  soundfile>=0.12.0
10
9
  scipy>=1.11.0
11
10
  mutagen>=1.47.0
11
+
12
+ # Optional: neo-Riemannian group theory (not required — harmony engine is pure Python)
13
+ # pip install opycleid>=0.5.1