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.
Files changed (35) hide show
  1. package/CHANGELOG.md +245 -0
  2. package/README.md +7 -7
  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/m4l_bridge.py +488 -13
  7. package/mcp_server/runtime/execution_router.py +7 -0
  8. package/mcp_server/runtime/mcp_dispatch.py +32 -0
  9. package/mcp_server/runtime/remote_commands.py +54 -0
  10. package/mcp_server/sample_engine/slice_classifier.py +169 -0
  11. package/mcp_server/server.py +11 -3
  12. package/mcp_server/tools/analyzer.py +187 -7
  13. package/mcp_server/tools/clips.py +65 -0
  14. package/mcp_server/tools/devices.py +517 -5
  15. package/mcp_server/tools/diagnostics.py +42 -0
  16. package/mcp_server/tools/follow_actions.py +202 -0
  17. package/mcp_server/tools/grooves.py +142 -0
  18. package/mcp_server/tools/miditool.py +280 -0
  19. package/mcp_server/tools/scales.py +126 -0
  20. package/mcp_server/tools/take_lanes.py +135 -0
  21. package/mcp_server/tools/tracks.py +46 -3
  22. package/mcp_server/tools/transport.py +62 -1
  23. package/package.json +2 -2
  24. package/remote_script/LivePilot/__init__.py +8 -4
  25. package/remote_script/LivePilot/clips.py +62 -0
  26. package/remote_script/LivePilot/devices.py +444 -0
  27. package/remote_script/LivePilot/diagnostics.py +52 -1
  28. package/remote_script/LivePilot/follow_actions.py +235 -0
  29. package/remote_script/LivePilot/grooves.py +185 -0
  30. package/remote_script/LivePilot/scales.py +138 -0
  31. package/remote_script/LivePilot/take_lanes.py +175 -0
  32. package/remote_script/LivePilot/tracks.py +59 -1
  33. package/remote_script/LivePilot/transport.py +90 -1
  34. package/remote_script/LivePilot/version_detect.py +9 -0
  35. 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
@@ -188,3 +188,64 @@ async def get_session_diagnostics(ctx: Context, check_clip_keys: bool = False) -
188
188
  result["clip_key_mismatch_count"] = len(audio_mismatches)
189
189
 
190
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.9",
3
+ "version": "1.12.2",
4
4
  "mcpName": "io.github.dreamrec/livepilot",
5
- "description": "Agentic production system for Ableton Live 12 — 325 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,7 +5,7 @@ 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.9"
8
+ __version__ = "1.12.2"
9
9
 
10
10
  from _Framework.ControlSurface import ControlSurface
11
11
  from . import router
@@ -17,10 +17,14 @@ from . import clips # noqa: F401 — registers clip handlers
17
17
  from . import notes # noqa: F401 — registers note handlers
18
18
  from . import devices # noqa: F401 — registers device handlers
19
19
  from . import scenes # noqa: F401 — registers scene handlers
20
+ from . import scales # noqa: F401 — registers song scale handlers (12.0+)
20
21
  from . import mixing # noqa: F401 — registers mixing handlers
21
22
  from . import browser # noqa: F401 — registers browser handlers
22
23
  from . import arrangement # noqa: F401 — registers arrangement handlers
23
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)
24
28
  from . import clip_automation # noqa: F401 — registers clip automation handlers
25
29
  from . import version_detect # noqa: F401 — version detection
26
30
 
@@ -47,9 +51,9 @@ _FIRST_CREATE_INSTANCE = True
47
51
 
48
52
  _HANDLER_MODULES = (
49
53
  utils,
50
- transport, tracks, clips, notes, devices, scenes,
51
- mixing, browser, arrangement, diagnostics,
52
- clip_automation, version_detect,
54
+ transport, tracks, clips, notes, devices, scenes, scales,
55
+ mixing, browser, arrangement, diagnostics, follow_actions,
56
+ grooves, take_lanes, clip_automation, version_detect,
53
57
  )
54
58
 
55
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)}