livepilot 1.9.9 → 1.9.12
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/.claude-plugin/marketplace.json +1 -1
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +92 -0
- package/CODE_OF_CONDUCT.md +27 -0
- package/CONTRIBUTING.md +131 -0
- package/README.md +112 -387
- package/SECURITY.md +48 -0
- package/bin/livepilot.js +42 -2
- package/livepilot/.Codex-plugin/plugin.json +8 -0
- package/livepilot/.claude-plugin/plugin.json +1 -1
- package/livepilot/commands/beat.md +18 -6
- package/livepilot/commands/sounddesign.md +6 -5
- package/livepilot/skills/livepilot-core/SKILL.md +36 -13
- package/livepilot/skills/livepilot-core/references/overview.md +1 -1
- package/livepilot/skills/livepilot-release/SKILL.md +11 -8
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/LivePilot_Analyzer.maxpat +378 -4
- package/m4l_device/capture_2026_04_07_192216.wav +0 -0
- package/m4l_device/livepilot_bridge.js +487 -184
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/connection.py +40 -1
- package/mcp_server/curves.py +5 -1
- package/mcp_server/m4l_bridge.py +93 -26
- package/mcp_server/server.py +26 -31
- package/mcp_server/tools/_perception_engine.py +26 -3
- package/mcp_server/tools/_theory_engine.py +36 -5
- package/mcp_server/tools/analyzer.py +74 -18
- package/mcp_server/tools/arrangement.py +20 -5
- package/mcp_server/tools/automation.py +56 -1
- package/mcp_server/tools/devices.py +201 -13
- package/mcp_server/tools/generative.py +8 -1
- package/mcp_server/tools/harmony.py +16 -3
- package/mcp_server/tools/midi_io.py +22 -4
- package/mcp_server/tools/notes.py +4 -1
- package/mcp_server/tools/perception.py +27 -1
- package/mcp_server/tools/scenes.py +4 -1
- package/mcp_server/tools/theory.py +23 -8
- package/mcp_server/tools/transport.py +16 -6
- package/package.json +1 -1
- package/remote_script/LivePilot/__init__.py +1 -1
- package/remote_script/LivePilot/arrangement.py +25 -134
- package/remote_script/LivePilot/browser.py +19 -8
- package/remote_script/LivePilot/clip_automation.py +5 -4
- package/remote_script/LivePilot/clips.py +14 -6
- package/remote_script/LivePilot/devices.py +6 -3
- package/remote_script/LivePilot/diagnostics.py +81 -5
- package/remote_script/LivePilot/router.py +22 -0
- package/remote_script/LivePilot/server.py +41 -17
- package/remote_script/LivePilot/tracks.py +7 -2
- package/remote_script/LivePilot/transport.py +3 -3
- package/requirements.txt +3 -1
|
@@ -19,7 +19,10 @@ from . import _theory_engine as theory
|
|
|
19
19
|
|
|
20
20
|
def _ensure_list(value: Any) -> list:
|
|
21
21
|
if isinstance(value, str):
|
|
22
|
-
|
|
22
|
+
try:
|
|
23
|
+
return json.loads(value)
|
|
24
|
+
except json.JSONDecodeError as exc:
|
|
25
|
+
raise ValueError(f"Invalid JSON in parameter: {exc}") from exc
|
|
23
26
|
return value
|
|
24
27
|
|
|
25
28
|
|
|
@@ -168,8 +171,18 @@ def classify_progression(
|
|
|
168
171
|
if len(chords) < 2:
|
|
169
172
|
return {"error": "Need at least 2 chords to classify"}
|
|
170
173
|
|
|
174
|
+
# Normalize dict inputs like {"root": "F#", "quality": "minor"} to strings
|
|
175
|
+
normalized = []
|
|
176
|
+
for c in chords:
|
|
177
|
+
if isinstance(c, dict):
|
|
178
|
+
root = c.get("root", "")
|
|
179
|
+
quality = c.get("quality", "major")
|
|
180
|
+
normalized.append(f"{root} {quality}")
|
|
181
|
+
else:
|
|
182
|
+
normalized.append(str(c))
|
|
183
|
+
|
|
171
184
|
try:
|
|
172
|
-
parsed = [harmony.parse_chord(c) for c in
|
|
185
|
+
parsed = [harmony.parse_chord(c) for c in normalized]
|
|
173
186
|
except ValueError as e:
|
|
174
187
|
return {"error": str(e)}
|
|
175
188
|
|
|
@@ -198,7 +211,7 @@ def classify_progression(
|
|
|
198
211
|
classification = names.get(clean, classification)
|
|
199
212
|
|
|
200
213
|
return {
|
|
201
|
-
"chords":
|
|
214
|
+
"chords": normalized,
|
|
202
215
|
"transforms": transforms,
|
|
203
216
|
"pattern": pattern,
|
|
204
217
|
"classification": classification,
|
|
@@ -51,6 +51,22 @@ def _output_dir() -> Path:
|
|
|
51
51
|
return d
|
|
52
52
|
|
|
53
53
|
|
|
54
|
+
def _safe_output_path(directory: Path, filename: str) -> Path:
|
|
55
|
+
"""Join *filename* to *directory* with path-traversal containment.
|
|
56
|
+
|
|
57
|
+
Strips directory components (``../../evil.mid`` → ``evil.mid``),
|
|
58
|
+
resolves the result, and verifies it is still inside *directory*.
|
|
59
|
+
Raises ``ValueError`` on any escape attempt.
|
|
60
|
+
"""
|
|
61
|
+
safe_name = Path(filename).name # strip directory components
|
|
62
|
+
if not safe_name:
|
|
63
|
+
raise ValueError(f"Invalid filename: {filename!r}")
|
|
64
|
+
out = (directory / safe_name).resolve()
|
|
65
|
+
if not str(out).startswith(str(directory.resolve())):
|
|
66
|
+
raise ValueError(f"Filename escapes output directory: {filename!r}")
|
|
67
|
+
return out
|
|
68
|
+
|
|
69
|
+
|
|
54
70
|
def _validate_midi_path(file_path: str) -> Path:
|
|
55
71
|
p = Path(file_path)
|
|
56
72
|
if not p.exists():
|
|
@@ -112,7 +128,7 @@ def export_clip_midi(
|
|
|
112
128
|
if not filename.endswith((".mid", ".midi")):
|
|
113
129
|
filename += ".mid"
|
|
114
130
|
|
|
115
|
-
out_path = _output_dir()
|
|
131
|
+
out_path = _safe_output_path(_output_dir(), filename)
|
|
116
132
|
|
|
117
133
|
midi = MIDIFile(1)
|
|
118
134
|
midi.addTempo(0, 0, tempo)
|
|
@@ -185,9 +201,11 @@ def import_midi_to_clip(
|
|
|
185
201
|
"clip_index": clip_index,
|
|
186
202
|
})
|
|
187
203
|
slot_has_clip = True
|
|
188
|
-
except AbletonConnectionError:
|
|
189
|
-
|
|
190
|
-
|
|
204
|
+
except AbletonConnectionError as exc:
|
|
205
|
+
msg = str(exc)
|
|
206
|
+
if "NOT_FOUND" not in msg and "STATE_ERROR" not in msg:
|
|
207
|
+
raise # propagate INDEX_ERROR, TIMEOUT, connection failures
|
|
208
|
+
# Slot is empty (NOT_FOUND) or no clip (STATE_ERROR)
|
|
191
209
|
|
|
192
210
|
if slot_has_clip:
|
|
193
211
|
# Clip exists — clear its notes before importing
|
|
@@ -21,7 +21,10 @@ def _get_ableton(ctx: Context):
|
|
|
21
21
|
def _ensure_list(value: Any) -> list:
|
|
22
22
|
"""Parse JSON strings into lists. MCP clients may serialize list params as strings."""
|
|
23
23
|
if isinstance(value, str):
|
|
24
|
-
|
|
24
|
+
try:
|
|
25
|
+
return json.loads(value)
|
|
26
|
+
except json.JSONDecodeError as exc:
|
|
27
|
+
raise ValueError(f"Invalid JSON in parameter: {exc}") from exc
|
|
25
28
|
return value
|
|
26
29
|
|
|
27
30
|
|
|
@@ -34,11 +34,31 @@ _LOSSY_EXTS = {".mp3", ".m4a"}
|
|
|
34
34
|
_MAX_FILE_SIZE = 500 * 1024 * 1024 # 500 MB
|
|
35
35
|
|
|
36
36
|
|
|
37
|
+
def _normalize_audio_path(file_path: str) -> str:
|
|
38
|
+
"""Convert Max-style HFS-ish paths into POSIX paths when possible."""
|
|
39
|
+
if len(file_path) >= 3 and file_path[1] == ":" and file_path[2] in ("/", "\\"):
|
|
40
|
+
return file_path
|
|
41
|
+
|
|
42
|
+
colon = file_path.find(":")
|
|
43
|
+
slash = file_path.find("/")
|
|
44
|
+
if colon <= 0 or (slash != -1 and colon > slash):
|
|
45
|
+
return file_path
|
|
46
|
+
|
|
47
|
+
rest = file_path[colon + 1:]
|
|
48
|
+
if ":" in rest:
|
|
49
|
+
rest = rest.replace(":", "/")
|
|
50
|
+
if not rest.startswith("/"):
|
|
51
|
+
rest = "/" + rest.lstrip("/\\")
|
|
52
|
+
return rest
|
|
53
|
+
|
|
54
|
+
|
|
37
55
|
def _validate_audio(file_path: str, allow_mp3: bool = False) -> Optional[dict]:
|
|
38
56
|
"""Validate that a file exists, has a supported extension, and is < 500 MB.
|
|
39
57
|
|
|
40
58
|
Returns None if valid, or an error dict if invalid.
|
|
41
59
|
"""
|
|
60
|
+
file_path = _normalize_audio_path(file_path)
|
|
61
|
+
|
|
42
62
|
if not os.path.exists(file_path):
|
|
43
63
|
return {"error": f"File not found: {file_path}", "code": "INVALID_PARAM"}
|
|
44
64
|
|
|
@@ -84,7 +104,8 @@ def analyze_loudness(
|
|
|
84
104
|
array (up to 100 points, mean-pooled).
|
|
85
105
|
|
|
86
106
|
Returns:
|
|
87
|
-
On success: dict with integrated_lufs, true_peak_dbtp
|
|
107
|
+
On success: dict with integrated_lufs, true_peak_dbtp (4x oversampled),
|
|
108
|
+
sample_peak_dbfs (raw sample peak, kept for backward compat), rms_dbfs,
|
|
88
109
|
crest_factor_db, lra_lu, meets_streaming {spotify, apple, youtube, tidal},
|
|
89
110
|
and optionally short_term_lufs.
|
|
90
111
|
On error: {"error": ..., "code": ...}
|
|
@@ -97,6 +118,7 @@ def analyze_loudness(
|
|
|
97
118
|
return {"error": "detail must be 'summary' or 'full'", "code": "INVALID_PARAM"}
|
|
98
119
|
|
|
99
120
|
try:
|
|
121
|
+
file_path = _normalize_audio_path(file_path)
|
|
100
122
|
return compute_loudness(file_path, detail=detail)
|
|
101
123
|
except FileNotFoundError as exc:
|
|
102
124
|
return {"error": str(exc), "code": "INVALID_PARAM"}
|
|
@@ -135,6 +157,7 @@ def analyze_spectrum_offline(
|
|
|
135
157
|
return {"error": "hop_length must be between 1 and n_fft", "code": "INVALID_PARAM"}
|
|
136
158
|
|
|
137
159
|
try:
|
|
160
|
+
file_path = _normalize_audio_path(file_path)
|
|
138
161
|
return compute_spectral(file_path, n_fft=n_fft, hop_length=hop_length)
|
|
139
162
|
except FileNotFoundError as exc:
|
|
140
163
|
return {"error": str(exc), "code": "INVALID_PARAM"}
|
|
@@ -176,6 +199,8 @@ def compare_to_reference(
|
|
|
176
199
|
return {"error": f"reference_path: {err['error']}", "code": err["code"]}
|
|
177
200
|
|
|
178
201
|
try:
|
|
202
|
+
mix_path = _normalize_audio_path(mix_path)
|
|
203
|
+
reference_path = _normalize_audio_path(reference_path)
|
|
179
204
|
return _compare(mix_path, reference_path, normalize=normalize)
|
|
180
205
|
except FileNotFoundError as exc:
|
|
181
206
|
return {"error": str(exc), "code": "INVALID_PARAM"}
|
|
@@ -207,6 +232,7 @@ def read_audio_metadata(
|
|
|
207
232
|
return err
|
|
208
233
|
|
|
209
234
|
try:
|
|
235
|
+
file_path = _normalize_audio_path(file_path)
|
|
210
236
|
return _read_metadata(file_path)
|
|
211
237
|
except FileNotFoundError as exc:
|
|
212
238
|
return {"error": str(exc), "code": "INVALID_PARAM"}
|
|
@@ -97,7 +97,10 @@ def set_scene_tempo(ctx: Context, scene_index: int, tempo: float) -> dict:
|
|
|
97
97
|
|
|
98
98
|
def _ensure_list(value: Any) -> list:
|
|
99
99
|
if isinstance(value, str):
|
|
100
|
-
|
|
100
|
+
try:
|
|
101
|
+
return json.loads(value)
|
|
102
|
+
except json.JSONDecodeError as exc:
|
|
103
|
+
raise ValueError(f"Invalid JSON in parameter: {exc}") from exc
|
|
101
104
|
return value
|
|
102
105
|
|
|
103
106
|
|
|
@@ -48,7 +48,12 @@ def _detect_or_parse_key(notes: list[dict], key_hint: str | None = None) -> dict
|
|
|
48
48
|
|
|
49
49
|
def _key_display(key_info: dict) -> str:
|
|
50
50
|
"""Format key info as 'C major' string."""
|
|
51
|
-
return f"{key_info['tonic_name']} {key_info['mode']}"
|
|
51
|
+
return f"{key_info['tonic_name']} {key_info['mode'].replace('_', ' ')}"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _mode_display(mode: str) -> str:
|
|
55
|
+
"""Format a canonical mode id for user-facing output."""
|
|
56
|
+
return mode.replace("_", " ")
|
|
52
57
|
|
|
53
58
|
|
|
54
59
|
# -- Tool 1: analyze_harmony ------------------------------------------------
|
|
@@ -345,8 +350,9 @@ def identify_scale(
|
|
|
345
350
|
) -> dict:
|
|
346
351
|
"""Identify the scale/mode of a MIDI clip beyond basic major/minor.
|
|
347
352
|
|
|
348
|
-
Uses Krumhansl-Schmuckler
|
|
349
|
-
dorian, phrygian, lydian, mixolydian, locrian
|
|
353
|
+
Uses Krumhansl-Schmuckler-style profiles with 8 mode profiles (major,
|
|
354
|
+
minor, dorian, phrygian, lydian, mixolydian, locrian, and phrygian
|
|
355
|
+
dominant / Hijaz).
|
|
350
356
|
|
|
351
357
|
Returns ranked key matches with confidence scores.
|
|
352
358
|
"""
|
|
@@ -357,17 +363,19 @@ def identify_scale(
|
|
|
357
363
|
detected = engine.detect_key(notes, mode_detection=True)
|
|
358
364
|
|
|
359
365
|
results = [{
|
|
360
|
-
"key": f"{detected['tonic_name']} {detected['mode']}",
|
|
366
|
+
"key": f"{detected['tonic_name']} {_mode_display(detected['mode'])}",
|
|
361
367
|
"confidence": detected["confidence"],
|
|
362
|
-
"mode": detected["mode"],
|
|
368
|
+
"mode": _mode_display(detected["mode"]),
|
|
369
|
+
"mode_id": detected["mode"],
|
|
363
370
|
"tonic": detected["tonic_name"],
|
|
364
371
|
}]
|
|
365
372
|
|
|
366
373
|
for alt in detected.get("alternatives", [])[:7]:
|
|
367
374
|
results.append({
|
|
368
|
-
"key": f"{alt['tonic_name']} {alt['mode']}",
|
|
375
|
+
"key": f"{alt['tonic_name']} {_mode_display(alt['mode'])}",
|
|
369
376
|
"confidence": alt["confidence"],
|
|
370
|
-
"mode": alt["mode"],
|
|
377
|
+
"mode": _mode_display(alt["mode"]),
|
|
378
|
+
"mode_id": alt["mode"],
|
|
371
379
|
"tonic": alt["tonic_name"],
|
|
372
380
|
})
|
|
373
381
|
|
|
@@ -661,7 +669,14 @@ def transpose_smart(
|
|
|
661
669
|
|
|
662
670
|
source_tonic = source_key["tonic"]
|
|
663
671
|
target_tonic = target["tonic"]
|
|
664
|
-
|
|
672
|
+
# Compute nearest-path semitone shift (never more than ±6)
|
|
673
|
+
raw_shift = target_tonic - source_tonic
|
|
674
|
+
if raw_shift > 6:
|
|
675
|
+
semitone_shift = raw_shift - 12
|
|
676
|
+
elif raw_shift < -6:
|
|
677
|
+
semitone_shift = raw_shift + 12
|
|
678
|
+
else:
|
|
679
|
+
semitone_shift = raw_shift
|
|
665
680
|
|
|
666
681
|
if mode == "chromatic":
|
|
667
682
|
transposed = []
|
|
@@ -23,21 +23,31 @@ def get_session_info(ctx: Context) -> dict:
|
|
|
23
23
|
return _get_ableton(ctx).send_command("get_session_info")
|
|
24
24
|
|
|
25
25
|
|
|
26
|
+
def _validate_tempo(tempo: float) -> None:
|
|
27
|
+
"""Validate tempo is within Ableton's accepted range."""
|
|
28
|
+
if not 20 <= tempo <= 999:
|
|
29
|
+
raise ValueError("Tempo must be between 20 and 999 BPM")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _validate_time_signature(numerator: int, denominator: int) -> None:
|
|
33
|
+
"""Validate time signature components."""
|
|
34
|
+
if numerator < 1 or numerator > 99:
|
|
35
|
+
raise ValueError("Numerator must be between 1 and 99")
|
|
36
|
+
if denominator not in (1, 2, 4, 8, 16):
|
|
37
|
+
raise ValueError("Denominator must be 1, 2, 4, 8, or 16")
|
|
38
|
+
|
|
39
|
+
|
|
26
40
|
@mcp.tool()
|
|
27
41
|
def set_tempo(ctx: Context, tempo: float) -> dict:
|
|
28
42
|
"""Set the song tempo in BPM (20-999)."""
|
|
29
|
-
|
|
30
|
-
raise ValueError("Tempo must be between 20 and 999 BPM")
|
|
43
|
+
_validate_tempo(tempo)
|
|
31
44
|
return _get_ableton(ctx).send_command("set_tempo", {"tempo": tempo})
|
|
32
45
|
|
|
33
46
|
|
|
34
47
|
@mcp.tool()
|
|
35
48
|
def set_time_signature(ctx: Context, numerator: int, denominator: int) -> dict:
|
|
36
49
|
"""Set the time signature (e.g., 4/4, 3/4, 6/8)."""
|
|
37
|
-
|
|
38
|
-
raise ValueError("Numerator must be between 1 and 99")
|
|
39
|
-
if denominator not in (1, 2, 4, 8, 16):
|
|
40
|
-
raise ValueError("Denominator must be 1, 2, 4, 8, or 16")
|
|
50
|
+
_validate_time_signature(numerator, denominator)
|
|
41
51
|
return _get_ableton(ctx).send_command("set_time_signature", {
|
|
42
52
|
"numerator": numerator,
|
|
43
53
|
"denominator": denominator,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "livepilot",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.12",
|
|
4
4
|
"mcpName": "io.github.dreamrec/livepilot",
|
|
5
5
|
"description": "Agentic production system for Ableton Live 12 — 178 tools, 17 domains, device atlas, spectral perception, technique memory, neo-Riemannian harmony, Euclidean rhythm, species counterpoint, MIDI I/O",
|
|
6
6
|
"author": "Pilot Studio",
|
|
@@ -5,7 +5,7 @@ Entry point for the ControlSurface. Ableton calls create_instance(c_instance)
|
|
|
5
5
|
when this script is selected in Preferences > Link, Tempo & MIDI.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
-
__version__ = "1.9.
|
|
8
|
+
__version__ = "1.9.12"
|
|
9
9
|
|
|
10
10
|
from _Framework.ControlSurface import ControlSurface
|
|
11
11
|
from .server import LivePilotServer
|
|
@@ -85,29 +85,19 @@ def create_arrangement_clip(song, params):
|
|
|
85
85
|
first_clip_index = None
|
|
86
86
|
|
|
87
87
|
while pos < end_pos:
|
|
88
|
-
# Snapshot clip IDs before duplication to identify the new one
|
|
89
|
-
old_ids = set(id(c) for c in track.arrangement_clips)
|
|
90
|
-
|
|
91
88
|
track.duplicate_clip_to_arrangement(source_clip, pos)
|
|
92
89
|
|
|
93
|
-
# Find the
|
|
90
|
+
# Find the new clip by position (id()-based detection is unreliable
|
|
91
|
+
# because CPython can reuse addresses of GC'd LOM wrappers)
|
|
94
92
|
arr_clips = list(track.arrangement_clips)
|
|
95
93
|
new_clip = None
|
|
96
94
|
new_clip_idx = None
|
|
97
95
|
for i, c in enumerate(arr_clips):
|
|
98
|
-
if
|
|
96
|
+
if abs(c.start_time - pos) < 0.01:
|
|
99
97
|
new_clip = c
|
|
100
98
|
new_clip_idx = i
|
|
101
99
|
break
|
|
102
100
|
|
|
103
|
-
# Fallback: if id-based detection fails, match by position
|
|
104
|
-
if new_clip is None:
|
|
105
|
-
for i, c in enumerate(arr_clips):
|
|
106
|
-
if abs(c.start_time - pos) < 0.01:
|
|
107
|
-
new_clip = c
|
|
108
|
-
new_clip_idx = i
|
|
109
|
-
break
|
|
110
|
-
|
|
111
101
|
if new_clip is not None:
|
|
112
102
|
if first_clip_index is None:
|
|
113
103
|
first_clip_index = new_clip_idx
|
|
@@ -158,20 +148,24 @@ def create_arrangement_clip(song, params):
|
|
|
158
148
|
finally:
|
|
159
149
|
song.end_undo_step()
|
|
160
150
|
|
|
161
|
-
# Re-read to get accurate final state
|
|
151
|
+
# Re-read to get accurate final state — locate by start_time, not stored
|
|
152
|
+
# index, because the trim pass (remove_notes_extended) can shift indices.
|
|
162
153
|
arr_clips = list(track.arrangement_clips)
|
|
163
|
-
|
|
154
|
+
first_clip = None
|
|
155
|
+
for c in arr_clips:
|
|
156
|
+
if abs(c.start_time - start_time) < 0.01:
|
|
157
|
+
first_clip = c
|
|
158
|
+
break
|
|
159
|
+
if first_clip is None:
|
|
164
160
|
raise ValueError("Failed to place any clips in arrangement")
|
|
165
|
-
first_clip = arr_clips[first_clip_index]
|
|
166
161
|
|
|
167
162
|
return {
|
|
168
163
|
"track_index": track_index,
|
|
169
|
-
"clip_index": first_clip_index,
|
|
170
164
|
"start_time": start_time,
|
|
171
165
|
"length": length,
|
|
172
166
|
"clip_count": clip_count,
|
|
173
167
|
"source_length": source_length,
|
|
174
|
-
"name": first_clip.name
|
|
168
|
+
"name": first_clip.name,
|
|
175
169
|
}
|
|
176
170
|
|
|
177
171
|
|
|
@@ -494,6 +488,10 @@ def set_arrangement_automation(song, params):
|
|
|
494
488
|
"parameter_type must be 'device', 'volume', 'panning', or 'send'"
|
|
495
489
|
)
|
|
496
490
|
|
|
491
|
+
# Clamp values to parameter range
|
|
492
|
+
p_min = float(parameter.min)
|
|
493
|
+
p_max = float(parameter.max)
|
|
494
|
+
|
|
497
495
|
# Try direct envelope access on the arrangement clip
|
|
498
496
|
envelope = clip.automation_envelope(parameter)
|
|
499
497
|
if envelope is None:
|
|
@@ -509,7 +507,7 @@ def set_arrangement_automation(song, params):
|
|
|
509
507
|
points_written = 0
|
|
510
508
|
for pt in points:
|
|
511
509
|
time = float(pt["time"])
|
|
512
|
-
value = float(pt["value"])
|
|
510
|
+
value = max(p_min, min(p_max, float(pt["value"])))
|
|
513
511
|
duration = float(pt.get("duration", 0.125))
|
|
514
512
|
envelope.insert_step(time, duration, value)
|
|
515
513
|
points_written += 1
|
|
@@ -523,121 +521,14 @@ def set_arrangement_automation(song, params):
|
|
|
523
521
|
"method": "direct",
|
|
524
522
|
}
|
|
525
523
|
|
|
526
|
-
#
|
|
527
|
-
#
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
temp_slot_index = None
|
|
535
|
-
for si, slot in enumerate(slots):
|
|
536
|
-
if not slot.has_clip:
|
|
537
|
-
temp_slot_index = si
|
|
538
|
-
break
|
|
539
|
-
|
|
540
|
-
# If all clip slots are full, create a temporary scene to get an empty slot
|
|
541
|
-
created_temp_scene = False
|
|
542
|
-
if temp_slot_index is None:
|
|
543
|
-
song.create_scene(-1) # append a new scene at the end
|
|
544
|
-
created_temp_scene = True
|
|
545
|
-
# Re-read slots — the new scene added one slot per track
|
|
546
|
-
slots = list(track.clip_slots)
|
|
547
|
-
temp_slot_index = len(slots) - 1
|
|
548
|
-
|
|
549
|
-
song.begin_undo_step()
|
|
550
|
-
try:
|
|
551
|
-
# Create temporary session clip
|
|
552
|
-
slot = slots[temp_slot_index]
|
|
553
|
-
slot.create_clip(arr_length)
|
|
554
|
-
temp_clip = slot.clip
|
|
555
|
-
|
|
556
|
-
# Write automation to the session clip
|
|
557
|
-
temp_envelope = temp_clip.automation_envelope(parameter)
|
|
558
|
-
if temp_envelope is None:
|
|
559
|
-
try:
|
|
560
|
-
temp_envelope = temp_clip.create_automation_envelope(parameter)
|
|
561
|
-
except (AttributeError, RuntimeError):
|
|
562
|
-
pass
|
|
563
|
-
|
|
564
|
-
if temp_envelope is None:
|
|
565
|
-
# Neither direct nor session clip approach works
|
|
566
|
-
slot.delete_clip()
|
|
567
|
-
if created_temp_scene:
|
|
568
|
-
song.delete_scene(len(list(song.scenes)) - 1)
|
|
569
|
-
raise ValueError(
|
|
570
|
-
"Cannot create automation envelope for parameter '%s' "
|
|
571
|
-
"(neither arrangement nor session clip supports it)"
|
|
572
|
-
% parameter.name
|
|
573
|
-
)
|
|
574
|
-
|
|
575
|
-
points_written = 0
|
|
576
|
-
for pt in points:
|
|
577
|
-
time = float(pt["time"])
|
|
578
|
-
value = float(pt["value"])
|
|
579
|
-
duration = float(pt.get("duration", 0.125))
|
|
580
|
-
temp_envelope.insert_step(time, duration, value)
|
|
581
|
-
points_written += 1
|
|
582
|
-
|
|
583
|
-
# Copy notes from original arrangement clip to the temp session clip
|
|
584
|
-
# so the replacement clip has the same musical content.
|
|
585
|
-
try:
|
|
586
|
-
orig_notes = clip.get_notes_extended(0, 128, 0.0, arr_length + 1.0)
|
|
587
|
-
import Live
|
|
588
|
-
note_specs = []
|
|
589
|
-
for note in orig_notes:
|
|
590
|
-
kwargs = dict(
|
|
591
|
-
pitch=note.pitch,
|
|
592
|
-
start_time=note.start_time,
|
|
593
|
-
duration=note.duration,
|
|
594
|
-
velocity=note.velocity,
|
|
595
|
-
mute=note.mute,
|
|
596
|
-
)
|
|
597
|
-
# Preserve per-note expression data
|
|
598
|
-
if hasattr(note, 'probability') and note.probability is not None:
|
|
599
|
-
kwargs['probability'] = note.probability
|
|
600
|
-
if hasattr(note, 'velocity_deviation') and note.velocity_deviation is not None:
|
|
601
|
-
kwargs['velocity_deviation'] = note.velocity_deviation
|
|
602
|
-
if hasattr(note, 'release_velocity') and note.release_velocity is not None:
|
|
603
|
-
kwargs['release_velocity'] = note.release_velocity
|
|
604
|
-
spec = Live.Clip.MidiNoteSpecification(**kwargs)
|
|
605
|
-
note_specs.append(spec)
|
|
606
|
-
if note_specs:
|
|
607
|
-
temp_clip.add_new_notes(tuple(note_specs))
|
|
608
|
-
except Exception:
|
|
609
|
-
pass # Non-MIDI clips or errors — automation-only is still valid
|
|
610
|
-
|
|
611
|
-
# Place the session clip (with automation + notes) into arrangement.
|
|
612
|
-
# Do this BEFORE deleting the original — if placement fails, the
|
|
613
|
-
# original clip is preserved (no data loss on partial failure).
|
|
614
|
-
track.duplicate_clip_to_arrangement(temp_clip, arr_start)
|
|
615
|
-
|
|
616
|
-
# Placement succeeded — now safe to remove the original clip
|
|
617
|
-
# to avoid a second overlapping clip at the same position.
|
|
618
|
-
try:
|
|
619
|
-
clip.delete_clip()
|
|
620
|
-
except (AttributeError, RuntimeError):
|
|
621
|
-
# delete_clip may not exist on arrangement clips in all versions;
|
|
622
|
-
# in that case we accept the overlap as a known limitation.
|
|
623
|
-
pass
|
|
624
|
-
|
|
625
|
-
# Clean up the temporary session clip
|
|
626
|
-
slot.delete_clip()
|
|
627
|
-
|
|
628
|
-
# Clean up the temporary scene if we created one
|
|
629
|
-
if created_temp_scene:
|
|
630
|
-
song.delete_scene(len(list(song.scenes)) - 1)
|
|
631
|
-
finally:
|
|
632
|
-
song.end_undo_step()
|
|
633
|
-
|
|
634
|
-
return {
|
|
635
|
-
"track_index": track_index,
|
|
636
|
-
"clip_index": clip_index,
|
|
637
|
-
"parameter_name": parameter.name,
|
|
638
|
-
"points_written": points_written,
|
|
639
|
-
"method": "session_workaround",
|
|
640
|
-
}
|
|
524
|
+
# No fallback — direct envelope creation is the only safe approach.
|
|
525
|
+
# Session-clip duplication can silently create overlapping clips.
|
|
526
|
+
raise ValueError(
|
|
527
|
+
"Cannot create automation envelope for parameter '%s' on this "
|
|
528
|
+
"arrangement clip. Direct envelope access is not supported for "
|
|
529
|
+
"this parameter type or Live version."
|
|
530
|
+
% parameter.name
|
|
531
|
+
)
|
|
641
532
|
|
|
642
533
|
|
|
643
534
|
@register("transpose_arrangement_notes")
|
|
@@ -76,13 +76,19 @@ def _navigate_path(browser, path):
|
|
|
76
76
|
return current
|
|
77
77
|
|
|
78
78
|
|
|
79
|
+
_MAX_SEARCH_ITERATIONS = 100000
|
|
80
|
+
|
|
81
|
+
|
|
79
82
|
def _search_recursive(item, name_filter, loadable_only, results, depth, max_depth,
|
|
80
|
-
max_results=100):
|
|
81
|
-
"""Recursively search browser children."""
|
|
83
|
+
max_results=100, _counter=None):
|
|
84
|
+
"""Recursively search browser children with iteration cap."""
|
|
85
|
+
if _counter is None:
|
|
86
|
+
_counter = [0] # mutable counter shared across recursion
|
|
82
87
|
if depth > max_depth or len(results) >= max_results:
|
|
83
88
|
return
|
|
84
89
|
for child in item.children:
|
|
85
|
-
|
|
90
|
+
_counter[0] += 1
|
|
91
|
+
if _counter[0] > _MAX_SEARCH_ITERATIONS or len(results) >= max_results:
|
|
86
92
|
return
|
|
87
93
|
match = True
|
|
88
94
|
if name_filter and name_filter.lower() not in child.name.lower():
|
|
@@ -102,7 +108,7 @@ def _search_recursive(item, name_filter, loadable_only, results, depth, max_dept
|
|
|
102
108
|
if child.is_folder:
|
|
103
109
|
_search_recursive(
|
|
104
110
|
child, name_filter, loadable_only, results, depth + 1, max_depth,
|
|
105
|
-
max_results
|
|
111
|
+
max_results, _counter
|
|
106
112
|
)
|
|
107
113
|
if len(results) >= max_results:
|
|
108
114
|
return
|
|
@@ -125,12 +131,17 @@ def get_browser_tree(song, params):
|
|
|
125
131
|
|
|
126
132
|
result = []
|
|
127
133
|
for name, item in categories.items():
|
|
128
|
-
children
|
|
129
|
-
|
|
134
|
+
# Count lazily without materializing the full children list
|
|
135
|
+
children_preview = []
|
|
136
|
+
count = 0
|
|
137
|
+
for c in item.children:
|
|
138
|
+
count += 1
|
|
139
|
+
if len(children_preview) < 20:
|
|
140
|
+
children_preview.append(c.name)
|
|
130
141
|
result.append({
|
|
131
142
|
"name": name,
|
|
132
|
-
"children_count":
|
|
133
|
-
"children_preview":
|
|
143
|
+
"children_count": count,
|
|
144
|
+
"children_preview": children_preview,
|
|
134
145
|
})
|
|
135
146
|
return {"categories": result}
|
|
136
147
|
|
|
@@ -20,16 +20,17 @@ def get_clip_automation(song, params):
|
|
|
20
20
|
envelopes = []
|
|
21
21
|
|
|
22
22
|
# Check mixer parameters: volume, panning, sends
|
|
23
|
+
# Use the specific parameter_type that set/clear accept (not generic "mixer")
|
|
23
24
|
mixer = track.mixer_device
|
|
24
|
-
for param_name, param in [
|
|
25
|
-
("Volume", mixer.volume),
|
|
26
|
-
("Pan", mixer.panning),
|
|
25
|
+
for param_name, param_type, param in [
|
|
26
|
+
("Volume", "volume", mixer.volume),
|
|
27
|
+
("Pan", "panning", mixer.panning),
|
|
27
28
|
]:
|
|
28
29
|
env = clip.automation_envelope(param)
|
|
29
30
|
if env is not None:
|
|
30
31
|
envelopes.append({
|
|
31
32
|
"parameter_name": param_name,
|
|
32
|
-
"parameter_type":
|
|
33
|
+
"parameter_type": param_type,
|
|
33
34
|
"has_envelope": True,
|
|
34
35
|
})
|
|
35
36
|
|
|
@@ -147,12 +147,14 @@ def set_clip_loop(song, params):
|
|
|
147
147
|
clip_index = int(params["clip_index"])
|
|
148
148
|
clip = get_clip(song, track_index, clip_index)
|
|
149
149
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
if "start" in params:
|
|
153
|
-
clip.loop_start = float(params["start"])
|
|
150
|
+
# Set end before start to avoid Live's loop_start < loop_end clamping.
|
|
151
|
+
# Expanding the window first ensures the left edge can move freely.
|
|
154
152
|
if "end" in params:
|
|
155
153
|
clip.loop_end = float(params["end"])
|
|
154
|
+
if "start" in params:
|
|
155
|
+
clip.loop_start = float(params["start"])
|
|
156
|
+
if "enabled" in params:
|
|
157
|
+
clip.looping = bool(params["enabled"])
|
|
156
158
|
|
|
157
159
|
return {
|
|
158
160
|
"track_index": track_index,
|
|
@@ -199,11 +201,17 @@ def set_clip_warp_mode(song, params):
|
|
|
199
201
|
"3=Re-Pitch, 4=Complex, 6=Complex Pro" % mode
|
|
200
202
|
)
|
|
201
203
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
+
# Enable warping first so warp_mode assignment is accepted by Live,
|
|
205
|
+
# then disable afterwards if requested.
|
|
206
|
+
enable_warping = params.get("warping")
|
|
207
|
+
if enable_warping is not None and bool(enable_warping):
|
|
208
|
+
clip.warping = True
|
|
204
209
|
|
|
205
210
|
clip.warp_mode = mode
|
|
206
211
|
|
|
212
|
+
if enable_warping is not None and not bool(enable_warping):
|
|
213
|
+
clip.warping = False
|
|
214
|
+
|
|
207
215
|
return {
|
|
208
216
|
"track_index": track_index,
|
|
209
217
|
"clip_index": clip_index,
|
|
@@ -3,6 +3,7 @@ LivePilot - Device domain handlers (11 commands).
|
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
5
|
import Live
|
|
6
|
+
from collections import deque
|
|
6
7
|
|
|
7
8
|
from .router import register
|
|
8
9
|
from .utils import get_track, get_device
|
|
@@ -178,6 +179,8 @@ def toggle_device(song, params):
|
|
|
178
179
|
break
|
|
179
180
|
if on_param is None:
|
|
180
181
|
# Fallback to parameter 0 for devices that don't use "Device On"
|
|
182
|
+
if not list(device.parameters):
|
|
183
|
+
raise ValueError("Device '%s' has no parameters to toggle" % device.name)
|
|
181
184
|
on_param = device.parameters[0]
|
|
182
185
|
|
|
183
186
|
on_param.value = 1.0 if active else 0.0
|
|
@@ -399,10 +402,10 @@ def find_and_load_device(song, params):
|
|
|
399
402
|
This ensures raw 'Operator' is found before 'Hello Operator.adg' buried
|
|
400
403
|
in a user_library subfolder."""
|
|
401
404
|
nonlocal iterations
|
|
402
|
-
# Queue of (item, depth) tuples
|
|
403
|
-
queue = [(category, 0)]
|
|
405
|
+
# Queue of (item, depth) tuples — deque for O(1) popleft
|
|
406
|
+
queue = deque([(category, 0)])
|
|
404
407
|
while queue:
|
|
405
|
-
item, depth = queue.
|
|
408
|
+
item, depth = queue.popleft()
|
|
406
409
|
if depth > 8:
|
|
407
410
|
continue
|
|
408
411
|
try:
|