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.
Files changed (35) hide show
  1. package/CHANGELOG.md +245 -0
  2. package/README.md +7 -7
  3. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  4. package/m4l_device/livepilot_bridge.js +1 -1
  5. package/mcp_server/__init__.py +1 -1
  6. package/mcp_server/m4l_bridge.py +488 -13
  7. package/mcp_server/runtime/execution_router.py +7 -0
  8. package/mcp_server/runtime/mcp_dispatch.py +32 -0
  9. package/mcp_server/runtime/remote_commands.py +54 -0
  10. package/mcp_server/sample_engine/slice_classifier.py +169 -0
  11. package/mcp_server/server.py +11 -3
  12. package/mcp_server/tools/analyzer.py +187 -7
  13. package/mcp_server/tools/clips.py +65 -0
  14. package/mcp_server/tools/devices.py +517 -5
  15. package/mcp_server/tools/diagnostics.py +42 -0
  16. package/mcp_server/tools/follow_actions.py +202 -0
  17. package/mcp_server/tools/grooves.py +142 -0
  18. package/mcp_server/tools/miditool.py +280 -0
  19. package/mcp_server/tools/scales.py +126 -0
  20. package/mcp_server/tools/take_lanes.py +135 -0
  21. package/mcp_server/tools/tracks.py +46 -3
  22. package/mcp_server/tools/transport.py +62 -1
  23. package/package.json +2 -2
  24. package/remote_script/LivePilot/__init__.py +8 -4
  25. package/remote_script/LivePilot/clips.py +62 -0
  26. package/remote_script/LivePilot/devices.py +444 -0
  27. package/remote_script/LivePilot/diagnostics.py +52 -1
  28. package/remote_script/LivePilot/follow_actions.py +235 -0
  29. package/remote_script/LivePilot/grooves.py +185 -0
  30. package/remote_script/LivePilot/scales.py +138 -0
  31. package/remote_script/LivePilot/take_lanes.py +175 -0
  32. package/remote_script/LivePilot/tracks.py +59 -1
  33. package/remote_script/LivePilot/transport.py +90 -1
  34. package/remote_script/LivePilot/version_detect.py +9 -0
  35. package/server.json +3 -3
@@ -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.index(b'\x00')
175
- address = data[:null_pos].decode('ascii')
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.index(b'\x00', offset)
185
- type_tags = data[offset + 1:tag_null].decode('ascii')
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.index(b'\x00', offset)
205
- val = data[offset:s_null].decode('ascii')
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__(self, cache: SpectralCache, receiver: Optional[SpectralReceiver] = None):
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
- if self._cmd_lock is None:
459
- self._cmd_lock = asyncio.Lock()
460
- async with self._cmd_lock:
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
  }