livepilot 1.10.8 → 1.10.9
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 +128 -0
- package/README.md +15 -15
- 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 +15 -5
- 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/semantic_moves/tools.py +139 -31
- package/mcp_server/server.py +140 -14
- 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 +22 -178
- package/mcp_server/tools/clips.py +239 -1
- package/mcp_server/tools/transport.py +58 -3
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +8 -1
- 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,172 @@ 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
|
+
}
|
|
@@ -130,6 +130,61 @@ def get_recent_actions(ctx: Context, limit: int = 20) -> dict:
|
|
|
130
130
|
|
|
131
131
|
|
|
132
132
|
@mcp.tool()
|
|
133
|
-
def get_session_diagnostics(ctx: Context) -> dict:
|
|
134
|
-
"""Analyze the session for potential issues: armed tracks, solo/mute leftovers, unnamed tracks, empty clips/scenes, MIDI tracks without instruments. Returns issues with severity (warning/info) and stats.
|
|
135
|
-
|
|
133
|
+
async def get_session_diagnostics(ctx: Context, check_clip_keys: bool = False) -> dict:
|
|
134
|
+
"""Analyze the session for potential issues: armed tracks, solo/mute leftovers, unnamed tracks, empty clips/scenes, MIDI tracks without instruments. Returns issues with severity (warning/info) and stats.
|
|
135
|
+
|
|
136
|
+
check_clip_keys: when True, also cross-checks every audio clip's
|
|
137
|
+
filename-encoded key against the detected session key (BUG-D1 scan).
|
|
138
|
+
Each mismatch appears as a diagnostic entry with the exact
|
|
139
|
+
set_clip_pitch call that would correct it. Requires the M4L bridge
|
|
140
|
+
(uses get_clip_file_path + get_detected_key); skipped gracefully if
|
|
141
|
+
the bridge is unavailable. Off by default because it round-trips
|
|
142
|
+
the bridge once per audio clip and can add noticeable latency on
|
|
143
|
+
large sessions.
|
|
144
|
+
"""
|
|
145
|
+
result = _get_ableton(ctx).send_command("get_session_diagnostics")
|
|
146
|
+
|
|
147
|
+
if not check_clip_keys:
|
|
148
|
+
return result
|
|
149
|
+
if not isinstance(result, dict):
|
|
150
|
+
return result
|
|
151
|
+
|
|
152
|
+
# Augment with per-clip key-consistency checks. Each mismatch is added
|
|
153
|
+
# as a diagnostic with severity="warning"; "unknown" results are
|
|
154
|
+
# skipped so we don't drown the user in "no key detected yet" noise.
|
|
155
|
+
from .clips import check_clip_key_consistency # local import to avoid cycles
|
|
156
|
+
|
|
157
|
+
audio_mismatches: list[dict] = []
|
|
158
|
+
session_info = _get_ableton(ctx).send_command("get_session_info")
|
|
159
|
+
tracks = (session_info or {}).get("tracks", []) if isinstance(session_info, dict) else []
|
|
160
|
+
for track in tracks:
|
|
161
|
+
t_idx = track.get("index")
|
|
162
|
+
if t_idx is None:
|
|
163
|
+
continue
|
|
164
|
+
# We don't know which slots hold audio clips without probing, so
|
|
165
|
+
# iterate the first N scene slots conservatively. A session with
|
|
166
|
+
# many scenes would benefit from a scene-count cap; 32 is a
|
|
167
|
+
# reasonable upper bound for typical production sessions.
|
|
168
|
+
for clip_idx in range(min(32, len(session_info.get("scenes", []) or []) or 8)):
|
|
169
|
+
try:
|
|
170
|
+
check = await check_clip_key_consistency.fn(ctx, t_idx, clip_idx)
|
|
171
|
+
except Exception: # noqa: BLE001 — any failure means "skip this clip"
|
|
172
|
+
continue
|
|
173
|
+
if not isinstance(check, dict):
|
|
174
|
+
continue
|
|
175
|
+
if check.get("status") == "mismatch":
|
|
176
|
+
audio_mismatches.append({
|
|
177
|
+
"severity": "warning",
|
|
178
|
+
"category": "clip_key_mismatch",
|
|
179
|
+
"track_index": t_idx,
|
|
180
|
+
"clip_index": clip_idx,
|
|
181
|
+
"message": check.get("reason", ""),
|
|
182
|
+
"recommended_fix": check.get("recommended_fix"),
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
if audio_mismatches:
|
|
186
|
+
issues = result.setdefault("issues", [])
|
|
187
|
+
issues.extend(audio_mismatches)
|
|
188
|
+
result["clip_key_mismatch_count"] = len(audio_mismatches)
|
|
189
|
+
|
|
190
|
+
return result
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "livepilot",
|
|
3
|
-
"version": "1.10.
|
|
3
|
+
"version": "1.10.9",
|
|
4
4
|
"mcpName": "io.github.dreamrec/livepilot",
|
|
5
|
-
"description": "Agentic production system for Ableton Live 12 —
|
|
5
|
+
"description": "Agentic production system for Ableton Live 12 — 325 tools, 45 domains. Device atlas (1305 devices), sample engine (Splice + browser + filesystem), auto-composition, spectral perception, technique memory, creative intelligence (12 engines)",
|
|
6
6
|
"author": "Pilot Studio",
|
|
7
7
|
"license": "BSL-1.1",
|
|
8
8
|
"type": "commonjs",
|
|
@@ -5,11 +5,12 @@ Entry point for the ControlSurface. Ableton calls create_instance(c_instance)
|
|
|
5
5
|
when this script is selected in Preferences > Link, Tempo & MIDI.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
-
__version__ = "1.10.
|
|
8
|
+
__version__ = "1.10.9"
|
|
9
9
|
|
|
10
10
|
from _Framework.ControlSurface import ControlSurface
|
|
11
11
|
from . import router
|
|
12
12
|
from .server import LivePilotServer
|
|
13
|
+
from . import utils # noqa: F401 — shared helpers (get_track, get_device)
|
|
13
14
|
from . import transport # noqa: F401 — registers transport handlers
|
|
14
15
|
from . import tracks # noqa: F401 — registers track handlers
|
|
15
16
|
from . import clips # noqa: F401 — registers clip handlers
|
|
@@ -36,10 +37,16 @@ from . import version_detect # noqa: F401 — version detection
|
|
|
36
37
|
# @register decorators with the updated code). Result: a Control Surface
|
|
37
38
|
# toggle now behaves like a fresh module reload, so live-editing mixing.py
|
|
38
39
|
# / devices.py / etc. and re-toggling is enough — no Ableton restart.
|
|
40
|
+
#
|
|
41
|
+
# Order matters: utils comes first because every handler imports
|
|
42
|
+
# ``from .utils import get_track, get_device``. If utils isn't reloaded
|
|
43
|
+
# first, those re-imports during ``importlib.reload(devices)`` still
|
|
44
|
+
# resolve to the stale ``utils`` module object in ``sys.modules``.
|
|
39
45
|
|
|
40
46
|
_FIRST_CREATE_INSTANCE = True
|
|
41
47
|
|
|
42
48
|
_HANDLER_MODULES = (
|
|
49
|
+
utils,
|
|
43
50
|
transport, tracks, clips, notes, devices, scenes,
|
|
44
51
|
mixing, browser, arrangement, diagnostics,
|
|
45
52
|
clip_automation, version_detect,
|
package/server.json
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
3
|
"name": "io.github.dreamrec/livepilot",
|
|
4
|
-
"description": "
|
|
4
|
+
"description": "325-tool agentic MCP production system for Ableton Live 12 — device atlas, sample engine, composer",
|
|
5
5
|
"repository": {
|
|
6
6
|
"url": "https://github.com/dreamrec/LivePilot",
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
|
-
"version": "1.10.
|
|
9
|
+
"version": "1.10.9",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "livepilot",
|
|
14
|
-
"version": "1.10.
|
|
14
|
+
"version": "1.10.9",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
}
|