livepilot 1.4.5 → 1.6.1
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 +187 -144
- 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/curves.py +741 -0
- package/mcp_server/m4l_bridge.py +285 -0
- package/mcp_server/server.py +29 -3
- package/mcp_server/tools/analyzer.py +508 -0
- package/mcp_server/tools/automation.py +431 -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/agents/livepilot-producer/AGENT.md +32 -2
- package/plugin/plugin.json +2 -2
- package/plugin/skills/livepilot-core/SKILL.md +76 -11
- package/plugin/skills/livepilot-core/references/automation-atlas.md +272 -0
- package/plugin/skills/livepilot-core/references/overview.md +68 -5
- package/plugin/skills/livepilot-release/SKILL.md +101 -0
- package/remote_script/LivePilot/__init__.py +3 -2
- package/remote_script/LivePilot/clip_automation.py +220 -0
- package/remote_script/LivePilot/mixing.py +90 -1
- package/remote_script/LivePilot/server.py +3 -0
|
@@ -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,14 @@ 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
|
|
58
|
+
from .tools import automation # noqa: F401, E402
|
|
33
59
|
|
|
34
60
|
|
|
35
61
|
# ---------------------------------------------------------------------------
|
|
36
62
|
# Schema coercion patch — accept strings for numeric parameters
|
|
37
63
|
# ---------------------------------------------------------------------------
|
|
38
|
-
# Some MCP clients (
|
|
64
|
+
# Some MCP clients (with deferred tools) send all parameter
|
|
39
65
|
# values as strings. Their client-side Zod validators reject "0" against
|
|
40
66
|
# {"type": "integer"} before the request even reaches our server.
|
|
41
67
|
#
|