livepilot 1.16.0 → 1.17.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 (86) hide show
  1. package/CHANGELOG.md +344 -5
  2. package/README.md +16 -15
  3. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  4. package/m4l_device/livepilot_bridge.js +1 -1
  5. package/mcp_server/__init__.py +1 -1
  6. package/mcp_server/atlas/__init__.py +85 -0
  7. package/mcp_server/atlas/device_atlas.json +3183 -382
  8. package/mcp_server/atlas/device_techniques_index.json +1510 -0
  9. package/mcp_server/atlas/enrichments/__init__.py +1 -0
  10. package/mcp_server/atlas/enrichments/audio_effects/amp.yaml +112 -0
  11. package/mcp_server/atlas/enrichments/audio_effects/audio_effect_rack.yaml +77 -0
  12. package/mcp_server/atlas/enrichments/audio_effects/cabinet.yaml +81 -0
  13. package/mcp_server/atlas/enrichments/audio_effects/corpus.yaml +128 -0
  14. package/mcp_server/atlas/enrichments/audio_effects/envelope_follower.yaml +99 -0
  15. package/mcp_server/atlas/enrichments/audio_effects/external_audio_effect.yaml +64 -0
  16. package/mcp_server/atlas/enrichments/audio_effects/looper.yaml +85 -0
  17. package/mcp_server/atlas/enrichments/audio_effects/pitch_hack.yaml +61 -0
  18. package/mcp_server/atlas/enrichments/audio_effects/pitchloop89.yaml +111 -0
  19. package/mcp_server/atlas/enrichments/audio_effects/re_enveloper.yaml +51 -0
  20. package/mcp_server/atlas/enrichments/audio_effects/resonators.yaml +121 -0
  21. package/mcp_server/atlas/enrichments/audio_effects/snipper.yaml +53 -0
  22. package/mcp_server/atlas/enrichments/audio_effects/spectral_blur.yaml +64 -0
  23. package/mcp_server/atlas/enrichments/audio_effects/spectrum.yaml +61 -0
  24. package/mcp_server/atlas/enrichments/audio_effects/tuner.yaml +43 -0
  25. package/mcp_server/atlas/enrichments/audio_effects/utility.yaml +118 -0
  26. package/mcp_server/atlas/enrichments/audio_effects/vocoder.yaml +94 -0
  27. package/mcp_server/atlas/enrichments/instruments/analog.yaml +11 -0
  28. package/mcp_server/atlas/enrichments/instruments/bass.yaml +11 -0
  29. package/mcp_server/atlas/enrichments/instruments/bell_tower.yaml +75 -0
  30. package/mcp_server/atlas/enrichments/instruments/collision.yaml +11 -0
  31. package/mcp_server/atlas/enrichments/instruments/drift.yaml +11 -0
  32. package/mcp_server/atlas/enrichments/instruments/drum_rack.yaml +142 -0
  33. package/mcp_server/atlas/enrichments/instruments/electric.yaml +11 -0
  34. package/mcp_server/atlas/enrichments/instruments/emit.yaml +11 -0
  35. package/mcp_server/atlas/enrichments/instruments/granulator_iii.yaml +124 -0
  36. package/mcp_server/atlas/enrichments/instruments/harmonic_drone_generator.yaml +83 -0
  37. package/mcp_server/atlas/enrichments/instruments/impulse.yaml +47 -0
  38. package/mcp_server/atlas/enrichments/instruments/meld.yaml +11 -0
  39. package/mcp_server/atlas/enrichments/instruments/operator.yaml +11 -0
  40. package/mcp_server/atlas/enrichments/instruments/poli.yaml +11 -0
  41. package/mcp_server/atlas/enrichments/instruments/sampler.yaml +12 -0
  42. package/mcp_server/atlas/enrichments/instruments/simpler.yaml +15 -0
  43. package/mcp_server/atlas/enrichments/instruments/sting_iftah.yaml +44 -0
  44. package/mcp_server/atlas/enrichments/instruments/tension.yaml +11 -0
  45. package/mcp_server/atlas/enrichments/instruments/vector_fm.yaml +11 -0
  46. package/mcp_server/atlas/enrichments/instruments/vector_grain.yaml +11 -0
  47. package/mcp_server/atlas/enrichments/instruments/wavetable.yaml +11 -0
  48. package/mcp_server/atlas/enrichments/midi_effects/expressive_chords.yaml +38 -0
  49. package/mcp_server/atlas/enrichments/midi_effects/filler.yaml +49 -0
  50. package/mcp_server/atlas/enrichments/midi_effects/microtuner.yaml +83 -0
  51. package/mcp_server/atlas/enrichments/midi_effects/patterns_iftah.yaml +38 -0
  52. package/mcp_server/atlas/enrichments/midi_effects/phase_pattern.yaml +51 -0
  53. package/mcp_server/atlas/enrichments/midi_effects/polyrhythm.yaml +46 -0
  54. package/mcp_server/atlas/enrichments/midi_effects/retrigger.yaml +40 -0
  55. package/mcp_server/atlas/enrichments/midi_effects/slice_shuffler.yaml +39 -0
  56. package/mcp_server/atlas/enrichments/midi_effects/sq_sequencer.yaml +39 -0
  57. package/mcp_server/atlas/enrichments/midi_effects/stages.yaml +38 -0
  58. package/mcp_server/atlas/enrichments/utility/arrangement_looper.yaml +31 -0
  59. package/mcp_server/atlas/enrichments/utility/cv_clock_in.yaml +25 -0
  60. package/mcp_server/atlas/enrichments/utility/cv_clock_out.yaml +25 -0
  61. package/mcp_server/atlas/enrichments/utility/cv_envelope_follower.yaml +38 -0
  62. package/mcp_server/atlas/enrichments/utility/cv_in.yaml +26 -0
  63. package/mcp_server/atlas/enrichments/utility/cv_instrument.yaml +34 -0
  64. package/mcp_server/atlas/enrichments/utility/cv_lfo.yaml +38 -0
  65. package/mcp_server/atlas/enrichments/utility/cv_shaper.yaml +35 -0
  66. package/mcp_server/atlas/enrichments/utility/cv_triggers.yaml +26 -0
  67. package/mcp_server/atlas/enrichments/utility/cv_utility.yaml +37 -0
  68. package/mcp_server/atlas/enrichments/utility/performer.yaml +51 -0
  69. package/mcp_server/atlas/enrichments/utility/prearranger.yaml +36 -0
  70. package/mcp_server/atlas/enrichments/utility/rotating_rhythm_generator.yaml +35 -0
  71. package/mcp_server/atlas/enrichments/utility/surround_panner.yaml +40 -0
  72. package/mcp_server/atlas/enrichments/utility/variations.yaml +40 -0
  73. package/mcp_server/atlas/enrichments/utility/vector_map.yaml +57 -0
  74. package/mcp_server/atlas/tools.py +291 -0
  75. package/mcp_server/m4l_bridge.py +19 -2
  76. package/mcp_server/sample_engine/tools.py +190 -72
  77. package/mcp_server/server.py +18 -6
  78. package/mcp_server/splice_client/client.py +90 -18
  79. package/mcp_server/splice_client/http_bridge.py +414 -138
  80. package/mcp_server/splice_client/models.py +12 -0
  81. package/mcp_server/tools/analyzer.py +150 -1
  82. package/mcp_server/tools/automation.py +168 -0
  83. package/package.json +2 -2
  84. package/remote_script/LivePilot/__init__.py +1 -1
  85. package/remote_script/LivePilot/arrangement.py +216 -1
  86. package/server.json +3 -3
@@ -212,6 +212,7 @@ async def get_master_spectrum(
212
212
  ctx: Context,
213
213
  window_ms: int = 0,
214
214
  samples: int = 0,
215
+ sub_detail: bool = False,
215
216
  ) -> dict:
216
217
  """Get 8-band frequency analysis of the master bus.
217
218
 
@@ -236,6 +237,14 @@ async def get_master_spectrum(
236
237
  The sampled bands are also returned as `bands_min`, `bands_max` and
237
238
  `bands_std` so callers can see variance within the window — useful
238
239
  for detecting transient-heavy content vs. sustained material.
240
+
241
+ BUG-2026-04-22#15 fix — sub-band resolution:
242
+ Pass `sub_detail=True` to attach a `sub_detail` dict with three
243
+ finer buckets: `sub_deep` (20-45 Hz), `sub_mid` (45-60 Hz),
244
+ `sub_high` (60-80 Hz). Derived from the FluCoMa mel spectrum
245
+ (40 bands) rather than the 8-band cache, so it requires FluCoMa
246
+ to be active. When FluCoMa is unavailable, sub_detail is omitted
247
+ with a `sub_detail_warning` field explaining why.
239
248
  """
240
249
  cache = _get_spectral(ctx)
241
250
  _require_analyzer(cache)
@@ -291,6 +300,8 @@ async def get_master_spectrum(
291
300
  key_data = cache.get("key")
292
301
  if key_data:
293
302
  result["detected_key"] = key_data["value"]
303
+ if sub_detail:
304
+ _attach_sub_detail(cache, result)
294
305
  return result
295
306
 
296
307
  # Legacy instantaneous path
@@ -303,10 +314,55 @@ async def get_master_spectrum(
303
314
  key_data = cache.get("key")
304
315
  if key_data:
305
316
  result["detected_key"] = key_data["value"]
317
+ if sub_detail:
318
+ _attach_sub_detail(cache, result)
306
319
 
307
320
  return result
308
321
 
309
322
 
323
+ def _attach_sub_detail(cache, result: dict) -> None:
324
+ """Compute finer sub-band breakdown (20-45, 45-60, 60-80 Hz) from
325
+ FluCoMa's 40-band mel spectrum and attach to the result dict.
326
+
327
+ Mel band edges are perceptual, not linear Hz — we map approximately:
328
+ with a standard 40-band mel filterbank from 0-20kHz, the first
329
+ ~4 bands cover 0-80 Hz and are distributed roughly:
330
+ band 0: ~0-25 Hz
331
+ band 1: ~25-45 Hz
332
+ band 2: ~45-65 Hz
333
+ band 3: ~65-90 Hz
334
+ We use these mappings as approximations; exact cutoffs depend on
335
+ FluCoMa's filterbank config but this is tight enough for mixing
336
+ decisions (the question is "is energy in the 30 Hz or 60 Hz range?").
337
+ """
338
+ mel_snap = cache.get("mel_bands")
339
+ if not mel_snap or not mel_snap.get("value"):
340
+ result["sub_detail_warning"] = (
341
+ "FluCoMa mel spectrum not available — sub_detail requires "
342
+ "FluCoMa active on the M4L analyzer. Use check_flucoma to diagnose."
343
+ )
344
+ return
345
+ mel_bands = mel_snap["value"]
346
+ if not isinstance(mel_bands, list) or len(mel_bands) < 4:
347
+ result["sub_detail_warning"] = (
348
+ f"Mel spectrum has {len(mel_bands) if isinstance(mel_bands, list) else 0} "
349
+ "bands — need at least 4 for sub_detail decomposition."
350
+ )
351
+ return
352
+
353
+ def _mean(indices):
354
+ vals = [float(mel_bands[i]) for i in indices if i < len(mel_bands)]
355
+ return round(sum(vals) / len(vals), 4) if vals else 0.0
356
+
357
+ result["sub_detail"] = {
358
+ "sub_deep": _mean([0, 1]), # ~0-45 Hz (kick fundamental)
359
+ "sub_mid": _mean([2]), # ~45-60 Hz (808 body / kick upper)
360
+ "sub_high": _mean([3]), # ~60-80 Hz (bass guitar low, sub-bass crossover)
361
+ "age_ms": mel_snap.get("age_ms"),
362
+ "source": "flucoma_mel_40",
363
+ }
364
+
365
+
310
366
  def _sanitize_pitch(pitch: Optional[dict]) -> Optional[dict]:
311
367
  """Validate a pitch reading from the M4L analyzer (BUG-F1).
312
368
 
@@ -751,7 +807,10 @@ async def add_drum_rack_pad(
751
807
  "method": "native_12_4",
752
808
  }
753
809
  """
754
- from .._analyzer_engine.sample import _simpler_post_load_hygiene # noqa
810
+ # _simpler_post_load_hygiene is already imported at module scope
811
+ # (line 29). Do not re-import inline — the earlier inline form used
812
+ # the wrong relative path (..; should've been .) and crashed at
813
+ # runtime with "No module named 'mcp_server._analyzer_engine'".
755
814
  ableton = ctx.lifespan_context["ableton"]
756
815
  caps = _live_caps(ctx)
757
816
  if not caps.has_replace_sample_native:
@@ -1534,6 +1593,96 @@ async def verify_device_health(
1534
1593
  }
1535
1594
 
1536
1595
 
1596
+ @mcp.tool()
1597
+ async def verify_all_devices_health(
1598
+ ctx: Context,
1599
+ test_midi_note: int = 60,
1600
+ test_velocity: int = 100,
1601
+ test_duration_ms: int = 250,
1602
+ threshold: float = 0.005,
1603
+ skip_audio_tracks: bool = True,
1604
+ skip_empty_tracks: bool = True,
1605
+ ) -> dict:
1606
+ """Run verify_device_health across every eligible track in one call.
1607
+
1608
+ Session-wide silent-track detector. Useful right after opening a
1609
+ project to surface dead plugins before mixing. Serial execution —
1610
+ firing notes in parallel would make the meter readings ambiguous.
1611
+
1612
+ skip_audio_tracks: audio tracks have no MIDI input, skip them (default True)
1613
+ skip_empty_tracks: tracks without any instrument also skip (default True)
1614
+
1615
+ Returns: {
1616
+ "ok": bool,
1617
+ "tracks_tested": int,
1618
+ "alive": [track_index...],
1619
+ "dead": [{track_index, track_name, peak_level}...],
1620
+ "skipped": [{track_index, reason}...],
1621
+ }
1622
+ """
1623
+ ableton = ctx.lifespan_context["ableton"]
1624
+ try:
1625
+ session = ableton.send_command("get_session_info", {})
1626
+ except Exception as exc:
1627
+ return {"ok": False, "error": f"get_session_info failed: {exc}"}
1628
+ if not isinstance(session, dict):
1629
+ return {"ok": False, "error": "Unexpected get_session_info response"}
1630
+
1631
+ tracks = session.get("tracks", []) or []
1632
+ alive: list = []
1633
+ dead: list = []
1634
+ skipped: list = []
1635
+
1636
+ for t in tracks:
1637
+ tid = t.get("index")
1638
+ tname = t.get("name", f"Track {tid}")
1639
+ if tid is None:
1640
+ continue
1641
+ is_audio = bool(t.get("is_audio_track") or t.get("type") == "audio")
1642
+ if skip_audio_tracks and is_audio:
1643
+ skipped.append({"track_index": tid, "track_name": tname,
1644
+ "reason": "audio_track_no_midi_input"})
1645
+ continue
1646
+ devices = t.get("devices") or []
1647
+ if skip_empty_tracks and not devices:
1648
+ skipped.append({"track_index": tid, "track_name": tname,
1649
+ "reason": "no_devices_on_track"})
1650
+ continue
1651
+
1652
+ # Run the per-track health check.
1653
+ result = await verify_device_health(
1654
+ ctx, track_index=tid,
1655
+ test_midi_note=test_midi_note,
1656
+ test_velocity=test_velocity,
1657
+ test_duration_ms=test_duration_ms,
1658
+ threshold=threshold,
1659
+ )
1660
+ if not result.get("ok"):
1661
+ skipped.append({"track_index": tid, "track_name": tname,
1662
+ "reason": result.get("error", "health_check_failed")})
1663
+ continue
1664
+ if result.get("alive"):
1665
+ alive.append(tid)
1666
+ else:
1667
+ dead.append({
1668
+ "track_index": tid,
1669
+ "track_name": tname,
1670
+ "peak_level": result.get("peak_level", 0),
1671
+ })
1672
+
1673
+ return {
1674
+ "ok": True,
1675
+ "tracks_tested": len(alive) + len(dead),
1676
+ "alive": alive,
1677
+ "dead": dead,
1678
+ "skipped": skipped,
1679
+ "summary": (
1680
+ f"{len(alive)} alive, {len(dead)} dead, "
1681
+ f"{len(skipped)} skipped out of {len(tracks)} total tracks"
1682
+ ),
1683
+ }
1684
+
1685
+
1537
1686
  @mcp.tool()
1538
1687
  def get_momentary_loudness(ctx: Context) -> dict:
1539
1688
  """Get EBU R128 momentary LUFS + true peak from FluCoMa.
@@ -602,3 +602,171 @@ def analyze_for_automation(
602
602
  "spectrum": spectrum,
603
603
  "suggestions": suggestions,
604
604
  }
605
+
606
+
607
+ # ── Track-level arrangement automation workaround (T5, 2026-04-22 handoff) ─
608
+ #
609
+ # The Live LOM has a well-known gap: you cannot directly write a
610
+ # TRACK-level automation envelope into arrangement between clips or
611
+ # outside any clip. You can only write CLIP-level automation (which
612
+ # lives inside a specific clip). The workaround is the classic
613
+ # session-clip + record-to-arrangement dance:
614
+ #
615
+ # 1. Build a session clip with the automation (set_clip_automation)
616
+ # 2. Arm the track
617
+ # 3. Jump to the target beat
618
+ # 4. Start arrangement recording
619
+ # 5. Fire the session clip
620
+ # 6. Wait for the clip to play through at tempo
621
+ # 7. Stop recording
622
+ # 8. Stop the session clip (return control to arrangement)
623
+ #
624
+ # The recorded arrangement clip has the automation rendered as a native
625
+ # arrangement envelope — which IS writable by Live even when the LOM
626
+ # disallows direct API access.
627
+
628
+ @mcp.tool()
629
+ async def set_arrangement_automation_via_session_record(
630
+ ctx: Context,
631
+ track_index: int,
632
+ parameter_type: str,
633
+ points: list | str,
634
+ target_beat: float,
635
+ duration_beats: float,
636
+ session_clip_slot: int = 0,
637
+ device_index: Optional[int] = None,
638
+ parameter_index: Optional[int] = None,
639
+ send_index: Optional[int] = None,
640
+ cleanup_session_clip: bool = True,
641
+ ) -> dict:
642
+ """Write an arrangement automation envelope at a specific beat via session record.
643
+
644
+ Workaround for the Live LOM limitation that prevents direct writing of
645
+ track-level arrangement automation outside of an existing arrangement
646
+ clip. Creates a temporary session clip with the automation, arms the
647
+ track, records the clip into arrangement at target_beat, then cleans
648
+ up. The recorded arrangement clip has the automation baked in.
649
+
650
+ **Status: LIVE** as of 2026-04-22. Uses a two-phase protocol so Live's
651
+ main thread never blocks: phase 1 (start) fires the session clip into
652
+ arrangement record; this tool then asyncio.sleeps for the expected
653
+ record duration (computed from the live tempo that phase 1 returns);
654
+ phase 2 (complete) stops record, cleans up, and returns the new
655
+ arrangement clip's index + start/length. Total wall time ≈
656
+ duration_beats × 60/tempo + 0.5s handler overhead.
657
+
658
+ parameter_type: "device" | "volume" | "panning" | "send"
659
+ points: [{time, value, duration?}] — time relative to
660
+ the session clip's start (0.0 = first beat)
661
+ target_beat: where the recording should begin in the
662
+ arrangement timeline (beats)
663
+ duration_beats: how long to record (usually matches the session
664
+ clip's natural length, but may be longer for
665
+ multiple repeats)
666
+ session_clip_slot: which session clip slot to use (default 0 — must
667
+ be EMPTY or its current content will be overwritten)
668
+ device_index,
669
+ parameter_index: required for parameter_type="device"
670
+ send_index: required for parameter_type="send" (0=A, 1=B)
671
+ cleanup_session_clip: delete the temp session clip after recording
672
+ completes (default True)
673
+
674
+ Returns a dict with the recorded arrangement clip index + verification
675
+ info, or an error if any step failed. Because this orchestrates a
676
+ real-time recording, any of the steps can fail at runtime (track
677
+ not armable, session clip already running, transport not cooperating).
678
+ Each failure is reported with the stage it happened at.
679
+ """
680
+ if track_index < 0:
681
+ raise ValueError("track_index must be >= 0 (track-level automation only supports regular tracks)")
682
+ if parameter_type not in ("device", "volume", "panning", "send"):
683
+ raise ValueError("parameter_type must be 'device', 'volume', 'panning', or 'send'")
684
+ if parameter_type == "device":
685
+ if device_index is None or parameter_index is None:
686
+ raise ValueError("device_index and parameter_index required for parameter_type='device'")
687
+ if parameter_type == "send" and send_index is None:
688
+ raise ValueError("send_index required for parameter_type='send'")
689
+ points_list = _ensure_list(points)
690
+ if not points_list:
691
+ raise ValueError("points list cannot be empty")
692
+ if target_beat < 0:
693
+ raise ValueError("target_beat must be >= 0")
694
+ if duration_beats <= 0:
695
+ raise ValueError("duration_beats must be > 0")
696
+
697
+ ableton = _get_ableton(ctx)
698
+
699
+ # Two-phase protocol — the start handler can't block Live's main thread
700
+ # for the full recording duration (audio dropouts, UI freezes). So:
701
+ # 1. start — writes session clip, arms, seeks, fires, enables record
702
+ # 2. MCP-side asyncio.sleep for duration + buffer
703
+ # 3. complete — stops record, cleans up, returns arrangement clip info
704
+ # The session-clip route is unchanged; the orchestration moved to us.
705
+ start_params: dict = {
706
+ "track_index": track_index,
707
+ "parameter_type": parameter_type,
708
+ "points": points_list,
709
+ "target_beat": target_beat,
710
+ "duration_beats": duration_beats,
711
+ "session_clip_slot": session_clip_slot,
712
+ }
713
+ if device_index is not None:
714
+ start_params["device_index"] = device_index
715
+ if parameter_index is not None:
716
+ start_params["parameter_index"] = parameter_index
717
+ if send_index is not None:
718
+ start_params["send_index"] = send_index
719
+
720
+ start_result = ableton.send_command(
721
+ "arrangement_automation_via_session_record_start",
722
+ start_params,
723
+ )
724
+ if not isinstance(start_result, dict) or start_result.get("status") != "recording":
725
+ return {
726
+ "ok": False,
727
+ "phase": "start",
728
+ "error": "arrangement record failed to engage",
729
+ "details": start_result,
730
+ }
731
+
732
+ # Compute sleep: duration_beats * 60/tempo seconds + 0.5s buffer for
733
+ # Live's record-arm latency. Tempo comes back from the start handler
734
+ # (fresh Song.tempo read) so we don't hardcode 120.
735
+ import asyncio
736
+ tempo = float(start_result.get("tempo") or 120.0)
737
+ if tempo <= 0:
738
+ tempo = 120.0
739
+ seconds_per_beat = 60.0 / tempo
740
+ sleep_sec = duration_beats * seconds_per_beat + 0.5
741
+ # Safety ceiling — if something is misconfigured we shouldn't sleep
742
+ # forever. 10 minutes is far beyond any realistic arrangement clip.
743
+ sleep_sec = min(sleep_sec, 600.0)
744
+ try:
745
+ await asyncio.sleep(sleep_sec)
746
+ except Exception as exc:
747
+ # Even if sleep fails we try to complete so we don't leave the
748
+ # track armed + recording.
749
+ logger.warning("sleep interrupted: %s", exc)
750
+
751
+ complete_params = {
752
+ "track_index": track_index,
753
+ "session_clip_slot": session_clip_slot,
754
+ "target_beat": target_beat,
755
+ "duration_beats": duration_beats,
756
+ "cleanup_session_clip": cleanup_session_clip,
757
+ }
758
+ complete_result = ableton.send_command(
759
+ "arrangement_automation_via_session_record_complete",
760
+ complete_params,
761
+ )
762
+ ok = (
763
+ isinstance(complete_result, dict)
764
+ and complete_result.get("status") == "completed"
765
+ )
766
+ return {
767
+ "ok": bool(ok),
768
+ "tempo": tempo,
769
+ "slept_sec": sleep_sec,
770
+ "start": start_result,
771
+ "complete": complete_result,
772
+ }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "livepilot",
3
- "version": "1.16.0",
3
+ "version": "1.17.0",
4
4
  "mcpName": "io.github.dreamrec/livepilot",
5
- "description": "Agentic production system for Ableton Live 12 — 421 tools, 52 domains. Device atlas (1305 devices), sample engine (Splice + browser + filesystem), auto-composition, spectral perception, technique memory, creative intelligence (12 engines)",
5
+ "description": "Agentic production system for Ableton Live 12 — 426 tools, 52 domains. Device atlas (1305 devices), sample engine (Splice + browser + filesystem), auto-composition, spectral perception, technique memory, creative intelligence (12 engines)",
6
6
  "author": "Pilot Studio",
7
7
  "license": "BSL-1.1",
8
8
  "type": "commonjs",
@@ -5,7 +5,7 @@ Entry point for the ControlSurface. Ableton calls create_instance(c_instance)
5
5
  when this script is selected in Preferences > Link, Tempo & MIDI.
6
6
  """
7
7
 
8
- __version__ = "1.16.0"
8
+ __version__ = "1.17.0"
9
9
 
10
10
  from _Framework.ControlSurface import ControlSurface
11
11
  from . import router
@@ -1,7 +1,14 @@
1
1
  """
2
- LivePilot - Arrangement domain handlers (19 commands).
2
+ LivePilot - Arrangement domain handlers (21 commands).
3
+
4
+ As of 2026-04-22: adds the two-phase session-record workaround for
5
+ track-level arrangement automation (T5 handoff). Live's LOM can't
6
+ write track-level automation envelopes outside a clip directly —
7
+ this handler pair writes to a session clip, records it into
8
+ arrangement at a target beat, then cleans up.
3
9
  """
4
10
 
11
+ from .clip_automation import set_clip_automation as _set_clip_automation_handler
5
12
  from .router import register
6
13
  from .utils import get_track
7
14
 
@@ -866,3 +873,211 @@ def force_arrangement(song, params):
866
873
  "is_playing": song.is_playing,
867
874
  "loop": song.loop,
868
875
  }
876
+
877
+
878
+ # ── Session-record arrangement automation (T5 workaround) ────────────
879
+ #
880
+ # Two-phase protocol so the MCP server can sleep between the record
881
+ # start and stop without blocking Live's main thread.
882
+ #
883
+ # Phase 1: _start — write session clip, arm, seek, record, fire
884
+ # Phase 2: _complete — stop record, stop transport, locate new
885
+ # arrangement clip, clean up session clip
886
+ #
887
+ # The MCP tool layer orchestrates: start → asyncio.sleep(duration * 60/tempo + 0.5) → complete.
888
+
889
+
890
+ def _fire_slot(slot):
891
+ """Fire a clip slot robustly — Live's API changed between versions."""
892
+ # Newer Live: slot.fire() is the canonical way. `slot.clip.fire()`
893
+ # also works when a clip is present. Try slot-level first so empty
894
+ # slots (we may have just created a clip) fire the row reliably.
895
+ try:
896
+ slot.fire()
897
+ except AttributeError:
898
+ slot.clip.fire()
899
+
900
+
901
+ def _ensure_session_clip_length(slot, points, min_length=1.0):
902
+ """Ensure slot has a session MIDI clip covering the points' time range.
903
+
904
+ Returns (clip, was_created). If the slot is empty we call
905
+ create_clip(length). If points extend past the existing clip
906
+ we leave it alone — the user may have intentional content before.
907
+ """
908
+ if not slot.has_clip:
909
+ max_t = 0.0
910
+ for p in points or []:
911
+ t = float(p.get("time", 0))
912
+ d = float(p.get("duration", 0.125))
913
+ max_t = max(max_t, t + d)
914
+ length = max(min_length, max_t)
915
+ slot.create_clip(length)
916
+ return slot.clip, True
917
+ return slot.clip, False
918
+
919
+
920
+ @register("arrangement_automation_via_session_record_start")
921
+ def arrangement_automation_via_session_record_start(song, params):
922
+ """Phase 1 of the T5 workaround.
923
+
924
+ Preps the record: ensures session clip exists with automation, arms
925
+ the track, seeks the arrangement to target_beat, enables record, and
926
+ fires the session clip. Returns tempo so the MCP layer can compute
927
+ the sleep duration before phase 2.
928
+ """
929
+ track_index = int(params["track_index"])
930
+ session_clip_slot = int(params.get("session_clip_slot", 0))
931
+ target_beat = float(params["target_beat"])
932
+ parameter_type = params["parameter_type"]
933
+ points = params.get("points") or []
934
+ if not points:
935
+ raise ValueError("points cannot be empty")
936
+
937
+ track = get_track(song, track_index)
938
+
939
+ # Probe 1: can we arm this track?
940
+ if not getattr(track, "can_be_armed", False):
941
+ raise ValueError(
942
+ "track %d cannot be armed — session-record workaround requires "
943
+ "an armable (MIDI or audio input) track" % track_index
944
+ )
945
+
946
+ slots = list(track.clip_slots)
947
+ if session_clip_slot < 0 or session_clip_slot >= len(slots):
948
+ raise IndexError(
949
+ "session_clip_slot %d out of range (0..%d)"
950
+ % (session_clip_slot, len(slots) - 1)
951
+ )
952
+ slot = slots[session_clip_slot]
953
+
954
+ # Probe 2: create a session clip if the slot is empty; otherwise reuse
955
+ clip, created = _ensure_session_clip_length(slot, points)
956
+
957
+ # Probe 3: write the automation envelope via the existing handler.
958
+ # We need to tell set_clip_automation which clip_index to target.
959
+ # set_clip_automation takes clip_index = the slot index. Perfect.
960
+ clip_auto_params = dict(params)
961
+ clip_auto_params["clip_index"] = session_clip_slot
962
+ # Drop keys that set_clip_automation doesn't expect
963
+ for k in ("target_beat", "duration_beats", "session_clip_slot",
964
+ "cleanup_session_clip"):
965
+ clip_auto_params.pop(k, None)
966
+ write_result = _set_clip_automation_handler(song, clip_auto_params)
967
+
968
+ # Probe 4: seek the arrangement to target_beat. Must be done
969
+ # BEFORE enabling record, or the record starts at wherever the
970
+ # playhead currently is.
971
+ song.back_to_arranger = True
972
+ song.current_song_time = max(0.0, target_beat)
973
+
974
+ # Probe 5: arm the track. Some tracks need `current_monitoring_state`
975
+ # clarification, but arming alone should route the session clip's
976
+ # output into arrangement record on most setups.
977
+ track.arm = True
978
+
979
+ # Probe 6: enable arrangement record + start transport. Live needs
980
+ # the transport playing for record_mode to engage.
981
+ if not song.is_playing:
982
+ song.start_playing()
983
+ song.record_mode = True
984
+
985
+ # Fire the session clip — its automation plays into arrangement.
986
+ _fire_slot(slot)
987
+
988
+ return {
989
+ "status": "recording",
990
+ "track_index": track_index,
991
+ "session_clip_slot": session_clip_slot,
992
+ "target_beat": song.current_song_time,
993
+ "tempo": float(song.tempo),
994
+ "session_clip_created": created,
995
+ "automation_write": write_result,
996
+ "record_mode": bool(song.record_mode),
997
+ "is_playing": bool(song.is_playing),
998
+ }
999
+
1000
+
1001
+ @register("arrangement_automation_via_session_record_complete")
1002
+ def arrangement_automation_via_session_record_complete(song, params):
1003
+ """Phase 2 of the T5 workaround.
1004
+
1005
+ Stops recording, stops transport, disarms the track, locates the
1006
+ newly-recorded arrangement clip, and optionally cleans up the
1007
+ temporary session clip used to source the automation.
1008
+ """
1009
+ track_index = int(params["track_index"])
1010
+ session_clip_slot = int(params.get("session_clip_slot", 0))
1011
+ cleanup = bool(params.get("cleanup_session_clip", True))
1012
+ target_beat = float(params["target_beat"])
1013
+ duration_beats = float(params.get("duration_beats", 0))
1014
+
1015
+ track = get_track(song, track_index)
1016
+ slots = list(track.clip_slots)
1017
+ if session_clip_slot < 0 or session_clip_slot >= len(slots):
1018
+ raise IndexError(
1019
+ "session_clip_slot %d out of range" % session_clip_slot
1020
+ )
1021
+ slot = slots[session_clip_slot]
1022
+
1023
+ # 1. Stop recording and transport
1024
+ song.record_mode = False
1025
+ was_playing = song.is_playing
1026
+ if was_playing:
1027
+ song.stop_playing()
1028
+
1029
+ # 2. Stop the session clip if still playing
1030
+ try:
1031
+ if slot.has_clip and getattr(slot, "is_playing", False):
1032
+ slot.clip.stop()
1033
+ except Exception:
1034
+ pass
1035
+
1036
+ # 3. Disarm the track (leave as-was if never armed)
1037
+ try:
1038
+ track.arm = False
1039
+ except Exception:
1040
+ pass
1041
+
1042
+ # 4. Find the new arrangement clip at or near target_beat.
1043
+ # Tolerance: half a beat — Live may nudge start by quantization.
1044
+ track_clips = list(track.arrangement_clips)
1045
+ new_clip = None
1046
+ new_index = None
1047
+ tolerance = 0.5
1048
+ for i, c in enumerate(track_clips):
1049
+ if abs(float(c.start_time) - target_beat) < tolerance:
1050
+ new_clip = c
1051
+ new_index = i
1052
+ break
1053
+
1054
+ # 5. Cleanup the session clip if requested
1055
+ session_clip_deleted = False
1056
+ if cleanup and slot.has_clip:
1057
+ try:
1058
+ slot.delete_clip()
1059
+ session_clip_deleted = True
1060
+ except Exception:
1061
+ pass
1062
+
1063
+ result = {
1064
+ "status": "completed",
1065
+ "track_index": track_index,
1066
+ "arrangement_clip_found": new_clip is not None,
1067
+ "arrangement_clip_index": new_index,
1068
+ "session_clip_deleted": session_clip_deleted,
1069
+ "record_mode": bool(song.record_mode),
1070
+ "was_playing": was_playing,
1071
+ }
1072
+ if new_clip is not None:
1073
+ result["arrangement_clip_start"] = float(new_clip.start_time)
1074
+ result["arrangement_clip_length"] = float(new_clip.length)
1075
+ try:
1076
+ result["arrangement_clip_name"] = str(new_clip.name)
1077
+ except Exception:
1078
+ pass
1079
+ elif duration_beats > 0:
1080
+ # Guess based on expected range — helpful debug if the lookup missed
1081
+ result["expected_clip_start"] = target_beat
1082
+ result["expected_clip_end"] = target_beat + duration_beats
1083
+ return result
package/server.json CHANGED
@@ -1,17 +1,17 @@
1
1
  {
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
3
  "name": "io.github.dreamrec/livepilot",
4
- "description": "421-tool agentic MCP production system for Ableton Live 12 — device atlas, sample engine, composer",
4
+ "description": "426-tool agentic MCP production system for Ableton Live 12 — device atlas, sample engine, composer",
5
5
  "repository": {
6
6
  "url": "https://github.com/dreamrec/LivePilot",
7
7
  "source": "github"
8
8
  },
9
- "version": "1.16.0",
9
+ "version": "1.17.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "livepilot",
14
- "version": "1.16.0",
14
+ "version": "1.17.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  }