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.
Files changed (43) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/README.md +106 -8
  3. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  4. package/m4l_device/livepilot_bridge.js +1 -1
  5. package/mcp_server/__init__.py +1 -1
  6. package/mcp_server/atlas/cross_pack_chain.py +658 -0
  7. package/mcp_server/atlas/demo_story.py +700 -0
  8. package/mcp_server/atlas/extract_chain.py +786 -0
  9. package/mcp_server/atlas/macro_fingerprint.py +554 -0
  10. package/mcp_server/atlas/overlays.py +95 -3
  11. package/mcp_server/atlas/pack_aware_compose.py +1255 -0
  12. package/mcp_server/atlas/preset_resolver.py +238 -0
  13. package/mcp_server/atlas/tools.py +1001 -31
  14. package/mcp_server/atlas/transplant.py +1177 -0
  15. package/mcp_server/mix_engine/state_builder.py +44 -1
  16. package/mcp_server/runtime/capability_state.py +34 -3
  17. package/mcp_server/server.py +45 -24
  18. package/mcp_server/tools/agent_os.py +33 -9
  19. package/mcp_server/tools/analyzer.py +38 -7
  20. package/mcp_server/tools/browser.py +20 -1
  21. package/mcp_server/tools/devices.py +78 -11
  22. package/mcp_server/tools/perception.py +5 -1
  23. package/mcp_server/tools/tracks.py +39 -2
  24. package/mcp_server/user_corpus/__init__.py +48 -0
  25. package/mcp_server/user_corpus/manifest.py +142 -0
  26. package/mcp_server/user_corpus/plugin_engine/__init__.py +39 -0
  27. package/mcp_server/user_corpus/plugin_engine/detector.py +579 -0
  28. package/mcp_server/user_corpus/plugin_engine/manual.py +347 -0
  29. package/mcp_server/user_corpus/plugin_engine/research.py +247 -0
  30. package/mcp_server/user_corpus/runner.py +261 -0
  31. package/mcp_server/user_corpus/scanner.py +115 -0
  32. package/mcp_server/user_corpus/scanners/__init__.py +18 -0
  33. package/mcp_server/user_corpus/scanners/adg.py +79 -0
  34. package/mcp_server/user_corpus/scanners/als.py +144 -0
  35. package/mcp_server/user_corpus/scanners/amxd.py +374 -0
  36. package/mcp_server/user_corpus/scanners/plugin_preset.py +202 -0
  37. package/mcp_server/user_corpus/tools.py +904 -0
  38. package/mcp_server/user_corpus/wizard.py +224 -0
  39. package/package.json +2 -2
  40. package/remote_script/LivePilot/__init__.py +1 -1
  41. package/remote_script/LivePilot/browser.py +7 -2
  42. package/requirements.txt +3 -3
  43. 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
- if role in _ANCHOR_ROLES:
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
- analyzer_reasons.append("analyzer_stale")
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 = "stale"
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) ────────────────────────────────────────
@@ -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
- Reads the expected count from ``tests/test_tools_contract.py`` (same
438
- source of truth sync_metadata.py uses), so no second magic number.
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
- return
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
- Takes before/after sonic snapshots and the active GoalVector.
241
- Returns a score and keep/undo recommendation.
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
- Snapshots should contain: spectrum (9-band dict sub_low → air, or
244
- 8-band from pre-v1.16 .amxd builds), rms, peak. Get these from
245
- get_master_spectrum + get_master_rms before and after making changes.
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
- is_audio = bool(t.get("is_audio_track") or t.get("type") == "audio")
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
- devices = t.get("devices") or []
1690
- if skip_empty_tracks and not devices:
1691
- skipped.append({"track_index": tid, "track_name": tname,
1692
- "reason": "no_devices_on_track"})
1693
- continue
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`: 20-135 index (NOT normalized)
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`: 0-1.25 on legacy, 0-1 on AutoFilter2
263
- - Auto Filter `Env. Modulation`: -127..+127 on legacy
264
- - Compressor I, Dynamic Tube, Vocoder: pre-2010 units
265
- - EQ Three `Frequency Hi/Lo`: 50Hz-15kHz absolute
266
- - Wavetable `Osc 1 Pos`: 0-1 normalized
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
- return _get_ableton(ctx).send_command("set_device_parameter", params)
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
- parameters_list = _ensure_list(parameters)
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 (offlineno Ableton needed).
96
+ """Analyze the integrated loudness of an audio file (OFFLINEneeds 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, armed: bool) -> dict:
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": armed,
354
+ "arm": arm,
318
355
  })
319
356
 
320
357