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,693 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LivePilot - Arrangement domain handlers (19 commands).
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .router import register
|
|
6
|
+
from .utils import get_track
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@register("get_arrangement_clips")
|
|
10
|
+
def get_arrangement_clips(song, params):
|
|
11
|
+
"""Return all arrangement clips on a track."""
|
|
12
|
+
track_index = int(params["track_index"])
|
|
13
|
+
track = get_track(song, track_index)
|
|
14
|
+
clips = []
|
|
15
|
+
for i, clip in enumerate(track.arrangement_clips):
|
|
16
|
+
clips.append({
|
|
17
|
+
"index": i,
|
|
18
|
+
"name": clip.name,
|
|
19
|
+
"start_time": clip.start_time,
|
|
20
|
+
"end_time": clip.start_time + clip.length,
|
|
21
|
+
"length": clip.length,
|
|
22
|
+
"color_index": clip.color_index,
|
|
23
|
+
"is_audio_clip": clip.is_audio_clip,
|
|
24
|
+
})
|
|
25
|
+
return {"track_index": track_index, "clips": clips}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@register("create_arrangement_clip")
|
|
29
|
+
def create_arrangement_clip(song, params):
|
|
30
|
+
"""Create MIDI clip(s) in arrangement view by duplicating a session clip.
|
|
31
|
+
|
|
32
|
+
Uses Live 12's Track.duplicate_clip_to_arrangement(clip, time) API.
|
|
33
|
+
When the requested length exceeds the source clip, multiple adjacent
|
|
34
|
+
copies are placed to fill the timeline region seamlessly.
|
|
35
|
+
|
|
36
|
+
Required: track_index, clip_slot_index, start_time, length
|
|
37
|
+
Optional: loop_length (defaults to session clip length), name, color_index
|
|
38
|
+
"""
|
|
39
|
+
track_index = int(params["track_index"])
|
|
40
|
+
clip_slot_index = int(params["clip_slot_index"])
|
|
41
|
+
start_time = float(params["start_time"])
|
|
42
|
+
length = float(params["length"])
|
|
43
|
+
if length <= 0:
|
|
44
|
+
raise ValueError("length must be > 0")
|
|
45
|
+
if start_time < 0:
|
|
46
|
+
raise ValueError("start_time must be >= 0")
|
|
47
|
+
|
|
48
|
+
track = get_track(song, track_index)
|
|
49
|
+
|
|
50
|
+
# Get source session clip
|
|
51
|
+
slots = list(track.clip_slots)
|
|
52
|
+
if clip_slot_index < 0 or clip_slot_index >= len(slots):
|
|
53
|
+
raise IndexError(
|
|
54
|
+
"Clip slot index %d out of range (0..%d)"
|
|
55
|
+
% (clip_slot_index, len(slots) - 1)
|
|
56
|
+
)
|
|
57
|
+
if not slots[clip_slot_index].has_clip:
|
|
58
|
+
raise ValueError("No clip in slot %d" % clip_slot_index)
|
|
59
|
+
source_clip = slots[clip_slot_index].clip
|
|
60
|
+
source_length = source_clip.length
|
|
61
|
+
|
|
62
|
+
# Use loop_length as the repeat unit (defaults to source clip length)
|
|
63
|
+
loop_length = float(params.get("loop_length", source_length))
|
|
64
|
+
|
|
65
|
+
name = str(params.get("name", ""))
|
|
66
|
+
color_index = params.get("color_index")
|
|
67
|
+
|
|
68
|
+
# Place adjacent copies to fill the requested length
|
|
69
|
+
song.begin_undo_step()
|
|
70
|
+
try:
|
|
71
|
+
pos = start_time
|
|
72
|
+
end_pos = start_time + length
|
|
73
|
+
clip_count = 0
|
|
74
|
+
first_clip_index = None
|
|
75
|
+
|
|
76
|
+
while pos < end_pos:
|
|
77
|
+
track.duplicate_clip_to_arrangement(source_clip, pos)
|
|
78
|
+
|
|
79
|
+
# Find and configure the newly placed clip
|
|
80
|
+
arr_clips = list(track.arrangement_clips)
|
|
81
|
+
for i, c in enumerate(arr_clips):
|
|
82
|
+
if abs(c.start_time - pos) < 0.01:
|
|
83
|
+
if first_clip_index is None:
|
|
84
|
+
first_clip_index = i
|
|
85
|
+
if name:
|
|
86
|
+
c.name = name
|
|
87
|
+
if color_index is not None:
|
|
88
|
+
c.color_index = int(color_index)
|
|
89
|
+
break
|
|
90
|
+
|
|
91
|
+
clip_count += 1
|
|
92
|
+
pos += loop_length
|
|
93
|
+
finally:
|
|
94
|
+
song.end_undo_step()
|
|
95
|
+
|
|
96
|
+
# Re-read to get accurate final state
|
|
97
|
+
arr_clips = list(track.arrangement_clips)
|
|
98
|
+
if first_clip_index is None or first_clip_index >= len(arr_clips):
|
|
99
|
+
raise ValueError("Failed to place any clips in arrangement")
|
|
100
|
+
first_clip = arr_clips[first_clip_index]
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
"track_index": track_index,
|
|
104
|
+
"clip_index": first_clip_index,
|
|
105
|
+
"start_time": start_time,
|
|
106
|
+
"length": length,
|
|
107
|
+
"clip_count": clip_count,
|
|
108
|
+
"source_length": source_length,
|
|
109
|
+
"name": first_clip.name if first_clip else "",
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@register("add_arrangement_notes")
|
|
114
|
+
def add_arrangement_notes(song, params):
|
|
115
|
+
"""Add MIDI notes to an arrangement clip (by index in arrangement_clips)."""
|
|
116
|
+
track_index = int(params["track_index"])
|
|
117
|
+
clip_index = int(params["clip_index"])
|
|
118
|
+
notes = params["notes"]
|
|
119
|
+
if not notes:
|
|
120
|
+
raise ValueError("notes list cannot be empty")
|
|
121
|
+
track = get_track(song, track_index)
|
|
122
|
+
arr_clips = list(track.arrangement_clips)
|
|
123
|
+
if clip_index < 0 or clip_index >= len(arr_clips):
|
|
124
|
+
raise IndexError(
|
|
125
|
+
"Arrangement clip index %d out of range (0..%d)"
|
|
126
|
+
% (clip_index, len(arr_clips) - 1)
|
|
127
|
+
)
|
|
128
|
+
clip = arr_clips[clip_index]
|
|
129
|
+
import Live
|
|
130
|
+
song.begin_undo_step()
|
|
131
|
+
try:
|
|
132
|
+
note_specs = []
|
|
133
|
+
for note in notes:
|
|
134
|
+
spec = Live.Clip.MidiNoteSpecification(
|
|
135
|
+
pitch=int(note["pitch"]),
|
|
136
|
+
start_time=float(note["start_time"]),
|
|
137
|
+
duration=float(note["duration"]),
|
|
138
|
+
velocity=float(note.get("velocity", 100)),
|
|
139
|
+
mute=bool(note.get("mute", False)),
|
|
140
|
+
)
|
|
141
|
+
note_specs.append(spec)
|
|
142
|
+
clip.add_new_notes(tuple(note_specs))
|
|
143
|
+
finally:
|
|
144
|
+
song.end_undo_step()
|
|
145
|
+
return {
|
|
146
|
+
"track_index": track_index,
|
|
147
|
+
"clip_index": clip_index,
|
|
148
|
+
"notes_added": len(notes),
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@register("get_arrangement_notes")
|
|
153
|
+
def get_arrangement_notes(song, params):
|
|
154
|
+
"""Get MIDI notes from an arrangement clip region."""
|
|
155
|
+
track_index = int(params["track_index"])
|
|
156
|
+
clip_index = int(params["clip_index"])
|
|
157
|
+
track = get_track(song, track_index)
|
|
158
|
+
arr_clips = list(track.arrangement_clips)
|
|
159
|
+
if clip_index < 0 or clip_index >= len(arr_clips):
|
|
160
|
+
raise IndexError(
|
|
161
|
+
"Arrangement clip index %d out of range (0..%d)"
|
|
162
|
+
% (clip_index, len(arr_clips) - 1)
|
|
163
|
+
)
|
|
164
|
+
clip = arr_clips[clip_index]
|
|
165
|
+
|
|
166
|
+
from_pitch = int(params.get("from_pitch", 0))
|
|
167
|
+
pitch_span = int(params.get("pitch_span", 128))
|
|
168
|
+
from_time = float(params.get("from_time", 0.0))
|
|
169
|
+
default_span = clip.length if clip.length > 0 else 32768.0
|
|
170
|
+
time_span = float(params.get("time_span", default_span))
|
|
171
|
+
|
|
172
|
+
raw_notes = clip.get_notes_extended(from_pitch, pitch_span, from_time, time_span)
|
|
173
|
+
result = []
|
|
174
|
+
for note in raw_notes:
|
|
175
|
+
result.append({
|
|
176
|
+
"note_id": note.note_id,
|
|
177
|
+
"pitch": note.pitch,
|
|
178
|
+
"start_time": note.start_time,
|
|
179
|
+
"duration": note.duration,
|
|
180
|
+
"velocity": note.velocity,
|
|
181
|
+
"mute": note.mute,
|
|
182
|
+
"probability": note.probability,
|
|
183
|
+
"velocity_deviation": note.velocity_deviation,
|
|
184
|
+
"release_velocity": note.release_velocity,
|
|
185
|
+
})
|
|
186
|
+
return {
|
|
187
|
+
"track_index": track_index,
|
|
188
|
+
"clip_index": clip_index,
|
|
189
|
+
"notes": result,
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@register("remove_arrangement_notes")
|
|
194
|
+
def remove_arrangement_notes(song, params):
|
|
195
|
+
"""Remove MIDI notes from an arrangement clip region."""
|
|
196
|
+
track_index = int(params["track_index"])
|
|
197
|
+
clip_index = int(params["clip_index"])
|
|
198
|
+
track = get_track(song, track_index)
|
|
199
|
+
arr_clips = list(track.arrangement_clips)
|
|
200
|
+
if clip_index < 0 or clip_index >= len(arr_clips):
|
|
201
|
+
raise IndexError(
|
|
202
|
+
"Arrangement clip index %d out of range (0..%d)"
|
|
203
|
+
% (clip_index, len(arr_clips) - 1)
|
|
204
|
+
)
|
|
205
|
+
clip = arr_clips[clip_index]
|
|
206
|
+
|
|
207
|
+
from_pitch = int(params.get("from_pitch", 0))
|
|
208
|
+
pitch_span = int(params.get("pitch_span", 128))
|
|
209
|
+
from_time = float(params.get("from_time", 0.0))
|
|
210
|
+
default_span = clip.length if clip.length > 0 else 32768.0
|
|
211
|
+
time_span = float(params.get("time_span", default_span))
|
|
212
|
+
|
|
213
|
+
song.begin_undo_step()
|
|
214
|
+
try:
|
|
215
|
+
clip.remove_notes_extended(from_pitch, pitch_span, from_time, time_span)
|
|
216
|
+
finally:
|
|
217
|
+
song.end_undo_step()
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
"track_index": track_index,
|
|
221
|
+
"clip_index": clip_index,
|
|
222
|
+
"removed": True,
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@register("remove_arrangement_notes_by_id")
|
|
227
|
+
def remove_arrangement_notes_by_id(song, params):
|
|
228
|
+
"""Remove specific MIDI notes from an arrangement clip by their IDs."""
|
|
229
|
+
track_index = int(params["track_index"])
|
|
230
|
+
clip_index = int(params["clip_index"])
|
|
231
|
+
note_ids = params["note_ids"]
|
|
232
|
+
if not note_ids:
|
|
233
|
+
raise ValueError("note_ids list cannot be empty")
|
|
234
|
+
|
|
235
|
+
track = get_track(song, track_index)
|
|
236
|
+
arr_clips = list(track.arrangement_clips)
|
|
237
|
+
if clip_index < 0 or clip_index >= len(arr_clips):
|
|
238
|
+
raise IndexError(
|
|
239
|
+
"Arrangement clip index %d out of range (0..%d)"
|
|
240
|
+
% (clip_index, len(arr_clips) - 1)
|
|
241
|
+
)
|
|
242
|
+
clip = arr_clips[clip_index]
|
|
243
|
+
song.begin_undo_step()
|
|
244
|
+
try:
|
|
245
|
+
clip.remove_notes_by_id(tuple(int(nid) for nid in note_ids))
|
|
246
|
+
finally:
|
|
247
|
+
song.end_undo_step()
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
"track_index": track_index,
|
|
251
|
+
"clip_index": clip_index,
|
|
252
|
+
"removed_count": len(note_ids),
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@register("modify_arrangement_notes")
|
|
257
|
+
def modify_arrangement_notes(song, params):
|
|
258
|
+
"""Modify existing MIDI notes in an arrangement clip by ID."""
|
|
259
|
+
track_index = int(params["track_index"])
|
|
260
|
+
clip_index = int(params["clip_index"])
|
|
261
|
+
modifications = params["modifications"]
|
|
262
|
+
if not modifications:
|
|
263
|
+
raise ValueError("modifications list cannot be empty")
|
|
264
|
+
|
|
265
|
+
track = get_track(song, track_index)
|
|
266
|
+
arr_clips = list(track.arrangement_clips)
|
|
267
|
+
if clip_index < 0 or clip_index >= len(arr_clips):
|
|
268
|
+
raise IndexError(
|
|
269
|
+
"Arrangement clip index %d out of range (0..%d)"
|
|
270
|
+
% (clip_index, len(arr_clips) - 1)
|
|
271
|
+
)
|
|
272
|
+
clip = arr_clips[clip_index]
|
|
273
|
+
|
|
274
|
+
all_notes = clip.get_notes_extended(0, 128, 0.0, clip.length + 1.0)
|
|
275
|
+
|
|
276
|
+
note_map = {}
|
|
277
|
+
for note in all_notes:
|
|
278
|
+
note_map[note.note_id] = note
|
|
279
|
+
|
|
280
|
+
modified_count = 0
|
|
281
|
+
for mod in modifications:
|
|
282
|
+
note_id = int(mod["note_id"])
|
|
283
|
+
if note_id not in note_map:
|
|
284
|
+
raise ValueError("Note ID %d not found in clip" % note_id)
|
|
285
|
+
note = note_map[note_id]
|
|
286
|
+
if "pitch" in mod:
|
|
287
|
+
note.pitch = int(mod["pitch"])
|
|
288
|
+
if "start_time" in mod:
|
|
289
|
+
note.start_time = float(mod["start_time"])
|
|
290
|
+
if "duration" in mod:
|
|
291
|
+
note.duration = float(mod["duration"])
|
|
292
|
+
if "velocity" in mod:
|
|
293
|
+
note.velocity = float(mod["velocity"])
|
|
294
|
+
if "probability" in mod:
|
|
295
|
+
note.probability = float(mod["probability"])
|
|
296
|
+
modified_count += 1
|
|
297
|
+
|
|
298
|
+
song.begin_undo_step()
|
|
299
|
+
try:
|
|
300
|
+
clip.apply_note_modifications(all_notes)
|
|
301
|
+
finally:
|
|
302
|
+
song.end_undo_step()
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
"track_index": track_index,
|
|
306
|
+
"clip_index": clip_index,
|
|
307
|
+
"modified_count": modified_count,
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
@register("duplicate_arrangement_notes")
|
|
312
|
+
def duplicate_arrangement_notes(song, params):
|
|
313
|
+
"""Duplicate specific notes in an arrangement clip by ID, with optional time offset."""
|
|
314
|
+
track_index = int(params["track_index"])
|
|
315
|
+
clip_index = int(params["clip_index"])
|
|
316
|
+
note_ids = params["note_ids"]
|
|
317
|
+
time_offset = float(params.get("time_offset", 0.0))
|
|
318
|
+
if not note_ids:
|
|
319
|
+
raise ValueError("note_ids list cannot be empty")
|
|
320
|
+
|
|
321
|
+
track = get_track(song, track_index)
|
|
322
|
+
arr_clips = list(track.arrangement_clips)
|
|
323
|
+
if clip_index < 0 or clip_index >= len(arr_clips):
|
|
324
|
+
raise IndexError(
|
|
325
|
+
"Arrangement clip index %d out of range (0..%d)"
|
|
326
|
+
% (clip_index, len(arr_clips) - 1)
|
|
327
|
+
)
|
|
328
|
+
clip = arr_clips[clip_index]
|
|
329
|
+
note_id_set = set(int(nid) for nid in note_ids)
|
|
330
|
+
|
|
331
|
+
all_notes = clip.get_notes_extended(0, 128, 0.0, clip.length + 1.0)
|
|
332
|
+
source_notes = []
|
|
333
|
+
for note in all_notes:
|
|
334
|
+
if note.note_id in note_id_set:
|
|
335
|
+
source_notes.append({
|
|
336
|
+
"pitch": note.pitch,
|
|
337
|
+
"start_time": note.start_time + time_offset,
|
|
338
|
+
"duration": note.duration,
|
|
339
|
+
"velocity": note.velocity,
|
|
340
|
+
"mute": note.mute,
|
|
341
|
+
"probability": note.probability,
|
|
342
|
+
"velocity_deviation": note.velocity_deviation,
|
|
343
|
+
"release_velocity": note.release_velocity,
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
if not source_notes:
|
|
347
|
+
raise ValueError("No matching notes found for the given IDs")
|
|
348
|
+
|
|
349
|
+
import Live
|
|
350
|
+
song.begin_undo_step()
|
|
351
|
+
try:
|
|
352
|
+
note_specs = []
|
|
353
|
+
for note in source_notes:
|
|
354
|
+
kwargs = dict(
|
|
355
|
+
pitch=int(note["pitch"]),
|
|
356
|
+
start_time=float(note["start_time"]),
|
|
357
|
+
duration=float(note["duration"]),
|
|
358
|
+
velocity=float(note["velocity"]),
|
|
359
|
+
mute=bool(note["mute"]),
|
|
360
|
+
)
|
|
361
|
+
if note.get("probability") is not None:
|
|
362
|
+
kwargs["probability"] = float(note["probability"])
|
|
363
|
+
if note.get("velocity_deviation") is not None:
|
|
364
|
+
kwargs["velocity_deviation"] = float(note["velocity_deviation"])
|
|
365
|
+
if note.get("release_velocity") is not None:
|
|
366
|
+
kwargs["release_velocity"] = float(note["release_velocity"])
|
|
367
|
+
spec = Live.Clip.MidiNoteSpecification(**kwargs)
|
|
368
|
+
note_specs.append(spec)
|
|
369
|
+
clip.add_new_notes(tuple(note_specs))
|
|
370
|
+
finally:
|
|
371
|
+
song.end_undo_step()
|
|
372
|
+
|
|
373
|
+
return {
|
|
374
|
+
"track_index": track_index,
|
|
375
|
+
"clip_index": clip_index,
|
|
376
|
+
"duplicated_count": len(source_notes),
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
@register("set_arrangement_automation")
|
|
381
|
+
def set_arrangement_automation(song, params):
|
|
382
|
+
"""Write automation points into an arrangement clip's envelope.
|
|
383
|
+
|
|
384
|
+
Required: track_index, clip_index, parameter_type, points
|
|
385
|
+
Optional: device_index, parameter_index, send_index
|
|
386
|
+
|
|
387
|
+
parameter_type: "device", "volume", "panning", "send"
|
|
388
|
+
points: list of {time, value, duration} — time relative to clip start
|
|
389
|
+
"""
|
|
390
|
+
track_index = int(params["track_index"])
|
|
391
|
+
clip_index = int(params["clip_index"])
|
|
392
|
+
parameter_type = str(params["parameter_type"])
|
|
393
|
+
points = params["points"]
|
|
394
|
+
if not points:
|
|
395
|
+
raise ValueError("points list cannot be empty")
|
|
396
|
+
|
|
397
|
+
track = get_track(song, track_index)
|
|
398
|
+
arr_clips = list(track.arrangement_clips)
|
|
399
|
+
if clip_index < 0 or clip_index >= len(arr_clips):
|
|
400
|
+
raise IndexError(
|
|
401
|
+
"Arrangement clip index %d out of range (0..%d)"
|
|
402
|
+
% (clip_index, len(arr_clips) - 1)
|
|
403
|
+
)
|
|
404
|
+
clip = arr_clips[clip_index]
|
|
405
|
+
|
|
406
|
+
# Resolve the target parameter
|
|
407
|
+
if parameter_type == "device":
|
|
408
|
+
device_index = int(params["device_index"])
|
|
409
|
+
parameter_index = int(params["parameter_index"])
|
|
410
|
+
devices = list(track.devices)
|
|
411
|
+
if device_index < 0 or device_index >= len(devices):
|
|
412
|
+
raise IndexError("Device index %d out of range" % device_index)
|
|
413
|
+
device_params = list(devices[device_index].parameters)
|
|
414
|
+
if parameter_index < 0 or parameter_index >= len(device_params):
|
|
415
|
+
raise IndexError("Parameter index %d out of range" % parameter_index)
|
|
416
|
+
parameter = device_params[parameter_index]
|
|
417
|
+
elif parameter_type == "volume":
|
|
418
|
+
parameter = track.mixer_device.volume
|
|
419
|
+
elif parameter_type == "panning":
|
|
420
|
+
parameter = track.mixer_device.panning
|
|
421
|
+
elif parameter_type == "send":
|
|
422
|
+
send_index = int(params["send_index"])
|
|
423
|
+
sends = list(track.mixer_device.sends)
|
|
424
|
+
if send_index < 0 or send_index >= len(sends):
|
|
425
|
+
raise IndexError("Send index %d out of range" % send_index)
|
|
426
|
+
parameter = sends[send_index]
|
|
427
|
+
else:
|
|
428
|
+
raise ValueError(
|
|
429
|
+
"parameter_type must be 'device', 'volume', 'panning', or 'send'"
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
# Try direct envelope access on the arrangement clip
|
|
433
|
+
envelope = clip.automation_envelope(parameter)
|
|
434
|
+
if envelope is None:
|
|
435
|
+
try:
|
|
436
|
+
envelope = clip.create_automation_envelope(parameter)
|
|
437
|
+
except (AttributeError, RuntimeError):
|
|
438
|
+
pass
|
|
439
|
+
|
|
440
|
+
if envelope is not None:
|
|
441
|
+
# Direct approach works — write points to the arrangement clip
|
|
442
|
+
song.begin_undo_step()
|
|
443
|
+
try:
|
|
444
|
+
points_written = 0
|
|
445
|
+
for pt in points:
|
|
446
|
+
time = float(pt["time"])
|
|
447
|
+
value = float(pt["value"])
|
|
448
|
+
duration = float(pt.get("duration", 0.125))
|
|
449
|
+
envelope.insert_step(time, duration, value)
|
|
450
|
+
points_written += 1
|
|
451
|
+
finally:
|
|
452
|
+
song.end_undo_step()
|
|
453
|
+
return {
|
|
454
|
+
"track_index": track_index,
|
|
455
|
+
"clip_index": clip_index,
|
|
456
|
+
"parameter_name": parameter.name,
|
|
457
|
+
"points_written": points_written,
|
|
458
|
+
"method": "direct",
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
# Fallback: session-clip-then-duplicate workaround.
|
|
462
|
+
# Create a temporary session clip, write automation there,
|
|
463
|
+
# then duplicate to arrangement. Envelopes may survive the copy.
|
|
464
|
+
arr_start = clip.start_time
|
|
465
|
+
arr_length = clip.length
|
|
466
|
+
|
|
467
|
+
# Find an empty clip slot for the temporary clip
|
|
468
|
+
slots = list(track.clip_slots)
|
|
469
|
+
temp_slot_index = None
|
|
470
|
+
for si, slot in enumerate(slots):
|
|
471
|
+
if not slot.has_clip:
|
|
472
|
+
temp_slot_index = si
|
|
473
|
+
break
|
|
474
|
+
|
|
475
|
+
# If all clip slots are full, create a temporary scene to get an empty slot
|
|
476
|
+
created_temp_scene = False
|
|
477
|
+
if temp_slot_index is None:
|
|
478
|
+
song.create_scene(-1) # append a new scene at the end
|
|
479
|
+
created_temp_scene = True
|
|
480
|
+
# Re-read slots — the new scene added one slot per track
|
|
481
|
+
slots = list(track.clip_slots)
|
|
482
|
+
temp_slot_index = len(slots) - 1
|
|
483
|
+
|
|
484
|
+
song.begin_undo_step()
|
|
485
|
+
try:
|
|
486
|
+
# Create temporary session clip
|
|
487
|
+
slot = slots[temp_slot_index]
|
|
488
|
+
slot.create_clip(arr_length)
|
|
489
|
+
temp_clip = slot.clip
|
|
490
|
+
|
|
491
|
+
# Write automation to the session clip
|
|
492
|
+
temp_envelope = temp_clip.automation_envelope(parameter)
|
|
493
|
+
if temp_envelope is None:
|
|
494
|
+
try:
|
|
495
|
+
temp_envelope = temp_clip.create_automation_envelope(parameter)
|
|
496
|
+
except (AttributeError, RuntimeError):
|
|
497
|
+
pass
|
|
498
|
+
|
|
499
|
+
if temp_envelope is None:
|
|
500
|
+
# Neither direct nor session clip approach works
|
|
501
|
+
slot.delete_clip()
|
|
502
|
+
if created_temp_scene:
|
|
503
|
+
song.delete_scene(len(list(song.scenes)) - 1)
|
|
504
|
+
raise ValueError(
|
|
505
|
+
"Cannot create automation envelope for parameter '%s' "
|
|
506
|
+
"(neither arrangement nor session clip supports it)"
|
|
507
|
+
% parameter.name
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
points_written = 0
|
|
511
|
+
for pt in points:
|
|
512
|
+
time = float(pt["time"])
|
|
513
|
+
value = float(pt["value"])
|
|
514
|
+
duration = float(pt.get("duration", 0.125))
|
|
515
|
+
temp_envelope.insert_step(time, duration, value)
|
|
516
|
+
points_written += 1
|
|
517
|
+
|
|
518
|
+
# Duplicate session clip to arrangement at the same position
|
|
519
|
+
track.duplicate_clip_to_arrangement(temp_clip, arr_start)
|
|
520
|
+
|
|
521
|
+
# Clean up the temporary session clip
|
|
522
|
+
slot.delete_clip()
|
|
523
|
+
|
|
524
|
+
# Clean up the temporary scene if we created one
|
|
525
|
+
if created_temp_scene:
|
|
526
|
+
song.delete_scene(len(list(song.scenes)) - 1)
|
|
527
|
+
finally:
|
|
528
|
+
song.end_undo_step()
|
|
529
|
+
|
|
530
|
+
return {
|
|
531
|
+
"track_index": track_index,
|
|
532
|
+
"clip_index": clip_index,
|
|
533
|
+
"parameter_name": parameter.name,
|
|
534
|
+
"points_written": points_written,
|
|
535
|
+
"method": "session_workaround",
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
@register("transpose_arrangement_notes")
|
|
540
|
+
def transpose_arrangement_notes(song, params):
|
|
541
|
+
"""Transpose notes in an arrangement clip by semitones."""
|
|
542
|
+
track_index = int(params["track_index"])
|
|
543
|
+
clip_index = int(params["clip_index"])
|
|
544
|
+
semitones = int(params["semitones"])
|
|
545
|
+
track = get_track(song, track_index)
|
|
546
|
+
arr_clips = list(track.arrangement_clips)
|
|
547
|
+
if clip_index < 0 or clip_index >= len(arr_clips):
|
|
548
|
+
raise IndexError(
|
|
549
|
+
"Arrangement clip index %d out of range (0..%d)"
|
|
550
|
+
% (clip_index, len(arr_clips) - 1)
|
|
551
|
+
)
|
|
552
|
+
clip = arr_clips[clip_index]
|
|
553
|
+
|
|
554
|
+
from_time = float(params.get("from_time", 0.0))
|
|
555
|
+
time_span = float(params.get("time_span", clip.length))
|
|
556
|
+
|
|
557
|
+
all_notes = clip.get_notes_extended(0, 128, from_time, time_span)
|
|
558
|
+
|
|
559
|
+
transposed_count = 0
|
|
560
|
+
skipped_count = 0
|
|
561
|
+
for note in all_notes:
|
|
562
|
+
new_pitch = note.pitch + semitones
|
|
563
|
+
if new_pitch < 0 or new_pitch > 127:
|
|
564
|
+
skipped_count += 1
|
|
565
|
+
continue
|
|
566
|
+
note.pitch = new_pitch
|
|
567
|
+
transposed_count += 1
|
|
568
|
+
|
|
569
|
+
if transposed_count > 0:
|
|
570
|
+
song.begin_undo_step()
|
|
571
|
+
try:
|
|
572
|
+
clip.apply_note_modifications(all_notes)
|
|
573
|
+
finally:
|
|
574
|
+
song.end_undo_step()
|
|
575
|
+
|
|
576
|
+
return {
|
|
577
|
+
"track_index": track_index,
|
|
578
|
+
"clip_index": clip_index,
|
|
579
|
+
"transposed_count": transposed_count,
|
|
580
|
+
"skipped_count": skipped_count,
|
|
581
|
+
"semitones": semitones,
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
@register("set_arrangement_clip_name")
|
|
586
|
+
def set_arrangement_clip_name(song, params):
|
|
587
|
+
"""Rename an arrangement clip by its index."""
|
|
588
|
+
track_index = int(params["track_index"])
|
|
589
|
+
clip_index = int(params["clip_index"])
|
|
590
|
+
name = str(params["name"])
|
|
591
|
+
track = get_track(song, track_index)
|
|
592
|
+
arr_clips = list(track.arrangement_clips)
|
|
593
|
+
if clip_index < 0 or clip_index >= len(arr_clips):
|
|
594
|
+
raise IndexError(
|
|
595
|
+
"Arrangement clip index %d out of range (0..%d)"
|
|
596
|
+
% (clip_index, len(arr_clips) - 1)
|
|
597
|
+
)
|
|
598
|
+
arr_clips[clip_index].name = name
|
|
599
|
+
return {"track_index": track_index, "clip_index": clip_index, "name": name}
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
@register("jump_to_time")
|
|
603
|
+
def jump_to_time(song, params):
|
|
604
|
+
"""Jump to a specific beat time in the arrangement."""
|
|
605
|
+
beat_time = float(params["beat_time"])
|
|
606
|
+
if beat_time < 0:
|
|
607
|
+
raise ValueError("beat_time must be >= 0")
|
|
608
|
+
song.current_song_time = beat_time
|
|
609
|
+
# Echo requested value — getter may return stale state before update propagates
|
|
610
|
+
return {"current_song_time": beat_time}
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
@register("capture_midi")
|
|
614
|
+
def capture_midi(song, params):
|
|
615
|
+
"""Capture recently played MIDI notes into a clip."""
|
|
616
|
+
song.capture_midi()
|
|
617
|
+
return {"captured": True}
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
@register("start_recording")
|
|
621
|
+
def start_recording(song, params):
|
|
622
|
+
"""Start recording in session or arrangement mode.
|
|
623
|
+
|
|
624
|
+
Live requires transport to be playing for record_mode to engage.
|
|
625
|
+
If not playing, we start playback first.
|
|
626
|
+
"""
|
|
627
|
+
arrangement = bool(params.get("arrangement", False))
|
|
628
|
+
if arrangement:
|
|
629
|
+
if not song.is_playing:
|
|
630
|
+
song.start_playing()
|
|
631
|
+
song.record_mode = True
|
|
632
|
+
else:
|
|
633
|
+
song.session_record = True
|
|
634
|
+
# Verify and report
|
|
635
|
+
result = {
|
|
636
|
+
"record_mode": song.record_mode,
|
|
637
|
+
"session_record": song.session_record,
|
|
638
|
+
}
|
|
639
|
+
if arrangement and not song.record_mode:
|
|
640
|
+
result["warning"] = "Record mode did not engage — check that at least one track is armed"
|
|
641
|
+
if not arrangement and not song.session_record:
|
|
642
|
+
result["warning"] = "Session record did not engage — check that at least one track is armed"
|
|
643
|
+
return result
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
@register("stop_recording")
|
|
647
|
+
def stop_recording(song, params):
|
|
648
|
+
"""Stop all recording."""
|
|
649
|
+
song.record_mode = False
|
|
650
|
+
song.session_record = False
|
|
651
|
+
return {"record_mode": False, "session_record": False}
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
@register("get_cue_points")
|
|
655
|
+
def get_cue_points(song, params):
|
|
656
|
+
"""Return all cue points in the arrangement."""
|
|
657
|
+
cue_points = list(song.cue_points)
|
|
658
|
+
result = []
|
|
659
|
+
for i, cue in enumerate(cue_points):
|
|
660
|
+
result.append({
|
|
661
|
+
"index": i,
|
|
662
|
+
"name": cue.name,
|
|
663
|
+
"time": cue.time,
|
|
664
|
+
})
|
|
665
|
+
return {"cue_points": result}
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
@register("jump_to_cue")
|
|
669
|
+
def jump_to_cue(song, params):
|
|
670
|
+
"""Jump to a cue point by index."""
|
|
671
|
+
cue_index = int(params["cue_index"])
|
|
672
|
+
cue_points = list(song.cue_points)
|
|
673
|
+
if cue_index < 0 or cue_index >= len(cue_points):
|
|
674
|
+
raise IndexError(
|
|
675
|
+
"Cue point index %d out of range (0..%d)"
|
|
676
|
+
% (cue_index, len(cue_points) - 1)
|
|
677
|
+
)
|
|
678
|
+
cue_points[cue_index].jump()
|
|
679
|
+
return {"cue_index": cue_index, "jumped": True}
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
@register("toggle_cue_point")
|
|
683
|
+
def toggle_cue_point(song, params):
|
|
684
|
+
"""Set or delete a cue point at the current position."""
|
|
685
|
+
song.set_or_delete_cue()
|
|
686
|
+
return {"toggled": True}
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
@register("back_to_arranger")
|
|
690
|
+
def back_to_arranger(song, params):
|
|
691
|
+
"""Switch playback from session clips back to the arrangement timeline."""
|
|
692
|
+
song.back_to_arranger = True
|
|
693
|
+
return {"back_to_arranger": song.back_to_arranger}
|