livepilot 1.14.1 → 1.16.0
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 +176 -1
- package/README.md +6 -6
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/device_atlas.json +91219 -7161
- package/mcp_server/atlas/tools.py +30 -2
- package/mcp_server/runtime/live_version.py +4 -2
- package/mcp_server/runtime/remote_commands.py +5 -0
- package/mcp_server/sample_engine/tools.py +692 -60
- package/mcp_server/splice_client/client.py +511 -65
- package/mcp_server/splice_client/http_bridge.py +361 -0
- package/mcp_server/splice_client/models.py +266 -2
- package/mcp_server/splice_client/quota.py +229 -0
- package/mcp_server/tools/_analyzer_engine/__init__.py +4 -0
- package/mcp_server/tools/_analyzer_engine/sample.py +73 -0
- package/mcp_server/tools/analyzer.py +666 -6
- package/mcp_server/tools/browser.py +164 -13
- package/mcp_server/tools/devices.py +56 -11
- package/mcp_server/tools/mixing.py +64 -15
- package/mcp_server/tools/scales.py +18 -6
- package/mcp_server/tools/tracks.py +92 -4
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +2 -1
- package/remote_script/LivePilot/_clip_helpers.py +86 -0
- package/remote_script/LivePilot/_drum_helpers.py +40 -0
- package/remote_script/LivePilot/_scale_helpers.py +87 -0
- package/remote_script/LivePilot/arrangement.py +44 -15
- package/remote_script/LivePilot/clips.py +182 -2
- package/remote_script/LivePilot/devices.py +82 -2
- package/remote_script/LivePilot/notes.py +17 -2
- package/remote_script/LivePilot/scales.py +31 -16
- package/remote_script/LivePilot/simpler_sample.py +186 -0
- package/server.json +3 -3
|
@@ -11,6 +11,7 @@ important for BUG-C1's refactor.
|
|
|
11
11
|
|
|
12
12
|
from __future__ import annotations
|
|
13
13
|
|
|
14
|
+
import asyncio
|
|
14
15
|
import logging
|
|
15
16
|
import os
|
|
16
17
|
from typing import Optional
|
|
@@ -64,6 +65,112 @@ def _enrich_slice_response(response: Optional[dict]) -> Optional[dict]:
|
|
|
64
65
|
return enriched
|
|
65
66
|
|
|
66
67
|
|
|
68
|
+
def _live_caps(ctx, *, force_refresh: bool = False):
|
|
69
|
+
"""Read (or lazily compute + cache) LiveVersionCapabilities on the context.
|
|
70
|
+
|
|
71
|
+
On first successful probe, caches the result in
|
|
72
|
+
``ctx.lifespan_context["_live_caps"]``. Subsequent calls short-circuit
|
|
73
|
+
to the cache unless ``force_refresh=True``.
|
|
74
|
+
|
|
75
|
+
If Ableton is unreachable OR returns no ``live_version`` field,
|
|
76
|
+
returns a conservative (12, 0, 0) fallback BUT does NOT cache it.
|
|
77
|
+
Caching the fallback pins the whole session to the oldest capability
|
|
78
|
+
tier even after Live finishes initializing — mirrors the pattern in
|
|
79
|
+
remote_script/LivePilot/version_detect.py::get_live_version, where
|
|
80
|
+
the same bug was fixed on the Remote Script side.
|
|
81
|
+
"""
|
|
82
|
+
from mcp_server.runtime.live_version import LiveVersionCapabilities
|
|
83
|
+
|
|
84
|
+
lsc = ctx.lifespan_context
|
|
85
|
+
if not force_refresh:
|
|
86
|
+
cached = lsc.get("_live_caps")
|
|
87
|
+
if cached is not None:
|
|
88
|
+
return cached
|
|
89
|
+
|
|
90
|
+
ableton = lsc.get("ableton")
|
|
91
|
+
if ableton is None:
|
|
92
|
+
logger.debug("_live_caps: no ableton in lifespan — returning 12.0.0 fallback (uncached)")
|
|
93
|
+
return LiveVersionCapabilities.from_version_string("12.0.0")
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
info = ableton.send_command("get_session_info") or {}
|
|
97
|
+
except Exception as exc:
|
|
98
|
+
logger.debug("_live_caps: get_session_info raised %s — returning 12.0.0 fallback (uncached)", exc)
|
|
99
|
+
return LiveVersionCapabilities.from_version_string("12.0.0")
|
|
100
|
+
|
|
101
|
+
version_str = info.get("live_version")
|
|
102
|
+
if not version_str:
|
|
103
|
+
logger.debug("_live_caps: get_session_info returned no live_version — returning 12.0.0 fallback (uncached)")
|
|
104
|
+
return LiveVersionCapabilities.from_version_string("12.0.0")
|
|
105
|
+
|
|
106
|
+
caps = LiveVersionCapabilities.from_version_string(version_str)
|
|
107
|
+
lsc["_live_caps"] = caps
|
|
108
|
+
logger.debug("_live_caps: cached %s (tier=%s)", version_str, caps.capability_tier)
|
|
109
|
+
return caps
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
async def _try_native_replace_sample(
|
|
113
|
+
ctx,
|
|
114
|
+
track_index: int,
|
|
115
|
+
device_index: int,
|
|
116
|
+
file_path: str,
|
|
117
|
+
chain_index: Optional[int] = None,
|
|
118
|
+
nested_device_index: Optional[int] = None,
|
|
119
|
+
):
|
|
120
|
+
"""Attempt the Live 12.4+ native SimplerDevice.replace_sample path.
|
|
121
|
+
|
|
122
|
+
Returns the remote-script response dict on success, or None if the
|
|
123
|
+
native path is unavailable (pre-12.4) or failed (caller should fall
|
|
124
|
+
back to the M4L-bridge path).
|
|
125
|
+
|
|
126
|
+
When `chain_index` is provided, the remote script walks into
|
|
127
|
+
`track.devices[device_index].chains[chain_index].devices[
|
|
128
|
+
nested_device_index or 0]` — this is how Drum Rack pad-by-pad
|
|
129
|
+
construction is unblocked (BUG-#1 in docs/2026-04-22-bugs-discovered.md).
|
|
130
|
+
|
|
131
|
+
A native "failure" is any of: gate closed, dispatch exception, non-dict
|
|
132
|
+
response, error field present, or missing sample_loaded flag. Each
|
|
133
|
+
failure path records a skip reason on ``ctx.lifespan_context[
|
|
134
|
+
"_native_replace_skip_reason"]`` so callers can surface it in the
|
|
135
|
+
bridge-path response and logs at INFO for live debugging.
|
|
136
|
+
"""
|
|
137
|
+
def _record_skip(reason: str) -> None:
|
|
138
|
+
ctx.lifespan_context["_native_replace_skip_reason"] = reason
|
|
139
|
+
logger.info("native replace_sample skipped — %s", reason)
|
|
140
|
+
|
|
141
|
+
ctx.lifespan_context.pop("_native_replace_skip_reason", None)
|
|
142
|
+
|
|
143
|
+
caps = _live_caps(ctx)
|
|
144
|
+
if not caps.has_replace_sample_native:
|
|
145
|
+
_record_skip("gate_closed: tier=%s (need collaborative/12.4+)" % caps.capability_tier)
|
|
146
|
+
return None
|
|
147
|
+
ableton = ctx.lifespan_context["ableton"]
|
|
148
|
+
params = {
|
|
149
|
+
"track_index": track_index,
|
|
150
|
+
"device_index": device_index,
|
|
151
|
+
"file_path": file_path,
|
|
152
|
+
}
|
|
153
|
+
if chain_index is not None:
|
|
154
|
+
params["chain_index"] = int(chain_index)
|
|
155
|
+
if nested_device_index is not None:
|
|
156
|
+
params["nested_device_index"] = int(nested_device_index)
|
|
157
|
+
try:
|
|
158
|
+
resp = ableton.send_command("replace_sample_native", params)
|
|
159
|
+
except Exception as exc:
|
|
160
|
+
_record_skip("dispatch_raised: %s: %s" % (type(exc).__name__, exc))
|
|
161
|
+
return None
|
|
162
|
+
if not isinstance(resp, dict):
|
|
163
|
+
_record_skip("non_dict_response: type=%s" % type(resp).__name__)
|
|
164
|
+
return None
|
|
165
|
+
if "error" in resp:
|
|
166
|
+
_record_skip("remote_error: code=%s msg=%s" % (resp.get("code"), resp.get("error")))
|
|
167
|
+
return None
|
|
168
|
+
if not resp.get("sample_loaded"):
|
|
169
|
+
_record_skip("missing_sample_loaded: resp=%s" % resp)
|
|
170
|
+
return None
|
|
171
|
+
return resp
|
|
172
|
+
|
|
173
|
+
|
|
67
174
|
@mcp.tool()
|
|
68
175
|
async def reconnect_bridge(ctx: Context) -> dict:
|
|
69
176
|
"""Attempt to reconnect the M4L UDP bridge (port 9880).
|
|
@@ -101,7 +208,11 @@ async def reconnect_bridge(ctx: Context) -> dict:
|
|
|
101
208
|
|
|
102
209
|
|
|
103
210
|
@mcp.tool()
|
|
104
|
-
def get_master_spectrum(
|
|
211
|
+
async def get_master_spectrum(
|
|
212
|
+
ctx: Context,
|
|
213
|
+
window_ms: int = 0,
|
|
214
|
+
samples: int = 0,
|
|
215
|
+
) -> dict:
|
|
105
216
|
"""Get 8-band frequency analysis of the master bus.
|
|
106
217
|
|
|
107
218
|
Returns band energies: sub (20-60Hz), low (60-200Hz), low_mid (200-500Hz),
|
|
@@ -110,10 +221,79 @@ def get_master_spectrum(ctx: Context) -> dict:
|
|
|
110
221
|
|
|
111
222
|
Also returns detected key/scale if enough audio has been analyzed.
|
|
112
223
|
Requires LivePilot Analyzer on master track.
|
|
224
|
+
|
|
225
|
+
BUG-2026-04-22#6 fix — windowed averaging:
|
|
226
|
+
Kick transients make single snapshots swing wildly (0.45 → 0.05 →
|
|
227
|
+
0.16 within a bar). When mixing, you want a STABLE band profile,
|
|
228
|
+
not an instantaneous frame. Pass `window_ms` to sample the cache
|
|
229
|
+
over a time window and mean-pool:
|
|
230
|
+
- window_ms=500 → sample over 500ms (common for mix reads)
|
|
231
|
+
- window_ms=2000 → sample over 2 seconds (long-tail stability)
|
|
232
|
+
When `window_ms=0` (default), returns a single instantaneous snapshot
|
|
233
|
+
— the legacy behavior. `samples` overrides the auto-computed sample
|
|
234
|
+
count (defaults to window_ms / 50, minimum 3).
|
|
235
|
+
|
|
236
|
+
The sampled bands are also returned as `bands_min`, `bands_max` and
|
|
237
|
+
`bands_std` so callers can see variance within the window — useful
|
|
238
|
+
for detecting transient-heavy content vs. sustained material.
|
|
113
239
|
"""
|
|
114
240
|
cache = _get_spectral(ctx)
|
|
115
241
|
_require_analyzer(cache)
|
|
116
242
|
|
|
243
|
+
if window_ms and window_ms > 0:
|
|
244
|
+
# Windowed sampling — mean-pool N readings across the window.
|
|
245
|
+
# Each cache read is ~free; we sleep between reads to let the
|
|
246
|
+
# analyzer update its internal buffer.
|
|
247
|
+
if window_ms > 10000:
|
|
248
|
+
return {"error": "window_ms must be <= 10000 (10 seconds)"}
|
|
249
|
+
n = samples if samples > 0 else max(3, window_ms // 50)
|
|
250
|
+
n = min(n, 100)
|
|
251
|
+
interval = (window_ms / 1000.0) / max(n - 1, 1)
|
|
252
|
+
bands_acc: list[dict] = []
|
|
253
|
+
for i in range(n):
|
|
254
|
+
snap = cache.get("spectrum")
|
|
255
|
+
if snap and snap.get("value"):
|
|
256
|
+
bands_acc.append(snap["value"])
|
|
257
|
+
if i < n - 1:
|
|
258
|
+
await asyncio.sleep(interval)
|
|
259
|
+
if not bands_acc:
|
|
260
|
+
return {
|
|
261
|
+
"error": "No spectrum data captured — analyzer may be stale",
|
|
262
|
+
"analyzer_hint": "Ensure LivePilot_Analyzer is active on master",
|
|
263
|
+
}
|
|
264
|
+
# Aggregate: mean / min / max / stddev per band.
|
|
265
|
+
keys = set()
|
|
266
|
+
for s in bands_acc:
|
|
267
|
+
keys.update(s.keys())
|
|
268
|
+
bands_mean = {}
|
|
269
|
+
bands_min = {}
|
|
270
|
+
bands_max = {}
|
|
271
|
+
bands_std = {}
|
|
272
|
+
for k in keys:
|
|
273
|
+
vals = [s.get(k, 0.0) for s in bands_acc]
|
|
274
|
+
mean = sum(vals) / len(vals)
|
|
275
|
+
bands_mean[k] = round(mean, 4)
|
|
276
|
+
bands_min[k] = round(min(vals), 4)
|
|
277
|
+
bands_max[k] = round(max(vals), 4)
|
|
278
|
+
if len(vals) > 1:
|
|
279
|
+
var = sum((v - mean) ** 2 for v in vals) / len(vals)
|
|
280
|
+
bands_std[k] = round(var ** 0.5, 4)
|
|
281
|
+
else:
|
|
282
|
+
bands_std[k] = 0.0
|
|
283
|
+
result = {
|
|
284
|
+
"bands": bands_mean,
|
|
285
|
+
"bands_min": bands_min,
|
|
286
|
+
"bands_max": bands_max,
|
|
287
|
+
"bands_std": bands_std,
|
|
288
|
+
"window_ms": window_ms,
|
|
289
|
+
"samples_collected": len(bands_acc),
|
|
290
|
+
}
|
|
291
|
+
key_data = cache.get("key")
|
|
292
|
+
if key_data:
|
|
293
|
+
result["detected_key"] = key_data["value"]
|
|
294
|
+
return result
|
|
295
|
+
|
|
296
|
+
# Legacy instantaneous path
|
|
117
297
|
result = {}
|
|
118
298
|
spectrum = cache.get("spectrum")
|
|
119
299
|
if spectrum:
|
|
@@ -322,6 +502,8 @@ async def replace_simpler_sample(
|
|
|
322
502
|
track_index: int,
|
|
323
503
|
device_index: int,
|
|
324
504
|
file_path: str,
|
|
505
|
+
chain_index: Optional[int] = None,
|
|
506
|
+
nested_device_index: Optional[int] = None,
|
|
325
507
|
) -> dict:
|
|
326
508
|
"""Load an audio file into a Simpler device by absolute file path.
|
|
327
509
|
|
|
@@ -337,6 +519,15 @@ async def replace_simpler_sample(
|
|
|
337
519
|
now verifies by reading back the device name and will return an error
|
|
338
520
|
if the replace didn't actually take effect.
|
|
339
521
|
|
|
522
|
+
Nested addressing (Live 12.4+ only, BUG-#1 fix from 2026-04-22):
|
|
523
|
+
- When `chain_index` is provided, the device is resolved at
|
|
524
|
+
`track.devices[device_index].chains[chain_index]
|
|
525
|
+
.devices[nested_device_index or 0]`. This is how Drum Rack
|
|
526
|
+
pad-by-pad construction works — see `add_drum_rack_pad` for the
|
|
527
|
+
high-level workflow.
|
|
528
|
+
- chain_index is only honored by the native 12.4 path; the M4L
|
|
529
|
+
bridge fallback cannot resolve nested paths.
|
|
530
|
+
|
|
340
531
|
Also auto-applies post-load hygiene:
|
|
341
532
|
- Sets Simpler Snap=0 (required for playback after replace)
|
|
342
533
|
- For warped loops (filename contains 'NNbpm'), sets S Start=0,
|
|
@@ -350,12 +541,29 @@ async def replace_simpler_sample(
|
|
|
350
541
|
_require_analyzer(cache)
|
|
351
542
|
bridge = _get_m4l(ctx)
|
|
352
543
|
ableton = ctx.lifespan_context["ableton"]
|
|
544
|
+
|
|
545
|
+
# Live 12.4+: prefer the native SimplerDevice.replace_sample path.
|
|
546
|
+
native = await _try_native_replace_sample(
|
|
547
|
+
ctx, track_index, device_index, file_path,
|
|
548
|
+
chain_index=chain_index, nested_device_index=nested_device_index,
|
|
549
|
+
)
|
|
550
|
+
if native is not None:
|
|
551
|
+
hygiene = await _simpler_post_load_hygiene(
|
|
552
|
+
bridge, ableton, track_index, device_index, file_path
|
|
553
|
+
)
|
|
554
|
+
if not hygiene.get("verified"):
|
|
555
|
+
return hygiene
|
|
556
|
+
result = dict(native)
|
|
557
|
+
result.update(hygiene)
|
|
558
|
+
result["method"] = "native_12_4" # preserved in case hygiene ever adds its own key
|
|
559
|
+
return result
|
|
560
|
+
|
|
561
|
+
# Pre-12.4 fallback: M4L bridge path (unchanged behavior).
|
|
562
|
+
skip_reason = ctx.lifespan_context.get("_native_replace_skip_reason")
|
|
353
563
|
result = await bridge.send_command(
|
|
354
564
|
"replace_simpler_sample", track_index, device_index, file_path
|
|
355
565
|
)
|
|
356
566
|
|
|
357
|
-
# Validate the response — the bridge may report success even when the
|
|
358
|
-
# sample silently failed to load (e.g., empty Simpler, bad path)
|
|
359
567
|
if "error" in result:
|
|
360
568
|
return result
|
|
361
569
|
if not result.get("sample_loaded"):
|
|
@@ -364,8 +572,6 @@ async def replace_simpler_sample(
|
|
|
364
572
|
"has a sample loaded — replace_sample silently fails on empty Simplers."
|
|
365
573
|
}
|
|
366
574
|
|
|
367
|
-
# Verify by reading back the device name — guards against the silent
|
|
368
|
-
# failure mode where the bridge reports success but keeps the placeholder.
|
|
369
575
|
hygiene = await _simpler_post_load_hygiene(
|
|
370
576
|
bridge, ableton, track_index, device_index, file_path
|
|
371
577
|
)
|
|
@@ -373,6 +579,9 @@ async def replace_simpler_sample(
|
|
|
373
579
|
return hygiene
|
|
374
580
|
|
|
375
581
|
result.update(hygiene)
|
|
582
|
+
result["method"] = "bridge_m4l"
|
|
583
|
+
if skip_reason:
|
|
584
|
+
result["native_skip_reason"] = skip_reason
|
|
376
585
|
return result
|
|
377
586
|
|
|
378
587
|
|
|
@@ -405,9 +614,39 @@ async def load_sample_to_simpler(
|
|
|
405
614
|
cache = _get_spectral(ctx)
|
|
406
615
|
_require_analyzer(cache)
|
|
407
616
|
bridge = _get_m4l(ctx)
|
|
617
|
+
ableton = ctx.lifespan_context["ableton"]
|
|
618
|
+
|
|
619
|
+
# Live 12.4+: create an empty Simpler via insert_device, then use the
|
|
620
|
+
# native replace_sample path. Skips the dummy-sample bootstrap entirely.
|
|
621
|
+
caps = _live_caps(ctx)
|
|
622
|
+
if caps.has_replace_sample_native:
|
|
623
|
+
try:
|
|
624
|
+
ins = ableton.send_command("insert_device", {
|
|
625
|
+
"track_index": track_index,
|
|
626
|
+
"device_name": "Simpler",
|
|
627
|
+
})
|
|
628
|
+
except Exception:
|
|
629
|
+
ins = None
|
|
630
|
+
if isinstance(ins, dict) and "error" not in ins:
|
|
631
|
+
actual_device_index = ins.get("device_index", device_index)
|
|
632
|
+
native = await _try_native_replace_sample(
|
|
633
|
+
ctx, track_index, actual_device_index, file_path
|
|
634
|
+
)
|
|
635
|
+
if native is not None:
|
|
636
|
+
hygiene = await _simpler_post_load_hygiene(
|
|
637
|
+
bridge, ableton, track_index, actual_device_index, file_path
|
|
638
|
+
)
|
|
639
|
+
if not hygiene.get("verified"):
|
|
640
|
+
return hygiene
|
|
641
|
+
result = dict(native)
|
|
642
|
+
result.update(hygiene)
|
|
643
|
+
result["method"] = "native_12_4"
|
|
644
|
+
result["device_index"] = actual_device_index
|
|
645
|
+
result["track_index"] = track_index
|
|
646
|
+
return result
|
|
647
|
+
# Fall through to the legacy bootstrap path below on any failure.
|
|
408
648
|
|
|
409
649
|
# Step 1: Load a sample from the browser to create Simpler with content
|
|
410
|
-
ableton = ctx.lifespan_context["ableton"]
|
|
411
650
|
try:
|
|
412
651
|
search = ableton.send_command("search_browser", {
|
|
413
652
|
"path": "samples",
|
|
@@ -463,6 +702,193 @@ async def load_sample_to_simpler(
|
|
|
463
702
|
return result
|
|
464
703
|
|
|
465
704
|
|
|
705
|
+
@mcp.tool()
|
|
706
|
+
async def add_drum_rack_pad(
|
|
707
|
+
ctx: Context,
|
|
708
|
+
track_index: int,
|
|
709
|
+
pad_note: int,
|
|
710
|
+
file_path: str,
|
|
711
|
+
rack_device_index: Optional[int] = None,
|
|
712
|
+
chain_name: Optional[str] = None,
|
|
713
|
+
) -> dict:
|
|
714
|
+
"""Add a new pad (chain) to a Drum Rack and load a sample into it.
|
|
715
|
+
|
|
716
|
+
**BUG-2026-04-22#1 FIX** — this is the tool that was missing.
|
|
717
|
+
Previously `load_browser_item` replaced the existing chain on repeat
|
|
718
|
+
calls, and `load_sample_to_simpler` couldn't address nested paths.
|
|
719
|
+
This single tool does the full drum-rack pad build atomically:
|
|
720
|
+
|
|
721
|
+
1. Locates the Drum Rack on the track (auto-finds if
|
|
722
|
+
`rack_device_index` is None — searches for class_name containing
|
|
723
|
+
"DrumGroupDevice").
|
|
724
|
+
2. Inserts a new chain on the rack (`insert_rack_chain`).
|
|
725
|
+
3. Assigns the chain's trigger note (`set_drum_chain_note`).
|
|
726
|
+
4. Inserts an empty Simpler into the chain (`insert_device` with
|
|
727
|
+
`chain_index`).
|
|
728
|
+
5. Calls the native Live 12.4 `replace_sample_native` with nested
|
|
729
|
+
addressing to load the sample.
|
|
730
|
+
6. Sets Snap=0 post-load (playback hygiene).
|
|
731
|
+
|
|
732
|
+
Requires Live 12.4+ for step 5. On earlier versions returns an error
|
|
733
|
+
directing the caller to the bridge-based workaround.
|
|
734
|
+
|
|
735
|
+
track_index: track containing the Drum Rack
|
|
736
|
+
pad_note: MIDI note for the pad (0..127). Standard drum map:
|
|
737
|
+
36=Kick, 38=Snare, 42=Closed HH, 46=Open HH.
|
|
738
|
+
file_path: absolute path to the audio file
|
|
739
|
+
rack_device_index: optional device_index of the Drum Rack on the track.
|
|
740
|
+
If None, auto-detects the first Drum Rack.
|
|
741
|
+
chain_name: optional display name for the new chain.
|
|
742
|
+
|
|
743
|
+
Returns: {
|
|
744
|
+
"ok": bool,
|
|
745
|
+
"track_index": int,
|
|
746
|
+
"rack_device_index": int,
|
|
747
|
+
"chain_index": int,
|
|
748
|
+
"pad_note": int,
|
|
749
|
+
"nested_device_index": int, # where the Simpler landed
|
|
750
|
+
"device_name": str,
|
|
751
|
+
"method": "native_12_4",
|
|
752
|
+
}
|
|
753
|
+
"""
|
|
754
|
+
from .._analyzer_engine.sample import _simpler_post_load_hygiene # noqa
|
|
755
|
+
ableton = ctx.lifespan_context["ableton"]
|
|
756
|
+
caps = _live_caps(ctx)
|
|
757
|
+
if not caps.has_replace_sample_native:
|
|
758
|
+
return {
|
|
759
|
+
"ok": False,
|
|
760
|
+
"error": (
|
|
761
|
+
"add_drum_rack_pad requires Live 12.4+ for native nested "
|
|
762
|
+
"sample loading. Detected tier: " + caps.capability_tier +
|
|
763
|
+
". Upgrade to Live 12.4 or use the legacy separate-tracks "
|
|
764
|
+
"workflow described in docs/2026-04-22-bugs-discovered.md."
|
|
765
|
+
),
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
if not (0 <= pad_note <= 127):
|
|
769
|
+
return {"ok": False, "error": "pad_note must be 0..127"}
|
|
770
|
+
if not file_path or not isinstance(file_path, str):
|
|
771
|
+
return {"ok": False, "error": "file_path (absolute path) is required"}
|
|
772
|
+
|
|
773
|
+
# Step 1: locate the Drum Rack if not provided.
|
|
774
|
+
if rack_device_index is None:
|
|
775
|
+
try:
|
|
776
|
+
info = ableton.send_command(
|
|
777
|
+
"get_track_info", {"track_index": track_index},
|
|
778
|
+
)
|
|
779
|
+
except Exception as exc:
|
|
780
|
+
return {"ok": False, "error": f"get_track_info failed: {exc}"}
|
|
781
|
+
devices = info.get("devices", []) if isinstance(info, dict) else []
|
|
782
|
+
found_idx = None
|
|
783
|
+
for idx, d in enumerate(devices):
|
|
784
|
+
class_name = str(d.get("class_name") or "")
|
|
785
|
+
if "DrumGroup" in class_name or class_name == "DrumGroupDevice":
|
|
786
|
+
found_idx = idx
|
|
787
|
+
break
|
|
788
|
+
if found_idx is None:
|
|
789
|
+
return {
|
|
790
|
+
"ok": False,
|
|
791
|
+
"error": (
|
|
792
|
+
"No Drum Rack found on track. Pass `rack_device_index` "
|
|
793
|
+
"explicitly, or use `insert_device('Drum Rack')` first."
|
|
794
|
+
),
|
|
795
|
+
}
|
|
796
|
+
rack_device_index = found_idx
|
|
797
|
+
|
|
798
|
+
# Step 2: insert a new chain on the rack.
|
|
799
|
+
try:
|
|
800
|
+
chain_result = ableton.send_command("insert_rack_chain", {
|
|
801
|
+
"track_index": track_index,
|
|
802
|
+
"device_index": rack_device_index,
|
|
803
|
+
"position": -1, # append to end
|
|
804
|
+
})
|
|
805
|
+
except Exception as exc:
|
|
806
|
+
return {"ok": False, "error": f"insert_rack_chain failed: {exc}"}
|
|
807
|
+
if not isinstance(chain_result, dict) or "error" in chain_result:
|
|
808
|
+
return {
|
|
809
|
+
"ok": False,
|
|
810
|
+
"error": f"insert_rack_chain returned: {chain_result}",
|
|
811
|
+
}
|
|
812
|
+
chain_index = int(chain_result.get("chain_index", chain_result.get("index", 0)))
|
|
813
|
+
|
|
814
|
+
# Step 3: assign pad note to the new chain.
|
|
815
|
+
try:
|
|
816
|
+
note_result = ableton.send_command("set_drum_chain_note", {
|
|
817
|
+
"track_index": track_index,
|
|
818
|
+
"device_index": rack_device_index,
|
|
819
|
+
"chain_index": chain_index,
|
|
820
|
+
"note": pad_note,
|
|
821
|
+
})
|
|
822
|
+
except Exception as exc:
|
|
823
|
+
return {"ok": False, "error": f"set_drum_chain_note failed: {exc}"}
|
|
824
|
+
if isinstance(note_result, dict) and "error" in note_result:
|
|
825
|
+
return {"ok": False, "error": f"set_drum_chain_note: {note_result['error']}"}
|
|
826
|
+
|
|
827
|
+
# Step 4: insert an empty Simpler into the chain.
|
|
828
|
+
try:
|
|
829
|
+
insert_result = ableton.send_command("insert_device", {
|
|
830
|
+
"track_index": track_index,
|
|
831
|
+
"device_index": rack_device_index,
|
|
832
|
+
"chain_index": chain_index,
|
|
833
|
+
"device_name": "Simpler",
|
|
834
|
+
})
|
|
835
|
+
except Exception as exc:
|
|
836
|
+
return {"ok": False, "error": f"insert_device(Simpler, chain) failed: {exc}"}
|
|
837
|
+
if not isinstance(insert_result, dict) or "error" in insert_result:
|
|
838
|
+
return {
|
|
839
|
+
"ok": False,
|
|
840
|
+
"error": f"insert_device into chain failed: {insert_result}",
|
|
841
|
+
}
|
|
842
|
+
nested_idx = int(insert_result.get("device_index", 0))
|
|
843
|
+
|
|
844
|
+
# Step 5: replace_sample_native with nested addressing.
|
|
845
|
+
native = await _try_native_replace_sample(
|
|
846
|
+
ctx,
|
|
847
|
+
track_index=track_index,
|
|
848
|
+
device_index=rack_device_index,
|
|
849
|
+
file_path=file_path,
|
|
850
|
+
chain_index=chain_index,
|
|
851
|
+
nested_device_index=nested_idx,
|
|
852
|
+
)
|
|
853
|
+
if native is None:
|
|
854
|
+
return {
|
|
855
|
+
"ok": False,
|
|
856
|
+
"error": "Native replace_sample failed — see logs for reason",
|
|
857
|
+
"track_index": track_index,
|
|
858
|
+
"rack_device_index": rack_device_index,
|
|
859
|
+
"chain_index": chain_index,
|
|
860
|
+
"nested_device_index": nested_idx,
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
# Step 6: apply chain name (wired to the new set_chain_name handler)
|
|
864
|
+
applied_name = None
|
|
865
|
+
if chain_name:
|
|
866
|
+
try:
|
|
867
|
+
rename_result = ableton.send_command("set_chain_name", {
|
|
868
|
+
"track_index": track_index,
|
|
869
|
+
"device_index": rack_device_index,
|
|
870
|
+
"chain_index": chain_index,
|
|
871
|
+
"name": chain_name,
|
|
872
|
+
})
|
|
873
|
+
if isinstance(rename_result, dict) and "name" in rename_result:
|
|
874
|
+
applied_name = rename_result["name"]
|
|
875
|
+
except Exception as exc:
|
|
876
|
+
logger.debug("set_chain_name skipped: %s", exc)
|
|
877
|
+
|
|
878
|
+
return {
|
|
879
|
+
"ok": True,
|
|
880
|
+
"track_index": track_index,
|
|
881
|
+
"rack_device_index": rack_device_index,
|
|
882
|
+
"chain_index": chain_index,
|
|
883
|
+
"pad_note": pad_note,
|
|
884
|
+
"nested_device_index": nested_idx,
|
|
885
|
+
"device_name": "Simpler",
|
|
886
|
+
"method": native.get("method", "native_12_4"),
|
|
887
|
+
"file_path": file_path,
|
|
888
|
+
"chain_name": applied_name,
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
|
|
466
892
|
@mcp.tool()
|
|
467
893
|
async def get_simpler_slices(
|
|
468
894
|
ctx: Context,
|
|
@@ -969,6 +1395,145 @@ def get_novelty(ctx: Context) -> dict:
|
|
|
969
1395
|
return {**data["value"], "age_ms": data["age_ms"]}
|
|
970
1396
|
|
|
971
1397
|
|
|
1398
|
+
@mcp.tool()
|
|
1399
|
+
async def verify_device_health(
|
|
1400
|
+
ctx: Context,
|
|
1401
|
+
track_index: int,
|
|
1402
|
+
test_midi_note: int = 60,
|
|
1403
|
+
test_velocity: int = 100,
|
|
1404
|
+
test_duration_ms: int = 300,
|
|
1405
|
+
threshold: float = 0.005,
|
|
1406
|
+
) -> dict:
|
|
1407
|
+
"""Fire a test MIDI note at a track's instrument and check for output.
|
|
1408
|
+
|
|
1409
|
+
BUG-2026-04-22#19 fix — parameter_count alone can't tell you whether
|
|
1410
|
+
an AU/VST is alive. Plenty of "loaded" plugins return 19 params and
|
|
1411
|
+
silence. This tool does the real-world check:
|
|
1412
|
+
|
|
1413
|
+
1. Snapshot the track meter.
|
|
1414
|
+
2. Emit a MIDI note at the specified pitch/velocity.
|
|
1415
|
+
3. Sample the track meter for `test_duration_ms` (peak across samples).
|
|
1416
|
+
4. Compare the peak to a threshold; report alive vs dead.
|
|
1417
|
+
|
|
1418
|
+
The meter readout is taken with `get_track_meters(samples=N)` so the
|
|
1419
|
+
BUG-#7 "left=right=0 while level>0" artifact can't cause false negatives.
|
|
1420
|
+
|
|
1421
|
+
track_index: track with the instrument to verify
|
|
1422
|
+
test_midi_note: pitch to fire (default C3 / 60 — safe for most samples)
|
|
1423
|
+
test_velocity: 1-127 (default 100)
|
|
1424
|
+
test_duration_ms: capture window for the meter (default 300ms)
|
|
1425
|
+
threshold: peak level below which the device is considered dead
|
|
1426
|
+
(default 0.005 — roughly -46 dBFS)
|
|
1427
|
+
|
|
1428
|
+
Returns: {
|
|
1429
|
+
"ok": bool,
|
|
1430
|
+
"alive": bool,
|
|
1431
|
+
"peak_level": float,
|
|
1432
|
+
"threshold": float,
|
|
1433
|
+
"samples_taken": int,
|
|
1434
|
+
"hint": str, # actionable advice when dead
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
Requires LivePilot Analyzer on master track and a playable instrument
|
|
1438
|
+
on the target track. Prefer this over trying to eyeball parameter_count.
|
|
1439
|
+
"""
|
|
1440
|
+
ableton = ctx.lifespan_context["ableton"]
|
|
1441
|
+
|
|
1442
|
+
# Bound test_duration to something humane
|
|
1443
|
+
if test_duration_ms < 100:
|
|
1444
|
+
test_duration_ms = 100
|
|
1445
|
+
if test_duration_ms > 2000:
|
|
1446
|
+
test_duration_ms = 2000
|
|
1447
|
+
if not 1 <= test_velocity <= 127:
|
|
1448
|
+
return {"ok": False, "error": "test_velocity must be 1-127"}
|
|
1449
|
+
if not 0 <= test_midi_note <= 127:
|
|
1450
|
+
return {"ok": False, "error": "test_midi_note must be 0-127"}
|
|
1451
|
+
|
|
1452
|
+
# Fire the test note via the remote script's play_note helper. Fall back
|
|
1453
|
+
# to a raw MIDI event if the helper isn't available.
|
|
1454
|
+
fired = False
|
|
1455
|
+
try:
|
|
1456
|
+
resp = ableton.send_command("fire_test_note", {
|
|
1457
|
+
"track_index": track_index,
|
|
1458
|
+
"midi_note": test_midi_note,
|
|
1459
|
+
"velocity": test_velocity,
|
|
1460
|
+
"duration_ms": test_duration_ms,
|
|
1461
|
+
})
|
|
1462
|
+
if isinstance(resp, dict) and not resp.get("error"):
|
|
1463
|
+
fired = True
|
|
1464
|
+
except Exception as exc:
|
|
1465
|
+
logger.debug("fire_test_note unavailable: %s", exc)
|
|
1466
|
+
|
|
1467
|
+
if not fired:
|
|
1468
|
+
# Graceful degradation when the remote-script helper isn't present.
|
|
1469
|
+
return {
|
|
1470
|
+
"ok": False,
|
|
1471
|
+
"error": (
|
|
1472
|
+
"fire_test_note handler not available on this remote script. "
|
|
1473
|
+
"Update LivePilot's remote script (npx livepilot --install + "
|
|
1474
|
+
"reload_handlers) to enable verify_device_health."
|
|
1475
|
+
),
|
|
1476
|
+
"alive": None,
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
# Sample the track meter over the duration of the note.
|
|
1480
|
+
sample_interval_ms = 50
|
|
1481
|
+
n = max(2, test_duration_ms // sample_interval_ms)
|
|
1482
|
+
peak = 0.0
|
|
1483
|
+
samples_taken = 0
|
|
1484
|
+
for i in range(n):
|
|
1485
|
+
try:
|
|
1486
|
+
snap = ableton.send_command("get_track_meters", {
|
|
1487
|
+
"track_index": track_index,
|
|
1488
|
+
})
|
|
1489
|
+
except Exception as exc:
|
|
1490
|
+
logger.debug("get_track_meters snapshot failed: %s", exc)
|
|
1491
|
+
continue
|
|
1492
|
+
samples_taken += 1
|
|
1493
|
+
if isinstance(snap, dict):
|
|
1494
|
+
tracks = snap.get("tracks") or []
|
|
1495
|
+
for t in tracks:
|
|
1496
|
+
if not isinstance(t, dict):
|
|
1497
|
+
continue
|
|
1498
|
+
level = t.get("level") or 0
|
|
1499
|
+
try:
|
|
1500
|
+
peak = max(peak, float(level))
|
|
1501
|
+
except (TypeError, ValueError):
|
|
1502
|
+
pass
|
|
1503
|
+
if i < n - 1:
|
|
1504
|
+
await asyncio.sleep(sample_interval_ms / 1000.0)
|
|
1505
|
+
|
|
1506
|
+
# Always clean up the scratch clip, even on errors.
|
|
1507
|
+
try:
|
|
1508
|
+
ableton.send_command("cleanup_test_note", {"track_index": track_index})
|
|
1509
|
+
except Exception as exc:
|
|
1510
|
+
logger.debug("cleanup_test_note failed: %s", exc)
|
|
1511
|
+
|
|
1512
|
+
alive = peak >= threshold
|
|
1513
|
+
hint = ""
|
|
1514
|
+
if not alive:
|
|
1515
|
+
hint = (
|
|
1516
|
+
"Device produced no audible output. Common causes: "
|
|
1517
|
+
"(1) plugin waiting for preset/bank selection, "
|
|
1518
|
+
"(2) algorithm/envelope configured for zero output, "
|
|
1519
|
+
"(3) wrong MIDI channel or velocity curve, "
|
|
1520
|
+
"(4) dead VST (reinstall). "
|
|
1521
|
+
"Try opening the device UI and auditioning manually."
|
|
1522
|
+
)
|
|
1523
|
+
|
|
1524
|
+
return {
|
|
1525
|
+
"ok": True,
|
|
1526
|
+
"alive": alive,
|
|
1527
|
+
"peak_level": round(peak, 4),
|
|
1528
|
+
"threshold": threshold,
|
|
1529
|
+
"samples_taken": samples_taken,
|
|
1530
|
+
"test_midi_note": test_midi_note,
|
|
1531
|
+
"test_velocity": test_velocity,
|
|
1532
|
+
"test_duration_ms": test_duration_ms,
|
|
1533
|
+
"hint": hint,
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
|
|
972
1537
|
@mcp.tool()
|
|
973
1538
|
def get_momentary_loudness(ctx: Context) -> dict:
|
|
974
1539
|
"""Get EBU R128 momentary LUFS + true peak from FluCoMa.
|
|
@@ -985,6 +1550,101 @@ def get_momentary_loudness(ctx: Context) -> dict:
|
|
|
985
1550
|
return {**data["value"], "age_ms": data["age_ms"]}
|
|
986
1551
|
|
|
987
1552
|
|
|
1553
|
+
@mcp.tool()
|
|
1554
|
+
async def analyze_loudness_live(
|
|
1555
|
+
ctx: Context,
|
|
1556
|
+
window_sec: float = 10.0,
|
|
1557
|
+
sample_interval_ms: int = 200,
|
|
1558
|
+
) -> dict:
|
|
1559
|
+
"""Analyze the currently-playing master output's loudness over a window.
|
|
1560
|
+
|
|
1561
|
+
BUG-2026-04-22#8 fix — the offline `analyze_loudness` requires a
|
|
1562
|
+
rendered file. This tool samples the LivePilot analyzer's realtime
|
|
1563
|
+
momentary LUFS / true peak stream over `window_sec` and reports
|
|
1564
|
+
integrated + max statistics. No render required.
|
|
1565
|
+
|
|
1566
|
+
Requires FluCoMa package in Max and playback to be running. Best
|
|
1567
|
+
called while the section you want to measure is actually playing.
|
|
1568
|
+
|
|
1569
|
+
window_sec: capture duration in seconds (default 10, max 120)
|
|
1570
|
+
sample_interval_ms: ms between samples (default 200 ≈ 5 Hz)
|
|
1571
|
+
|
|
1572
|
+
Returns: {
|
|
1573
|
+
"integrated_lufs": float, # mean momentary LUFS over window
|
|
1574
|
+
"max_momentary_lufs": float, # peak momentary reading
|
|
1575
|
+
"min_momentary_lufs": float, # quietest reading
|
|
1576
|
+
"range_lu": float, # max - min (proxy for LRA)
|
|
1577
|
+
"max_true_peak_dbtp": float, # max true peak across window
|
|
1578
|
+
"samples_collected": int,
|
|
1579
|
+
"window_sec": float,
|
|
1580
|
+
"is_playing": bool,
|
|
1581
|
+
}
|
|
1582
|
+
"""
|
|
1583
|
+
if window_sec <= 0 or window_sec > 120:
|
|
1584
|
+
return {"error": "window_sec must be > 0 and <= 120"}
|
|
1585
|
+
if sample_interval_ms < 50 or sample_interval_ms > 5000:
|
|
1586
|
+
return {"error": "sample_interval_ms must be 50..5000"}
|
|
1587
|
+
|
|
1588
|
+
cache = _get_spectral(ctx)
|
|
1589
|
+
_require_analyzer(cache)
|
|
1590
|
+
ableton = ctx.lifespan_context["ableton"]
|
|
1591
|
+
|
|
1592
|
+
# Verify FluCoMa is alive — otherwise there's no stream to sample.
|
|
1593
|
+
preview = cache.get("loudness")
|
|
1594
|
+
if not preview:
|
|
1595
|
+
hint = _flucoma_hint(cache)
|
|
1596
|
+
return {"error": f"No live loudness stream — {hint}"}
|
|
1597
|
+
|
|
1598
|
+
try:
|
|
1599
|
+
session = ableton.send_command("get_session_info", {})
|
|
1600
|
+
is_playing = bool(session.get("is_playing", False))
|
|
1601
|
+
except Exception:
|
|
1602
|
+
is_playing = None
|
|
1603
|
+
|
|
1604
|
+
interval_s = sample_interval_ms / 1000.0
|
|
1605
|
+
total_samples = max(1, int(window_sec / interval_s))
|
|
1606
|
+
lufs_vals: list[float] = []
|
|
1607
|
+
peak_vals: list[float] = []
|
|
1608
|
+
|
|
1609
|
+
for i in range(total_samples):
|
|
1610
|
+
snap = cache.get("loudness")
|
|
1611
|
+
if snap and snap.get("value"):
|
|
1612
|
+
v = snap["value"]
|
|
1613
|
+
if "momentary_lufs" in v:
|
|
1614
|
+
lufs_vals.append(float(v["momentary_lufs"]))
|
|
1615
|
+
elif "lufs" in v:
|
|
1616
|
+
lufs_vals.append(float(v["lufs"]))
|
|
1617
|
+
if "true_peak_dbtp" in v:
|
|
1618
|
+
peak_vals.append(float(v["true_peak_dbtp"]))
|
|
1619
|
+
elif "peak_dbfs" in v:
|
|
1620
|
+
peak_vals.append(float(v["peak_dbfs"]))
|
|
1621
|
+
if i < total_samples - 1:
|
|
1622
|
+
await asyncio.sleep(interval_s)
|
|
1623
|
+
|
|
1624
|
+
if not lufs_vals:
|
|
1625
|
+
return {"error": "No valid loudness samples captured over the window"}
|
|
1626
|
+
|
|
1627
|
+
integrated = sum(lufs_vals) / len(lufs_vals)
|
|
1628
|
+
result = {
|
|
1629
|
+
"integrated_lufs": round(integrated, 2),
|
|
1630
|
+
"max_momentary_lufs": round(max(lufs_vals), 2),
|
|
1631
|
+
"min_momentary_lufs": round(min(lufs_vals), 2),
|
|
1632
|
+
"range_lu": round(max(lufs_vals) - min(lufs_vals), 2),
|
|
1633
|
+
"samples_collected": len(lufs_vals),
|
|
1634
|
+
"window_sec": window_sec,
|
|
1635
|
+
"sample_interval_ms": sample_interval_ms,
|
|
1636
|
+
"is_playing": is_playing,
|
|
1637
|
+
}
|
|
1638
|
+
if peak_vals:
|
|
1639
|
+
result["max_true_peak_dbtp"] = round(max(peak_vals), 2)
|
|
1640
|
+
if is_playing is False:
|
|
1641
|
+
result["warning"] = (
|
|
1642
|
+
"Playback was not running — readings reflect stale cache. "
|
|
1643
|
+
"Start playback and call again for accurate live analysis."
|
|
1644
|
+
)
|
|
1645
|
+
return result
|
|
1646
|
+
|
|
1647
|
+
|
|
988
1648
|
@mcp.tool()
|
|
989
1649
|
def check_flucoma(ctx: Context) -> dict:
|
|
990
1650
|
"""Check if FluCoMa is installed and sending data."""
|