livepilot 1.10.8 → 1.12.2

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 (49) hide show
  1. package/CHANGELOG.md +373 -0
  2. package/README.md +16 -16
  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/evaluation/fabric.py +62 -1
  7. package/mcp_server/m4l_bridge.py +503 -18
  8. package/mcp_server/project_brain/automation_graph.py +23 -1
  9. package/mcp_server/project_brain/builder.py +2 -0
  10. package/mcp_server/project_brain/models.py +20 -1
  11. package/mcp_server/project_brain/tools.py +10 -3
  12. package/mcp_server/runtime/execution_router.py +7 -0
  13. package/mcp_server/runtime/mcp_dispatch.py +32 -0
  14. package/mcp_server/runtime/remote_commands.py +54 -0
  15. package/mcp_server/sample_engine/slice_classifier.py +169 -0
  16. package/mcp_server/semantic_moves/tools.py +139 -31
  17. package/mcp_server/server.py +151 -17
  18. package/mcp_server/session_continuity/models.py +13 -0
  19. package/mcp_server/session_continuity/tools.py +2 -0
  20. package/mcp_server/session_continuity/tracker.py +93 -0
  21. package/mcp_server/tools/_analyzer_engine/__init__.py +39 -0
  22. package/mcp_server/tools/_analyzer_engine/context.py +103 -0
  23. package/mcp_server/tools/_analyzer_engine/flucoma.py +23 -0
  24. package/mcp_server/tools/_analyzer_engine/sample.py +122 -0
  25. package/mcp_server/tools/_motif_engine.py +19 -4
  26. package/mcp_server/tools/analyzer.py +204 -180
  27. package/mcp_server/tools/clips.py +304 -1
  28. package/mcp_server/tools/devices.py +517 -5
  29. package/mcp_server/tools/diagnostics.py +42 -0
  30. package/mcp_server/tools/follow_actions.py +202 -0
  31. package/mcp_server/tools/grooves.py +142 -0
  32. package/mcp_server/tools/miditool.py +280 -0
  33. package/mcp_server/tools/scales.py +126 -0
  34. package/mcp_server/tools/take_lanes.py +135 -0
  35. package/mcp_server/tools/tracks.py +46 -3
  36. package/mcp_server/tools/transport.py +120 -4
  37. package/package.json +2 -2
  38. package/remote_script/LivePilot/__init__.py +15 -4
  39. package/remote_script/LivePilot/clips.py +62 -0
  40. package/remote_script/LivePilot/devices.py +444 -0
  41. package/remote_script/LivePilot/diagnostics.py +52 -1
  42. package/remote_script/LivePilot/follow_actions.py +235 -0
  43. package/remote_script/LivePilot/grooves.py +185 -0
  44. package/remote_script/LivePilot/scales.py +138 -0
  45. package/remote_script/LivePilot/take_lanes.py +175 -0
  46. package/remote_script/LivePilot/tracks.py +59 -1
  47. package/remote_script/LivePilot/transport.py +90 -1
  48. package/remote_script/LivePilot/version_detect.py +9 -0
  49. package/server.json +3 -3
@@ -0,0 +1,202 @@
1
+ """Follow Actions tools (Live 12.0+ clip, 12.2+ scene).
2
+
3
+ 8 tools matching the Remote Script follow_actions domain:
4
+ - Clip follow actions (Live 12.0 revamp): get/set/clear, a preset
5
+ wrapper, and enum-name enumeration.
6
+ - Scene follow actions (Live 12.2+): get/set/clear with "Longest"
7
+ mode (``linked``) and the 1-8 ``multiplier`` selector.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Optional
13
+
14
+ from fastmcp import Context
15
+
16
+ from ..server import mcp
17
+
18
+
19
+ def _get_ableton(ctx: Context):
20
+ """Extract AbletonConnection from lifespan context."""
21
+ return ctx.lifespan_context["ableton"]
22
+
23
+
24
+ # ── Clip follow actions (Live 12.0+) ─────────────────────────────────────
25
+
26
+
27
+ @mcp.tool()
28
+ def get_clip_follow_action(ctx: Context, track_index: int, clip_index: int) -> dict:
29
+ """Read a clip's follow-action state (Live 12.0+).
30
+
31
+ Returns:
32
+ enabled: bool — follow-action master switch
33
+ action_a: primary action name (stop, play_again, previous,
34
+ next, first, last, any, other, jump)
35
+ action_b: secondary action (used when chance_b > 0)
36
+ chance_a: probability of action_a firing (0.0-1.0)
37
+ chance_b: probability of action_b firing (0.0-1.0)
38
+ time: follow-action trigger time in beats
39
+ """
40
+ return _get_ableton(ctx).send_command("get_clip_follow_action", {
41
+ "track_index": track_index,
42
+ "clip_index": clip_index,
43
+ })
44
+
45
+
46
+ @mcp.tool()
47
+ def set_clip_follow_action(
48
+ ctx: Context,
49
+ track_index: int,
50
+ clip_index: int,
51
+ action_a: Optional[str] = None,
52
+ action_b: Optional[str] = None,
53
+ chance_a: Optional[float] = None,
54
+ chance_b: Optional[float] = None,
55
+ time: Optional[float] = None,
56
+ enabled: Optional[bool] = None,
57
+ ) -> dict:
58
+ """Set a clip's follow action (Live 12.0+). Any omitted arg preserves.
59
+
60
+ action_a/b values (string): stop, play_again, previous, next,
61
+ first, last, any, other, jump.
62
+ chance_a/b: probability 0.0-1.0. Live normalizes the split between
63
+ the two actions — set chance_b=0 to always fire action_a.
64
+ time: follow-action trigger time in beats (e.g. 1.0 = one bar in 4/4,
65
+ 4.0 = one bar in 4/4 if the clip is 4 beats long).
66
+ enabled: master on/off for follow actions on this clip.
67
+ """
68
+ payload: dict = {"track_index": track_index, "clip_index": clip_index}
69
+ if action_a is not None:
70
+ payload["action_a"] = action_a
71
+ if action_b is not None:
72
+ payload["action_b"] = action_b
73
+ if chance_a is not None:
74
+ if not 0.0 <= chance_a <= 1.0:
75
+ raise ValueError("chance_a must be 0.0-1.0")
76
+ payload["chance_a"] = chance_a
77
+ if chance_b is not None:
78
+ if not 0.0 <= chance_b <= 1.0:
79
+ raise ValueError("chance_b must be 0.0-1.0")
80
+ payload["chance_b"] = chance_b
81
+ if time is not None:
82
+ if time < 0.0:
83
+ raise ValueError("time must be >= 0.0")
84
+ payload["time"] = time
85
+ if enabled is not None:
86
+ payload["enabled"] = enabled
87
+ return _get_ableton(ctx).send_command("set_clip_follow_action", payload)
88
+
89
+
90
+ @mcp.tool()
91
+ def clear_clip_follow_action(ctx: Context, track_index: int, clip_index: int) -> dict:
92
+ """Disable follow action on a clip (Live 12.0+).
93
+
94
+ Sets follow_action_enabled=False without touching the action/chance
95
+ values, so re-enabling keeps the previous configuration.
96
+ """
97
+ return _get_ableton(ctx).send_command("clear_clip_follow_action", {
98
+ "track_index": track_index,
99
+ "clip_index": clip_index,
100
+ })
101
+
102
+
103
+ @mcp.tool()
104
+ def list_follow_action_types(ctx: Context) -> dict:
105
+ """List valid follow-action names (Live 12.0+).
106
+
107
+ Returns the 9 enum values usable for action_a/action_b:
108
+ stop, play_again, previous, next, first, last, any, other, jump.
109
+ """
110
+ return _get_ableton(ctx).send_command("list_follow_action_types", {})
111
+
112
+
113
+ @mcp.tool()
114
+ def apply_follow_action_preset(
115
+ ctx: Context,
116
+ track_index: int,
117
+ clip_index: int,
118
+ preset: str,
119
+ ) -> dict:
120
+ """Apply a named follow-action preset to a clip (Live 12.0+).
121
+
122
+ Presets:
123
+ loop_forever — re-fires the clip each bar indefinitely
124
+ (action_a=play_again, chance 100%)
125
+ random_walk — 50/50 split between next and previous clip
126
+ next_after_one — play the clip once, advance to next slot
127
+ stop_after_one — play the clip once, then stop
128
+ Each preset sets action_a, action_b, chance_a, chance_b, time
129
+ and enables follow actions. Time is 1.0 beat across all presets.
130
+ """
131
+ valid = ["loop_forever", "random_walk", "next_after_one", "stop_after_one"]
132
+ if preset not in valid:
133
+ raise ValueError("preset must be one of %s" % ", ".join(valid))
134
+ return _get_ableton(ctx).send_command("apply_follow_action_preset", {
135
+ "track_index": track_index,
136
+ "clip_index": clip_index,
137
+ "preset": preset,
138
+ })
139
+
140
+
141
+ # ── Scene follow actions (Live 12.2+) ────────────────────────────────────
142
+
143
+
144
+ @mcp.tool()
145
+ def get_scene_follow_action(ctx: Context, scene_index: int) -> dict:
146
+ """Read a scene's follow-action state (Live 12.2+).
147
+
148
+ Returns:
149
+ enabled: bool — scene follow-action master switch
150
+ time: trigger time in beats
151
+ linked: True = "Longest" mode (waits for longest clip's loop)
152
+ multiplier: 1-8, used when not linked (time * multiplier = trigger)
153
+ """
154
+ return _get_ableton(ctx).send_command("get_scene_follow_action", {
155
+ "scene_index": scene_index,
156
+ })
157
+
158
+
159
+ @mcp.tool()
160
+ def set_scene_follow_action(
161
+ ctx: Context,
162
+ scene_index: int,
163
+ enabled: Optional[bool] = None,
164
+ time: Optional[float] = None,
165
+ linked: Optional[bool] = None,
166
+ multiplier: Optional[int] = None,
167
+ ) -> dict:
168
+ """Set a scene's follow action (Live 12.2+). Any omitted arg preserves.
169
+
170
+ enabled: on/off master switch for this scene's follow action
171
+ time: trigger time in beats (e.g. 4.0 = one bar in 4/4)
172
+ linked: True = "Longest" mode — waits for the longest clip in
173
+ the scene to complete one loop
174
+ multiplier: 1-8 — multiplies `time` when not linked. Used to trigger
175
+ the follow action every N beats.
176
+ """
177
+ payload: dict = {"scene_index": scene_index}
178
+ if enabled is not None:
179
+ payload["enabled"] = enabled
180
+ if time is not None:
181
+ if time < 0.0:
182
+ raise ValueError("time must be >= 0.0")
183
+ payload["time"] = time
184
+ if linked is not None:
185
+ payload["linked"] = linked
186
+ if multiplier is not None:
187
+ if not 1 <= multiplier <= 8:
188
+ raise ValueError("multiplier must be 1-8")
189
+ payload["multiplier"] = multiplier
190
+ return _get_ableton(ctx).send_command("set_scene_follow_action", payload)
191
+
192
+
193
+ @mcp.tool()
194
+ def clear_scene_follow_action(ctx: Context, scene_index: int) -> dict:
195
+ """Disable a scene's follow action (Live 12.2+).
196
+
197
+ Sets follow_action_enabled=False without touching time/linked/
198
+ multiplier, so re-enabling preserves the prior configuration.
199
+ """
200
+ return _get_ableton(ctx).send_command("clear_scene_follow_action", {
201
+ "scene_index": scene_index,
202
+ })
@@ -0,0 +1,142 @@
1
+ """Groove Pool tools (Live 11+).
2
+
3
+ 7 tools matching the Remote Script grooves domain:
4
+ - list_grooves / get_groove_info — enumerate the pool and inspect.
5
+ - set_groove_params — adjust quantization/random/timing/velocity amounts.
6
+ - assign_clip_groove / get_clip_groove — per-clip groove binding
7
+ (pass ``groove_id = -1`` to clear).
8
+ - get/set_song_groove_amount — master groove dial (0.0-1.31).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Optional
14
+
15
+ from fastmcp import Context
16
+
17
+ from ..server import mcp
18
+
19
+
20
+ def _get_ableton(ctx: Context):
21
+ """Extract AbletonConnection from lifespan context."""
22
+ return ctx.lifespan_context["ableton"]
23
+
24
+
25
+ @mcp.tool()
26
+ def list_grooves(ctx: Context) -> dict:
27
+ """List all grooves in the Groove Pool (Live 11+).
28
+
29
+ Returns each groove's id (index), name, base quantization grid
30
+ (integer enum, e.g. 1/16th = 4), quantization_amount, random_amount,
31
+ timing_amount, and velocity_amount. Use the id with
32
+ assign_clip_groove() or set_groove_params().
33
+ """
34
+ return _get_ableton(ctx).send_command("list_grooves", {})
35
+
36
+
37
+ @mcp.tool()
38
+ def get_groove_info(ctx: Context, groove_id: int) -> dict:
39
+ """Read a single groove's parameters (Live 11+).
40
+
41
+ groove_id is the index from list_grooves(). Returns the same shape
42
+ as one entry of list_grooves().
43
+ """
44
+ return _get_ableton(ctx).send_command("get_groove_info", {
45
+ "groove_id": groove_id,
46
+ })
47
+
48
+
49
+ @mcp.tool()
50
+ def set_groove_params(
51
+ ctx: Context,
52
+ groove_id: int,
53
+ quantization_amount: Optional[float] = None,
54
+ random_amount: Optional[float] = None,
55
+ timing_amount: Optional[float] = None,
56
+ velocity_amount: Optional[float] = None,
57
+ ) -> dict:
58
+ """Adjust a groove's parameters (Live 11+). Omitted args preserve.
59
+
60
+ Ranges:
61
+ quantization_amount, random_amount, timing_amount: 0.0-1.0
62
+ velocity_amount: -1.0 to 1.0 (signed — negative subtracts velocity)
63
+ Any field left unspecified keeps its current value. Returns the
64
+ full groove_info dict after the update.
65
+ """
66
+ payload: dict = {"groove_id": groove_id}
67
+ if quantization_amount is not None:
68
+ if not 0.0 <= quantization_amount <= 1.0:
69
+ raise ValueError("quantization_amount must be 0.0-1.0")
70
+ payload["quantization_amount"] = quantization_amount
71
+ if random_amount is not None:
72
+ if not 0.0 <= random_amount <= 1.0:
73
+ raise ValueError("random_amount must be 0.0-1.0")
74
+ payload["random_amount"] = random_amount
75
+ if timing_amount is not None:
76
+ if not 0.0 <= timing_amount <= 1.0:
77
+ raise ValueError("timing_amount must be 0.0-1.0")
78
+ payload["timing_amount"] = timing_amount
79
+ if velocity_amount is not None:
80
+ if not -1.0 <= velocity_amount <= 1.0:
81
+ raise ValueError("velocity_amount must be -1.0 to 1.0")
82
+ payload["velocity_amount"] = velocity_amount
83
+ return _get_ableton(ctx).send_command("set_groove_params", payload)
84
+
85
+
86
+ @mcp.tool()
87
+ def assign_clip_groove(
88
+ ctx: Context,
89
+ track_index: int,
90
+ clip_index: int,
91
+ groove_id: int = -1,
92
+ ) -> dict:
93
+ """Assign a groove to a clip (Live 11+).
94
+
95
+ groove_id: integer index from list_grooves(), or -1 to clear the
96
+ clip's groove (sets clip.groove = None). Returns
97
+ {track_index, clip_index, groove_id, groove_name} — both id and
98
+ name are None when cleared.
99
+ """
100
+ return _get_ableton(ctx).send_command("assign_clip_groove", {
101
+ "track_index": track_index,
102
+ "clip_index": clip_index,
103
+ "groove_id": groove_id,
104
+ })
105
+
106
+
107
+ @mcp.tool()
108
+ def get_clip_groove(ctx: Context, track_index: int, clip_index: int) -> dict:
109
+ """Read a clip's current groove assignment (Live 11+).
110
+
111
+ Returns {groove_id, groove_name}. Both are null/None if the clip
112
+ has no groove assigned.
113
+ """
114
+ return _get_ableton(ctx).send_command("get_clip_groove", {
115
+ "track_index": track_index,
116
+ "clip_index": clip_index,
117
+ })
118
+
119
+
120
+ @mcp.tool()
121
+ def get_song_groove_amount(ctx: Context) -> dict:
122
+ """Read the master groove amount dial (Live 11+).
123
+
124
+ Scales the effect of ALL assigned grooves on playback. 0.0 = no
125
+ groove influence; 1.0 = nominal; up to 1.31 = exaggerated.
126
+ """
127
+ return _get_ableton(ctx).send_command("get_song_groove_amount", {})
128
+
129
+
130
+ @mcp.tool()
131
+ def set_song_groove_amount(ctx: Context, amount: float) -> dict:
132
+ """Set the master groove amount dial (Live 11+).
133
+
134
+ Scales all grooves' effect on playback. Range 0.0-1.31.
135
+ Live's spec nominally caps at 1.0 but the exposed property
136
+ accepts values up to ~1.31, matching the UI's maximum nudge.
137
+ """
138
+ if not 0.0 <= amount <= 1.31:
139
+ raise ValueError("amount must be 0.0-1.31")
140
+ return _get_ableton(ctx).send_command("set_song_groove_amount", {
141
+ "amount": amount,
142
+ })
@@ -0,0 +1,280 @@
1
+ """MIDI Tool bridge (Live 12.0+ MIDI Generators / Transformations).
2
+
3
+ 4 tools that let LivePilot generators run inside a clip's native
4
+ MIDI Tool slot. Traffic rides the existing UDP 9880/9881 M4L bridge
5
+ with a new /miditool/* OSC prefix; the user drops one of the
6
+ companion .amxd files (LivePilot_MIDITool_Transform.amxd for
7
+ Transformations, LivePilot_MIDITool_Generate.amxd for Generators)
8
+ onto the clip and configures which generator handles the note list
9
+ via ``set_miditool_target``. Both .amxd files share the same
10
+ miditool_bridge.js logic — the only difference is whether
11
+ ``live.miditool.in`` is in Transformation or Generator mode.
12
+
13
+ See ``m4l_device/MIDITOOL_BUILD_GUIDE.md`` for the Max build.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import platform
20
+ import shutil
21
+ from typing import Optional
22
+
23
+ from fastmcp import Context
24
+
25
+ from ..server import mcp
26
+ from .. import m4l_bridge as _bridge_module
27
+
28
+
29
+ # ── Install paths ───────────────────────────────────────────────────────────
30
+
31
+ _M4L_DIR = os.path.normpath(
32
+ os.path.join(
33
+ os.path.dirname(os.path.abspath(__file__)),
34
+ "..", "..", "m4l_device",
35
+ )
36
+ )
37
+
38
+ # Two .amxd variants. Live 12 classifies them as Generator or Transformation
39
+ # via the 'nagg' vs 'natt' amxdtype marker in project.amxdtype. They install
40
+ # into DIFFERENT User Library subfolders for each role — Live's MIDI Tool
41
+ # indexer treats the folder as the authoritative category.
42
+ _AMXD_VARIANTS = (
43
+ ("LivePilot_MIDITool_Generate.amxd", "MIDI Tools/Max Generators"),
44
+ ("LivePilot_MIDITool_Transform.amxd", "MIDI Tools/Max Transformations"),
45
+ )
46
+
47
+ # Also copy the JS bridge alongside the .amxd so Max can find it — the
48
+ # device references `js miditool_bridge.js` and Max searches relative to
49
+ # the .amxd's location.
50
+ _BRIDGE_JS = "miditool_bridge.js"
51
+
52
+ _MACOS_USER_LIB = os.path.expanduser("~/Music/Ableton/User Library")
53
+
54
+ _BUILD_GUIDE_REL = "m4l_device/MIDITOOL_BUILD_GUIDE.md"
55
+
56
+
57
+ def _get_miditool_cache(ctx: Context):
58
+ """Resolve the MidiToolCache from the lifespan context."""
59
+ cache = ctx.lifespan_context.get("miditool")
60
+ if cache is None:
61
+ raise ValueError(
62
+ "MIDI Tool cache not initialized — restart the MCP server"
63
+ )
64
+ return cache
65
+
66
+
67
+ def _get_m4l_bridge(ctx: Context):
68
+ """Resolve the M4LBridge from the lifespan context."""
69
+ bridge = ctx.lifespan_context.get("m4l")
70
+ if bridge is None:
71
+ raise ValueError("M4L bridge not initialized — restart the MCP server")
72
+ return bridge
73
+
74
+
75
+ # ── Tool 1: install_miditool_device ────────────────────────────────────────
76
+
77
+ @mcp.tool()
78
+ def install_miditool_device(ctx: Context) -> dict:
79
+ """Install LivePilot MIDI Tool .amxd files into Ableton's User Library.
80
+
81
+ Copies both variants from ``m4l_device/`` to the correct MIDI Tools
82
+ subfolders. Live 12 classifies a device as Generator vs Transformation
83
+ via the ``project.amxdtype`` marker ('nagg' vs 'natt') inside the .amxd,
84
+ AND indexes them from these specific folders:
85
+
86
+ - ``Generate.amxd`` → ``User Library/MIDI Tools/Max Generators/``
87
+ - ``Transform.amxd`` → ``User Library/MIDI Tools/Max Transformations/``
88
+
89
+ Also copies ``miditool_bridge.js`` alongside each .amxd so the ``[js]``
90
+ object can find it (Max searches relative to the .amxd's location).
91
+
92
+ Build the .amxd files first with ``scripts/build_miditool_amxd.py``,
93
+ which patches Live's factory Max MIDI Generator/Transformation
94
+ templates with our bridge wiring while preserving the amxdtype marker.
95
+
96
+ After running this, right-click User Library in Live's browser →
97
+ Refresh. Then open a MIDI clip's Generators or Transformations
98
+ dropdown — ``LivePilot MIDI Tool (Generate/Transform)`` will be listed
99
+ under User:.
100
+
101
+ Returns ``{installed: [...], skipped: [...], user_library}``.
102
+ macOS-only for this chunk.
103
+ """
104
+ if platform.system() != "Darwin":
105
+ raise NotImplementedError(
106
+ "install_miditool_device currently supports macOS only. "
107
+ "Windows install path is ~/Documents/Ableton/User Library/... "
108
+ "— copy manually until Windows support ships."
109
+ )
110
+
111
+ bridge_src = os.path.join(_M4L_DIR, _BRIDGE_JS)
112
+ if not os.path.isfile(bridge_src):
113
+ raise FileNotFoundError(
114
+ f"Bridge JS not found at {bridge_src}. Source tree is missing "
115
+ "miditool_bridge.js — re-clone or check out the branch."
116
+ )
117
+
118
+ installed = []
119
+ skipped = []
120
+ for filename, subfolder in _AMXD_VARIANTS:
121
+ src = os.path.join(_M4L_DIR, filename)
122
+ if not os.path.isfile(src):
123
+ skipped.append({
124
+ "variant": filename,
125
+ "reason": f"source not found at {src}. Run "
126
+ "scripts/build_miditool_amxd.py to build it.",
127
+ })
128
+ continue
129
+ dest_dir = os.path.join(_MACOS_USER_LIB, subfolder)
130
+ os.makedirs(dest_dir, exist_ok=True)
131
+ dest = os.path.join(dest_dir, filename)
132
+ existed_before = os.path.isfile(dest)
133
+ shutil.copy2(src, dest)
134
+ # Also copy the bridge JS into the same folder so Max's [js] object
135
+ # can find it at runtime.
136
+ shutil.copy2(bridge_src, os.path.join(dest_dir, _BRIDGE_JS))
137
+ installed.append({
138
+ "variant": filename,
139
+ "installed_path": dest,
140
+ "existed_before": existed_before,
141
+ "category": subfolder.split("/")[-1],
142
+ })
143
+
144
+ if not installed:
145
+ raise FileNotFoundError(
146
+ "No .amxd variants found in m4l_device/. Run "
147
+ "scripts/build_miditool_amxd.py first to build them from "
148
+ "Live's factory templates, then re-run install_miditool_device()."
149
+ )
150
+
151
+ return {
152
+ "installed": installed,
153
+ "skipped": skipped,
154
+ "user_library": _MACOS_USER_LIB,
155
+ "hint": (
156
+ "Right-click User Library in Live's browser → Refresh, then "
157
+ "open a MIDI clip's Generators (for Generate.amxd) or "
158
+ "Transformations (for Transform.amxd) dropdown. The LivePilot "
159
+ "devices appear in the User section. Call set_miditool_target() "
160
+ "to configure which generator handles incoming requests "
161
+ "before firing the tool on a clip."
162
+ ),
163
+ }
164
+
165
+
166
+ # ── Tool 2: set_miditool_target ────────────────────────────────────────────
167
+
168
+ @mcp.tool()
169
+ def set_miditool_target(
170
+ ctx: Context,
171
+ tool_name: str,
172
+ params: Optional[dict] = None,
173
+ ) -> dict:
174
+ """Configure which LivePilot generator handles MIDI Tool requests.
175
+
176
+ When Live fires the MIDI Tool on a clip, the bridge forwards
177
+ ``(notes, context)`` to the server; the server invokes the configured
178
+ generator and pushes transformed notes back for Live to write into
179
+ the clip.
180
+
181
+ Args:
182
+ tool_name: One of the registered generators. Call
183
+ ``list_miditool_generators()`` to see names and
184
+ required params. v1.11.0 ships with
185
+ ``euclidean_rhythm``, ``tintinnabuli``, ``humanize``.
186
+ params: Generator-specific options (see
187
+ ``list_miditool_generators``). Pass ``None`` or ``{}``
188
+ to use defaults.
189
+
190
+ Returns ``{tool_name, params, active}``.
191
+ """
192
+ known = _bridge_module.available_generators()
193
+ if tool_name not in known:
194
+ raise ValueError(
195
+ f"Unknown generator '{tool_name}'. "
196
+ f"Registered generators: {', '.join(known)}. "
197
+ "Call list_miditool_generators() for descriptions."
198
+ )
199
+
200
+ params = dict(params or {})
201
+ cache = _get_miditool_cache(ctx)
202
+ cache.set_target(tool_name, params)
203
+
204
+ # Tell the JS bridge too so it knows what's queued even if it wants to
205
+ # show UI state. The bridge itself still asks the server to run the
206
+ # generator — this is informational + future-proofing.
207
+ bridge = _get_m4l_bridge(ctx)
208
+ try:
209
+ bridge.send_miditool_config(tool_name, params)
210
+ config_sent = True
211
+ except Exception:
212
+ # Bridge may not be up yet; not fatal — the server-side target is set.
213
+ config_sent = False
214
+
215
+ return {
216
+ "tool_name": tool_name,
217
+ "params": params,
218
+ "active": True,
219
+ "config_pushed_to_bridge": config_sent,
220
+ }
221
+
222
+
223
+ # ── Tool 3: get_miditool_context ───────────────────────────────────────────
224
+
225
+ @mcp.tool()
226
+ def get_miditool_context(ctx: Context) -> dict:
227
+ """Return the most recent MIDI Tool context received from the bridge.
228
+
229
+ Fields come from Live's ``live.miditool.in`` right outlet:
230
+ grid: current grid subdivision (float beats)
231
+ selection: {start, end} clip time range Live will replace
232
+ scale: {root, name, mode} current Scale Mode state
233
+ seed: RNG seed Live passes to the tool for determinism
234
+ tuning: {name, reference_pitch} Tuning System info (12.1+)
235
+
236
+ Also returns ``note_count`` (how many notes arrived in the last
237
+ request) and ``connected`` (True once the bridge has pinged).
238
+
239
+ If the bridge hasn't emitted a request in the last ~5 seconds,
240
+ returns ``{"connected": False}`` — the analyzer/miditool .amxd
241
+ may not be loaded, or no MIDI Tool fire has happened yet.
242
+ """
243
+ cache = _get_miditool_cache(ctx)
244
+ if not cache.is_connected:
245
+ return {"connected": False}
246
+
247
+ ctx_data = cache.get_last_context() or {}
248
+ notes = cache.get_last_notes() or []
249
+
250
+ return {
251
+ "connected": True,
252
+ "grid": ctx_data.get("grid"),
253
+ "selection": ctx_data.get("selection"),
254
+ "scale": ctx_data.get("scale"),
255
+ "seed": ctx_data.get("seed"),
256
+ "tuning": ctx_data.get("tuning"),
257
+ "note_count": len(notes),
258
+ }
259
+
260
+
261
+ # ── Tool 4: list_miditool_generators ───────────────────────────────────────
262
+
263
+ @mcp.tool()
264
+ def list_miditool_generators(ctx: Context) -> dict:
265
+ """Enumerate the generators available for MIDI Tool targets.
266
+
267
+ Each entry reports ``name``, ``description``, ``required_params``,
268
+ and ``optional_params``. Use the names with
269
+ ``set_miditool_target(tool_name=...)`` to configure the bridge.
270
+ """
271
+ entries = []
272
+ for name in _bridge_module.available_generators():
273
+ meta = _bridge_module.GENERATOR_METADATA.get(name, {})
274
+ entries.append({
275
+ "name": name,
276
+ "description": meta.get("description", ""),
277
+ "required_params": list(meta.get("required_params", [])),
278
+ "optional_params": list(meta.get("optional_params", [])),
279
+ })
280
+ return {"generators": entries, "count": len(entries)}