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.
@@ -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,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 (Claude Code with deferred tools) send all parameter
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
  #