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.
Files changed (33) hide show
  1. package/CHANGELOG.md +176 -1
  2. package/README.md +6 -6
  3. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  4. package/mcp_server/__init__.py +1 -1
  5. package/mcp_server/atlas/device_atlas.json +91219 -7161
  6. package/mcp_server/atlas/tools.py +30 -2
  7. package/mcp_server/runtime/live_version.py +4 -2
  8. package/mcp_server/runtime/remote_commands.py +5 -0
  9. package/mcp_server/sample_engine/tools.py +692 -60
  10. package/mcp_server/splice_client/client.py +511 -65
  11. package/mcp_server/splice_client/http_bridge.py +361 -0
  12. package/mcp_server/splice_client/models.py +266 -2
  13. package/mcp_server/splice_client/quota.py +229 -0
  14. package/mcp_server/tools/_analyzer_engine/__init__.py +4 -0
  15. package/mcp_server/tools/_analyzer_engine/sample.py +73 -0
  16. package/mcp_server/tools/analyzer.py +666 -6
  17. package/mcp_server/tools/browser.py +164 -13
  18. package/mcp_server/tools/devices.py +56 -11
  19. package/mcp_server/tools/mixing.py +64 -15
  20. package/mcp_server/tools/scales.py +18 -6
  21. package/mcp_server/tools/tracks.py +92 -4
  22. package/package.json +2 -2
  23. package/remote_script/LivePilot/__init__.py +2 -1
  24. package/remote_script/LivePilot/_clip_helpers.py +86 -0
  25. package/remote_script/LivePilot/_drum_helpers.py +40 -0
  26. package/remote_script/LivePilot/_scale_helpers.py +87 -0
  27. package/remote_script/LivePilot/arrangement.py +44 -15
  28. package/remote_script/LivePilot/clips.py +182 -2
  29. package/remote_script/LivePilot/devices.py +82 -2
  30. package/remote_script/LivePilot/notes.py +17 -2
  31. package/remote_script/LivePilot/scales.py +31 -16
  32. package/remote_script/LivePilot/simpler_sample.py +186 -0
  33. 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(ctx: Context) -> dict:
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."""