livepilot 1.10.8 → 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 (49) hide show
  1. package/CHANGELOG.md +373 -0
  2. package/README.md +16 -16
  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/evaluation/fabric.py +62 -1
  7. package/mcp_server/m4l_bridge.py +503 -18
  8. package/mcp_server/project_brain/automation_graph.py +23 -1
  9. package/mcp_server/project_brain/builder.py +2 -0
  10. package/mcp_server/project_brain/models.py +20 -1
  11. package/mcp_server/project_brain/tools.py +10 -3
  12. package/mcp_server/runtime/execution_router.py +7 -0
  13. package/mcp_server/runtime/mcp_dispatch.py +32 -0
  14. package/mcp_server/runtime/remote_commands.py +54 -0
  15. package/mcp_server/sample_engine/slice_classifier.py +169 -0
  16. package/mcp_server/semantic_moves/tools.py +139 -31
  17. package/mcp_server/server.py +151 -17
  18. package/mcp_server/session_continuity/models.py +13 -0
  19. package/mcp_server/session_continuity/tools.py +2 -0
  20. package/mcp_server/session_continuity/tracker.py +93 -0
  21. package/mcp_server/tools/_analyzer_engine/__init__.py +39 -0
  22. package/mcp_server/tools/_analyzer_engine/context.py +103 -0
  23. package/mcp_server/tools/_analyzer_engine/flucoma.py +23 -0
  24. package/mcp_server/tools/_analyzer_engine/sample.py +122 -0
  25. package/mcp_server/tools/_motif_engine.py +19 -4
  26. package/mcp_server/tools/analyzer.py +204 -180
  27. package/mcp_server/tools/clips.py +304 -1
  28. package/mcp_server/tools/devices.py +517 -5
  29. package/mcp_server/tools/diagnostics.py +42 -0
  30. package/mcp_server/tools/follow_actions.py +202 -0
  31. package/mcp_server/tools/grooves.py +142 -0
  32. package/mcp_server/tools/miditool.py +280 -0
  33. package/mcp_server/tools/scales.py +126 -0
  34. package/mcp_server/tools/take_lanes.py +135 -0
  35. package/mcp_server/tools/tracks.py +46 -3
  36. package/mcp_server/tools/transport.py +120 -4
  37. package/package.json +2 -2
  38. package/remote_script/LivePilot/__init__.py +15 -4
  39. package/remote_script/LivePilot/clips.py +62 -0
  40. package/remote_script/LivePilot/devices.py +444 -0
  41. package/remote_script/LivePilot/diagnostics.py +52 -1
  42. package/remote_script/LivePilot/follow_actions.py +235 -0
  43. package/remote_script/LivePilot/grooves.py +185 -0
  44. package/remote_script/LivePilot/scales.py +138 -0
  45. package/remote_script/LivePilot/take_lanes.py +175 -0
  46. package/remote_script/LivePilot/tracks.py +59 -1
  47. package/remote_script/LivePilot/transport.py +90 -1
  48. package/remote_script/LivePilot/version_detect.py +9 -0
  49. package/server.json +3 -3
@@ -1,10 +1,13 @@
1
1
  """Clip MCP tools — info, create, delete, duplicate, fire, stop, properties, warp.
2
2
 
3
- 11 tools matching the Remote Script clips domain.
3
+ 11 tools matching the Remote Script clips domain, plus a key-consistency
4
+ diagnostic (BUG-D1) that cross-references filename-encoded keys against
5
+ the analyzer-detected session key.
4
6
  """
5
7
 
6
8
  from __future__ import annotations
7
9
 
10
+ import re
8
11
  from typing import Optional
9
12
 
10
13
  from fastmcp import Context
@@ -17,6 +20,72 @@ def _get_ableton(ctx: Context):
17
20
  return ctx.lifespan_context["ableton"]
18
21
 
19
22
 
23
+ # ── Key-token parsing (BUG-D1) ─────────────────────────────────────────
24
+ #
25
+ # Splice filenames encode the key as one of:
26
+ # _D#min _Dmin _Dm → minor
27
+ # _Dmaj _DMaj _D → major (trailing nothing or just "maj")
28
+ # _Eb _Ebmin _Dbm → accidentals accepted as # or b
29
+ #
30
+ # We accept any of those forms and emit a canonical (root, mode) tuple.
31
+
32
+ # Note → semitone offset from C (C=0, C#=1, D=2, ...)
33
+ _NOTE_TO_SEMI = {
34
+ "c": 0, "c#": 1, "db": 1, "d": 2, "d#": 3, "eb": 3, "e": 4, "fb": 4,
35
+ "e#": 5, "f": 5, "f#": 6, "gb": 6, "g": 7, "g#": 8, "ab": 8,
36
+ "a": 9, "a#": 10, "bb": 10, "b": 11, "cb": 11,
37
+ }
38
+
39
+ # Match the trailing key token in a filename stem (everything before the
40
+ # extension, underscore-delimited). We anchor to the end of the stem so
41
+ # an earlier "D" in the filename (e.g. "Dabrye_...") doesn't match.
42
+ _KEY_RE = re.compile(
43
+ r"(?P<root>[A-Ga-g][#b]?)(?P<mode>maj|min|m|Maj|Min)?$",
44
+ flags=re.IGNORECASE,
45
+ )
46
+
47
+
48
+ def _parse_key_from_filename(filename: str) -> Optional[dict]:
49
+ """Extract key info from a Splice-style filename.
50
+
51
+ Returns ``{"root": "D#", "mode": "minor", "semi": 3, "token": "D#min"}``
52
+ or ``None`` if no recognizable key token is present in the final
53
+ underscore-segment of the filename stem.
54
+ """
55
+ if not filename:
56
+ return None
57
+ stem = filename.rsplit(".", 1)[0]
58
+ last = stem.split("_")[-1]
59
+ match = _KEY_RE.fullmatch(last)
60
+ if not match:
61
+ return None
62
+ root_raw = match.group("root").lower()
63
+ mode_raw = (match.group("mode") or "").lower()
64
+ # Normalize the root to lookup form. Canonicalize B# → C, etc. (rare
65
+ # but possible in hand-named samples).
66
+ semi = _NOTE_TO_SEMI.get(root_raw)
67
+ if semi is None:
68
+ return None
69
+ # Without an explicit mode suffix, Splice convention defaults to major.
70
+ mode = "minor" if mode_raw in ("min", "m") else "major"
71
+ # Canonical display: capitalize root, preserve #/b.
72
+ root_display = root_raw[0].upper() + root_raw[1:]
73
+ return {
74
+ "root": root_display,
75
+ "mode": mode,
76
+ "semi": semi,
77
+ "token": last,
78
+ }
79
+
80
+
81
+ def _key_to_semi(root: str, mode: str = "major") -> Optional[int]:
82
+ """Convert a session-reported key like ``"D"`` + ``"minor"`` to 0..11 semis."""
83
+ if not root:
84
+ return None
85
+ semi = _NOTE_TO_SEMI.get(root.strip().lower())
86
+ return semi
87
+
88
+
20
89
  def _validate_track_index(track_index: int):
21
90
  """Validate track index. Must be >= 0 for regular tracks."""
22
91
  if track_index < 0:
@@ -265,3 +334,237 @@ def set_clip_warp_mode(
265
334
  if warping is not None:
266
335
  params["warping"] = warping
267
336
  return _get_ableton(ctx).send_command("set_clip_warp_mode", params)
337
+
338
+
339
+ @mcp.tool()
340
+ async def check_clip_key_consistency(
341
+ ctx: Context,
342
+ track_index: int,
343
+ clip_index: int,
344
+ ) -> dict:
345
+ """Cross-check a clip's filename-encoded key against the session key (BUG-D1).
346
+
347
+ Splice-style sample filenames encode the sample's key (e.g.
348
+ ``AU_THF2_128_vocal_..._D#min.wav``). This tool parses that token,
349
+ compares it to the analyzer-detected session key, and — when they
350
+ disagree — computes the semitone delta needed to realign, returning
351
+ the exact ``set_clip_pitch(coarse=...)`` call that would correct it.
352
+
353
+ Return shape::
354
+
355
+ {
356
+ "track_index": 6,
357
+ "clip_index": 0,
358
+ "filename_key": {"root": "D#", "mode": "minor", "token": "D#min"},
359
+ "session_key": {"root": "D", "mode": "minor"},
360
+ "status": "mismatch" | "match" | "unknown",
361
+ "semitone_delta": -1, # clip needs to shift DOWN 1
362
+ "recommended_fix": {
363
+ "tool": "set_clip_pitch",
364
+ "args": {"track_index": 6, "clip_index": 0, "coarse": -1}
365
+ },
366
+ "reason": "Clip is D#min, session is Dm — shift -1 semitone."
367
+ }
368
+
369
+ Returns ``status="unknown"`` (not an error) when:
370
+ - the clip is MIDI (no audio file path)
371
+ - the filename has no parseable key token
372
+ - the analyzer hasn't detected a session key yet
373
+
374
+ Requires the M4L bridge for both ``get_clip_file_path`` and
375
+ ``get_detected_key``. Degrades gracefully without it.
376
+ """
377
+ _validate_track_index(track_index)
378
+ _validate_clip_index(clip_index)
379
+
380
+ # 1) Resolve the clip's file path. Relies on the M4L bridge.
381
+ try:
382
+ from .analyzer import get_clip_file_path as _get_path
383
+ # get_clip_file_path is an @mcp.tool, but FastMCP decorators preserve
384
+ # the underlying function — we can call it directly for composition.
385
+ path_resp = await _get_path.fn(ctx, track_index, clip_index)
386
+ except Exception as exc:
387
+ return {
388
+ "track_index": track_index,
389
+ "clip_index": clip_index,
390
+ "status": "unknown",
391
+ "reason": f"Could not resolve clip file path: {exc}",
392
+ }
393
+ if not isinstance(path_resp, dict) or path_resp.get("error"):
394
+ return {
395
+ "track_index": track_index,
396
+ "clip_index": clip_index,
397
+ "status": "unknown",
398
+ "reason": path_resp.get("error", "No file path available (MIDI clip?)."),
399
+ }
400
+ file_path = path_resp.get("path") or path_resp.get("file_path") or ""
401
+
402
+ # 2) Parse key token from the filename.
403
+ import os
404
+ filename_key = _parse_key_from_filename(os.path.basename(file_path))
405
+ if filename_key is None:
406
+ return {
407
+ "track_index": track_index,
408
+ "clip_index": clip_index,
409
+ "file_path": file_path,
410
+ "status": "unknown",
411
+ "reason": "Filename has no recognizable key token.",
412
+ }
413
+
414
+ # 3) Query the session-detected key (needs the analyzer).
415
+ try:
416
+ from .analyzer import get_detected_key as _get_key
417
+ key_resp = await _get_key.fn(ctx)
418
+ except Exception as exc:
419
+ return {
420
+ "track_index": track_index,
421
+ "clip_index": clip_index,
422
+ "file_path": file_path,
423
+ "filename_key": filename_key,
424
+ "status": "unknown",
425
+ "reason": f"Analyzer unavailable: {exc}",
426
+ }
427
+ if not isinstance(key_resp, dict) or key_resp.get("error") or not key_resp.get("key"):
428
+ return {
429
+ "track_index": track_index,
430
+ "clip_index": clip_index,
431
+ "file_path": file_path,
432
+ "filename_key": filename_key,
433
+ "status": "unknown",
434
+ "reason": key_resp.get(
435
+ "error", "Session key not yet detected — play 4-8 bars."
436
+ ),
437
+ }
438
+ session_root = str(key_resp.get("key", ""))
439
+ session_mode = str(key_resp.get("scale", "major")).lower()
440
+ session_semi = _key_to_semi(session_root)
441
+
442
+ # 4) Classify + compute fix.
443
+ file_semi = filename_key["semi"]
444
+ if session_semi is None or file_semi is None:
445
+ return {
446
+ "track_index": track_index,
447
+ "clip_index": clip_index,
448
+ "file_path": file_path,
449
+ "filename_key": filename_key,
450
+ "session_key": {"root": session_root, "mode": session_mode},
451
+ "status": "unknown",
452
+ "reason": "Could not resolve semitone offsets for comparison.",
453
+ }
454
+
455
+ if filename_key["mode"] != session_mode:
456
+ mode_note = (
457
+ f" (clip is {filename_key['mode']}, session is {session_mode} — "
458
+ "mode mismatch is often OK for ambient/background use)"
459
+ )
460
+ else:
461
+ mode_note = ""
462
+
463
+ if file_semi == session_semi and filename_key["mode"] == session_mode:
464
+ return {
465
+ "track_index": track_index,
466
+ "clip_index": clip_index,
467
+ "file_path": file_path,
468
+ "filename_key": filename_key,
469
+ "session_key": {"root": session_root, "mode": session_mode},
470
+ "status": "match",
471
+ "semitone_delta": 0,
472
+ "recommended_fix": None,
473
+ "reason": "Clip key matches session.",
474
+ }
475
+
476
+ # Semitone delta: how much the clip should shift to align with the
477
+ # session root. Choose the smaller magnitude (shift up or down).
478
+ raw_delta = (session_semi - file_semi) % 12
479
+ if raw_delta > 6:
480
+ raw_delta -= 12 # prefer the nearer direction (−1 over +11)
481
+ delta = raw_delta
482
+
483
+ return {
484
+ "track_index": track_index,
485
+ "clip_index": clip_index,
486
+ "file_path": file_path,
487
+ "filename_key": filename_key,
488
+ "session_key": {"root": session_root, "mode": session_mode},
489
+ "status": "mismatch",
490
+ "semitone_delta": delta,
491
+ "recommended_fix": {
492
+ "tool": "set_clip_pitch",
493
+ "args": {
494
+ "track_index": track_index,
495
+ "clip_index": clip_index,
496
+ "coarse": delta,
497
+ },
498
+ },
499
+ "reason": (
500
+ f"Clip is {filename_key['root']}{filename_key['mode'][:3]}, "
501
+ f"session is {session_root}{session_mode[:3]} — "
502
+ f"shift {delta:+d} semitone{'' if abs(delta) == 1 else 's'}."
503
+ f"{mode_note}"
504
+ ),
505
+ }
506
+
507
+
508
+ @mcp.tool()
509
+ def get_clip_scale(ctx: Context, track_index: int, clip_index: int) -> dict:
510
+ """Read a clip's per-clip scale override (Live 12.0+).
511
+
512
+ Per-clip scales are independent of Song.scale_*. A clip can have
513
+ Scale Mode enabled with a different root/name than the Song.
514
+
515
+ Returns {root_note (0-11), scale_mode (bool), scale_name (str)}.
516
+ Raises if the clip slot is empty.
517
+ """
518
+ return _get_ableton(ctx).send_command("get_clip_scale", {
519
+ "track_index": track_index,
520
+ "clip_index": clip_index,
521
+ })
522
+
523
+
524
+ @mcp.tool()
525
+ def set_clip_scale(
526
+ ctx: Context,
527
+ track_index: int,
528
+ clip_index: int,
529
+ root_note: int,
530
+ scale_name: str,
531
+ ) -> dict:
532
+ """Set a clip's per-clip scale override (Live 12.0+).
533
+
534
+ Overrides the Song-level scale for this clip only. Useful for
535
+ key changes within a set, or for clips that live in a different
536
+ mode than the rest of the arrangement.
537
+
538
+ root_note: 0-11 (C=0, C#=1, ... B=11)
539
+ scale_name: must match one of Live's built-in scales
540
+ (call list_available_scales() if unsure)
541
+ """
542
+ if not 0 <= root_note <= 11:
543
+ raise ValueError("root_note must be 0-11")
544
+ if not scale_name.strip():
545
+ raise ValueError("scale_name cannot be empty")
546
+ return _get_ableton(ctx).send_command("set_clip_scale", {
547
+ "track_index": track_index,
548
+ "clip_index": clip_index,
549
+ "root_note": root_note,
550
+ "scale_name": scale_name,
551
+ })
552
+
553
+
554
+ @mcp.tool()
555
+ def set_clip_scale_mode(
556
+ ctx: Context,
557
+ track_index: int,
558
+ clip_index: int,
559
+ enabled: bool,
560
+ ) -> dict:
561
+ """Enable or disable Scale Mode on a single clip (Live 12.0+).
562
+
563
+ When enabled on a clip, its notes are constrained/highlighted
564
+ by the clip's own root_note + scale_name (set via set_clip_scale).
565
+ """
566
+ return _get_ableton(ctx).send_command("set_clip_scale_mode", {
567
+ "track_index": track_index,
568
+ "clip_index": clip_index,
569
+ "enabled": enabled,
570
+ })