livepilot 1.9.9 → 1.9.11

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 (39) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/AGENTS.md +1 -1
  3. package/CHANGELOG.md +45 -0
  4. package/CODE_OF_CONDUCT.md +27 -0
  5. package/CONTRIBUTING.md +131 -0
  6. package/README.md +119 -395
  7. package/SECURITY.md +48 -0
  8. package/bin/livepilot.js +41 -1
  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 +30 -7
  14. package/livepilot/skills/livepilot-core/references/overview.md +1 -1
  15. package/livepilot/skills/livepilot-release/SKILL.md +7 -4
  16. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  17. package/m4l_device/LivePilot_Analyzer.maxpat +378 -4
  18. package/m4l_device/livepilot_bridge.js +413 -184
  19. package/mcp_server/__init__.py +1 -1
  20. package/mcp_server/connection.py +35 -0
  21. package/mcp_server/m4l_bridge.py +55 -5
  22. package/mcp_server/server.py +16 -29
  23. package/mcp_server/tools/_perception_engine.py +26 -3
  24. package/mcp_server/tools/_theory_engine.py +36 -5
  25. package/mcp_server/tools/analyzer.py +62 -17
  26. package/mcp_server/tools/automation.py +4 -1
  27. package/mcp_server/tools/devices.py +199 -10
  28. package/mcp_server/tools/generative.py +4 -1
  29. package/mcp_server/tools/harmony.py +16 -3
  30. package/mcp_server/tools/notes.py +4 -1
  31. package/mcp_server/tools/perception.py +27 -1
  32. package/mcp_server/tools/scenes.py +4 -1
  33. package/mcp_server/tools/theory.py +15 -7
  34. package/package.json +1 -1
  35. package/remote_script/LivePilot/__init__.py +1 -1
  36. package/remote_script/LivePilot/arrangement.py +13 -116
  37. package/remote_script/LivePilot/diagnostics.py +81 -5
  38. package/remote_script/LivePilot/router.py +22 -0
  39. package/remote_script/LivePilot/server.py +25 -13
@@ -10,13 +10,16 @@ from typing import Any, Optional
10
10
 
11
11
  from fastmcp import Context
12
12
 
13
- from ..server import mcp
13
+ from ..server import mcp, _identify_port_holder
14
14
 
15
15
 
16
16
  def _ensure_list(value: Any) -> list:
17
17
  """Parse JSON strings into lists. MCP clients may serialize list params as strings."""
18
18
  if isinstance(value, str):
19
- return json.loads(value)
19
+ try:
20
+ return json.loads(value)
21
+ except json.JSONDecodeError as exc:
22
+ raise ValueError(f"Invalid JSON in parameter: {exc}") from exc
20
23
  return value
21
24
 
22
25
 
@@ -26,6 +29,153 @@ def _get_ableton(ctx: Context):
26
29
 
27
30
 
28
31
  MASTER_TRACK_INDEX = -1000
32
+ _PLUGIN_CLASS_NAMES = {"PluginDevice", "AuPluginDevice"}
33
+ _SAMPLE_DEPENDENT_DEVICE_NAMES = {
34
+ "idensity": "Requires source audio loaded inside the plugin UI before MIDI can produce sound.",
35
+ "tardigrain": "Requires source audio loaded inside the plugin UI before MIDI can produce sound.",
36
+ "koala sampler": "Requires source audio loaded inside the plugin UI before MIDI can produce sound.",
37
+ "burns audio granular": "Requires source audio loaded inside the plugin UI before MIDI can produce sound.",
38
+ "audiolayer": "Requires samples loaded inside the plugin UI before MIDI can produce sound.",
39
+ "segments": "Requires source audio loaded inside the plugin UI before MIDI can produce sound.",
40
+ "segments (instr)": "Requires source audio loaded inside the plugin UI before MIDI can produce sound.",
41
+ }
42
+
43
+
44
+ def _sample_dependency_reason(device_name: str) -> Optional[str]:
45
+ lowered = device_name.strip().lower()
46
+ for candidate, reason in _SAMPLE_DEPENDENT_DEVICE_NAMES.items():
47
+ if candidate in lowered:
48
+ return reason
49
+ return None
50
+
51
+
52
+ def _annotate_device_info(result: dict) -> dict:
53
+ """Attach MCP-focused health hints to raw get_device_info results."""
54
+ if not isinstance(result, dict):
55
+ return result
56
+
57
+ class_name = str(result.get("class_name") or "")
58
+ device_name = str(result.get("name") or "")
59
+ parameter_count = int(result.get("parameter_count") or 0)
60
+ is_plugin = class_name in _PLUGIN_CLASS_NAMES
61
+
62
+ plugin_host_status = "not_plugin"
63
+ if is_plugin:
64
+ plugin_host_status = "host_visible" if parameter_count > 1 else "opaque_or_failed"
65
+
66
+ flags: list[str] = []
67
+ warnings: list[str] = []
68
+
69
+ sample_reason = _sample_dependency_reason(device_name)
70
+ if sample_reason:
71
+ flags.append("sample_dependent")
72
+ warnings.append(sample_reason)
73
+
74
+ if plugin_host_status == "opaque_or_failed":
75
+ flags.append("opaque_or_failed_plugin")
76
+ warnings.append(
77
+ "Ableton only sees %d host parameter(s) for this plugin. "
78
+ "If auditioning produces no audio, the plugin likely failed to initialize. "
79
+ "If audio is flowing, the plugin is usable but opaque to MCP sound design."
80
+ % parameter_count
81
+ )
82
+
83
+ annotated = dict(result)
84
+ annotated["is_plugin"] = is_plugin
85
+ annotated["plugin_host_status"] = plugin_host_status
86
+ annotated["health_flags"] = flags
87
+ annotated["mcp_sound_design_ready"] = len(flags) == 0
88
+ if warnings:
89
+ annotated["warnings"] = warnings
90
+ return annotated
91
+
92
+
93
+ def _annotate_loaded_device_result(result: dict) -> dict:
94
+ """Attach preflight warnings to load results based on loaded device names."""
95
+ if not isinstance(result, dict):
96
+ return result
97
+
98
+ loaded_name = str(result.get("loaded") or "")
99
+ sample_reason = _sample_dependency_reason(loaded_name)
100
+ if not sample_reason:
101
+ return result
102
+
103
+ annotated = dict(result)
104
+ annotated["health_flags"] = ["sample_dependent"]
105
+ annotated["warnings"] = [sample_reason]
106
+ annotated["mcp_sound_design_ready"] = False
107
+ return annotated
108
+
109
+
110
+ def _merge_unique(base: list[str], extra: list[str]) -> list[str]:
111
+ merged = list(base)
112
+ for item in extra:
113
+ if item not in merged:
114
+ merged.append(item)
115
+ return merged
116
+
117
+
118
+ def _postflight_loaded_device(ctx: Context, result: dict) -> dict:
119
+ """Attach post-load health info by inspecting the newly loaded device."""
120
+ annotated = _annotate_loaded_device_result(result)
121
+ if not isinstance(annotated, dict):
122
+ return annotated
123
+
124
+ track_index = annotated.get("track_index")
125
+ loaded_name = str(annotated.get("loaded") or "")
126
+ if track_index is None or not loaded_name:
127
+ return annotated
128
+
129
+ try:
130
+ track_info = _get_ableton(ctx).send_command("get_track_info", {
131
+ "track_index": int(track_index),
132
+ })
133
+ except Exception:
134
+ return annotated
135
+
136
+ devices = track_info.get("devices", []) if isinstance(track_info, dict) else []
137
+ if not isinstance(devices, list) or not devices:
138
+ return annotated
139
+
140
+ match = None
141
+ for device in reversed(devices):
142
+ if str(device.get("name") or "") == loaded_name:
143
+ match = device
144
+ break
145
+ if match is None:
146
+ match = devices[-1]
147
+
148
+ device_info = _annotate_device_info({
149
+ "name": match.get("name"),
150
+ "class_name": match.get("class_name"),
151
+ "is_active": match.get("is_active"),
152
+ "parameter_count": len(match.get("parameters", [])),
153
+ })
154
+
155
+ merged = dict(annotated)
156
+ merged["device_index"] = match.get("index")
157
+ merged["class_name"] = device_info.get("class_name")
158
+ merged["parameter_count"] = device_info.get("parameter_count")
159
+ merged["is_plugin"] = device_info.get("is_plugin")
160
+ merged["plugin_host_status"] = device_info.get("plugin_host_status")
161
+ merged["mcp_sound_design_ready"] = (
162
+ merged.get("mcp_sound_design_ready", True)
163
+ and device_info.get("mcp_sound_design_ready", True)
164
+ )
165
+
166
+ merged["health_flags"] = _merge_unique(
167
+ annotated.get("health_flags", []),
168
+ device_info.get("health_flags", []),
169
+ )
170
+
171
+ warnings = _merge_unique(
172
+ annotated.get("warnings", []),
173
+ device_info.get("warnings", []),
174
+ )
175
+ if warnings:
176
+ merged["warnings"] = warnings
177
+
178
+ return merged
29
179
 
30
180
 
31
181
  def _validate_track_index(track_index: int):
@@ -54,10 +204,11 @@ def get_device_info(ctx: Context, track_index: int, device_index: int) -> dict:
54
204
  track_index: 0+ for regular tracks, -1/-2/... for return tracks (A/B/...), -1000 for master."""
55
205
  _validate_track_index(track_index)
56
206
  _validate_device_index(device_index)
57
- return _get_ableton(ctx).send_command("get_device_info", {
207
+ result = _get_ableton(ctx).send_command("get_device_info", {
58
208
  "track_index": track_index,
59
209
  "device_index": device_index,
60
210
  })
211
+ return _annotate_device_info(result)
61
212
 
62
213
 
63
214
  @mcp.tool()
@@ -157,10 +308,11 @@ def load_device_by_uri(ctx: Context, track_index: int, uri: str) -> dict:
157
308
  _validate_track_index(track_index)
158
309
  if not uri.strip():
159
310
  raise ValueError("URI cannot be empty")
160
- return _get_ableton(ctx).send_command("load_device_by_uri", {
311
+ result = _get_ableton(ctx).send_command("load_device_by_uri", {
161
312
  "track_index": track_index,
162
313
  "uri": uri,
163
314
  })
315
+ return _postflight_loaded_device(ctx, result)
164
316
 
165
317
 
166
318
  @mcp.tool()
@@ -170,10 +322,11 @@ def find_and_load_device(ctx: Context, track_index: int, device_name: str) -> di
170
322
  _validate_track_index(track_index)
171
323
  if not device_name.strip():
172
324
  raise ValueError("device_name cannot be empty")
173
- return _get_ableton(ctx).send_command("find_and_load_device", {
325
+ result = _get_ableton(ctx).send_command("find_and_load_device", {
174
326
  "track_index": track_index,
175
327
  "device_name": device_name,
176
328
  })
329
+ return _postflight_loaded_device(ctx, result)
177
330
 
178
331
 
179
332
  @mcp.tool()
@@ -263,7 +416,7 @@ def _get_m4l(ctx: Context):
263
416
  """Get M4LBridge from lifespan context."""
264
417
  bridge = ctx.lifespan_context.get("m4l")
265
418
  if not bridge:
266
- raise RuntimeError("M4L bridge not initialized")
419
+ raise ValueError("M4L bridge not initialized — restart the MCP server")
267
420
  return bridge
268
421
 
269
422
 
@@ -271,12 +424,48 @@ def _get_spectral(ctx: Context):
271
424
  """Get SpectralCache from lifespan context."""
272
425
  cache = ctx.lifespan_context.get("spectral")
273
426
  if not cache:
274
- raise RuntimeError("Spectral cache not initialized")
427
+ raise ValueError("Spectral cache not initialized — restart the MCP server")
428
+ # Keep the active request context attached so analyzer error paths can
429
+ # distinguish "device missing" from "bridge disconnected".
430
+ setattr(cache, "_livepilot_ctx", ctx)
275
431
  return cache
276
432
 
277
433
 
278
434
  def _require_analyzer(cache) -> None:
279
435
  if not cache.is_connected:
436
+ ctx = getattr(cache, "_livepilot_ctx", None)
437
+ try:
438
+ track = (
439
+ ctx.lifespan_context["ableton"].send_command("get_master_track")
440
+ if ctx else {}
441
+ )
442
+ except Exception:
443
+ track = {}
444
+
445
+ devices = track.get("devices", []) if isinstance(track, dict) else []
446
+ analyzer_loaded = False
447
+ for device in devices:
448
+ normalized = " ".join(
449
+ str(device.get("name") or "").replace("_", " ").replace("-", " ").lower().split()
450
+ )
451
+ if normalized == "livepilot analyzer":
452
+ analyzer_loaded = True
453
+ break
454
+
455
+ if analyzer_loaded:
456
+ holder = _identify_port_holder(9880)
457
+ detail = (
458
+ "LivePilot Analyzer is loaded on the master track, but its UDP bridge is not connected. "
459
+ )
460
+ if holder:
461
+ detail += (
462
+ "UDP port 9880 is currently held by another LivePilot instance "
463
+ f"({holder}). Close the other client/server, then retry."
464
+ )
465
+ else:
466
+ detail += "Reload the analyzer device or restart the MCP server."
467
+ raise ValueError(detail)
468
+
280
469
  raise ValueError(
281
470
  "LivePilot Analyzer not detected. "
282
471
  "Drag 'LivePilot Analyzer' onto the master track."
@@ -302,7 +491,7 @@ async def get_plugin_parameters(
302
491
  cache = _get_spectral(ctx)
303
492
  _require_analyzer(cache)
304
493
  bridge = _get_m4l(ctx)
305
- return await bridge.send_command("get_plugin_params", track_index, device_index)
494
+ return await bridge.send_command("get_plugin_params", track_index, device_index, timeout=20.0)
306
495
 
307
496
 
308
497
  @mcp.tool()
@@ -326,7 +515,7 @@ async def map_plugin_parameter(
326
515
  cache = _get_spectral(ctx)
327
516
  _require_analyzer(cache)
328
517
  bridge = _get_m4l(ctx)
329
- return await bridge.send_command("map_plugin_param", track_index, device_index, parameter_index)
518
+ return await bridge.send_command("map_plugin_param", track_index, device_index, parameter_index, timeout=10.0)
330
519
 
331
520
 
332
521
  @mcp.tool()
@@ -346,4 +535,4 @@ async def get_plugin_presets(
346
535
  cache = _get_spectral(ctx)
347
536
  _require_analyzer(cache)
348
537
  bridge = _get_m4l(ctx)
349
- return await bridge.send_command("get_plugin_presets", track_index, device_index)
538
+ return await bridge.send_command("get_plugin_presets", track_index, device_index, timeout=15.0)
@@ -17,7 +17,10 @@ from . import _theory_engine as theory
17
17
 
18
18
  def _ensure_list(value: Any) -> list:
19
19
  if isinstance(value, str):
20
- return json.loads(value)
20
+ try:
21
+ return json.loads(value)
22
+ except json.JSONDecodeError as exc:
23
+ raise ValueError(f"Invalid JSON in parameter: {exc}") from exc
21
24
  return value
22
25
 
23
26
 
@@ -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,
@@ -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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "livepilot",
3
- "version": "1.9.9",
3
+ "version": "1.9.11",
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.11"
9
9
 
10
10
  from _Framework.ControlSurface import ControlSurface
11
11
  from .server import LivePilotServer
@@ -494,6 +494,10 @@ def set_arrangement_automation(song, params):
494
494
  "parameter_type must be 'device', 'volume', 'panning', or 'send'"
495
495
  )
496
496
 
497
+ # Clamp values to parameter range
498
+ p_min = float(parameter.min)
499
+ p_max = float(parameter.max)
500
+
497
501
  # Try direct envelope access on the arrangement clip
498
502
  envelope = clip.automation_envelope(parameter)
499
503
  if envelope is None:
@@ -509,7 +513,7 @@ def set_arrangement_automation(song, params):
509
513
  points_written = 0
510
514
  for pt in points:
511
515
  time = float(pt["time"])
512
- value = float(pt["value"])
516
+ value = max(p_min, min(p_max, float(pt["value"])))
513
517
  duration = float(pt.get("duration", 0.125))
514
518
  envelope.insert_step(time, duration, value)
515
519
  points_written += 1
@@ -523,121 +527,14 @@ def set_arrangement_automation(song, params):
523
527
  "method": "direct",
524
528
  }
525
529
 
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
- }
530
+ # No fallback — direct envelope creation is the only safe approach.
531
+ # Session-clip duplication can silently create overlapping clips.
532
+ raise ValueError(
533
+ "Cannot create automation envelope for parameter '%s' on this "
534
+ "arrangement clip. Direct envelope access is not supported for "
535
+ "this parameter type or Live version."
536
+ % parameter.name
537
+ )
641
538
 
642
539
 
643
540
  @register("transpose_arrangement_notes")