livepilot 1.10.7 → 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 +254 -0
- package/README.md +19 -17
- package/bin/livepilot.js +146 -28
- package/installer/install.js +117 -11
- 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/atlas/__init__.py +39 -7
- package/mcp_server/atlas/tools.py +56 -15
- package/mcp_server/composer/layer_planner.py +27 -0
- package/mcp_server/composer/prompt_parser.py +15 -6
- package/mcp_server/connection.py +11 -3
- package/mcp_server/corpus/__init__.py +14 -4
- package/mcp_server/evaluation/fabric.py +62 -1
- package/mcp_server/m4l_bridge.py +63 -12
- 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 +16 -2
- package/mcp_server/runtime/remote_commands.py +6 -0
- package/mcp_server/sample_engine/models.py +22 -3
- package/mcp_server/semantic_moves/__init__.py +1 -0
- package/mcp_server/semantic_moves/compiler.py +9 -1
- package/mcp_server/semantic_moves/device_creation_compilers.py +47 -0
- package/mcp_server/semantic_moves/mix_compilers.py +170 -0
- package/mcp_server/semantic_moves/mix_moves.py +1 -1
- package/mcp_server/semantic_moves/models.py +5 -0
- package/mcp_server/semantic_moves/tools.py +154 -35
- package/mcp_server/server.py +147 -17
- package/mcp_server/services/singletons.py +68 -0
- 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/splice_client/client.py +29 -8
- 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 +25 -180
- package/mcp_server/tools/clips.py +240 -2
- package/mcp_server/tools/midi_io.py +10 -0
- package/mcp_server/tools/tracks.py +1 -1
- package/mcp_server/tools/transport.py +59 -4
- package/mcp_server/translation_engine/tools.py +8 -4
- package/package.json +25 -3
- package/remote_script/LivePilot/__init__.py +36 -9
- package/remote_script/LivePilot/arrangement.py +12 -2
- package/remote_script/LivePilot/browser.py +16 -6
- package/remote_script/LivePilot/devices.py +10 -5
- package/remote_script/LivePilot/notes.py +13 -2
- package/remote_script/LivePilot/server.py +51 -13
- package/remote_script/LivePilot/version_detect.py +7 -4
- package/server.json +20 -0
- package/.claude-plugin/marketplace.json +0 -21
- package/.mcp.json.disabled +0 -9
- package/.mcpbignore +0 -60
- package/AGENTS.md +0 -46
- package/BUGS.md +0 -1570
- package/CODE_OF_CONDUCT.md +0 -27
- package/CONTRIBUTING.md +0 -131
- package/SECURITY.md +0 -48
- package/livepilot/.Codex-plugin/plugin.json +0 -8
- package/livepilot/.claude-plugin/plugin.json +0 -8
- package/livepilot/agents/livepilot-producer/AGENT.md +0 -313
- package/livepilot/commands/arrange.md +0 -47
- package/livepilot/commands/beat.md +0 -77
- package/livepilot/commands/evaluate.md +0 -49
- package/livepilot/commands/memory.md +0 -22
- package/livepilot/commands/mix.md +0 -44
- package/livepilot/commands/perform.md +0 -42
- package/livepilot/commands/session.md +0 -13
- package/livepilot/commands/sounddesign.md +0 -43
- package/livepilot/skills/livepilot-arrangement/SKILL.md +0 -155
- package/livepilot/skills/livepilot-composition-engine/SKILL.md +0 -107
- package/livepilot/skills/livepilot-composition-engine/references/form-patterns.md +0 -97
- package/livepilot/skills/livepilot-composition-engine/references/transition-archetypes.md +0 -102
- package/livepilot/skills/livepilot-core/SKILL.md +0 -184
- package/livepilot/skills/livepilot-core/references/ableton-workflow-patterns.md +0 -831
- package/livepilot/skills/livepilot-core/references/automation-atlas.md +0 -272
- package/livepilot/skills/livepilot-core/references/device-atlas/00-index.md +0 -110
- package/livepilot/skills/livepilot-core/references/device-atlas/distortion-and-character.md +0 -687
- package/livepilot/skills/livepilot-core/references/device-atlas/drums-and-percussion.md +0 -753
- package/livepilot/skills/livepilot-core/references/device-atlas/dynamics-and-punch.md +0 -525
- package/livepilot/skills/livepilot-core/references/device-atlas/eq-and-filtering.md +0 -402
- package/livepilot/skills/livepilot-core/references/device-atlas/midi-tools.md +0 -963
- package/livepilot/skills/livepilot-core/references/device-atlas/movement-and-modulation.md +0 -874
- package/livepilot/skills/livepilot-core/references/device-atlas/space-and-depth.md +0 -571
- package/livepilot/skills/livepilot-core/references/device-atlas/spectral-and-weird.md +0 -714
- package/livepilot/skills/livepilot-core/references/device-atlas/synths-native.md +0 -953
- package/livepilot/skills/livepilot-core/references/device-knowledge/00-index.md +0 -34
- package/livepilot/skills/livepilot-core/references/device-knowledge/automation-as-music.md +0 -204
- package/livepilot/skills/livepilot-core/references/device-knowledge/chains-genre.md +0 -173
- package/livepilot/skills/livepilot-core/references/device-knowledge/creative-thinking.md +0 -211
- package/livepilot/skills/livepilot-core/references/device-knowledge/effects-distortion.md +0 -188
- package/livepilot/skills/livepilot-core/references/device-knowledge/effects-space.md +0 -162
- package/livepilot/skills/livepilot-core/references/device-knowledge/effects-spectral.md +0 -229
- package/livepilot/skills/livepilot-core/references/device-knowledge/instruments-synths.md +0 -243
- package/livepilot/skills/livepilot-core/references/m4l-devices.md +0 -352
- package/livepilot/skills/livepilot-core/references/memory-guide.md +0 -107
- package/livepilot/skills/livepilot-core/references/midi-recipes.md +0 -402
- package/livepilot/skills/livepilot-core/references/mixing-patterns.md +0 -578
- package/livepilot/skills/livepilot-core/references/overview.md +0 -290
- package/livepilot/skills/livepilot-core/references/sample-manipulation.md +0 -724
- package/livepilot/skills/livepilot-core/references/sound-design-deep.md +0 -140
- package/livepilot/skills/livepilot-core/references/sound-design.md +0 -393
- package/livepilot/skills/livepilot-devices/SKILL.md +0 -169
- package/livepilot/skills/livepilot-evaluation/SKILL.md +0 -156
- package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +0 -118
- package/livepilot/skills/livepilot-evaluation/references/evaluation-contracts.md +0 -121
- package/livepilot/skills/livepilot-evaluation/references/memory-promotion.md +0 -110
- package/livepilot/skills/livepilot-mix-engine/SKILL.md +0 -123
- package/livepilot/skills/livepilot-mix-engine/references/mix-critics.md +0 -143
- package/livepilot/skills/livepilot-mix-engine/references/mix-moves.md +0 -105
- package/livepilot/skills/livepilot-mixing/SKILL.md +0 -157
- package/livepilot/skills/livepilot-notes/SKILL.md +0 -130
- package/livepilot/skills/livepilot-performance-engine/SKILL.md +0 -122
- package/livepilot/skills/livepilot-performance-engine/references/performance-safety.md +0 -98
- package/livepilot/skills/livepilot-release/SKILL.md +0 -130
- package/livepilot/skills/livepilot-sample-engine/SKILL.md +0 -105
- package/livepilot/skills/livepilot-sample-engine/references/sample-critics.md +0 -87
- package/livepilot/skills/livepilot-sample-engine/references/sample-philosophy.md +0 -51
- package/livepilot/skills/livepilot-sample-engine/references/sample-techniques.md +0 -131
- package/livepilot/skills/livepilot-sound-design-engine/SKILL.md +0 -168
- package/livepilot/skills/livepilot-sound-design-engine/references/patch-model.md +0 -119
- package/livepilot/skills/livepilot-sound-design-engine/references/sound-design-critics.md +0 -118
- package/livepilot/skills/livepilot-wonder/SKILL.md +0 -79
- package/m4l_device/LivePilot_Analyzer.amxd.pre-presentation-backup +0 -0
- package/m4l_device/LivePilot_Analyzer.maxpat +0 -2705
- package/m4l_device/LivePilot_Analyzer.maxproj +0 -53
- package/manifest.json +0 -91
- package/mcp_server/splice_client/protos/app_pb2.pyi +0 -1153
- package/scripts/generate_tool_catalog.py +0 -106
- package/scripts/sync_metadata.py +0 -349
|
@@ -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:
|
|
@@ -114,7 +183,7 @@ def stop_clip(ctx: Context, track_index: int, clip_index: int) -> dict:
|
|
|
114
183
|
|
|
115
184
|
@mcp.tool()
|
|
116
185
|
def set_clip_name(ctx: Context, track_index: int, clip_index: int, name: str) -> dict:
|
|
117
|
-
"""Rename a clip."""
|
|
186
|
+
"""Rename a clip in the Session view. The new name appears on the clip slot and in Device Chain displays."""
|
|
118
187
|
_validate_track_index(track_index)
|
|
119
188
|
_validate_clip_index(clip_index)
|
|
120
189
|
if not name.strip():
|
|
@@ -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
|
+
}
|
|
@@ -144,6 +144,16 @@ def export_clip_midi(
|
|
|
144
144
|
else:
|
|
145
145
|
out_path = _safe_output_path(_output_dir(), filename)
|
|
146
146
|
|
|
147
|
+
# Extension guard: after path resolution, confirm the final file really
|
|
148
|
+
# has a MIDI extension. Blocks a model-supplied path like
|
|
149
|
+
# "/etc/cron.d/evil" that accidentally drops its extension through the
|
|
150
|
+
# resolve() step, or a caller that passed "evil.mid/../evil".
|
|
151
|
+
if out_path.suffix.lower() not in {".mid", ".midi"}:
|
|
152
|
+
raise ValueError(
|
|
153
|
+
f"Refusing to write non-MIDI file: {out_path}. "
|
|
154
|
+
f"export_clip_midi requires a .mid or .midi extension."
|
|
155
|
+
)
|
|
156
|
+
|
|
147
157
|
midi = MIDIFile(1)
|
|
148
158
|
midi.addTempo(0, 0, tempo)
|
|
149
159
|
|
|
@@ -106,7 +106,7 @@ def duplicate_track(ctx: Context, track_index: int) -> dict:
|
|
|
106
106
|
|
|
107
107
|
@mcp.tool()
|
|
108
108
|
def set_track_name(ctx: Context, track_index: int, name: str) -> dict:
|
|
109
|
-
"""Rename a track."""
|
|
109
|
+
"""Rename a track. The new name appears in both the Session and Arrangement views and survives session save."""
|
|
110
110
|
_validate_track_index(track_index)
|
|
111
111
|
if not name.strip():
|
|
112
112
|
raise ValueError("Track name cannot be empty")
|
|
@@ -62,7 +62,7 @@ def start_playback(ctx: Context) -> dict:
|
|
|
62
62
|
|
|
63
63
|
@mcp.tool()
|
|
64
64
|
def stop_playback(ctx: Context) -> dict:
|
|
65
|
-
"""Stop playback."""
|
|
65
|
+
"""Stop playback — halts the session transport and the arrangement cursor returns to its last position."""
|
|
66
66
|
return _get_ableton(ctx).send_command("stop_playback")
|
|
67
67
|
|
|
68
68
|
|
|
@@ -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
|
|
@@ -6,11 +6,19 @@ then delegates to pure-computation critics.
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
+
import logging
|
|
10
|
+
|
|
9
11
|
from fastmcp import Context
|
|
10
12
|
|
|
11
13
|
from ..server import mcp
|
|
12
14
|
from .critics import build_translation_report, run_all_translation_critics
|
|
13
15
|
|
|
16
|
+
# Logger defined at module top: _fetch_translation_data below calls
|
|
17
|
+
# logger.debug on an exception path. The previous version buried the logger
|
|
18
|
+
# definition inside a docstring mid-file, which meant the name was never
|
|
19
|
+
# actually bound at module level — any exception path would raise NameError.
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
14
22
|
|
|
15
23
|
# ── Helpers ─────────────────────────────────────────────────────────
|
|
16
24
|
|
|
@@ -97,10 +105,6 @@ def get_translation_issues(ctx: Context) -> dict:
|
|
|
97
105
|
|
|
98
106
|
Lighter than check_translation — returns only detected issues
|
|
99
107
|
from the 5 playback robustness critics.
|
|
100
|
-
import logging
|
|
101
|
-
|
|
102
|
-
logger = logging.getLogger(__name__)
|
|
103
|
-
|
|
104
108
|
"""
|
|
105
109
|
mix_snapshot = _fetch_translation_data(ctx)
|
|
106
110
|
issues = run_all_translation_critics(mix_snapshot)
|
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",
|
|
@@ -43,5 +43,27 @@
|
|
|
43
43
|
],
|
|
44
44
|
"engines": {
|
|
45
45
|
"node": ">=18.0.0"
|
|
46
|
-
}
|
|
46
|
+
},
|
|
47
|
+
"files": [
|
|
48
|
+
"bin/**/*.js",
|
|
49
|
+
"installer/**/*.js",
|
|
50
|
+
"mcp_server/**/*.py",
|
|
51
|
+
"mcp_server/**/*.json",
|
|
52
|
+
"mcp_server/**/*.yaml",
|
|
53
|
+
"mcp_server/**/*.md",
|
|
54
|
+
"mcp_server/**/*.db",
|
|
55
|
+
"remote_script/**/*.py",
|
|
56
|
+
"m4l_device/LivePilot_Analyzer.amxd",
|
|
57
|
+
"m4l_device/LivePilot_Analyzer.adv",
|
|
58
|
+
"m4l_device/livepilot_bridge.js",
|
|
59
|
+
"m4l_device/BUILD_GUIDE.md",
|
|
60
|
+
"requirements.txt",
|
|
61
|
+
"README.md",
|
|
62
|
+
"LICENSE",
|
|
63
|
+
"CHANGELOG.md",
|
|
64
|
+
"server.json",
|
|
65
|
+
"!**/__pycache__/**",
|
|
66
|
+
"!**/*.pyc",
|
|
67
|
+
"!**/.DS_Store"
|
|
68
|
+
]
|
|
47
69
|
}
|
|
@@ -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,36 +37,62 @@ 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,
|
|
46
53
|
)
|
|
47
54
|
|
|
48
55
|
|
|
49
|
-
def _force_reload_handlers():
|
|
56
|
+
def _force_reload_handlers(cs=None):
|
|
50
57
|
"""Force Python to re-read the handler modules from disk.
|
|
51
58
|
|
|
52
59
|
Called on every create_instance() except the first, so edits to
|
|
53
60
|
handler files take effect via Control Surface toggle without
|
|
54
61
|
restarting Ableton. Order matters: router first (clears _handlers),
|
|
55
62
|
then each handler module (re-registers its @register decorators).
|
|
63
|
+
|
|
64
|
+
When ``cs`` is provided, reload exceptions are logged through the
|
|
65
|
+
ControlSurface so a SyntaxError / NameError in an edited handler is
|
|
66
|
+
surfaced in Live's status log instead of silently swallowed. The
|
|
67
|
+
previous ``except Exception: pass`` turned any bad handler into a
|
|
68
|
+
silent NOT_FOUND at dispatch time with no hint that reload had failed.
|
|
56
69
|
"""
|
|
57
70
|
import importlib
|
|
71
|
+
def _log(msg):
|
|
72
|
+
if cs is None:
|
|
73
|
+
return
|
|
74
|
+
try:
|
|
75
|
+
cs.log_message("[LivePilot] " + msg)
|
|
76
|
+
except Exception:
|
|
77
|
+
pass
|
|
78
|
+
|
|
58
79
|
try:
|
|
59
80
|
importlib.reload(router)
|
|
60
|
-
except Exception:
|
|
61
|
-
|
|
81
|
+
except Exception as exc:
|
|
82
|
+
_log("reload(router) FAILED — %s: %s. Handlers will be "
|
|
83
|
+
"stale until Ableton restart." % (type(exc).__name__, exc))
|
|
62
84
|
for mod in _HANDLER_MODULES:
|
|
63
85
|
try:
|
|
64
86
|
importlib.reload(mod)
|
|
65
|
-
except Exception:
|
|
66
|
-
# Don't block Ableton startup on a single bad reload
|
|
67
|
-
# the
|
|
68
|
-
|
|
87
|
+
except Exception as exc:
|
|
88
|
+
# Don't block Ableton startup on a single bad reload, but do
|
|
89
|
+
# tell the user what happened — the stale handler will keep
|
|
90
|
+
# serving the OLD code until a full restart.
|
|
91
|
+
_log("reload(%s) FAILED — %s: %s. Handler is stale." % (
|
|
92
|
+
getattr(mod, "__name__", "?"),
|
|
93
|
+
type(exc).__name__,
|
|
94
|
+
exc,
|
|
95
|
+
))
|
|
69
96
|
|
|
70
97
|
|
|
71
98
|
def create_instance(c_instance):
|
|
@@ -78,7 +105,7 @@ def create_instance(c_instance):
|
|
|
78
105
|
"""
|
|
79
106
|
global _FIRST_CREATE_INSTANCE
|
|
80
107
|
if not _FIRST_CREATE_INSTANCE:
|
|
81
|
-
_force_reload_handlers()
|
|
108
|
+
_force_reload_handlers(cs=c_instance)
|
|
82
109
|
_FIRST_CREATE_INSTANCE = False
|
|
83
110
|
return LivePilot(c_instance)
|
|
84
111
|
|
|
@@ -407,11 +407,21 @@ def modify_arrangement_notes(song, params):
|
|
|
407
407
|
for note in all_notes:
|
|
408
408
|
note_map[note.note_id] = note
|
|
409
409
|
|
|
410
|
+
# Two-pass: validate all note_ids BEFORE mutating any notes. See the
|
|
411
|
+
# identical fix in notes.py:modify_notes — partial mid-loop mutation on
|
|
412
|
+
# the C++ NoteVector was leaving the clip in a half-modified state that
|
|
413
|
+
# never got committed.
|
|
414
|
+
missing = [int(mod["note_id"]) for mod in modifications
|
|
415
|
+
if int(mod["note_id"]) not in note_map]
|
|
416
|
+
if missing:
|
|
417
|
+
raise ValueError(
|
|
418
|
+
"Note IDs not found in arrangement clip: %s. "
|
|
419
|
+
"No modifications applied." % missing
|
|
420
|
+
)
|
|
421
|
+
|
|
410
422
|
modified_count = 0
|
|
411
423
|
for mod in modifications:
|
|
412
424
|
note_id = int(mod["note_id"])
|
|
413
|
-
if note_id not in note_map:
|
|
414
|
-
raise ValueError("Note ID %d not found in clip" % note_id)
|
|
415
425
|
note = note_map[note_id]
|
|
416
426
|
if "pitch" in mod:
|
|
417
427
|
note.pitch = int(mod["pitch"])
|
|
@@ -287,11 +287,20 @@ def load_browser_item(song, params):
|
|
|
287
287
|
# and use the subcategory or the full fragment for matching
|
|
288
288
|
if "FileId_" in device_name:
|
|
289
289
|
# URI contains an internal file ID — name-based search won't work.
|
|
290
|
-
#
|
|
290
|
+
# We fall back to one URI walk, but with a TIGHT iteration budget:
|
|
291
|
+
# this runs synchronously on Ableton's audio/main thread, and the
|
|
292
|
+
# previous 200 000-node walk could stall audio and GUI for several
|
|
293
|
+
# seconds on large libraries (documented in CLAUDE.md).
|
|
294
|
+
#
|
|
295
|
+
# If the item isn't found inside the budget, we return a clean
|
|
296
|
+
# STATE_ERROR pointing the caller at search_browser(), which does
|
|
297
|
+
# the same walk lazily from a cached Python-side index without
|
|
298
|
+
# hogging the audio thread.
|
|
291
299
|
_iterations[0] = 0
|
|
292
|
-
DEEP_MAX =
|
|
300
|
+
DEEP_MAX = 20000 # was 200_000 — 10x reduction
|
|
301
|
+
DEEP_DEPTH_MAX = 8 # was 12 — shallower depth is usually enough
|
|
293
302
|
def find_by_uri_deep(parent, target_uri, depth=0):
|
|
294
|
-
if depth >
|
|
303
|
+
if depth > DEEP_DEPTH_MAX or _iterations[0] > DEEP_MAX:
|
|
295
304
|
return None
|
|
296
305
|
try:
|
|
297
306
|
children = list(parent.children)
|
|
@@ -326,9 +335,10 @@ def load_browser_item(song, params):
|
|
|
326
335
|
}
|
|
327
336
|
|
|
328
337
|
raise ValueError(
|
|
329
|
-
"Item '%s' not found
|
|
330
|
-
"search_browser to
|
|
331
|
-
"
|
|
338
|
+
"Item '%s' not found inside deep-scan budget (FileId URI). "
|
|
339
|
+
"Use search_browser(query=...) to locate it without stalling "
|
|
340
|
+
"Ableton's audio thread, then call load_browser_item with the "
|
|
341
|
+
"returned URI." % uri
|
|
332
342
|
)
|
|
333
343
|
|
|
334
344
|
for sep in (":", "/"):
|
|
@@ -199,17 +199,22 @@ def toggle_device(song, params):
|
|
|
199
199
|
track = get_track(song, track_index)
|
|
200
200
|
device = get_device(track, device_index)
|
|
201
201
|
|
|
202
|
-
# Find the "Device On" parameter by name
|
|
202
|
+
# Find the "Device On" parameter by name — the previous fallback
|
|
203
|
+
# blindly assumed parameters[0] was an on/off switch, which for many
|
|
204
|
+
# devices is actually "Filter Frequency", "Gain", or similar. The
|
|
205
|
+
# fallback silently mutated an arbitrary parameter while reporting
|
|
206
|
+
# is_active as if toggling had worked. Now refuse to guess.
|
|
203
207
|
on_param = None
|
|
204
208
|
for p in device.parameters:
|
|
205
209
|
if p.name == "Device On":
|
|
206
210
|
on_param = p
|
|
207
211
|
break
|
|
208
212
|
if on_param is None:
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
+
raise ValueError(
|
|
214
|
+
"Device '%s' exposes no 'Device On' parameter and cannot be "
|
|
215
|
+
"toggled programmatically. Use delete_device or disable it "
|
|
216
|
+
"through the UI." % device.name
|
|
217
|
+
)
|
|
213
218
|
|
|
214
219
|
on_param.value = 1.0 if active else 0.0
|
|
215
220
|
return {"name": device.name, "is_active": on_param.value > 0.5}
|
|
@@ -155,12 +155,23 @@ def modify_notes(song, params):
|
|
|
155
155
|
for note in all_notes:
|
|
156
156
|
note_map[note.note_id] = note
|
|
157
157
|
|
|
158
|
+
# Two-pass: validate every note_id BEFORE mutating any notes. The previous
|
|
159
|
+
# one-pass version raised ValueError mid-loop after some notes had already
|
|
160
|
+
# been mutated in place on the C++ NoteVector — yielding a half-modified
|
|
161
|
+
# state where the caller saw an error but earlier edits silently stuck
|
|
162
|
+
# (until apply_note_modifications was called, which it never was in the
|
|
163
|
+
# error path). Fail-all or apply-all is the safer contract.
|
|
164
|
+
missing = [int(mod["note_id"]) for mod in modifications
|
|
165
|
+
if int(mod["note_id"]) not in note_map]
|
|
166
|
+
if missing:
|
|
167
|
+
raise ValueError(
|
|
168
|
+
"Note IDs not found in clip: %s. No modifications applied." % missing
|
|
169
|
+
)
|
|
170
|
+
|
|
158
171
|
# Apply modifications in-place on the original NoteVector's objects
|
|
159
172
|
modified_count = 0
|
|
160
173
|
for mod in modifications:
|
|
161
174
|
note_id = int(mod["note_id"])
|
|
162
|
-
if note_id not in note_map:
|
|
163
|
-
raise ValueError("Note ID %d not found in clip" % note_id)
|
|
164
175
|
note = note_map[note_id]
|
|
165
176
|
if "pitch" in mod:
|
|
166
177
|
note.pitch = int(mod["pitch"])
|