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.
- package/CHANGELOG.md +373 -0
- package/README.md +16 -16
- 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/evaluation/fabric.py +62 -1
- package/mcp_server/m4l_bridge.py +503 -18
- package/mcp_server/project_brain/automation_graph.py +23 -1
- package/mcp_server/project_brain/builder.py +2 -0
- package/mcp_server/project_brain/models.py +20 -1
- package/mcp_server/project_brain/tools.py +10 -3
- 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/semantic_moves/tools.py +139 -31
- package/mcp_server/server.py +151 -17
- package/mcp_server/session_continuity/models.py +13 -0
- package/mcp_server/session_continuity/tools.py +2 -0
- package/mcp_server/session_continuity/tracker.py +93 -0
- package/mcp_server/tools/_analyzer_engine/__init__.py +39 -0
- package/mcp_server/tools/_analyzer_engine/context.py +103 -0
- package/mcp_server/tools/_analyzer_engine/flucoma.py +23 -0
- package/mcp_server/tools/_analyzer_engine/sample.py +122 -0
- package/mcp_server/tools/_motif_engine.py +19 -4
- package/mcp_server/tools/analyzer.py +204 -180
- package/mcp_server/tools/clips.py +304 -1
- 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 +120 -4
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +15 -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
|
@@ -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
|
+
})
|