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.
@@ -1,2 +1,2 @@
1
1
  """LivePilot MCP Server — bridges MCP protocol to Ableton Live."""
2
- __version__ = "1.0.0"
2
+ __version__ = "1.5.0"
@@ -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
- resp = self._send_raw({"type": "ping"})
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
- # Ensure we have a connection
115
- if not self.is_connected():
116
- self.connect()
119
+ with self._lock:
120
+ # Ensure we have a connection
121
+ if not self.is_connected():
122
+ self.connect()
117
123
 
118
- command: dict = {"type": command_type}
119
- if params:
120
- command["params"] = params
124
+ command: dict = {"type": command_type}
125
+ if params:
126
+ command["params"] = params
121
127
 
122
- try:
123
- response = self._send_raw(command)
124
- except (OSError, AbletonConnectionError):
125
- # Retry once with a fresh connection
126
- self.disconnect()
127
- self.connect()
128
- response = self._send_raw(command)
129
-
130
- # Log the command and response
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()
@@ -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 for all tools."""
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 {"ableton": ableton}
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 (Claude Code with deferred tools) send all parameter
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
  #