livepilot 1.27.1 → 1.27.3

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 (117) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +10 -7
  3. package/bin/livepilot.js +156 -37
  4. package/livepilot/.Codex-plugin/plugin.json +1 -1
  5. package/livepilot/.claude-plugin/plugin.json +1 -1
  6. package/livepilot/skills/livepilot-core/SKILL.md +33 -1
  7. package/livepilot/skills/livepilot-core/references/atlas-tool-notes.md +69 -0
  8. package/livepilot/skills/livepilot-core/references/overview.md +18 -11
  9. package/livepilot/skills/livepilot-core/references/perception.md +125 -0
  10. package/livepilot/skills/livepilot-devices/references/device-parameter-units.md +66 -0
  11. package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
  12. package/livepilot/skills/livepilot-mix-engine/SKILL.md +8 -0
  13. package/livepilot/skills/livepilot-release/SKILL.md +1 -1
  14. package/livepilot/skills/livepilot-sample-engine/references/splice-tools-notes.md +56 -0
  15. package/livepilot/skills/livepilot-wonder/SKILL.md +6 -0
  16. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  17. package/m4l_device/livepilot_bridge.js +48 -11
  18. package/mcp_server/__init__.py +1 -1
  19. package/mcp_server/atlas/__init__.py +181 -40
  20. package/mcp_server/atlas/overlays.py +46 -3
  21. package/mcp_server/atlas/tools.py +226 -86
  22. package/mcp_server/audit/checks.py +50 -22
  23. package/mcp_server/audit/state.py +10 -2
  24. package/mcp_server/audit/tools.py +27 -6
  25. package/mcp_server/composer/develop/apply.py +7 -7
  26. package/mcp_server/composer/fast/apply.py +29 -23
  27. package/mcp_server/composer/fast/brief_builder.py +6 -2
  28. package/mcp_server/composer/framework/atlas_resolver.py +16 -1
  29. package/mcp_server/composer/full/apply.py +40 -34
  30. package/mcp_server/composer/full/engine.py +66 -32
  31. package/mcp_server/composer/tools.py +16 -9
  32. package/mcp_server/connection.py +69 -10
  33. package/mcp_server/creative_constraints/engine.py +10 -2
  34. package/mcp_server/creative_constraints/tools.py +24 -7
  35. package/mcp_server/curves.py +30 -7
  36. package/mcp_server/device_forge/builder.py +40 -11
  37. package/mcp_server/device_forge/models.py +9 -0
  38. package/mcp_server/device_forge/tools.py +30 -11
  39. package/mcp_server/experiment/engine.py +29 -22
  40. package/mcp_server/experiment/tools.py +15 -5
  41. package/mcp_server/m4l_bridge.py +217 -62
  42. package/mcp_server/memory/taste_graph.py +43 -0
  43. package/mcp_server/memory/technique_store.py +26 -3
  44. package/mcp_server/memory/tools.py +62 -4
  45. package/mcp_server/mix_engine/critics.py +187 -30
  46. package/mcp_server/mix_engine/models.py +21 -1
  47. package/mcp_server/mix_engine/state_builder.py +87 -8
  48. package/mcp_server/mix_engine/tools.py +16 -4
  49. package/mcp_server/performance_engine/tools.py +56 -8
  50. package/mcp_server/persistence/base_store.py +11 -0
  51. package/mcp_server/persistence/project_store.py +132 -8
  52. package/mcp_server/persistence/taste_store.py +90 -7
  53. package/mcp_server/preview_studio/engine.py +40 -10
  54. package/mcp_server/preview_studio/models.py +40 -0
  55. package/mcp_server/preview_studio/tools.py +56 -12
  56. package/mcp_server/project_brain/arrangement_graph.py +4 -0
  57. package/mcp_server/project_brain/models.py +2 -0
  58. package/mcp_server/project_brain/role_graph.py +13 -7
  59. package/mcp_server/reference_engine/gap_analyzer.py +58 -3
  60. package/mcp_server/reference_engine/profile_builder.py +47 -4
  61. package/mcp_server/reference_engine/tools.py +6 -0
  62. package/mcp_server/runtime/execution_router.py +57 -1
  63. package/mcp_server/runtime/mcp_dispatch.py +22 -0
  64. package/mcp_server/runtime/remote_commands.py +9 -4
  65. package/mcp_server/runtime/tools.py +0 -1
  66. package/mcp_server/sample_engine/sources.py +0 -2
  67. package/mcp_server/sample_engine/tools.py +19 -38
  68. package/mcp_server/semantic_moves/mix_compilers.py +276 -51
  69. package/mcp_server/semantic_moves/performance_compilers.py +51 -17
  70. package/mcp_server/semantic_moves/resolvers.py +45 -0
  71. package/mcp_server/semantic_moves/sound_design_compilers.py +14 -6
  72. package/mcp_server/semantic_moves/tools.py +80 -2
  73. package/mcp_server/semantic_moves/transition_compilers.py +26 -9
  74. package/mcp_server/server.py +27 -25
  75. package/mcp_server/session_continuity/tracker.py +51 -3
  76. package/mcp_server/song_brain/builder.py +47 -5
  77. package/mcp_server/song_brain/tools.py +21 -7
  78. package/mcp_server/sound_design/critics.py +1 -0
  79. package/mcp_server/splice_client/client.py +117 -33
  80. package/mcp_server/splice_client/http_bridge.py +15 -3
  81. package/mcp_server/splice_client/quota.py +28 -0
  82. package/mcp_server/stuckness_detector/detector.py +8 -5
  83. package/mcp_server/synthesis_brain/adapters/drift.py +13 -0
  84. package/mcp_server/synthesis_brain/adapters/meld.py +13 -0
  85. package/mcp_server/tools/_analyzer_engine/sample.py +36 -10
  86. package/mcp_server/tools/_perception_engine.py +6 -0
  87. package/mcp_server/tools/agent_os.py +4 -1
  88. package/mcp_server/tools/analyzer.py +198 -209
  89. package/mcp_server/tools/arrangement.py +7 -4
  90. package/mcp_server/tools/automation.py +24 -4
  91. package/mcp_server/tools/browser.py +25 -11
  92. package/mcp_server/tools/clips.py +6 -0
  93. package/mcp_server/tools/composition.py +33 -2
  94. package/mcp_server/tools/devices.py +53 -53
  95. package/mcp_server/tools/generative.py +14 -14
  96. package/mcp_server/tools/harmony.py +7 -7
  97. package/mcp_server/tools/mixing.py +4 -4
  98. package/mcp_server/tools/planner.py +68 -6
  99. package/mcp_server/tools/research.py +20 -2
  100. package/mcp_server/tools/theory.py +10 -10
  101. package/mcp_server/tools/transport.py +7 -2
  102. package/mcp_server/transition_engine/critics.py +13 -1
  103. package/mcp_server/user_corpus/tools.py +30 -1
  104. package/mcp_server/wonder_mode/engine.py +82 -9
  105. package/mcp_server/wonder_mode/session.py +32 -10
  106. package/mcp_server/wonder_mode/tools.py +14 -1
  107. package/package.json +1 -1
  108. package/remote_script/LivePilot/__init__.py +21 -8
  109. package/remote_script/LivePilot/arrangement.py +93 -33
  110. package/remote_script/LivePilot/browser.py +60 -4
  111. package/remote_script/LivePilot/devices.py +132 -62
  112. package/remote_script/LivePilot/mixing.py +31 -5
  113. package/remote_script/LivePilot/server.py +94 -22
  114. package/remote_script/LivePilot/tracks.py +11 -5
  115. package/remote_script/LivePilot/transport.py +11 -0
  116. package/requirements.txt +5 -5
  117. package/server.json +2 -2
@@ -140,7 +140,7 @@ async def _try_native_replace_sample(
140
140
 
141
141
  ctx.lifespan_context.pop("_native_replace_skip_reason", None)
142
142
 
143
- caps = _live_caps(ctx)
143
+ caps = await asyncio.to_thread(_live_caps, ctx)
144
144
  if not caps.has_replace_sample_native:
145
145
  _record_skip("gate_closed: tier=%s (need collaborative/12.4+)" % caps.capability_tier)
146
146
  return None
@@ -155,7 +155,9 @@ async def _try_native_replace_sample(
155
155
  if nested_device_index is not None:
156
156
  params["nested_device_index"] = int(nested_device_index)
157
157
  try:
158
- resp = ableton.send_command("replace_sample_native", params)
158
+ resp = await asyncio.to_thread(
159
+ ableton.send_command, "replace_sample_native", params
160
+ )
159
161
  except Exception as exc:
160
162
  _record_skip("dispatch_raised: %s: %s" % (type(exc).__name__, exc))
161
163
  return None
@@ -196,7 +198,7 @@ async def reconnect_bridge(ctx: Context) -> dict:
196
198
 
197
199
  bridge_state = ctx.lifespan_context.get("_bridge_state")
198
200
  if not bridge_state:
199
- return {"error": "Bridge state not available — restart the MCP server"}
201
+ return {"error": "Bridge state not available — restart the MCP server", "code": "STATE_ERROR"}
200
202
 
201
203
  if bridge_state["transport"] is not None:
202
204
  return {"ok": True, "message": "Bridge already connected on UDP 9880"}
@@ -214,6 +216,7 @@ async def reconnect_bridge(ctx: Context) -> dict:
214
216
  holder = _identify_port_holder(9880)
215
217
  return {
216
218
  "ok": False,
219
+ "code": "STATE_ERROR",
217
220
  "error": f"UDP port 9880 still in use{f' (PID {holder})' if holder else ''}. "
218
221
  "Close the other LivePilot instance first.",
219
222
  }
@@ -226,50 +229,28 @@ async def get_master_spectrum(
226
229
  samples: int = 0,
227
230
  sub_detail: bool = False,
228
231
  ) -> dict:
229
- """Get 9-band frequency analysis of the master bus.
230
-
231
- Returns band energies (fffb~ center frequencies shown in parens):
232
- sub_low 20-60 Hz (~35 Hz center) kick fundamentals, Villalobos subs
233
- sub 60-120 Hz (~85 Hz) 808s, sub-bass body
234
- low 120-250 Hz (~175 Hz) — bass body, warmth
235
- low_mid 250-500 Hz (~350 Hz) — mud zone, male vocal lows
236
- mid 500-1 kHz (~700 Hz) — vocal presence, snare body
237
- high_mid 1-2 kHz (~1.4 kHz) — consonants, pick attack
238
- high 2-4 kHz (~2.8 kHz) — presence, vocal intelligibility
239
- presence 4-8 kHz (~5.6 kHz) — cymbal definition, air of breath
240
- air 8-20 kHz (~12 kHz) — shimmer, sparkle
241
- Values 0.0-1.0.
242
-
243
- Older .amxd builds (pre-v1.16) emit the legacy 8-band layout without the
244
- explicit `sub_low` split — the server auto-detects band count from the OSC
245
- payload and picks the right name set. Re-freeze the Max device to get the
246
- 9-band resolution.
232
+ """Get 9-band frequency analysis of the master bus. Values 0.0-1.0.
233
+
234
+ Bands (low->high): sub_low (20-60Hz), sub (60-120Hz), low (120-250Hz),
235
+ low_mid (250-500Hz), mid (500Hz-1kHz), high_mid (1-2kHz), high (2-4kHz),
236
+ presence (4-8kHz), air (8-20kHz). Full Hz-range/use-case table:
237
+ livepilot-core references/perception.md#get_master_spectrum-9-band-table.
238
+ Legacy pre-v1.16 .amxd builds emit 8 bands (no sub_low split) —
239
+ auto-detected from the OSC payload.
247
240
 
248
241
  Also returns detected key/scale if enough audio has been analyzed.
249
242
  Requires LivePilot Analyzer on master track.
250
243
 
251
- BUG-2026-04-22#6 fix windowed averaging:
252
- Kick transients make single snapshots swing wildly (0.45 → 0.05 →
253
- 0.16 within a bar). When mixing, you want a STABLE band profile,
254
- not an instantaneous frame. Pass `window_ms` to sample the cache
255
- over a time window and mean-pool:
256
- - window_ms=500 → sample over 500ms (common for mix reads)
257
- - window_ms=2000 sample over 2 seconds (long-tail stability)
258
- When `window_ms=0` (default), returns a single instantaneous snapshot
259
- the legacy behavior. `samples` overrides the auto-computed sample
260
- count (defaults to window_ms / 50, minimum 3).
261
-
262
- The sampled bands are also returned as `bands_min`, `bands_max` and
263
- `bands_std` so callers can see variance within the window — useful
264
- for detecting transient-heavy content vs. sustained material.
265
-
266
- BUG-2026-04-22#15 fix — sub-band resolution:
267
- Pass `sub_detail=True` to attach a `sub_detail` dict with three
268
- finer buckets: `sub_deep` (20-45 Hz), `sub_mid` (45-60 Hz),
269
- `sub_high` (60-80 Hz). Derived from the FluCoMa mel spectrum
270
- (40 bands) rather than the 9-band cache, so it requires FluCoMa
271
- to be active. When FluCoMa is unavailable, sub_detail is omitted
272
- with a `sub_detail_warning` field explaining why.
244
+ window_ms (default 0): 0 returns a single instantaneous snapshot.
245
+ >0 (max 10000) mean-pools `samples` readings (default window_ms/50,
246
+ min 3, max 100) over that window instead — use for a stable mix read
247
+ since single frames swing wildly on transients. Also returns
248
+ bands_min/bands_max/bands_std for variance across the window.
249
+
250
+ sub_detail=True: attaches sub_detail {sub_deep 20-45Hz, sub_mid
251
+ 45-60Hz, sub_high 60-80Hz} derived from the FluCoMa 40-band mel
252
+ spectrum (requires FluCoMa active; omitted with sub_detail_warning
253
+ otherwise).
273
254
  """
274
255
  cache = _get_spectral(ctx)
275
256
  _require_analyzer(cache)
@@ -279,7 +260,7 @@ async def get_master_spectrum(
279
260
  # Each cache read is ~free; we sleep between reads to let the
280
261
  # analyzer update its internal buffer.
281
262
  if window_ms > 10000:
282
- return {"error": "window_ms must be <= 10000 (10 seconds)"}
263
+ return {"error": "window_ms must be <= 10000 (10 seconds)", "code": "INVALID_PARAM"}
283
264
  n = samples if samples > 0 else max(3, window_ms // 50)
284
265
  n = min(n, 100)
285
266
  interval = (window_ms / 1000.0) / max(n - 1, 1)
@@ -292,6 +273,7 @@ async def get_master_spectrum(
292
273
  await asyncio.sleep(interval)
293
274
  if not bands_acc:
294
275
  return {
276
+ "code": "STATE_ERROR",
295
277
  "error": "No spectrum data captured — analyzer may be stale",
296
278
  "analyzer_hint": "Ensure LivePilot_Analyzer is active on master",
297
279
  }
@@ -467,7 +449,7 @@ async def get_detected_key(ctx: Context) -> dict:
467
449
  if "error" in result:
468
450
  return result
469
451
  if not result.get("key"):
470
- return {"error": "Not enough audio analyzed yet. Play 4-8 bars for key detection."}
452
+ return {"error": "Not enough audio analyzed yet. Play 4-8 bars for key detection.", "code": "STATE_ERROR"}
471
453
  return result
472
454
 
473
455
 
@@ -590,25 +572,22 @@ async def replace_simpler_sample(
590
572
  """Load an audio file into a Simpler device by absolute file path.
591
573
 
592
574
  Replaces the currently loaded sample. The Simpler must already have
593
- a sample loaded — this replaces it, it cannot load into an empty Simpler.
594
- If the Simpler is empty (freshly created with no sample), load a sample
595
- manually first or use find_and_load_device to load a preset that already
596
- contains a sample.
597
-
598
- **Prefer `load_browser_item(track, uri)` when possible** — see P0-1 in
599
- docs/2026-04-14-bugs-discovered.md. The M4L bridge's replace path can
600
- silently keep the bootstrap placeholder in some conditions; this tool
601
- now verifies by reading back the device name and will return an error
602
- if the replace didn't actually take effect.
603
-
604
- Nested addressing (Live 12.4+ only, BUG-#1 fix from 2026-04-22):
605
- - When `chain_index` is provided, the device is resolved at
606
- `track.devices[device_index].chains[chain_index]
607
- .devices[nested_device_index or 0]`. This is how Drum Rack
608
- pad-by-pad construction works see `add_drum_rack_pad` for the
609
- high-level workflow.
610
- - chain_index is only honored by the native 12.4 path; the M4L
611
- bridge fallback cannot resolve nested paths.
575
+ a sample loaded — this cannot load into an empty Simpler. If empty,
576
+ load a sample manually first or use find_and_load_device to load a
577
+ preset that already contains a sample.
578
+
579
+ **Prefer `load_browser_item(track, uri)` when the file is browser-
580
+ indexed** more reliable (see livepilot-core references/
581
+ perception.md#replace_simpler_sample--load_sample_to_simpler). This
582
+ tool verifies by reading back the device name post-load and returns
583
+ an error if the replace didn't actually take effect.
584
+
585
+ Nested addressing (Live 12.4+ only): when `chain_index` is provided,
586
+ the device is resolved at `track.devices[device_index]
587
+ .chains[chain_index].devices[nested_device_index or 0]` how Drum
588
+ Rack pad-by-pad construction works (see `add_drum_rack_pad` for the
589
+ high-level workflow). Only the native 12.4 path honors chain_index;
590
+ the M4L bridge fallback cannot resolve nested paths.
612
591
 
613
592
  Also auto-applies post-load hygiene:
614
593
  - Sets Simpler Snap=0 (required for playback after replace)
@@ -657,6 +636,7 @@ async def replace_simpler_sample(
657
636
  return result
658
637
  if not result.get("sample_loaded"):
659
638
  return {
639
+ "code": "STATE_ERROR",
660
640
  "error": "Sample may not have loaded. Ensure the Simpler already "
661
641
  "has a sample loaded — replace_sample silently fails on empty Simplers.",
662
642
  "native_attempted": _native_dispatch_was_attempted(skip_reason),
@@ -691,20 +671,20 @@ async def load_sample_to_simpler(
691
671
  ) -> dict:
692
672
  """Load an audio file into a NEW Simpler device on a track.
693
673
 
694
- This is the full workflow for programmatic sample loading:
695
- 1. Loads a dummy sample via the browser (creates Simpler with a sample)
696
- 2. Replaces the dummy with your audio file
697
- 3. Applies post-load hygiene (Snap=0, loop defaults for warped loops)
698
- 4. Verifies by reading back the device name returns an error if
699
- the Simpler still has the bootstrap placeholder (P0-1 guard)
674
+ Creates a Simpler (native insert+replace on Live 12.4+; a
675
+ bootstrap-sample-then-replace workaround on earlier versions),
676
+ applies post-load hygiene (Snap=0, loop defaults for warped loops),
677
+ then verifies by reading back the device name errors if the
678
+ Simpler still has the bootstrap placeholder. Full rationale:
679
+ livepilot-core references/perception.md
680
+ #replace_simpler_sample--load_sample_to_simpler.
700
681
 
701
- Use this instead of replace_simpler_sample when the track has no Simpler
702
- or the Simpler is empty. Works with any audio file path.
682
+ Use this instead of replace_simpler_sample when the track has no
683
+ Simpler or the Simpler is empty. Works with any audio file path.
703
684
 
704
- **For files that exist in Ableton's browser index** (Samples, User Library,
705
- Packs), PREFER `load_browser_item(track, uri)` — it goes through Ableton's
706
- native loading path and is more reliable. This tool is a workaround for
707
- files that aren't browser-indexed.
685
+ **For files that exist in Ableton's browser index** (Samples, User
686
+ Library, Packs), PREFER `load_browser_item(track, uri)` — more
687
+ reliable. This tool is a workaround for non-browser-indexed files.
708
688
 
709
689
  Requires LivePilot Analyzer on master track.
710
690
  """
@@ -715,13 +695,13 @@ async def load_sample_to_simpler(
715
695
 
716
696
  # Live 12.4+: create an empty Simpler via insert_device, then use the
717
697
  # native replace_sample path. Skips the dummy-sample bootstrap entirely.
718
- caps = _live_caps(ctx)
698
+ caps = await asyncio.to_thread(_live_caps, ctx)
719
699
  native_attempted = False
720
700
  fallback_reason = "live_version_below_12_4"
721
701
  if caps.has_replace_sample_native:
722
702
  fallback_reason = "native_insert_device_unavailable"
723
703
  try:
724
- ins = ableton.send_command("insert_device", {
704
+ ins = await asyncio.to_thread(ableton.send_command, "insert_device", {
725
705
  "track_index": track_index,
726
706
  "device_name": "Simpler",
727
707
  })
@@ -756,33 +736,35 @@ async def load_sample_to_simpler(
756
736
 
757
737
  # Step 1: Load a sample from the browser to create Simpler with content
758
738
  try:
759
- search = ableton.send_command("search_browser", {
739
+ search = await asyncio.to_thread(ableton.send_command, "search_browser", {
760
740
  "path": "samples",
761
741
  "name_filter": "kick",
762
742
  "loadable_only": True,
763
743
  "max_results": 1,
764
744
  })
765
745
  except Exception as exc:
766
- return {"error": f"Browser search failed: {exc}"}
746
+ return {"error": f"Browser search failed: {exc}", "code": "INTERNAL"}
767
747
  results = search.get("results", [])
768
748
  if not results:
769
- return {"error": "No samples found in browser to bootstrap Simpler"}
749
+ return {"error": "No samples found in browser to bootstrap Simpler", "code": "NOT_FOUND"}
770
750
 
771
751
  # Load the dummy sample — Ableton auto-creates Simpler
772
752
  uri = results[0]["uri"]
773
753
  try:
774
- ableton.send_command("load_browser_item", {
754
+ await asyncio.to_thread(ableton.send_command, "load_browser_item", {
775
755
  "track_index": track_index,
776
756
  "uri": uri,
777
757
  })
778
758
  except Exception as exc:
779
- return {"error": f"Failed to load bootstrap sample: {exc}"}
759
+ return {"error": f"Failed to load bootstrap sample: {exc}", "code": "INTERNAL"}
780
760
 
781
761
  # Step 2: Find the newly created device (it's at the end of the chain)
782
762
  try:
783
- track_info = ableton.send_command("get_track_info", {"track_index": track_index})
763
+ track_info = await asyncio.to_thread(
764
+ ableton.send_command, "get_track_info", {"track_index": track_index}
765
+ )
784
766
  except Exception as exc:
785
- return {"error": f"Failed to read track after loading sample: {exc}"}
767
+ return {"error": f"Failed to read track after loading sample: {exc}", "code": "INTERNAL"}
786
768
  actual_device_index = len(track_info.get("devices", [])) - 1
787
769
  if actual_device_index < 0:
788
770
  actual_device_index = 0
@@ -794,7 +776,7 @@ async def load_sample_to_simpler(
794
776
  if "error" in result:
795
777
  return result
796
778
  if not result.get("sample_loaded"):
797
- return {"error": "Sample replacement failed after bootstrap"}
779
+ return {"error": "Sample replacement failed after bootstrap", "code": "INTERNAL"}
798
780
 
799
781
  # Step 4: Verify by reading back the device name (P0-1 guard)
800
782
  hygiene = await _simpler_post_load_hygiene(
@@ -823,26 +805,19 @@ async def add_drum_rack_pad(
823
805
  rack_device_index: Optional[int] = None,
824
806
  chain_name: Optional[str] = None,
825
807
  ) -> dict:
826
- """Add a new pad (chain) to a Drum Rack and load a sample into it.
827
-
828
- **BUG-2026-04-22#1 FIX** this is the tool that was missing.
829
- Previously `load_browser_item` replaced the existing chain on repeat
830
- calls, and `load_sample_to_simpler` couldn't address nested paths.
831
- This single tool does the full drum-rack pad build atomically:
832
-
833
- 1. Locates the Drum Rack on the track (auto-finds if
834
- `rack_device_index` is None — searches for class_name containing
835
- "DrumGroupDevice").
836
- 2. Inserts a new chain on the rack (`insert_rack_chain`).
837
- 3. Assigns the chain's trigger note (`set_drum_chain_note`).
838
- 4. Inserts an empty Simpler into the chain (`insert_device` with
839
- `chain_index`).
840
- 5. Calls the native Live 12.4 `replace_sample_native` with nested
841
- addressing to load the sample.
842
- 6. Sets Snap=0 post-load (playback hygiene).
843
-
844
- Requires Live 12.4+ for step 5. On earlier versions returns an error
845
- directing the caller to the bridge-based workaround.
808
+ """Add a new pad (chain) to a Drum Rack and load a sample into it — atomic.
809
+
810
+ One call does the full build: locate/auto-detect the Drum Rack
811
+ (auto-detect searches for class_name containing "DrumGroupDevice"),
812
+ insert a new chain, assign the trigger note, insert an empty Simpler
813
+ into that chain, native-replace the sample with nested addressing,
814
+ Snap=0 post-load. Full history: livepilot-core references/
815
+ perception.md#add_drum_rack_pad.
816
+
817
+ Requires Live 12.4+ for the nested-addressing sample load. On
818
+ earlier versions returns an error directing to the bridge-based
819
+ workaround (call insert_rack_chain / set_drum_chain_note /
820
+ insert_device / replace_simpler_sample individually).
846
821
 
847
822
  track_index: track containing the Drum Rack
848
823
  pad_note: MIDI note for the pad (0..127). Standard drum map:
@@ -852,26 +827,20 @@ async def add_drum_rack_pad(
852
827
  If None, auto-detects the first Drum Rack.
853
828
  chain_name: optional display name for the new chain.
854
829
 
855
- Returns: {
856
- "ok": bool,
857
- "track_index": int,
858
- "rack_device_index": int,
859
- "chain_index": int,
860
- "pad_note": int,
861
- "nested_device_index": int, # where the Simpler landed
862
- "device_name": str,
863
- "method": "native_12_4",
864
- }
830
+ Returns {ok, track_index, rack_device_index, chain_index, pad_note,
831
+ nested_device_index (where the Simpler landed), device_name,
832
+ method:"native_12_4"}.
865
833
  """
866
834
  # _simpler_post_load_hygiene is already imported at module scope
867
835
  # (line 29). Do not re-import inline — the earlier inline form used
868
836
  # the wrong relative path (..; should've been .) and crashed at
869
837
  # runtime with "No module named 'mcp_server._analyzer_engine'".
870
838
  ableton = ctx.lifespan_context["ableton"]
871
- caps = _live_caps(ctx)
839
+ caps = await asyncio.to_thread(_live_caps, ctx)
872
840
  if not caps.has_replace_sample_native:
873
841
  return {
874
842
  "ok": False,
843
+ "code": "STATE_ERROR",
875
844
  "error": (
876
845
  "add_drum_rack_pad requires Live 12.4+ for native nested "
877
846
  "sample loading. Detected tier: " + caps.capability_tier +
@@ -881,18 +850,19 @@ async def add_drum_rack_pad(
881
850
  }
882
851
 
883
852
  if not (0 <= pad_note <= 127):
884
- return {"ok": False, "error": "pad_note must be 0..127"}
853
+ return {"ok": False, "error": "pad_note must be 0..127", "code": "INVALID_PARAM"}
885
854
  if not file_path or not isinstance(file_path, str):
886
- return {"ok": False, "error": "file_path (absolute path) is required"}
855
+ return {"ok": False, "error": "file_path (absolute path) is required", "code": "INVALID_PARAM"}
887
856
 
888
857
  # Step 1: locate the Drum Rack if not provided.
889
858
  if rack_device_index is None:
890
859
  try:
891
- info = ableton.send_command(
860
+ info = await asyncio.to_thread(
861
+ ableton.send_command,
892
862
  "get_track_info", {"track_index": track_index},
893
863
  )
894
864
  except Exception as exc:
895
- return {"ok": False, "error": f"get_track_info failed: {exc}"}
865
+ return {"ok": False, "error": f"get_track_info failed: {exc}", "code": "INTERNAL"}
896
866
  devices = info.get("devices", []) if isinstance(info, dict) else []
897
867
  found_idx = None
898
868
  for idx, d in enumerate(devices):
@@ -907,52 +877,55 @@ async def add_drum_rack_pad(
907
877
  "No Drum Rack found on track. Pass `rack_device_index` "
908
878
  "explicitly, or use `insert_device('Drum Rack')` first."
909
879
  ),
880
+ "code": "NOT_FOUND",
910
881
  }
911
882
  rack_device_index = found_idx
912
883
 
913
884
  # Step 2: insert a new chain on the rack.
914
885
  try:
915
- chain_result = ableton.send_command("insert_rack_chain", {
886
+ chain_result = await asyncio.to_thread(ableton.send_command, "insert_rack_chain", {
916
887
  "track_index": track_index,
917
888
  "device_index": rack_device_index,
918
889
  "position": -1, # append to end
919
890
  })
920
891
  except Exception as exc:
921
- return {"ok": False, "error": f"insert_rack_chain failed: {exc}"}
892
+ return {"ok": False, "error": f"insert_rack_chain failed: {exc}", "code": "INTERNAL"}
922
893
  if not isinstance(chain_result, dict) or "error" in chain_result:
923
894
  return {
924
895
  "ok": False,
925
896
  "error": f"insert_rack_chain returned: {chain_result}",
897
+ "code": "INTERNAL",
926
898
  }
927
899
  chain_index = int(chain_result.get("chain_index", chain_result.get("index", 0)))
928
900
 
929
901
  # Step 3: assign pad note to the new chain.
930
902
  try:
931
- note_result = ableton.send_command("set_drum_chain_note", {
903
+ note_result = await asyncio.to_thread(ableton.send_command, "set_drum_chain_note", {
932
904
  "track_index": track_index,
933
905
  "device_index": rack_device_index,
934
906
  "chain_index": chain_index,
935
907
  "note": pad_note,
936
908
  })
937
909
  except Exception as exc:
938
- return {"ok": False, "error": f"set_drum_chain_note failed: {exc}"}
910
+ return {"ok": False, "error": f"set_drum_chain_note failed: {exc}", "code": "INTERNAL"}
939
911
  if isinstance(note_result, dict) and "error" in note_result:
940
- return {"ok": False, "error": f"set_drum_chain_note: {note_result['error']}"}
912
+ return {"ok": False, "error": f"set_drum_chain_note: {note_result['error']}", "code": "INTERNAL"}
941
913
 
942
914
  # Step 4: insert an empty Simpler into the chain.
943
915
  try:
944
- insert_result = ableton.send_command("insert_device", {
916
+ insert_result = await asyncio.to_thread(ableton.send_command, "insert_device", {
945
917
  "track_index": track_index,
946
918
  "device_index": rack_device_index,
947
919
  "chain_index": chain_index,
948
920
  "device_name": "Simpler",
949
921
  })
950
922
  except Exception as exc:
951
- return {"ok": False, "error": f"insert_device(Simpler, chain) failed: {exc}"}
923
+ return {"ok": False, "error": f"insert_device(Simpler, chain) failed: {exc}", "code": "INTERNAL"}
952
924
  if not isinstance(insert_result, dict) or "error" in insert_result:
953
925
  return {
954
926
  "ok": False,
955
927
  "error": f"insert_device into chain failed: {insert_result}",
928
+ "code": "INTERNAL",
956
929
  }
957
930
  nested_idx = int(insert_result.get("device_index", 0))
958
931
 
@@ -968,6 +941,7 @@ async def add_drum_rack_pad(
968
941
  if native is None:
969
942
  return {
970
943
  "ok": False,
944
+ "code": "INTERNAL",
971
945
  "error": "Native replace_sample failed — see logs for reason",
972
946
  "track_index": track_index,
973
947
  "rack_device_index": rack_device_index,
@@ -979,7 +953,7 @@ async def add_drum_rack_pad(
979
953
  applied_name = None
980
954
  if chain_name:
981
955
  try:
982
- rename_result = ableton.send_command("set_chain_name", {
956
+ rename_result = await asyncio.to_thread(ableton.send_command, "set_chain_name", {
983
957
  "track_index": track_index,
984
958
  "device_index": rack_device_index,
985
959
  "chain_index": chain_index,
@@ -1051,21 +1025,20 @@ async def classify_simpler_slices(
1051
1025
  break.** Slice content depends on transient detection order in the
1052
1026
  source audio — slice 0 is NOT guaranteed to be a kick. Assuming
1053
1027
  drum-rack convention produces wrong grooves that take iterations to
1054
- diagnose (see 2026-04-18 creative session for the canonical case).
1028
+ diagnose.
1055
1029
 
1056
- Classification rules (validated on "Break Ghosts 90 bpm"):
1057
- - KICK: sub+low >= 45%, high < 40%
1058
- - HAT: high >= 70% AND mid < 25% (thin metal disc = no drum body)
1059
- - SNARE: mid >= 25% AND high >= 40% AND peak >= 0.6 (broadband loud)
1060
- - ghost: peak < 0.35
1030
+ Classification thresholds: livepilot-core references/
1031
+ perception.md#classify_simpler_slices--classification-thresholds
1032
+ (KICK: sub+low >= 45%, high < 40%. HAT: high >= 70% AND mid < 25%.
1033
+ SNARE: mid >= 25% AND high >= 40% AND peak >= 0.6. ghost: peak < 0.35).
1061
1034
 
1062
1035
  Parameters:
1063
1036
  track_index, device_index: the Simpler to analyze
1064
- file_path: (optional) explicit WAV path. If omitted, the bridge
1065
- resolves it automatically via ``get_simpler_file_path``
1066
- (v1.23.3+). Pass explicitly only when running against an .amxd
1067
- freeze that predates the case (returns the bridge error string
1068
- in that case so the caller knows to re-freeze).
1037
+ file_path: (optional) explicit WAV path. If omitted, resolved
1038
+ automatically via Remote Script then M4L bridge fallback. Pass
1039
+ explicitly only against a stale .amxd freeze that predates
1040
+ auto-resolution (returns the bridge error string so the caller
1041
+ knows to re-freeze).
1069
1042
 
1070
1043
  Returns: dict with ``slices`` list. Each slice entry has:
1071
1044
  index, frame, seconds, midi_pitch (36+index), label, peak, rms,
@@ -1087,7 +1060,7 @@ async def classify_simpler_slices(
1087
1060
  )
1088
1061
  enriched = _enrich_slice_response(raw_slices)
1089
1062
  if enriched is None:
1090
- return {"error": "Bridge returned no slice data"}
1063
+ return {"error": "Bridge returned no slice data", "code": "STATE_ERROR"}
1091
1064
 
1092
1065
  # 2. Resolve file path via Remote Script TCP path (v1.23.3+ — closes
1093
1066
  # the v1.12 follow-up). Reads ``device.sample.file_path`` directly
@@ -1102,7 +1075,8 @@ async def classify_simpler_slices(
1102
1075
  ableton = ctx.request_context.lifespan_context.get("ableton")
1103
1076
  if ableton is not None:
1104
1077
  try:
1105
- rs_resp = ableton.send_command(
1078
+ rs_resp = await asyncio.to_thread(
1079
+ ableton.send_command,
1106
1080
  "get_simpler_file_path",
1107
1081
  {"track_index": track_index, "device_index": device_index},
1108
1082
  )
@@ -1134,28 +1108,32 @@ async def classify_simpler_slices(
1134
1108
  if not wav_path:
1135
1109
  return {
1136
1110
  **enriched,
1111
+ "code": "STATE_ERROR",
1137
1112
  "error": (
1138
1113
  resolve_error
1139
1114
  or "No file_path available — pass file_path= explicitly."
1140
1115
  ),
1141
1116
  }
1142
1117
 
1143
- # 3. Load WAV and build frame boundaries
1118
+ # 3. Load WAV and build frame boundaries (decode off the event loop)
1144
1119
  try:
1145
- audio, sr = sf.read(wav_path)
1120
+ audio, sr = await asyncio.to_thread(sf.read, wav_path)
1146
1121
  except (sf.LibsndfileError, sf.SoundFileError, RuntimeError, OSError) as exc:
1147
1122
  # BUG-audit-C3: corrupt / missing / non-audio files must return a
1148
1123
  # structured error dict instead of raising through the MCP framework
1149
1124
  # (inconsistent with every other tool in this module).
1150
1125
  return {
1151
1126
  **enriched,
1127
+ "code": "INTERNAL",
1152
1128
  "error": f"Could not load WAV at {wav_path!r}: {exc}",
1153
1129
  }
1154
1130
  slices = enriched["slices"]
1155
1131
  frame_boundaries = [s["frame"] for s in slices] + [len(audio)]
1156
1132
 
1157
- # 4. Classify
1158
- classifications = classify_slices(audio, sr, frame_boundaries)
1133
+ # 4. Classify (per-slice FFT loop — offload off the event loop)
1134
+ classifications = await asyncio.to_thread(
1135
+ classify_slices, audio, sr, frame_boundaries
1136
+ )
1159
1137
 
1160
1138
  # 5. Merge classification into each slice entry
1161
1139
  merged_slices = []
@@ -1372,6 +1350,30 @@ async def get_display_values(
1372
1350
  # ── Phase 3: Audio Capture ─────────────────────────────────────────────
1373
1351
 
1374
1352
 
1353
+ def _relocate_capture_file(src: str) -> Optional[str]:
1354
+ """Move a bridge-captured audio file into CAPTURE_DIR (blocking file I/O).
1355
+
1356
+ Tries common extensions the bridge might have produced. Returns the
1357
+ new path on success, or None if no matching file was found or the
1358
+ move failed (caller should leave the original path unchanged in the
1359
+ latter case). Run via asyncio.to_thread — never call directly from
1360
+ an async def.
1361
+ """
1362
+ for ext in ("", ".aiff", ".wav", ".aif"):
1363
+ src_path = src + ext if not src.endswith(ext) else src
1364
+ if os.path.isfile(src_path):
1365
+ dst_name = os.path.basename(src_path)
1366
+ dst_path = os.path.join(CAPTURE_DIR, dst_name)
1367
+ try:
1368
+ import shutil
1369
+
1370
+ shutil.move(src_path, dst_path)
1371
+ return dst_path
1372
+ except OSError:
1373
+ return None
1374
+ return None
1375
+
1376
+
1375
1377
  @mcp.tool()
1376
1378
  async def capture_audio(
1377
1379
  ctx: Context,
@@ -1407,7 +1409,7 @@ async def capture_audio(
1407
1409
 
1408
1410
  bridge = _get_m4l(ctx)
1409
1411
  # Ensure captures directory exists before sending to bridge
1410
- os.makedirs(CAPTURE_DIR, exist_ok=True)
1412
+ await asyncio.to_thread(os.makedirs, CAPTURE_DIR, exist_ok=True)
1411
1413
  duration_ms = duration_seconds * 1000
1412
1414
  result = await bridge.send_capture(
1413
1415
  "capture_audio",
@@ -1418,21 +1420,9 @@ async def capture_audio(
1418
1420
 
1419
1421
  # Move captured file from M4L device directory to CAPTURE_DIR
1420
1422
  if result.get("ok") and result.get("file_path"):
1421
- src = result["file_path"]
1422
- # Try common extensions the bridge might produce
1423
- for ext in ("", ".aiff", ".wav", ".aif"):
1424
- src_path = src + ext if not src.endswith(ext) else src
1425
- if os.path.isfile(src_path):
1426
- dst_name = os.path.basename(src_path)
1427
- dst_path = os.path.join(CAPTURE_DIR, dst_name)
1428
- try:
1429
- import shutil
1430
-
1431
- shutil.move(src_path, dst_path)
1432
- result["file_path"] = dst_path
1433
- except OSError:
1434
- pass # Leave in original location if move fails
1435
- break
1423
+ new_path = await asyncio.to_thread(_relocate_capture_file, result["file_path"])
1424
+ if new_path:
1425
+ result["file_path"] = new_path
1436
1426
 
1437
1427
  return result
1438
1428
 
@@ -1471,7 +1461,7 @@ def get_spectral_shape(ctx: Context) -> dict:
1471
1461
  data = cache.get("spectral_shape")
1472
1462
  if not data:
1473
1463
  hint = _flucoma_hint(cache)
1474
- return {"error": f"No spectral shape data — {hint}"}
1464
+ return {"error": f"No spectral shape data — {hint}", "code": "STATE_ERROR"}
1475
1465
  return {**data["value"], "age_ms": data["age_ms"]}
1476
1466
 
1477
1467
 
@@ -1486,7 +1476,7 @@ def get_mel_spectrum(ctx: Context) -> dict:
1486
1476
  data = cache.get("mel_bands")
1487
1477
  if not data:
1488
1478
  hint = _flucoma_hint(cache)
1489
- return {"error": f"No mel data — {hint}"}
1479
+ return {"error": f"No mel data — {hint}", "code": "STATE_ERROR"}
1490
1480
  return {"mel_bands": data["value"], "band_count": len(data["value"]), "age_ms": data["age_ms"]}
1491
1481
 
1492
1482
 
@@ -1501,7 +1491,7 @@ def get_chroma(ctx: Context) -> dict:
1501
1491
  data = cache.get("chroma")
1502
1492
  if not data:
1503
1493
  hint = _flucoma_hint(cache)
1504
- return {"error": f"No chroma data — {hint}"}
1494
+ return {"error": f"No chroma data — {hint}", "code": "STATE_ERROR"}
1505
1495
  values = data["value"]
1506
1496
  chroma_dict = {PITCH_NAMES[i]: round(v, 3) for i, v in enumerate(values[:12])}
1507
1497
  max_val = max(values[:12]) if values else 0
@@ -1521,7 +1511,7 @@ def get_onsets(ctx: Context) -> dict:
1521
1511
  data = cache.get("onset")
1522
1512
  if not data:
1523
1513
  hint = _flucoma_hint(cache)
1524
- return {"error": f"No onset data — {hint}"}
1514
+ return {"error": f"No onset data — {hint}", "code": "STATE_ERROR"}
1525
1515
  return {**data["value"], "age_ms": data["age_ms"]}
1526
1516
 
1527
1517
 
@@ -1536,7 +1526,7 @@ def get_novelty(ctx: Context) -> dict:
1536
1526
  data = cache.get("novelty")
1537
1527
  if not data:
1538
1528
  hint = _flucoma_hint(cache)
1539
- return {"error": f"No novelty data — {hint}"}
1529
+ return {"error": f"No novelty data — {hint}", "code": "STATE_ERROR"}
1540
1530
  return {**data["value"], "age_ms": data["age_ms"]}
1541
1531
 
1542
1532
 
@@ -1551,17 +1541,13 @@ async def verify_device_health(
1551
1541
  ) -> dict:
1552
1542
  """Fire a test MIDI note at a track's instrument and check for output.
1553
1543
 
1554
- BUG-2026-04-22#19 fix — parameter_count alone can't tell you whether
1555
- an AU/VST is alive. Plenty of "loaded" plugins return 19 params and
1556
- silence. This tool does the real-world check:
1557
-
1558
- 1. Snapshot the track meter.
1559
- 2. Emit a MIDI note at the specified pitch/velocity.
1560
- 3. Sample the track meter for `test_duration_ms` (peak across samples).
1561
- 4. Compare the peak to a threshold; report alive vs dead.
1562
-
1563
- The meter readout is taken with `get_track_meters(samples=N)` so the
1564
- BUG-#7 "left=right=0 while level>0" artifact can't cause false negatives.
1544
+ parameter_count alone can't tell you whether an AU/VST is alive —
1545
+ plenty of "loaded" plugins return N params and silence. Real-world
1546
+ check: snapshot the track meter, emit a MIDI note at the specified
1547
+ pitch/velocity, sample the meter for `test_duration_ms` (peak across
1548
+ samples, dodging a left=right=0-while-level>0 meter artifact), and
1549
+ compare the peak to `threshold`. Common dead-device causes and
1550
+ history: livepilot-core references/perception.md#verify_device_health--verify_all_devices_health.
1565
1551
 
1566
1552
  track_index: track with the instrument to verify
1567
1553
  test_midi_note: pitch to fire (default C3 / 60 — safe for most samples)
@@ -1570,14 +1556,8 @@ async def verify_device_health(
1570
1556
  threshold: peak level below which the device is considered dead
1571
1557
  (default 0.005 — roughly -46 dBFS)
1572
1558
 
1573
- Returns: {
1574
- "ok": bool,
1575
- "alive": bool,
1576
- "peak_level": float,
1577
- "threshold": float,
1578
- "samples_taken": int,
1579
- "hint": str, # actionable advice when dead
1580
- }
1559
+ Returns {ok, alive, peak_level, threshold, samples_taken, hint
1560
+ (actionable advice when dead)}.
1581
1561
 
1582
1562
  Requires LivePilot Analyzer on master track and a playable instrument
1583
1563
  on the target track. Prefer this over trying to eyeball parameter_count.
@@ -1590,15 +1570,15 @@ async def verify_device_health(
1590
1570
  if test_duration_ms > 2000:
1591
1571
  test_duration_ms = 2000
1592
1572
  if not 1 <= test_velocity <= 127:
1593
- return {"ok": False, "error": "test_velocity must be 1-127"}
1573
+ return {"ok": False, "error": "test_velocity must be 1-127", "code": "INVALID_PARAM"}
1594
1574
  if not 0 <= test_midi_note <= 127:
1595
- return {"ok": False, "error": "test_midi_note must be 0-127"}
1575
+ return {"ok": False, "error": "test_midi_note must be 0-127", "code": "INVALID_PARAM"}
1596
1576
 
1597
1577
  # Fire the test note via the remote script's play_note helper. Fall back
1598
1578
  # to a raw MIDI event if the helper isn't available.
1599
1579
  fired = False
1600
1580
  try:
1601
- resp = ableton.send_command("fire_test_note", {
1581
+ resp = await asyncio.to_thread(ableton.send_command, "fire_test_note", {
1602
1582
  "track_index": track_index,
1603
1583
  "midi_note": test_midi_note,
1604
1584
  "velocity": test_velocity,
@@ -1613,6 +1593,7 @@ async def verify_device_health(
1613
1593
  # Graceful degradation when the remote-script helper isn't present.
1614
1594
  return {
1615
1595
  "ok": False,
1596
+ "code": "STATE_ERROR",
1616
1597
  "error": (
1617
1598
  "fire_test_note handler not available on this remote script. "
1618
1599
  "Update LivePilot's remote script (npx livepilot --install + "
@@ -1628,7 +1609,7 @@ async def verify_device_health(
1628
1609
  samples_taken = 0
1629
1610
  for i in range(n):
1630
1611
  try:
1631
- snap = ableton.send_command("get_track_meters", {
1612
+ snap = await asyncio.to_thread(ableton.send_command, "get_track_meters", {
1632
1613
  "track_index": track_index,
1633
1614
  })
1634
1615
  except Exception as exc:
@@ -1650,7 +1631,9 @@ async def verify_device_health(
1650
1631
 
1651
1632
  # Always clean up the scratch clip, even on errors.
1652
1633
  try:
1653
- ableton.send_command("cleanup_test_note", {"track_index": track_index})
1634
+ await asyncio.to_thread(
1635
+ ableton.send_command, "cleanup_test_note", {"track_index": track_index}
1636
+ )
1654
1637
  except Exception as exc:
1655
1638
  logger.debug("cleanup_test_note failed: %s", exc)
1656
1639
 
@@ -1708,11 +1691,11 @@ async def verify_all_devices_health(
1708
1691
  """
1709
1692
  ableton = ctx.lifespan_context["ableton"]
1710
1693
  try:
1711
- session = ableton.send_command("get_session_info", {})
1694
+ session = await asyncio.to_thread(ableton.send_command, "get_session_info", {})
1712
1695
  except Exception as exc:
1713
- return {"ok": False, "error": f"get_session_info failed: {exc}"}
1696
+ return {"ok": False, "error": f"get_session_info failed: {exc}", "code": "INTERNAL"}
1714
1697
  if not isinstance(session, dict):
1715
- return {"ok": False, "error": "Unexpected get_session_info response"}
1698
+ return {"ok": False, "error": "Unexpected get_session_info response", "code": "INTERNAL"}
1716
1699
 
1717
1700
  tracks = session.get("tracks", []) or []
1718
1701
  alive: list = []
@@ -1748,7 +1731,8 @@ async def verify_all_devices_health(
1748
1731
  # devices) would change a hot-path payload size for every caller.
1749
1732
  if skip_empty_tracks:
1750
1733
  try:
1751
- track_info = ableton.send_command(
1734
+ track_info = await asyncio.to_thread(
1735
+ ableton.send_command,
1752
1736
  "get_track_info", {"track_index": tid},
1753
1737
  )
1754
1738
  except Exception:
@@ -1809,7 +1793,7 @@ def get_momentary_loudness(ctx: Context) -> dict:
1809
1793
  data = cache.get("loudness")
1810
1794
  if not data:
1811
1795
  hint = _flucoma_hint(cache)
1812
- return {"error": f"No loudness data — {hint}"}
1796
+ return {"error": f"No loudness data — {hint}", "code": "STATE_ERROR"}
1813
1797
  return {**data["value"], "age_ms": data["age_ms"]}
1814
1798
 
1815
1799
 
@@ -1821,13 +1805,11 @@ async def analyze_loudness_live(
1821
1805
  ) -> dict:
1822
1806
  """Analyze the currently-playing master output's loudness over a window (LIVE).
1823
1807
 
1824
- Use this tool during a session — no rendered file needed.
1825
- For offline analysis of an exported audio file use analyze_loudness() instead.
1826
-
1827
- BUG-2026-04-22#8 fix the offline `analyze_loudness` requires a
1828
- rendered file. This tool samples the LivePilot analyzer's realtime
1829
- momentary LUFS / true peak stream over `window_sec` and reports
1830
- integrated + max statistics. No render required.
1808
+ Use this tool during a session — no rendered file needed. For
1809
+ offline analysis of an exported audio file use analyze_loudness()
1810
+ instead. Samples the LivePilot analyzer's realtime momentary LUFS /
1811
+ true peak stream over `window_sec` and reports integrated + max
1812
+ statistics.
1831
1813
 
1832
1814
  Requires FluCoMa package in Max and playback to be running. Best
1833
1815
  called while the section you want to measure is actually playing.
@@ -1847,9 +1829,9 @@ async def analyze_loudness_live(
1847
1829
  }
1848
1830
  """
1849
1831
  if window_sec <= 0 or window_sec > 120:
1850
- return {"error": "window_sec must be > 0 and <= 120"}
1832
+ return {"error": "window_sec must be > 0 and <= 120", "code": "INVALID_PARAM"}
1851
1833
  if sample_interval_ms < 50 or sample_interval_ms > 5000:
1852
- return {"error": "sample_interval_ms must be 50..5000"}
1834
+ return {"error": "sample_interval_ms must be 50..5000", "code": "INVALID_PARAM"}
1853
1835
 
1854
1836
  cache = _get_spectral(ctx)
1855
1837
  _require_analyzer(cache)
@@ -1859,10 +1841,10 @@ async def analyze_loudness_live(
1859
1841
  preview = cache.get("loudness")
1860
1842
  if not preview:
1861
1843
  hint = _flucoma_hint(cache)
1862
- return {"error": f"No live loudness stream — {hint}"}
1844
+ return {"error": f"No live loudness stream — {hint}", "code": "STATE_ERROR"}
1863
1845
 
1864
1846
  try:
1865
- session = ableton.send_command("get_session_info", {})
1847
+ session = await asyncio.to_thread(ableton.send_command, "get_session_info", {})
1866
1848
  is_playing = bool(session.get("is_playing", False))
1867
1849
  except Exception:
1868
1850
  is_playing = None
@@ -1888,7 +1870,7 @@ async def analyze_loudness_live(
1888
1870
  await asyncio.sleep(interval_s)
1889
1871
 
1890
1872
  if not lufs_vals:
1891
- return {"error": "No valid loudness samples captured over the window"}
1873
+ return {"error": "No valid loudness samples captured over the window", "code": "STATE_ERROR"}
1892
1874
 
1893
1875
  integrated = sum(lufs_vals) / len(lufs_vals)
1894
1876
  result = {
@@ -2016,7 +1998,11 @@ async def compressor_set_sidechain(
2016
1998
  if source_channel:
2017
1999
  params["source_channel"] = str(source_channel)
2018
2000
  ableton = ctx.lifespan_context["ableton"]
2019
- return ableton.send_command("set_compressor_sidechain", params)
2001
+ # Offload the blocking TCP round-trip off the asyncio event loop so this
2002
+ # async tool doesn't freeze every other concurrent handler + the bridge.
2003
+ return await asyncio.to_thread(
2004
+ ableton.send_command, "set_compressor_sidechain", params
2005
+ )
2020
2006
 
2021
2007
 
2022
2008
  # ──────────────────────────────────────────────────────────────────────
@@ -2100,6 +2086,7 @@ def ensure_analyzer_on_master(ctx: Context) -> dict:
2100
2086
  except Exception as exc:
2101
2087
  return {
2102
2088
  "status": "failed",
2089
+ "code": "INTERNAL",
2103
2090
  "error": f"Could not read master track: {exc}",
2104
2091
  "hint": "Verify MCP connection to Ableton; retry with get_session_info first.",
2105
2092
  }
@@ -2154,6 +2141,7 @@ def ensure_analyzer_on_master(ctx: Context) -> dict:
2154
2141
  if _analyzer_amxd_installed_at_user_library():
2155
2142
  return {
2156
2143
  "status": "cache_cold",
2144
+ "code": "STATE_ERROR",
2157
2145
  "error": str(exc),
2158
2146
  "hint": (
2159
2147
  "LivePilot_Analyzer.amxd is installed at "
@@ -2169,6 +2157,7 @@ def ensure_analyzer_on_master(ctx: Context) -> dict:
2169
2157
  }
2170
2158
  return {
2171
2159
  "status": "install_required",
2160
+ "code": "STATE_ERROR",
2172
2161
  "error": str(exc),
2173
2162
  "hint": (
2174
2163
  "LivePilot_Analyzer not found in Ableton's browser. Install "