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.
- package/.claude-plugin/marketplace.json +3 -3
- package/AGENTS.md +2 -2
- package/CHANGELOG.md +52 -1
- package/README.md +2 -2
- package/livepilot/.Codex-plugin/plugin.json +2 -2
- package/livepilot/.claude-plugin/plugin.json +2 -2
- package/livepilot/agents/livepilot-producer/AGENT.md +15 -1
- package/livepilot/commands/arrange.md +19 -0
- package/livepilot/commands/evaluate.md +39 -0
- package/livepilot/commands/mix.md +9 -4
- package/livepilot/commands/perform.md +30 -0
- package/livepilot/commands/sounddesign.md +9 -4
- package/livepilot/skills/livepilot-arrangement/SKILL.md +137 -0
- package/livepilot/skills/livepilot-composition-engine/SKILL.md +107 -0
- package/livepilot/skills/livepilot-composition-engine/references/form-patterns.md +97 -0
- package/livepilot/skills/livepilot-composition-engine/references/transition-archetypes.md +102 -0
- package/livepilot/skills/livepilot-core/SKILL.md +69 -449
- package/livepilot/skills/livepilot-core/references/overview.md +2 -2
- package/livepilot/skills/livepilot-devices/SKILL.md +134 -0
- package/livepilot/skills/livepilot-evaluation/SKILL.md +152 -0
- package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +118 -0
- package/livepilot/skills/livepilot-evaluation/references/evaluation-contracts.md +121 -0
- package/livepilot/skills/livepilot-evaluation/references/memory-promotion.md +110 -0
- package/livepilot/skills/livepilot-mix-engine/SKILL.md +123 -0
- package/livepilot/skills/livepilot-mix-engine/references/mix-critics.md +143 -0
- package/livepilot/skills/livepilot-mix-engine/references/mix-moves.md +105 -0
- package/livepilot/skills/livepilot-mixing/SKILL.md +155 -0
- package/livepilot/skills/livepilot-notes/SKILL.md +129 -0
- package/livepilot/skills/livepilot-performance-engine/SKILL.md +122 -0
- package/livepilot/skills/livepilot-performance-engine/references/performance-safety.md +98 -0
- package/livepilot/skills/livepilot-release/SKILL.md +10 -10
- package/livepilot/skills/livepilot-sound-design-engine/SKILL.md +123 -0
- package/livepilot/skills/livepilot-sound-design-engine/references/patch-model.md +119 -0
- package/livepilot/skills/livepilot-sound-design-engine/references/sound-design-critics.md +118 -0
- package/m4l_device/livepilot_bridge.js +1 -1
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/connection.py +29 -22
- package/mcp_server/evaluation/tools.py +1 -1
- package/mcp_server/m4l_bridge.py +7 -4
- package/mcp_server/mix_engine/tools.py +1 -1
- package/mcp_server/performance_engine/tools.py +1 -1
- package/mcp_server/reference_engine/tools.py +1 -1
- package/mcp_server/sound_design/tools.py +1 -1
- package/mcp_server/tools/analyzer.py +4 -3
- package/mcp_server/tools/tracks.py +3 -3
- package/mcp_server/translation_engine/tools.py +1 -1
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +1 -1
- package/remote_script/LivePilot/arrangement.py +9 -2
- package/remote_script/LivePilot/browser.py +1 -0
- package/remote_script/LivePilot/clip_automation.py +6 -0
- package/remote_script/LivePilot/clips.py +2 -0
- package/remote_script/LivePilot/devices.py +1 -0
- package/remote_script/LivePilot/utils.py +2 -2
package/mcp_server/connection.py
CHANGED
|
@@ -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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
)
|
|
261
|
-
|
|
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
|
-
)
|
|
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
|
-
|
|
272
|
+
err = AbletonConnectionError(
|
|
268
273
|
f"Socket error reading response: {exc}"
|
|
269
|
-
)
|
|
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
|
|
9
|
+
from fastmcp import Context
|
|
10
10
|
|
|
11
11
|
from ..server import mcp
|
|
12
12
|
from ..tools._evaluation_contracts import EvaluationRequest, EvaluationResult
|
package/mcp_server/m4l_bridge.py
CHANGED
|
@@ -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
|
-
"""
|
|
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
|
-
|
|
407
|
-
|
|
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.
|
|
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:
|
|
@@ -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
|
-
|
|
638
|
-
|
|
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
|
-
#
|
|
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=
|
|
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=
|
|
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,
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "livepilot",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.18",
|
|
4
4
|
"mcpName": "io.github.dreamrec/livepilot",
|
|
5
|
-
"description": "Agentic production system for Ableton Live 12 — 236 tools,
|
|
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.
|
|
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
|
-
|
|
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":
|
|
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
|
|
58
|
-
% (ri, len(return_tracks)
|
|
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):
|