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.
@@ -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
- return _get_ableton(ctx).send_command("get_session_diagnostics")
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.8",
3
+ "version": "1.10.9",
4
4
  "mcpName": "io.github.dreamrec/livepilot",
5
- "description": "Agentic production system for Ableton Live 12 — 324 tools, 45 domains. Device atlas (1305 devices), sample engine (Splice + browser + filesystem), auto-composition, spectral perception, technique memory, creative intelligence (12 engines)",
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"
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": "323-tool agentic MCP production system for Ableton Live 12 — device atlas, sample engine, composer",
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.8",
9
+ "version": "1.10.9",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "livepilot",
14
- "version": "1.10.8",
14
+ "version": "1.10.9",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  }