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,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,99 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LivePilot - Scene domain handlers (8 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}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@register("set_scene_color")
|
|
79
|
+
def set_scene_color(song, params):
|
|
80
|
+
"""Set a scene's color."""
|
|
81
|
+
scene_index = int(params["scene_index"])
|
|
82
|
+
scene = get_scene(song, scene_index)
|
|
83
|
+
scene.color_index = int(params["color_index"])
|
|
84
|
+
return {"index": scene_index, "color_index": scene.color_index}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@register("set_scene_tempo")
|
|
88
|
+
def set_scene_tempo(song, params):
|
|
89
|
+
"""Set a scene's tempo (BPM, 20-999)."""
|
|
90
|
+
scene_index = int(params["scene_index"])
|
|
91
|
+
scene = get_scene(song, scene_index)
|
|
92
|
+
tempo = float(params["tempo"])
|
|
93
|
+
if tempo < 20 or tempo > 999:
|
|
94
|
+
raise ValueError("Tempo must be between 20 and 999 BPM")
|
|
95
|
+
scene.tempo = tempo
|
|
96
|
+
return {
|
|
97
|
+
"index": scene_index,
|
|
98
|
+
"tempo": scene.tempo,
|
|
99
|
+
}
|