livepilot 1.10.9 → 1.12.2
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 +245 -0
- package/README.md +7 -7
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/livepilot_bridge.js +1 -1
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/m4l_bridge.py +488 -13
- package/mcp_server/runtime/execution_router.py +7 -0
- package/mcp_server/runtime/mcp_dispatch.py +32 -0
- package/mcp_server/runtime/remote_commands.py +54 -0
- package/mcp_server/sample_engine/slice_classifier.py +169 -0
- package/mcp_server/server.py +11 -3
- package/mcp_server/tools/analyzer.py +187 -7
- package/mcp_server/tools/clips.py +65 -0
- package/mcp_server/tools/devices.py +517 -5
- package/mcp_server/tools/diagnostics.py +42 -0
- package/mcp_server/tools/follow_actions.py +202 -0
- package/mcp_server/tools/grooves.py +142 -0
- package/mcp_server/tools/miditool.py +280 -0
- package/mcp_server/tools/scales.py +126 -0
- package/mcp_server/tools/take_lanes.py +135 -0
- package/mcp_server/tools/tracks.py +46 -3
- package/mcp_server/tools/transport.py +62 -1
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +8 -4
- package/remote_script/LivePilot/clips.py +62 -0
- package/remote_script/LivePilot/devices.py +444 -0
- package/remote_script/LivePilot/diagnostics.py +52 -1
- package/remote_script/LivePilot/follow_actions.py +235 -0
- package/remote_script/LivePilot/grooves.py +185 -0
- package/remote_script/LivePilot/scales.py +138 -0
- package/remote_script/LivePilot/take_lanes.py +175 -0
- package/remote_script/LivePilot/tracks.py +59 -1
- package/remote_script/LivePilot/transport.py +90 -1
- package/remote_script/LivePilot/version_detect.py +9 -0
- package/server.json +3 -3
package/mcp_server/m4l_bridge.py
CHANGED
|
@@ -25,11 +25,12 @@ from __future__ import annotations
|
|
|
25
25
|
import asyncio
|
|
26
26
|
import base64
|
|
27
27
|
import json
|
|
28
|
+
import random
|
|
28
29
|
import socket
|
|
29
30
|
import struct
|
|
30
31
|
import threading
|
|
31
32
|
import time
|
|
32
|
-
from typing import Any, Optional
|
|
33
|
+
from typing import Any, Callable, Optional
|
|
33
34
|
|
|
34
35
|
|
|
35
36
|
def _encode_string_arg(value: str) -> str:
|
|
@@ -135,6 +136,336 @@ class SpectralCache:
|
|
|
135
136
|
return result
|
|
136
137
|
|
|
137
138
|
|
|
139
|
+
# ─── MIDI Tool bridge (Live 12.0+ MIDI Generators / Transformations) ─────────
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class MidiToolCache:
|
|
143
|
+
"""Thread-safe cache for MIDI Tool requests from live.miditool.in.
|
|
144
|
+
|
|
145
|
+
Mirrors SpectralCache semantics: entries age out after max_age seconds
|
|
146
|
+
(default 5). Distinct cache so MIDI-Tool state doesn't get mixed in with
|
|
147
|
+
analyzer spectrum/RMS/pitch data that tools read by key.
|
|
148
|
+
|
|
149
|
+
The last-received request payload carries ``{context, notes}`` where
|
|
150
|
+
``context`` is ``{grid, selection, scale, seed, tuning}`` emitted by
|
|
151
|
+
Live's ``live.miditool.in`` right outlet, and ``notes`` is the note
|
|
152
|
+
list from the left outlet.
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
def __init__(self, max_age: float = 5.0):
|
|
156
|
+
self._lock = threading.Lock()
|
|
157
|
+
self._max_age = max_age
|
|
158
|
+
self._context: Optional[dict] = None
|
|
159
|
+
self._notes: Optional[list] = None
|
|
160
|
+
self._last_seen = 0.0
|
|
161
|
+
self._connected = False
|
|
162
|
+
self._target_tool: Optional[str] = None
|
|
163
|
+
self._target_params: dict = {}
|
|
164
|
+
|
|
165
|
+
def set_request(self, context: dict, notes: list) -> None:
|
|
166
|
+
with self._lock:
|
|
167
|
+
self._context = context
|
|
168
|
+
self._notes = notes
|
|
169
|
+
self._last_seen = time.monotonic()
|
|
170
|
+
self._connected = True
|
|
171
|
+
|
|
172
|
+
def mark_ready(self) -> None:
|
|
173
|
+
"""Called when the bridge announces itself (``/miditool/ready``)."""
|
|
174
|
+
with self._lock:
|
|
175
|
+
self._last_seen = time.monotonic()
|
|
176
|
+
self._connected = True
|
|
177
|
+
|
|
178
|
+
def get_last_context(self) -> Optional[dict]:
|
|
179
|
+
with self._lock:
|
|
180
|
+
if self._context is None:
|
|
181
|
+
return None
|
|
182
|
+
age = time.monotonic() - self._last_seen
|
|
183
|
+
if age > self._max_age:
|
|
184
|
+
return None
|
|
185
|
+
return dict(self._context)
|
|
186
|
+
|
|
187
|
+
def get_last_notes(self) -> Optional[list]:
|
|
188
|
+
with self._lock:
|
|
189
|
+
if self._notes is None:
|
|
190
|
+
return None
|
|
191
|
+
age = time.monotonic() - self._last_seen
|
|
192
|
+
if age > self._max_age:
|
|
193
|
+
return None
|
|
194
|
+
return list(self._notes)
|
|
195
|
+
|
|
196
|
+
@property
|
|
197
|
+
def is_connected(self) -> bool:
|
|
198
|
+
with self._lock:
|
|
199
|
+
if not self._connected:
|
|
200
|
+
return False
|
|
201
|
+
return (time.monotonic() - self._last_seen) < self._max_age
|
|
202
|
+
|
|
203
|
+
def set_target(self, tool_name: Optional[str], params: Optional[dict]) -> None:
|
|
204
|
+
with self._lock:
|
|
205
|
+
self._target_tool = tool_name
|
|
206
|
+
self._target_params = dict(params or {})
|
|
207
|
+
|
|
208
|
+
def get_target(self) -> tuple[Optional[str], dict]:
|
|
209
|
+
with self._lock:
|
|
210
|
+
return self._target_tool, dict(self._target_params)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# ─── Built-in generator implementations ──────────────────────────────────────
|
|
214
|
+
#
|
|
215
|
+
# These run in-process when a MIDI Tool request arrives. Each takes
|
|
216
|
+
# ``(notes, context, params)`` and returns a new notes list. Notes are dicts
|
|
217
|
+
# matching Live's live.miditool.in format:
|
|
218
|
+
# {pitch, start_time, duration, velocity, mute, probability,
|
|
219
|
+
# velocity_deviation, release_velocity, note_id}
|
|
220
|
+
#
|
|
221
|
+
# Generators should preserve unknown fields so Live's richer note data round-
|
|
222
|
+
# trips unchanged through the bridge.
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _bjorklund(pulses: int, steps: int) -> list[int]:
|
|
226
|
+
"""Classic Bjorklund equidistribution. Returns [0, 1] pattern of length steps."""
|
|
227
|
+
if steps <= 0:
|
|
228
|
+
return []
|
|
229
|
+
if pulses <= 0:
|
|
230
|
+
return [0] * steps
|
|
231
|
+
if pulses >= steps:
|
|
232
|
+
return [1] * steps
|
|
233
|
+
|
|
234
|
+
counts: list[int] = []
|
|
235
|
+
remainders: list[int] = [pulses]
|
|
236
|
+
divisor = steps - pulses
|
|
237
|
+
level = 0
|
|
238
|
+
while True:
|
|
239
|
+
counts.append(divisor // remainders[level])
|
|
240
|
+
remainders.append(divisor % remainders[level])
|
|
241
|
+
divisor = remainders[level]
|
|
242
|
+
level += 1
|
|
243
|
+
if remainders[level] <= 1:
|
|
244
|
+
break
|
|
245
|
+
counts.append(divisor)
|
|
246
|
+
|
|
247
|
+
def build(lv: int) -> list[int]:
|
|
248
|
+
if lv == -1:
|
|
249
|
+
return [0]
|
|
250
|
+
if lv == -2:
|
|
251
|
+
return [1]
|
|
252
|
+
out: list[int] = []
|
|
253
|
+
for _ in range(counts[lv]):
|
|
254
|
+
out += build(lv - 1)
|
|
255
|
+
if remainders[lv] != 0:
|
|
256
|
+
out += build(lv - 2)
|
|
257
|
+
return out
|
|
258
|
+
|
|
259
|
+
pattern = build(level)
|
|
260
|
+
return pattern[:steps] if len(pattern) >= steps else pattern + [0] * (steps - len(pattern))
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _euclidean_rhythm(notes: list, context: dict, params: dict) -> list:
|
|
264
|
+
"""Replace the selection with a Bjorklund-distributed rhythm.
|
|
265
|
+
|
|
266
|
+
params:
|
|
267
|
+
steps (int, required) — subdivisions of the selection
|
|
268
|
+
pulses (int, required) — hits to distribute
|
|
269
|
+
rotation (int, optional) — pattern rotation, default 0
|
|
270
|
+
note (int, optional) — MIDI pitch, default 36 (C1)
|
|
271
|
+
velocity (float, optional) — 0.0..1.0, default 0.8
|
|
272
|
+
|
|
273
|
+
Selection span comes from context["selection"] if present, otherwise
|
|
274
|
+
falls back to min/max of input note start_times.
|
|
275
|
+
"""
|
|
276
|
+
steps = int(params.get("steps", 16))
|
|
277
|
+
pulses = int(params.get("pulses", 4))
|
|
278
|
+
rotation = int(params.get("rotation", 0))
|
|
279
|
+
pitch = int(params.get("note", 36))
|
|
280
|
+
velocity = float(params.get("velocity", 0.8))
|
|
281
|
+
|
|
282
|
+
if steps <= 0:
|
|
283
|
+
return list(notes)
|
|
284
|
+
pulses = max(0, min(pulses, steps))
|
|
285
|
+
|
|
286
|
+
pattern = _bjorklund(pulses, steps)
|
|
287
|
+
if rotation:
|
|
288
|
+
rotation = rotation % steps
|
|
289
|
+
pattern = pattern[rotation:] + pattern[:rotation]
|
|
290
|
+
|
|
291
|
+
selection = context.get("selection") or {}
|
|
292
|
+
try:
|
|
293
|
+
start = float(selection.get("start", 0.0))
|
|
294
|
+
end = float(selection.get("end", start + float(steps)))
|
|
295
|
+
except (TypeError, ValueError):
|
|
296
|
+
start = 0.0
|
|
297
|
+
end = float(steps)
|
|
298
|
+
if end <= start:
|
|
299
|
+
# Fall back to input note span, else a bar at current tempo.
|
|
300
|
+
if notes:
|
|
301
|
+
start = min(float(n.get("start_time", 0.0)) for n in notes)
|
|
302
|
+
end = max(
|
|
303
|
+
float(n.get("start_time", 0.0)) + float(n.get("duration", 0.25))
|
|
304
|
+
for n in notes
|
|
305
|
+
)
|
|
306
|
+
if end <= start:
|
|
307
|
+
end = start + 4.0
|
|
308
|
+
|
|
309
|
+
step_dur = (end - start) / float(steps)
|
|
310
|
+
velocity = max(0.0, min(1.0, velocity))
|
|
311
|
+
|
|
312
|
+
out: list[dict] = []
|
|
313
|
+
for i, hit in enumerate(pattern):
|
|
314
|
+
if not hit:
|
|
315
|
+
continue
|
|
316
|
+
out.append({
|
|
317
|
+
"pitch": max(0, min(127, pitch)),
|
|
318
|
+
"start_time": round(start + i * step_dur, 6),
|
|
319
|
+
"duration": round(step_dur, 6),
|
|
320
|
+
"velocity": velocity,
|
|
321
|
+
"mute": False,
|
|
322
|
+
"probability": 1.0,
|
|
323
|
+
"velocity_deviation": 0.0,
|
|
324
|
+
"release_velocity": 0.5,
|
|
325
|
+
"note_id": -1,
|
|
326
|
+
})
|
|
327
|
+
return out
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _tintinnabuli(notes: list, context: dict, params: dict) -> list:
|
|
331
|
+
"""Add an Arvo Pärt-style companion voice on the tonic triad.
|
|
332
|
+
|
|
333
|
+
For each input note, emit the input plus a companion note locked to
|
|
334
|
+
the nearest member of the supplied triad (above / below / alternating).
|
|
335
|
+
|
|
336
|
+
params:
|
|
337
|
+
t_voice_triad (list[int], optional) — semitone offsets from scale root.
|
|
338
|
+
default [0, 4, 7] (major triad)
|
|
339
|
+
direction (str, optional) — "above" | "below" | "alternate".
|
|
340
|
+
default "above"
|
|
341
|
+
"""
|
|
342
|
+
triad = params.get("t_voice_triad")
|
|
343
|
+
if not triad:
|
|
344
|
+
triad = [0, 4, 7]
|
|
345
|
+
triad = [int(t) % 12 for t in triad]
|
|
346
|
+
direction = str(params.get("direction", "above")).lower()
|
|
347
|
+
|
|
348
|
+
scale = context.get("scale") or {}
|
|
349
|
+
try:
|
|
350
|
+
scale_root = int(scale.get("root", 0)) % 12
|
|
351
|
+
except (TypeError, ValueError):
|
|
352
|
+
scale_root = 0
|
|
353
|
+
|
|
354
|
+
out: list[dict] = []
|
|
355
|
+
for i, n in enumerate(notes):
|
|
356
|
+
out.append(dict(n)) # preserve the melody
|
|
357
|
+
try:
|
|
358
|
+
pitch = int(n.get("pitch", 60))
|
|
359
|
+
except (TypeError, ValueError):
|
|
360
|
+
continue
|
|
361
|
+
|
|
362
|
+
# Build absolute candidate triad pitches within ±1 octave of the note.
|
|
363
|
+
candidates = []
|
|
364
|
+
for octave in range(-2, 3):
|
|
365
|
+
for t in triad:
|
|
366
|
+
cand = ((pitch // 12) + octave) * 12 + ((scale_root + t) % 12)
|
|
367
|
+
if 0 <= cand <= 127 and cand != pitch:
|
|
368
|
+
candidates.append(cand)
|
|
369
|
+
if not candidates:
|
|
370
|
+
continue
|
|
371
|
+
|
|
372
|
+
if direction == "below":
|
|
373
|
+
below = [c for c in candidates if c < pitch]
|
|
374
|
+
companion = max(below) if below else min(candidates)
|
|
375
|
+
elif direction == "alternate":
|
|
376
|
+
if i % 2 == 0:
|
|
377
|
+
above = [c for c in candidates if c > pitch]
|
|
378
|
+
companion = min(above) if above else max(candidates)
|
|
379
|
+
else:
|
|
380
|
+
below = [c for c in candidates if c < pitch]
|
|
381
|
+
companion = max(below) if below else min(candidates)
|
|
382
|
+
else: # "above" (default)
|
|
383
|
+
above = [c for c in candidates if c > pitch]
|
|
384
|
+
companion = min(above) if above else max(candidates)
|
|
385
|
+
|
|
386
|
+
comp = dict(n)
|
|
387
|
+
comp["pitch"] = max(0, min(127, companion))
|
|
388
|
+
comp["note_id"] = -1 # new note
|
|
389
|
+
out.append(comp)
|
|
390
|
+
return out
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _humanize(notes: list, context: dict, params: dict) -> list:
|
|
394
|
+
"""Humanize timing + velocity of existing notes.
|
|
395
|
+
|
|
396
|
+
params:
|
|
397
|
+
timing_spread (float, optional) — beats, default 0.05
|
|
398
|
+
velocity_spread (float, optional) — 0.0..1.0, default 0.1
|
|
399
|
+
|
|
400
|
+
Uses context["seed"] for deterministic jitter when present, otherwise
|
|
401
|
+
system randomness.
|
|
402
|
+
"""
|
|
403
|
+
timing = float(params.get("timing_spread", 0.05))
|
|
404
|
+
vel_spread = float(params.get("velocity_spread", 0.1))
|
|
405
|
+
|
|
406
|
+
seed = context.get("seed")
|
|
407
|
+
rng = random.Random()
|
|
408
|
+
if seed is not None:
|
|
409
|
+
try:
|
|
410
|
+
rng.seed(int(seed))
|
|
411
|
+
except (TypeError, ValueError):
|
|
412
|
+
rng.seed(str(seed))
|
|
413
|
+
|
|
414
|
+
out: list[dict] = []
|
|
415
|
+
for n in notes:
|
|
416
|
+
m = dict(n)
|
|
417
|
+
try:
|
|
418
|
+
start = float(m.get("start_time", 0.0))
|
|
419
|
+
except (TypeError, ValueError):
|
|
420
|
+
start = 0.0
|
|
421
|
+
try:
|
|
422
|
+
vel = float(m.get("velocity", 0.8))
|
|
423
|
+
except (TypeError, ValueError):
|
|
424
|
+
vel = 0.8
|
|
425
|
+
m["start_time"] = round(max(0.0, start + rng.uniform(-timing, timing)), 6)
|
|
426
|
+
m["velocity"] = round(max(0.0, min(1.0, vel + rng.uniform(-vel_spread, vel_spread))), 4)
|
|
427
|
+
out.append(m)
|
|
428
|
+
return out
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
# Registry: name -> callable(notes, context, params) -> list[note_dict]
|
|
432
|
+
_GENERATORS: dict[str, Callable[[list, dict, dict], list]] = {
|
|
433
|
+
"euclidean_rhythm": _euclidean_rhythm,
|
|
434
|
+
"tintinnabuli": _tintinnabuli,
|
|
435
|
+
"humanize": _humanize,
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
# Metadata for list_miditool_generators.
|
|
440
|
+
GENERATOR_METADATA: dict[str, dict] = {
|
|
441
|
+
"euclidean_rhythm": {
|
|
442
|
+
"description": "Bjorklund-distributed rhythm over the selection",
|
|
443
|
+
"required_params": ["steps", "pulses"],
|
|
444
|
+
"optional_params": ["rotation", "note", "velocity"],
|
|
445
|
+
},
|
|
446
|
+
"tintinnabuli": {
|
|
447
|
+
"description": "Pärt-style voice with tintinnabuli companion",
|
|
448
|
+
"required_params": [],
|
|
449
|
+
"optional_params": ["t_voice_triad", "direction"],
|
|
450
|
+
},
|
|
451
|
+
"humanize": {
|
|
452
|
+
"description": "Humanize timing + velocity of existing notes",
|
|
453
|
+
"required_params": [],
|
|
454
|
+
"optional_params": ["timing_spread", "velocity_spread"],
|
|
455
|
+
},
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def available_generators() -> list[str]:
|
|
460
|
+
"""List registered generator names."""
|
|
461
|
+
return sorted(_GENERATORS.keys())
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def run_generator(tool_name: str, notes: list, context: dict, params: dict) -> list:
|
|
465
|
+
"""Invoke a registered generator by name. Raises KeyError if unknown."""
|
|
466
|
+
fn = _GENERATORS[tool_name]
|
|
467
|
+
return fn(list(notes or []), dict(context or {}), dict(params or {}))
|
|
468
|
+
|
|
138
469
|
|
|
139
470
|
class SpectralReceiver(asyncio.DatagramProtocol):
|
|
140
471
|
"""Receives OSC-formatted UDP packets from the M4L device.
|
|
@@ -150,13 +481,24 @@ class SpectralReceiver(asyncio.DatagramProtocol):
|
|
|
150
481
|
|
|
151
482
|
BAND_NAMES = ["sub", "low", "low_mid", "mid", "high_mid", "high", "presence", "air"]
|
|
152
483
|
|
|
153
|
-
def __init__(self, cache: SpectralCache):
|
|
484
|
+
def __init__(self, cache: SpectralCache, miditool_cache: Optional["MidiToolCache"] = None):
|
|
154
485
|
self.cache = cache
|
|
486
|
+
self.miditool_cache = miditool_cache
|
|
155
487
|
self._chunks: dict[str, dict] = {} # Reassembly buffer for chunked responses
|
|
156
488
|
self._chunk_times: dict[str, float] = {} # Monotonic timestamp per chunk sequence
|
|
157
489
|
self._chunk_id = 0
|
|
158
490
|
self._response_callback: Optional[asyncio.Future] = None
|
|
159
491
|
self._capture_future: Optional[asyncio.Future] = None
|
|
492
|
+
self._miditool_handler: Optional[Callable[[str, dict, list], None]] = None
|
|
493
|
+
|
|
494
|
+
def set_miditool_handler(self, handler: Optional[Callable[[str, dict, list], None]]) -> None:
|
|
495
|
+
"""Register a callback invoked on each /miditool/request packet.
|
|
496
|
+
|
|
497
|
+
Signature: ``handler(request_id, context, notes) -> None``.
|
|
498
|
+
The handler is expected to run the configured generator and push
|
|
499
|
+
a ``/miditool/response`` OSC back via M4LBridge.send_miditool_response.
|
|
500
|
+
"""
|
|
501
|
+
self._miditool_handler = handler
|
|
160
502
|
|
|
161
503
|
def connection_made(self, transport: asyncio.DatagramTransport) -> None:
|
|
162
504
|
self.transport = transport
|
|
@@ -169,10 +511,19 @@ class SpectralReceiver(asyncio.DatagramProtocol):
|
|
|
169
511
|
print(f"LivePilot: malformed OSC packet from {addr}: {exc}", file=sys.stderr)
|
|
170
512
|
|
|
171
513
|
def _parse_osc(self, data: bytes) -> None:
|
|
172
|
-
"""Parse a minimal OSC message (address + typed args).
|
|
514
|
+
"""Parse a minimal OSC message (address + typed args).
|
|
515
|
+
|
|
516
|
+
BUG-audit-C2: earlier versions used `data.index(b'\\x00')` directly,
|
|
517
|
+
which raises ValueError on malformed/truncated packets. When UDP
|
|
518
|
+
port 9880 gets traffic from a non-OSC source (port collision,
|
|
519
|
+
corrupt sender), every packet was logging a noisy stack trace.
|
|
520
|
+
Now we bounds-check null terminators and drop bad packets silently.
|
|
521
|
+
"""
|
|
173
522
|
# OSC address is a null-terminated string, padded to 4-byte boundary
|
|
174
|
-
null_pos = data.
|
|
175
|
-
|
|
523
|
+
null_pos = data.find(b'\x00')
|
|
524
|
+
if null_pos < 0:
|
|
525
|
+
return # No null byte at all — not an OSC packet, drop silently
|
|
526
|
+
address = data[:null_pos].decode('ascii', errors='replace')
|
|
176
527
|
|
|
177
528
|
# Skip to type tag string (after address padding)
|
|
178
529
|
offset = null_pos + 1
|
|
@@ -181,8 +532,10 @@ class SpectralReceiver(asyncio.DatagramProtocol):
|
|
|
181
532
|
|
|
182
533
|
# Type tag string starts with ','
|
|
183
534
|
if offset < len(data) and data[offset] == ord(','):
|
|
184
|
-
tag_null = data.
|
|
185
|
-
|
|
535
|
+
tag_null = data.find(b'\x00', offset)
|
|
536
|
+
if tag_null < 0:
|
|
537
|
+
return # Tag string missing terminator — drop silently
|
|
538
|
+
type_tags = data[offset + 1:tag_null].decode('ascii', errors='replace')
|
|
186
539
|
offset = tag_null + 1
|
|
187
540
|
while offset % 4 != 0:
|
|
188
541
|
offset += 1
|
|
@@ -193,16 +546,22 @@ class SpectralReceiver(asyncio.DatagramProtocol):
|
|
|
193
546
|
args = []
|
|
194
547
|
for tag in type_tags:
|
|
195
548
|
if tag == 'f':
|
|
549
|
+
if offset + 4 > len(data):
|
|
550
|
+
return # Truncated float arg
|
|
196
551
|
val = struct.unpack('>f', data[offset:offset + 4])[0]
|
|
197
552
|
args.append(val)
|
|
198
553
|
offset += 4
|
|
199
554
|
elif tag == 'i':
|
|
555
|
+
if offset + 4 > len(data):
|
|
556
|
+
return # Truncated int arg
|
|
200
557
|
val = struct.unpack('>i', data[offset:offset + 4])[0]
|
|
201
558
|
args.append(val)
|
|
202
559
|
offset += 4
|
|
203
560
|
elif tag == 's':
|
|
204
|
-
s_null = data.
|
|
205
|
-
|
|
561
|
+
s_null = data.find(b'\x00', offset)
|
|
562
|
+
if s_null < 0:
|
|
563
|
+
return # String arg missing terminator — drop silently
|
|
564
|
+
val = data[offset:s_null].decode('ascii', errors='replace')
|
|
206
565
|
args.append(val)
|
|
207
566
|
offset = s_null + 1
|
|
208
567
|
while offset % 4 != 0:
|
|
@@ -277,6 +636,13 @@ class SpectralReceiver(asyncio.DatagramProtocol):
|
|
|
277
636
|
elif address == "/response_chunk" and len(args) >= 3:
|
|
278
637
|
self._handle_chunk(int(args[0]), int(args[1]), str(args[2]))
|
|
279
638
|
|
|
639
|
+
elif address == "/miditool/request" and len(args) >= 1:
|
|
640
|
+
self._handle_miditool_request(str(args[0]))
|
|
641
|
+
|
|
642
|
+
elif address == "/miditool/ready":
|
|
643
|
+
if self.miditool_cache is not None:
|
|
644
|
+
self.miditool_cache.mark_ready()
|
|
645
|
+
|
|
280
646
|
def _handle_response(self, encoded: str) -> None:
|
|
281
647
|
"""Decode a single-packet base64 response.
|
|
282
648
|
|
|
@@ -302,6 +668,37 @@ class SpectralReceiver(asyncio.DatagramProtocol):
|
|
|
302
668
|
import sys
|
|
303
669
|
print(f"LivePilot: failed to decode bridge response: {exc}", file=sys.stderr)
|
|
304
670
|
|
|
671
|
+
def _handle_miditool_request(self, encoded: str) -> None:
|
|
672
|
+
"""Decode a /miditool/request packet and dispatch to the configured handler.
|
|
673
|
+
|
|
674
|
+
Payload: base64(JSON({request_id, context, notes})).
|
|
675
|
+
Packet arrives on the UDP receive thread; the registered handler is
|
|
676
|
+
expected to schedule work on an event loop rather than block here.
|
|
677
|
+
"""
|
|
678
|
+
try:
|
|
679
|
+
padded = encoded + "=" * (-len(encoded) % 4)
|
|
680
|
+
decoded = base64.urlsafe_b64decode(padded).decode("utf-8")
|
|
681
|
+
payload = json.loads(decoded)
|
|
682
|
+
except Exception as exc:
|
|
683
|
+
import sys
|
|
684
|
+
print(f"LivePilot: failed to decode miditool request: {exc}", file=sys.stderr)
|
|
685
|
+
return
|
|
686
|
+
|
|
687
|
+
request_id = str(payload.get("request_id", ""))
|
|
688
|
+
context = payload.get("context") or {}
|
|
689
|
+
notes = payload.get("notes") or []
|
|
690
|
+
|
|
691
|
+
if self.miditool_cache is not None:
|
|
692
|
+
self.miditool_cache.set_request(context, notes)
|
|
693
|
+
|
|
694
|
+
handler = self._miditool_handler
|
|
695
|
+
if handler is not None:
|
|
696
|
+
try:
|
|
697
|
+
handler(request_id, context, notes)
|
|
698
|
+
except Exception as exc:
|
|
699
|
+
import sys
|
|
700
|
+
print(f"LivePilot: miditool handler error: {exc}", file=sys.stderr)
|
|
701
|
+
|
|
305
702
|
def _handle_chunk(self, index: int, total: int, encoded: str) -> None:
|
|
306
703
|
"""Reassemble chunked responses.
|
|
307
704
|
|
|
@@ -387,12 +784,86 @@ class M4LBridge:
|
|
|
387
784
|
and are handled by the SpectralReceiver.
|
|
388
785
|
"""
|
|
389
786
|
|
|
390
|
-
def __init__(
|
|
787
|
+
def __init__(
|
|
788
|
+
self,
|
|
789
|
+
cache: SpectralCache,
|
|
790
|
+
receiver: Optional[SpectralReceiver] = None,
|
|
791
|
+
miditool_cache: Optional[MidiToolCache] = None,
|
|
792
|
+
):
|
|
391
793
|
self.cache = cache
|
|
392
794
|
self.receiver = receiver
|
|
795
|
+
self.miditool_cache = miditool_cache
|
|
393
796
|
self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
394
797
|
self._m4l_addr = ("127.0.0.1", 9881)
|
|
395
798
|
self._cmd_lock: Optional[asyncio.Lock] = None
|
|
799
|
+
# BUG-audit-C1: send_capture uses _capture_future, which is
|
|
800
|
+
# independent of _response_callback used by send_command.
|
|
801
|
+
# They must be serialised independently — sharing a lock made
|
|
802
|
+
# send_command block for the entire capture duration (up to 35s).
|
|
803
|
+
self._capture_lock: Optional[asyncio.Lock] = None
|
|
804
|
+
|
|
805
|
+
# Wire the miditool dispatch: receiver calls us on /miditool/request,
|
|
806
|
+
# we look up the configured generator and push the response back.
|
|
807
|
+
if self.receiver is not None:
|
|
808
|
+
self.receiver.set_miditool_handler(self._dispatch_miditool_request)
|
|
809
|
+
if self.receiver.miditool_cache is None and miditool_cache is not None:
|
|
810
|
+
self.receiver.miditool_cache = miditool_cache
|
|
811
|
+
|
|
812
|
+
def _dispatch_miditool_request(
|
|
813
|
+
self, request_id: str, context: dict, notes: list,
|
|
814
|
+
) -> None:
|
|
815
|
+
"""Handle a decoded /miditool/request: run the configured generator,
|
|
816
|
+
send /miditool/response back with {request_id, notes}.
|
|
817
|
+
|
|
818
|
+
Invoked from SpectralReceiver on the UDP receive thread. We do not
|
|
819
|
+
await anything here — generators are synchronous, pure Python.
|
|
820
|
+
If no target is configured, pass notes through unchanged (identity).
|
|
821
|
+
"""
|
|
822
|
+
if self.miditool_cache is None:
|
|
823
|
+
return
|
|
824
|
+
tool_name, params = self.miditool_cache.get_target()
|
|
825
|
+
|
|
826
|
+
try:
|
|
827
|
+
if tool_name and tool_name in _GENERATORS:
|
|
828
|
+
out_notes = run_generator(tool_name, notes, context, params)
|
|
829
|
+
else:
|
|
830
|
+
out_notes = list(notes)
|
|
831
|
+
except Exception as exc:
|
|
832
|
+
import sys
|
|
833
|
+
print(
|
|
834
|
+
f"LivePilot: miditool generator '{tool_name}' failed: {exc} — "
|
|
835
|
+
f"passing input unchanged.",
|
|
836
|
+
file=sys.stderr,
|
|
837
|
+
)
|
|
838
|
+
out_notes = list(notes)
|
|
839
|
+
|
|
840
|
+
try:
|
|
841
|
+
self.send_miditool_response(request_id, out_notes)
|
|
842
|
+
except Exception as exc:
|
|
843
|
+
import sys
|
|
844
|
+
print(f"LivePilot: failed to send miditool response: {exc}", file=sys.stderr)
|
|
845
|
+
|
|
846
|
+
def send_miditool_config(self, tool_name: Optional[str], params: Optional[dict]) -> None:
|
|
847
|
+
"""Push the currently-selected generator config to the JS bridge.
|
|
848
|
+
|
|
849
|
+
Sends ``miditool/config`` OSC with a JSON blob. The underlying
|
|
850
|
+
``_build_osc`` applies the standard ``b64:`` prefix encoding to the
|
|
851
|
+
string arg, so we just pass the raw JSON — single-encoded on the wire.
|
|
852
|
+
The JS side decodes via ``_decode_b64_arg`` like every other command.
|
|
853
|
+
"""
|
|
854
|
+
payload = {"tool_name": tool_name or "", "params": params or {}}
|
|
855
|
+
osc = self._build_osc("miditool/config", (json.dumps(payload),))
|
|
856
|
+
self._sock.sendto(osc, self._m4l_addr)
|
|
857
|
+
|
|
858
|
+
def send_miditool_response(self, request_id: str, notes: list) -> None:
|
|
859
|
+
"""Send transformed notes back to the JS bridge.
|
|
860
|
+
|
|
861
|
+
Packet: ``miditool/response`` <b64-encoded JSON({request_id, notes})>.
|
|
862
|
+
The JS side matches request_id and emits notes out live.miditool.out.
|
|
863
|
+
"""
|
|
864
|
+
payload = {"request_id": str(request_id or ""), "notes": list(notes or [])}
|
|
865
|
+
osc = self._build_osc("miditool/response", (json.dumps(payload),))
|
|
866
|
+
self._sock.sendto(osc, self._m4l_addr)
|
|
396
867
|
|
|
397
868
|
async def send_command(self, command: str, *args: Any, timeout: float = 5.0) -> dict:
|
|
398
869
|
"""Send an OSC command to the M4L device and wait for the response."""
|
|
@@ -455,9 +926,13 @@ class M4LBridge:
|
|
|
455
926
|
"for a bind failure on port 9880."
|
|
456
927
|
}
|
|
457
928
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
929
|
+
# BUG-audit-C1: use a dedicated _capture_lock (not _cmd_lock) so
|
|
930
|
+
# concurrent send_command calls are not blocked for the full
|
|
931
|
+
# recording duration. _capture_future and _response_callback are
|
|
932
|
+
# independent receiver state and can be driven concurrently.
|
|
933
|
+
if self._capture_lock is None:
|
|
934
|
+
self._capture_lock = asyncio.Lock()
|
|
935
|
+
async with self._capture_lock:
|
|
461
936
|
# Cancel any stale capture future before creating a new one
|
|
462
937
|
if self.receiver._capture_future and not self.receiver._capture_future.done():
|
|
463
938
|
self.receiver._capture_future.cancel()
|
|
@@ -47,6 +47,13 @@ MCP_TOOLS: frozenset[str] = frozenset({
|
|
|
47
47
|
"generate_m4l_effect",
|
|
48
48
|
"install_m4l_device",
|
|
49
49
|
"list_genexpr_templates",
|
|
50
|
+
# MIDI Tool bridge (v1.12.0+) — these run entirely in the MCP server:
|
|
51
|
+
# config dispatch via OSC to m4l_bridge, cache state reads, filesystem
|
|
52
|
+
# copy. No TCP remote command, no bridge TCP round-trip.
|
|
53
|
+
"install_miditool_device",
|
|
54
|
+
"set_miditool_target",
|
|
55
|
+
"get_miditool_context",
|
|
56
|
+
"list_miditool_generators",
|
|
50
57
|
})
|
|
51
58
|
|
|
52
59
|
|
|
@@ -95,6 +95,33 @@ async def _list_genexpr_templates(params: dict, ctx: Any = None) -> dict:
|
|
|
95
95
|
return await _call(list_genexpr_templates, ctx, params)
|
|
96
96
|
|
|
97
97
|
|
|
98
|
+
# ── MIDI Tool bridge (v1.12.0+) ───────────────────────────────────────────
|
|
99
|
+
#
|
|
100
|
+
# These four run entirely in-process: install_miditool_device copies .amxd
|
|
101
|
+
# files, set_miditool_target writes to MidiToolCache + OSC-sends config,
|
|
102
|
+
# get_miditool_context reads the cache, list_miditool_generators reads the
|
|
103
|
+
# GENERATOR_METADATA dict. None of them need TCP or bridge round-trips.
|
|
104
|
+
|
|
105
|
+
async def _install_miditool_device(params: dict, ctx: Any = None) -> dict:
|
|
106
|
+
from ..tools.miditool import install_miditool_device
|
|
107
|
+
return await _call(install_miditool_device, ctx, params)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def _set_miditool_target(params: dict, ctx: Any = None) -> dict:
|
|
111
|
+
from ..tools.miditool import set_miditool_target
|
|
112
|
+
return await _call(set_miditool_target, ctx, params)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
async def _get_miditool_context(params: dict, ctx: Any = None) -> dict:
|
|
116
|
+
from ..tools.miditool import get_miditool_context
|
|
117
|
+
return await _call(get_miditool_context, ctx, params)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
async def _list_miditool_generators(params: dict, ctx: Any = None) -> dict:
|
|
121
|
+
from ..tools.miditool import list_miditool_generators
|
|
122
|
+
return await _call(list_miditool_generators, ctx, params)
|
|
123
|
+
|
|
124
|
+
|
|
98
125
|
def build_mcp_dispatch_registry() -> dict[str, Callable]:
|
|
99
126
|
"""Return the canonical registry of MCP-only tools for plan execution.
|
|
100
127
|
|
|
@@ -115,4 +142,9 @@ def build_mcp_dispatch_registry() -> dict[str, Callable]:
|
|
|
115
142
|
"generate_m4l_effect": _generate_m4l_effect,
|
|
116
143
|
"install_m4l_device": _install_m4l_device,
|
|
117
144
|
"list_genexpr_templates": _list_genexpr_templates,
|
|
145
|
+
# v1.12.0 MIDI Tool bridge
|
|
146
|
+
"install_miditool_device": _install_miditool_device,
|
|
147
|
+
"set_miditool_target": _set_miditool_target,
|
|
148
|
+
"get_miditool_context": _get_miditool_context,
|
|
149
|
+
"list_miditool_generators": _list_miditool_generators,
|
|
118
150
|
}
|