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.
Files changed (37) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/AGENTS.md +1 -1
  3. package/CHANGELOG.md +57 -0
  4. package/README.md +11 -10
  5. package/bin/livepilot.js +27 -9
  6. package/livepilot/.Codex-plugin/plugin.json +1 -1
  7. package/livepilot/.claude-plugin/plugin.json +1 -1
  8. package/livepilot/skills/livepilot-core/SKILL.md +6 -6
  9. package/livepilot/skills/livepilot-core/references/overview.md +1 -1
  10. package/livepilot/skills/livepilot-release/SKILL.md +4 -4
  11. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  12. package/m4l_device/livepilot_bridge.js +77 -3
  13. package/mcp_server/__init__.py +1 -1
  14. package/mcp_server/connection.py +5 -1
  15. package/mcp_server/curves.py +5 -1
  16. package/mcp_server/m4l_bridge.py +37 -20
  17. package/mcp_server/memory/technique_store.py +30 -2
  18. package/mcp_server/server.py +10 -2
  19. package/mcp_server/tools/analyzer.py +12 -1
  20. package/mcp_server/tools/arrangement.py +20 -5
  21. package/mcp_server/tools/automation.py +52 -0
  22. package/mcp_server/tools/devices.py +2 -3
  23. package/mcp_server/tools/generative.py +4 -0
  24. package/mcp_server/tools/midi_io.py +22 -4
  25. package/mcp_server/tools/theory.py +8 -1
  26. package/mcp_server/tools/transport.py +16 -6
  27. package/package.json +1 -1
  28. package/remote_script/LivePilot/__init__.py +3 -3
  29. package/remote_script/LivePilot/arrangement.py +12 -18
  30. package/remote_script/LivePilot/browser.py +19 -8
  31. package/remote_script/LivePilot/clip_automation.py +5 -4
  32. package/remote_script/LivePilot/clips.py +14 -6
  33. package/remote_script/LivePilot/devices.py +6 -3
  34. package/remote_script/LivePilot/server.py +20 -7
  35. package/remote_script/LivePilot/tracks.py +7 -2
  36. package/remote_script/LivePilot/transport.py +3 -3
  37. 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
@@ -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
- _coerce_schema_property(variant)
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
- notes = json.loads(notes)
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
- points = json.loads(points)
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
- note_ids = json.loads(note_ids)
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
- modifications = json.loads(modifications)
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
- note_ids = json.loads(note_ids)
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 < -100:
183
+ if not (-99 <= track_index <= -1):
184
184
  raise ValueError(
185
185
  "track_index must be >= 0 for regular tracks, "
186
- "negative for return tracks (-1=A, -2=B), or -1000 for master"
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() / filename
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
- # Slot is empty — no clip to clear
190
- pass
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
- semitone_shift = target_tonic - source_tonic
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
- if not 20 <= tempo <= 999:
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
- if numerator < 1 or numerator > 99:
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.11",
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.11"
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 initialized" % __version__)
38
- self.show_message("LivePilot: Listening on port 9878")
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 NEW clip (not in old_ids) at the target position
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 id(c) not in old_ids and abs(c.start_time - pos) < 0.01:
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
- if first_clip_index is None or first_clip_index >= len(arr_clips):
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 if first_clip else "",
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
- if len(results) >= max_results:
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 = list(item.children)
129
- child_names = [c.name for c in children[:20]]
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": len(children),
133
- "children_preview": child_names,
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": "mixer",
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
- if "enabled" in params:
151
- clip.looping = bool(params["enabled"])
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
- if "warping" in params:
203
- clip.warping = bool(params["warping"])
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,