livepilot 1.10.9 → 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.
- package/CHANGELOG.md +245 -0
- package/README.md +7 -7
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/livepilot_bridge.js +1 -1
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/m4l_bridge.py +488 -13
- package/mcp_server/runtime/execution_router.py +7 -0
- package/mcp_server/runtime/mcp_dispatch.py +32 -0
- package/mcp_server/runtime/remote_commands.py +54 -0
- package/mcp_server/sample_engine/slice_classifier.py +169 -0
- package/mcp_server/server.py +11 -3
- package/mcp_server/tools/analyzer.py +187 -7
- package/mcp_server/tools/clips.py +65 -0
- package/mcp_server/tools/devices.py +517 -5
- package/mcp_server/tools/diagnostics.py +42 -0
- package/mcp_server/tools/follow_actions.py +202 -0
- package/mcp_server/tools/grooves.py +142 -0
- package/mcp_server/tools/miditool.py +280 -0
- package/mcp_server/tools/scales.py +126 -0
- package/mcp_server/tools/take_lanes.py +135 -0
- package/mcp_server/tools/tracks.py +46 -3
- package/mcp_server/tools/transport.py +62 -1
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +8 -4
- package/remote_script/LivePilot/clips.py +62 -0
- package/remote_script/LivePilot/devices.py +444 -0
- package/remote_script/LivePilot/diagnostics.py +52 -1
- package/remote_script/LivePilot/follow_actions.py +235 -0
- package/remote_script/LivePilot/grooves.py +185 -0
- package/remote_script/LivePilot/scales.py +138 -0
- package/remote_script/LivePilot/take_lanes.py +175 -0
- package/remote_script/LivePilot/tracks.py +59 -1
- package/remote_script/LivePilot/transport.py +90 -1
- package/remote_script/LivePilot/version_detect.py +9 -0
- 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)}
|