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.
Files changed (51) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/AGENTS.md +1 -1
  3. package/CHANGELOG.md +92 -0
  4. package/CODE_OF_CONDUCT.md +27 -0
  5. package/CONTRIBUTING.md +131 -0
  6. package/README.md +112 -387
  7. package/SECURITY.md +48 -0
  8. package/bin/livepilot.js +42 -2
  9. package/livepilot/.Codex-plugin/plugin.json +8 -0
  10. package/livepilot/.claude-plugin/plugin.json +1 -1
  11. package/livepilot/commands/beat.md +18 -6
  12. package/livepilot/commands/sounddesign.md +6 -5
  13. package/livepilot/skills/livepilot-core/SKILL.md +36 -13
  14. package/livepilot/skills/livepilot-core/references/overview.md +1 -1
  15. package/livepilot/skills/livepilot-release/SKILL.md +11 -8
  16. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  17. package/m4l_device/LivePilot_Analyzer.maxpat +378 -4
  18. package/m4l_device/capture_2026_04_07_192216.wav +0 -0
  19. package/m4l_device/livepilot_bridge.js +487 -184
  20. package/mcp_server/__init__.py +1 -1
  21. package/mcp_server/connection.py +40 -1
  22. package/mcp_server/curves.py +5 -1
  23. package/mcp_server/m4l_bridge.py +93 -26
  24. package/mcp_server/server.py +26 -31
  25. package/mcp_server/tools/_perception_engine.py +26 -3
  26. package/mcp_server/tools/_theory_engine.py +36 -5
  27. package/mcp_server/tools/analyzer.py +74 -18
  28. package/mcp_server/tools/arrangement.py +20 -5
  29. package/mcp_server/tools/automation.py +56 -1
  30. package/mcp_server/tools/devices.py +201 -13
  31. package/mcp_server/tools/generative.py +8 -1
  32. package/mcp_server/tools/harmony.py +16 -3
  33. package/mcp_server/tools/midi_io.py +22 -4
  34. package/mcp_server/tools/notes.py +4 -1
  35. package/mcp_server/tools/perception.py +27 -1
  36. package/mcp_server/tools/scenes.py +4 -1
  37. package/mcp_server/tools/theory.py +23 -8
  38. package/mcp_server/tools/transport.py +16 -6
  39. package/package.json +1 -1
  40. package/remote_script/LivePilot/__init__.py +1 -1
  41. package/remote_script/LivePilot/arrangement.py +25 -134
  42. package/remote_script/LivePilot/browser.py +19 -8
  43. package/remote_script/LivePilot/clip_automation.py +5 -4
  44. package/remote_script/LivePilot/clips.py +14 -6
  45. package/remote_script/LivePilot/devices.py +6 -3
  46. package/remote_script/LivePilot/diagnostics.py +81 -5
  47. package/remote_script/LivePilot/router.py +22 -0
  48. package/remote_script/LivePilot/server.py +41 -17
  49. package/remote_script/LivePilot/tracks.py +7 -2
  50. package/remote_script/LivePilot/transport.py +3 -3
  51. 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
- return json.loads(value)
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 chords]
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": 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() / 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
@@ -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
- return json.loads(value)
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, rms_dbfs,
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
- return json.loads(value)
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 algorithm with 7 mode profiles (major, minor,
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
- 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
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
- 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.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.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 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
 
@@ -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
- # Fallback: session-clip-then-duplicate workaround.
527
- # Create a temporary session clip, write automation there,
528
- # then duplicate to arrangement. Envelopes may survive the copy.
529
- arr_start = clip.start_time
530
- arr_length = clip.length
531
-
532
- # Find an empty clip slot for the temporary clip
533
- slots = list(track.clip_slots)
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
- 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,
@@ -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.pop(0)
408
+ item, depth = queue.popleft()
406
409
  if depth > 8:
407
410
  continue
408
411
  try: