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.
Files changed (64) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/LICENSE +21 -0
  3. package/README.md +409 -0
  4. package/bin/livepilot.js +390 -0
  5. package/installer/install.js +95 -0
  6. package/installer/paths.js +79 -0
  7. package/mcp_server/__init__.py +2 -0
  8. package/mcp_server/__main__.py +5 -0
  9. package/mcp_server/connection.py +210 -0
  10. package/mcp_server/memory/__init__.py +5 -0
  11. package/mcp_server/memory/technique_store.py +296 -0
  12. package/mcp_server/server.py +87 -0
  13. package/mcp_server/tools/__init__.py +1 -0
  14. package/mcp_server/tools/arrangement.py +407 -0
  15. package/mcp_server/tools/browser.py +86 -0
  16. package/mcp_server/tools/clips.py +218 -0
  17. package/mcp_server/tools/devices.py +256 -0
  18. package/mcp_server/tools/memory.py +198 -0
  19. package/mcp_server/tools/mixing.py +121 -0
  20. package/mcp_server/tools/notes.py +269 -0
  21. package/mcp_server/tools/scenes.py +89 -0
  22. package/mcp_server/tools/tracks.py +175 -0
  23. package/mcp_server/tools/transport.py +117 -0
  24. package/package.json +37 -0
  25. package/plugin/agents/livepilot-producer/AGENT.md +62 -0
  26. package/plugin/commands/beat.md +18 -0
  27. package/plugin/commands/memory.md +22 -0
  28. package/plugin/commands/mix.md +15 -0
  29. package/plugin/commands/session.md +13 -0
  30. package/plugin/commands/sounddesign.md +16 -0
  31. package/plugin/plugin.json +19 -0
  32. package/plugin/skills/livepilot-core/SKILL.md +208 -0
  33. package/plugin/skills/livepilot-core/references/ableton-workflow-patterns.md +831 -0
  34. package/plugin/skills/livepilot-core/references/device-atlas/00-index.md +110 -0
  35. package/plugin/skills/livepilot-core/references/device-atlas/distortion-and-character.md +687 -0
  36. package/plugin/skills/livepilot-core/references/device-atlas/drums-and-percussion.md +753 -0
  37. package/plugin/skills/livepilot-core/references/device-atlas/dynamics-and-punch.md +525 -0
  38. package/plugin/skills/livepilot-core/references/device-atlas/eq-and-filtering.md +402 -0
  39. package/plugin/skills/livepilot-core/references/device-atlas/midi-tools.md +963 -0
  40. package/plugin/skills/livepilot-core/references/device-atlas/movement-and-modulation.md +874 -0
  41. package/plugin/skills/livepilot-core/references/device-atlas/space-and-depth.md +571 -0
  42. package/plugin/skills/livepilot-core/references/device-atlas/spectral-and-weird.md +714 -0
  43. package/plugin/skills/livepilot-core/references/device-atlas/synths-native.md +953 -0
  44. package/plugin/skills/livepilot-core/references/m4l-devices.md +352 -0
  45. package/plugin/skills/livepilot-core/references/memory-guide.md +107 -0
  46. package/plugin/skills/livepilot-core/references/midi-recipes.md +402 -0
  47. package/plugin/skills/livepilot-core/references/mixing-patterns.md +578 -0
  48. package/plugin/skills/livepilot-core/references/overview.md +209 -0
  49. package/plugin/skills/livepilot-core/references/sound-design.md +392 -0
  50. package/remote_script/LivePilot/__init__.py +42 -0
  51. package/remote_script/LivePilot/arrangement.py +693 -0
  52. package/remote_script/LivePilot/browser.py +424 -0
  53. package/remote_script/LivePilot/clips.py +211 -0
  54. package/remote_script/LivePilot/devices.py +596 -0
  55. package/remote_script/LivePilot/diagnostics.py +198 -0
  56. package/remote_script/LivePilot/mixing.py +194 -0
  57. package/remote_script/LivePilot/notes.py +339 -0
  58. package/remote_script/LivePilot/router.py +74 -0
  59. package/remote_script/LivePilot/scenes.py +99 -0
  60. package/remote_script/LivePilot/server.py +293 -0
  61. package/remote_script/LivePilot/tracks.py +268 -0
  62. package/remote_script/LivePilot/transport.py +151 -0
  63. package/remote_script/LivePilot/utils.py +123 -0
  64. 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}