livepilot 1.27.2 → 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.
- package/CHANGELOG.md +66 -0
- package/README.md +10 -7
- package/bin/livepilot.js +97 -39
- package/livepilot/.Codex-plugin/plugin.json +1 -1
- package/livepilot/.claude-plugin/plugin.json +1 -1
- package/livepilot/skills/livepilot-core/SKILL.md +33 -1
- package/livepilot/skills/livepilot-core/references/atlas-tool-notes.md +69 -0
- package/livepilot/skills/livepilot-core/references/overview.md +18 -11
- package/livepilot/skills/livepilot-core/references/perception.md +125 -0
- package/livepilot/skills/livepilot-devices/references/device-parameter-units.md +66 -0
- package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
- package/livepilot/skills/livepilot-mix-engine/SKILL.md +8 -0
- package/livepilot/skills/livepilot-release/SKILL.md +1 -1
- package/livepilot/skills/livepilot-sample-engine/references/splice-tools-notes.md +56 -0
- package/livepilot/skills/livepilot-wonder/SKILL.md +6 -0
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/livepilot_bridge.js +1 -1
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/__init__.py +181 -40
- package/mcp_server/atlas/overlays.py +46 -3
- package/mcp_server/atlas/tools.py +226 -86
- package/mcp_server/audit/checks.py +50 -22
- package/mcp_server/audit/state.py +10 -2
- package/mcp_server/audit/tools.py +27 -6
- package/mcp_server/composer/develop/apply.py +7 -7
- package/mcp_server/composer/fast/apply.py +29 -23
- package/mcp_server/composer/framework/atlas_resolver.py +16 -1
- package/mcp_server/composer/full/apply.py +40 -34
- package/mcp_server/composer/full/engine.py +66 -32
- package/mcp_server/composer/tools.py +4 -3
- package/mcp_server/connection.py +67 -9
- package/mcp_server/creative_constraints/engine.py +10 -2
- package/mcp_server/creative_constraints/tools.py +24 -7
- package/mcp_server/curves.py +30 -7
- package/mcp_server/device_forge/builder.py +40 -11
- package/mcp_server/device_forge/models.py +9 -0
- package/mcp_server/device_forge/tools.py +30 -11
- package/mcp_server/experiment/engine.py +29 -22
- package/mcp_server/experiment/tools.py +15 -5
- package/mcp_server/m4l_bridge.py +69 -7
- package/mcp_server/memory/taste_graph.py +43 -0
- package/mcp_server/memory/technique_store.py +26 -3
- package/mcp_server/memory/tools.py +62 -4
- package/mcp_server/mix_engine/critics.py +187 -30
- package/mcp_server/mix_engine/models.py +21 -1
- package/mcp_server/mix_engine/state_builder.py +87 -8
- package/mcp_server/mix_engine/tools.py +16 -4
- package/mcp_server/performance_engine/tools.py +56 -8
- package/mcp_server/persistence/base_store.py +11 -0
- package/mcp_server/persistence/project_store.py +132 -8
- package/mcp_server/persistence/taste_store.py +90 -7
- package/mcp_server/preview_studio/engine.py +40 -10
- package/mcp_server/preview_studio/models.py +40 -0
- package/mcp_server/preview_studio/tools.py +56 -12
- package/mcp_server/project_brain/arrangement_graph.py +4 -0
- package/mcp_server/project_brain/models.py +2 -0
- package/mcp_server/project_brain/role_graph.py +13 -7
- package/mcp_server/reference_engine/gap_analyzer.py +58 -3
- package/mcp_server/reference_engine/profile_builder.py +47 -4
- package/mcp_server/reference_engine/tools.py +6 -0
- package/mcp_server/runtime/execution_router.py +51 -1
- package/mcp_server/runtime/tools.py +0 -1
- package/mcp_server/sample_engine/sources.py +0 -2
- package/mcp_server/sample_engine/tools.py +19 -38
- package/mcp_server/semantic_moves/mix_compilers.py +276 -51
- package/mcp_server/semantic_moves/performance_compilers.py +51 -17
- package/mcp_server/semantic_moves/resolvers.py +45 -0
- package/mcp_server/semantic_moves/sound_design_compilers.py +14 -6
- package/mcp_server/semantic_moves/tools.py +80 -2
- package/mcp_server/semantic_moves/transition_compilers.py +26 -9
- package/mcp_server/server.py +26 -24
- package/mcp_server/session_continuity/tracker.py +51 -3
- package/mcp_server/song_brain/builder.py +47 -5
- package/mcp_server/song_brain/tools.py +21 -7
- package/mcp_server/sound_design/critics.py +1 -0
- package/mcp_server/splice_client/client.py +117 -33
- package/mcp_server/splice_client/http_bridge.py +15 -3
- package/mcp_server/splice_client/quota.py +28 -0
- package/mcp_server/stuckness_detector/detector.py +8 -5
- package/mcp_server/synthesis_brain/adapters/drift.py +13 -0
- package/mcp_server/synthesis_brain/adapters/meld.py +13 -0
- package/mcp_server/tools/_analyzer_engine/sample.py +36 -10
- package/mcp_server/tools/_perception_engine.py +6 -0
- package/mcp_server/tools/agent_os.py +4 -1
- package/mcp_server/tools/analyzer.py +198 -209
- package/mcp_server/tools/arrangement.py +7 -4
- package/mcp_server/tools/automation.py +24 -4
- package/mcp_server/tools/browser.py +25 -11
- package/mcp_server/tools/clips.py +6 -0
- package/mcp_server/tools/composition.py +33 -2
- package/mcp_server/tools/devices.py +53 -53
- package/mcp_server/tools/generative.py +14 -14
- package/mcp_server/tools/harmony.py +7 -7
- package/mcp_server/tools/mixing.py +4 -4
- package/mcp_server/tools/planner.py +68 -6
- package/mcp_server/tools/research.py +20 -2
- package/mcp_server/tools/theory.py +10 -10
- package/mcp_server/tools/transport.py +7 -2
- package/mcp_server/transition_engine/critics.py +13 -1
- package/mcp_server/user_corpus/tools.py +30 -1
- package/mcp_server/wonder_mode/engine.py +82 -9
- package/mcp_server/wonder_mode/session.py +32 -10
- package/mcp_server/wonder_mode/tools.py +14 -1
- package/package.json +1 -1
- package/remote_script/LivePilot/__init__.py +1 -1
- package/remote_script/LivePilot/arrangement.py +93 -33
- package/remote_script/LivePilot/browser.py +60 -4
- package/remote_script/LivePilot/devices.py +132 -62
- package/remote_script/LivePilot/mixing.py +31 -5
- package/remote_script/LivePilot/server.py +94 -22
- package/remote_script/LivePilot/transport.py +11 -0
- package/requirements.txt +5 -5
- 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
|
|
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 =
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
**
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
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
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
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
|
|
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
|
|
705
|
-
Packs), PREFER `load_browser_item(track, uri)` —
|
|
706
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
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
|
-
|
|
857
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1028
|
+
diagnose.
|
|
1055
1029
|
|
|
1056
|
-
Classification
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
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,
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
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
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
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
|
|
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
|
-
|
|
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 "
|