livepilot 1.9.11 → 1.9.13
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 +57 -0
- package/README.md +11 -10
- package/bin/livepilot.js +27 -9
- package/livepilot/.Codex-plugin/plugin.json +1 -1
- package/livepilot/.claude-plugin/plugin.json +1 -1
- package/livepilot/skills/livepilot-core/SKILL.md +6 -6
- package/livepilot/skills/livepilot-core/references/overview.md +1 -1
- package/livepilot/skills/livepilot-release/SKILL.md +4 -4
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/livepilot_bridge.js +77 -3
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/connection.py +5 -1
- package/mcp_server/curves.py +5 -1
- package/mcp_server/m4l_bridge.py +37 -20
- package/mcp_server/memory/technique_store.py +30 -2
- package/mcp_server/server.py +10 -2
- package/mcp_server/tools/analyzer.py +12 -1
- package/mcp_server/tools/arrangement.py +20 -5
- package/mcp_server/tools/automation.py +52 -0
- package/mcp_server/tools/devices.py +2 -3
- package/mcp_server/tools/generative.py +4 -0
- package/mcp_server/tools/midi_io.py +22 -4
- package/mcp_server/tools/theory.py +8 -1
- package/mcp_server/tools/transport.py +16 -6
- package/package.json +1 -1
- package/remote_script/LivePilot/__init__.py +3 -3
- package/remote_script/LivePilot/arrangement.py +12 -18
- 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/server.py +20 -7
- package/remote_script/LivePilot/tracks.py +7 -2
- package/remote_script/LivePilot/transport.py +3 -3
- package/requirements.txt +6 -1
|
@@ -26,10 +26,26 @@ class TechniqueStore:
|
|
|
26
26
|
if base_dir is None:
|
|
27
27
|
base_dir = os.path.join(os.path.expanduser("~"), ".livepilot", "memory")
|
|
28
28
|
self._base_dir = Path(base_dir)
|
|
29
|
-
self._base_dir.mkdir(parents=True, exist_ok=True)
|
|
30
29
|
self._file = self._base_dir / "techniques.json"
|
|
31
30
|
self._lock = threading.Lock()
|
|
32
|
-
|
|
31
|
+
self._initialized = False
|
|
32
|
+
self._data: dict = {"version": 1, "techniques": []}
|
|
33
|
+
|
|
34
|
+
def _ensure_initialized(self) -> None:
|
|
35
|
+
"""Lazily create directory and load data on first access.
|
|
36
|
+
|
|
37
|
+
Deferred so that a read-only HOME doesn't crash the entire MCP
|
|
38
|
+
server at import time — memory tools just return errors instead.
|
|
39
|
+
"""
|
|
40
|
+
if self._initialized:
|
|
41
|
+
return
|
|
42
|
+
try:
|
|
43
|
+
self._base_dir.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
except OSError as exc:
|
|
45
|
+
raise RuntimeError(
|
|
46
|
+
f"Cannot create memory directory {self._base_dir}: {exc}. "
|
|
47
|
+
"Memory tools are unavailable."
|
|
48
|
+
) from exc
|
|
33
49
|
if self._file.exists():
|
|
34
50
|
try:
|
|
35
51
|
with open(self._file, "r") as f:
|
|
@@ -41,6 +57,7 @@ class TechniqueStore:
|
|
|
41
57
|
else:
|
|
42
58
|
self._data = {"version": 1, "techniques": []}
|
|
43
59
|
self._flush()
|
|
60
|
+
self._initialized = True
|
|
44
61
|
|
|
45
62
|
# ── persistence ──────────────────────────────────────────────
|
|
46
63
|
|
|
@@ -64,6 +81,7 @@ class TechniqueStore:
|
|
|
64
81
|
tags: Optional[list[str]] = None,
|
|
65
82
|
) -> dict:
|
|
66
83
|
"""Create a new technique. Returns {id, name, type, summary}."""
|
|
84
|
+
self._ensure_initialized()
|
|
67
85
|
if type not in VALID_TYPES:
|
|
68
86
|
raise ValueError(
|
|
69
87
|
f"INVALID_PARAM: type must be one of {sorted(VALID_TYPES)}, got '{type}'"
|
|
@@ -99,6 +117,7 @@ class TechniqueStore:
|
|
|
99
117
|
|
|
100
118
|
def get(self, technique_id: str) -> dict:
|
|
101
119
|
"""Return full technique by id."""
|
|
120
|
+
self._ensure_initialized()
|
|
102
121
|
with self._lock:
|
|
103
122
|
for t in self._data["techniques"]:
|
|
104
123
|
if t["id"] == technique_id:
|
|
@@ -113,6 +132,9 @@ class TechniqueStore:
|
|
|
113
132
|
limit: int = 10,
|
|
114
133
|
) -> list[dict]:
|
|
115
134
|
"""Search techniques. Returns summaries (no payload)."""
|
|
135
|
+
self._ensure_initialized()
|
|
136
|
+
if limit < 0:
|
|
137
|
+
raise ValueError("INVALID_PARAM: limit must be >= 0")
|
|
116
138
|
with self._lock:
|
|
117
139
|
results = copy.deepcopy(self._data["techniques"])
|
|
118
140
|
|
|
@@ -150,6 +172,9 @@ class TechniqueStore:
|
|
|
150
172
|
limit: int = 20,
|
|
151
173
|
) -> list[dict]:
|
|
152
174
|
"""List techniques as compact summaries."""
|
|
175
|
+
self._ensure_initialized()
|
|
176
|
+
if limit < 0:
|
|
177
|
+
raise ValueError("INVALID_PARAM: limit must be >= 0")
|
|
153
178
|
if sort_by not in VALID_SORT_FIELDS:
|
|
154
179
|
raise ValueError(
|
|
155
180
|
f"INVALID_PARAM: sort_by must be one of {sorted(VALID_SORT_FIELDS)}, got '{sort_by}'"
|
|
@@ -179,6 +204,7 @@ class TechniqueStore:
|
|
|
179
204
|
rating: Optional[int] = None,
|
|
180
205
|
) -> dict:
|
|
181
206
|
"""Set favorite flag and/or rating."""
|
|
207
|
+
self._ensure_initialized()
|
|
182
208
|
if rating is not None and (rating < 0 or rating > 5):
|
|
183
209
|
raise ValueError("INVALID_PARAM: rating must be between 0 and 5")
|
|
184
210
|
|
|
@@ -200,6 +226,7 @@ class TechniqueStore:
|
|
|
200
226
|
qualities: Optional[dict] = None,
|
|
201
227
|
) -> dict:
|
|
202
228
|
"""Update technique fields. Qualities are merged (lists replaced)."""
|
|
229
|
+
self._ensure_initialized()
|
|
203
230
|
with self._lock:
|
|
204
231
|
t = self._find(technique_id)
|
|
205
232
|
if name is not None:
|
|
@@ -216,6 +243,7 @@ class TechniqueStore:
|
|
|
216
243
|
|
|
217
244
|
def delete(self, technique_id: str) -> dict:
|
|
218
245
|
"""Delete technique after creating a timestamped backup."""
|
|
246
|
+
self._ensure_initialized()
|
|
219
247
|
with self._lock:
|
|
220
248
|
t = self._find(technique_id)
|
|
221
249
|
# backup
|
package/mcp_server/server.py
CHANGED
|
@@ -119,12 +119,20 @@ from .tools import perception # noqa: F401, E402
|
|
|
119
119
|
|
|
120
120
|
def _coerce_schema_property(prop: dict) -> None:
|
|
121
121
|
"""Widen a single JSON Schema property to also accept strings."""
|
|
122
|
-
if prop.get("type") in ("integer", "number"):
|
|
122
|
+
if prop.get("type") in ("integer", "number") and "anyOf" not in prop:
|
|
123
123
|
original_type = prop.pop("type")
|
|
124
124
|
prop["anyOf"] = [{"type": original_type}, {"type": "string"}]
|
|
125
125
|
elif "anyOf" in prop:
|
|
126
|
+
# Skip if this anyOf was already coerced (contains both a numeric and string type)
|
|
127
|
+
variant_types = {v.get("type") for v in prop["anyOf"] if isinstance(v, dict)}
|
|
128
|
+
if "string" in variant_types and variant_types & {"integer", "number"}:
|
|
129
|
+
return
|
|
126
130
|
for variant in prop["anyOf"]:
|
|
127
|
-
|
|
131
|
+
if isinstance(variant, dict):
|
|
132
|
+
_coerce_schema_property(variant)
|
|
133
|
+
# Recurse into array items so list[int]/list[float] params also accept strings
|
|
134
|
+
if "items" in prop and isinstance(prop["items"], dict):
|
|
135
|
+
_coerce_schema_property(prop["items"])
|
|
128
136
|
|
|
129
137
|
|
|
130
138
|
def _get_all_tools():
|
|
@@ -590,7 +590,18 @@ async def capture_audio(
|
|
|
590
590
|
if source not in ("master",):
|
|
591
591
|
raise ValueError(f"Unsupported source '{source}'. Valid: 'master'")
|
|
592
592
|
|
|
593
|
+
# Sanitize filename — strip directory components to prevent path traversal
|
|
594
|
+
if filename:
|
|
595
|
+
safe_name = os.path.basename(filename)
|
|
596
|
+
if not safe_name or safe_name != filename:
|
|
597
|
+
raise ValueError(
|
|
598
|
+
f"Filename must not contain path separators or '..' segments: {filename!r}"
|
|
599
|
+
)
|
|
600
|
+
filename = safe_name
|
|
601
|
+
|
|
593
602
|
bridge = _get_m4l(ctx)
|
|
603
|
+
# Ensure captures directory exists before sending to bridge
|
|
604
|
+
os.makedirs(CAPTURE_DIR, exist_ok=True)
|
|
594
605
|
duration_ms = duration_seconds * 1000
|
|
595
606
|
result = await bridge.send_capture(
|
|
596
607
|
"capture_audio",
|
|
@@ -613,7 +624,7 @@ async def capture_stop(ctx: Context) -> dict:
|
|
|
613
624
|
_require_analyzer(cache)
|
|
614
625
|
bridge = _get_m4l(ctx)
|
|
615
626
|
# Cancel the capture future so send_capture doesn't hang forever
|
|
616
|
-
bridge.cancel_capture_future()
|
|
627
|
+
await bridge.cancel_capture_future()
|
|
617
628
|
return await bridge.send_command("capture_stop")
|
|
618
629
|
|
|
619
630
|
|
|
@@ -162,7 +162,10 @@ def add_arrangement_notes(
|
|
|
162
162
|
_validate_track_index(track_index)
|
|
163
163
|
_validate_clip_index(clip_index)
|
|
164
164
|
if isinstance(notes, str):
|
|
165
|
-
|
|
165
|
+
try:
|
|
166
|
+
notes = json.loads(notes)
|
|
167
|
+
except json.JSONDecodeError as exc:
|
|
168
|
+
raise ValueError(f"Invalid JSON in notes parameter: {exc}") from exc
|
|
166
169
|
for note in notes:
|
|
167
170
|
_validate_note(note)
|
|
168
171
|
return _get_ableton(ctx).send_command("add_arrangement_notes", {
|
|
@@ -206,7 +209,10 @@ def set_arrangement_automation(
|
|
|
206
209
|
if parameter_type == "send" and send_index is None:
|
|
207
210
|
raise ValueError("send_index required for parameter_type='send'")
|
|
208
211
|
if isinstance(points, str):
|
|
209
|
-
|
|
212
|
+
try:
|
|
213
|
+
points = json.loads(points)
|
|
214
|
+
except json.JSONDecodeError as exc:
|
|
215
|
+
raise ValueError(f"Invalid JSON in points parameter: {exc}") from exc
|
|
210
216
|
if not points:
|
|
211
217
|
raise ValueError("points list cannot be empty")
|
|
212
218
|
params: dict = {
|
|
@@ -352,7 +358,10 @@ def remove_arrangement_notes_by_id(
|
|
|
352
358
|
_validate_track_index(track_index)
|
|
353
359
|
_validate_clip_index(clip_index)
|
|
354
360
|
if isinstance(note_ids, str):
|
|
355
|
-
|
|
361
|
+
try:
|
|
362
|
+
note_ids = json.loads(note_ids)
|
|
363
|
+
except json.JSONDecodeError as exc:
|
|
364
|
+
raise ValueError(f"Invalid JSON in note_ids parameter: {exc}") from exc
|
|
356
365
|
if not note_ids:
|
|
357
366
|
raise ValueError("note_ids list cannot be empty")
|
|
358
367
|
return _get_ableton(ctx).send_command("remove_arrangement_notes_by_id", {
|
|
@@ -374,7 +383,10 @@ def modify_arrangement_notes(
|
|
|
374
383
|
_validate_track_index(track_index)
|
|
375
384
|
_validate_clip_index(clip_index)
|
|
376
385
|
if isinstance(modifications, str):
|
|
377
|
-
|
|
386
|
+
try:
|
|
387
|
+
modifications = json.loads(modifications)
|
|
388
|
+
except json.JSONDecodeError as exc:
|
|
389
|
+
raise ValueError(f"Invalid JSON in modifications parameter: {exc}") from exc
|
|
378
390
|
if not modifications:
|
|
379
391
|
raise ValueError("modifications list cannot be empty")
|
|
380
392
|
for mod in modifications:
|
|
@@ -407,7 +419,10 @@ def duplicate_arrangement_notes(
|
|
|
407
419
|
_validate_track_index(track_index)
|
|
408
420
|
_validate_clip_index(clip_index)
|
|
409
421
|
if isinstance(note_ids, str):
|
|
410
|
-
|
|
422
|
+
try:
|
|
423
|
+
note_ids = json.loads(note_ids)
|
|
424
|
+
except json.JSONDecodeError as exc:
|
|
425
|
+
raise ValueError(f"Invalid JSON in note_ids parameter: {exc}") from exc
|
|
411
426
|
if not note_ids:
|
|
412
427
|
raise ValueError("note_ids list cannot be empty")
|
|
413
428
|
return _get_ableton(ctx).send_command("duplicate_arrangement_notes", {
|
|
@@ -43,6 +43,10 @@ def get_clip_automation(
|
|
|
43
43
|
parameter name, and type (mixer/send/device). Use this to see
|
|
44
44
|
what's already automated before writing new curves.
|
|
45
45
|
"""
|
|
46
|
+
if track_index < 0:
|
|
47
|
+
raise ValueError("track_index must be >= 0")
|
|
48
|
+
if clip_index < 0:
|
|
49
|
+
raise ValueError("clip_index must be >= 0")
|
|
46
50
|
return _get_ableton(ctx).send_command("get_clip_automation", {
|
|
47
51
|
"track_index": track_index,
|
|
48
52
|
"clip_index": clip_index,
|
|
@@ -72,6 +76,17 @@ def set_clip_automation(
|
|
|
72
76
|
Tip: Use apply_automation_shape to generate points from curves/recipes
|
|
73
77
|
instead of calculating points manually.
|
|
74
78
|
"""
|
|
79
|
+
if track_index < 0:
|
|
80
|
+
raise ValueError("track_index must be >= 0")
|
|
81
|
+
if clip_index < 0:
|
|
82
|
+
raise ValueError("clip_index must be >= 0")
|
|
83
|
+
if parameter_type not in ("device", "volume", "panning", "send"):
|
|
84
|
+
raise ValueError("parameter_type must be 'device', 'volume', 'panning', or 'send'")
|
|
85
|
+
if parameter_type == "device":
|
|
86
|
+
if device_index is None or parameter_index is None:
|
|
87
|
+
raise ValueError("device_index and parameter_index required for parameter_type='device'")
|
|
88
|
+
if parameter_type == "send" and send_index is None:
|
|
89
|
+
raise ValueError("send_index required for parameter_type='send'")
|
|
75
90
|
params: dict = {
|
|
76
91
|
"track_index": track_index,
|
|
77
92
|
"clip_index": clip_index,
|
|
@@ -102,11 +117,22 @@ def clear_clip_automation(
|
|
|
102
117
|
If parameter_type is omitted, clears ALL envelopes.
|
|
103
118
|
If provided, clears only that parameter's envelope.
|
|
104
119
|
"""
|
|
120
|
+
if track_index < 0:
|
|
121
|
+
raise ValueError("track_index must be >= 0")
|
|
122
|
+
if clip_index < 0:
|
|
123
|
+
raise ValueError("clip_index must be >= 0")
|
|
105
124
|
params: dict = {
|
|
106
125
|
"track_index": track_index,
|
|
107
126
|
"clip_index": clip_index,
|
|
108
127
|
}
|
|
109
128
|
if parameter_type is not None:
|
|
129
|
+
if parameter_type not in ("device", "volume", "panning", "send"):
|
|
130
|
+
raise ValueError("parameter_type must be 'device', 'volume', 'panning', or 'send'")
|
|
131
|
+
if parameter_type == "device":
|
|
132
|
+
if device_index is None or parameter_index is None:
|
|
133
|
+
raise ValueError("device_index and parameter_index required for parameter_type='device'")
|
|
134
|
+
if parameter_type == "send" and send_index is None:
|
|
135
|
+
raise ValueError("send_index required for parameter_type='send'")
|
|
110
136
|
params["parameter_type"] = parameter_type
|
|
111
137
|
if device_index is not None:
|
|
112
138
|
params["device_index"] = device_index
|
|
@@ -190,6 +216,19 @@ def apply_automation_shape(
|
|
|
190
216
|
- Throws: use spike with short duration (1-2 beats)
|
|
191
217
|
- Tremolo/pan: use sine with frequency in musical divisions
|
|
192
218
|
"""
|
|
219
|
+
# Validate indices and parameter_type (same rules as set_clip_automation)
|
|
220
|
+
if track_index < 0:
|
|
221
|
+
raise ValueError("track_index must be >= 0")
|
|
222
|
+
if clip_index < 0:
|
|
223
|
+
raise ValueError("clip_index must be >= 0")
|
|
224
|
+
if parameter_type not in ("device", "volume", "panning", "send"):
|
|
225
|
+
raise ValueError("parameter_type must be 'device', 'volume', 'panning', or 'send'")
|
|
226
|
+
if parameter_type == "device":
|
|
227
|
+
if device_index is None or parameter_index is None:
|
|
228
|
+
raise ValueError("device_index and parameter_index required for parameter_type='device'")
|
|
229
|
+
if parameter_type == "send" and send_index is None:
|
|
230
|
+
raise ValueError("send_index required for parameter_type='send'")
|
|
231
|
+
|
|
193
232
|
# Generate the curve
|
|
194
233
|
points = generate_curve(
|
|
195
234
|
curve_type=curve_type,
|
|
@@ -272,6 +311,19 @@ def apply_automation_recipe(
|
|
|
272
311
|
- vinyl_crackle: slow bit reduction movement
|
|
273
312
|
- stereo_narrow: collapse to mono before drop
|
|
274
313
|
"""
|
|
314
|
+
# Validate indices and parameter_type (same rules as set_clip_automation)
|
|
315
|
+
if track_index < 0:
|
|
316
|
+
raise ValueError("track_index must be >= 0")
|
|
317
|
+
if clip_index < 0:
|
|
318
|
+
raise ValueError("clip_index must be >= 0")
|
|
319
|
+
if parameter_type not in ("device", "volume", "panning", "send"):
|
|
320
|
+
raise ValueError("parameter_type must be 'device', 'volume', 'panning', or 'send'")
|
|
321
|
+
if parameter_type == "device":
|
|
322
|
+
if device_index is None or parameter_index is None:
|
|
323
|
+
raise ValueError("device_index and parameter_index required for parameter_type='device'")
|
|
324
|
+
if parameter_type == "send" and send_index is None:
|
|
325
|
+
raise ValueError("send_index required for parameter_type='send'")
|
|
326
|
+
|
|
275
327
|
points = generate_from_recipe(recipe, duration=duration, density=density)
|
|
276
328
|
|
|
277
329
|
if time_offset > 0:
|
|
@@ -180,12 +180,11 @@ def _postflight_loaded_device(ctx: Context, result: dict) -> dict:
|
|
|
180
180
|
|
|
181
181
|
def _validate_track_index(track_index: int):
|
|
182
182
|
if track_index < 0 and track_index != MASTER_TRACK_INDEX:
|
|
183
|
-
if track_index
|
|
183
|
+
if not (-99 <= track_index <= -1):
|
|
184
184
|
raise ValueError(
|
|
185
185
|
"track_index must be >= 0 for regular tracks, "
|
|
186
|
-
"
|
|
186
|
+
"-1..-99 for return tracks (-1=A, -2=B), or -1000 for master"
|
|
187
187
|
)
|
|
188
|
-
# Negative values -1..-99 are valid return track indices
|
|
189
188
|
|
|
190
189
|
|
|
191
190
|
def _validate_device_index(device_index: int):
|
|
@@ -94,6 +94,10 @@ def layer_euclidean_rhythms(
|
|
|
94
94
|
for layer in layers:
|
|
95
95
|
p = int(layer["pulses"])
|
|
96
96
|
s = int(layer["steps"])
|
|
97
|
+
if s < 1 or s > 64:
|
|
98
|
+
raise ValueError(f"steps must be between 1 and 64, got {s}")
|
|
99
|
+
if p < 0 or p > s:
|
|
100
|
+
raise ValueError(f"pulses must be between 0 and steps ({s}), got {p}")
|
|
97
101
|
rot = int(layer.get("rotation", 0))
|
|
98
102
|
pitch = int(layer["pitch"])
|
|
99
103
|
vel = int(layer.get("velocity", 100))
|
|
@@ -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
|
|
@@ -669,7 +669,14 @@ def transpose_smart(
|
|
|
669
669
|
|
|
670
670
|
source_tonic = source_key["tonic"]
|
|
671
671
|
target_tonic = target["tonic"]
|
|
672
|
-
|
|
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
|
|
673
680
|
|
|
674
681
|
if mode == "chromatic":
|
|
675
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.13",
|
|
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.13"
|
|
9
9
|
|
|
10
10
|
from _Framework.ControlSurface import ControlSurface
|
|
11
11
|
from .server import LivePilotServer
|
|
@@ -34,8 +34,8 @@ class LivePilot(ControlSurface):
|
|
|
34
34
|
ControlSurface.__init__(self, c_instance)
|
|
35
35
|
self._server = LivePilotServer(self)
|
|
36
36
|
self._server.start()
|
|
37
|
-
self.log_message("LivePilot v%s
|
|
38
|
-
self.show_message("LivePilot
|
|
37
|
+
self.log_message("LivePilot v%s starting..." % __version__)
|
|
38
|
+
self.show_message("LivePilot v%s starting..." % __version__)
|
|
39
39
|
|
|
40
40
|
def disconnect(self):
|
|
41
41
|
"""Called by Ableton when the script is unloaded."""
|
|
@@ -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
|
|
|
@@ -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,
|