livepilot 1.23.2 → 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 (46) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/README.md +108 -10
  3. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  4. package/m4l_device/livepilot_bridge.js +39 -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/runtime/remote_commands.py +10 -0
  18. package/mcp_server/server.py +45 -24
  19. package/mcp_server/tools/agent_os.py +33 -9
  20. package/mcp_server/tools/analyzer.py +84 -23
  21. package/mcp_server/tools/browser.py +20 -1
  22. package/mcp_server/tools/devices.py +78 -11
  23. package/mcp_server/tools/perception.py +5 -1
  24. package/mcp_server/tools/tracks.py +39 -2
  25. package/mcp_server/user_corpus/__init__.py +48 -0
  26. package/mcp_server/user_corpus/manifest.py +142 -0
  27. package/mcp_server/user_corpus/plugin_engine/__init__.py +39 -0
  28. package/mcp_server/user_corpus/plugin_engine/detector.py +579 -0
  29. package/mcp_server/user_corpus/plugin_engine/manual.py +347 -0
  30. package/mcp_server/user_corpus/plugin_engine/research.py +247 -0
  31. package/mcp_server/user_corpus/runner.py +261 -0
  32. package/mcp_server/user_corpus/scanner.py +115 -0
  33. package/mcp_server/user_corpus/scanners/__init__.py +18 -0
  34. package/mcp_server/user_corpus/scanners/adg.py +79 -0
  35. package/mcp_server/user_corpus/scanners/als.py +144 -0
  36. package/mcp_server/user_corpus/scanners/amxd.py +374 -0
  37. package/mcp_server/user_corpus/scanners/plugin_preset.py +202 -0
  38. package/mcp_server/user_corpus/tools.py +904 -0
  39. package/mcp_server/user_corpus/wizard.py +224 -0
  40. package/package.json +2 -2
  41. package/remote_script/LivePilot/__init__.py +1 -1
  42. package/remote_script/LivePilot/browser.py +7 -2
  43. package/remote_script/LivePilot/devices.py +9 -0
  44. package/remote_script/LivePilot/simpler_sample.py +98 -0
  45. package/requirements.txt +3 -3
  46. 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) ────────────────────────────────────────
@@ -122,6 +122,13 @@ REMOTE_COMMANDS: frozenset[str] = frozenset({
122
122
  "ping",
123
123
  # Live 12.4+ native Simpler sample replacement (Collaborative tier)
124
124
  "replace_sample_native",
125
+ # v1.23.3: read Simpler.sample.file_path directly via Python LOM —
126
+ # primary path for classify_simpler_slices' auto-resolution. Beats
127
+ # the M4L bridge round-trip (which had a chunked-response correlation
128
+ # issue in live testing). The bridge case `get_simpler_file_path`
129
+ # remains as a forward-compat fallback in case Remote Script is
130
+ # somehow stale.
131
+ "get_simpler_file_path",
125
132
  })
126
133
 
127
134
  # M4L bridge commands — routed through TCP but handled by livepilot_bridge.js
@@ -130,6 +137,9 @@ BRIDGE_COMMANDS: frozenset[str] = frozenset({
130
137
  "get_params", "get_hidden_params", "get_auto_state", "walk_rack",
131
138
  "get_chains_deep", "get_track_cpu", "get_selected", "get_key",
132
139
  "get_clip_file_path", "replace_simpler_sample", "get_simpler_slices",
140
+ "get_simpler_file_path", # v1.23.3 — closes the v1.12 follow-up that
141
+ # left classify_simpler_slices unable to
142
+ # auto-resolve the Simpler's sample path
133
143
  "crop_simpler", "reverse_simpler", "warp_simpler",
134
144
  "get_warp_markers", "add_warp_marker", "move_warp_marker",
135
145
  "remove_warp_marker", "capture_audio", "capture_stop",
@@ -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")
@@ -1018,10 +1018,11 @@ async def classify_simpler_slices(
1018
1018
 
1019
1019
  Parameters:
1020
1020
  track_index, device_index: the Simpler to analyze
1021
- file_path: (optional) explicit WAV path. If omitted, attempts
1022
- lookup via the bridge. Bridge-native resolution is limited in
1023
- v1.11 when the sample lives in the Core Library, pass the
1024
- absolute path explicitly.
1021
+ file_path: (optional) explicit WAV path. If omitted, the bridge
1022
+ resolves it automatically via ``get_simpler_file_path``
1023
+ (v1.23.3+). Pass explicitly only when running against an .amxd
1024
+ freeze that predates the case (returns the bridge error string
1025
+ in that case so the caller knows to re-freeze).
1025
1026
 
1026
1027
  Returns: dict with ``slices`` list. Each slice entry has:
1027
1028
  index, frame, seconds, midi_pitch (36+index), label, peak, rms,
@@ -1045,25 +1046,54 @@ async def classify_simpler_slices(
1045
1046
  if enriched is None:
1046
1047
  return {"error": "Bridge returned no slice data"}
1047
1048
 
1048
- # 2. Resolve file path
1049
+ # 2. Resolve file path via Remote Script TCP path (v1.23.3+ — closes
1050
+ # the v1.12 follow-up). Reads ``device.sample.file_path`` directly
1051
+ # via Python LOM, more reliable than the M4L bridge UDP round-trip
1052
+ # (which surfaced a chunked-response correlation issue under live
1053
+ # testing). The bridge case `get_simpler_file_path` is still
1054
+ # registered as a forward-compat fallback for environments where
1055
+ # Remote Script is unavailable.
1049
1056
  wav_path = file_path
1057
+ resolve_error: str | None = None
1050
1058
  if not wav_path:
1051
- try:
1052
- file_info = await bridge.send_command(
1053
- "get_simpler_file_path", track_index, device_index
1054
- )
1055
- if isinstance(file_info, dict):
1056
- wav_path = file_info.get("file_path")
1057
- except Exception: # noqa: BLE001 — bridge command may not exist yet
1058
- wav_path = None
1059
+ ableton = ctx.request_context.lifespan_context.get("ableton")
1060
+ if ableton is not None:
1061
+ try:
1062
+ rs_resp = ableton.send_command(
1063
+ "get_simpler_file_path",
1064
+ {"track_index": track_index, "device_index": device_index},
1065
+ )
1066
+ if isinstance(rs_resp, dict):
1067
+ wav_path = rs_resp.get("file_path")
1068
+ resolve_error = rs_resp.get("error")
1069
+ except Exception as exc: # noqa: BLE001
1070
+ resolve_error = f"remote_script unreachable: {exc}"
1071
+ wav_path = None
1072
+ else:
1073
+ resolve_error = "no ableton TCP connection"
1074
+
1075
+ # Fallback: try the M4L bridge if Remote Script didn't yield a path.
1076
+ # Useful if a stale Remote Script install lacks the new handler
1077
+ # (the user can call reload_handlers to refresh without restart).
1078
+ if not wav_path:
1079
+ try:
1080
+ bridge_resp = await bridge.send_command(
1081
+ "get_simpler_file_path", track_index, device_index
1082
+ )
1083
+ if isinstance(bridge_resp, dict):
1084
+ bridge_path = bridge_resp.get("file_path")
1085
+ if bridge_path:
1086
+ wav_path = bridge_path
1087
+ resolve_error = None
1088
+ except Exception: # noqa: BLE001 — defensive
1089
+ pass
1059
1090
 
1060
1091
  if not wav_path:
1061
1092
  return {
1062
1093
  **enriched,
1063
1094
  "error": (
1064
- "No file_path available — pass file_path= explicitly. "
1065
- "Bridge-based lookup for Simpler sample paths is a v1.12 "
1066
- "follow-up."
1095
+ resolve_error
1096
+ or "No file_path available pass file_path= explicitly."
1067
1097
  ),
1068
1098
  }
1069
1099
 
@@ -1651,16 +1681,44 @@ async def verify_all_devices_health(
1651
1681
  tname = t.get("name", f"Track {tid}")
1652
1682
  if tid is None:
1653
1683
  continue
1654
- 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
1655
1694
  if skip_audio_tracks and is_audio:
1656
1695
  skipped.append({"track_index": tid, "track_name": tname,
1657
1696
  "reason": "audio_track_no_midi_input"})
1658
1697
  continue
1659
- devices = t.get("devices") or []
1660
- if skip_empty_tracks and not devices:
1661
- skipped.append({"track_index": tid, "track_name": tname,
1662
- "reason": "no_devices_on_track"})
1663
- 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
1664
1722
 
1665
1723
  # Run the per-track health check.
1666
1724
  result = await verify_device_health(
@@ -1718,7 +1776,10 @@ async def analyze_loudness_live(
1718
1776
  window_sec: float = 10.0,
1719
1777
  sample_interval_ms: int = 200,
1720
1778
  ) -> dict:
1721
- """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.
1722
1783
 
1723
1784
  BUG-2026-04-22#8 fix — the offline `analyze_loudness` requires a
1724
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]