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.
- package/CHANGELOG.md +176 -1
- package/README.md +6 -6
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/device_atlas.json +91219 -7161
- package/mcp_server/atlas/tools.py +30 -2
- package/mcp_server/runtime/live_version.py +4 -2
- package/mcp_server/runtime/remote_commands.py +5 -0
- package/mcp_server/sample_engine/tools.py +692 -60
- package/mcp_server/splice_client/client.py +511 -65
- package/mcp_server/splice_client/http_bridge.py +361 -0
- package/mcp_server/splice_client/models.py +266 -2
- package/mcp_server/splice_client/quota.py +229 -0
- package/mcp_server/tools/_analyzer_engine/__init__.py +4 -0
- package/mcp_server/tools/_analyzer_engine/sample.py +73 -0
- package/mcp_server/tools/analyzer.py +666 -6
- package/mcp_server/tools/browser.py +164 -13
- package/mcp_server/tools/devices.py +56 -11
- package/mcp_server/tools/mixing.py +64 -15
- package/mcp_server/tools/scales.py +18 -6
- package/mcp_server/tools/tracks.py +92 -4
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +2 -1
- package/remote_script/LivePilot/_clip_helpers.py +86 -0
- package/remote_script/LivePilot/_drum_helpers.py +40 -0
- package/remote_script/LivePilot/_scale_helpers.py +87 -0
- package/remote_script/LivePilot/arrangement.py +44 -15
- package/remote_script/LivePilot/clips.py +182 -2
- package/remote_script/LivePilot/devices.py +82 -2
- package/remote_script/LivePilot/notes.py +17 -2
- package/remote_script/LivePilot/scales.py +31 -16
- package/remote_script/LivePilot/simpler_sample.py +186 -0
- 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
|
-
#
|
|
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
|
-
#
|
|
615
|
-
#
|
|
616
|
-
#
|
|
617
|
-
#
|
|
618
|
-
#
|
|
619
|
-
#
|
|
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.
|
|
623
|
-
"
|
|
624
|
-
"
|
|
625
|
-
"
|
|
626
|
-
"
|
|
627
|
-
"
|
|
628
|
-
"
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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")
|