livepilot 1.14.1 → 1.16.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 (33) hide show
  1. package/CHANGELOG.md +176 -1
  2. package/README.md +6 -6
  3. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  4. package/mcp_server/__init__.py +1 -1
  5. package/mcp_server/atlas/device_atlas.json +91219 -7161
  6. package/mcp_server/atlas/tools.py +30 -2
  7. package/mcp_server/runtime/live_version.py +4 -2
  8. package/mcp_server/runtime/remote_commands.py +5 -0
  9. package/mcp_server/sample_engine/tools.py +692 -60
  10. package/mcp_server/splice_client/client.py +511 -65
  11. package/mcp_server/splice_client/http_bridge.py +361 -0
  12. package/mcp_server/splice_client/models.py +266 -2
  13. package/mcp_server/splice_client/quota.py +229 -0
  14. package/mcp_server/tools/_analyzer_engine/__init__.py +4 -0
  15. package/mcp_server/tools/_analyzer_engine/sample.py +73 -0
  16. package/mcp_server/tools/analyzer.py +666 -6
  17. package/mcp_server/tools/browser.py +164 -13
  18. package/mcp_server/tools/devices.py +56 -11
  19. package/mcp_server/tools/mixing.py +64 -15
  20. package/mcp_server/tools/scales.py +18 -6
  21. package/mcp_server/tools/tracks.py +92 -4
  22. package/package.json +2 -2
  23. package/remote_script/LivePilot/__init__.py +2 -1
  24. package/remote_script/LivePilot/_clip_helpers.py +86 -0
  25. package/remote_script/LivePilot/_drum_helpers.py +40 -0
  26. package/remote_script/LivePilot/_scale_helpers.py +87 -0
  27. package/remote_script/LivePilot/arrangement.py +44 -15
  28. package/remote_script/LivePilot/clips.py +182 -2
  29. package/remote_script/LivePilot/devices.py +82 -2
  30. package/remote_script/LivePilot/notes.py +17 -2
  31. package/remote_script/LivePilot/scales.py +31 -16
  32. package/remote_script/LivePilot/simpler_sample.py +186 -0
  33. package/server.json +3 -3
@@ -0,0 +1,86 @@
1
+ """Pure-Python helpers for clip-length and note-range invariants.
2
+
3
+ Importable outside Ableton (no `_Framework` dependency, no `import Live`).
4
+ The main `clips.py` and `notes.py` modules re-import these helpers so
5
+ both the live handler and the contract tests run identical logic.
6
+
7
+ Bug context: `clip_slot.create_clip(length)` in Live 12 sets the clip's
8
+ *length* but defaults `loop_end` to length/2 in some configurations
9
+ (depends on time signature defaults). Downstream tools that add notes
10
+ beyond the implicit half-length loop see them silently dropped — silent
11
+ data corruption. These helpers centralize the "loop_end must equal
12
+ intent" rule so it can't drift back.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+
18
+ def _required_loop_end(notes) -> float:
19
+ """Return the largest start_time + duration across the notes list.
20
+
21
+ Pure function. Used by `add_notes` to decide whether to extend the
22
+ clip's loop_end before adding the notes — Live silently drops notes
23
+ that fall outside [loop_start, loop_end).
24
+
25
+ Returns 0.0 for an empty list. Defensively treats missing duration
26
+ as 0 so a malformed note (start_time only) doesn't raise.
27
+ """
28
+ if not notes:
29
+ return 0.0
30
+ return max(
31
+ float(n.get("start_time", 0.0)) + float(n.get("duration", 0.0))
32
+ for n in notes
33
+ )
34
+
35
+
36
+ def _apply_clip_length_invariants(clip, length: float) -> dict:
37
+ """Force a freshly-created clip's loop_end + end_marker to match length.
38
+
39
+ Mutates `clip` in place. Returns a dict suitable for inclusion in a
40
+ handler response (`{loop_end, end_marker}`). Catches AttributeError
41
+ and RuntimeError defensively — some Live builds enforce
42
+ `loop_end <= sample_length` on audio clips, but for MIDI this should
43
+ always succeed. On failure, returns the actual current values so the
44
+ caller can surface the discrepancy.
45
+ """
46
+ try:
47
+ clip.loop_end = length
48
+ except (AttributeError, RuntimeError):
49
+ pass
50
+ try:
51
+ clip.end_marker = length
52
+ except (AttributeError, RuntimeError):
53
+ pass
54
+
55
+ return {
56
+ "loop_end": getattr(clip, "loop_end", None),
57
+ "end_marker": getattr(clip, "end_marker", None),
58
+ }
59
+
60
+
61
+ def _extend_loop_end_for_notes(clip, notes) -> float | None:
62
+ """If any incoming note exceeds clip.loop_end, extend it to fit.
63
+
64
+ Returns the new loop_end value if extended, or None if no extension
65
+ was needed. Also updates `end_marker` to keep them aligned (Live
66
+ treats end_marker as the "stop reading" marker when looping is off).
67
+ """
68
+ required = _required_loop_end(notes)
69
+ current = getattr(clip, "loop_end", 0.0)
70
+ if required <= current:
71
+ return None
72
+
73
+ try:
74
+ clip.loop_end = required
75
+ except (AttributeError, RuntimeError):
76
+ return None
77
+
78
+ try:
79
+ # Keep end_marker at least as far out as loop_end. Don't shrink
80
+ # it if the user has set it further out manually.
81
+ existing_end = getattr(clip, "end_marker", required)
82
+ clip.end_marker = max(existing_end, required)
83
+ except (AttributeError, RuntimeError):
84
+ pass
85
+
86
+ return required
@@ -0,0 +1,40 @@
1
+ """Pure-Python helpers for Drum Rack chain operations.
2
+
3
+ Importable outside Ableton (no `_Framework` dependency, no `import Live`).
4
+ The main `devices.py` module re-imports these helpers so the live
5
+ handler and contract tests run identical logic.
6
+
7
+ BUG-2026-04-22 #13 context: Live's `RackDevice.insert_chain()` always
8
+ defaults the new chain's `in_note` to 36 — same as every previous chain.
9
+ Repeated inserts pile up multiple chains on note 36 ("Multi") and they
10
+ can't be triggered independently from a MIDI pattern. The helper here
11
+ picks the next free MIDI slot above any existing chain so each
12
+ insert_rack_chain call lands on a distinct pad.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+
18
+ def _next_drum_chain_note(device):
19
+ """Pick the next MIDI note for a new Drum Rack chain.
20
+
21
+ Walks existing drum-rack chains, returns max(in_note) + 1 clamped to
22
+ 127. Returns 36 (standard kick slot) if the rack has no chains yet.
23
+ Returns None if the device is not a drum rack — chains without an
24
+ `in_note` attribute mean it's an Instrument or Audio Effect Rack,
25
+ where pad-note assignment doesn't apply.
26
+
27
+ Pure function — `device` only needs `.chains` (iterable) and each
28
+ chain only needs `.in_note` (int). Tests pass plain Python objects.
29
+ """
30
+ chains = list(getattr(device, "chains", []) or [])
31
+ in_notes = []
32
+ for chain in chains:
33
+ try:
34
+ in_notes.append(int(chain.in_note))
35
+ except (AttributeError, TypeError, ValueError):
36
+ # Non-drum rack chains don't have in_note — bail.
37
+ return None
38
+ if not in_notes:
39
+ return 36 # standard kick slot for the first chain
40
+ return min(127, max(in_notes) + 1)
@@ -0,0 +1,87 @@
1
+ """Pure-Python helpers for scale handling — importable outside Ableton.
2
+
3
+ These helpers are extracted so tests can import them without pulling in
4
+ Live's `_Framework` package (which only exists inside the Ableton
5
+ runtime). The main scales.py module re-imports them.
6
+
7
+ Nothing here talks to Live's LOM. The `_resolve_scale_names` helper
8
+ takes a `song` argument but only calls `getattr` on it, so duck-typing
9
+ is enough — tests pass a plain object with the right attrs.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+
15
+ # Live's built-in scale names — stable since 12.0. Used as a final fallback
16
+ # when both `Song.scale_names` and the alternative attribute names are absent
17
+ # (e.g. Live 12.4.0 where `scale_names` attribute was dropped from Python LOM).
18
+ # Source: Live 12 Manual — "Scale Mode" chapter; order matches Live's picker.
19
+ _BUILTIN_SCALES_FALLBACK = [
20
+ "Major", "Minor", "Dorian", "Mixolydian", "Lydian", "Phrygian",
21
+ "Locrian", "Whole Tone", "Half-whole Dim.", "Whole-half Dim.",
22
+ "Minor Blues", "Minor Pentatonic", "Major Pentatonic",
23
+ "Harmonic Minor", "Harmonic Major", "Dorian #4", "Phrygian Dominant",
24
+ "Melodic Minor", "Lydian Augmented", "Lydian Dominant",
25
+ "Super Locrian", "8-Tone Spanish",
26
+ ]
27
+
28
+
29
+ def _resolve_scale_names(song):
30
+ """Get Live's available scale names, resilient to 12.4 API changes.
31
+
32
+ Probes: song.scale_names, song.available_scale_names, song.get_scale_names()
33
+ in that order. Falls back to _BUILTIN_SCALES_FALLBACK so callers always
34
+ get a usable list rather than an AttributeError.
35
+ """
36
+ for attr in ("scale_names", "available_scale_names"):
37
+ try:
38
+ value = getattr(song, attr, None)
39
+ if value is not None:
40
+ return list(value)
41
+ except Exception:
42
+ continue
43
+ for attr in ("get_scale_names", "get_available_scale_names"):
44
+ try:
45
+ fn = getattr(song, attr, None)
46
+ if callable(fn):
47
+ value = fn()
48
+ if value is not None:
49
+ return list(value)
50
+ except Exception:
51
+ continue
52
+ return list(_BUILTIN_SCALES_FALLBACK)
53
+
54
+
55
+ def _coerce_root_note(value):
56
+ """Accept int 0-11 OR note-name string ('C', 'C#', 'Db', 'F#', etc.)."""
57
+ if isinstance(value, bool):
58
+ # bool is a subclass of int in Python — reject explicitly.
59
+ raise ValueError("root_note cannot be a boolean")
60
+ if isinstance(value, int):
61
+ root = value
62
+ elif isinstance(value, str):
63
+ s = value.strip()
64
+ try:
65
+ root = int(s)
66
+ except ValueError:
67
+ name_map = {
68
+ "C": 0, "C#": 1, "Db": 1, "D": 2, "D#": 3, "Eb": 3,
69
+ "E": 4, "Fb": 4, "E#": 5, "F": 5, "F#": 6, "Gb": 6,
70
+ "G": 7, "G#": 8, "Ab": 8, "A": 9, "A#": 10, "Bb": 10,
71
+ "B": 11, "Cb": 11,
72
+ }
73
+ if not s:
74
+ raise ValueError("root_note string cannot be empty")
75
+ norm = s[0].upper() + s[1:]
76
+ if norm not in name_map:
77
+ raise ValueError(
78
+ "Unknown note name '%s'. Use C, C#, Db, D, ..., B." % value
79
+ )
80
+ root = name_map[norm]
81
+ else:
82
+ raise ValueError(
83
+ "root_note must be int (0-11) or note name string ('C#', 'F', ...)"
84
+ )
85
+ if not 0 <= root <= 11:
86
+ raise ValueError("root_note must be 0-11 (C=0, C#=1, ... B=11)")
87
+ return root
@@ -608,24 +608,53 @@ def set_arrangement_automation(song, params):
608
608
  "method": "direct",
609
609
  }
610
610
 
611
- # No fallback direct envelope creation is the only safe approach.
612
- # Session-clip duplication can silently create overlapping clips.
611
+ # BUG-2026-04-22#1b: This is a LIVE API LIMITATION, not a LivePilot bug.
613
612
  #
614
- # Known limitation: clips created via create_arrangement_clip
615
- # (duplicate_clip_to_arrangement) often cannot have envelopes
616
- # created programmatically. Workaround: record automation by
617
- # arming the track and playing back with parameter changes,
618
- # or use session-clip automation (set_clip_automation) and then
619
- # record the session performance to arrangement.
613
+ # Per Ableton's Python LOM documentation:
614
+ # "Clip.automation_envelope returns None for Arrangement clips
615
+ # and parameters from a different track."
616
+ #
617
+ # The Python LOM exposes value_at_time() for READING existing
618
+ # arrangement automation, but does NOT expose a method to CREATE
619
+ # new automation breakpoints programmatically in the arrangement
620
+ # view. Automation in arrangement view lives on the track's
621
+ # automation lane, which is only writable via the GUI or by
622
+ # recording.
623
+ #
624
+ # The two viable workarounds:
625
+ #
626
+ # 1. session-clip path (programmatic, but requires manual record):
627
+ # a. Use set_clip_automation on a session clip to write the points
628
+ # b. Arm the track for recording
629
+ # c. Switch to arrangement view and start recording at the target
630
+ # position
631
+ # d. Fire the session clip — Live records the parameter changes
632
+ # into the arrangement automation lane
633
+ # e. Stop recording when the session clip completes
634
+ #
635
+ # 2. manual draw path (most reliable): the user draws the envelope
636
+ # in arrangement view by hand. No code can replace this for now.
637
+ #
638
+ # We surface this clearly rather than silently failing or attempting
639
+ # an unreliable workaround that could leave the session in a half-
640
+ # configured state.
620
641
  raise RuntimeError(
621
642
  "Cannot create automation envelope for parameter '%s' on this "
622
- "arrangement clip. Direct envelope access is not supported for "
623
- "programmatically-created arrangement clips (known Live API limitation). "
624
- "Workarounds: "
625
- "(1) use set_clip_automation on a session clip instead, then fire "
626
- "the scene and record to arrangement; "
627
- "(2) use arrangement-level volume/pan fades by creating separate "
628
- "clips at different volumes for each section."
643
+ "arrangement clip. This is a Live LOM limitation, not a "
644
+ "LivePilot bug: per Ableton's Python API docs, "
645
+ "Clip.automation_envelope returns None for arrangement clips, "
646
+ "and the LOM does not expose a method to CREATE new automation "
647
+ "breakpoints in arrangement view (only value_at_time for "
648
+ "READING existing automation). "
649
+ "Two workarounds: "
650
+ "(1) Session-clip path — call set_clip_automation on a session "
651
+ "clip with the same points, then arm the track, switch to "
652
+ "arrangement view, start arrangement record, fire the session "
653
+ "clip, stop record when it finishes. Live records the parameter "
654
+ "changes into the arrangement track lane. "
655
+ "(2) Section-clip path — slice the arrangement into multiple "
656
+ "clips and set per-clip volume/parameter values per section "
657
+ "(no envelope, just stepped values per region)."
629
658
  % parameter.name
630
659
  )
631
660
 
@@ -1,11 +1,176 @@
1
1
  """
2
- LivePilot - Clip domain handlers (11 commands).
2
+ LivePilot - Clip domain handlers (12 commands).
3
3
  """
4
4
 
5
5
  from .router import register
6
6
  from .utils import get_clip, get_clip_slot
7
7
 
8
8
 
9
+ # Scratch clip slot used by fire_test_note (BUG-2026-04-22#19). We pick a
10
+ # slot far above the user's usual scene count; a helper reuses/cleans up
11
+ # the slot between calls so no clutter accumulates.
12
+ _TEST_NOTE_SLOT_INDEX = 127 # max scene count is 1024; 127 is safe
13
+
14
+
15
+ @register("fire_test_note")
16
+ def fire_test_note(song, params):
17
+ """Fire a single MIDI note at a track's instrument to verify output.
18
+
19
+ Creates a one-note MIDI clip in a scratch slot, fires it, waits for
20
+ the note duration, then stops + deletes it. No session clutter.
21
+
22
+ Required: track_index, midi_note, velocity, duration_ms
23
+ Returns: {fired: bool, track_index, note, velocity, duration_ms}
24
+
25
+ Refuses on audio tracks (no MIDI input) — returns error instead of
26
+ crashing. Works on any MIDI track with an instrument loaded, including
27
+ Drum Racks and Instrument Racks.
28
+ """
29
+ track_index = int(params["track_index"])
30
+ midi_note = int(params["midi_note"])
31
+ velocity = int(params["velocity"])
32
+ duration_ms = int(params["duration_ms"])
33
+
34
+ if not 0 <= midi_note <= 127:
35
+ raise ValueError("midi_note must be 0-127")
36
+ if not 1 <= velocity <= 127:
37
+ raise ValueError("velocity must be 1-127")
38
+ if duration_ms < 50 or duration_ms > 5000:
39
+ raise ValueError("duration_ms must be 50-5000")
40
+
41
+ tracks = list(song.tracks)
42
+ if not 0 <= track_index < len(tracks):
43
+ return {"error": "track_index out of range", "code": "INDEX_ERROR"}
44
+ track = tracks[track_index]
45
+
46
+ if not getattr(track, "has_midi_input", True):
47
+ return {
48
+ "error": "Track has no MIDI input — fire_test_note only works "
49
+ "on MIDI tracks with an instrument loaded",
50
+ "code": "INVALID_PARAM",
51
+ }
52
+
53
+ # Use a scratch slot far above the user's usual scene count.
54
+ clip_slots = list(track.clip_slots)
55
+ scene_count = len(clip_slots)
56
+ slot_idx = min(_TEST_NOTE_SLOT_INDEX, scene_count - 1)
57
+ if slot_idx < 0:
58
+ return {"error": "No clip slots available", "code": "STATE_ERROR"}
59
+
60
+ slot = clip_slots[slot_idx]
61
+
62
+ # If the slot already has a clip (unlikely at index 127), delete it
63
+ # first to avoid stomping on user content.
64
+ if slot.has_clip:
65
+ try:
66
+ slot.delete_clip()
67
+ except Exception:
68
+ pass
69
+
70
+ # Create a short MIDI clip. Length is in beats — 0.25 beat at any
71
+ # reasonable tempo is <500ms; we don't rely on clip length for the
72
+ # note duration, the MCP tool measures real time.
73
+ song.begin_undo_step()
74
+ try:
75
+ slot.create_clip(0.5)
76
+ clip = slot.clip
77
+ if clip is None:
78
+ return {"error": "Failed to create scratch clip", "code": "INTERNAL"}
79
+
80
+ # Add the test note. Live 12 modern API: add_new_notes with a
81
+ # notes_specification struct. Fall back to legacy set_notes.
82
+ note_length = min(duration_ms / 1000.0, 0.45) # stay inside the clip
83
+ if hasattr(clip, "add_new_notes"):
84
+ spec = _build_notes_spec(midi_note, velocity, note_length)
85
+ if spec is not None:
86
+ try:
87
+ clip.add_new_notes(spec)
88
+ except Exception:
89
+ _legacy_set_notes(clip, midi_note, velocity, note_length)
90
+ else:
91
+ _legacy_set_notes(clip, midi_note, velocity, note_length)
92
+ else:
93
+ _legacy_set_notes(clip, midi_note, velocity, note_length)
94
+
95
+ # Fire the clip. The MCP caller samples the meter over duration_ms.
96
+ slot.fire()
97
+ finally:
98
+ song.end_undo_step()
99
+
100
+ return {
101
+ "fired": True,
102
+ "track_index": track_index,
103
+ "slot_index": slot_idx,
104
+ "midi_note": midi_note,
105
+ "velocity": velocity,
106
+ "duration_ms": duration_ms,
107
+ "note": "clip created + fired; caller is responsible for post-sample cleanup via cleanup_test_note",
108
+ }
109
+
110
+
111
+ @register("cleanup_test_note")
112
+ def cleanup_test_note(song, params):
113
+ """Stop + delete the scratch clip created by fire_test_note.
114
+
115
+ Idempotent — does nothing if no scratch clip exists.
116
+ Required: track_index
117
+ """
118
+ track_index = int(params["track_index"])
119
+ tracks = list(song.tracks)
120
+ if not 0 <= track_index < len(tracks):
121
+ return {"cleaned": False}
122
+ track = tracks[track_index]
123
+ clip_slots = list(track.clip_slots)
124
+ slot_idx = min(_TEST_NOTE_SLOT_INDEX, len(clip_slots) - 1)
125
+ if slot_idx < 0:
126
+ return {"cleaned": False}
127
+ slot = clip_slots[slot_idx]
128
+ try:
129
+ if slot.has_clip:
130
+ if slot.is_playing:
131
+ slot.stop()
132
+ slot.delete_clip()
133
+ return {"cleaned": True}
134
+ except Exception:
135
+ return {"cleaned": False}
136
+
137
+
138
+ def _build_notes_spec(midi_note, velocity, duration_beats):
139
+ """Build the Live 12 add_new_notes argument — a list of NoteSpecification.
140
+
141
+ Returns None when the Live build doesn't expose the modern API (caller
142
+ falls back to legacy set_notes).
143
+ """
144
+ try:
145
+ from Live.Clip import MidiNoteSpecification
146
+ except ImportError:
147
+ return None
148
+ try:
149
+ return [MidiNoteSpecification(
150
+ pitch=midi_note,
151
+ start_time=0.0,
152
+ duration=duration_beats,
153
+ velocity=velocity,
154
+ mute=False,
155
+ )]
156
+ except Exception:
157
+ return None
158
+
159
+
160
+ def _legacy_set_notes(clip, midi_note, velocity, duration_beats):
161
+ """Fallback for pre-12 Live builds — uses set_notes tuple API."""
162
+ try:
163
+ clip.select_all_notes()
164
+ clip.replace_selected_notes((
165
+ (midi_note, 0.0, duration_beats, velocity, False),
166
+ ))
167
+ except Exception:
168
+ # Last-resort: ignore silently. Caller will see no meter bump
169
+ # and report the device as dead — which is less informative
170
+ # than "handler couldn't fire" but still fails-safe.
171
+ pass
172
+
173
+
9
174
  @register("get_clip_info")
10
175
  def get_clip_info(song, params):
11
176
  """Return detailed info for a single clip."""
@@ -52,7 +217,18 @@ def get_clip_info(song, params):
52
217
 
53
218
  @register("create_clip")
54
219
  def create_clip(song, params):
55
- """Create an empty MIDI clip in the given clip slot."""
220
+ """Create an empty MIDI clip in the given clip slot.
221
+
222
+ BUG-2026-04-22#1c FIX: Live's `clip_slot.create_clip(length)` sets
223
+ the clip's *length* but defaults `loop_end` to length/2 in some
224
+ configurations (depends on time signature). Without enforcing
225
+ `loop_end == length`, downstream `add_notes` calls silently drop
226
+ notes that fall beyond the implicit half-length loop. This handler
227
+ now applies the invariant via `_apply_clip_length_invariants` (a
228
+ pure-Python helper that's also unit-tested).
229
+ """
230
+ from ._clip_helpers import _apply_clip_length_invariants
231
+
56
232
  track_index = int(params["track_index"])
57
233
  clip_index = int(params["clip_index"])
58
234
  length = float(params["length"])
@@ -68,11 +244,15 @@ def create_clip(song, params):
68
244
  clip_slot.create_clip(length)
69
245
  clip = clip_slot.clip
70
246
 
247
+ invariants = _apply_clip_length_invariants(clip, length)
248
+
71
249
  return {
72
250
  "track_index": track_index,
73
251
  "clip_index": clip_index,
74
252
  "name": clip.name,
75
253
  "length": clip.length,
254
+ "loop_end": invariants["loop_end"],
255
+ "end_marker": invariants["end_marker"],
76
256
  }
77
257
 
78
258
 
@@ -558,9 +558,16 @@ def insert_rack_chain(song, params):
558
558
  """Insert a new chain into an Instrument Rack, Audio Effect Rack, or Drum Rack (12.3+).
559
559
 
560
560
  Required: track_index, device_index
561
- Optional: position (-1 = end)
561
+ Optional: position (-1 = end), auto_pad_note (default True for drum racks)
562
+
563
+ BUG-2026-04-22#13 FIX: For Drum Racks, the new chain's `in_note` is
564
+ auto-incremented to the next free MIDI slot above any existing chain
565
+ (or 36 if it's the first). Without this, multiple new chains pile up
566
+ on note 36 ("Multi") and can't be triggered independently. Pass
567
+ `auto_pad_note=false` to keep Live's default behavior.
562
568
  """
563
569
  from .version_detect import has_feature
570
+ from ._drum_helpers import _next_drum_chain_note
564
571
 
565
572
  if not has_feature("insert_chain"):
566
573
  raise RuntimeError(
@@ -570,6 +577,7 @@ def insert_rack_chain(song, params):
570
577
  track_index = int(params["track_index"])
571
578
  device_index = int(params["device_index"])
572
579
  position = int(params.get("position", -1))
580
+ auto_pad_note = bool(params.get("auto_pad_note", True))
573
581
 
574
582
  track = get_track(song, track_index)
575
583
  device = get_device(track, device_index)
@@ -580,22 +588,94 @@ def insert_rack_chain(song, params):
580
588
  % device.name
581
589
  )
582
590
 
591
+ next_note = _next_drum_chain_note(device) if auto_pad_note else None
592
+
583
593
  song.begin_undo_step()
594
+ assigned_note = None
584
595
  try:
585
596
  if position >= 0:
586
597
  device.insert_chain(position)
587
598
  else:
588
599
  device.insert_chain()
600
+
601
+ # Apply auto pad-note if this is a drum rack.
602
+ if next_note is not None:
603
+ chains = list(device.chains)
604
+ if chains:
605
+ # The newly inserted chain is the last one (insert_chain()
606
+ # appends; insert_chain(N) inserts at N, so prefer the
607
+ # explicit position if given).
608
+ target_idx = position if position >= 0 else len(chains) - 1
609
+ if 0 <= target_idx < len(chains):
610
+ new_chain = chains[target_idx]
611
+ try:
612
+ new_chain.in_note = next_note
613
+ assigned_note = next_note
614
+ except (AttributeError, TypeError):
615
+ # Not a drum chain after all — silent skip.
616
+ pass
589
617
  finally:
590
618
  song.end_undo_step()
591
619
 
592
620
  chain_count = len(list(device.chains))
593
- return {
621
+ result = {
594
622
  "inserted": True,
595
623
  "track_index": track_index,
596
624
  "device_index": device_index,
597
625
  "chain_count": chain_count,
598
626
  }
627
+ if assigned_note is not None:
628
+ result["assigned_pad_note"] = assigned_note
629
+ return result
630
+
631
+
632
+ @register("set_chain_name")
633
+ def set_chain_name(song, params):
634
+ """Rename a chain inside any Rack device (Instrument / Audio Effect / Drum).
635
+
636
+ Required: track_index, device_index, chain_index, name
637
+ Returns the applied name (which Live may truncate) + chain path.
638
+
639
+ Completes the Drum-Rack construction UX opened by BUG-2026-04-22#1:
640
+ add_drum_rack_pad now rides this handler to actually apply the
641
+ user-supplied chain_name server-side instead of leaving it as a hint.
642
+ """
643
+ track_index = int(params["track_index"])
644
+ device_index = int(params["device_index"])
645
+ chain_index = int(params["chain_index"])
646
+ name = str(params.get("name", "")).strip()
647
+ if not name:
648
+ raise ValueError("name cannot be empty")
649
+
650
+ track = get_track(song, track_index)
651
+ device = get_device(track, device_index)
652
+
653
+ if not getattr(device, "can_have_chains", False):
654
+ raise ValueError(
655
+ "Device '%s' is not a rack — cannot rename chains" % device.name
656
+ )
657
+
658
+ chains = list(device.chains)
659
+ if chain_index < 0 or chain_index >= len(chains):
660
+ raise IndexError(
661
+ "Chain index %d out of range (0..%d)"
662
+ % (chain_index, len(chains) - 1)
663
+ )
664
+
665
+ chain = chains[chain_index]
666
+ try:
667
+ chain.name = name
668
+ except AttributeError:
669
+ raise RuntimeError(
670
+ "Chain object does not expose a writable `name` property"
671
+ )
672
+
673
+ return {
674
+ "track_index": track_index,
675
+ "device_index": device_index,
676
+ "chain_index": chain_index,
677
+ "name": getattr(chain, "name", name),
678
+ }
599
679
 
600
680
 
601
681
  @register("set_drum_chain_note")
@@ -11,7 +11,17 @@ from .utils import get_clip, get_track
11
11
 
12
12
  @register("add_notes")
13
13
  def add_notes(song, params):
14
- """Add MIDI notes to a clip using Live 12's modern API."""
14
+ """Add MIDI notes to a clip using Live 12's modern API.
15
+
16
+ BUG-2026-04-22#1c FIX: Auto-extends `clip.loop_end` if any incoming
17
+ note's `start_time + duration` exceeds it. Without this, Live
18
+ silently drops the out-of-range notes — the response would say
19
+ "notes_added: N" but get_notes would return fewer. The extension
20
+ is reported back in the response as `loop_end_extended_to` when it
21
+ fires, so callers can see what happened.
22
+ """
23
+ from ._clip_helpers import _extend_loop_end_for_notes
24
+
15
25
  track_index = int(params["track_index"])
16
26
  clip_index = int(params["clip_index"])
17
27
  notes = params["notes"]
@@ -19,6 +29,8 @@ def add_notes(song, params):
19
29
  raise ValueError("notes list cannot be empty")
20
30
 
21
31
  clip = get_clip(song, track_index, clip_index)
32
+ extended_to = _extend_loop_end_for_notes(clip, notes)
33
+
22
34
  import Live
23
35
  song.begin_undo_step()
24
36
  try:
@@ -43,11 +55,14 @@ def add_notes(song, params):
43
55
  finally:
44
56
  song.end_undo_step()
45
57
 
46
- return {
58
+ result = {
47
59
  "track_index": track_index,
48
60
  "clip_index": clip_index,
49
61
  "notes_added": len(notes),
50
62
  }
63
+ if extended_to is not None:
64
+ result["loop_end_extended_to"] = extended_to
65
+ return result
51
66
 
52
67
 
53
68
  @register("get_notes")