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.
- package/CHANGELOG.md +373 -0
- package/README.md +16 -16
- 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/evaluation/fabric.py +62 -1
- package/mcp_server/m4l_bridge.py +503 -18
- package/mcp_server/project_brain/automation_graph.py +23 -1
- package/mcp_server/project_brain/builder.py +2 -0
- package/mcp_server/project_brain/models.py +20 -1
- package/mcp_server/project_brain/tools.py +10 -3
- 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/semantic_moves/tools.py +139 -31
- package/mcp_server/server.py +151 -17
- package/mcp_server/session_continuity/models.py +13 -0
- package/mcp_server/session_continuity/tools.py +2 -0
- package/mcp_server/session_continuity/tracker.py +93 -0
- package/mcp_server/tools/_analyzer_engine/__init__.py +39 -0
- package/mcp_server/tools/_analyzer_engine/context.py +103 -0
- package/mcp_server/tools/_analyzer_engine/flucoma.py +23 -0
- package/mcp_server/tools/_analyzer_engine/sample.py +122 -0
- package/mcp_server/tools/_motif_engine.py +19 -4
- package/mcp_server/tools/analyzer.py +204 -180
- package/mcp_server/tools/clips.py +304 -1
- 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 +120 -4
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +15 -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,235 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LivePilot — Follow Actions handlers (Live 12.0+ clip, 12.2+ scene).
|
|
3
|
+
|
|
4
|
+
Exposes the revamped clip follow-action API (Live 12.0) and the
|
|
5
|
+
scene-level follow-action properties added in Live 12.2.
|
|
6
|
+
|
|
7
|
+
Clip follow-actions use an integer enum internally (0..8) mapped
|
|
8
|
+
bidirectionally to string names via ``_FOLLOW_ACTION_NAMES``. We
|
|
9
|
+
accept strings from tool wrappers and convert to int for the Live
|
|
10
|
+
API; on read we convert back to the string form. Unknown / future
|
|
11
|
+
enum values (e.g. anything Live 12.4+ might add) fall through as a
|
|
12
|
+
plain stringified int rather than raising, so the tool remains
|
|
13
|
+
forward-compatible for additive enum growth.
|
|
14
|
+
|
|
15
|
+
Chance values are documented by Live as 0.0-1.0 normalized. We
|
|
16
|
+
accept the same range from callers and pass through unchanged.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from .router import register
|
|
20
|
+
from .utils import get_clip, get_scene
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_FOLLOW_ACTION_NAMES = [
|
|
24
|
+
"stop", # 0
|
|
25
|
+
"play_again", # 1
|
|
26
|
+
"previous", # 2
|
|
27
|
+
"next", # 3
|
|
28
|
+
"first", # 4
|
|
29
|
+
"last", # 5
|
|
30
|
+
"any", # 6
|
|
31
|
+
"other", # 7
|
|
32
|
+
"jump", # 8
|
|
33
|
+
]
|
|
34
|
+
_FOLLOW_ACTION_IDX = {name: i for i, name in enumerate(_FOLLOW_ACTION_NAMES)}
|
|
35
|
+
|
|
36
|
+
_FOLLOW_ACTION_PRESETS = {
|
|
37
|
+
"loop_forever": {"action_a": "play_again", "action_b": "stop",
|
|
38
|
+
"chance_a": 1.0, "chance_b": 0.0, "time": 1.0},
|
|
39
|
+
"random_walk": {"action_a": "next", "action_b": "previous",
|
|
40
|
+
"chance_a": 0.5, "chance_b": 0.5, "time": 1.0},
|
|
41
|
+
"next_after_one": {"action_a": "next", "action_b": "stop",
|
|
42
|
+
"chance_a": 1.0, "chance_b": 0.0, "time": 1.0},
|
|
43
|
+
"stop_after_one": {"action_a": "stop", "action_b": "stop",
|
|
44
|
+
"chance_a": 1.0, "chance_b": 0.0, "time": 1.0},
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _action_name(idx):
|
|
49
|
+
"""Map int enum → string, tolerating out-of-range (future) values."""
|
|
50
|
+
try:
|
|
51
|
+
return _FOLLOW_ACTION_NAMES[int(idx)]
|
|
52
|
+
except (IndexError, ValueError):
|
|
53
|
+
return str(idx)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _action_idx(name_or_int):
|
|
57
|
+
"""Map string → int enum, passing through ints untouched."""
|
|
58
|
+
if isinstance(name_or_int, int):
|
|
59
|
+
return name_or_int
|
|
60
|
+
key = str(name_or_int).lower()
|
|
61
|
+
if key not in _FOLLOW_ACTION_IDX:
|
|
62
|
+
raise ValueError(
|
|
63
|
+
"Unknown follow action '%s'. Valid: %s"
|
|
64
|
+
% (name_or_int, ", ".join(_FOLLOW_ACTION_NAMES))
|
|
65
|
+
)
|
|
66
|
+
return _FOLLOW_ACTION_IDX[key]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _read_clip_follow_action(clip):
|
|
70
|
+
"""Snapshot all clip follow-action fields as a plain dict."""
|
|
71
|
+
return {
|
|
72
|
+
"enabled": bool(getattr(clip, "follow_action_enabled", False)),
|
|
73
|
+
"action_a": _action_name(clip.follow_action_a),
|
|
74
|
+
"action_b": _action_name(clip.follow_action_b),
|
|
75
|
+
"chance_a": float(clip.follow_action_chance_a),
|
|
76
|
+
"chance_b": float(clip.follow_action_chance_b),
|
|
77
|
+
"time": float(clip.follow_action_time),
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@register("list_follow_action_types")
|
|
82
|
+
def list_follow_action_types(song, params):
|
|
83
|
+
"""Return the list of valid follow-action names."""
|
|
84
|
+
return {"actions": list(_FOLLOW_ACTION_NAMES)}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@register("get_clip_follow_action")
|
|
88
|
+
def get_clip_follow_action(song, params):
|
|
89
|
+
"""Read a clip's follow-action state (Live 12.0+)."""
|
|
90
|
+
from .version_detect import has_feature
|
|
91
|
+
if not has_feature("clip_follow_action_v2"):
|
|
92
|
+
raise RuntimeError("Clip follow actions require Live 12.0+.")
|
|
93
|
+
clip = get_clip(song, int(params["track_index"]), int(params["clip_index"]))
|
|
94
|
+
return _read_clip_follow_action(clip)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@register("set_clip_follow_action")
|
|
98
|
+
def set_clip_follow_action(song, params):
|
|
99
|
+
"""Set a clip's follow-action state (Live 12.0+).
|
|
100
|
+
|
|
101
|
+
Any of action_a, action_b, chance_a, chance_b, time, enabled may
|
|
102
|
+
be omitted — omitted fields leave the current value untouched.
|
|
103
|
+
Chance values are 0.0-1.0 normalized per Live's public API.
|
|
104
|
+
"""
|
|
105
|
+
from .version_detect import has_feature
|
|
106
|
+
if not has_feature("clip_follow_action_v2"):
|
|
107
|
+
raise RuntimeError("Clip follow actions require Live 12.0+.")
|
|
108
|
+
clip = get_clip(song, int(params["track_index"]), int(params["clip_index"]))
|
|
109
|
+
|
|
110
|
+
if "action_a" in params:
|
|
111
|
+
clip.follow_action_a = _action_idx(params["action_a"])
|
|
112
|
+
if "action_b" in params:
|
|
113
|
+
clip.follow_action_b = _action_idx(params["action_b"])
|
|
114
|
+
if "chance_a" in params:
|
|
115
|
+
c = float(params["chance_a"])
|
|
116
|
+
if not 0.0 <= c <= 1.0:
|
|
117
|
+
raise ValueError("chance_a must be 0.0-1.0")
|
|
118
|
+
clip.follow_action_chance_a = c
|
|
119
|
+
if "chance_b" in params:
|
|
120
|
+
c = float(params["chance_b"])
|
|
121
|
+
if not 0.0 <= c <= 1.0:
|
|
122
|
+
raise ValueError("chance_b must be 0.0-1.0")
|
|
123
|
+
clip.follow_action_chance_b = c
|
|
124
|
+
if "time" in params:
|
|
125
|
+
t = float(params["time"])
|
|
126
|
+
if t < 0.0:
|
|
127
|
+
raise ValueError("time must be >= 0.0 beats")
|
|
128
|
+
clip.follow_action_time = t
|
|
129
|
+
if "enabled" in params:
|
|
130
|
+
# Some Live versions expose this as ``follow_action_enabled``; fall
|
|
131
|
+
# back silently if the attribute isn't present so the rest of the
|
|
132
|
+
# set still applies on e.g. an older 12.0 point release.
|
|
133
|
+
if hasattr(clip, "follow_action_enabled"):
|
|
134
|
+
clip.follow_action_enabled = bool(params["enabled"])
|
|
135
|
+
|
|
136
|
+
return _read_clip_follow_action(clip)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@register("clear_clip_follow_action")
|
|
140
|
+
def clear_clip_follow_action(song, params):
|
|
141
|
+
"""Disable a clip's follow actions (Live 12.0+)."""
|
|
142
|
+
from .version_detect import has_feature
|
|
143
|
+
if not has_feature("clip_follow_action_v2"):
|
|
144
|
+
raise RuntimeError("Clip follow actions require Live 12.0+.")
|
|
145
|
+
clip = get_clip(song, int(params["track_index"]), int(params["clip_index"]))
|
|
146
|
+
if hasattr(clip, "follow_action_enabled"):
|
|
147
|
+
clip.follow_action_enabled = False
|
|
148
|
+
return {"enabled": False}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@register("apply_follow_action_preset")
|
|
152
|
+
def apply_follow_action_preset(song, params):
|
|
153
|
+
"""Apply a named follow-action preset to a clip (Live 12.0+)."""
|
|
154
|
+
from .version_detect import has_feature
|
|
155
|
+
if not has_feature("clip_follow_action_v2"):
|
|
156
|
+
raise RuntimeError("Clip follow actions require Live 12.0+.")
|
|
157
|
+
preset_name = str(params["preset"]).lower()
|
|
158
|
+
if preset_name not in _FOLLOW_ACTION_PRESETS:
|
|
159
|
+
raise ValueError(
|
|
160
|
+
"Unknown preset '%s'. Valid: %s"
|
|
161
|
+
% (params["preset"], ", ".join(_FOLLOW_ACTION_PRESETS))
|
|
162
|
+
)
|
|
163
|
+
preset = _FOLLOW_ACTION_PRESETS[preset_name]
|
|
164
|
+
# Delegate to set_clip_follow_action with the preset values merged in.
|
|
165
|
+
# User-supplied params take no precedence — presets are all-or-nothing.
|
|
166
|
+
apply_params = dict(params)
|
|
167
|
+
apply_params.update(preset)
|
|
168
|
+
apply_params["enabled"] = True
|
|
169
|
+
return set_clip_follow_action(song, apply_params)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ── Scene follow actions (Live 12.2+) ────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _read_scene_follow_action(scene):
|
|
176
|
+
"""Snapshot all scene follow-action fields as a plain dict."""
|
|
177
|
+
return {
|
|
178
|
+
"enabled": bool(scene.follow_action_enabled),
|
|
179
|
+
"time": float(scene.follow_action_time),
|
|
180
|
+
"linked": bool(getattr(scene, "follow_action_linked", False)),
|
|
181
|
+
"multiplier": int(getattr(scene, "follow_action_multiplier", 1)),
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@register("get_scene_follow_action")
|
|
186
|
+
def get_scene_follow_action(song, params):
|
|
187
|
+
"""Read a scene's follow-action state (Live 12.2+)."""
|
|
188
|
+
from .version_detect import has_feature
|
|
189
|
+
if not has_feature("scene_follow_actions"):
|
|
190
|
+
raise RuntimeError("Scene follow actions require Live 12.2+.")
|
|
191
|
+
scene = get_scene(song, int(params["scene_index"]))
|
|
192
|
+
return _read_scene_follow_action(scene)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@register("set_scene_follow_action")
|
|
196
|
+
def set_scene_follow_action(song, params):
|
|
197
|
+
"""Set a scene's follow-action state (Live 12.2+).
|
|
198
|
+
|
|
199
|
+
All args except scene_index are optional; omitted ones preserve
|
|
200
|
+
the current value. ``linked`` controls "Longest" mode — when True
|
|
201
|
+
the scene waits for the longest clip's loop length; when False it
|
|
202
|
+
uses ``time * multiplier`` as the trigger point.
|
|
203
|
+
"""
|
|
204
|
+
from .version_detect import has_feature
|
|
205
|
+
if not has_feature("scene_follow_actions"):
|
|
206
|
+
raise RuntimeError("Scene follow actions require Live 12.2+.")
|
|
207
|
+
scene = get_scene(song, int(params["scene_index"]))
|
|
208
|
+
|
|
209
|
+
if "enabled" in params:
|
|
210
|
+
scene.follow_action_enabled = bool(params["enabled"])
|
|
211
|
+
if "time" in params:
|
|
212
|
+
t = float(params["time"])
|
|
213
|
+
if t < 0.0:
|
|
214
|
+
raise ValueError("time must be >= 0.0 beats")
|
|
215
|
+
scene.follow_action_time = t
|
|
216
|
+
if "linked" in params and hasattr(scene, "follow_action_linked"):
|
|
217
|
+
scene.follow_action_linked = bool(params["linked"])
|
|
218
|
+
if "multiplier" in params and hasattr(scene, "follow_action_multiplier"):
|
|
219
|
+
m = int(params["multiplier"])
|
|
220
|
+
if not 1 <= m <= 8:
|
|
221
|
+
raise ValueError("multiplier must be 1-8")
|
|
222
|
+
scene.follow_action_multiplier = m
|
|
223
|
+
|
|
224
|
+
return _read_scene_follow_action(scene)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@register("clear_scene_follow_action")
|
|
228
|
+
def clear_scene_follow_action(song, params):
|
|
229
|
+
"""Disable a scene's follow action (Live 12.2+)."""
|
|
230
|
+
from .version_detect import has_feature
|
|
231
|
+
if not has_feature("scene_follow_actions"):
|
|
232
|
+
raise RuntimeError("Scene follow actions require Live 12.2+.")
|
|
233
|
+
scene = get_scene(song, int(params["scene_index"]))
|
|
234
|
+
scene.follow_action_enabled = False
|
|
235
|
+
return {"enabled": False}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LivePilot — Groove Pool handlers (Live 11+).
|
|
3
|
+
|
|
4
|
+
Exposes ``song.groove_pool`` enumeration, per-groove parameter
|
|
5
|
+
tuning, per-clip groove assignment, and the master
|
|
6
|
+
``song.groove_amount`` dial.
|
|
7
|
+
|
|
8
|
+
Groove ids are zero-based indices into ``song.groove_pool.grooves``;
|
|
9
|
+
the index is stable for the lifetime of the pool but may shift if
|
|
10
|
+
the user adds/removes grooves in the UI. Callers should re-list
|
|
11
|
+
before issuing long-running sequences that depend on a specific id.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from .router import register
|
|
15
|
+
from .utils import get_clip
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _groove_info(groove, groove_id):
|
|
19
|
+
"""Serialize a Groove object to a plain dict."""
|
|
20
|
+
return {
|
|
21
|
+
"id": int(groove_id),
|
|
22
|
+
"name": str(groove.name),
|
|
23
|
+
"base": int(getattr(groove, "base", 0)),
|
|
24
|
+
"quantization_amount": float(groove.quantization_amount),
|
|
25
|
+
"random_amount": float(groove.random_amount),
|
|
26
|
+
"timing_amount": float(groove.timing_amount),
|
|
27
|
+
"velocity_amount": float(groove.velocity_amount),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _get_groove(song, groove_id):
|
|
32
|
+
"""Resolve a groove_id to a Groove object, raising IndexError on miss."""
|
|
33
|
+
grooves = list(song.groove_pool.grooves)
|
|
34
|
+
idx = int(groove_id)
|
|
35
|
+
if not 0 <= idx < len(grooves):
|
|
36
|
+
raise IndexError(
|
|
37
|
+
"groove_id %d out of range (0..%d). Groove pool has %d grooves."
|
|
38
|
+
% (idx, len(grooves) - 1 if grooves else -1, len(grooves))
|
|
39
|
+
)
|
|
40
|
+
return grooves[idx]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@register("list_grooves")
|
|
44
|
+
def list_grooves(song, params):
|
|
45
|
+
"""List all grooves in the Groove Pool (Live 11+)."""
|
|
46
|
+
from .version_detect import has_feature
|
|
47
|
+
if not has_feature("groove_pool_api"):
|
|
48
|
+
raise RuntimeError("Groove pool API requires Live 11+.")
|
|
49
|
+
grooves = []
|
|
50
|
+
for i, g in enumerate(song.groove_pool.grooves):
|
|
51
|
+
grooves.append(_groove_info(g, i))
|
|
52
|
+
return {"grooves": grooves}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@register("get_groove_info")
|
|
56
|
+
def get_groove_info(song, params):
|
|
57
|
+
"""Read a single groove's parameters (Live 11+)."""
|
|
58
|
+
from .version_detect import has_feature
|
|
59
|
+
if not has_feature("groove_pool_api"):
|
|
60
|
+
raise RuntimeError("Groove pool API requires Live 11+.")
|
|
61
|
+
groove = _get_groove(song, params["groove_id"])
|
|
62
|
+
return _groove_info(groove, params["groove_id"])
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@register("set_groove_params")
|
|
66
|
+
def set_groove_params(song, params):
|
|
67
|
+
"""Set one or more groove parameters (Live 11+).
|
|
68
|
+
|
|
69
|
+
Any of quantization_amount, random_amount, timing_amount,
|
|
70
|
+
velocity_amount may be omitted — omitted fields leave the current
|
|
71
|
+
value untouched. quantization/random/timing are 0.0-1.0; velocity
|
|
72
|
+
is signed -1.0 to 1.0 (negative = subtract velocity, positive = add).
|
|
73
|
+
"""
|
|
74
|
+
from .version_detect import has_feature
|
|
75
|
+
if not has_feature("groove_pool_api"):
|
|
76
|
+
raise RuntimeError("Groove pool API requires Live 11+.")
|
|
77
|
+
groove = _get_groove(song, params["groove_id"])
|
|
78
|
+
|
|
79
|
+
if "quantization_amount" in params:
|
|
80
|
+
v = float(params["quantization_amount"])
|
|
81
|
+
if not 0.0 <= v <= 1.0:
|
|
82
|
+
raise ValueError("quantization_amount must be 0.0-1.0")
|
|
83
|
+
groove.quantization_amount = v
|
|
84
|
+
if "random_amount" in params:
|
|
85
|
+
v = float(params["random_amount"])
|
|
86
|
+
if not 0.0 <= v <= 1.0:
|
|
87
|
+
raise ValueError("random_amount must be 0.0-1.0")
|
|
88
|
+
groove.random_amount = v
|
|
89
|
+
if "timing_amount" in params:
|
|
90
|
+
v = float(params["timing_amount"])
|
|
91
|
+
if not 0.0 <= v <= 1.0:
|
|
92
|
+
raise ValueError("timing_amount must be 0.0-1.0")
|
|
93
|
+
groove.timing_amount = v
|
|
94
|
+
if "velocity_amount" in params:
|
|
95
|
+
v = float(params["velocity_amount"])
|
|
96
|
+
if not -1.0 <= v <= 1.0:
|
|
97
|
+
raise ValueError("velocity_amount must be -1.0 to 1.0")
|
|
98
|
+
groove.velocity_amount = v
|
|
99
|
+
|
|
100
|
+
return _groove_info(groove, params["groove_id"])
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@register("assign_clip_groove")
|
|
104
|
+
def assign_clip_groove(song, params):
|
|
105
|
+
"""Assign (or clear) a groove on a clip (Live 11+).
|
|
106
|
+
|
|
107
|
+
Pass ``groove_id = -1`` (or null/None) to clear the clip's groove.
|
|
108
|
+
"""
|
|
109
|
+
from .version_detect import has_feature
|
|
110
|
+
if not has_feature("groove_pool_api"):
|
|
111
|
+
raise RuntimeError("Groove pool API requires Live 11+.")
|
|
112
|
+
clip = get_clip(song, int(params["track_index"]), int(params["clip_index"]))
|
|
113
|
+
|
|
114
|
+
groove_id = params.get("groove_id")
|
|
115
|
+
if groove_id is None or int(groove_id) < 0:
|
|
116
|
+
clip.groove = None
|
|
117
|
+
return {
|
|
118
|
+
"track_index": int(params["track_index"]),
|
|
119
|
+
"clip_index": int(params["clip_index"]),
|
|
120
|
+
"groove_id": None,
|
|
121
|
+
"groove_name": None,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
groove = _get_groove(song, groove_id)
|
|
125
|
+
clip.groove = groove
|
|
126
|
+
return {
|
|
127
|
+
"track_index": int(params["track_index"]),
|
|
128
|
+
"clip_index": int(params["clip_index"]),
|
|
129
|
+
"groove_id": int(groove_id),
|
|
130
|
+
"groove_name": str(groove.name),
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@register("get_clip_groove")
|
|
135
|
+
def get_clip_groove(song, params):
|
|
136
|
+
"""Read a clip's current groove assignment (Live 11+).
|
|
137
|
+
|
|
138
|
+
Returns ``{groove_id: int, groove_name: str}`` when set, or
|
|
139
|
+
``{groove_id: None, groove_name: None}`` when unset.
|
|
140
|
+
"""
|
|
141
|
+
from .version_detect import has_feature
|
|
142
|
+
if not has_feature("groove_pool_api"):
|
|
143
|
+
raise RuntimeError("Groove pool API requires Live 11+.")
|
|
144
|
+
clip = get_clip(song, int(params["track_index"]), int(params["clip_index"]))
|
|
145
|
+
|
|
146
|
+
clip_groove = getattr(clip, "groove", None)
|
|
147
|
+
if clip_groove is None:
|
|
148
|
+
return {"groove_id": None, "groove_name": None}
|
|
149
|
+
|
|
150
|
+
# Resolve the groove object's id by matching against the pool. Live's
|
|
151
|
+
# Python API compares Groove objects by identity, so == is fine here;
|
|
152
|
+
# we fall back to a None id (but keep the name) if the clip's groove
|
|
153
|
+
# somehow isn't in the current pool — shouldn't happen in practice but
|
|
154
|
+
# avoids crashing on an orphan reference.
|
|
155
|
+
for i, g in enumerate(song.groove_pool.grooves):
|
|
156
|
+
if g == clip_groove:
|
|
157
|
+
return {"groove_id": i, "groove_name": str(g.name)}
|
|
158
|
+
return {"groove_id": None, "groove_name": str(clip_groove.name)}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@register("get_song_groove_amount")
|
|
162
|
+
def get_song_groove_amount(song, params):
|
|
163
|
+
"""Read the master groove amount dial (Live 11+)."""
|
|
164
|
+
from .version_detect import has_feature
|
|
165
|
+
if not has_feature("groove_pool_api"):
|
|
166
|
+
raise RuntimeError("Groove pool API requires Live 11+.")
|
|
167
|
+
return {"groove_amount": float(song.groove_amount)}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@register("set_song_groove_amount")
|
|
171
|
+
def set_song_groove_amount(song, params):
|
|
172
|
+
"""Set the master groove amount dial (Live 11+).
|
|
173
|
+
|
|
174
|
+
Range: 0.0-1.31. Live's spec nominally goes to 1.0 but the
|
|
175
|
+
exposed property clamps at roughly 1.31 internally; we accept
|
|
176
|
+
the full exposed range so scripts can match UI nudges exactly.
|
|
177
|
+
"""
|
|
178
|
+
from .version_detect import has_feature
|
|
179
|
+
if not has_feature("groove_pool_api"):
|
|
180
|
+
raise RuntimeError("Groove pool API requires Live 11+.")
|
|
181
|
+
amount = float(params["amount"])
|
|
182
|
+
if not 0.0 <= amount <= 1.31:
|
|
183
|
+
raise ValueError("amount must be 0.0-1.31")
|
|
184
|
+
song.groove_amount = amount
|
|
185
|
+
return {"groove_amount": float(song.groove_amount)}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LivePilot — Song-level scale handlers (Live 12.0+).
|
|
3
|
+
|
|
4
|
+
Exposes Song.root_note / scale_mode / scale_name / scale_intervals
|
|
5
|
+
and the Song.scale_names list via the LivePilot TCP protocol.
|
|
6
|
+
|
|
7
|
+
All four props shipped in Live 12.0 when Scale Mode was introduced.
|
|
8
|
+
Gated behind the `song_scale_api` feature flag for defensive safety
|
|
9
|
+
on older versions, even though we target 12.3.6.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .router import register
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@register("get_song_scale")
|
|
16
|
+
def get_song_scale(song, params):
|
|
17
|
+
"""Read Live's current Scale Mode state."""
|
|
18
|
+
from .version_detect import has_feature
|
|
19
|
+
if not has_feature("song_scale_api"):
|
|
20
|
+
raise RuntimeError("Song scale API requires Live 12.0+.")
|
|
21
|
+
return {
|
|
22
|
+
"root_note": int(song.root_note),
|
|
23
|
+
"scale_mode": bool(song.scale_mode),
|
|
24
|
+
"scale_name": str(song.scale_name),
|
|
25
|
+
"scale_intervals": list(song.scale_intervals),
|
|
26
|
+
"available_scales": list(song.scale_names),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@register("set_song_scale")
|
|
31
|
+
def set_song_scale(song, params):
|
|
32
|
+
"""Set both root_note (0-11) and scale_name atomically."""
|
|
33
|
+
from .version_detect import has_feature
|
|
34
|
+
if not has_feature("song_scale_api"):
|
|
35
|
+
raise RuntimeError("Song scale API requires Live 12.0+.")
|
|
36
|
+
root = int(params["root_note"])
|
|
37
|
+
if not 0 <= root <= 11:
|
|
38
|
+
raise ValueError("root_note must be 0-11 (C=0, C#=1, ... B=11)")
|
|
39
|
+
name = str(params["scale_name"])
|
|
40
|
+
available = list(song.scale_names)
|
|
41
|
+
if name not in available:
|
|
42
|
+
raise ValueError(
|
|
43
|
+
"Unknown scale '%s'. Available: %s" % (name, ", ".join(available))
|
|
44
|
+
)
|
|
45
|
+
song.root_note = root
|
|
46
|
+
song.scale_name = name
|
|
47
|
+
return {
|
|
48
|
+
"root_note": int(song.root_note),
|
|
49
|
+
"scale_name": str(song.scale_name),
|
|
50
|
+
"scale_intervals": list(song.scale_intervals),
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@register("set_song_scale_mode")
|
|
55
|
+
def set_song_scale_mode(song, params):
|
|
56
|
+
"""Enable/disable Scale Mode on the set."""
|
|
57
|
+
from .version_detect import has_feature
|
|
58
|
+
if not has_feature("song_scale_api"):
|
|
59
|
+
raise RuntimeError("Song scale API requires Live 12.0+.")
|
|
60
|
+
song.scale_mode = bool(params["enabled"])
|
|
61
|
+
return {"scale_mode": bool(song.scale_mode)}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@register("list_available_scales")
|
|
65
|
+
def list_available_scales(song, params):
|
|
66
|
+
"""Return Live's built-in list of scale names."""
|
|
67
|
+
from .version_detect import has_feature
|
|
68
|
+
if not has_feature("song_scale_api"):
|
|
69
|
+
raise RuntimeError("Song scale API requires Live 12.0+.")
|
|
70
|
+
return {"scales": list(song.scale_names)}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@register("get_tuning_system")
|
|
74
|
+
def get_tuning_system(song, params):
|
|
75
|
+
"""Read the current Tuning System state (Live 12.1+).
|
|
76
|
+
|
|
77
|
+
Returns name, pseudo-octave size, range, reference pitch,
|
|
78
|
+
and the full per-degree cent offset table.
|
|
79
|
+
"""
|
|
80
|
+
from .version_detect import has_feature
|
|
81
|
+
if not has_feature("tuning_system"):
|
|
82
|
+
raise RuntimeError("Tuning System requires Live 12.1+.")
|
|
83
|
+
ts = song.tuning_system
|
|
84
|
+
return {
|
|
85
|
+
"name": str(ts.name),
|
|
86
|
+
"pseudo_octave_in_cents": float(ts.pseudo_octave_in_cents),
|
|
87
|
+
"lowest_note": int(ts.lowest_note),
|
|
88
|
+
"highest_note": int(ts.highest_note),
|
|
89
|
+
"reference_pitch": float(ts.reference_pitch),
|
|
90
|
+
"note_tunings": list(ts.note_tunings),
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@register("set_tuning_reference_pitch")
|
|
95
|
+
def set_tuning_reference_pitch(song, params):
|
|
96
|
+
"""Set the tuning reference pitch in Hz (Live 12.1+)."""
|
|
97
|
+
from .version_detect import has_feature
|
|
98
|
+
if not has_feature("tuning_system"):
|
|
99
|
+
raise RuntimeError("Tuning System requires Live 12.1+.")
|
|
100
|
+
pitch = float(params["reference_pitch"])
|
|
101
|
+
if pitch <= 0:
|
|
102
|
+
raise ValueError("reference_pitch must be > 0 Hz")
|
|
103
|
+
song.tuning_system.reference_pitch = pitch
|
|
104
|
+
return {"reference_pitch": float(song.tuning_system.reference_pitch)}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@register("set_tuning_note")
|
|
108
|
+
def set_tuning_note(song, params):
|
|
109
|
+
"""Set the cent offset for a single scale degree (Live 12.1+).
|
|
110
|
+
|
|
111
|
+
degree: 0-based index into note_tunings
|
|
112
|
+
cent_offset: offset in cents from 12-TET (float, any sign)
|
|
113
|
+
"""
|
|
114
|
+
from .version_detect import has_feature
|
|
115
|
+
if not has_feature("tuning_system"):
|
|
116
|
+
raise RuntimeError("Tuning System requires Live 12.1+.")
|
|
117
|
+
ts = song.tuning_system
|
|
118
|
+
degree = int(params["degree"])
|
|
119
|
+
cents = float(params["cent_offset"])
|
|
120
|
+
tunings = list(ts.note_tunings)
|
|
121
|
+
if not 0 <= degree < len(tunings):
|
|
122
|
+
raise IndexError(
|
|
123
|
+
"degree %d out of range (0..%d)" % (degree, len(tunings) - 1)
|
|
124
|
+
)
|
|
125
|
+
tunings[degree] = cents
|
|
126
|
+
ts.note_tunings = tunings
|
|
127
|
+
return {"degree": degree, "cent_offset": cents}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@register("reset_tuning_system")
|
|
131
|
+
def reset_tuning_system(song, params):
|
|
132
|
+
"""Reset all per-degree tuning offsets to 12-TET (Live 12.1+)."""
|
|
133
|
+
from .version_detect import has_feature
|
|
134
|
+
if not has_feature("tuning_system"):
|
|
135
|
+
raise RuntimeError("Tuning System requires Live 12.1+.")
|
|
136
|
+
ts = song.tuning_system
|
|
137
|
+
ts.note_tunings = [0.0] * len(ts.note_tunings)
|
|
138
|
+
return {"note_tunings": list(ts.note_tunings)}
|