livepilot 1.4.4 → 1.5.0
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/CHANGELOG.md +151 -136
- package/README.md +136 -61
- package/m4l_device/BUILD_GUIDE.md +161 -0
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/LivePilot_Analyzer.maxpat +680 -0
- package/m4l_device/livepilot_bridge.js +942 -0
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/connection.py +22 -16
- package/mcp_server/m4l_bridge.py +285 -0
- package/mcp_server/server.py +28 -3
- package/mcp_server/tools/analyzer.py +508 -0
- package/mcp_server/tools/clips.py +16 -12
- package/mcp_server/tools/devices.py +2 -2
- package/mcp_server/tools/mixing.py +50 -14
- package/mcp_server/tools/tracks.py +2 -2
- package/package.json +2 -3
- package/plugin/plugin.json +2 -2
- package/plugin/skills/livepilot-core/SKILL.md +52 -11
- package/plugin/skills/livepilot-core/references/overview.md +51 -5
- package/remote_script/LivePilot/__init__.py +1 -1
- package/remote_script/LivePilot/mixing.py +90 -1
- package/.mcp.json +0 -9
- package/plugin/.mcp.json +0 -8
- package/plugin/skills/livepilot-core/references/device-atlas/plugins-synths.md +0 -2012
- package/plugin/skills/livepilot-core/references/device-atlas/presets-by-vibe.md +0 -727
- package/plugin/skills/livepilot-core/references/device-atlas/samples-and-irs.md +0 -598
- package/plugin/skills/livepilot-core/references/device-atlas/synths-m4l.md +0 -730
- package/plugin/skills/livepilot-core/references/device-atlas/utility-and-workflow.md +0 -843
package/mcp_server/__init__.py
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""LivePilot MCP Server — bridges MCP protocol to Ableton Live."""
|
|
2
|
-
__version__ = "1.
|
|
2
|
+
__version__ = "1.5.0"
|
package/mcp_server/connection.py
CHANGED
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import json
|
|
6
6
|
import os
|
|
7
7
|
import socket
|
|
8
|
+
import threading
|
|
8
9
|
import time
|
|
9
10
|
import uuid
|
|
10
11
|
from collections import deque
|
|
@@ -54,6 +55,7 @@ class AbletonConnection:
|
|
|
54
55
|
self._socket: Optional[socket.socket] = None
|
|
55
56
|
self._recv_buf: bytes = b""
|
|
56
57
|
self._command_log: deque[dict] = deque(maxlen=self.MAX_LOG_ENTRIES)
|
|
58
|
+
self._lock = threading.Lock() # Serialize all TCP send/receive cycles
|
|
57
59
|
|
|
58
60
|
# ------------------------------------------------------------------
|
|
59
61
|
# Connection lifecycle
|
|
@@ -101,7 +103,8 @@ class AbletonConnection:
|
|
|
101
103
|
def ping(self) -> bool:
|
|
102
104
|
"""Send a ping and return True if a pong is received."""
|
|
103
105
|
try:
|
|
104
|
-
|
|
106
|
+
with self._lock:
|
|
107
|
+
resp = self._send_raw({"type": "ping"})
|
|
105
108
|
return resp.get("result", {}).get("pong") is True
|
|
106
109
|
except Exception:
|
|
107
110
|
return False
|
|
@@ -109,25 +112,28 @@ class AbletonConnection:
|
|
|
109
112
|
def send_command(self, command_type: str, params: Optional[dict] = None) -> dict:
|
|
110
113
|
"""Send a command to Ableton and return the result dict.
|
|
111
114
|
|
|
115
|
+
Thread-safe: a lock serializes all TCP send/receive cycles to
|
|
116
|
+
prevent socket corruption when multiple MCP tools fire concurrently.
|
|
112
117
|
Retries once on socket errors with a fresh connection.
|
|
113
118
|
"""
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
self.
|
|
119
|
+
with self._lock:
|
|
120
|
+
# Ensure we have a connection
|
|
121
|
+
if not self.is_connected():
|
|
122
|
+
self.connect()
|
|
117
123
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
124
|
+
command: dict = {"type": command_type}
|
|
125
|
+
if params:
|
|
126
|
+
command["params"] = params
|
|
121
127
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
# Log the
|
|
128
|
+
try:
|
|
129
|
+
response = self._send_raw(command)
|
|
130
|
+
except (OSError, AbletonConnectionError):
|
|
131
|
+
# Retry once with a fresh connection
|
|
132
|
+
self.disconnect()
|
|
133
|
+
self.connect()
|
|
134
|
+
response = self._send_raw(command)
|
|
135
|
+
|
|
136
|
+
# Log and error handling outside the lock (no socket access needed)
|
|
131
137
|
log_entry = {
|
|
132
138
|
"command": command_type,
|
|
133
139
|
"params": params,
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LivePilot M4L Bridge — UDP communication with the LivePilot Analyzer device.
|
|
3
|
+
|
|
4
|
+
Receives spectral data (spectrum bands, RMS, peak, pitch) via UDP/OSC from
|
|
5
|
+
the M4L device on the master track. Sends commands back for deep LOM access.
|
|
6
|
+
|
|
7
|
+
Architecture:
|
|
8
|
+
M4L → UDP:9880 → SpectralReceiver → SpectralCache → MCP tools
|
|
9
|
+
MCP tools → M4LBridge → UDP:9881 → M4L device
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import base64
|
|
16
|
+
import json
|
|
17
|
+
import socket
|
|
18
|
+
import struct
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
from typing import Any, Optional
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SpectralCache:
|
|
25
|
+
"""Thread-safe cache for incoming spectral data from M4L.
|
|
26
|
+
|
|
27
|
+
Data goes stale after max_age seconds (default 5).
|
|
28
|
+
When the M4L device is removed, data stops arriving and
|
|
29
|
+
get() returns None — graceful degradation.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, max_age: float = 5.0):
|
|
33
|
+
self._lock = threading.Lock()
|
|
34
|
+
self._data: dict[str, dict] = {}
|
|
35
|
+
self._max_age = max_age
|
|
36
|
+
self._connected = False
|
|
37
|
+
self._last_seen = 0.0
|
|
38
|
+
|
|
39
|
+
def update(self, key: str, value: Any) -> None:
|
|
40
|
+
with self._lock:
|
|
41
|
+
self._data[key] = {
|
|
42
|
+
"value": value,
|
|
43
|
+
"time": time.monotonic(),
|
|
44
|
+
}
|
|
45
|
+
self._last_seen = time.monotonic()
|
|
46
|
+
self._connected = True
|
|
47
|
+
|
|
48
|
+
def get(self, key: str) -> Optional[dict]:
|
|
49
|
+
with self._lock:
|
|
50
|
+
entry = self._data.get(key)
|
|
51
|
+
if not entry:
|
|
52
|
+
return None
|
|
53
|
+
age = time.monotonic() - entry["time"]
|
|
54
|
+
if age > self._max_age:
|
|
55
|
+
return None
|
|
56
|
+
return {
|
|
57
|
+
"value": entry["value"],
|
|
58
|
+
"age_ms": int(age * 1000),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def is_connected(self) -> bool:
|
|
63
|
+
with self._lock:
|
|
64
|
+
if not self._connected:
|
|
65
|
+
return False
|
|
66
|
+
return (time.monotonic() - self._last_seen) < self._max_age
|
|
67
|
+
|
|
68
|
+
def get_all(self) -> dict:
|
|
69
|
+
"""Get all cached data that hasn't gone stale."""
|
|
70
|
+
with self._lock:
|
|
71
|
+
now = time.monotonic()
|
|
72
|
+
result = {}
|
|
73
|
+
for key, entry in self._data.items():
|
|
74
|
+
age = now - entry["time"]
|
|
75
|
+
if age <= self._max_age:
|
|
76
|
+
result[key] = {
|
|
77
|
+
"value": entry["value"],
|
|
78
|
+
"age_ms": int(age * 1000),
|
|
79
|
+
}
|
|
80
|
+
return result
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class SpectralReceiver(asyncio.DatagramProtocol):
|
|
84
|
+
"""Receives OSC-formatted UDP packets from the M4L device.
|
|
85
|
+
|
|
86
|
+
OSC messages:
|
|
87
|
+
/spectrum f f f f f f f f — 8-band spectrum
|
|
88
|
+
/peak f — peak level
|
|
89
|
+
/rms f — RMS level
|
|
90
|
+
/pitch f f — MIDI note, amplitude
|
|
91
|
+
/response s — base64-encoded JSON (single packet)
|
|
92
|
+
/response_chunk i i s — chunked response (index, total, data)
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
BAND_NAMES = ["sub", "low", "low_mid", "mid", "high_mid", "high", "presence", "air"]
|
|
96
|
+
|
|
97
|
+
def __init__(self, cache: SpectralCache):
|
|
98
|
+
self.cache = cache
|
|
99
|
+
self._chunks: dict[str, dict] = {} # Reassembly buffer for chunked responses
|
|
100
|
+
self._response_callback: Optional[asyncio.Future] = None
|
|
101
|
+
|
|
102
|
+
def connection_made(self, transport: asyncio.DatagramTransport) -> None:
|
|
103
|
+
self.transport = transport
|
|
104
|
+
|
|
105
|
+
def datagram_received(self, data: bytes, addr: tuple) -> None:
|
|
106
|
+
try:
|
|
107
|
+
self._parse_osc(data)
|
|
108
|
+
except Exception:
|
|
109
|
+
pass # Malformed packet, ignore
|
|
110
|
+
|
|
111
|
+
def _parse_osc(self, data: bytes) -> None:
|
|
112
|
+
"""Parse a minimal OSC message (address + typed args)."""
|
|
113
|
+
# OSC address is a null-terminated string, padded to 4-byte boundary
|
|
114
|
+
null_pos = data.index(b'\x00')
|
|
115
|
+
address = data[:null_pos].decode('ascii')
|
|
116
|
+
|
|
117
|
+
# Skip to type tag string (after address padding)
|
|
118
|
+
offset = null_pos + 1
|
|
119
|
+
while offset % 4 != 0:
|
|
120
|
+
offset += 1
|
|
121
|
+
|
|
122
|
+
# Type tag string starts with ','
|
|
123
|
+
if offset < len(data) and data[offset] == ord(','):
|
|
124
|
+
tag_null = data.index(b'\x00', offset)
|
|
125
|
+
type_tags = data[offset + 1:tag_null].decode('ascii')
|
|
126
|
+
offset = tag_null + 1
|
|
127
|
+
while offset % 4 != 0:
|
|
128
|
+
offset += 1
|
|
129
|
+
else:
|
|
130
|
+
type_tags = ""
|
|
131
|
+
|
|
132
|
+
# Parse arguments based on type tags
|
|
133
|
+
args = []
|
|
134
|
+
for tag in type_tags:
|
|
135
|
+
if tag == 'f':
|
|
136
|
+
val = struct.unpack('>f', data[offset:offset + 4])[0]
|
|
137
|
+
args.append(val)
|
|
138
|
+
offset += 4
|
|
139
|
+
elif tag == 'i':
|
|
140
|
+
val = struct.unpack('>i', data[offset:offset + 4])[0]
|
|
141
|
+
args.append(val)
|
|
142
|
+
offset += 4
|
|
143
|
+
elif tag == 's':
|
|
144
|
+
s_null = data.index(b'\x00', offset)
|
|
145
|
+
val = data[offset:s_null].decode('ascii')
|
|
146
|
+
args.append(val)
|
|
147
|
+
offset = s_null + 1
|
|
148
|
+
while offset % 4 != 0:
|
|
149
|
+
offset += 1
|
|
150
|
+
|
|
151
|
+
self._handle_message(address, args)
|
|
152
|
+
|
|
153
|
+
def _handle_message(self, address: str, args: list) -> None:
|
|
154
|
+
if address == "/spectrum" and len(args) >= 8:
|
|
155
|
+
bands = {}
|
|
156
|
+
for i, name in enumerate(self.BAND_NAMES):
|
|
157
|
+
if i < len(args):
|
|
158
|
+
bands[name] = round(float(args[i]), 4)
|
|
159
|
+
self.cache.update("spectrum", bands)
|
|
160
|
+
|
|
161
|
+
elif address == "/peak" and len(args) >= 1:
|
|
162
|
+
self.cache.update("peak", round(float(args[0]), 4))
|
|
163
|
+
|
|
164
|
+
elif address == "/rms" and len(args) >= 1:
|
|
165
|
+
self.cache.update("rms", round(float(args[0]), 4))
|
|
166
|
+
|
|
167
|
+
elif address == "/pitch" and len(args) >= 2:
|
|
168
|
+
self.cache.update("pitch", {
|
|
169
|
+
"midi_note": round(float(args[0]), 2),
|
|
170
|
+
"amplitude": round(float(args[1]), 4),
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
elif address == "/key" and len(args) >= 2:
|
|
174
|
+
self.cache.update("key", {
|
|
175
|
+
"key": str(args[0]),
|
|
176
|
+
"scale": str(args[1]),
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
elif address == "/response" and len(args) >= 1:
|
|
180
|
+
self._handle_response(str(args[0]))
|
|
181
|
+
|
|
182
|
+
elif address == "/response_chunk" and len(args) >= 3:
|
|
183
|
+
self._handle_chunk(int(args[0]), int(args[1]), str(args[2]))
|
|
184
|
+
|
|
185
|
+
def _handle_response(self, encoded: str) -> None:
|
|
186
|
+
"""Decode a single-packet base64 response."""
|
|
187
|
+
try:
|
|
188
|
+
# URL-safe base64 decode (- and _ instead of + and /)
|
|
189
|
+
padded = encoded + "=" * (-len(encoded) % 4)
|
|
190
|
+
decoded = base64.urlsafe_b64decode(padded).decode('utf-8')
|
|
191
|
+
result = json.loads(decoded)
|
|
192
|
+
if self._response_callback and not self._response_callback.done():
|
|
193
|
+
self._response_callback.set_result(result)
|
|
194
|
+
except Exception:
|
|
195
|
+
pass
|
|
196
|
+
|
|
197
|
+
def _handle_chunk(self, index: int, total: int, encoded: str) -> None:
|
|
198
|
+
"""Reassemble chunked responses."""
|
|
199
|
+
key = str(total) # Simple key — assumes one response at a time
|
|
200
|
+
if key not in self._chunks:
|
|
201
|
+
self._chunks[key] = {"parts": {}, "total": total}
|
|
202
|
+
|
|
203
|
+
self._chunks[key]["parts"][index] = encoded
|
|
204
|
+
|
|
205
|
+
if len(self._chunks[key]["parts"]) == total:
|
|
206
|
+
# All chunks received — reassemble
|
|
207
|
+
full = ""
|
|
208
|
+
for i in range(total):
|
|
209
|
+
full += self._chunks[key]["parts"][i]
|
|
210
|
+
del self._chunks[key]
|
|
211
|
+
self._handle_response(full)
|
|
212
|
+
|
|
213
|
+
def set_response_future(self, future: asyncio.Future) -> None:
|
|
214
|
+
"""Set a future to be resolved with the next response."""
|
|
215
|
+
self._response_callback = future
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class M4LBridge:
|
|
219
|
+
"""Sends commands to the M4L device and waits for responses.
|
|
220
|
+
|
|
221
|
+
Commands are sent via UDP to port 9881. Responses come back on port 9880
|
|
222
|
+
and are handled by the SpectralReceiver.
|
|
223
|
+
"""
|
|
224
|
+
|
|
225
|
+
def __init__(self, cache: SpectralCache, receiver: Optional[SpectralReceiver] = None):
|
|
226
|
+
self.cache = cache
|
|
227
|
+
self.receiver = receiver
|
|
228
|
+
self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
229
|
+
self._m4l_addr = ("127.0.0.1", 9881)
|
|
230
|
+
|
|
231
|
+
async def send_command(self, command: str, *args: Any, timeout: float = 5.0) -> dict:
|
|
232
|
+
"""Send an OSC command to the M4L device and wait for the response."""
|
|
233
|
+
if not self.cache.is_connected:
|
|
234
|
+
return {"error": "LivePilot Analyzer not connected. Drop it on the master track."}
|
|
235
|
+
|
|
236
|
+
# Create a future for the response
|
|
237
|
+
loop = asyncio.get_event_loop()
|
|
238
|
+
future = loop.create_future()
|
|
239
|
+
if self.receiver:
|
|
240
|
+
self.receiver.set_response_future(future)
|
|
241
|
+
|
|
242
|
+
# Build and send OSC message (no leading / — Max udpreceive
|
|
243
|
+
# passes messagename with / intact to JS, breaking dispatch)
|
|
244
|
+
osc_data = self._build_osc(command, args)
|
|
245
|
+
self._sock.sendto(osc_data, self._m4l_addr)
|
|
246
|
+
|
|
247
|
+
# Wait for response with timeout
|
|
248
|
+
try:
|
|
249
|
+
result = await asyncio.wait_for(future, timeout=timeout)
|
|
250
|
+
return result
|
|
251
|
+
except asyncio.TimeoutError:
|
|
252
|
+
return {"error": "M4L bridge timeout — device may be busy or removed"}
|
|
253
|
+
|
|
254
|
+
def _build_osc(self, address: str, args: tuple) -> bytes:
|
|
255
|
+
"""Build a minimal OSC message."""
|
|
256
|
+
# Address string (null-terminated, padded to 4 bytes)
|
|
257
|
+
addr_bytes = address.encode('ascii') + b'\x00'
|
|
258
|
+
while len(addr_bytes) % 4 != 0:
|
|
259
|
+
addr_bytes += b'\x00'
|
|
260
|
+
|
|
261
|
+
# Type tag string
|
|
262
|
+
type_tags = ","
|
|
263
|
+
arg_data = b""
|
|
264
|
+
for arg in args:
|
|
265
|
+
if isinstance(arg, int):
|
|
266
|
+
type_tags += "i"
|
|
267
|
+
arg_data += struct.pack('>i', arg)
|
|
268
|
+
elif isinstance(arg, float):
|
|
269
|
+
type_tags += "f"
|
|
270
|
+
arg_data += struct.pack('>f', arg)
|
|
271
|
+
elif isinstance(arg, str):
|
|
272
|
+
type_tags += "s"
|
|
273
|
+
s_bytes = arg.encode('ascii') + b'\x00'
|
|
274
|
+
while len(s_bytes) % 4 != 0:
|
|
275
|
+
s_bytes += b'\x00'
|
|
276
|
+
arg_data += s_bytes
|
|
277
|
+
|
|
278
|
+
tag_bytes = type_tags.encode('ascii') + b'\x00'
|
|
279
|
+
while len(tag_bytes) % 4 != 0:
|
|
280
|
+
tag_bytes += b'\x00'
|
|
281
|
+
|
|
282
|
+
return addr_bytes + tag_bytes + arg_data
|
|
283
|
+
|
|
284
|
+
def close(self) -> None:
|
|
285
|
+
self._sock.close()
|
package/mcp_server/server.py
CHANGED
|
@@ -1,19 +1,43 @@
|
|
|
1
1
|
"""FastMCP entry point for LivePilot."""
|
|
2
2
|
|
|
3
3
|
from contextlib import asynccontextmanager
|
|
4
|
+
import asyncio
|
|
4
5
|
|
|
5
6
|
from fastmcp import FastMCP, Context # noqa: F401
|
|
6
7
|
|
|
7
8
|
from .connection import AbletonConnection
|
|
9
|
+
from .m4l_bridge import SpectralCache, SpectralReceiver, M4LBridge
|
|
8
10
|
|
|
9
11
|
|
|
10
12
|
@asynccontextmanager
|
|
11
13
|
async def lifespan(server):
|
|
12
|
-
"""Create and yield the shared AbletonConnection
|
|
14
|
+
"""Create and yield the shared AbletonConnection + M4L bridge."""
|
|
13
15
|
ableton = AbletonConnection()
|
|
16
|
+
spectral = SpectralCache()
|
|
17
|
+
receiver = SpectralReceiver(spectral)
|
|
18
|
+
m4l = M4LBridge(spectral, receiver)
|
|
19
|
+
|
|
20
|
+
# Start UDP listener for incoming M4L spectral data (port 9880)
|
|
21
|
+
loop = asyncio.get_event_loop()
|
|
22
|
+
try:
|
|
23
|
+
transport, _ = await loop.create_datagram_endpoint(
|
|
24
|
+
lambda: receiver,
|
|
25
|
+
local_addr=('127.0.0.1', 9880),
|
|
26
|
+
)
|
|
27
|
+
except OSError:
|
|
28
|
+
# Port in use — M4L bridge won't work but core tools still function
|
|
29
|
+
transport = None
|
|
30
|
+
|
|
14
31
|
try:
|
|
15
|
-
yield {
|
|
32
|
+
yield {
|
|
33
|
+
"ableton": ableton,
|
|
34
|
+
"spectral": spectral,
|
|
35
|
+
"m4l": m4l,
|
|
36
|
+
}
|
|
16
37
|
finally:
|
|
38
|
+
if transport:
|
|
39
|
+
transport.close()
|
|
40
|
+
m4l.close()
|
|
17
41
|
ableton.disconnect()
|
|
18
42
|
|
|
19
43
|
|
|
@@ -30,12 +54,13 @@ from .tools import mixing # noqa: F401, E402
|
|
|
30
54
|
from .tools import browser # noqa: F401, E402
|
|
31
55
|
from .tools import arrangement # noqa: F401, E402
|
|
32
56
|
from .tools import memory # noqa: F401, E402
|
|
57
|
+
from .tools import analyzer # noqa: F401, E402
|
|
33
58
|
|
|
34
59
|
|
|
35
60
|
# ---------------------------------------------------------------------------
|
|
36
61
|
# Schema coercion patch — accept strings for numeric parameters
|
|
37
62
|
# ---------------------------------------------------------------------------
|
|
38
|
-
# Some MCP clients (
|
|
63
|
+
# Some MCP clients (with deferred tools) send all parameter
|
|
39
64
|
# values as strings. Their client-side Zod validators reject "0" against
|
|
40
65
|
# {"type": "integer"} before the request even reaches our server.
|
|
41
66
|
#
|