livepilot 1.16.1 → 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 (50) hide show
  1. package/CHANGELOG.md +269 -0
  2. package/README.md +16 -15
  3. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  4. package/mcp_server/__init__.py +1 -1
  5. package/mcp_server/atlas/__init__.py +85 -0
  6. package/mcp_server/atlas/device_atlas.json +3183 -382
  7. package/mcp_server/atlas/device_techniques_index.json +1510 -0
  8. package/mcp_server/atlas/enrichments/__init__.py +1 -0
  9. package/mcp_server/atlas/enrichments/audio_effects/amp.yaml +112 -0
  10. package/mcp_server/atlas/enrichments/audio_effects/audio_effect_rack.yaml +77 -0
  11. package/mcp_server/atlas/enrichments/audio_effects/cabinet.yaml +81 -0
  12. package/mcp_server/atlas/enrichments/audio_effects/corpus.yaml +128 -0
  13. package/mcp_server/atlas/enrichments/audio_effects/envelope_follower.yaml +99 -0
  14. package/mcp_server/atlas/enrichments/audio_effects/external_audio_effect.yaml +64 -0
  15. package/mcp_server/atlas/enrichments/audio_effects/looper.yaml +85 -0
  16. package/mcp_server/atlas/enrichments/audio_effects/resonators.yaml +121 -0
  17. package/mcp_server/atlas/enrichments/audio_effects/snipper.yaml +17 -0
  18. package/mcp_server/atlas/enrichments/audio_effects/spectrum.yaml +61 -0
  19. package/mcp_server/atlas/enrichments/audio_effects/tuner.yaml +43 -0
  20. package/mcp_server/atlas/enrichments/audio_effects/utility.yaml +118 -0
  21. package/mcp_server/atlas/enrichments/audio_effects/vocoder.yaml +94 -0
  22. package/mcp_server/atlas/enrichments/instruments/analog.yaml +11 -0
  23. package/mcp_server/atlas/enrichments/instruments/bass.yaml +11 -0
  24. package/mcp_server/atlas/enrichments/instruments/bell_tower.yaml +38 -0
  25. package/mcp_server/atlas/enrichments/instruments/collision.yaml +11 -0
  26. package/mcp_server/atlas/enrichments/instruments/drift.yaml +11 -0
  27. package/mcp_server/atlas/enrichments/instruments/drum_rack.yaml +142 -0
  28. package/mcp_server/atlas/enrichments/instruments/electric.yaml +11 -0
  29. package/mcp_server/atlas/enrichments/instruments/emit.yaml +11 -0
  30. package/mcp_server/atlas/enrichments/instruments/meld.yaml +11 -0
  31. package/mcp_server/atlas/enrichments/instruments/operator.yaml +11 -0
  32. package/mcp_server/atlas/enrichments/instruments/poli.yaml +11 -0
  33. package/mcp_server/atlas/enrichments/instruments/sampler.yaml +12 -0
  34. package/mcp_server/atlas/enrichments/instruments/simpler.yaml +15 -0
  35. package/mcp_server/atlas/enrichments/instruments/tension.yaml +11 -0
  36. package/mcp_server/atlas/enrichments/instruments/vector_fm.yaml +11 -0
  37. package/mcp_server/atlas/enrichments/instruments/vector_grain.yaml +11 -0
  38. package/mcp_server/atlas/enrichments/instruments/wavetable.yaml +11 -0
  39. package/mcp_server/atlas/enrichments/midi_effects/filler.yaml +17 -0
  40. package/mcp_server/atlas/enrichments/utility/performer.yaml +15 -0
  41. package/mcp_server/atlas/enrichments/utility/vector_map.yaml +21 -0
  42. package/mcp_server/atlas/tools.py +291 -0
  43. package/mcp_server/m4l_bridge.py +19 -2
  44. package/mcp_server/sample_engine/tools.py +140 -68
  45. package/mcp_server/splice_client/http_bridge.py +319 -116
  46. package/mcp_server/tools/automation.py +168 -0
  47. package/package.json +2 -2
  48. package/remote_script/LivePilot/__init__.py +1 -1
  49. package/remote_script/LivePilot/arrangement.py +216 -1
  50. package/server.json +3 -3
@@ -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.1",
3
+ "version": "1.17.0",
4
4
  "mcpName": "io.github.dreamrec/livepilot",
5
- "description": "Agentic production system for Ableton Live 12 — 422 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.1"
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": "422-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.1",
9
+ "version": "1.17.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "livepilot",
14
- "version": "1.16.1",
14
+ "version": "1.17.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  }