livepilot 1.0.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/CHANGELOG.md +33 -0
- package/LICENSE +21 -0
- package/README.md +409 -0
- package/bin/livepilot.js +390 -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 +210 -0
- package/mcp_server/memory/__init__.py +5 -0
- package/mcp_server/memory/technique_store.py +296 -0
- package/mcp_server/server.py +87 -0
- package/mcp_server/tools/__init__.py +1 -0
- package/mcp_server/tools/arrangement.py +407 -0
- package/mcp_server/tools/browser.py +86 -0
- package/mcp_server/tools/clips.py +218 -0
- package/mcp_server/tools/devices.py +256 -0
- package/mcp_server/tools/memory.py +198 -0
- package/mcp_server/tools/mixing.py +121 -0
- package/mcp_server/tools/notes.py +269 -0
- package/mcp_server/tools/scenes.py +89 -0
- package/mcp_server/tools/tracks.py +175 -0
- package/mcp_server/tools/transport.py +117 -0
- package/package.json +37 -0
- package/plugin/agents/livepilot-producer/AGENT.md +62 -0
- package/plugin/commands/beat.md +18 -0
- package/plugin/commands/memory.md +22 -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 +19 -0
- package/plugin/skills/livepilot-core/SKILL.md +208 -0
- package/plugin/skills/livepilot-core/references/ableton-workflow-patterns.md +831 -0
- package/plugin/skills/livepilot-core/references/device-atlas/00-index.md +110 -0
- package/plugin/skills/livepilot-core/references/device-atlas/distortion-and-character.md +687 -0
- package/plugin/skills/livepilot-core/references/device-atlas/drums-and-percussion.md +753 -0
- package/plugin/skills/livepilot-core/references/device-atlas/dynamics-and-punch.md +525 -0
- package/plugin/skills/livepilot-core/references/device-atlas/eq-and-filtering.md +402 -0
- package/plugin/skills/livepilot-core/references/device-atlas/midi-tools.md +963 -0
- package/plugin/skills/livepilot-core/references/device-atlas/movement-and-modulation.md +874 -0
- package/plugin/skills/livepilot-core/references/device-atlas/space-and-depth.md +571 -0
- package/plugin/skills/livepilot-core/references/device-atlas/spectral-and-weird.md +714 -0
- package/plugin/skills/livepilot-core/references/device-atlas/synths-native.md +953 -0
- package/plugin/skills/livepilot-core/references/m4l-devices.md +352 -0
- package/plugin/skills/livepilot-core/references/memory-guide.md +107 -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 +209 -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 +693 -0
- package/remote_script/LivePilot/browser.py +424 -0
- package/remote_script/LivePilot/clips.py +211 -0
- package/remote_script/LivePilot/devices.py +596 -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 +99 -0
- package/remote_script/LivePilot/server.py +293 -0
- package/remote_script/LivePilot/tracks.py +268 -0
- package/remote_script/LivePilot/transport.py +151 -0
- package/remote_script/LivePilot/utils.py +123 -0
- package/requirements.txt +2 -0
|
@@ -0,0 +1,293 @@
|
|
|
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
|
+
"set_group_fold", "set_track_input_monitoring",
|
|
28
|
+
# clips
|
|
29
|
+
"create_clip", "delete_clip", "duplicate_clip", "fire_clip", "stop_clip",
|
|
30
|
+
"set_clip_name", "set_clip_color", "set_clip_loop", "set_clip_launch",
|
|
31
|
+
"set_clip_warp_mode",
|
|
32
|
+
# notes
|
|
33
|
+
"add_notes", "remove_notes", "remove_notes_by_id", "modify_notes",
|
|
34
|
+
"duplicate_notes", "transpose_notes", "quantize_clip",
|
|
35
|
+
# devices
|
|
36
|
+
"set_device_parameter", "batch_set_parameters", "toggle_device",
|
|
37
|
+
"delete_device", "load_device_by_uri", "find_and_load_device",
|
|
38
|
+
"set_chain_volume", "set_simpler_playback_mode",
|
|
39
|
+
# scenes
|
|
40
|
+
"create_scene", "delete_scene", "duplicate_scene", "fire_scene",
|
|
41
|
+
"set_scene_name", "set_scene_color", "set_scene_tempo",
|
|
42
|
+
# mixing
|
|
43
|
+
"set_track_volume", "set_track_pan", "set_track_send",
|
|
44
|
+
"set_master_volume", "set_track_routing",
|
|
45
|
+
# browser
|
|
46
|
+
"load_browser_item",
|
|
47
|
+
# arrangement
|
|
48
|
+
"jump_to_time", "jump_to_cue", "capture_midi", "start_recording",
|
|
49
|
+
"stop_recording", "toggle_cue_point", "back_to_arranger",
|
|
50
|
+
"create_arrangement_clip", "add_arrangement_notes",
|
|
51
|
+
"remove_arrangement_notes", "remove_arrangement_notes_by_id",
|
|
52
|
+
"modify_arrangement_notes", "duplicate_arrangement_notes",
|
|
53
|
+
"transpose_arrangement_notes", "set_arrangement_automation",
|
|
54
|
+
"set_arrangement_clip_name",
|
|
55
|
+
])
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class LivePilotServer(object):
|
|
59
|
+
"""TCP server that bridges JSON commands to Ableton's main thread.
|
|
60
|
+
|
|
61
|
+
Single-client by design: only one client can be connected at a time.
|
|
62
|
+
All commands must execute on Ableton's main thread (Live Object Model
|
|
63
|
+
is not thread-safe), so serialized client access prevents race conditions.
|
|
64
|
+
Additional connection attempts are rejected with a clear error message.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(self, control_surface, host="127.0.0.1", port=9878):
|
|
68
|
+
self._cs = control_surface
|
|
69
|
+
self._host = host
|
|
70
|
+
self._port = port
|
|
71
|
+
self._running = False
|
|
72
|
+
self._server_socket = None
|
|
73
|
+
self._thread = None
|
|
74
|
+
self._command_queue = queue.Queue()
|
|
75
|
+
self._client_lock = threading.Lock()
|
|
76
|
+
self._client_connected = False
|
|
77
|
+
|
|
78
|
+
# ── Public API ───────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
def start(self):
|
|
81
|
+
"""Start the background listener thread."""
|
|
82
|
+
self._running = True
|
|
83
|
+
self._thread = threading.Thread(target=self._server_loop)
|
|
84
|
+
self._thread.daemon = True
|
|
85
|
+
self._thread.start()
|
|
86
|
+
self._log("Server started on %s:%d" % (self._host, self._port))
|
|
87
|
+
|
|
88
|
+
def stop(self):
|
|
89
|
+
"""Shutdown the server gracefully."""
|
|
90
|
+
self._running = False
|
|
91
|
+
if self._server_socket:
|
|
92
|
+
try:
|
|
93
|
+
self._server_socket.close()
|
|
94
|
+
except OSError:
|
|
95
|
+
pass
|
|
96
|
+
if self._thread and self._thread.is_alive():
|
|
97
|
+
self._thread.join(timeout=3)
|
|
98
|
+
self._log("Server stopped")
|
|
99
|
+
|
|
100
|
+
# ── Logging ──────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
def _log(self, message):
|
|
103
|
+
try:
|
|
104
|
+
self._cs.log_message("[LivePilot] " + str(message))
|
|
105
|
+
except Exception:
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
# ── Background thread ────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
def _server_loop(self):
|
|
111
|
+
"""Runs in a daemon thread. Accepts one client at a time."""
|
|
112
|
+
try:
|
|
113
|
+
self._server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
114
|
+
self._server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
115
|
+
self._server_socket.bind((self._host, self._port))
|
|
116
|
+
self._server_socket.listen(2)
|
|
117
|
+
self._server_socket.settimeout(1.0)
|
|
118
|
+
self._log("Listening on %s:%d" % (self._host, self._port))
|
|
119
|
+
except OSError as exc:
|
|
120
|
+
self._log("Failed to bind: %s" % exc)
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
while self._running:
|
|
124
|
+
try:
|
|
125
|
+
client, addr = self._server_socket.accept()
|
|
126
|
+
with self._client_lock:
|
|
127
|
+
if self._client_connected:
|
|
128
|
+
# Reject concurrent clients with an explicit message
|
|
129
|
+
self._log("Rejected client from %s:%d (another client is connected)" % addr)
|
|
130
|
+
try:
|
|
131
|
+
reject = json.dumps({
|
|
132
|
+
"id": "system",
|
|
133
|
+
"ok": False,
|
|
134
|
+
"error": {
|
|
135
|
+
"code": "STATE_ERROR",
|
|
136
|
+
"message": "Another client is already connected. "
|
|
137
|
+
"LivePilot accepts one client at a time. "
|
|
138
|
+
"Disconnect the current client first."
|
|
139
|
+
}
|
|
140
|
+
}) + "\n"
|
|
141
|
+
client.sendall(reject.encode("utf-8"))
|
|
142
|
+
except OSError:
|
|
143
|
+
pass
|
|
144
|
+
try:
|
|
145
|
+
client.close()
|
|
146
|
+
except OSError:
|
|
147
|
+
pass
|
|
148
|
+
continue
|
|
149
|
+
self._client_connected = True
|
|
150
|
+
self._log("Client connected from %s:%d" % addr)
|
|
151
|
+
try:
|
|
152
|
+
self._handle_client(client)
|
|
153
|
+
except OSError as exc:
|
|
154
|
+
self._log("Client error: %s" % exc)
|
|
155
|
+
finally:
|
|
156
|
+
with self._client_lock:
|
|
157
|
+
self._client_connected = False
|
|
158
|
+
try:
|
|
159
|
+
client.close()
|
|
160
|
+
except OSError:
|
|
161
|
+
pass
|
|
162
|
+
self._log("Client disconnected")
|
|
163
|
+
except socket.timeout:
|
|
164
|
+
continue
|
|
165
|
+
except OSError:
|
|
166
|
+
if self._running:
|
|
167
|
+
self._log("Accept error")
|
|
168
|
+
break
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
self._server_socket.close()
|
|
172
|
+
except OSError:
|
|
173
|
+
pass
|
|
174
|
+
|
|
175
|
+
def _handle_client(self, client):
|
|
176
|
+
"""Read newline-delimited JSON from a connected client."""
|
|
177
|
+
client.settimeout(1.0)
|
|
178
|
+
buf = ""
|
|
179
|
+
while self._running:
|
|
180
|
+
try:
|
|
181
|
+
data = client.recv(4096)
|
|
182
|
+
if not data:
|
|
183
|
+
break
|
|
184
|
+
buf += data.decode("utf-8", errors="replace")
|
|
185
|
+
while "\n" in buf:
|
|
186
|
+
line, buf = buf.split("\n", 1)
|
|
187
|
+
line = line.strip()
|
|
188
|
+
if line:
|
|
189
|
+
self._process_line(client, line)
|
|
190
|
+
except socket.timeout:
|
|
191
|
+
continue
|
|
192
|
+
except OSError as exc:
|
|
193
|
+
self._log("Recv error: %s" % exc)
|
|
194
|
+
break
|
|
195
|
+
|
|
196
|
+
def _process_line(self, client, line):
|
|
197
|
+
"""Parse one JSON command, queue it for main thread, wait for result."""
|
|
198
|
+
try:
|
|
199
|
+
command = json.loads(line)
|
|
200
|
+
except (ValueError, TypeError) as exc:
|
|
201
|
+
resp = {
|
|
202
|
+
"id": "unknown",
|
|
203
|
+
"ok": False,
|
|
204
|
+
"error": {"code": "INVALID_PARAM", "message": "Bad JSON: %s" % exc},
|
|
205
|
+
}
|
|
206
|
+
self._send(client, resp)
|
|
207
|
+
return
|
|
208
|
+
|
|
209
|
+
request_id = command.get("id", "unknown")
|
|
210
|
+
cmd_type = command.get("type", "")
|
|
211
|
+
|
|
212
|
+
# Determine timeout based on read vs write
|
|
213
|
+
is_write = cmd_type in WRITE_COMMANDS
|
|
214
|
+
timeout = 15 if is_write else 10
|
|
215
|
+
|
|
216
|
+
# Per-command response queue
|
|
217
|
+
response_queue = queue.Queue()
|
|
218
|
+
self._command_queue.put((command, response_queue))
|
|
219
|
+
|
|
220
|
+
# Schedule processing on Ableton's main thread
|
|
221
|
+
try:
|
|
222
|
+
self._cs.schedule_message(0, self._process_next_command)
|
|
223
|
+
except AssertionError:
|
|
224
|
+
# Already on main thread — process directly
|
|
225
|
+
self._process_next_command()
|
|
226
|
+
|
|
227
|
+
# Wait for response from main thread
|
|
228
|
+
try:
|
|
229
|
+
resp = response_queue.get(timeout=timeout)
|
|
230
|
+
except queue.Empty:
|
|
231
|
+
resp = {
|
|
232
|
+
"id": request_id,
|
|
233
|
+
"ok": False,
|
|
234
|
+
"error": {"code": "TIMEOUT", "message": "Command timed out after %ds" % timeout},
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
self._send(client, resp)
|
|
238
|
+
|
|
239
|
+
# ── Main thread execution ────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
def _process_next_command(self):
|
|
242
|
+
"""Called on Ableton's main thread via schedule_message.
|
|
243
|
+
Processes one command from the queue."""
|
|
244
|
+
try:
|
|
245
|
+
command, response_queue = self._command_queue.get_nowait()
|
|
246
|
+
except queue.Empty:
|
|
247
|
+
return
|
|
248
|
+
|
|
249
|
+
cmd_type = command.get("type", "")
|
|
250
|
+
is_write = cmd_type in WRITE_COMMANDS
|
|
251
|
+
|
|
252
|
+
try:
|
|
253
|
+
song = self._cs.song()
|
|
254
|
+
result = router.dispatch(song, command)
|
|
255
|
+
except Exception as exc:
|
|
256
|
+
result = {
|
|
257
|
+
"id": command.get("id", "unknown"),
|
|
258
|
+
"ok": False,
|
|
259
|
+
"error": {"code": "INTERNAL", "message": str(exc)},
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if is_write:
|
|
263
|
+
# Schedule response after 100ms settle delay for write operations
|
|
264
|
+
def send_response():
|
|
265
|
+
response_queue.put(result)
|
|
266
|
+
# Drain any remaining queued commands
|
|
267
|
+
self._drain_queue()
|
|
268
|
+
try:
|
|
269
|
+
self._cs.schedule_message(1, send_response) # ~100ms
|
|
270
|
+
except AssertionError:
|
|
271
|
+
send_response()
|
|
272
|
+
else:
|
|
273
|
+
response_queue.put(result)
|
|
274
|
+
# Drain any remaining queued commands
|
|
275
|
+
self._drain_queue()
|
|
276
|
+
|
|
277
|
+
def _drain_queue(self):
|
|
278
|
+
"""Process any remaining commands in the queue."""
|
|
279
|
+
if not self._command_queue.empty():
|
|
280
|
+
try:
|
|
281
|
+
self._cs.schedule_message(0, self._process_next_command)
|
|
282
|
+
except AssertionError:
|
|
283
|
+
self._process_next_command()
|
|
284
|
+
|
|
285
|
+
# ── Socket I/O ───────────────────────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
def _send(self, client, response):
|
|
288
|
+
"""Send a JSON response to the client."""
|
|
289
|
+
from .utils import serialize_json
|
|
290
|
+
try:
|
|
291
|
+
client.sendall(serialize_json(response).encode("utf-8"))
|
|
292
|
+
except OSError as exc:
|
|
293
|
+
self._log("Send error: %s" % exc)
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LivePilot - Track domain handlers (14 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
|
+
"is_foldable": track.is_foldable,
|
|
84
|
+
"is_grouped": track.is_grouped,
|
|
85
|
+
"clip_slots": clips,
|
|
86
|
+
"devices": devices,
|
|
87
|
+
"mixer": mixer,
|
|
88
|
+
"sends": sends,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if track.is_foldable:
|
|
92
|
+
result["fold_state"] = bool(track.fold_state)
|
|
93
|
+
|
|
94
|
+
# Regular tracks have arm and input type; return tracks get null values
|
|
95
|
+
if track_index >= 0:
|
|
96
|
+
result["arm"] = track.arm
|
|
97
|
+
result["has_midi_input"] = track.has_midi_input
|
|
98
|
+
result["has_audio_input"] = track.has_audio_input
|
|
99
|
+
result["current_monitoring_state"] = track.current_monitoring_state
|
|
100
|
+
else:
|
|
101
|
+
result["arm"] = None
|
|
102
|
+
result["has_midi_input"] = None
|
|
103
|
+
result["has_audio_input"] = None
|
|
104
|
+
result["is_return_track"] = True
|
|
105
|
+
|
|
106
|
+
return result
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@register("create_midi_track")
|
|
110
|
+
def create_midi_track(song, params):
|
|
111
|
+
"""Create a new MIDI track at the given index."""
|
|
112
|
+
index = int(params.get("index", -1))
|
|
113
|
+
song.create_midi_track(index)
|
|
114
|
+
# The new track is at the requested index (or end if -1)
|
|
115
|
+
if index == -1:
|
|
116
|
+
new_index = len(list(song.tracks)) - 1
|
|
117
|
+
else:
|
|
118
|
+
new_index = index
|
|
119
|
+
track = list(song.tracks)[new_index]
|
|
120
|
+
if "name" in params:
|
|
121
|
+
track.name = str(params["name"])
|
|
122
|
+
if "color_index" in params:
|
|
123
|
+
track.color_index = int(params["color_index"])
|
|
124
|
+
return {"index": new_index, "name": track.name}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@register("create_audio_track")
|
|
128
|
+
def create_audio_track(song, params):
|
|
129
|
+
"""Create a new audio track at the given index."""
|
|
130
|
+
index = int(params.get("index", -1))
|
|
131
|
+
song.create_audio_track(index)
|
|
132
|
+
if index == -1:
|
|
133
|
+
new_index = len(list(song.tracks)) - 1
|
|
134
|
+
else:
|
|
135
|
+
new_index = index
|
|
136
|
+
track = list(song.tracks)[new_index]
|
|
137
|
+
if "name" in params:
|
|
138
|
+
track.name = str(params["name"])
|
|
139
|
+
if "color_index" in params:
|
|
140
|
+
track.color_index = int(params["color_index"])
|
|
141
|
+
return {"index": new_index, "name": track.name}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@register("create_return_track")
|
|
145
|
+
def create_return_track(song, params):
|
|
146
|
+
"""Create a new return track."""
|
|
147
|
+
song.create_return_track()
|
|
148
|
+
return_tracks = list(song.return_tracks)
|
|
149
|
+
new_index = len(return_tracks) - 1
|
|
150
|
+
return {"index": new_index, "name": return_tracks[new_index].name}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@register("delete_track")
|
|
154
|
+
def delete_track(song, params):
|
|
155
|
+
"""Delete a track by index."""
|
|
156
|
+
track_index = int(params["track_index"])
|
|
157
|
+
tracks = list(song.tracks)
|
|
158
|
+
if track_index < 0 or track_index >= len(tracks):
|
|
159
|
+
raise IndexError(
|
|
160
|
+
"Track index %d out of range (0..%d)"
|
|
161
|
+
% (track_index, len(tracks) - 1)
|
|
162
|
+
)
|
|
163
|
+
song.delete_track(track_index)
|
|
164
|
+
return {"deleted": track_index}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@register("duplicate_track")
|
|
168
|
+
def duplicate_track(song, params):
|
|
169
|
+
"""Duplicate a track by index."""
|
|
170
|
+
track_index = int(params["track_index"])
|
|
171
|
+
tracks = list(song.tracks)
|
|
172
|
+
if track_index < 0 or track_index >= len(tracks):
|
|
173
|
+
raise IndexError(
|
|
174
|
+
"Track index %d out of range (0..%d)"
|
|
175
|
+
% (track_index, len(tracks) - 1)
|
|
176
|
+
)
|
|
177
|
+
song.duplicate_track(track_index)
|
|
178
|
+
new_index = track_index + 1
|
|
179
|
+
return {"index": new_index, "name": list(song.tracks)[new_index].name}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@register("set_track_name")
|
|
183
|
+
def set_track_name(song, params):
|
|
184
|
+
"""Rename a track."""
|
|
185
|
+
track_index = int(params["track_index"])
|
|
186
|
+
track = get_track(song, track_index)
|
|
187
|
+
track.name = str(params["name"])
|
|
188
|
+
return {"index": track_index, "name": track.name}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@register("set_track_color")
|
|
192
|
+
def set_track_color(song, params):
|
|
193
|
+
"""Set a track's color."""
|
|
194
|
+
track_index = int(params["track_index"])
|
|
195
|
+
track = get_track(song, track_index)
|
|
196
|
+
track.color_index = int(params["color_index"])
|
|
197
|
+
return {"index": track_index, "color_index": track.color_index}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@register("set_track_mute")
|
|
201
|
+
def set_track_mute(song, params):
|
|
202
|
+
"""Mute or unmute a track."""
|
|
203
|
+
track_index = int(params["track_index"])
|
|
204
|
+
track = get_track(song, track_index)
|
|
205
|
+
track.mute = bool(params["mute"])
|
|
206
|
+
return {"index": track_index, "mute": track.mute}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@register("set_track_solo")
|
|
210
|
+
def set_track_solo(song, params):
|
|
211
|
+
"""Solo or unsolo a track."""
|
|
212
|
+
track_index = int(params["track_index"])
|
|
213
|
+
track = get_track(song, track_index)
|
|
214
|
+
track.solo = bool(params["solo"])
|
|
215
|
+
return {"index": track_index, "solo": track.solo}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@register("set_track_arm")
|
|
219
|
+
def set_track_arm(song, params):
|
|
220
|
+
"""Arm or disarm a track for recording."""
|
|
221
|
+
track_index = int(params["track_index"])
|
|
222
|
+
if track_index < 0:
|
|
223
|
+
raise ValueError("Cannot arm a return track")
|
|
224
|
+
track = get_track(song, track_index)
|
|
225
|
+
track.arm = bool(params["arm"])
|
|
226
|
+
return {"index": track_index, "arm": track.arm}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@register("stop_track_clips")
|
|
230
|
+
def stop_track_clips(song, params):
|
|
231
|
+
"""Stop all clips on a track."""
|
|
232
|
+
track_index = int(params["track_index"])
|
|
233
|
+
track = get_track(song, track_index)
|
|
234
|
+
track.stop_all_clips()
|
|
235
|
+
return {"index": track_index, "stopped": True}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@register("set_group_fold")
|
|
239
|
+
def set_group_fold(song, params):
|
|
240
|
+
"""Fold or unfold a group track."""
|
|
241
|
+
track_index = int(params["track_index"])
|
|
242
|
+
track = get_track(song, track_index)
|
|
243
|
+
if not track.is_foldable:
|
|
244
|
+
raise ValueError("Track %d is not a group track" % track_index)
|
|
245
|
+
track.fold_state = int(bool(params["folded"]))
|
|
246
|
+
return {
|
|
247
|
+
"index": track_index,
|
|
248
|
+
"folded": bool(track.fold_state),
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@register("set_track_input_monitoring")
|
|
253
|
+
def set_track_input_monitoring(song, params):
|
|
254
|
+
"""Set input monitoring state for a track (0=In, 1=Auto, 2=Off)."""
|
|
255
|
+
track_index = int(params["track_index"])
|
|
256
|
+
if track_index < 0:
|
|
257
|
+
raise ValueError("Cannot set input monitoring on a return track")
|
|
258
|
+
track = get_track(song, track_index)
|
|
259
|
+
state = int(params["state"])
|
|
260
|
+
if state not in (0, 1, 2):
|
|
261
|
+
raise ValueError(
|
|
262
|
+
"Invalid monitoring state %d. Valid: 0=In, 1=Auto, 2=Off" % state
|
|
263
|
+
)
|
|
264
|
+
track.current_monitoring_state = state
|
|
265
|
+
return {
|
|
266
|
+
"index": track_index,
|
|
267
|
+
"monitoring_state": track.current_monitoring_state,
|
|
268
|
+
}
|