livepilot 1.23.3 → 1.23.4
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 +93 -0
- package/README.md +106 -8
- 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/cross_pack_chain.py +658 -0
- package/mcp_server/atlas/demo_story.py +700 -0
- package/mcp_server/atlas/extract_chain.py +786 -0
- package/mcp_server/atlas/macro_fingerprint.py +554 -0
- package/mcp_server/atlas/overlays.py +95 -3
- package/mcp_server/atlas/pack_aware_compose.py +1255 -0
- package/mcp_server/atlas/preset_resolver.py +238 -0
- package/mcp_server/atlas/tools.py +1001 -31
- package/mcp_server/atlas/transplant.py +1177 -0
- package/mcp_server/mix_engine/state_builder.py +44 -1
- package/mcp_server/runtime/capability_state.py +34 -3
- package/mcp_server/server.py +45 -24
- package/mcp_server/tools/agent_os.py +33 -9
- package/mcp_server/tools/analyzer.py +38 -7
- package/mcp_server/tools/browser.py +20 -1
- package/mcp_server/tools/devices.py +78 -11
- package/mcp_server/tools/perception.py +5 -1
- package/mcp_server/tools/tracks.py +39 -2
- package/mcp_server/user_corpus/__init__.py +48 -0
- package/mcp_server/user_corpus/manifest.py +142 -0
- package/mcp_server/user_corpus/plugin_engine/__init__.py +39 -0
- package/mcp_server/user_corpus/plugin_engine/detector.py +579 -0
- package/mcp_server/user_corpus/plugin_engine/manual.py +347 -0
- package/mcp_server/user_corpus/plugin_engine/research.py +247 -0
- package/mcp_server/user_corpus/runner.py +261 -0
- package/mcp_server/user_corpus/scanner.py +115 -0
- package/mcp_server/user_corpus/scanners/__init__.py +18 -0
- package/mcp_server/user_corpus/scanners/adg.py +79 -0
- package/mcp_server/user_corpus/scanners/als.py +144 -0
- package/mcp_server/user_corpus/scanners/amxd.py +374 -0
- package/mcp_server/user_corpus/scanners/plugin_preset.py +202 -0
- package/mcp_server/user_corpus/tools.py +904 -0
- package/mcp_server/user_corpus/wizard.py +224 -0
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +1 -1
- package/remote_script/LivePilot/browser.py +7 -2
- package/requirements.txt +3 -3
- package/server.json +2 -2
|
@@ -24,6 +24,47 @@ from .models import (
|
|
|
24
24
|
# Roles considered "anchor" — should be prominent in the mix.
|
|
25
25
|
_ANCHOR_ROLES = frozenset({"kick", "bass", "vocal", "lead", "drums"})
|
|
26
26
|
|
|
27
|
+
# BUG-2026-04-26#5: track-name substrings that explicitly mark a track
|
|
28
|
+
# as SUPPORT, even when its role-name maps to an anchor role. Without
|
|
29
|
+
# this filter, a track named "VOX-GHOST" infers role=vocal and gets
|
|
30
|
+
# auto-classified as an anchor, which then triggers `anchor_too_weak`
|
|
31
|
+
# from the balance critic for any volume below the session average —
|
|
32
|
+
# a guaranteed false positive on every ghost / wisp / texture layer.
|
|
33
|
+
#
|
|
34
|
+
# Substring-based (case-insensitive). Add new hints conservatively —
|
|
35
|
+
# any new entry de-promotes a previously-anchor track silently.
|
|
36
|
+
_NON_ANCHOR_NAME_HINTS = (
|
|
37
|
+
"ghost", # VOX-GHOST, ghost-snare
|
|
38
|
+
"wisp", # vocal-wisp
|
|
39
|
+
"fx", # fx-bus, fx-rain
|
|
40
|
+
"atmos", # ATMOS, atmosphere
|
|
41
|
+
"atmosphere",
|
|
42
|
+
"rain", # rain-bed
|
|
43
|
+
"texture",
|
|
44
|
+
"drone", # drone-bed
|
|
45
|
+
"shimmer",
|
|
46
|
+
"wash", # reverb-wash, vocal-wash
|
|
47
|
+
"ambient", # ambient-pad
|
|
48
|
+
"sublayer",
|
|
49
|
+
"sub-layer",
|
|
50
|
+
"sub_layer",
|
|
51
|
+
"ghosting",
|
|
52
|
+
"back", # back-vox, back-pad (background layers)
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _name_signals_non_anchor(track_name: str) -> bool:
|
|
57
|
+
"""Return True when the track name marks this layer as SUPPORT.
|
|
58
|
+
|
|
59
|
+
Used in build_balance_state to exclude false anchors from the
|
|
60
|
+
balance critic's `anchor_too_weak` signal. See BUG-2026-04-26#5.
|
|
61
|
+
"""
|
|
62
|
+
if not track_name:
|
|
63
|
+
return False
|
|
64
|
+
name_lower = track_name.lower()
|
|
65
|
+
return any(hint in name_lower for hint in _NON_ANCHOR_NAME_HINTS)
|
|
66
|
+
|
|
67
|
+
|
|
27
68
|
# Frequency bands where masking is most problematic.
|
|
28
69
|
_MASKING_BANDS = ("sub", "low", "low_mid", "mid", "high_mid", "presence", "high")
|
|
29
70
|
|
|
@@ -77,7 +118,9 @@ def build_balance_state(
|
|
|
77
118
|
)
|
|
78
119
|
states.append(ts)
|
|
79
120
|
|
|
80
|
-
|
|
121
|
+
# BUG-2026-04-26#5: don't flag explicit support layers as anchors
|
|
122
|
+
# even when their role inference returns an anchor-class role.
|
|
123
|
+
if role in _ANCHOR_ROLES and not _name_signals_non_anchor(name):
|
|
81
124
|
anchor_indices.append(idx)
|
|
82
125
|
|
|
83
126
|
if not ts.mute:
|
|
@@ -18,7 +18,21 @@ from typing import Optional
|
|
|
18
18
|
|
|
19
19
|
@dataclass
|
|
20
20
|
class CapabilityDomain:
|
|
21
|
-
"""A single capability domain's runtime status.
|
|
21
|
+
"""A single capability domain's runtime status.
|
|
22
|
+
|
|
23
|
+
BUG-2026-04-26#4: ``available`` collapses two distinct conditions
|
|
24
|
+
into one bit (device installed AND data fresh). Callers that wanted
|
|
25
|
+
"is the analyzer .amxd loaded?" had no way to ask without conflating
|
|
26
|
+
it with "has the analyzer captured fresh data yet?". The
|
|
27
|
+
``device_loaded`` field separates these concerns:
|
|
28
|
+
|
|
29
|
+
- ``device_loaded``: True when the optional .amxd / external
|
|
30
|
+
dependency exists. Independent of data freshness. Defaults to
|
|
31
|
+
``available`` when the domain has no installable component
|
|
32
|
+
(session_access / memory / web / research).
|
|
33
|
+
- ``available``: True when the domain is ready for use end-to-end
|
|
34
|
+
(device_loaded AND fresh data, where applicable).
|
|
35
|
+
"""
|
|
22
36
|
|
|
23
37
|
name: str
|
|
24
38
|
available: bool
|
|
@@ -26,10 +40,16 @@ class CapabilityDomain:
|
|
|
26
40
|
freshness_ms: Optional[int] = None
|
|
27
41
|
mode: str = "unavailable"
|
|
28
42
|
reasons: list[str] = field(default_factory=list)
|
|
43
|
+
device_loaded: Optional[bool] = None
|
|
29
44
|
|
|
30
45
|
def __post_init__(self) -> None:
|
|
31
46
|
if not 0.0 <= self.confidence <= 1.0:
|
|
32
47
|
raise ValueError(f"confidence must be 0.0–1.0, got {self.confidence}")
|
|
48
|
+
# Default device_loaded to mirror `available` for domains that
|
|
49
|
+
# don't have a separate installable component (memory, web, etc.).
|
|
50
|
+
# Domains that DO have one (analyzer, flucoma) override explicitly.
|
|
51
|
+
if self.device_loaded is None:
|
|
52
|
+
self.device_loaded = self.available
|
|
33
53
|
|
|
34
54
|
def to_dict(self) -> dict:
|
|
35
55
|
return asdict(self)
|
|
@@ -119,18 +139,27 @@ def build_capability_state(
|
|
|
119
139
|
)
|
|
120
140
|
|
|
121
141
|
# ── analyzer ────────────────────────────────────────────────────
|
|
142
|
+
# BUG-2026-04-26#4: ``available`` requires both device-loaded AND
|
|
143
|
+
# fresh data. The new ``device_loaded`` field exposes the .amxd
|
|
144
|
+
# presence separately, so "I just loaded the analyzer, why does
|
|
145
|
+
# capability_state still say offline?" can be answered correctly:
|
|
146
|
+
# device_loaded=True, available=False, reasons=['analyzer_warming_up'].
|
|
122
147
|
analyzer_reasons: list[str] = []
|
|
123
148
|
if not analyzer_ok:
|
|
124
149
|
analyzer_reasons.append("analyzer_offline")
|
|
125
150
|
elif not analyzer_fresh:
|
|
126
|
-
|
|
151
|
+
# Pre-fix this said `analyzer_stale` even immediately after the
|
|
152
|
+
# device finished loading. ``analyzer_warming_up`` is more
|
|
153
|
+
# accurate when the device is present but hasn't streamed a
|
|
154
|
+
# frame yet — distinguishes cold-start from genuine staleness.
|
|
155
|
+
analyzer_reasons.append("analyzer_warming_up")
|
|
127
156
|
analyzer_available = analyzer_ok and analyzer_fresh
|
|
128
157
|
if analyzer_available:
|
|
129
158
|
analyzer_conf = 0.9
|
|
130
159
|
analyzer_mode = "measured"
|
|
131
160
|
elif analyzer_ok:
|
|
132
161
|
analyzer_conf = 0.4
|
|
133
|
-
analyzer_mode = "
|
|
162
|
+
analyzer_mode = "warming_up"
|
|
134
163
|
else:
|
|
135
164
|
analyzer_conf = 0.0
|
|
136
165
|
analyzer_mode = "unavailable"
|
|
@@ -140,6 +169,7 @@ def build_capability_state(
|
|
|
140
169
|
confidence=analyzer_conf,
|
|
141
170
|
mode=analyzer_mode,
|
|
142
171
|
reasons=analyzer_reasons,
|
|
172
|
+
device_loaded=analyzer_ok,
|
|
143
173
|
)
|
|
144
174
|
|
|
145
175
|
# ── memory ──────────────────────────────────────────────────────
|
|
@@ -182,6 +212,7 @@ def build_capability_state(
|
|
|
182
212
|
confidence=0.9 if flucoma_ok else 0.0,
|
|
183
213
|
mode="available" if flucoma_ok else "unavailable",
|
|
184
214
|
reasons=flucoma_reasons,
|
|
215
|
+
device_loaded=flucoma_ok,
|
|
185
216
|
)
|
|
186
217
|
|
|
187
218
|
# ── research (composite) ────────────────────────────────────────
|
package/mcp_server/server.py
CHANGED
|
@@ -307,6 +307,7 @@ from .sample_engine import tools as sample_engine_tools # noqa: F401, E40
|
|
|
307
307
|
from .atlas import tools as atlas_tools # noqa: F401, E402
|
|
308
308
|
from .composer import tools as composer_tools # noqa: F401, E402
|
|
309
309
|
from .synthesis_brain import tools as synthesis_brain_tools # noqa: F401, E402
|
|
310
|
+
from .user_corpus import tools as user_corpus_tools # noqa: F401, E402
|
|
310
311
|
from .tools import diagnostics # noqa: F401, E402
|
|
311
312
|
from .tools import miditool # noqa: F401, E402
|
|
312
313
|
|
|
@@ -425,6 +426,26 @@ def _get_all_tools():
|
|
|
425
426
|
return []
|
|
426
427
|
|
|
427
428
|
|
|
429
|
+
def _read_expected_tool_count() -> int | None:
|
|
430
|
+
"""Read the expected tool count from tests/test_tools_contract.py."""
|
|
431
|
+
import re
|
|
432
|
+
from pathlib import Path
|
|
433
|
+
try:
|
|
434
|
+
contract_path = (
|
|
435
|
+
Path(__file__).resolve().parents[1]
|
|
436
|
+
/ "tests" / "test_tools_contract.py"
|
|
437
|
+
)
|
|
438
|
+
if not contract_path.exists():
|
|
439
|
+
return None
|
|
440
|
+
match = re.search(
|
|
441
|
+
r"assert len\(tools\) == (\d+)",
|
|
442
|
+
contract_path.read_text(encoding="utf-8"),
|
|
443
|
+
)
|
|
444
|
+
return int(match.group(1)) if match else None
|
|
445
|
+
except Exception: # noqa: BLE001 — must not block startup
|
|
446
|
+
return None
|
|
447
|
+
|
|
448
|
+
|
|
428
449
|
def _assert_tool_registry_accessible() -> None:
|
|
429
450
|
"""Loudly fail startup if the FastMCP registry probe returns nothing.
|
|
430
451
|
|
|
@@ -434,31 +455,15 @@ def _assert_tool_registry_accessible() -> None:
|
|
|
434
455
|
with 324 tools but no string-to-number coercion — a subtle, hard-to-
|
|
435
456
|
diagnose class of failure we've paid for once already.
|
|
436
457
|
|
|
437
|
-
|
|
438
|
-
|
|
458
|
+
Note: only the "registry accessible at all" guard runs at module load.
|
|
459
|
+
The exact-count check moved to ``_assert_expected_tool_count()`` and
|
|
460
|
+
runs from main() — at module-load time, circular imports between
|
|
461
|
+
server.py and tool modules can leave the count temporarily under-
|
|
462
|
+
reported (a tool module being imported directly by a test or another
|
|
463
|
+
consumer triggers server.py's self-test before the importing module's
|
|
464
|
+
own ``@mcp.tool()`` decorators have fired). See BUG fix in v1.23.4.
|
|
439
465
|
"""
|
|
440
|
-
import re
|
|
441
466
|
import sys
|
|
442
|
-
|
|
443
|
-
try:
|
|
444
|
-
contract_src = (
|
|
445
|
-
(__file__.rsplit("/", 2)[0] + "/tests/test_tools_contract.py")
|
|
446
|
-
if "__file__" in globals() else None
|
|
447
|
-
)
|
|
448
|
-
# Prefer an absolute path via Path for reliability:
|
|
449
|
-
from pathlib import Path
|
|
450
|
-
contract_path = Path(__file__).resolve().parents[1] / "tests" / "test_tools_contract.py"
|
|
451
|
-
expected = None
|
|
452
|
-
if contract_path.exists():
|
|
453
|
-
match = re.search(
|
|
454
|
-
r"assert len\(tools\) == (\d+)",
|
|
455
|
-
contract_path.read_text(encoding="utf-8"),
|
|
456
|
-
)
|
|
457
|
-
if match:
|
|
458
|
-
expected = int(match.group(1))
|
|
459
|
-
except Exception: # noqa: BLE001 — self-test must not block startup
|
|
460
|
-
expected = None
|
|
461
|
-
|
|
462
467
|
actual = len(_get_all_tools())
|
|
463
468
|
if actual == 0:
|
|
464
469
|
# Registry probe returned empty — this is the regression the test guards.
|
|
@@ -470,7 +475,19 @@ def _assert_tool_registry_accessible() -> None:
|
|
|
470
475
|
"(fastmcp>=3.0.0,<3.3.0) matches the installed version.",
|
|
471
476
|
file=sys.stderr,
|
|
472
477
|
)
|
|
473
|
-
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _assert_expected_tool_count() -> None:
|
|
481
|
+
"""Verify the registered tool count matches the contract.
|
|
482
|
+
|
|
483
|
+
Run from ``main()`` after all tool-module imports have completed. Avoids
|
|
484
|
+
the false-positive that fires when a tool module is imported directly
|
|
485
|
+
(which triggers server.py's self-test mid-import, before the importer's
|
|
486
|
+
own decorators have run).
|
|
487
|
+
"""
|
|
488
|
+
import sys
|
|
489
|
+
expected = _read_expected_tool_count()
|
|
490
|
+
actual = len(_get_all_tools())
|
|
474
491
|
if expected is not None and actual != expected:
|
|
475
492
|
print(
|
|
476
493
|
f"LivePilot: STARTUP SELF-TEST WARNING — _get_all_tools() "
|
|
@@ -528,6 +545,10 @@ except Exception as e:
|
|
|
528
545
|
|
|
529
546
|
def main():
|
|
530
547
|
"""Run the MCP server over stdio."""
|
|
548
|
+
# Verify tool count matches the contract — runs here (not at module load)
|
|
549
|
+
# so all tool-module imports have completed regardless of the import path
|
|
550
|
+
# that brought server.py in. See _assert_tool_registry_accessible() docstring.
|
|
551
|
+
_assert_expected_tool_count()
|
|
531
552
|
mcp.run(transport="stdio")
|
|
532
553
|
|
|
533
554
|
if __name__ == "__main__":
|
|
@@ -231,20 +231,26 @@ def build_world_model(ctx: Context) -> dict:
|
|
|
231
231
|
@mcp.tool()
|
|
232
232
|
def evaluate_move(
|
|
233
233
|
ctx: Context,
|
|
234
|
-
goal_vector: dict | str,
|
|
235
|
-
before_snapshot: dict | str,
|
|
236
|
-
after_snapshot: dict | str,
|
|
234
|
+
goal_vector: Optional[dict | str] = None,
|
|
235
|
+
before_snapshot: Optional[dict | str] = None,
|
|
236
|
+
after_snapshot: Optional[dict | str] = None,
|
|
237
|
+
description: Optional[str] = None,
|
|
237
238
|
) -> dict:
|
|
238
239
|
"""Evaluate whether a production move improved the mix toward the goal.
|
|
239
240
|
|
|
240
|
-
|
|
241
|
-
|
|
241
|
+
Two call modes:
|
|
242
|
+
|
|
243
|
+
**Structured** (full numeric scoring): supply goal_vector + before_snapshot
|
|
244
|
+
+ after_snapshot. Snapshots must contain spectrum (9-band dict sub_low → air)
|
|
245
|
+
+ rms + peak — capture via get_master_spectrum + get_master_rms before and
|
|
246
|
+
after the move. Returns a numeric score and keep/undo recommendation.
|
|
242
247
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
248
|
+
**Description-only** (quick log, no snapshots needed): supply only
|
|
249
|
+
``description``. Returns {evaluated: false, logged: true} — move is recorded
|
|
250
|
+
as a session event but no numeric score is computed. Useful for mid-session
|
|
251
|
+
bookkeeping when you haven't pre-captured snapshots.
|
|
246
252
|
|
|
247
|
-
Hard rules enforce undo when:
|
|
253
|
+
Hard rules (structured mode only) enforce undo when:
|
|
248
254
|
- No measurable improvement (delta <= 0)
|
|
249
255
|
- Protected dimension dropped below its threshold or by > 0.15
|
|
250
256
|
- Total score < 0.40
|
|
@@ -255,6 +261,24 @@ def evaluate_move(
|
|
|
255
261
|
Returns consecutive_undo_hint=true when keep_change=false — the agent
|
|
256
262
|
should track consecutive undos and switch to observe mode after 3.
|
|
257
263
|
"""
|
|
264
|
+
# Description-only (quick-log) mode — no snapshots available
|
|
265
|
+
if goal_vector is None and before_snapshot is None and after_snapshot is None:
|
|
266
|
+
if description:
|
|
267
|
+
return {
|
|
268
|
+
"evaluated": False,
|
|
269
|
+
"logged": True,
|
|
270
|
+
"description": description,
|
|
271
|
+
"note": (
|
|
272
|
+
"No snapshots supplied — move logged but not scored. "
|
|
273
|
+
"For numeric evaluation capture get_master_spectrum + "
|
|
274
|
+
"get_master_rms before and after the move."
|
|
275
|
+
),
|
|
276
|
+
}
|
|
277
|
+
raise ValueError(
|
|
278
|
+
"Provide either goal_vector + before_snapshot + after_snapshot "
|
|
279
|
+
"for full evaluation, or description for quick logging."
|
|
280
|
+
)
|
|
281
|
+
|
|
258
282
|
gv_dict = _parse_json_param(goal_vector, "goal_vector")
|
|
259
283
|
before = _parse_json_param(before_snapshot, "before_snapshot")
|
|
260
284
|
after = _parse_json_param(after_snapshot, "after_snapshot")
|
|
@@ -1681,16 +1681,44 @@ async def verify_all_devices_health(
|
|
|
1681
1681
|
tname = t.get("name", f"Track {tid}")
|
|
1682
1682
|
if tid is None:
|
|
1683
1683
|
continue
|
|
1684
|
-
|
|
1684
|
+
|
|
1685
|
+
# BUG-2026-04-26#1: detect audio tracks via has_midi_input /
|
|
1686
|
+
# has_audio_input fields that get_session_info actually returns.
|
|
1687
|
+
# Pre-fix the code looked for `is_audio_track` / `type` fields which
|
|
1688
|
+
# don't exist on the session_info payload, so audio detection
|
|
1689
|
+
# silently always evaluated False and ALL tracks fell through to
|
|
1690
|
+
# the empty-tracks check below.
|
|
1691
|
+
has_midi = bool(t.get("has_midi_input"))
|
|
1692
|
+
has_audio = bool(t.get("has_audio_input"))
|
|
1693
|
+
is_audio = has_audio and not has_midi
|
|
1685
1694
|
if skip_audio_tracks and is_audio:
|
|
1686
1695
|
skipped.append({"track_index": tid, "track_name": tname,
|
|
1687
1696
|
"reason": "audio_track_no_midi_input"})
|
|
1688
1697
|
continue
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1698
|
+
|
|
1699
|
+
# BUG-2026-04-26#1: get_session_info does NOT include per-track
|
|
1700
|
+
# `devices` arrays — only get_track_info does. Pre-fix,
|
|
1701
|
+
# `t.get("devices") or []` always returned [], so every MIDI track
|
|
1702
|
+
# was flagged "no_devices_on_track" even when a Simpler / Operator
|
|
1703
|
+
# / synth was loaded. Round-trip per track is the price of correct
|
|
1704
|
+
# detection; the alternative (extending get_session_info to embed
|
|
1705
|
+
# devices) would change a hot-path payload size for every caller.
|
|
1706
|
+
if skip_empty_tracks:
|
|
1707
|
+
try:
|
|
1708
|
+
track_info = ableton.send_command(
|
|
1709
|
+
"get_track_info", {"track_index": tid},
|
|
1710
|
+
)
|
|
1711
|
+
except Exception:
|
|
1712
|
+
track_info = None
|
|
1713
|
+
devices = (
|
|
1714
|
+
(track_info or {}).get("devices") or []
|
|
1715
|
+
if isinstance(track_info, dict)
|
|
1716
|
+
else []
|
|
1717
|
+
)
|
|
1718
|
+
if not devices:
|
|
1719
|
+
skipped.append({"track_index": tid, "track_name": tname,
|
|
1720
|
+
"reason": "no_devices_on_track"})
|
|
1721
|
+
continue
|
|
1694
1722
|
|
|
1695
1723
|
# Run the per-track health check.
|
|
1696
1724
|
result = await verify_device_health(
|
|
@@ -1748,7 +1776,10 @@ async def analyze_loudness_live(
|
|
|
1748
1776
|
window_sec: float = 10.0,
|
|
1749
1777
|
sample_interval_ms: int = 200,
|
|
1750
1778
|
) -> dict:
|
|
1751
|
-
"""Analyze the currently-playing master output's loudness over a window.
|
|
1779
|
+
"""Analyze the currently-playing master output's loudness over a window (LIVE).
|
|
1780
|
+
|
|
1781
|
+
Use this tool during a session — no rendered file needed.
|
|
1782
|
+
For offline analysis of an exported audio file use analyze_loudness() instead.
|
|
1752
1783
|
|
|
1753
1784
|
BUG-2026-04-22#8 fix — the offline `analyze_loudness` requires a
|
|
1754
1785
|
rendered file. This tool samples the LivePilot analyzer's realtime
|
|
@@ -96,6 +96,21 @@ def get_browser_items(
|
|
|
96
96
|
return result
|
|
97
97
|
|
|
98
98
|
|
|
99
|
+
_BROWSER_PATH_ALIASES: dict[str, str] = {
|
|
100
|
+
"effects": "audio_effects",
|
|
101
|
+
"fx": "audio_effects",
|
|
102
|
+
"audio_fx": "audio_effects",
|
|
103
|
+
"audiofx": "audio_effects",
|
|
104
|
+
"midi_fx": "midi_effects",
|
|
105
|
+
"midifx": "midi_effects",
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _normalize_browser_path(path: str) -> str:
|
|
110
|
+
"""Normalise common path aliases to their canonical browser category name."""
|
|
111
|
+
return _BROWSER_PATH_ALIASES.get(path.strip().lower(), path)
|
|
112
|
+
|
|
113
|
+
|
|
99
114
|
@mcp.tool()
|
|
100
115
|
def search_browser(
|
|
101
116
|
ctx: Context,
|
|
@@ -114,7 +129,10 @@ def search_browser(
|
|
|
114
129
|
|
|
115
130
|
path: top-level category to search under. Valid categories:
|
|
116
131
|
instruments, audio_effects, midi_effects, sounds, drums,
|
|
117
|
-
samples, packs, user_library, plugins, max_for_live, clips
|
|
132
|
+
samples, packs, user_library, plugins, max_for_live, clips.
|
|
133
|
+
Common aliases are normalised automatically:
|
|
134
|
+
"effects" / "fx" → "audio_effects"
|
|
135
|
+
"midi_fx" → "midi_effects"
|
|
118
136
|
name_filter: case-insensitive substring filter on item name
|
|
119
137
|
query: alias for name_filter (accepts either)
|
|
120
138
|
max_depth: how deep to recurse into subfolders (default 8)
|
|
@@ -122,6 +140,7 @@ def search_browser(
|
|
|
122
140
|
"""
|
|
123
141
|
if not path.strip():
|
|
124
142
|
raise ValueError("Path cannot be empty")
|
|
143
|
+
path = _normalize_browser_path(path)
|
|
125
144
|
if max_depth < 1:
|
|
126
145
|
raise ValueError("max_depth must be >= 1")
|
|
127
146
|
if max_results < 1:
|
|
@@ -252,24 +252,40 @@ def set_device_parameter(
|
|
|
252
252
|
|
|
253
253
|
track_index: 0+ for regular tracks, -1/-2/... for return tracks (A/B/...), -1000 for master.
|
|
254
254
|
|
|
255
|
-
⚠️ PARAMETER RANGES ARE NOT ALWAYS 0-1 (BUG-B4 / B9):
|
|
255
|
+
⚠️ PARAMETER RANGES ARE NOT ALWAYS 0-1 (BUG-B4 / B9 / 2026-04-26#2):
|
|
256
256
|
Ableton devices use MIXED units depending on the parameter. Always
|
|
257
257
|
read the `value_string` in the response (and the `min`/`max` from
|
|
258
258
|
get_device_parameters) before assuming 0-1 semantics:
|
|
259
259
|
|
|
260
|
-
- Auto Filter `Frequency`:
|
|
260
|
+
- Auto Filter `Frequency`: 20-135 index (NOT normalized)
|
|
261
261
|
- Auto Filter Legacy `LFO Amount`: 0-30 absolute (displays as %)
|
|
262
|
-
- Auto Filter `Resonance`:
|
|
263
|
-
- Auto Filter `Env. Modulation`:
|
|
264
|
-
- Compressor I
|
|
265
|
-
-
|
|
266
|
-
|
|
262
|
+
- Auto Filter `Resonance`: 0-1.25 on legacy, 0-1 on AutoFilter2
|
|
263
|
+
- Auto Filter `Env. Modulation`: -127..+127 on legacy
|
|
264
|
+
- Compressor I (legacy): pre-2010 units (Threshold dB direct)
|
|
265
|
+
- **Compressor 2 (modern, default)**: 0-1 NORMALIZED.
|
|
266
|
+
`Threshold 0.85 ≈ 0 dB`, `Ratio 0.75 = 4:1`, `Release 0.16 = 30 ms`.
|
|
267
|
+
Setting Threshold to a dB value like -22 will fail. Compute
|
|
268
|
+
normalized: `(dB + 50) / 50` for typical dB→0-1 mapping, OR
|
|
269
|
+
read the param's value_string after a probe write.
|
|
270
|
+
- **Saturator** `Drive`, `Output`, `Threshold`, `Color *`: 0-1
|
|
271
|
+
NORMALIZED (Drive 0.5 ≈ 0 dB, Drive 0.6 ≈ +7 dB).
|
|
272
|
+
- Dynamic Tube, Vocoder: pre-2010 units
|
|
273
|
+
- EQ Three `Frequency Hi/Lo`: 50Hz-15kHz absolute
|
|
274
|
+
- Wavetable `Osc 1 Pos`: 0-1 normalized ✓
|
|
267
275
|
- Drift / Analog / Operator macros: 0-1 normalized ✓
|
|
276
|
+
- Pedal `Output`: -20..+20 dB direct
|
|
277
|
+
- Pedal `Bass / Mid / Treble`: -1..+1 direct
|
|
268
278
|
|
|
269
279
|
The `value_string` field in the response is the SOURCE OF TRUTH
|
|
270
280
|
for what the user sees. Automation recipes that assume 0-1 will
|
|
271
281
|
clamp on legacy devices. When in doubt, call
|
|
272
282
|
get_device_parameters first to inspect min/max/is_quantized.
|
|
283
|
+
|
|
284
|
+
Error enrichment (BUG-2026-04-26#2): if the Remote Script rejects
|
|
285
|
+
the value as out-of-range, this wrapper fetches the parameter's
|
|
286
|
+
actual min/max/value_string and re-raises with that context inline.
|
|
287
|
+
Saves a follow-up get_device_parameters round-trip in the agent
|
|
288
|
+
loop after every miss.
|
|
273
289
|
"""
|
|
274
290
|
_validate_track_index(track_index)
|
|
275
291
|
_validate_device_index(device_index)
|
|
@@ -286,7 +302,52 @@ def set_device_parameter(
|
|
|
286
302
|
params["parameter_name"] = parameter_name
|
|
287
303
|
if parameter_index is not None:
|
|
288
304
|
params["parameter_index"] = parameter_index
|
|
289
|
-
|
|
305
|
+
try:
|
|
306
|
+
return _get_ableton(ctx).send_command("set_device_parameter", params)
|
|
307
|
+
except Exception as exc:
|
|
308
|
+
# BUG-2026-04-26#2: enrich out-of-range errors with the actual
|
|
309
|
+
# min/max/value_string from get_device_parameters so the caller
|
|
310
|
+
# doesn't need a follow-up probe to learn the unit semantics.
|
|
311
|
+
# Best-effort — if the enrichment fetch itself fails, re-raise
|
|
312
|
+
# the original exception untouched.
|
|
313
|
+
msg = str(exc)
|
|
314
|
+
looks_like_range_error = (
|
|
315
|
+
"Invalid value" in msg
|
|
316
|
+
or "STATE_ERROR" in msg
|
|
317
|
+
or "out of range" in msg.lower()
|
|
318
|
+
)
|
|
319
|
+
if not looks_like_range_error:
|
|
320
|
+
raise
|
|
321
|
+
try:
|
|
322
|
+
param_info = _get_ableton(ctx).send_command(
|
|
323
|
+
"get_device_parameters",
|
|
324
|
+
{"track_index": track_index, "device_index": device_index},
|
|
325
|
+
)
|
|
326
|
+
except Exception:
|
|
327
|
+
raise exc
|
|
328
|
+
params_list = (param_info or {}).get("parameters") if isinstance(param_info, dict) else None
|
|
329
|
+
if not isinstance(params_list, list):
|
|
330
|
+
raise exc
|
|
331
|
+
target = None
|
|
332
|
+
for p in params_list:
|
|
333
|
+
if not isinstance(p, dict):
|
|
334
|
+
continue
|
|
335
|
+
if parameter_name is not None and p.get("name") == parameter_name:
|
|
336
|
+
target = p
|
|
337
|
+
break
|
|
338
|
+
if parameter_index is not None and p.get("index") == parameter_index:
|
|
339
|
+
target = p
|
|
340
|
+
break
|
|
341
|
+
if target is None:
|
|
342
|
+
raise exc
|
|
343
|
+
raise ValueError(
|
|
344
|
+
f"set_device_parameter rejected value={value} for "
|
|
345
|
+
f"'{target.get('name')}' (index={target.get('index')}). "
|
|
346
|
+
f"Accepts min={target.get('min')}, max={target.get('max')}, "
|
|
347
|
+
f"is_quantized={target.get('is_quantized')}. "
|
|
348
|
+
f"Current value={target.get('value')} ({target.get('value_string')!r}). "
|
|
349
|
+
f"Original error: {exc}"
|
|
350
|
+
) from exc
|
|
290
351
|
|
|
291
352
|
|
|
292
353
|
def _normalize_batch_entry(entry: dict) -> dict:
|
|
@@ -428,15 +489,18 @@ def batch_set_parameters(
|
|
|
428
489
|
ctx: Context,
|
|
429
490
|
track_index: int,
|
|
430
491
|
device_index: int,
|
|
431
|
-
parameters: Any,
|
|
492
|
+
parameters: Any = None,
|
|
493
|
+
operations: Any = None,
|
|
432
494
|
) -> dict:
|
|
433
495
|
"""Set multiple device parameters in one call.
|
|
434
496
|
|
|
435
|
-
parameters: JSON array of objects. Each entry uses exactly one of:
|
|
497
|
+
parameters (or operations): JSON array of objects. Each entry uses exactly one of:
|
|
436
498
|
- {"parameter_index": N, "value": V} (preferred, aligned with set_device_parameter)
|
|
437
499
|
- {"parameter_name": "Dry/Wet", "value": V} (preferred)
|
|
438
500
|
- {"name_or_index": X, "value": V} (legacy, still accepted)
|
|
439
501
|
|
|
502
|
+
``operations`` is accepted as an alias for ``parameters`` (either works).
|
|
503
|
+
|
|
440
504
|
track_index: 0+ for regular tracks, -1/-2/... for return tracks (A/B/...), -1000 for master.
|
|
441
505
|
|
|
442
506
|
Response (v1.20.2+): the dict now includes a ``snapped_params`` list
|
|
@@ -449,7 +513,10 @@ def batch_set_parameters(
|
|
|
449
513
|
"""
|
|
450
514
|
_validate_track_index(track_index)
|
|
451
515
|
_validate_device_index(device_index)
|
|
452
|
-
|
|
516
|
+
effective = parameters if parameters is not None else operations
|
|
517
|
+
if effective is None:
|
|
518
|
+
raise ValueError("parameters (or operations) list cannot be empty")
|
|
519
|
+
parameters_list = _ensure_list(effective)
|
|
453
520
|
if not parameters_list:
|
|
454
521
|
raise ValueError("parameters list cannot be empty")
|
|
455
522
|
normalized = [_normalize_batch_entry(e) for e in parameters_list]
|
|
@@ -93,7 +93,11 @@ def analyze_loudness(
|
|
|
93
93
|
file_path: str,
|
|
94
94
|
detail: str = "summary",
|
|
95
95
|
) -> dict[str, Any]:
|
|
96
|
-
"""Analyze the integrated loudness of an audio file (
|
|
96
|
+
"""Analyze the integrated loudness of an audio file (OFFLINE — needs a rendered file).
|
|
97
|
+
|
|
98
|
+
⚠ This tool reads a file on disk. It does NOT connect to Ableton.
|
|
99
|
+
For live session monitoring while a track is playing, use
|
|
100
|
+
analyze_loudness_live() instead — no file needed.
|
|
97
101
|
|
|
98
102
|
Computes integrated LUFS (EBU R128), true peak, RMS, crest factor,
|
|
99
103
|
loudness range (LRA), and streaming platform compliance.
|
|
@@ -163,21 +163,52 @@ def _find_name_collisions(ctx: Context, name: str) -> list[int]:
|
|
|
163
163
|
return matches
|
|
164
164
|
|
|
165
165
|
|
|
166
|
+
def _resolve_color_alias(
|
|
167
|
+
color: Optional[int],
|
|
168
|
+
color_index: Optional[int],
|
|
169
|
+
) -> Optional[int]:
|
|
170
|
+
"""BUG-2026-04-26#3: accept both `color` and `color_index` keywords.
|
|
171
|
+
|
|
172
|
+
The track-creation tools used `color` while `set_track_color` used
|
|
173
|
+
`color_index`. Callers writing parallel tool batches (create + paint
|
|
174
|
+
in one shot) consistently picked the wrong name and lost a whole
|
|
175
|
+
parallel batch to the validation error. This helper accepts either,
|
|
176
|
+
rejects the conflict case, and returns the resolved value.
|
|
177
|
+
"""
|
|
178
|
+
if color is not None and color_index is not None:
|
|
179
|
+
if color != color_index:
|
|
180
|
+
raise ValueError(
|
|
181
|
+
"Pass either 'color' or 'color_index', not both with "
|
|
182
|
+
f"different values (got color={color}, color_index={color_index})"
|
|
183
|
+
)
|
|
184
|
+
return color
|
|
185
|
+
if color is not None:
|
|
186
|
+
return color
|
|
187
|
+
return color_index
|
|
188
|
+
|
|
189
|
+
|
|
166
190
|
@mcp.tool()
|
|
167
191
|
def create_midi_track(
|
|
168
192
|
ctx: Context,
|
|
169
193
|
index: int = -1,
|
|
170
194
|
name: Optional[str] = None,
|
|
171
195
|
color: Optional[int] = None,
|
|
196
|
+
color_index: Optional[int] = None,
|
|
172
197
|
) -> dict:
|
|
173
198
|
"""Create a new MIDI track. index=-1 appends at end.
|
|
174
199
|
|
|
200
|
+
`color` and `color_index` are accepted interchangeably (BUG-2026-04-26#3).
|
|
201
|
+
Both reference Ableton's 0-69 color palette. Pass either; passing
|
|
202
|
+
both with different values is rejected.
|
|
203
|
+
|
|
175
204
|
Response (v1.20.2+): when `name` is provided, the response carries
|
|
176
205
|
a ``name_collision`` bool and ``existing_tracks_with_same_name``
|
|
177
206
|
list[int]. Downstream role-based resolvers (find_tracks_by_role)
|
|
178
207
|
match duplicate names and apply mix changes twice — check the
|
|
179
208
|
warning before proceeding with mix moves on the new track's role.
|
|
180
209
|
"""
|
|
210
|
+
color = _resolve_color_alias(color, color_index)
|
|
211
|
+
|
|
181
212
|
collisions: list[int] = []
|
|
182
213
|
if name is not None and name.strip():
|
|
183
214
|
collisions = _find_name_collisions(ctx, name)
|
|
@@ -205,12 +236,18 @@ def create_audio_track(
|
|
|
205
236
|
index: int = -1,
|
|
206
237
|
name: Optional[str] = None,
|
|
207
238
|
color: Optional[int] = None,
|
|
239
|
+
color_index: Optional[int] = None,
|
|
208
240
|
) -> dict:
|
|
209
241
|
"""Create a new audio track. index=-1 appends at end.
|
|
210
242
|
|
|
243
|
+
`color` and `color_index` are accepted interchangeably (BUG-2026-04-26#3).
|
|
244
|
+
See create_midi_track for full semantics.
|
|
245
|
+
|
|
211
246
|
Response (v1.20.2+): ``name_collision`` + ``existing_tracks_with_same_name``
|
|
212
247
|
same as create_midi_track — see BUG #5 rationale there.
|
|
213
248
|
"""
|
|
249
|
+
color = _resolve_color_alias(color, color_index)
|
|
250
|
+
|
|
214
251
|
collisions: list[int] = []
|
|
215
252
|
if name is not None and name.strip():
|
|
216
253
|
collisions = _find_name_collisions(ctx, name)
|
|
@@ -309,12 +346,12 @@ def set_track_solo(ctx: Context, track_index: int, solo: bool) -> dict:
|
|
|
309
346
|
|
|
310
347
|
|
|
311
348
|
@mcp.tool()
|
|
312
|
-
def set_track_arm(ctx: Context, track_index: int,
|
|
349
|
+
def set_track_arm(ctx: Context, track_index: int, arm: bool) -> dict:
|
|
313
350
|
"""Arm or disarm a track for recording."""
|
|
314
351
|
_validate_track_index(track_index)
|
|
315
352
|
return _get_ableton(ctx).send_command("set_track_arm", {
|
|
316
353
|
"track_index": track_index,
|
|
317
|
-
"arm":
|
|
354
|
+
"arm": arm,
|
|
318
355
|
})
|
|
319
356
|
|
|
320
357
|
|