livepilot 1.1.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/settings.local.json +10 -0
- package/.mcpregistry_github_token +1 -0
- package/.mcpregistry_registry_token +1 -0
- package/.playwright-mcp/console-2026-03-17T15-47-29-021Z.log +10 -0
- package/.playwright-mcp/console-2026-03-17T15-51-09-247Z.log +10 -0
- package/.playwright-mcp/console-2026-03-17T15-52-22-831Z.log +12 -0
- package/.playwright-mcp/console-2026-03-17T15-52-29-709Z.log +10 -0
- package/.playwright-mcp/console-2026-03-17T15-53-20-147Z.log +1 -0
- package/.playwright-mcp/glama-snapshot.md +2140 -0
- package/.playwright-mcp/page-2026-03-17T15-49-02-625Z.png +0 -0
- package/.playwright-mcp/page-2026-03-17T15-52-15-149Z.png +0 -0
- package/.playwright-mcp/page-2026-03-17T15-52-57-333Z.png +0 -0
- package/CHANGELOG.md +33 -0
- package/LICENSE +21 -0
- package/README.md +296 -0
- package/bin/livepilot.js +376 -0
- package/installer/install.js +95 -0
- package/installer/paths.js +79 -0
- package/mcp_server/__init__.py +2 -0
- package/mcp_server/__main__.py +5 -0
- package/mcp_server/connection.py +207 -0
- package/mcp_server/server.py +40 -0
- package/mcp_server/tools/__init__.py +1 -0
- package/mcp_server/tools/arrangement.py +399 -0
- package/mcp_server/tools/browser.py +78 -0
- package/mcp_server/tools/clips.py +187 -0
- package/mcp_server/tools/devices.py +238 -0
- package/mcp_server/tools/mixing.py +113 -0
- package/mcp_server/tools/notes.py +266 -0
- package/mcp_server/tools/scenes.py +63 -0
- package/mcp_server/tools/tracks.py +148 -0
- package/mcp_server/tools/transport.py +113 -0
- package/package.json +38 -0
- package/plugin/.mcp.json +8 -0
- package/plugin/agents/livepilot-producer/AGENT.md +61 -0
- package/plugin/commands/beat.md +18 -0
- package/plugin/commands/mix.md +15 -0
- package/plugin/commands/session.md +13 -0
- package/plugin/commands/sounddesign.md +16 -0
- package/plugin/plugin.json +18 -0
- package/plugin/skills/livepilot-core/SKILL.md +160 -0
- package/plugin/skills/livepilot-core/references/ableton-workflow-patterns.md +831 -0
- package/plugin/skills/livepilot-core/references/m4l-devices.md +352 -0
- package/plugin/skills/livepilot-core/references/midi-recipes.md +402 -0
- package/plugin/skills/livepilot-core/references/mixing-patterns.md +578 -0
- package/plugin/skills/livepilot-core/references/overview.md +191 -0
- package/plugin/skills/livepilot-core/references/sound-design.md +392 -0
- package/remote_script/LivePilot/__init__.py +42 -0
- package/remote_script/LivePilot/arrangement.py +678 -0
- package/remote_script/LivePilot/browser.py +325 -0
- package/remote_script/LivePilot/clips.py +172 -0
- package/remote_script/LivePilot/devices.py +466 -0
- package/remote_script/LivePilot/diagnostics.py +198 -0
- package/remote_script/LivePilot/mixing.py +194 -0
- package/remote_script/LivePilot/notes.py +339 -0
- package/remote_script/LivePilot/router.py +74 -0
- package/remote_script/LivePilot/scenes.py +75 -0
- package/remote_script/LivePilot/server.py +286 -0
- package/remote_script/LivePilot/tracks.py +229 -0
- package/remote_script/LivePilot/transport.py +147 -0
- package/remote_script/LivePilot/utils.py +112 -0
- package/requirements.txt +2 -0
- package/server.json +20 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LivePilot - TCP server with thread-safe command queue.
|
|
3
|
+
|
|
4
|
+
Runs a background daemon thread that accepts JSON-over-TCP connections.
|
|
5
|
+
Commands are forwarded to Ableton's main thread via schedule_message,
|
|
6
|
+
and responses are returned through per-command Queue objects.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import socket
|
|
10
|
+
import threading
|
|
11
|
+
import json
|
|
12
|
+
|
|
13
|
+
import queue
|
|
14
|
+
|
|
15
|
+
from . import router
|
|
16
|
+
|
|
17
|
+
# ── Commands that modify Live state (need settle delay) ──────────────────────
|
|
18
|
+
|
|
19
|
+
WRITE_COMMANDS = frozenset([
|
|
20
|
+
# transport
|
|
21
|
+
"set_tempo", "set_time_signature", "start_playback", "stop_playback",
|
|
22
|
+
"continue_playback", "toggle_metronome", "set_session_loop", "undo", "redo",
|
|
23
|
+
# tracks
|
|
24
|
+
"create_midi_track", "create_audio_track", "create_return_track",
|
|
25
|
+
"delete_track", "duplicate_track", "set_track_name", "set_track_color",
|
|
26
|
+
"set_track_mute", "set_track_solo", "set_track_arm", "stop_track_clips",
|
|
27
|
+
# clips
|
|
28
|
+
"create_clip", "delete_clip", "duplicate_clip", "fire_clip", "stop_clip",
|
|
29
|
+
"set_clip_name", "set_clip_color", "set_clip_loop", "set_clip_launch",
|
|
30
|
+
# notes
|
|
31
|
+
"add_notes", "remove_notes", "remove_notes_by_id", "modify_notes",
|
|
32
|
+
"duplicate_notes", "transpose_notes", "quantize_clip",
|
|
33
|
+
# devices
|
|
34
|
+
"set_device_parameter", "batch_set_parameters", "toggle_device",
|
|
35
|
+
"delete_device", "load_device_by_uri", "find_and_load_device",
|
|
36
|
+
"set_chain_volume", "set_simpler_playback_mode",
|
|
37
|
+
# scenes
|
|
38
|
+
"create_scene", "delete_scene", "duplicate_scene", "fire_scene",
|
|
39
|
+
"set_scene_name",
|
|
40
|
+
# mixing
|
|
41
|
+
"set_track_volume", "set_track_pan", "set_track_send",
|
|
42
|
+
"set_master_volume", "set_track_routing",
|
|
43
|
+
# browser
|
|
44
|
+
"load_browser_item",
|
|
45
|
+
# arrangement
|
|
46
|
+
"jump_to_time", "jump_to_cue", "capture_midi", "start_recording",
|
|
47
|
+
"stop_recording", "toggle_cue_point",
|
|
48
|
+
])
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class LivePilotServer(object):
|
|
52
|
+
"""TCP server that bridges JSON commands to Ableton's main thread.
|
|
53
|
+
|
|
54
|
+
Single-client by design: only one client can be connected at a time.
|
|
55
|
+
All commands must execute on Ableton's main thread (Live Object Model
|
|
56
|
+
is not thread-safe), so serialized client access prevents race conditions.
|
|
57
|
+
Additional connection attempts are rejected with a clear error message.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(self, control_surface, host="127.0.0.1", port=9878):
|
|
61
|
+
self._cs = control_surface
|
|
62
|
+
self._host = host
|
|
63
|
+
self._port = port
|
|
64
|
+
self._running = False
|
|
65
|
+
self._server_socket = None
|
|
66
|
+
self._thread = None
|
|
67
|
+
self._command_queue = queue.Queue()
|
|
68
|
+
self._client_lock = threading.Lock()
|
|
69
|
+
self._client_connected = False
|
|
70
|
+
|
|
71
|
+
# ── Public API ───────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
def start(self):
|
|
74
|
+
"""Start the background listener thread."""
|
|
75
|
+
self._running = True
|
|
76
|
+
self._thread = threading.Thread(target=self._server_loop)
|
|
77
|
+
self._thread.daemon = True
|
|
78
|
+
self._thread.start()
|
|
79
|
+
self._log("Server started on %s:%d" % (self._host, self._port))
|
|
80
|
+
|
|
81
|
+
def stop(self):
|
|
82
|
+
"""Shutdown the server gracefully."""
|
|
83
|
+
self._running = False
|
|
84
|
+
if self._server_socket:
|
|
85
|
+
try:
|
|
86
|
+
self._server_socket.close()
|
|
87
|
+
except Exception:
|
|
88
|
+
pass
|
|
89
|
+
if self._thread and self._thread.is_alive():
|
|
90
|
+
self._thread.join(timeout=3)
|
|
91
|
+
self._log("Server stopped")
|
|
92
|
+
|
|
93
|
+
# ── Logging ──────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
def _log(self, message):
|
|
96
|
+
try:
|
|
97
|
+
self._cs.log_message("[LivePilot] " + str(message))
|
|
98
|
+
except Exception:
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
# ── Background thread ────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
def _server_loop(self):
|
|
104
|
+
"""Runs in a daemon thread. Accepts one client at a time."""
|
|
105
|
+
try:
|
|
106
|
+
self._server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
107
|
+
self._server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
108
|
+
self._server_socket.bind((self._host, self._port))
|
|
109
|
+
self._server_socket.listen(2)
|
|
110
|
+
self._server_socket.settimeout(1.0)
|
|
111
|
+
self._log("Listening on %s:%d" % (self._host, self._port))
|
|
112
|
+
except Exception as exc:
|
|
113
|
+
self._log("Failed to bind: %s" % exc)
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
while self._running:
|
|
117
|
+
try:
|
|
118
|
+
client, addr = self._server_socket.accept()
|
|
119
|
+
with self._client_lock:
|
|
120
|
+
if self._client_connected:
|
|
121
|
+
# Reject concurrent clients with an explicit message
|
|
122
|
+
self._log("Rejected client from %s:%d (another client is connected)" % addr)
|
|
123
|
+
try:
|
|
124
|
+
reject = json.dumps({
|
|
125
|
+
"id": "system",
|
|
126
|
+
"ok": False,
|
|
127
|
+
"error": {
|
|
128
|
+
"code": "STATE_ERROR",
|
|
129
|
+
"message": "Another client is already connected. "
|
|
130
|
+
"LivePilot accepts one client at a time. "
|
|
131
|
+
"Disconnect the current client first."
|
|
132
|
+
}
|
|
133
|
+
}) + "\n"
|
|
134
|
+
client.sendall(reject.encode("utf-8"))
|
|
135
|
+
except Exception:
|
|
136
|
+
pass
|
|
137
|
+
try:
|
|
138
|
+
client.close()
|
|
139
|
+
except Exception:
|
|
140
|
+
pass
|
|
141
|
+
continue
|
|
142
|
+
self._client_connected = True
|
|
143
|
+
self._log("Client connected from %s:%d" % addr)
|
|
144
|
+
try:
|
|
145
|
+
self._handle_client(client)
|
|
146
|
+
except Exception as exc:
|
|
147
|
+
self._log("Client error: %s" % exc)
|
|
148
|
+
finally:
|
|
149
|
+
with self._client_lock:
|
|
150
|
+
self._client_connected = False
|
|
151
|
+
try:
|
|
152
|
+
client.close()
|
|
153
|
+
except Exception:
|
|
154
|
+
pass
|
|
155
|
+
self._log("Client disconnected")
|
|
156
|
+
except socket.timeout:
|
|
157
|
+
continue
|
|
158
|
+
except Exception:
|
|
159
|
+
if self._running:
|
|
160
|
+
self._log("Accept error")
|
|
161
|
+
break
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
self._server_socket.close()
|
|
165
|
+
except Exception:
|
|
166
|
+
pass
|
|
167
|
+
|
|
168
|
+
def _handle_client(self, client):
|
|
169
|
+
"""Read newline-delimited JSON from a connected client."""
|
|
170
|
+
client.settimeout(1.0)
|
|
171
|
+
buf = ""
|
|
172
|
+
while self._running:
|
|
173
|
+
try:
|
|
174
|
+
data = client.recv(4096)
|
|
175
|
+
if not data:
|
|
176
|
+
break
|
|
177
|
+
buf += data.decode("utf-8", errors="replace")
|
|
178
|
+
while "\n" in buf:
|
|
179
|
+
line, buf = buf.split("\n", 1)
|
|
180
|
+
line = line.strip()
|
|
181
|
+
if line:
|
|
182
|
+
self._process_line(client, line)
|
|
183
|
+
except socket.timeout:
|
|
184
|
+
continue
|
|
185
|
+
except Exception as exc:
|
|
186
|
+
self._log("Recv error: %s" % exc)
|
|
187
|
+
break
|
|
188
|
+
|
|
189
|
+
def _process_line(self, client, line):
|
|
190
|
+
"""Parse one JSON command, queue it for main thread, wait for result."""
|
|
191
|
+
try:
|
|
192
|
+
command = json.loads(line)
|
|
193
|
+
except Exception as exc:
|
|
194
|
+
resp = {
|
|
195
|
+
"id": "unknown",
|
|
196
|
+
"ok": False,
|
|
197
|
+
"error": {"code": "INVALID_PARAM", "message": "Bad JSON: %s" % exc},
|
|
198
|
+
}
|
|
199
|
+
self._send(client, resp)
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
request_id = command.get("id", "unknown")
|
|
203
|
+
cmd_type = command.get("type", "")
|
|
204
|
+
|
|
205
|
+
# Determine timeout based on read vs write
|
|
206
|
+
is_write = cmd_type in WRITE_COMMANDS
|
|
207
|
+
timeout = 15 if is_write else 10
|
|
208
|
+
|
|
209
|
+
# Per-command response queue
|
|
210
|
+
response_queue = queue.Queue()
|
|
211
|
+
self._command_queue.put((command, response_queue))
|
|
212
|
+
|
|
213
|
+
# Schedule processing on Ableton's main thread
|
|
214
|
+
try:
|
|
215
|
+
self._cs.schedule_message(0, self._process_next_command)
|
|
216
|
+
except AssertionError:
|
|
217
|
+
# Already on main thread — process directly
|
|
218
|
+
self._process_next_command()
|
|
219
|
+
|
|
220
|
+
# Wait for response from main thread
|
|
221
|
+
try:
|
|
222
|
+
resp = response_queue.get(timeout=timeout)
|
|
223
|
+
except queue.Empty:
|
|
224
|
+
resp = {
|
|
225
|
+
"id": request_id,
|
|
226
|
+
"ok": False,
|
|
227
|
+
"error": {"code": "TIMEOUT", "message": "Command timed out after %ds" % timeout},
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
self._send(client, resp)
|
|
231
|
+
|
|
232
|
+
# ── Main thread execution ────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
def _process_next_command(self):
|
|
235
|
+
"""Called on Ableton's main thread via schedule_message.
|
|
236
|
+
Processes one command from the queue."""
|
|
237
|
+
try:
|
|
238
|
+
command, response_queue = self._command_queue.get_nowait()
|
|
239
|
+
except queue.Empty:
|
|
240
|
+
return
|
|
241
|
+
|
|
242
|
+
cmd_type = command.get("type", "")
|
|
243
|
+
is_write = cmd_type in WRITE_COMMANDS
|
|
244
|
+
|
|
245
|
+
try:
|
|
246
|
+
song = self._cs.song()
|
|
247
|
+
result = router.dispatch(song, command)
|
|
248
|
+
except Exception as exc:
|
|
249
|
+
result = {
|
|
250
|
+
"id": command.get("id", "unknown"),
|
|
251
|
+
"ok": False,
|
|
252
|
+
"error": {"code": "INTERNAL", "message": str(exc)},
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if is_write:
|
|
256
|
+
# Schedule response after 100ms settle delay for write operations
|
|
257
|
+
def send_response():
|
|
258
|
+
response_queue.put(result)
|
|
259
|
+
# Drain any remaining queued commands
|
|
260
|
+
self._drain_queue()
|
|
261
|
+
try:
|
|
262
|
+
self._cs.schedule_message(1, send_response) # ~100ms
|
|
263
|
+
except AssertionError:
|
|
264
|
+
send_response()
|
|
265
|
+
else:
|
|
266
|
+
response_queue.put(result)
|
|
267
|
+
# Drain any remaining queued commands
|
|
268
|
+
self._drain_queue()
|
|
269
|
+
|
|
270
|
+
def _drain_queue(self):
|
|
271
|
+
"""Process any remaining commands in the queue."""
|
|
272
|
+
if not self._command_queue.empty():
|
|
273
|
+
try:
|
|
274
|
+
self._cs.schedule_message(0, self._process_next_command)
|
|
275
|
+
except AssertionError:
|
|
276
|
+
self._process_next_command()
|
|
277
|
+
|
|
278
|
+
# ── Socket I/O ───────────────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
def _send(self, client, response):
|
|
281
|
+
"""Send a JSON response to the client."""
|
|
282
|
+
from .utils import serialize_json
|
|
283
|
+
try:
|
|
284
|
+
client.sendall(serialize_json(response).encode("utf-8"))
|
|
285
|
+
except Exception as exc:
|
|
286
|
+
self._log("Send error: %s" % exc)
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LivePilot - Track domain handlers (12 commands).
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .router import register
|
|
6
|
+
from .utils import get_track
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@register("get_track_info")
|
|
10
|
+
def get_track_info(song, params):
|
|
11
|
+
"""Return detailed info for a single track."""
|
|
12
|
+
track_index = int(params["track_index"])
|
|
13
|
+
track = get_track(song, track_index)
|
|
14
|
+
|
|
15
|
+
# Clip slots
|
|
16
|
+
clips = []
|
|
17
|
+
for i, slot in enumerate(track.clip_slots):
|
|
18
|
+
clip_info = {
|
|
19
|
+
"index": i,
|
|
20
|
+
"has_clip": slot.has_clip,
|
|
21
|
+
}
|
|
22
|
+
if slot.has_clip and slot.clip:
|
|
23
|
+
clip = slot.clip
|
|
24
|
+
clip_info.update({
|
|
25
|
+
"name": clip.name,
|
|
26
|
+
"color_index": clip.color_index,
|
|
27
|
+
"length": clip.length,
|
|
28
|
+
"is_playing": clip.is_playing,
|
|
29
|
+
"is_recording": clip.is_recording,
|
|
30
|
+
"looping": clip.looping,
|
|
31
|
+
"loop_start": clip.loop_start,
|
|
32
|
+
"loop_end": clip.loop_end,
|
|
33
|
+
"start_marker": clip.start_marker,
|
|
34
|
+
"end_marker": clip.end_marker,
|
|
35
|
+
})
|
|
36
|
+
clips.append(clip_info)
|
|
37
|
+
|
|
38
|
+
# Devices
|
|
39
|
+
devices = []
|
|
40
|
+
for i, device in enumerate(track.devices):
|
|
41
|
+
dev_info = {
|
|
42
|
+
"index": i,
|
|
43
|
+
"name": device.name,
|
|
44
|
+
"class_name": device.class_name,
|
|
45
|
+
"is_active": device.is_active,
|
|
46
|
+
}
|
|
47
|
+
dev_params = []
|
|
48
|
+
for j, param in enumerate(device.parameters):
|
|
49
|
+
dev_params.append({
|
|
50
|
+
"index": j,
|
|
51
|
+
"name": param.name,
|
|
52
|
+
"value": param.value,
|
|
53
|
+
"min": param.min,
|
|
54
|
+
"max": param.max,
|
|
55
|
+
"is_quantized": param.is_quantized,
|
|
56
|
+
})
|
|
57
|
+
dev_info["parameters"] = dev_params
|
|
58
|
+
devices.append(dev_info)
|
|
59
|
+
|
|
60
|
+
# Mixer info
|
|
61
|
+
mixer = {
|
|
62
|
+
"volume": track.mixer_device.volume.value,
|
|
63
|
+
"panning": track.mixer_device.panning.value,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
# Sends
|
|
67
|
+
sends = []
|
|
68
|
+
for i, send in enumerate(track.mixer_device.sends):
|
|
69
|
+
sends.append({
|
|
70
|
+
"index": i,
|
|
71
|
+
"name": send.name,
|
|
72
|
+
"value": send.value,
|
|
73
|
+
"min": send.min,
|
|
74
|
+
"max": send.max,
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
result = {
|
|
78
|
+
"index": track_index,
|
|
79
|
+
"name": track.name,
|
|
80
|
+
"color_index": track.color_index,
|
|
81
|
+
"mute": track.mute,
|
|
82
|
+
"solo": track.solo,
|
|
83
|
+
"clip_slots": clips,
|
|
84
|
+
"devices": devices,
|
|
85
|
+
"mixer": mixer,
|
|
86
|
+
"sends": sends,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
# Regular tracks have arm and input type; return tracks get null values
|
|
90
|
+
if track_index >= 0:
|
|
91
|
+
result["arm"] = track.arm
|
|
92
|
+
result["has_midi_input"] = track.has_midi_input
|
|
93
|
+
result["has_audio_input"] = track.has_audio_input
|
|
94
|
+
else:
|
|
95
|
+
result["arm"] = None
|
|
96
|
+
result["has_midi_input"] = None
|
|
97
|
+
result["has_audio_input"] = None
|
|
98
|
+
result["is_return_track"] = True
|
|
99
|
+
|
|
100
|
+
return result
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@register("create_midi_track")
|
|
104
|
+
def create_midi_track(song, params):
|
|
105
|
+
"""Create a new MIDI track at the given index."""
|
|
106
|
+
index = int(params.get("index", -1))
|
|
107
|
+
song.create_midi_track(index)
|
|
108
|
+
# The new track is at the requested index (or end if -1)
|
|
109
|
+
if index == -1:
|
|
110
|
+
new_index = len(list(song.tracks)) - 1
|
|
111
|
+
else:
|
|
112
|
+
new_index = index
|
|
113
|
+
track = list(song.tracks)[new_index]
|
|
114
|
+
if "name" in params:
|
|
115
|
+
track.name = str(params["name"])
|
|
116
|
+
if "color_index" in params:
|
|
117
|
+
track.color_index = int(params["color_index"])
|
|
118
|
+
return {"index": new_index, "name": track.name}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@register("create_audio_track")
|
|
122
|
+
def create_audio_track(song, params):
|
|
123
|
+
"""Create a new audio track at the given index."""
|
|
124
|
+
index = int(params.get("index", -1))
|
|
125
|
+
song.create_audio_track(index)
|
|
126
|
+
if index == -1:
|
|
127
|
+
new_index = len(list(song.tracks)) - 1
|
|
128
|
+
else:
|
|
129
|
+
new_index = index
|
|
130
|
+
track = list(song.tracks)[new_index]
|
|
131
|
+
if "name" in params:
|
|
132
|
+
track.name = str(params["name"])
|
|
133
|
+
if "color_index" in params:
|
|
134
|
+
track.color_index = int(params["color_index"])
|
|
135
|
+
return {"index": new_index, "name": track.name}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@register("create_return_track")
|
|
139
|
+
def create_return_track(song, params):
|
|
140
|
+
"""Create a new return track."""
|
|
141
|
+
song.create_return_track()
|
|
142
|
+
return_tracks = list(song.return_tracks)
|
|
143
|
+
new_index = len(return_tracks) - 1
|
|
144
|
+
return {"index": new_index, "name": return_tracks[new_index].name}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@register("delete_track")
|
|
148
|
+
def delete_track(song, params):
|
|
149
|
+
"""Delete a track by index."""
|
|
150
|
+
track_index = int(params["track_index"])
|
|
151
|
+
tracks = list(song.tracks)
|
|
152
|
+
if track_index < 0 or track_index >= len(tracks):
|
|
153
|
+
raise IndexError(
|
|
154
|
+
"Track index %d out of range (0..%d)"
|
|
155
|
+
% (track_index, len(tracks) - 1)
|
|
156
|
+
)
|
|
157
|
+
song.delete_track(track_index)
|
|
158
|
+
return {"deleted": track_index}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@register("duplicate_track")
|
|
162
|
+
def duplicate_track(song, params):
|
|
163
|
+
"""Duplicate a track by index."""
|
|
164
|
+
track_index = int(params["track_index"])
|
|
165
|
+
tracks = list(song.tracks)
|
|
166
|
+
if track_index < 0 or track_index >= len(tracks):
|
|
167
|
+
raise IndexError(
|
|
168
|
+
"Track index %d out of range (0..%d)"
|
|
169
|
+
% (track_index, len(tracks) - 1)
|
|
170
|
+
)
|
|
171
|
+
song.duplicate_track(track_index)
|
|
172
|
+
new_index = track_index + 1
|
|
173
|
+
return {"index": new_index, "name": list(song.tracks)[new_index].name}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@register("set_track_name")
|
|
177
|
+
def set_track_name(song, params):
|
|
178
|
+
"""Rename a track."""
|
|
179
|
+
track_index = int(params["track_index"])
|
|
180
|
+
track = get_track(song, track_index)
|
|
181
|
+
track.name = str(params["name"])
|
|
182
|
+
return {"index": track_index, "name": track.name}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@register("set_track_color")
|
|
186
|
+
def set_track_color(song, params):
|
|
187
|
+
"""Set a track's color."""
|
|
188
|
+
track_index = int(params["track_index"])
|
|
189
|
+
track = get_track(song, track_index)
|
|
190
|
+
track.color_index = int(params["color_index"])
|
|
191
|
+
return {"index": track_index, "color_index": track.color_index}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@register("set_track_mute")
|
|
195
|
+
def set_track_mute(song, params):
|
|
196
|
+
"""Mute or unmute a track."""
|
|
197
|
+
track_index = int(params["track_index"])
|
|
198
|
+
track = get_track(song, track_index)
|
|
199
|
+
track.mute = bool(params["mute"])
|
|
200
|
+
return {"index": track_index, "mute": track.mute}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@register("set_track_solo")
|
|
204
|
+
def set_track_solo(song, params):
|
|
205
|
+
"""Solo or unsolo a track."""
|
|
206
|
+
track_index = int(params["track_index"])
|
|
207
|
+
track = get_track(song, track_index)
|
|
208
|
+
track.solo = bool(params["solo"])
|
|
209
|
+
return {"index": track_index, "solo": track.solo}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@register("set_track_arm")
|
|
213
|
+
def set_track_arm(song, params):
|
|
214
|
+
"""Arm or disarm a track for recording."""
|
|
215
|
+
track_index = int(params["track_index"])
|
|
216
|
+
if track_index < 0:
|
|
217
|
+
raise ValueError("Cannot arm a return track")
|
|
218
|
+
track = get_track(song, track_index)
|
|
219
|
+
track.arm = bool(params["arm"])
|
|
220
|
+
return {"index": track_index, "arm": track.arm}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@register("stop_track_clips")
|
|
224
|
+
def stop_track_clips(song, params):
|
|
225
|
+
"""Stop all clips on a track."""
|
|
226
|
+
track_index = int(params["track_index"])
|
|
227
|
+
track = get_track(song, track_index)
|
|
228
|
+
track.stop_all_clips()
|
|
229
|
+
return {"index": track_index, "stopped": True}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LivePilot - Transport domain handlers (10 commands).
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .router import register
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@register("get_session_info")
|
|
9
|
+
def get_session_info(song, params):
|
|
10
|
+
"""Return comprehensive session state."""
|
|
11
|
+
tracks_info = []
|
|
12
|
+
for i, track in enumerate(song.tracks):
|
|
13
|
+
tracks_info.append({
|
|
14
|
+
"index": i,
|
|
15
|
+
"name": track.name,
|
|
16
|
+
"color_index": track.color_index,
|
|
17
|
+
"has_midi_input": track.has_midi_input,
|
|
18
|
+
"has_audio_input": track.has_audio_input,
|
|
19
|
+
"mute": track.mute,
|
|
20
|
+
"solo": track.solo,
|
|
21
|
+
"arm": track.arm,
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
return_tracks_info = []
|
|
25
|
+
for i, track in enumerate(song.return_tracks):
|
|
26
|
+
return_tracks_info.append({
|
|
27
|
+
"index": i,
|
|
28
|
+
"name": track.name,
|
|
29
|
+
"color_index": track.color_index,
|
|
30
|
+
"mute": track.mute,
|
|
31
|
+
"solo": track.solo,
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
scenes_info = []
|
|
35
|
+
for i, scene in enumerate(song.scenes):
|
|
36
|
+
scenes_info.append({
|
|
37
|
+
"index": i,
|
|
38
|
+
"name": scene.name,
|
|
39
|
+
"color_index": scene.color_index,
|
|
40
|
+
"tempo": scene.tempo if scene.tempo > 0 else None,
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
"tempo": song.tempo,
|
|
45
|
+
"signature_numerator": song.signature_numerator,
|
|
46
|
+
"signature_denominator": song.signature_denominator,
|
|
47
|
+
"is_playing": song.is_playing,
|
|
48
|
+
"song_length": song.song_length,
|
|
49
|
+
"current_song_time": song.current_song_time,
|
|
50
|
+
"loop": song.loop,
|
|
51
|
+
"loop_start": song.loop_start,
|
|
52
|
+
"loop_length": song.loop_length,
|
|
53
|
+
"metronome": song.metronome,
|
|
54
|
+
"record_mode": song.record_mode,
|
|
55
|
+
"session_record": song.session_record,
|
|
56
|
+
"track_count": len(list(song.tracks)),
|
|
57
|
+
"return_track_count": len(list(song.return_tracks)),
|
|
58
|
+
"scene_count": len(list(song.scenes)),
|
|
59
|
+
"tracks": tracks_info,
|
|
60
|
+
"return_tracks": return_tracks_info,
|
|
61
|
+
"scenes": scenes_info,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@register("set_tempo")
|
|
66
|
+
def set_tempo(song, params):
|
|
67
|
+
"""Set the song tempo in BPM."""
|
|
68
|
+
tempo = float(params["tempo"])
|
|
69
|
+
if tempo < 20 or tempo > 999:
|
|
70
|
+
raise ValueError("Tempo must be between 20 and 999 BPM")
|
|
71
|
+
song.tempo = tempo
|
|
72
|
+
return {"tempo": song.tempo}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@register("set_time_signature")
|
|
76
|
+
def set_time_signature(song, params):
|
|
77
|
+
"""Set the song time signature."""
|
|
78
|
+
numerator = int(params["numerator"])
|
|
79
|
+
denominator = int(params["denominator"])
|
|
80
|
+
if numerator < 1 or numerator > 99:
|
|
81
|
+
raise ValueError("Numerator must be between 1 and 99")
|
|
82
|
+
if denominator not in (1, 2, 4, 8, 16):
|
|
83
|
+
raise ValueError("Denominator must be 1, 2, 4, 8, or 16")
|
|
84
|
+
song.signature_numerator = numerator
|
|
85
|
+
song.signature_denominator = denominator
|
|
86
|
+
return {
|
|
87
|
+
"signature_numerator": song.signature_numerator,
|
|
88
|
+
"signature_denominator": song.signature_denominator,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@register("start_playback")
|
|
93
|
+
def start_playback(song, params):
|
|
94
|
+
"""Start playback from the beginning."""
|
|
95
|
+
song.start_playing()
|
|
96
|
+
return {"is_playing": True}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@register("stop_playback")
|
|
100
|
+
def stop_playback(song, params):
|
|
101
|
+
"""Stop playback."""
|
|
102
|
+
song.stop_playing()
|
|
103
|
+
return {"is_playing": False}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@register("continue_playback")
|
|
107
|
+
def continue_playback(song, params):
|
|
108
|
+
"""Continue playback from the current position."""
|
|
109
|
+
song.continue_playing()
|
|
110
|
+
return {"is_playing": True}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@register("toggle_metronome")
|
|
114
|
+
def toggle_metronome(song, params):
|
|
115
|
+
"""Enable or disable the metronome."""
|
|
116
|
+
enabled = bool(params["enabled"])
|
|
117
|
+
song.metronome = enabled
|
|
118
|
+
return {"metronome": song.metronome}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@register("set_session_loop")
|
|
122
|
+
def set_session_loop(song, params):
|
|
123
|
+
"""Enable/disable loop and optionally set loop start/length."""
|
|
124
|
+
song.loop = bool(params["enabled"])
|
|
125
|
+
if "loop_start" in params:
|
|
126
|
+
song.loop_start = float(params["loop_start"])
|
|
127
|
+
if "loop_length" in params:
|
|
128
|
+
song.loop_length = float(params["loop_length"])
|
|
129
|
+
return {
|
|
130
|
+
"loop": song.loop,
|
|
131
|
+
"loop_start": song.loop_start,
|
|
132
|
+
"loop_length": song.loop_length,
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@register("undo")
|
|
137
|
+
def undo(song, params):
|
|
138
|
+
"""Undo the last action."""
|
|
139
|
+
song.undo()
|
|
140
|
+
return {"undone": True}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@register("redo")
|
|
144
|
+
def redo(song, params):
|
|
145
|
+
"""Redo the last undone action."""
|
|
146
|
+
song.redo()
|
|
147
|
+
return {"redone": True}
|