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,126 @@
1
+ """Song-level scale tools (Live 12.0+ Scale Mode).
2
+
3
+ 4 tools matching the Remote Script scales domain.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from fastmcp import Context
9
+
10
+ from ..server import mcp
11
+
12
+
13
+ def _get_ableton(ctx: Context):
14
+ """Extract AbletonConnection from lifespan context."""
15
+ return ctx.lifespan_context["ableton"]
16
+
17
+
18
+ @mcp.tool()
19
+ def get_song_scale(ctx: Context) -> dict:
20
+ """Read Live's current Scale Mode state (Live 12.0+).
21
+
22
+ Returns:
23
+ root_note: 0-11 (C=0, C#=1, ... B=11)
24
+ scale_mode: bool — is Scale Mode currently enabled
25
+ scale_name: e.g. "Major", "Minor Pentatonic", "Dorian"
26
+ scale_intervals: tuple of semitone offsets from root_note
27
+ available_scales: all scale names Live knows about
28
+
29
+ Prefer this over our own `identify_scale` detector when you want
30
+ the user's actual Live selection rather than an audio-detected key.
31
+ """
32
+ return _get_ableton(ctx).send_command("get_song_scale", {})
33
+
34
+
35
+ @mcp.tool()
36
+ def set_song_scale(ctx: Context, root_note: int, scale_name: str) -> dict:
37
+ """Set the Song-level Scale Mode root + scale name (Live 12.0+).
38
+
39
+ root_note: 0-11 (C=0, C#=1, ... B=11)
40
+ scale_name: must match one of Live's built-in scale names.
41
+ Call list_available_scales() first if unsure.
42
+ """
43
+ if not 0 <= root_note <= 11:
44
+ raise ValueError("root_note must be 0-11")
45
+ if not scale_name.strip():
46
+ raise ValueError("scale_name cannot be empty")
47
+ return _get_ableton(ctx).send_command("set_song_scale", {
48
+ "root_note": root_note,
49
+ "scale_name": scale_name,
50
+ })
51
+
52
+
53
+ @mcp.tool()
54
+ def set_song_scale_mode(ctx: Context, enabled: bool) -> dict:
55
+ """Enable or disable Scale Mode on the current set (Live 12.0+).
56
+
57
+ When enabled, Live's MIDI input and some devices become scale-aware.
58
+ """
59
+ return _get_ableton(ctx).send_command("set_song_scale_mode", {
60
+ "enabled": enabled,
61
+ })
62
+
63
+
64
+ @mcp.tool()
65
+ def list_available_scales(ctx: Context) -> dict:
66
+ """Return Live's built-in scale names (Live 12.0+).
67
+
68
+ Use before set_song_scale() to validate names or offer the user
69
+ a list. Returns e.g. ["Major", "Minor", "Dorian", "Mixolydian", ...].
70
+ """
71
+ return _get_ableton(ctx).send_command("list_available_scales", {})
72
+
73
+
74
+ @mcp.tool()
75
+ def get_tuning_system(ctx: Context) -> dict:
76
+ """Read the current Tuning System state (Live 12.1+).
77
+
78
+ Exposes Ableton's microtonal tuning: name, pseudo-octave size
79
+ (in cents), note range, reference pitch (Hz), and per-degree
80
+ cent offsets from 12-TET.
81
+
82
+ Use for maqam, just intonation, or any non-12-TET workflow.
83
+ """
84
+ return _get_ableton(ctx).send_command("get_tuning_system", {})
85
+
86
+
87
+ @mcp.tool()
88
+ def set_tuning_reference_pitch(ctx: Context, reference_pitch: float) -> dict:
89
+ """Set the Tuning System's reference pitch in Hz (Live 12.1+).
90
+
91
+ Default is 440.0. Common alternatives: 432.0 (A432), 415.3 (Baroque).
92
+ """
93
+ if reference_pitch <= 0:
94
+ raise ValueError("reference_pitch must be > 0 Hz")
95
+ return _get_ableton(ctx).send_command("set_tuning_reference_pitch", {
96
+ "reference_pitch": reference_pitch,
97
+ })
98
+
99
+
100
+ @mcp.tool()
101
+ def set_tuning_note(ctx: Context, degree: int, cent_offset: float) -> dict:
102
+ """Adjust the cent offset for a single scale degree (Live 12.1+).
103
+
104
+ degree: 0-based scale-degree index (length depends on the
105
+ loaded tuning system — call get_tuning_system() first
106
+ to see the note_tunings array length).
107
+ cent_offset: cents from 12-TET. Examples:
108
+ -13.686 -> pure minor third
109
+ +1.955 -> pure major third (third harmonic)
110
+ """
111
+ if degree < 0:
112
+ raise ValueError("degree must be >= 0")
113
+ return _get_ableton(ctx).send_command("set_tuning_note", {
114
+ "degree": degree,
115
+ "cent_offset": cent_offset,
116
+ })
117
+
118
+
119
+ @mcp.tool()
120
+ def reset_tuning_system(ctx: Context) -> dict:
121
+ """Reset all per-degree tuning offsets to 12-TET (Live 12.1+).
122
+
123
+ Clears all per-note microtonal offsets. Doesn't change the
124
+ tuning system's name or reference pitch — just the offsets.
125
+ """
126
+ return _get_ableton(ctx).send_command("reset_tuning_system", {})
@@ -0,0 +1,135 @@
1
+ """Take Lanes tools (Live 12.0 UI / 12.2 API).
2
+
3
+ 6 tools matching the Remote Script take_lanes domain:
4
+ - get_take_lanes / get_take_lane_clips — read-only introspection
5
+ (works on any Live 12.x).
6
+ - create_take_lane / set_take_lane_name — mutation ops (12.2+).
7
+ - create_audio_clip_on_take_lane / create_midi_clip_on_take_lane —
8
+ programmatic lane-scoped clip creation (12.2+).
9
+
10
+ Take lanes are alternative clip rows stacked under an arrangement
11
+ track — they let you audition or comp multiple passes of the same
12
+ part without occupying extra tracks. Live 12.0 shipped the UI;
13
+ scripting access to create/rename lanes and attach clips to them
14
+ landed in Live 12.2.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from fastmcp import Context
20
+
21
+ from ..server import mcp
22
+
23
+
24
+ def _get_ableton(ctx: Context):
25
+ """Extract AbletonConnection from lifespan context."""
26
+ return ctx.lifespan_context["ableton"]
27
+
28
+
29
+ @mcp.tool()
30
+ def get_take_lanes(ctx: Context, track_index: int) -> dict:
31
+ """List all take lanes on a track (Live 12.0+).
32
+
33
+ Returns {lanes: [{index, name, is_frozen, clip_count}]}. Works
34
+ on any Live 12.x — pure introspection, no version gate. Returns
35
+ an empty list on tracks that don't expose take_lanes.
36
+ """
37
+ return _get_ableton(ctx).send_command("get_take_lanes", {
38
+ "track_index": track_index,
39
+ })
40
+
41
+
42
+ @mcp.tool()
43
+ def create_take_lane(ctx: Context, track_index: int) -> dict:
44
+ """Create a new take lane on a track (Live 12.2+).
45
+
46
+ Returns {lane_index, name}. Raises if the Live version predates
47
+ 12.2 or if the specific build doesn't expose Track.create_take_lane.
48
+ """
49
+ return _get_ableton(ctx).send_command("create_take_lane", {
50
+ "track_index": track_index,
51
+ })
52
+
53
+
54
+ @mcp.tool()
55
+ def set_take_lane_name(
56
+ ctx: Context,
57
+ track_index: int,
58
+ lane_index: int,
59
+ name: str,
60
+ ) -> dict:
61
+ """Rename an existing take lane (Live 12.2+).
62
+
63
+ Returns {name} — the name after the update (Live may normalize
64
+ whitespace or reject duplicates in some builds).
65
+ """
66
+ return _get_ableton(ctx).send_command("set_take_lane_name", {
67
+ "track_index": track_index,
68
+ "lane_index": lane_index,
69
+ "name": name,
70
+ })
71
+
72
+
73
+ @mcp.tool()
74
+ def create_audio_clip_on_take_lane(
75
+ ctx: Context,
76
+ track_index: int,
77
+ lane_index: int,
78
+ start_time: float,
79
+ length: float,
80
+ ) -> dict:
81
+ """Create an arrangement audio clip on a specific take lane (Live 12.2+).
82
+
83
+ start_time / length are in beats. length must be > 0. The track
84
+ must be an audio track; Live raises on MIDI tracks. Returns
85
+ {ok, track_index, lane_index, start_time, length}.
86
+ """
87
+ if length <= 0:
88
+ raise ValueError("length must be > 0")
89
+ return _get_ableton(ctx).send_command("create_audio_clip_on_take_lane", {
90
+ "track_index": track_index,
91
+ "lane_index": lane_index,
92
+ "start_time": start_time,
93
+ "length": length,
94
+ })
95
+
96
+
97
+ @mcp.tool()
98
+ def create_midi_clip_on_take_lane(
99
+ ctx: Context,
100
+ track_index: int,
101
+ lane_index: int,
102
+ start_time: float,
103
+ length: float,
104
+ ) -> dict:
105
+ """Create an arrangement MIDI clip on a specific take lane (Live 12.2+).
106
+
107
+ start_time / length are in beats. length must be > 0. The track
108
+ must be a MIDI track; Live raises on audio tracks. Returns
109
+ {ok, track_index, lane_index, start_time, length}.
110
+ """
111
+ if length <= 0:
112
+ raise ValueError("length must be > 0")
113
+ return _get_ableton(ctx).send_command("create_midi_clip_on_take_lane", {
114
+ "track_index": track_index,
115
+ "lane_index": lane_index,
116
+ "start_time": start_time,
117
+ "length": length,
118
+ })
119
+
120
+
121
+ @mcp.tool()
122
+ def get_take_lane_clips(
123
+ ctx: Context,
124
+ track_index: int,
125
+ lane_index: int,
126
+ ) -> dict:
127
+ """List the arrangement clips on a specific take lane (Live 12.0+).
128
+
129
+ Returns {clips: [{name, start_time, length, is_midi_clip}]}. Pure
130
+ introspection — no version gate.
131
+ """
132
+ return _get_ableton(ctx).send_command("get_take_lane_clips", {
133
+ "track_index": track_index,
134
+ "lane_index": lane_index,
135
+ })
@@ -1,6 +1,6 @@
1
1
  """Track MCP tools — create, delete, rename, mute, solo, arm, group fold, monitor, freeze, flatten.
2
2
 
3
- 17 tools matching the Remote Script tracks domain.
3
+ 20 tools matching the Remote Script tracks domain.
4
4
  """
5
5
 
6
6
  from __future__ import annotations
@@ -92,9 +92,24 @@ def create_return_track(ctx: Context) -> dict:
92
92
 
93
93
  @mcp.tool()
94
94
  def delete_track(ctx: Context, track_index: int) -> dict:
95
- """Delete a track by index. Use undo to revert if needed."""
95
+ """Delete a track by index. Use undo to revert if needed.
96
+
97
+ Ableton requires at least one track in the session. Attempting to
98
+ delete the last remaining track raises ValueError with actionable
99
+ guidance rather than surfacing Ableton's misleading default
100
+ STATE_ERROR text (BUG-F3).
101
+ """
96
102
  _validate_track_index(track_index)
97
- return _get_ableton(ctx).send_command("delete_track", {"track_index": track_index})
103
+ ableton = _get_ableton(ctx)
104
+ session_info = ableton.send_command("get_session_info")
105
+ track_count = session_info.get("track_count") if session_info else None
106
+ if track_count is not None and track_count <= 1:
107
+ raise ValueError(
108
+ "Cannot delete track: Ableton requires at least one track "
109
+ "in the session. Add another track first, or rename the "
110
+ "current track if you want a clean slate."
111
+ )
112
+ return ableton.send_command("delete_track", {"track_index": track_index})
98
113
 
99
114
 
100
115
  @mcp.tool()
@@ -231,3 +246,31 @@ def get_freeze_status(ctx: Context, track_index: int) -> dict:
231
246
  return _get_ableton(ctx).send_command("get_freeze_status", {
232
247
  "track_index": track_index,
233
248
  })
249
+
250
+
251
+ # ── Track long-tail primitives ──────────────────────────────────────────
252
+
253
+
254
+ @mcp.tool()
255
+ def jump_in_session_clip(ctx: Context, track_index: int, beats: float) -> dict:
256
+ """Jump playhead within a running session clip, in beats from start."""
257
+ _validate_track_index(track_index, allow_return=False)
258
+ return _get_ableton(ctx).send_command("jump_in_session_clip", {
259
+ "track_index": track_index,
260
+ "beats": beats,
261
+ })
262
+
263
+
264
+ @mcp.tool()
265
+ def get_track_performance_impact(ctx: Context, track_index: int) -> dict:
266
+ """Read a track's CPU performance impact metric."""
267
+ _validate_track_index(track_index)
268
+ return _get_ableton(ctx).send_command("get_track_performance_impact", {
269
+ "track_index": track_index,
270
+ })
271
+
272
+
273
+ @mcp.tool()
274
+ def get_appointed_device(ctx: Context) -> dict:
275
+ """Return the Blue Hand (appointed/focused) device location as (track_index, device_index, track_name, device_name)."""
276
+ return _get_ableton(ctx).send_command("get_appointed_device", {})
@@ -1,6 +1,6 @@
1
1
  """Transport MCP tools — playback, tempo, metronome, loop, undo/redo, action log, diagnostics.
2
2
 
3
- 12 tools matching the Remote Script transport domain.
3
+ 21 tools matching the Remote Script transport domain.
4
4
  """
5
5
 
6
6
  from __future__ import annotations
@@ -130,6 +130,122 @@ def get_recent_actions(ctx: Context, limit: int = 20) -> dict:
130
130
 
131
131
 
132
132
  @mcp.tool()
133
- def get_session_diagnostics(ctx: Context) -> dict:
134
- """Analyze the session for potential issues: armed tracks, solo/mute leftovers, unnamed tracks, empty clips/scenes, MIDI tracks without instruments. Returns issues with severity (warning/info) and stats."""
135
- return _get_ableton(ctx).send_command("get_session_diagnostics")
133
+ async def get_session_diagnostics(ctx: Context, check_clip_keys: bool = False) -> dict:
134
+ """Analyze the session for potential issues: armed tracks, solo/mute leftovers, unnamed tracks, empty clips/scenes, MIDI tracks without instruments. Returns issues with severity (warning/info) and stats.
135
+
136
+ check_clip_keys: when True, also cross-checks every audio clip's
137
+ filename-encoded key against the detected session key (BUG-D1 scan).
138
+ Each mismatch appears as a diagnostic entry with the exact
139
+ set_clip_pitch call that would correct it. Requires the M4L bridge
140
+ (uses get_clip_file_path + get_detected_key); skipped gracefully if
141
+ the bridge is unavailable. Off by default because it round-trips
142
+ the bridge once per audio clip and can add noticeable latency on
143
+ large sessions.
144
+ """
145
+ result = _get_ableton(ctx).send_command("get_session_diagnostics")
146
+
147
+ if not check_clip_keys:
148
+ return result
149
+ if not isinstance(result, dict):
150
+ return result
151
+
152
+ # Augment with per-clip key-consistency checks. Each mismatch is added
153
+ # as a diagnostic with severity="warning"; "unknown" results are
154
+ # skipped so we don't drown the user in "no key detected yet" noise.
155
+ from .clips import check_clip_key_consistency # local import to avoid cycles
156
+
157
+ audio_mismatches: list[dict] = []
158
+ session_info = _get_ableton(ctx).send_command("get_session_info")
159
+ tracks = (session_info or {}).get("tracks", []) if isinstance(session_info, dict) else []
160
+ for track in tracks:
161
+ t_idx = track.get("index")
162
+ if t_idx is None:
163
+ continue
164
+ # We don't know which slots hold audio clips without probing, so
165
+ # iterate the first N scene slots conservatively. A session with
166
+ # many scenes would benefit from a scene-count cap; 32 is a
167
+ # reasonable upper bound for typical production sessions.
168
+ for clip_idx in range(min(32, len(session_info.get("scenes", []) or []) or 8)):
169
+ try:
170
+ check = await check_clip_key_consistency.fn(ctx, t_idx, clip_idx)
171
+ except Exception: # noqa: BLE001 — any failure means "skip this clip"
172
+ continue
173
+ if not isinstance(check, dict):
174
+ continue
175
+ if check.get("status") == "mismatch":
176
+ audio_mismatches.append({
177
+ "severity": "warning",
178
+ "category": "clip_key_mismatch",
179
+ "track_index": t_idx,
180
+ "clip_index": clip_idx,
181
+ "message": check.get("reason", ""),
182
+ "recommended_fix": check.get("recommended_fix"),
183
+ })
184
+
185
+ if audio_mismatches:
186
+ issues = result.setdefault("issues", [])
187
+ issues.extend(audio_mismatches)
188
+ result["clip_key_mismatch_count"] = len(audio_mismatches)
189
+
190
+ return result
191
+
192
+
193
+ # ── Song / Transport long-tail primitives ─────────────────────────────
194
+
195
+
196
+ @mcp.tool()
197
+ def tap_tempo(ctx: Context) -> dict:
198
+ """Tap the tempo (one tap). Live averages consecutive taps to set BPM."""
199
+ return _get_ableton(ctx).send_command("tap_tempo", {})
200
+
201
+
202
+ @mcp.tool()
203
+ def nudge_tempo(ctx: Context, direction: str) -> dict:
204
+ """Nudge tempo up or down by Live's internal nudge delta. direction: 'up' or 'down'."""
205
+ if direction not in ("up", "down"):
206
+ raise ValueError("direction must be 'up' or 'down'")
207
+ return _get_ableton(ctx).send_command("nudge_tempo", {"direction": direction})
208
+
209
+
210
+ @mcp.tool()
211
+ def set_exclusive_arm(ctx: Context, enabled: bool) -> dict:
212
+ """Enable/disable exclusive arm mode (only one track armed at a time)."""
213
+ return _get_ableton(ctx).send_command("set_exclusive_arm", {"enabled": enabled})
214
+
215
+
216
+ @mcp.tool()
217
+ def set_exclusive_solo(ctx: Context, enabled: bool) -> dict:
218
+ """Enable/disable exclusive solo mode (only one track soloed at a time)."""
219
+ return _get_ableton(ctx).send_command("set_exclusive_solo", {"enabled": enabled})
220
+
221
+
222
+ @mcp.tool()
223
+ def capture_and_insert_scene(ctx: Context) -> dict:
224
+ """Capture currently-playing clips and insert them as a new scene. Distinct from capture_midi."""
225
+ return _get_ableton(ctx).send_command("capture_and_insert_scene", {})
226
+
227
+
228
+ @mcp.tool()
229
+ def set_count_in_duration(ctx: Context, bars: int) -> dict:
230
+ """Set pre-record count-in duration (0-4 bars)."""
231
+ if not 0 <= bars <= 4:
232
+ raise ValueError("bars must be 0-4")
233
+ return _get_ableton(ctx).send_command("set_count_in_duration", {"bars": bars})
234
+
235
+
236
+ @mcp.tool()
237
+ def get_link_state(ctx: Context) -> dict:
238
+ """Read Ableton Link + count-in state (enabled, start/stop sync, tempo follower, is_counting_in)."""
239
+ return _get_ableton(ctx).send_command("get_link_state", {})
240
+
241
+
242
+ @mcp.tool()
243
+ def set_link_enabled(ctx: Context, enabled: bool) -> dict:
244
+ """Enable or disable Ableton Link (network tempo synchronization)."""
245
+ return _get_ableton(ctx).send_command("set_link_enabled", {"enabled": enabled})
246
+
247
+
248
+ @mcp.tool()
249
+ def force_link_beat_time(ctx: Context, beat_time: float) -> dict:
250
+ """Force Ableton Link to a specific beat time (if supported by this Live version)."""
251
+ return _get_ableton(ctx).send_command("force_link_beat_time", {"beat_time": beat_time})
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "livepilot",
3
- "version": "1.10.8",
3
+ "version": "1.12.2",
4
4
  "mcpName": "io.github.dreamrec/livepilot",
5
- "description": "Agentic production system for Ableton Live 12 — 324 tools, 45 domains. Device atlas (1305 devices), sample engine (Splice + browser + filesystem), auto-composition, spectral perception, technique memory, creative intelligence (12 engines)",
5
+ "description": "Agentic production system for Ableton Live 12 — 398 tools, 51 domains. Device atlas (1305 devices), sample engine (Splice + browser + filesystem), auto-composition, spectral perception, technique memory, creative intelligence (12 engines)",
6
6
  "author": "Pilot Studio",
7
7
  "license": "BSL-1.1",
8
8
  "type": "commonjs",
@@ -5,21 +5,26 @@ Entry point for the ControlSurface. Ableton calls create_instance(c_instance)
5
5
  when this script is selected in Preferences > Link, Tempo & MIDI.
6
6
  """
7
7
 
8
- __version__ = "1.10.8"
8
+ __version__ = "1.12.2"
9
9
 
10
10
  from _Framework.ControlSurface import ControlSurface
11
11
  from . import router
12
12
  from .server import LivePilotServer
13
+ from . import utils # noqa: F401 — shared helpers (get_track, get_device)
13
14
  from . import transport # noqa: F401 — registers transport handlers
14
15
  from . import tracks # noqa: F401 — registers track handlers
15
16
  from . import clips # noqa: F401 — registers clip handlers
16
17
  from . import notes # noqa: F401 — registers note handlers
17
18
  from . import devices # noqa: F401 — registers device handlers
18
19
  from . import scenes # noqa: F401 — registers scene handlers
20
+ from . import scales # noqa: F401 — registers song scale handlers (12.0+)
19
21
  from . import mixing # noqa: F401 — registers mixing handlers
20
22
  from . import browser # noqa: F401 — registers browser handlers
21
23
  from . import arrangement # noqa: F401 — registers arrangement handlers
22
24
  from . import diagnostics # noqa: F401 — registers diagnostics handler
25
+ from . import follow_actions # noqa: F401 — registers follow action handlers (12.0+, 12.2+)
26
+ from . import grooves # noqa: F401 — registers groove pool handlers (11+)
27
+ from . import take_lanes # noqa: F401 — registers take lane handlers (12.0+ read, 12.2+ write)
23
28
  from . import clip_automation # noqa: F401 — registers clip automation handlers
24
29
  from . import version_detect # noqa: F401 — version detection
25
30
 
@@ -36,13 +41,19 @@ from . import version_detect # noqa: F401 — version detection
36
41
  # @register decorators with the updated code). Result: a Control Surface
37
42
  # toggle now behaves like a fresh module reload, so live-editing mixing.py
38
43
  # / devices.py / etc. and re-toggling is enough — no Ableton restart.
44
+ #
45
+ # Order matters: utils comes first because every handler imports
46
+ # ``from .utils import get_track, get_device``. If utils isn't reloaded
47
+ # first, those re-imports during ``importlib.reload(devices)`` still
48
+ # resolve to the stale ``utils`` module object in ``sys.modules``.
39
49
 
40
50
  _FIRST_CREATE_INSTANCE = True
41
51
 
42
52
  _HANDLER_MODULES = (
43
- transport, tracks, clips, notes, devices, scenes,
44
- mixing, browser, arrangement, diagnostics,
45
- clip_automation, version_detect,
53
+ utils,
54
+ transport, tracks, clips, notes, devices, scenes, scales,
55
+ mixing, browser, arrangement, diagnostics, follow_actions,
56
+ grooves, take_lanes, clip_automation, version_detect,
46
57
  )
47
58
 
48
59
 
@@ -305,3 +305,65 @@ def set_clip_warp_mode(song, params):
305
305
  "warp_mode": clip.warp_mode,
306
306
  "warping": clip.warping,
307
307
  }
308
+
309
+
310
+ @register("get_clip_scale")
311
+ def get_clip_scale(song, params):
312
+ """Read a clip's per-clip scale override (Live 12.0+).
313
+
314
+ Per-clip scale is independent of Song.scale_* and lets each clip
315
+ carry its own key/mode.
316
+ """
317
+ from .version_detect import has_feature
318
+ if not has_feature("song_scale_api"):
319
+ raise RuntimeError("Per-clip scale requires Live 12.0+.")
320
+ clip_slot = get_clip_slot(song, int(params["track_index"]), int(params["clip_index"]))
321
+ if not clip_slot.has_clip:
322
+ raise ValueError("Clip slot is empty")
323
+ clip = clip_slot.clip
324
+ return {
325
+ "root_note": int(clip.root_note),
326
+ "scale_mode": bool(clip.scale_mode),
327
+ "scale_name": str(clip.scale_name),
328
+ }
329
+
330
+
331
+ @register("set_clip_scale")
332
+ def set_clip_scale(song, params):
333
+ """Set a clip's per-clip scale override (Live 12.0+)."""
334
+ from .version_detect import has_feature
335
+ if not has_feature("song_scale_api"):
336
+ raise RuntimeError("Per-clip scale requires Live 12.0+.")
337
+ clip_slot = get_clip_slot(song, int(params["track_index"]), int(params["clip_index"]))
338
+ if not clip_slot.has_clip:
339
+ raise ValueError("Clip slot is empty")
340
+ clip = clip_slot.clip
341
+ root = int(params["root_note"])
342
+ if not 0 <= root <= 11:
343
+ raise ValueError("root_note must be 0-11 (C=0, C#=1, ... B=11)")
344
+ scale_name = str(params["scale_name"])
345
+ # scale_name validation against Song.scale_names — clip uses the same list
346
+ available = list(song.scale_names)
347
+ if scale_name not in available:
348
+ raise ValueError(
349
+ "Unknown scale '%s'. Available: %s" % (scale_name, ", ".join(available))
350
+ )
351
+ clip.root_note = root
352
+ clip.scale_name = scale_name
353
+ return {
354
+ "root_note": int(clip.root_note),
355
+ "scale_name": str(clip.scale_name),
356
+ }
357
+
358
+
359
+ @register("set_clip_scale_mode")
360
+ def set_clip_scale_mode(song, params):
361
+ """Enable/disable Scale Mode on a single clip (Live 12.0+)."""
362
+ from .version_detect import has_feature
363
+ if not has_feature("song_scale_api"):
364
+ raise RuntimeError("Per-clip scale requires Live 12.0+.")
365
+ clip_slot = get_clip_slot(song, int(params["track_index"]), int(params["clip_index"]))
366
+ if not clip_slot.has_clip:
367
+ raise ValueError("Clip slot is empty")
368
+ clip_slot.clip.scale_mode = bool(params["enabled"])
369
+ return {"scale_mode": bool(clip_slot.clip.scale_mode)}