livepilot 1.8.3 → 1.9.0
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/.claude-plugin/marketplace.json +3 -3
- package/AGENTS.md +46 -0
- package/CHANGELOG.md +41 -0
- package/README.md +26 -19
- package/bin/livepilot.js +4 -2
- package/livepilot/.claude-plugin/plugin.json +2 -2
- package/livepilot/skills/livepilot-core/SKILL.md +16 -8
- package/livepilot/skills/livepilot-core/references/overview.md +3 -3
- package/livepilot/skills/livepilot-release/SKILL.md +37 -29
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/livepilot_bridge.js +170 -1
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/m4l_bridge.py +32 -24
- package/mcp_server/memory/technique_store.py +2 -2
- package/mcp_server/server.py +16 -1
- package/mcp_server/tools/_perception_engine.py +3 -2
- package/mcp_server/tools/analyzer.py +8 -2
- package/mcp_server/tools/arrangement.py +12 -1
- package/mcp_server/tools/automation.py +4 -2
- package/mcp_server/tools/devices.py +95 -2
- package/mcp_server/tools/harmony.py +2 -2
- package/mcp_server/tools/midi_io.py +57 -22
- package/mcp_server/tools/notes.py +4 -0
- package/mcp_server/tools/scenes.py +65 -2
- package/mcp_server/tools/tracks.py +45 -2
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +2 -2
- package/remote_script/LivePilot/arrangement.py +42 -0
- package/remote_script/LivePilot/clip_automation.py +18 -30
- package/remote_script/LivePilot/scenes.py +110 -1
- package/remote_script/LivePilot/server.py +15 -2
- package/remote_script/LivePilot/tracks.py +73 -2
|
@@ -14,6 +14,7 @@ from typing import Any, Optional
|
|
|
14
14
|
|
|
15
15
|
from fastmcp import Context
|
|
16
16
|
|
|
17
|
+
from ..connection import AbletonConnectionError
|
|
17
18
|
from ..server import mcp
|
|
18
19
|
from . import _theory_engine as theory
|
|
19
20
|
|
|
@@ -59,6 +60,27 @@ def _validate_midi_path(file_path: str) -> Path:
|
|
|
59
60
|
return p
|
|
60
61
|
|
|
61
62
|
|
|
63
|
+
def _midi_notes_to_beats(pm) -> list[dict]:
|
|
64
|
+
"""Convert pretty_midi notes to beat-position dicts using the file's own tempo map.
|
|
65
|
+
|
|
66
|
+
Uses time_to_tick/resolution to preserve the MIDI file's beat grid,
|
|
67
|
+
regardless of the current Ableton session tempo.
|
|
68
|
+
"""
|
|
69
|
+
notes_raw = []
|
|
70
|
+
for inst in pm.instruments:
|
|
71
|
+
for n in inst.notes:
|
|
72
|
+
start_beat = round(pm.time_to_tick(n.start) / pm.resolution, 3)
|
|
73
|
+
end_beat = round(pm.time_to_tick(n.end) / pm.resolution, 3)
|
|
74
|
+
dur_beat = round(end_beat - start_beat, 3)
|
|
75
|
+
notes_raw.append({
|
|
76
|
+
"pitch": n.pitch,
|
|
77
|
+
"start_time": start_beat,
|
|
78
|
+
"duration": max(dur_beat, 0.001),
|
|
79
|
+
"velocity": n.velocity,
|
|
80
|
+
})
|
|
81
|
+
return notes_raw
|
|
82
|
+
|
|
83
|
+
|
|
62
84
|
# -- Tool 1: export_clip_midi ------------------------------------------------
|
|
63
85
|
|
|
64
86
|
@mcp.tool()
|
|
@@ -127,8 +149,10 @@ def import_midi_to_clip(
|
|
|
127
149
|
) -> dict:
|
|
128
150
|
"""Load a .mid file into a session clip.
|
|
129
151
|
|
|
130
|
-
Reads MIDI, converts timing to beats using
|
|
131
|
-
notes into the target clip slot.
|
|
152
|
+
Reads MIDI, converts timing to beats using the file's own tempo map,
|
|
153
|
+
and writes notes into the target clip slot. When create_clip=True
|
|
154
|
+
(default), creates a new clip if the slot is empty; if a clip already
|
|
155
|
+
exists, clears its notes before importing.
|
|
132
156
|
"""
|
|
133
157
|
pretty_midi = _require_pretty_midi()
|
|
134
158
|
ableton = _get_ableton(ctx)
|
|
@@ -136,20 +160,8 @@ def import_midi_to_clip(
|
|
|
136
160
|
path = _validate_midi_path(file_path)
|
|
137
161
|
pm = pretty_midi.PrettyMIDI(str(path))
|
|
138
162
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
notes_raw = []
|
|
143
|
-
for inst in pm.instruments:
|
|
144
|
-
for n in inst.notes:
|
|
145
|
-
start_beat = round(n.start * (tempo / 60.0), 3)
|
|
146
|
-
dur_beat = round((n.end - n.start) * (tempo / 60.0), 3)
|
|
147
|
-
notes_raw.append({
|
|
148
|
-
"pitch": n.pitch,
|
|
149
|
-
"start_time": start_beat,
|
|
150
|
-
"duration": max(dur_beat, 0.001),
|
|
151
|
-
"velocity": n.velocity,
|
|
152
|
-
})
|
|
163
|
+
# Convert using the MIDI file's own tempo map (not session tempo)
|
|
164
|
+
notes_raw = _midi_notes_to_beats(pm)
|
|
153
165
|
|
|
154
166
|
seen = set()
|
|
155
167
|
notes = []
|
|
@@ -165,11 +177,31 @@ def import_midi_to_clip(
|
|
|
165
177
|
default=4.0)
|
|
166
178
|
|
|
167
179
|
if create_clip:
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
"
|
|
172
|
-
|
|
180
|
+
# Check if clip already exists — only create if the slot is empty
|
|
181
|
+
slot_has_clip = False
|
|
182
|
+
try:
|
|
183
|
+
ableton.send_command("get_clip_info", {
|
|
184
|
+
"track_index": track_index,
|
|
185
|
+
"clip_index": clip_index,
|
|
186
|
+
})
|
|
187
|
+
slot_has_clip = True
|
|
188
|
+
except AbletonConnectionError:
|
|
189
|
+
# Slot is empty — no clip to clear
|
|
190
|
+
pass
|
|
191
|
+
|
|
192
|
+
if slot_has_clip:
|
|
193
|
+
# Clip exists — clear its notes before importing
|
|
194
|
+
ableton.send_command("remove_notes", {
|
|
195
|
+
"track_index": track_index,
|
|
196
|
+
"clip_index": clip_index,
|
|
197
|
+
})
|
|
198
|
+
else:
|
|
199
|
+
# Empty slot — create a new clip
|
|
200
|
+
ableton.send_command("create_clip", {
|
|
201
|
+
"track_index": track_index,
|
|
202
|
+
"clip_index": clip_index,
|
|
203
|
+
"length": round(duration_beats, 2),
|
|
204
|
+
})
|
|
173
205
|
|
|
174
206
|
if notes:
|
|
175
207
|
ableton.send_command("add_notes", {
|
|
@@ -178,10 +210,13 @@ def import_midi_to_clip(
|
|
|
178
210
|
"notes": notes,
|
|
179
211
|
})
|
|
180
212
|
|
|
213
|
+
tempo_changes = pm.get_tempo_changes()
|
|
214
|
+
midi_tempo = float(tempo_changes[1][0]) if len(tempo_changes[1]) > 0 else 120.0
|
|
215
|
+
|
|
181
216
|
return {
|
|
182
217
|
"note_count": len(notes),
|
|
183
218
|
"duration_beats": round(duration_beats, 4),
|
|
184
|
-
"
|
|
219
|
+
"midi_tempo": midi_tempo,
|
|
185
220
|
}
|
|
186
221
|
|
|
187
222
|
|
|
@@ -135,6 +135,10 @@ def remove_notes(
|
|
|
135
135
|
"""Remove all MIDI notes in a pitch/time region. Use undo to revert. Defaults remove ALL notes in the clip."""
|
|
136
136
|
_validate_track_index(track_index)
|
|
137
137
|
_validate_clip_index(clip_index)
|
|
138
|
+
if not 0 <= from_pitch <= 127:
|
|
139
|
+
raise ValueError("from_pitch must be 0-127")
|
|
140
|
+
if pitch_span < 1 or pitch_span > 128:
|
|
141
|
+
raise ValueError("pitch_span must be 1-128")
|
|
138
142
|
params = {
|
|
139
143
|
"track_index": track_index,
|
|
140
144
|
"clip_index": clip_index,
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
"""Scene MCP tools — list, create, delete, duplicate, fire, rename, color, tempo.
|
|
1
|
+
"""Scene MCP tools — list, create, delete, duplicate, fire, rename, color, tempo, matrix.
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
12 tools matching the Remote Script scenes domain.
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
|
|
6
9
|
from fastmcp import Context
|
|
7
10
|
|
|
8
11
|
from ..server import mcp
|
|
@@ -87,3 +90,63 @@ def set_scene_tempo(ctx: Context, scene_index: int, tempo: float) -> dict:
|
|
|
87
90
|
"scene_index": scene_index,
|
|
88
91
|
"tempo": tempo,
|
|
89
92
|
})
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ── Scene Matrix Operations ─────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _ensure_list(value: Any) -> list:
|
|
99
|
+
if isinstance(value, str):
|
|
100
|
+
return json.loads(value)
|
|
101
|
+
return value
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@mcp.tool()
|
|
105
|
+
def get_scene_matrix(ctx: Context) -> dict:
|
|
106
|
+
"""Get the full session clip grid: every track x every scene.
|
|
107
|
+
|
|
108
|
+
Returns clip states (empty/stopped/playing/triggered/recording),
|
|
109
|
+
clip names, and colors. Use this for a bird's-eye view of the
|
|
110
|
+
entire session before making clip launch decisions.
|
|
111
|
+
"""
|
|
112
|
+
return _get_ableton(ctx).send_command("get_scene_matrix")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@mcp.tool()
|
|
116
|
+
def fire_scene_clips(
|
|
117
|
+
ctx: Context,
|
|
118
|
+
scene_index: int,
|
|
119
|
+
track_indices: Optional[Any] = None,
|
|
120
|
+
) -> dict:
|
|
121
|
+
"""Fire a scene, optionally filtering to specific tracks.
|
|
122
|
+
|
|
123
|
+
If track_indices is omitted, fires the entire scene (all tracks).
|
|
124
|
+
If provided (JSON array of ints), fires only those tracks' clip slots
|
|
125
|
+
from the scene — useful for launching drums + bass without triggering
|
|
126
|
+
the lead, or building up layers gradually.
|
|
127
|
+
"""
|
|
128
|
+
_validate_scene_index(scene_index)
|
|
129
|
+
params: dict = {"scene_index": scene_index}
|
|
130
|
+
if track_indices is not None:
|
|
131
|
+
track_indices = _ensure_list(track_indices)
|
|
132
|
+
for ti in track_indices:
|
|
133
|
+
if int(ti) < 0:
|
|
134
|
+
raise ValueError("track_indices must all be >= 0")
|
|
135
|
+
params["track_indices"] = track_indices
|
|
136
|
+
return _get_ableton(ctx).send_command("fire_scene_clips", params)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@mcp.tool()
|
|
140
|
+
def stop_all_clips(ctx: Context) -> dict:
|
|
141
|
+
"""Stop all playing clips in the session. Panic button."""
|
|
142
|
+
return _get_ableton(ctx).send_command("stop_all_clips")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@mcp.tool()
|
|
146
|
+
def get_playing_clips(ctx: Context) -> dict:
|
|
147
|
+
"""Get all currently playing or triggered clips.
|
|
148
|
+
|
|
149
|
+
Returns track index/name, clip index/name, and whether each clip
|
|
150
|
+
is actively playing or just triggered (waiting for quantization).
|
|
151
|
+
"""
|
|
152
|
+
return _get_ableton(ctx).send_command("get_playing_clips")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
"""Track MCP tools — create, delete, rename, mute, solo, arm, group fold, monitor.
|
|
1
|
+
"""Track MCP tools — create, delete, rename, mute, solo, arm, group fold, monitor, freeze, flatten.
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
17 tools matching the Remote Script tracks domain.
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
6
|
from __future__ import annotations
|
|
@@ -173,3 +173,46 @@ def set_track_input_monitoring(ctx: Context, track_index: int, state: int) -> di
|
|
|
173
173
|
"track_index": track_index,
|
|
174
174
|
"state": state,
|
|
175
175
|
})
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ── Freeze / Flatten ────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@mcp.tool()
|
|
182
|
+
def freeze_track(ctx: Context, track_index: int) -> dict:
|
|
183
|
+
"""Freeze a track — render all devices to audio for CPU savings.
|
|
184
|
+
|
|
185
|
+
Freeze is async in Ableton: this initiates the render and returns
|
|
186
|
+
immediately. Poll get_freeze_status to check when it's done.
|
|
187
|
+
Freezing a track that's already frozen is a no-op.
|
|
188
|
+
"""
|
|
189
|
+
_validate_track_index(track_index)
|
|
190
|
+
return _get_ableton(ctx).send_command("freeze_track", {
|
|
191
|
+
"track_index": track_index,
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@mcp.tool()
|
|
196
|
+
def flatten_track(ctx: Context, track_index: int) -> dict:
|
|
197
|
+
"""Flatten a frozen track — commit rendered audio permanently.
|
|
198
|
+
|
|
199
|
+
Destructive: replaces all devices with the rendered audio file.
|
|
200
|
+
The track must already be frozen. Use undo to revert.
|
|
201
|
+
"""
|
|
202
|
+
_validate_track_index(track_index)
|
|
203
|
+
return _get_ableton(ctx).send_command("flatten_track", {
|
|
204
|
+
"track_index": track_index,
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@mcp.tool()
|
|
209
|
+
def get_freeze_status(ctx: Context, track_index: int) -> dict:
|
|
210
|
+
"""Check if a track is frozen.
|
|
211
|
+
|
|
212
|
+
Use after freeze_track to poll for completion, or before
|
|
213
|
+
flatten_track to verify the track is ready to flatten.
|
|
214
|
+
"""
|
|
215
|
+
_validate_track_index(track_index)
|
|
216
|
+
return _get_ableton(ctx).send_command("get_freeze_status", {
|
|
217
|
+
"track_index": track_index,
|
|
218
|
+
})
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "livepilot",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
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 — 178 tools, 17 domains, device atlas, spectral perception, technique memory, neo-Riemannian harmony, Euclidean rhythm, species counterpoint, MIDI I/O",
|
|
6
6
|
"author": "Pilot Studio",
|
|
7
7
|
"license": "MIT",
|
|
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.
|
|
8
|
+
__version__ = "1.9.0"
|
|
9
9
|
|
|
10
10
|
from _Framework.ControlSurface import ControlSurface
|
|
11
11
|
from .server import LivePilotServer
|
|
@@ -34,7 +34,7 @@ class LivePilot(ControlSurface):
|
|
|
34
34
|
ControlSurface.__init__(self, c_instance)
|
|
35
35
|
self._server = LivePilotServer(self)
|
|
36
36
|
self._server.start()
|
|
37
|
-
self.log_message("LivePilot
|
|
37
|
+
self.log_message("LivePilot v%s initialized" % __version__)
|
|
38
38
|
self.show_message("LivePilot: Listening on port 9878")
|
|
39
39
|
|
|
40
40
|
def disconnect(self):
|
|
@@ -61,6 +61,8 @@ def create_arrangement_clip(song, params):
|
|
|
61
61
|
|
|
62
62
|
# Use loop_length as the repeat unit (defaults to source clip length)
|
|
63
63
|
loop_length = float(params.get("loop_length", source_length))
|
|
64
|
+
if loop_length <= 0:
|
|
65
|
+
raise ValueError("loop_length must be > 0")
|
|
64
66
|
|
|
65
67
|
name = str(params.get("name", ""))
|
|
66
68
|
color_index = params.get("color_index")
|
|
@@ -86,10 +88,50 @@ def create_arrangement_clip(song, params):
|
|
|
86
88
|
c.name = name
|
|
87
89
|
if color_index is not None:
|
|
88
90
|
c.color_index = int(color_index)
|
|
91
|
+
|
|
92
|
+
# When loop_length < source_length, set the internal
|
|
93
|
+
# loop region so only loop_length beats of content play.
|
|
94
|
+
# Arrangement clip timeline length is read-only in the
|
|
95
|
+
# LOM, but overlapping clips are handled by Ableton
|
|
96
|
+
# (later clips take priority), so playback is correct.
|
|
97
|
+
remaining = end_pos - pos
|
|
98
|
+
target_len = min(loop_length, remaining)
|
|
99
|
+
if target_len < source_length:
|
|
100
|
+
try:
|
|
101
|
+
c.looping = True
|
|
102
|
+
c.loop_start = 0.0
|
|
103
|
+
c.loop_end = target_len
|
|
104
|
+
except (AttributeError, RuntimeError):
|
|
105
|
+
pass
|
|
89
106
|
break
|
|
90
107
|
|
|
91
108
|
clip_count += 1
|
|
92
109
|
pos += loop_length
|
|
110
|
+
|
|
111
|
+
# Trim the last clip's overshoot: if the last duplicate extends
|
|
112
|
+
# past end_pos, remove notes beyond the requested region and
|
|
113
|
+
# set loop_end so only the needed portion plays.
|
|
114
|
+
if clip_count > 0:
|
|
115
|
+
arr_clips = list(track.arrangement_clips)
|
|
116
|
+
for c in arr_clips:
|
|
117
|
+
clip_end = c.start_time + c.length
|
|
118
|
+
if c.start_time >= start_time and clip_end > end_pos + 0.01:
|
|
119
|
+
# This clip overshoots — trim its content
|
|
120
|
+
overshoot_start = end_pos - c.start_time
|
|
121
|
+
if overshoot_start > 0:
|
|
122
|
+
try:
|
|
123
|
+
c.looping = True
|
|
124
|
+
c.loop_start = 0.0
|
|
125
|
+
c.loop_end = overshoot_start
|
|
126
|
+
except (AttributeError, RuntimeError):
|
|
127
|
+
pass
|
|
128
|
+
# Remove notes beyond the trim point
|
|
129
|
+
try:
|
|
130
|
+
c.remove_notes_extended(
|
|
131
|
+
0, 128, overshoot_start, c.length
|
|
132
|
+
)
|
|
133
|
+
except Exception:
|
|
134
|
+
pass
|
|
93
135
|
finally:
|
|
94
136
|
song.end_undo_step()
|
|
95
137
|
|
|
@@ -12,8 +12,8 @@ from .utils import get_track, get_clip
|
|
|
12
12
|
@register("get_clip_automation")
|
|
13
13
|
def get_clip_automation(song, params):
|
|
14
14
|
"""List automation envelopes on a session clip."""
|
|
15
|
-
track_index = params["track_index"]
|
|
16
|
-
clip_index = params["clip_index"]
|
|
15
|
+
track_index = int(params["track_index"])
|
|
16
|
+
clip_index = int(params["clip_index"])
|
|
17
17
|
|
|
18
18
|
track = get_track(song, track_index)
|
|
19
19
|
clip = get_clip(song, track_index, clip_index)
|
|
@@ -80,8 +80,8 @@ def set_clip_automation(song, params):
|
|
|
80
80
|
parameter_type: "device", "volume", "panning", "send"
|
|
81
81
|
points: [{time, value, duration?}] — time relative to clip start
|
|
82
82
|
"""
|
|
83
|
-
track_index = params["track_index"]
|
|
84
|
-
clip_index = params["clip_index"]
|
|
83
|
+
track_index = int(params["track_index"])
|
|
84
|
+
clip_index = int(params["clip_index"])
|
|
85
85
|
parameter_type = params["parameter_type"]
|
|
86
86
|
points = params["points"]
|
|
87
87
|
device_index = params.get("device_index")
|
|
@@ -98,29 +98,23 @@ def set_clip_automation(song, params):
|
|
|
98
98
|
parameter = track.mixer_device.panning
|
|
99
99
|
elif parameter_type == "send":
|
|
100
100
|
if send_index is None:
|
|
101
|
-
|
|
102
|
-
"message": "send_index required for send automation"}}
|
|
101
|
+
raise ValueError("send_index required for send automation")
|
|
103
102
|
sends = list(track.mixer_device.sends)
|
|
104
103
|
if send_index < 0 or send_index >= len(sends):
|
|
105
|
-
|
|
106
|
-
"message": "send_index %d out of range" % send_index}}
|
|
104
|
+
raise IndexError("send_index %d out of range" % send_index)
|
|
107
105
|
parameter = sends[send_index]
|
|
108
106
|
elif parameter_type == "device":
|
|
109
107
|
if device_index is None or parameter_index is None:
|
|
110
|
-
|
|
111
|
-
"message": "device_index and parameter_index required"}}
|
|
108
|
+
raise ValueError("device_index and parameter_index required")
|
|
112
109
|
devices = list(track.devices)
|
|
113
110
|
if device_index < 0 or device_index >= len(devices):
|
|
114
|
-
|
|
115
|
-
"message": "device_index %d out of range" % device_index}}
|
|
111
|
+
raise IndexError("device_index %d out of range" % device_index)
|
|
116
112
|
dev_params = list(devices[device_index].parameters)
|
|
117
113
|
if parameter_index < 0 or parameter_index >= len(dev_params):
|
|
118
|
-
|
|
119
|
-
"message": "parameter_index %d out of range" % parameter_index}}
|
|
114
|
+
raise IndexError("parameter_index %d out of range" % parameter_index)
|
|
120
115
|
parameter = dev_params[parameter_index]
|
|
121
116
|
else:
|
|
122
|
-
|
|
123
|
-
"message": "parameter_type must be device/volume/panning/send"}}
|
|
117
|
+
raise ValueError("parameter_type must be device/volume/panning/send")
|
|
124
118
|
|
|
125
119
|
# Get or create envelope
|
|
126
120
|
song.begin_undo_step()
|
|
@@ -158,8 +152,8 @@ def clear_clip_automation(song, params):
|
|
|
158
152
|
If parameter_type is provided, clears only that parameter's envelope.
|
|
159
153
|
If omitted, clears ALL envelopes on the clip.
|
|
160
154
|
"""
|
|
161
|
-
track_index = params["track_index"]
|
|
162
|
-
clip_index = params["clip_index"]
|
|
155
|
+
track_index = int(params["track_index"])
|
|
156
|
+
clip_index = int(params["clip_index"])
|
|
163
157
|
parameter_type = params.get("parameter_type")
|
|
164
158
|
|
|
165
159
|
track = get_track(song, track_index)
|
|
@@ -184,31 +178,25 @@ def clear_clip_automation(song, params):
|
|
|
184
178
|
elif parameter_type == "send":
|
|
185
179
|
send_index = params.get("send_index")
|
|
186
180
|
if send_index is None:
|
|
187
|
-
|
|
188
|
-
"message": "send_index required for send automation"}}
|
|
181
|
+
raise ValueError("send_index required for send automation")
|
|
189
182
|
sends = list(track.mixer_device.sends)
|
|
190
183
|
if send_index < 0 or send_index >= len(sends):
|
|
191
|
-
|
|
192
|
-
"message": "send_index %d out of range" % send_index}}
|
|
184
|
+
raise IndexError("send_index %d out of range" % send_index)
|
|
193
185
|
parameter = sends[send_index]
|
|
194
186
|
elif parameter_type == "device":
|
|
195
187
|
device_index = params.get("device_index")
|
|
196
188
|
parameter_index = params.get("parameter_index")
|
|
197
189
|
if device_index is None or parameter_index is None:
|
|
198
|
-
|
|
199
|
-
"message": "device_index and parameter_index required"}}
|
|
190
|
+
raise ValueError("device_index and parameter_index required")
|
|
200
191
|
devices = list(track.devices)
|
|
201
192
|
if device_index < 0 or device_index >= len(devices):
|
|
202
|
-
|
|
203
|
-
"message": "device_index %d out of range" % device_index}}
|
|
193
|
+
raise IndexError("device_index %d out of range" % device_index)
|
|
204
194
|
dev_params = list(devices[device_index].parameters)
|
|
205
195
|
if parameter_index < 0 or parameter_index >= len(dev_params):
|
|
206
|
-
|
|
207
|
-
"message": "parameter_index %d out of range" % parameter_index}}
|
|
196
|
+
raise IndexError("parameter_index %d out of range" % parameter_index)
|
|
208
197
|
parameter = dev_params[parameter_index]
|
|
209
198
|
else:
|
|
210
|
-
|
|
211
|
-
"message": "Unknown parameter_type"}}
|
|
199
|
+
raise ValueError("Unknown parameter_type")
|
|
212
200
|
|
|
213
201
|
clip.clear_envelope(parameter)
|
|
214
202
|
finally:
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"""
|
|
2
|
-
LivePilot - Scene domain handlers (
|
|
2
|
+
LivePilot - Scene domain handlers (12 commands).
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
5
|
from .router import register
|
|
@@ -97,3 +97,112 @@ def set_scene_tempo(song, params):
|
|
|
97
97
|
"index": scene_index,
|
|
98
98
|
"tempo": scene.tempo,
|
|
99
99
|
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ── Scene Matrix Operations ─────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@register("get_scene_matrix")
|
|
106
|
+
def get_scene_matrix(song, params):
|
|
107
|
+
"""Return the full clip grid: tracks x scenes with clip states."""
|
|
108
|
+
tracks = list(song.tracks)
|
|
109
|
+
scenes = list(song.scenes)
|
|
110
|
+
|
|
111
|
+
track_headers = []
|
|
112
|
+
for i, t in enumerate(tracks):
|
|
113
|
+
track_headers.append({"index": i, "name": t.name})
|
|
114
|
+
|
|
115
|
+
scene_headers = []
|
|
116
|
+
for i, s in enumerate(scenes):
|
|
117
|
+
scene_headers.append({
|
|
118
|
+
"index": i,
|
|
119
|
+
"name": s.name,
|
|
120
|
+
"tempo": s.tempo if s.tempo > 0 else None,
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
matrix = []
|
|
124
|
+
for si, scene in enumerate(scenes):
|
|
125
|
+
row = []
|
|
126
|
+
for ti, track in enumerate(tracks):
|
|
127
|
+
slots = list(track.clip_slots)
|
|
128
|
+
if si >= len(slots):
|
|
129
|
+
row.append({"state": "missing"})
|
|
130
|
+
continue
|
|
131
|
+
slot = slots[si]
|
|
132
|
+
cell = {"state": "empty"}
|
|
133
|
+
if slot.has_clip and slot.clip:
|
|
134
|
+
clip = slot.clip
|
|
135
|
+
if clip.is_recording:
|
|
136
|
+
cell["state"] = "recording"
|
|
137
|
+
elif clip.is_playing:
|
|
138
|
+
cell["state"] = "playing"
|
|
139
|
+
elif clip.is_triggered:
|
|
140
|
+
cell["state"] = "triggered"
|
|
141
|
+
else:
|
|
142
|
+
cell["state"] = "stopped"
|
|
143
|
+
cell["name"] = clip.name
|
|
144
|
+
cell["color_index"] = clip.color_index
|
|
145
|
+
row.append(cell)
|
|
146
|
+
matrix.append(row)
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
"tracks": track_headers,
|
|
150
|
+
"scenes": scene_headers,
|
|
151
|
+
"matrix": matrix,
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@register("fire_scene_clips")
|
|
156
|
+
def fire_scene_clips(song, params):
|
|
157
|
+
"""Fire a scene with optional track filter."""
|
|
158
|
+
scene_index = int(params["scene_index"])
|
|
159
|
+
track_indices = params.get("track_indices")
|
|
160
|
+
|
|
161
|
+
scene = get_scene(song, scene_index)
|
|
162
|
+
|
|
163
|
+
if track_indices is None:
|
|
164
|
+
# Fire entire scene
|
|
165
|
+
scene.fire()
|
|
166
|
+
return {"scene_index": scene_index, "fired": "all"}
|
|
167
|
+
|
|
168
|
+
# Fire specific tracks only
|
|
169
|
+
tracks = list(song.tracks)
|
|
170
|
+
fired = []
|
|
171
|
+
for ti in track_indices:
|
|
172
|
+
ti = int(ti)
|
|
173
|
+
if ti < 0 or ti >= len(tracks):
|
|
174
|
+
raise IndexError("Track index %d out of range (0..%d)" % (ti, len(tracks) - 1))
|
|
175
|
+
slots = list(tracks[ti].clip_slots)
|
|
176
|
+
if scene_index < len(slots):
|
|
177
|
+
slots[scene_index].fire()
|
|
178
|
+
fired.append(ti)
|
|
179
|
+
|
|
180
|
+
return {"scene_index": scene_index, "fired_tracks": fired}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@register("stop_all_clips")
|
|
184
|
+
def stop_all_clips(song, params):
|
|
185
|
+
"""Stop all playing clips in the session."""
|
|
186
|
+
song.stop_all_clips()
|
|
187
|
+
return {"stopped": True}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@register("get_playing_clips")
|
|
191
|
+
def get_playing_clips(song, params):
|
|
192
|
+
"""Return all currently playing or triggered clips."""
|
|
193
|
+
tracks = list(song.tracks)
|
|
194
|
+
clips = []
|
|
195
|
+
for ti, track in enumerate(tracks):
|
|
196
|
+
for si, slot in enumerate(track.clip_slots):
|
|
197
|
+
if slot.has_clip and slot.clip:
|
|
198
|
+
clip = slot.clip
|
|
199
|
+
if clip.is_playing or clip.is_triggered:
|
|
200
|
+
clips.append({
|
|
201
|
+
"track_index": ti,
|
|
202
|
+
"track_name": track.name,
|
|
203
|
+
"clip_index": si,
|
|
204
|
+
"clip_name": clip.name,
|
|
205
|
+
"is_playing": clip.is_playing,
|
|
206
|
+
"is_triggered": clip.is_triggered,
|
|
207
|
+
})
|
|
208
|
+
return {"clips": clips}
|
|
@@ -39,6 +39,9 @@ WRITE_COMMANDS = frozenset([
|
|
|
39
39
|
# scenes
|
|
40
40
|
"create_scene", "delete_scene", "duplicate_scene", "fire_scene",
|
|
41
41
|
"set_scene_name", "set_scene_color", "set_scene_tempo",
|
|
42
|
+
"fire_scene_clips", "stop_all_clips",
|
|
43
|
+
# tracks (freeze/flatten)
|
|
44
|
+
"freeze_track", "flatten_track",
|
|
42
45
|
# mixing
|
|
43
46
|
"set_track_volume", "set_track_pan", "set_track_send",
|
|
44
47
|
"set_master_volume", "set_track_routing",
|
|
@@ -57,6 +60,11 @@ WRITE_COMMANDS = frozenset([
|
|
|
57
60
|
"clear_clip_automation",
|
|
58
61
|
])
|
|
59
62
|
|
|
63
|
+
# Commands that need longer timeouts (e.g., freeze renders audio)
|
|
64
|
+
SLOW_WRITE_COMMANDS = frozenset([
|
|
65
|
+
"freeze_track",
|
|
66
|
+
])
|
|
67
|
+
|
|
60
68
|
|
|
61
69
|
class LivePilotServer(object):
|
|
62
70
|
"""TCP server that bridges JSON commands to Ableton's main thread.
|
|
@@ -212,9 +220,14 @@ class LivePilotServer(object):
|
|
|
212
220
|
request_id = command.get("id", "unknown")
|
|
213
221
|
cmd_type = command.get("type", "")
|
|
214
222
|
|
|
215
|
-
# Determine timeout based on read vs write
|
|
223
|
+
# Determine timeout based on read vs write vs slow write
|
|
216
224
|
is_write = cmd_type in WRITE_COMMANDS
|
|
217
|
-
|
|
225
|
+
if cmd_type in SLOW_WRITE_COMMANDS:
|
|
226
|
+
timeout = 35
|
|
227
|
+
elif is_write:
|
|
228
|
+
timeout = 15
|
|
229
|
+
else:
|
|
230
|
+
timeout = 10
|
|
218
231
|
|
|
219
232
|
# Per-command response queue
|
|
220
233
|
response_queue = queue.Queue()
|