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,194 @@
1
+ """
2
+ LivePilot - Mixing domain handlers (8 commands).
3
+ """
4
+
5
+ from .router import register
6
+ from .utils import get_track
7
+
8
+
9
+ @register("set_track_volume")
10
+ def set_track_volume(song, params):
11
+ """Set the volume of a track."""
12
+ track_index = int(params["track_index"])
13
+ track = get_track(song, track_index)
14
+ volume = float(params["volume"])
15
+ track.mixer_device.volume.value = volume
16
+ return {"index": track_index, "volume": track.mixer_device.volume.value}
17
+
18
+
19
+ @register("set_track_pan")
20
+ def set_track_pan(song, params):
21
+ """Set the panning of a track."""
22
+ track_index = int(params["track_index"])
23
+ track = get_track(song, track_index)
24
+ pan = float(params["pan"])
25
+ track.mixer_device.panning.value = pan
26
+ return {"index": track_index, "pan": track.mixer_device.panning.value}
27
+
28
+
29
+ @register("set_track_send")
30
+ def set_track_send(song, params):
31
+ """Set a send value on a track."""
32
+ track_index = int(params["track_index"])
33
+ track = get_track(song, track_index)
34
+ send_index = int(params["send_index"])
35
+ sends = list(track.mixer_device.sends)
36
+ if send_index < 0 or send_index >= len(sends):
37
+ raise IndexError(
38
+ "Send index %d out of range (0..%d)"
39
+ % (send_index, len(sends) - 1)
40
+ )
41
+ sends[send_index].value = float(params["value"])
42
+ return {
43
+ "index": track_index,
44
+ "send_index": send_index,
45
+ "value": sends[send_index].value,
46
+ }
47
+
48
+
49
+ @register("get_return_tracks")
50
+ def get_return_tracks(song, params):
51
+ """Return info about all return tracks."""
52
+ result = []
53
+ for i, track in enumerate(song.return_tracks):
54
+ result.append({
55
+ "index": i,
56
+ "name": track.name,
57
+ "color_index": track.color_index,
58
+ "volume": track.mixer_device.volume.value,
59
+ "panning": track.mixer_device.panning.value,
60
+ })
61
+ return {"return_tracks": result}
62
+
63
+
64
+ @register("get_master_track")
65
+ def get_master_track(song, params):
66
+ """Return info about the master track."""
67
+ master = song.master_track
68
+ devices = []
69
+ for i, device in enumerate(master.devices):
70
+ devices.append({
71
+ "index": i,
72
+ "name": device.name,
73
+ "class_name": device.class_name,
74
+ "is_active": device.is_active,
75
+ })
76
+ return {
77
+ "name": master.name,
78
+ "volume": master.mixer_device.volume.value,
79
+ "panning": master.mixer_device.panning.value,
80
+ "devices": devices,
81
+ }
82
+
83
+
84
+ @register("set_master_volume")
85
+ def set_master_volume(song, params):
86
+ """Set the master track volume."""
87
+ volume = float(params["volume"])
88
+ song.master_track.mixer_device.volume.value = volume
89
+ return {"volume": song.master_track.mixer_device.volume.value}
90
+
91
+
92
+ @register("get_track_routing")
93
+ def get_track_routing(song, params):
94
+ """Get the input/output routing for a track."""
95
+ track_index = int(params["track_index"])
96
+ track = get_track(song, track_index)
97
+ result = {"index": track_index}
98
+ try:
99
+ result["input_routing_type"] = track.input_routing_type.display_name
100
+ except Exception:
101
+ result["input_routing_type"] = None
102
+ try:
103
+ result["input_routing_channel"] = track.input_routing_channel.display_name
104
+ except Exception:
105
+ result["input_routing_channel"] = None
106
+ try:
107
+ result["output_routing_type"] = track.output_routing_type.display_name
108
+ except Exception:
109
+ result["output_routing_type"] = None
110
+ try:
111
+ result["output_routing_channel"] = track.output_routing_channel.display_name
112
+ except Exception:
113
+ result["output_routing_channel"] = None
114
+ return result
115
+
116
+
117
+ @register("set_track_routing")
118
+ def set_track_routing(song, params):
119
+ """Set input/output routing for a track by display name."""
120
+ track_index = int(params["track_index"])
121
+ track = get_track(song, track_index)
122
+ if not any(k in params for k in ("input_type", "input_channel", "output_type", "output_channel")):
123
+ raise ValueError("At least one routing parameter must be provided")
124
+ result = {"index": track_index}
125
+
126
+ if "input_type" in params:
127
+ name = str(params["input_type"])
128
+ available = list(track.available_input_routing_types)
129
+ matched = None
130
+ for rt in available:
131
+ if rt.display_name == name:
132
+ matched = rt
133
+ break
134
+ if matched is None:
135
+ options = [rt.display_name for rt in available]
136
+ raise ValueError(
137
+ "Input routing type '%s' not found. Available: %s"
138
+ % (name, ", ".join(options))
139
+ )
140
+ track.input_routing_type = matched
141
+ result["input_routing_type"] = track.input_routing_type.display_name
142
+
143
+ if "input_channel" in params:
144
+ name = str(params["input_channel"])
145
+ available = list(track.available_input_routing_channels)
146
+ matched = None
147
+ for ch in available:
148
+ if ch.display_name == name:
149
+ matched = ch
150
+ break
151
+ if matched is None:
152
+ options = [ch.display_name for ch in available]
153
+ raise ValueError(
154
+ "Input routing channel '%s' not found. Available: %s"
155
+ % (name, ", ".join(options))
156
+ )
157
+ track.input_routing_channel = matched
158
+ result["input_routing_channel"] = track.input_routing_channel.display_name
159
+
160
+ if "output_type" in params:
161
+ name = str(params["output_type"])
162
+ available = list(track.available_output_routing_types)
163
+ matched = None
164
+ for rt in available:
165
+ if rt.display_name == name:
166
+ matched = rt
167
+ break
168
+ if matched is None:
169
+ options = [rt.display_name for rt in available]
170
+ raise ValueError(
171
+ "Output routing type '%s' not found. Available: %s"
172
+ % (name, ", ".join(options))
173
+ )
174
+ track.output_routing_type = matched
175
+ result["output_routing_type"] = track.output_routing_type.display_name
176
+
177
+ if "output_channel" in params:
178
+ name = str(params["output_channel"])
179
+ available = list(track.available_output_routing_channels)
180
+ matched = None
181
+ for ch in available:
182
+ if ch.display_name == name:
183
+ matched = ch
184
+ break
185
+ if matched is None:
186
+ options = [ch.display_name for ch in available]
187
+ raise ValueError(
188
+ "Output routing channel '%s' not found. Available: %s"
189
+ % (name, ", ".join(options))
190
+ )
191
+ track.output_routing_channel = matched
192
+ result["output_routing_channel"] = track.output_routing_channel.display_name
193
+
194
+ return result
@@ -0,0 +1,339 @@
1
+ """
2
+ LivePilot - Notes domain handlers (8 commands).
3
+
4
+ Uses Live 12's modern note API: add_new_notes, get_notes_extended,
5
+ remove_notes_extended, remove_notes_by_id, apply_note_modifications.
6
+ """
7
+
8
+ from .router import register
9
+ from .utils import get_clip, get_track
10
+
11
+
12
+ @register("add_notes")
13
+ def add_notes(song, params):
14
+ """Add MIDI notes to a clip using Live 12's modern API."""
15
+ track_index = int(params["track_index"])
16
+ clip_index = int(params["clip_index"])
17
+ notes = params["notes"]
18
+ if not notes:
19
+ raise ValueError("notes list cannot be empty")
20
+
21
+ clip = get_clip(song, track_index, clip_index)
22
+ import Live
23
+ song.begin_undo_step()
24
+ try:
25
+ note_specs = []
26
+ for note in notes:
27
+ kwargs = dict(
28
+ pitch=int(note["pitch"]),
29
+ start_time=float(note["start_time"]),
30
+ duration=float(note["duration"]),
31
+ velocity=float(note.get("velocity", 100)),
32
+ mute=bool(note.get("mute", False)),
33
+ )
34
+ if "probability" in note:
35
+ kwargs["probability"] = float(note["probability"])
36
+ if "velocity_deviation" in note:
37
+ kwargs["velocity_deviation"] = float(note["velocity_deviation"])
38
+ if "release_velocity" in note:
39
+ kwargs["release_velocity"] = float(note["release_velocity"])
40
+ spec = Live.Clip.MidiNoteSpecification(**kwargs)
41
+ note_specs.append(spec)
42
+ clip.add_new_notes(tuple(note_specs))
43
+ finally:
44
+ song.end_undo_step()
45
+
46
+ return {
47
+ "track_index": track_index,
48
+ "clip_index": clip_index,
49
+ "notes_added": len(notes),
50
+ }
51
+
52
+
53
+ @register("get_notes")
54
+ def get_notes(song, params):
55
+ """Get MIDI notes from a clip region."""
56
+ track_index = int(params["track_index"])
57
+ clip_index = int(params["clip_index"])
58
+ clip = get_clip(song, track_index, clip_index)
59
+
60
+ from_pitch = int(params.get("from_pitch", 0))
61
+ pitch_span = int(params.get("pitch_span", 128))
62
+ from_time = float(params.get("from_time", 0.0))
63
+ # Default to clip length, but use a large span if clip has no length yet
64
+ default_span = clip.length if clip.length > 0 else 32768.0
65
+ time_span = float(params.get("time_span", default_span))
66
+
67
+ raw_notes = clip.get_notes_extended(from_pitch, pitch_span, from_time, time_span)
68
+
69
+ result = []
70
+ for note in raw_notes:
71
+ result.append({
72
+ "note_id": note.note_id,
73
+ "pitch": note.pitch,
74
+ "start_time": note.start_time,
75
+ "duration": note.duration,
76
+ "velocity": note.velocity,
77
+ "mute": note.mute,
78
+ "probability": note.probability,
79
+ "velocity_deviation": note.velocity_deviation,
80
+ "release_velocity": note.release_velocity,
81
+ })
82
+
83
+ return {
84
+ "track_index": track_index,
85
+ "clip_index": clip_index,
86
+ "notes": result,
87
+ }
88
+
89
+
90
+ @register("remove_notes")
91
+ def remove_notes(song, params):
92
+ """Remove MIDI notes from a clip region."""
93
+ track_index = int(params["track_index"])
94
+ clip_index = int(params["clip_index"])
95
+ clip = get_clip(song, track_index, clip_index)
96
+
97
+ from_pitch = int(params.get("from_pitch", 0))
98
+ pitch_span = int(params.get("pitch_span", 128))
99
+ from_time = float(params.get("from_time", 0.0))
100
+ default_span = clip.length if clip.length > 0 else 32768.0
101
+ time_span = float(params.get("time_span", default_span))
102
+
103
+ song.begin_undo_step()
104
+ try:
105
+ clip.remove_notes_extended(from_pitch, pitch_span, from_time, time_span)
106
+ finally:
107
+ song.end_undo_step()
108
+
109
+ return {
110
+ "track_index": track_index,
111
+ "clip_index": clip_index,
112
+ "removed": True,
113
+ }
114
+
115
+
116
+ @register("remove_notes_by_id")
117
+ def remove_notes_by_id(song, params):
118
+ """Remove specific MIDI notes by their IDs."""
119
+ track_index = int(params["track_index"])
120
+ clip_index = int(params["clip_index"])
121
+ note_ids = params["note_ids"]
122
+ if not note_ids:
123
+ raise ValueError("note_ids list cannot be empty")
124
+
125
+ clip = get_clip(song, track_index, clip_index)
126
+ song.begin_undo_step()
127
+ try:
128
+ clip.remove_notes_by_id(tuple(int(nid) for nid in note_ids))
129
+ finally:
130
+ song.end_undo_step()
131
+
132
+ return {
133
+ "track_index": track_index,
134
+ "clip_index": clip_index,
135
+ "removed_count": len(note_ids),
136
+ }
137
+
138
+
139
+ @register("modify_notes")
140
+ def modify_notes(song, params):
141
+ """Modify existing MIDI notes by ID."""
142
+ track_index = int(params["track_index"])
143
+ clip_index = int(params["clip_index"])
144
+ modifications = params["modifications"]
145
+ if not modifications:
146
+ raise ValueError("modifications list cannot be empty")
147
+
148
+ clip = get_clip(song, track_index, clip_index)
149
+
150
+ # Get all notes — returns a C++ NoteVector that must be passed back intact
151
+ all_notes = clip.get_notes_extended(0, 128, 0.0, clip.length + 1.0)
152
+
153
+ # Build a lookup by note_id
154
+ note_map = {}
155
+ for note in all_notes:
156
+ note_map[note.note_id] = note
157
+
158
+ # Apply modifications in-place on the original NoteVector's objects
159
+ modified_count = 0
160
+ for mod in modifications:
161
+ note_id = int(mod["note_id"])
162
+ if note_id not in note_map:
163
+ raise ValueError("Note ID %d not found in clip" % note_id)
164
+ note = note_map[note_id]
165
+ if "pitch" in mod:
166
+ note.pitch = int(mod["pitch"])
167
+ if "start_time" in mod:
168
+ note.start_time = float(mod["start_time"])
169
+ if "duration" in mod:
170
+ note.duration = float(mod["duration"])
171
+ if "velocity" in mod:
172
+ note.velocity = float(mod["velocity"])
173
+ if "probability" in mod:
174
+ note.probability = float(mod["probability"])
175
+ modified_count += 1
176
+
177
+ # Pass the original NoteVector back — Boost.Python requires the C++ type
178
+ song.begin_undo_step()
179
+ try:
180
+ clip.apply_note_modifications(all_notes)
181
+ finally:
182
+ song.end_undo_step()
183
+
184
+ return {
185
+ "track_index": track_index,
186
+ "clip_index": clip_index,
187
+ "modified_count": modified_count,
188
+ }
189
+
190
+
191
+ @register("duplicate_notes")
192
+ def duplicate_notes(song, params):
193
+ """Duplicate specific notes by ID, with optional time offset."""
194
+ track_index = int(params["track_index"])
195
+ clip_index = int(params["clip_index"])
196
+ note_ids = params["note_ids"]
197
+ time_offset = float(params.get("time_offset", 0.0))
198
+ if not note_ids:
199
+ raise ValueError("note_ids list cannot be empty")
200
+
201
+ clip = get_clip(song, track_index, clip_index)
202
+ note_id_set = set(int(nid) for nid in note_ids)
203
+
204
+ # Get all notes and filter to the requested IDs
205
+ all_notes = clip.get_notes_extended(0, 128, 0.0, clip.length + 1.0)
206
+ source_notes = []
207
+ for note in all_notes:
208
+ if note.note_id in note_id_set:
209
+ source_notes.append({
210
+ "pitch": note.pitch,
211
+ "start_time": note.start_time + time_offset,
212
+ "duration": note.duration,
213
+ "velocity": note.velocity,
214
+ "mute": note.mute,
215
+ "probability": note.probability,
216
+ "velocity_deviation": note.velocity_deviation,
217
+ "release_velocity": note.release_velocity,
218
+ })
219
+
220
+ if not source_notes:
221
+ raise ValueError("No matching notes found for the given IDs")
222
+
223
+ # Add the duplicated notes with all attributes preserved
224
+ import Live
225
+ song.begin_undo_step()
226
+ try:
227
+ note_specs = []
228
+ for note in source_notes:
229
+ kwargs = dict(
230
+ pitch=int(note["pitch"]),
231
+ start_time=float(note["start_time"]),
232
+ duration=float(note["duration"]),
233
+ velocity=float(note["velocity"]),
234
+ mute=bool(note["mute"]),
235
+ )
236
+ if note.get("probability") is not None:
237
+ kwargs["probability"] = float(note["probability"])
238
+ if note.get("velocity_deviation") is not None:
239
+ kwargs["velocity_deviation"] = float(note["velocity_deviation"])
240
+ if note.get("release_velocity") is not None:
241
+ kwargs["release_velocity"] = float(note["release_velocity"])
242
+ spec = Live.Clip.MidiNoteSpecification(**kwargs)
243
+ note_specs.append(spec)
244
+ clip.add_new_notes(tuple(note_specs))
245
+ finally:
246
+ song.end_undo_step()
247
+
248
+ return {
249
+ "track_index": track_index,
250
+ "clip_index": clip_index,
251
+ "duplicated_count": len(source_notes),
252
+ }
253
+
254
+
255
+ @register("transpose_notes")
256
+ def transpose_notes(song, params):
257
+ """Transpose notes in a time range by a number of semitones."""
258
+ track_index = int(params["track_index"])
259
+ clip_index = int(params["clip_index"])
260
+ semitones = int(params["semitones"])
261
+ arrangement = bool(params.get("arrangement", False))
262
+
263
+ if arrangement:
264
+ track = get_track(song, track_index)
265
+ arr_clips = list(track.arrangement_clips)
266
+ if clip_index < 0 or clip_index >= len(arr_clips):
267
+ raise IndexError(
268
+ "Arrangement clip index %d out of range (0..%d)"
269
+ % (clip_index, len(arr_clips) - 1)
270
+ )
271
+ clip = arr_clips[clip_index]
272
+ else:
273
+ clip = get_clip(song, track_index, clip_index)
274
+
275
+ from_time = float(params.get("from_time", 0.0))
276
+ time_span = float(params.get("time_span", clip.length))
277
+
278
+ # Get notes — returns C++ NoteVector that must be passed back intact
279
+ all_notes = clip.get_notes_extended(0, 128, from_time, time_span)
280
+
281
+ # Modify pitch in-place, skip notes that would go out of MIDI range
282
+ transposed_count = 0
283
+ skipped_count = 0
284
+ total_in_range = 0
285
+ for note in all_notes:
286
+ total_in_range += 1
287
+ new_pitch = note.pitch + semitones
288
+ if new_pitch < 0 or new_pitch > 127:
289
+ skipped_count += 1
290
+ continue
291
+ note.pitch = new_pitch
292
+ transposed_count += 1
293
+
294
+ if transposed_count > 0:
295
+ # Pass the original NoteVector back — Boost.Python requires the C++ type
296
+ song.begin_undo_step()
297
+ try:
298
+ clip.apply_note_modifications(all_notes)
299
+ finally:
300
+ song.end_undo_step()
301
+
302
+ result = {
303
+ "track_index": track_index,
304
+ "clip_index": clip_index,
305
+ "transposed_count": transposed_count,
306
+ "semitones": semitones,
307
+ }
308
+ if skipped_count > 0:
309
+ result["skipped_out_of_range"] = skipped_count
310
+ result["warning"] = (
311
+ "%d note(s) skipped — transposing by %+d semitones would "
312
+ "exceed MIDI range (0-127)" % (skipped_count, semitones)
313
+ )
314
+ return result
315
+
316
+
317
+ @register("quantize_clip")
318
+ def quantize_clip(song, params):
319
+ """Quantize a clip to a grid.
320
+
321
+ grid is a RecordQuantization enum integer:
322
+ 0=None, 1=1/4, 2=1/8, 3=1/8T, 4=1/8+T,
323
+ 5=1/16, 6=1/16T, 7=1/16+T, 8=1/32
324
+ """
325
+ track_index = int(params["track_index"])
326
+ clip_index = int(params["clip_index"])
327
+ grid = int(params["grid"])
328
+ amount = float(params.get("amount", 1.0))
329
+
330
+ clip = get_clip(song, track_index, clip_index)
331
+ clip.quantize(grid, amount)
332
+
333
+ return {
334
+ "track_index": track_index,
335
+ "clip_index": clip_index,
336
+ "grid": grid,
337
+ "amount": amount,
338
+ "quantized": True,
339
+ }
@@ -0,0 +1,74 @@
1
+ """
2
+ LivePilot - Command dispatch registry.
3
+
4
+ Handlers register themselves via the @register decorator.
5
+ dispatch() is called on the main thread to route commands.
6
+ """
7
+
8
+ from .utils import (
9
+ success_response,
10
+ error_response,
11
+ INDEX_ERROR,
12
+ INVALID_PARAM,
13
+ NOT_FOUND,
14
+ INTERNAL,
15
+ )
16
+
17
+ # ── Handler registry ─────────────────────────────────────────────────────────
18
+
19
+ _handlers = {}
20
+
21
+
22
+ def register(command_type):
23
+ """Decorator that registers a handler function for *command_type*."""
24
+ def decorator(fn):
25
+ _handlers[command_type] = fn
26
+ return fn
27
+ return decorator
28
+
29
+
30
+ # ── Dispatcher ───────────────────────────────────────────────────────────────
31
+
32
+ def dispatch(song, command):
33
+ """Route a parsed command dict to the appropriate handler.
34
+
35
+ Parameters
36
+ ----------
37
+ song : Live.Song.Song
38
+ The current song instance.
39
+ command : dict
40
+ Must contain ``id`` (str), ``type`` (str), and optionally ``params`` (dict).
41
+
42
+ Returns
43
+ -------
44
+ dict
45
+ A success or error response envelope.
46
+ """
47
+ request_id = command.get("id", "unknown")
48
+ cmd_type = command.get("type")
49
+ params = command.get("params", {})
50
+
51
+ if cmd_type is None:
52
+ return error_response(request_id, "Missing 'type' field", INVALID_PARAM)
53
+
54
+ # Built-in ping — no handler registration needed.
55
+ if cmd_type == "ping":
56
+ return success_response(request_id, {"pong": True})
57
+
58
+ handler = _handlers.get(cmd_type)
59
+ if handler is None:
60
+ return error_response(
61
+ request_id,
62
+ "Unknown command type: %s" % cmd_type,
63
+ NOT_FOUND,
64
+ )
65
+
66
+ try:
67
+ result = handler(song, params)
68
+ return success_response(request_id, result)
69
+ except IndexError as exc:
70
+ return error_response(request_id, str(exc), INDEX_ERROR)
71
+ except ValueError as exc:
72
+ return error_response(request_id, str(exc), INVALID_PARAM)
73
+ except Exception as exc:
74
+ return error_response(request_id, str(exc), INTERNAL)
@@ -0,0 +1,75 @@
1
+ """
2
+ LivePilot - Scene domain handlers (6 commands).
3
+ """
4
+
5
+ from .router import register
6
+ from .utils import get_scene
7
+
8
+
9
+ @register("get_scenes_info")
10
+ def get_scenes_info(song, params):
11
+ """Return info for all scenes."""
12
+ scenes = []
13
+ for i, scene in enumerate(song.scenes):
14
+ scenes.append({
15
+ "index": i,
16
+ "name": scene.name,
17
+ "tempo": scene.tempo if scene.tempo > 0 else None,
18
+ "color_index": scene.color_index,
19
+ })
20
+ return {"scenes": scenes}
21
+
22
+
23
+ @register("create_scene")
24
+ def create_scene(song, params):
25
+ """Create a new scene at the given index."""
26
+ index = int(params.get("index", -1))
27
+ song.create_scene(index)
28
+ if index == -1:
29
+ new_index = len(list(song.scenes)) - 1
30
+ else:
31
+ new_index = index
32
+ scene = list(song.scenes)[new_index]
33
+ return {
34
+ "index": new_index,
35
+ "name": scene.name,
36
+ "color_index": scene.color_index,
37
+ }
38
+
39
+
40
+ @register("delete_scene")
41
+ def delete_scene(song, params):
42
+ """Delete a scene by index."""
43
+ scene_index = int(params["scene_index"])
44
+ get_scene(song, scene_index)
45
+ song.delete_scene(scene_index)
46
+ return {"deleted": scene_index}
47
+
48
+
49
+ @register("duplicate_scene")
50
+ def duplicate_scene(song, params):
51
+ """Duplicate a scene by index."""
52
+ scene_index = int(params["scene_index"])
53
+ get_scene(song, scene_index)
54
+ song.duplicate_scene(scene_index)
55
+ new_index = scene_index + 1
56
+ scene = list(song.scenes)[new_index]
57
+ return {"index": new_index, "name": scene.name}
58
+
59
+
60
+ @register("fire_scene")
61
+ def fire_scene(song, params):
62
+ """Fire (launch) a scene."""
63
+ scene_index = int(params["scene_index"])
64
+ scene = get_scene(song, scene_index)
65
+ scene.fire()
66
+ return {"index": scene_index, "fired": True}
67
+
68
+
69
+ @register("set_scene_name")
70
+ def set_scene_name(song, params):
71
+ """Rename a scene."""
72
+ scene_index = int(params["scene_index"])
73
+ scene = get_scene(song, scene_index)
74
+ scene.name = str(params["name"])
75
+ return {"index": scene_index, "name": scene.name}