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.
Files changed (63) hide show
  1. package/.claude/settings.local.json +10 -0
  2. package/.mcpregistry_github_token +1 -0
  3. package/.mcpregistry_registry_token +1 -0
  4. package/.playwright-mcp/console-2026-03-17T15-47-29-021Z.log +10 -0
  5. package/.playwright-mcp/console-2026-03-17T15-51-09-247Z.log +10 -0
  6. package/.playwright-mcp/console-2026-03-17T15-52-22-831Z.log +12 -0
  7. package/.playwright-mcp/console-2026-03-17T15-52-29-709Z.log +10 -0
  8. package/.playwright-mcp/console-2026-03-17T15-53-20-147Z.log +1 -0
  9. package/.playwright-mcp/glama-snapshot.md +2140 -0
  10. package/.playwright-mcp/page-2026-03-17T15-49-02-625Z.png +0 -0
  11. package/.playwright-mcp/page-2026-03-17T15-52-15-149Z.png +0 -0
  12. package/.playwright-mcp/page-2026-03-17T15-52-57-333Z.png +0 -0
  13. package/CHANGELOG.md +33 -0
  14. package/LICENSE +21 -0
  15. package/README.md +296 -0
  16. package/bin/livepilot.js +376 -0
  17. package/installer/install.js +95 -0
  18. package/installer/paths.js +79 -0
  19. package/mcp_server/__init__.py +2 -0
  20. package/mcp_server/__main__.py +5 -0
  21. package/mcp_server/connection.py +207 -0
  22. package/mcp_server/server.py +40 -0
  23. package/mcp_server/tools/__init__.py +1 -0
  24. package/mcp_server/tools/arrangement.py +399 -0
  25. package/mcp_server/tools/browser.py +78 -0
  26. package/mcp_server/tools/clips.py +187 -0
  27. package/mcp_server/tools/devices.py +238 -0
  28. package/mcp_server/tools/mixing.py +113 -0
  29. package/mcp_server/tools/notes.py +266 -0
  30. package/mcp_server/tools/scenes.py +63 -0
  31. package/mcp_server/tools/tracks.py +148 -0
  32. package/mcp_server/tools/transport.py +113 -0
  33. package/package.json +38 -0
  34. package/plugin/.mcp.json +8 -0
  35. package/plugin/agents/livepilot-producer/AGENT.md +61 -0
  36. package/plugin/commands/beat.md +18 -0
  37. package/plugin/commands/mix.md +15 -0
  38. package/plugin/commands/session.md +13 -0
  39. package/plugin/commands/sounddesign.md +16 -0
  40. package/plugin/plugin.json +18 -0
  41. package/plugin/skills/livepilot-core/SKILL.md +160 -0
  42. package/plugin/skills/livepilot-core/references/ableton-workflow-patterns.md +831 -0
  43. package/plugin/skills/livepilot-core/references/m4l-devices.md +352 -0
  44. package/plugin/skills/livepilot-core/references/midi-recipes.md +402 -0
  45. package/plugin/skills/livepilot-core/references/mixing-patterns.md +578 -0
  46. package/plugin/skills/livepilot-core/references/overview.md +191 -0
  47. package/plugin/skills/livepilot-core/references/sound-design.md +392 -0
  48. package/remote_script/LivePilot/__init__.py +42 -0
  49. package/remote_script/LivePilot/arrangement.py +678 -0
  50. package/remote_script/LivePilot/browser.py +325 -0
  51. package/remote_script/LivePilot/clips.py +172 -0
  52. package/remote_script/LivePilot/devices.py +466 -0
  53. package/remote_script/LivePilot/diagnostics.py +198 -0
  54. package/remote_script/LivePilot/mixing.py +194 -0
  55. package/remote_script/LivePilot/notes.py +339 -0
  56. package/remote_script/LivePilot/router.py +74 -0
  57. package/remote_script/LivePilot/scenes.py +75 -0
  58. package/remote_script/LivePilot/server.py +286 -0
  59. package/remote_script/LivePilot/tracks.py +229 -0
  60. package/remote_script/LivePilot/transport.py +147 -0
  61. package/remote_script/LivePilot/utils.py +112 -0
  62. package/requirements.txt +2 -0
  63. 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}