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,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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "1.12.2",
|
|
4
4
|
"mcpName": "io.github.dreamrec/livepilot",
|
|
5
|
-
"description": "Agentic production system for Ableton Live 12 —
|
|
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.
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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)}
|