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,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)}