livepilot 1.9.16 → 1.9.18

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 (54) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/AGENTS.md +2 -2
  3. package/CHANGELOG.md +52 -1
  4. package/README.md +2 -2
  5. package/livepilot/.Codex-plugin/plugin.json +2 -2
  6. package/livepilot/.claude-plugin/plugin.json +2 -2
  7. package/livepilot/agents/livepilot-producer/AGENT.md +15 -1
  8. package/livepilot/commands/arrange.md +19 -0
  9. package/livepilot/commands/evaluate.md +39 -0
  10. package/livepilot/commands/mix.md +9 -4
  11. package/livepilot/commands/perform.md +30 -0
  12. package/livepilot/commands/sounddesign.md +9 -4
  13. package/livepilot/skills/livepilot-arrangement/SKILL.md +137 -0
  14. package/livepilot/skills/livepilot-composition-engine/SKILL.md +107 -0
  15. package/livepilot/skills/livepilot-composition-engine/references/form-patterns.md +97 -0
  16. package/livepilot/skills/livepilot-composition-engine/references/transition-archetypes.md +102 -0
  17. package/livepilot/skills/livepilot-core/SKILL.md +69 -449
  18. package/livepilot/skills/livepilot-core/references/overview.md +2 -2
  19. package/livepilot/skills/livepilot-devices/SKILL.md +134 -0
  20. package/livepilot/skills/livepilot-evaluation/SKILL.md +152 -0
  21. package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +118 -0
  22. package/livepilot/skills/livepilot-evaluation/references/evaluation-contracts.md +121 -0
  23. package/livepilot/skills/livepilot-evaluation/references/memory-promotion.md +110 -0
  24. package/livepilot/skills/livepilot-mix-engine/SKILL.md +123 -0
  25. package/livepilot/skills/livepilot-mix-engine/references/mix-critics.md +143 -0
  26. package/livepilot/skills/livepilot-mix-engine/references/mix-moves.md +105 -0
  27. package/livepilot/skills/livepilot-mixing/SKILL.md +155 -0
  28. package/livepilot/skills/livepilot-notes/SKILL.md +129 -0
  29. package/livepilot/skills/livepilot-performance-engine/SKILL.md +122 -0
  30. package/livepilot/skills/livepilot-performance-engine/references/performance-safety.md +98 -0
  31. package/livepilot/skills/livepilot-release/SKILL.md +10 -10
  32. package/livepilot/skills/livepilot-sound-design-engine/SKILL.md +123 -0
  33. package/livepilot/skills/livepilot-sound-design-engine/references/patch-model.md +119 -0
  34. package/livepilot/skills/livepilot-sound-design-engine/references/sound-design-critics.md +118 -0
  35. package/m4l_device/livepilot_bridge.js +1 -1
  36. package/mcp_server/__init__.py +1 -1
  37. package/mcp_server/connection.py +29 -22
  38. package/mcp_server/evaluation/tools.py +1 -1
  39. package/mcp_server/m4l_bridge.py +7 -4
  40. package/mcp_server/mix_engine/tools.py +1 -1
  41. package/mcp_server/performance_engine/tools.py +1 -1
  42. package/mcp_server/reference_engine/tools.py +1 -1
  43. package/mcp_server/sound_design/tools.py +1 -1
  44. package/mcp_server/tools/analyzer.py +4 -3
  45. package/mcp_server/tools/tracks.py +3 -3
  46. package/mcp_server/translation_engine/tools.py +1 -1
  47. package/package.json +2 -2
  48. package/remote_script/LivePilot/__init__.py +1 -1
  49. package/remote_script/LivePilot/arrangement.py +9 -2
  50. package/remote_script/LivePilot/browser.py +1 -0
  51. package/remote_script/LivePilot/clip_automation.py +6 -0
  52. package/remote_script/LivePilot/clips.py +2 -0
  53. package/remote_script/LivePilot/devices.py +1 -0
  54. package/remote_script/LivePilot/utils.py +2 -2
@@ -2,7 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import asyncio
6
5
  import json
7
6
  import os
8
7
  import socket
@@ -162,15 +161,20 @@ class AbletonConnection:
162
161
  try:
163
162
  response = self._send_raw(command)
164
163
  except AbletonConnectionError as exc:
165
- # Don't retry timeouts Ableton may have processed the command
164
+ # If the send phase succeeded (data left this process),
165
+ # Ableton may have already applied the command. Never
166
+ # replay — the duplicate mutation is worse than the error.
167
+ if getattr(exc, '_send_completed', False):
168
+ raise
169
+ # Don't retry timeouts either
166
170
  if "Timeout" in str(exc):
167
171
  raise
168
- # Retry once with a fresh connection for non-timeout errors
172
+ # Send itself failed — safe to retry with a fresh connection
169
173
  self.disconnect()
170
174
  self.connect()
171
175
  response = self._send_raw(command)
172
176
  except OSError:
173
- # Retry once with a fresh connection
177
+ # Socket error before send safe to retry
174
178
  self.disconnect()
175
179
  self.connect()
176
180
  response = self._send_raw(command)
@@ -196,15 +200,6 @@ class AbletonConnection:
196
200
  self._command_log.append(log_entry)
197
201
  return response.get("result", {})
198
202
 
199
- async def send_command_async(self, command_type: str, params: Optional[dict] = None) -> dict:
200
- """Async wrapper around send_command that avoids blocking the event loop.
201
-
202
- Runs the blocking TCP send/receive in a thread pool executor so the
203
- asyncio event loop remains responsive to other concurrent MCP tools.
204
- """
205
- loop = asyncio.get_running_loop()
206
- return await loop.run_in_executor(None, self.send_command, command_type, params)
207
-
208
203
  # ------------------------------------------------------------------
209
204
  # Command log
210
205
  # ------------------------------------------------------------------
@@ -234,7 +229,9 @@ class AbletonConnection:
234
229
  self.disconnect()
235
230
  raise AbletonConnectionError(f"Failed to send command: {exc}") from exc
236
231
 
237
- # Read until newline, preserving any trailing bytes in _recv_buf
232
+ # Read until newline, preserving any trailing bytes in _recv_buf.
233
+ # Any error past this point means the send already reached Ableton,
234
+ # so callers must NOT retry the command (it may have been applied).
238
235
  buf = self._recv_buf
239
236
  try:
240
237
  while b"\n" not in buf:
@@ -242,31 +239,41 @@ class AbletonConnection:
242
239
  if not chunk:
243
240
  self._recv_buf = b""
244
241
  self.disconnect()
245
- raise AbletonConnectionError("Connection closed by Ableton")
242
+ err = AbletonConnectionError("Connection closed by Ableton")
243
+ err._send_completed = True
244
+ raise err
246
245
  buf += chunk
247
246
  if len(buf) > 10 * 1024 * 1024: # 10 MB
248
247
  self._recv_buf = b""
249
248
  self.disconnect()
250
- raise AbletonConnectionError("Response too large (>10 MB)")
249
+ err = AbletonConnectionError("Response too large (>10 MB)")
250
+ err._send_completed = True
251
+ raise err
251
252
  except socket.timeout as exc:
252
253
  self._recv_buf = buf
253
254
  self.disconnect()
254
255
  other_client = _identify_other_tcp_client(self.host, self.port)
255
256
  if other_client:
256
- raise AbletonConnectionError(
257
+ err = AbletonConnectionError(
257
258
  "Timeout waiting for response from Ableton. "
258
259
  f"Another LivePilot client appears to be connected on {self.host}:{self.port} "
259
260
  f"({other_client}). Disconnect the other client and retry."
260
- ) from exc
261
- raise AbletonConnectionError(
261
+ )
262
+ err._send_completed = True
263
+ raise err from exc
264
+ err = AbletonConnectionError(
262
265
  f"Timeout waiting for response from Ableton ({RECV_TIMEOUT}s)"
263
- ) from exc
266
+ )
267
+ err._send_completed = True
268
+ raise err from exc
264
269
  except OSError as exc:
265
270
  self._recv_buf = b""
266
271
  self.disconnect()
267
- raise AbletonConnectionError(
272
+ err = AbletonConnectionError(
268
273
  f"Socket error reading response: {exc}"
269
- ) from exc
274
+ )
275
+ err._send_completed = True
276
+ raise err from exc
270
277
 
271
278
  line, remainder = buf.split(b"\n", 1)
272
279
  self._recv_buf = remainder
@@ -6,7 +6,7 @@ to the appropriate engine-specific evaluator via fabric.evaluate().
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
- from mcp.server.fastmcp import Context
9
+ from fastmcp import Context
10
10
 
11
11
  from ..server import mcp
12
12
  from ..tools._evaluation_contracts import EvaluationRequest, EvaluationResult
@@ -400,15 +400,18 @@ class M4LBridge:
400
400
  return {"error": "M4L capture timeout — device may be busy or removed"}
401
401
 
402
402
  async def cancel_capture_future(self) -> None:
403
- """Cancel any in-progress capture future (called by capture_stop).
403
+ """Resolve any in-progress capture future with a stopped result.
404
404
 
405
405
  Does NOT acquire _cmd_lock — send_capture holds it during recording.
406
- Cancelling the future causes send_capture's wait_for to raise
407
- CancelledError, which releases the lock naturally.
406
+ Resolving (not cancelling) the future lets send_capture return a
407
+ clean partial-result dict instead of raising CancelledError.
408
408
  """
409
409
  if self.receiver and self.receiver._capture_future \
410
410
  and not self.receiver._capture_future.done():
411
- self.receiver._capture_future.cancel()
411
+ self.receiver._capture_future.set_result({
412
+ "ok": True,
413
+ "stopped_early": True,
414
+ })
412
415
  self.receiver._capture_future = None
413
416
 
414
417
  def _build_osc(self, address: str, args: tuple) -> bytes:
@@ -6,7 +6,7 @@ then delegates to pure-computation modules.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
- from mcp.server.fastmcp import Context
9
+ from fastmcp import Context
10
10
 
11
11
  from ..server import mcp
12
12
  from ..tools._evaluation_contracts import EvaluationRequest
@@ -6,7 +6,7 @@ then delegates to pure-computation modules.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
- from mcp.server.fastmcp import Context
9
+ from fastmcp import Context
10
10
 
11
11
  from ..server import mcp
12
12
  from .models import EnergyWindow, SceneRole
@@ -6,7 +6,7 @@ then delegates to pure-computation modules.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
- from mcp.server.fastmcp import Context
9
+ from fastmcp import Context
10
10
 
11
11
  from ..server import mcp
12
12
  from ..tools._research_engine import get_style_tactics
@@ -6,7 +6,7 @@ then delegates to pure-computation modules.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
- from mcp.server.fastmcp import Context
9
+ from fastmcp import Context
10
10
 
11
11
  from ..server import mcp
12
12
  from .models import (
@@ -634,14 +634,15 @@ async def capture_audio(
634
634
  async def capture_stop(ctx: Context) -> dict:
635
635
  """Stop an in-progress audio capture early.
636
636
 
637
- Cancels the running buffer~ recording and returns whatever audio has
638
- been captured so far. The partial file is still written to disk.
637
+ Tells the M4L bridge to stop buffer~ recording and resolves the
638
+ in-flight capture_audio call with a partial result (stopped_early=True).
639
+ The partial file is still written to disk by the bridge.
639
640
  Requires LivePilot Analyzer on master track.
640
641
  """
641
642
  cache = _get_spectral(ctx)
642
643
  _require_analyzer(cache)
643
644
  bridge = _get_m4l(ctx)
644
- # Cancel the capture future so send_capture doesn't hang forever
645
+ # Resolve the capture future so send_capture returns cleanly
645
646
  await bridge.cancel_capture_future()
646
647
  return await bridge.send_command("capture_stop")
647
648
 
@@ -176,10 +176,10 @@ def set_group_fold(ctx: Context, track_index: int, folded: bool) -> dict:
176
176
 
177
177
  @mcp.tool()
178
178
  def set_track_input_monitoring(ctx: Context, track_index: int, state: int) -> dict:
179
- """Set input monitoring (0=Off, 1=In, 2=Auto). Only for regular tracks, not return tracks."""
180
- _validate_track_index(track_index)
179
+ """Set input monitoring (0=In, 1=Auto, 2=Off). Only for regular tracks, not return tracks."""
180
+ _validate_track_index(track_index, allow_return=False)
181
181
  if state not in (0, 1, 2):
182
- raise ValueError("Monitoring state must be 0=Off, 1=In, or 2=Auto")
182
+ raise ValueError("Monitoring state must be 0=In, 1=Auto, or 2=Off")
183
183
  return _get_ableton(ctx).send_command("set_track_input_monitoring", {
184
184
  "track_index": track_index,
185
185
  "state": state,
@@ -6,7 +6,7 @@ then delegates to pure-computation critics.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
- from mcp.server.fastmcp import Context
9
+ from fastmcp import Context
10
10
 
11
11
  from ..server import mcp
12
12
  from .critics import build_translation_report, run_all_translation_critics
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "livepilot",
3
- "version": "1.9.16",
3
+ "version": "1.9.18",
4
4
  "mcpName": "io.github.dreamrec/livepilot",
5
- "description": "Agentic production system for Ableton Live 12 — 236 tools, 32 domains, device atlas, spectral perception, technique memory, neo-Riemannian harmony, Euclidean rhythm, species counterpoint, MIDI I/O",
5
+ "description": "Agentic production system for Ableton Live 12 — 236 tools, 31 domains, device atlas, spectral perception, technique memory, neo-Riemannian harmony, Euclidean rhythm, species counterpoint, MIDI I/O",
6
6
  "author": "Pilot Studio",
7
7
  "license": "MIT",
8
8
  "type": "commonjs",
@@ -5,7 +5,7 @@ Entry point for the ControlSurface. Ableton calls create_instance(c_instance)
5
5
  when this script is selected in Preferences > Link, Tempo & MIDI.
6
6
  """
7
7
 
8
- __version__ = "1.9.16"
8
+ __version__ = "1.9.18"
9
9
 
10
10
  from _Framework.ControlSurface import ControlSurface
11
11
  from .server import LivePilotServer
@@ -190,13 +190,20 @@ def add_arrangement_notes(song, params):
190
190
  try:
191
191
  note_specs = []
192
192
  for note in notes:
193
- spec = Live.Clip.MidiNoteSpecification(
193
+ kwargs = dict(
194
194
  pitch=int(note["pitch"]),
195
195
  start_time=float(note["start_time"]),
196
196
  duration=float(note["duration"]),
197
197
  velocity=float(note.get("velocity", 100)),
198
198
  mute=bool(note.get("mute", False)),
199
199
  )
200
+ if "probability" in note:
201
+ kwargs["probability"] = float(note["probability"])
202
+ if "velocity_deviation" in note:
203
+ kwargs["velocity_deviation"] = float(note["velocity_deviation"])
204
+ if "release_velocity" in note:
205
+ kwargs["release_velocity"] = float(note["release_velocity"])
206
+ spec = Live.Clip.MidiNoteSpecification(**kwargs)
200
207
  note_specs.append(spec)
201
208
  clip.add_new_notes(tuple(note_specs))
202
209
  finally:
@@ -703,4 +710,4 @@ def toggle_cue_point(song, params):
703
710
  def back_to_arranger(song, params):
704
711
  """Switch playback from session clips back to the arrangement timeline."""
705
712
  song.back_to_arranger = True
706
- return {"back_to_arranger": song.back_to_arranger}
713
+ return {"back_to_arranger": True}
@@ -263,6 +263,7 @@ def load_browser_item(song, params):
263
263
  return None
264
264
 
265
265
  for category in categories:
266
+ _iterations[0] = 0 # Reset counter per category to avoid premature cutoff
266
267
  found = find_by_uri(category, uri)
267
268
  if found is not None:
268
269
  song.view.selected_track = track
@@ -100,6 +100,7 @@ def set_clip_automation(song, params):
100
100
  elif parameter_type == "send":
101
101
  if send_index is None:
102
102
  raise ValueError("send_index required for send automation")
103
+ send_index = int(send_index)
103
104
  sends = list(track.mixer_device.sends)
104
105
  if send_index < 0 or send_index >= len(sends):
105
106
  raise IndexError("send_index %d out of range" % send_index)
@@ -107,6 +108,8 @@ def set_clip_automation(song, params):
107
108
  elif parameter_type == "device":
108
109
  if device_index is None or parameter_index is None:
109
110
  raise ValueError("device_index and parameter_index required")
111
+ device_index = int(device_index)
112
+ parameter_index = int(parameter_index)
110
113
  devices = list(track.devices)
111
114
  if device_index < 0 or device_index >= len(devices):
112
115
  raise IndexError("device_index %d out of range" % device_index)
@@ -180,6 +183,7 @@ def clear_clip_automation(song, params):
180
183
  send_index = params.get("send_index")
181
184
  if send_index is None:
182
185
  raise ValueError("send_index required for send automation")
186
+ send_index = int(send_index)
183
187
  sends = list(track.mixer_device.sends)
184
188
  if send_index < 0 or send_index >= len(sends):
185
189
  raise IndexError("send_index %d out of range" % send_index)
@@ -189,6 +193,8 @@ def clear_clip_automation(song, params):
189
193
  parameter_index = params.get("parameter_index")
190
194
  if device_index is None or parameter_index is None:
191
195
  raise ValueError("device_index and parameter_index required")
196
+ device_index = int(device_index)
197
+ parameter_index = int(parameter_index)
192
198
  devices = list(track.devices)
193
199
  if device_index < 0 or device_index >= len(devices):
194
200
  raise IndexError("device_index %d out of range" % device_index)
@@ -72,6 +72,8 @@ def delete_clip(song, params):
72
72
  track_index = int(params["track_index"])
73
73
  clip_index = int(params["clip_index"])
74
74
  clip_slot = get_clip_slot(song, track_index, clip_index)
75
+ if not clip_slot.has_clip:
76
+ raise ValueError("No clip in slot %d on track %d" % (clip_index, track_index))
75
77
  clip_slot.delete_clip()
76
78
  return {"track_index": track_index, "clip_index": clip_index, "deleted": True}
77
79
 
@@ -270,6 +270,7 @@ def load_device_by_uri(song, params):
270
270
  return None
271
271
 
272
272
  for category in categories:
273
+ _iterations[0] = 0 # Reset counter per category to avoid premature cutoff
273
274
  found = find_by_uri(category, uri)
274
275
  if found is not None:
275
276
  song.view.selected_track = track
@@ -54,8 +54,8 @@ def get_track(song, track_index):
54
54
  ri = abs(track_index) - 1
55
55
  if ri >= len(return_tracks):
56
56
  raise IndexError(
57
- "Return track index %d out of range (0..%d)"
58
- % (ri, len(return_tracks) - 1)
57
+ "Return track index %d out of range — %d return tracks available"
58
+ % (ri, len(return_tracks))
59
59
  )
60
60
  return return_tracks[ri]
61
61
  if track_index >= len(tracks):