livepilot 1.27.1 → 1.27.3

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 (117) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +10 -7
  3. package/bin/livepilot.js +156 -37
  4. package/livepilot/.Codex-plugin/plugin.json +1 -1
  5. package/livepilot/.claude-plugin/plugin.json +1 -1
  6. package/livepilot/skills/livepilot-core/SKILL.md +33 -1
  7. package/livepilot/skills/livepilot-core/references/atlas-tool-notes.md +69 -0
  8. package/livepilot/skills/livepilot-core/references/overview.md +18 -11
  9. package/livepilot/skills/livepilot-core/references/perception.md +125 -0
  10. package/livepilot/skills/livepilot-devices/references/device-parameter-units.md +66 -0
  11. package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
  12. package/livepilot/skills/livepilot-mix-engine/SKILL.md +8 -0
  13. package/livepilot/skills/livepilot-release/SKILL.md +1 -1
  14. package/livepilot/skills/livepilot-sample-engine/references/splice-tools-notes.md +56 -0
  15. package/livepilot/skills/livepilot-wonder/SKILL.md +6 -0
  16. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  17. package/m4l_device/livepilot_bridge.js +48 -11
  18. package/mcp_server/__init__.py +1 -1
  19. package/mcp_server/atlas/__init__.py +181 -40
  20. package/mcp_server/atlas/overlays.py +46 -3
  21. package/mcp_server/atlas/tools.py +226 -86
  22. package/mcp_server/audit/checks.py +50 -22
  23. package/mcp_server/audit/state.py +10 -2
  24. package/mcp_server/audit/tools.py +27 -6
  25. package/mcp_server/composer/develop/apply.py +7 -7
  26. package/mcp_server/composer/fast/apply.py +29 -23
  27. package/mcp_server/composer/fast/brief_builder.py +6 -2
  28. package/mcp_server/composer/framework/atlas_resolver.py +16 -1
  29. package/mcp_server/composer/full/apply.py +40 -34
  30. package/mcp_server/composer/full/engine.py +66 -32
  31. package/mcp_server/composer/tools.py +16 -9
  32. package/mcp_server/connection.py +69 -10
  33. package/mcp_server/creative_constraints/engine.py +10 -2
  34. package/mcp_server/creative_constraints/tools.py +24 -7
  35. package/mcp_server/curves.py +30 -7
  36. package/mcp_server/device_forge/builder.py +40 -11
  37. package/mcp_server/device_forge/models.py +9 -0
  38. package/mcp_server/device_forge/tools.py +30 -11
  39. package/mcp_server/experiment/engine.py +29 -22
  40. package/mcp_server/experiment/tools.py +15 -5
  41. package/mcp_server/m4l_bridge.py +217 -62
  42. package/mcp_server/memory/taste_graph.py +43 -0
  43. package/mcp_server/memory/technique_store.py +26 -3
  44. package/mcp_server/memory/tools.py +62 -4
  45. package/mcp_server/mix_engine/critics.py +187 -30
  46. package/mcp_server/mix_engine/models.py +21 -1
  47. package/mcp_server/mix_engine/state_builder.py +87 -8
  48. package/mcp_server/mix_engine/tools.py +16 -4
  49. package/mcp_server/performance_engine/tools.py +56 -8
  50. package/mcp_server/persistence/base_store.py +11 -0
  51. package/mcp_server/persistence/project_store.py +132 -8
  52. package/mcp_server/persistence/taste_store.py +90 -7
  53. package/mcp_server/preview_studio/engine.py +40 -10
  54. package/mcp_server/preview_studio/models.py +40 -0
  55. package/mcp_server/preview_studio/tools.py +56 -12
  56. package/mcp_server/project_brain/arrangement_graph.py +4 -0
  57. package/mcp_server/project_brain/models.py +2 -0
  58. package/mcp_server/project_brain/role_graph.py +13 -7
  59. package/mcp_server/reference_engine/gap_analyzer.py +58 -3
  60. package/mcp_server/reference_engine/profile_builder.py +47 -4
  61. package/mcp_server/reference_engine/tools.py +6 -0
  62. package/mcp_server/runtime/execution_router.py +57 -1
  63. package/mcp_server/runtime/mcp_dispatch.py +22 -0
  64. package/mcp_server/runtime/remote_commands.py +9 -4
  65. package/mcp_server/runtime/tools.py +0 -1
  66. package/mcp_server/sample_engine/sources.py +0 -2
  67. package/mcp_server/sample_engine/tools.py +19 -38
  68. package/mcp_server/semantic_moves/mix_compilers.py +276 -51
  69. package/mcp_server/semantic_moves/performance_compilers.py +51 -17
  70. package/mcp_server/semantic_moves/resolvers.py +45 -0
  71. package/mcp_server/semantic_moves/sound_design_compilers.py +14 -6
  72. package/mcp_server/semantic_moves/tools.py +80 -2
  73. package/mcp_server/semantic_moves/transition_compilers.py +26 -9
  74. package/mcp_server/server.py +27 -25
  75. package/mcp_server/session_continuity/tracker.py +51 -3
  76. package/mcp_server/song_brain/builder.py +47 -5
  77. package/mcp_server/song_brain/tools.py +21 -7
  78. package/mcp_server/sound_design/critics.py +1 -0
  79. package/mcp_server/splice_client/client.py +117 -33
  80. package/mcp_server/splice_client/http_bridge.py +15 -3
  81. package/mcp_server/splice_client/quota.py +28 -0
  82. package/mcp_server/stuckness_detector/detector.py +8 -5
  83. package/mcp_server/synthesis_brain/adapters/drift.py +13 -0
  84. package/mcp_server/synthesis_brain/adapters/meld.py +13 -0
  85. package/mcp_server/tools/_analyzer_engine/sample.py +36 -10
  86. package/mcp_server/tools/_perception_engine.py +6 -0
  87. package/mcp_server/tools/agent_os.py +4 -1
  88. package/mcp_server/tools/analyzer.py +198 -209
  89. package/mcp_server/tools/arrangement.py +7 -4
  90. package/mcp_server/tools/automation.py +24 -4
  91. package/mcp_server/tools/browser.py +25 -11
  92. package/mcp_server/tools/clips.py +6 -0
  93. package/mcp_server/tools/composition.py +33 -2
  94. package/mcp_server/tools/devices.py +53 -53
  95. package/mcp_server/tools/generative.py +14 -14
  96. package/mcp_server/tools/harmony.py +7 -7
  97. package/mcp_server/tools/mixing.py +4 -4
  98. package/mcp_server/tools/planner.py +68 -6
  99. package/mcp_server/tools/research.py +20 -2
  100. package/mcp_server/tools/theory.py +10 -10
  101. package/mcp_server/tools/transport.py +7 -2
  102. package/mcp_server/transition_engine/critics.py +13 -1
  103. package/mcp_server/user_corpus/tools.py +30 -1
  104. package/mcp_server/wonder_mode/engine.py +82 -9
  105. package/mcp_server/wonder_mode/session.py +32 -10
  106. package/mcp_server/wonder_mode/tools.py +14 -1
  107. package/package.json +1 -1
  108. package/remote_script/LivePilot/__init__.py +21 -8
  109. package/remote_script/LivePilot/arrangement.py +93 -33
  110. package/remote_script/LivePilot/browser.py +60 -4
  111. package/remote_script/LivePilot/devices.py +132 -62
  112. package/remote_script/LivePilot/mixing.py +31 -5
  113. package/remote_script/LivePilot/server.py +94 -22
  114. package/remote_script/LivePilot/tracks.py +11 -5
  115. package/remote_script/LivePilot/transport.py +11 -0
  116. package/requirements.txt +5 -5
  117. package/server.json +2 -2
@@ -107,10 +107,13 @@ def create_arrangement_clip(
107
107
  8-beat pattern inside a 128-beat section). Defaults to
108
108
  the source clip's length. Must be > 0.
109
109
 
110
- When loop_length < source clip length, overlapping copies are placed
111
- every loop_length beats. Ableton's "later clip takes priority" rule
112
- ensures correct playback. Each copy's internal loop region is set to
113
- loop_length beats. For best results, use loop_length >= source length.
110
+ Copies are tiled every min(loop_length, source length) beats so the
111
+ region is always filled seamlessly a loop_length larger than the
112
+ source no longer leaves a silent gap between copies. When
113
+ loop_length < source length, overlapping copies are placed every
114
+ loop_length beats and each copy's internal loop region is set to
115
+ loop_length beats (Ableton's "later clip takes priority" rule ensures
116
+ correct playback).
114
117
 
115
118
  name: optional clip display name
116
119
  color_index: optional 0-69 Ableton color
@@ -7,6 +7,7 @@ engine (curves.py) for musically intelligent automation.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ import asyncio
10
11
  from typing import Any, Optional
11
12
 
12
13
  from fastmcp import Context
@@ -110,11 +111,25 @@ def set_clip_automation(
110
111
  raise ValueError("device_index and parameter_index required for parameter_type='device'")
111
112
  if parameter_type == "send" and send_index is None:
112
113
  raise ValueError("send_index required for parameter_type='send'")
114
+ points_list = _ensure_list(points)
115
+ # Clamp values for fixed-range mixer envelopes so an out-of-range curve
116
+ # value (e.g. volume=1.5 from an inverted/scaled curve) can't reach the
117
+ # Remote Script. Mirrors set_track_volume/send (0.0-1.0) and
118
+ # set_track_pan (-1.0..1.0). Device params keep their native range — the
119
+ # caller is responsible for scaling those to the parameter's min/max.
120
+ if parameter_type in ("volume", "send"):
121
+ for pt in points_list:
122
+ if isinstance(pt, dict) and "value" in pt:
123
+ pt["value"] = max(0.0, min(1.0, pt["value"]))
124
+ elif parameter_type == "panning":
125
+ for pt in points_list:
126
+ if isinstance(pt, dict) and "value" in pt:
127
+ pt["value"] = max(-1.0, min(1.0, pt["value"]))
113
128
  params: dict = {
114
129
  "track_index": track_index,
115
130
  "clip_index": clip_index,
116
131
  "parameter_type": parameter_type,
117
- "points": _ensure_list(points),
132
+ "points": points_list,
118
133
  }
119
134
  if device_index is not None:
120
135
  params["device_index"] = device_index
@@ -257,6 +272,8 @@ def apply_automation_shape(
257
272
  raise ValueError("device_index and parameter_index required for parameter_type='device'")
258
273
  if parameter_type == "send" and send_index is None:
259
274
  raise ValueError("send_index required for parameter_type='send'")
275
+ if curve_type == "euclidean" and steps < 1:
276
+ raise ValueError("euclidean 'steps' must be >= 1")
260
277
 
261
278
  # Generate the curve
262
279
  points = generate_curve(
@@ -465,6 +482,8 @@ def generate_automation_curve(
465
482
  Pass the returned points to set_clip_automation or
466
483
  set_arrangement_automation to write them.
467
484
  """
485
+ if curve_type == "euclidean" and steps < 1:
486
+ raise ValueError("euclidean 'steps' must be >= 1")
468
487
  points = generate_curve(
469
488
  curve_type=curve_type,
470
489
  duration=duration,
@@ -717,7 +736,8 @@ async def set_arrangement_automation_via_session_record(
717
736
  if send_index is not None:
718
737
  start_params["send_index"] = send_index
719
738
 
720
- start_result = ableton.send_command(
739
+ start_result = await asyncio.to_thread(
740
+ ableton.send_command,
721
741
  "arrangement_automation_via_session_record_start",
722
742
  start_params,
723
743
  )
@@ -732,7 +752,6 @@ async def set_arrangement_automation_via_session_record(
732
752
  # Compute sleep: duration_beats * 60/tempo seconds + 0.5s buffer for
733
753
  # Live's record-arm latency. Tempo comes back from the start handler
734
754
  # (fresh Song.tempo read) so we don't hardcode 120.
735
- import asyncio
736
755
  tempo = float(start_result.get("tempo") or 120.0)
737
756
  if tempo <= 0:
738
757
  tempo = 120.0
@@ -755,7 +774,8 @@ async def set_arrangement_automation_via_session_record(
755
774
  "duration_beats": duration_beats,
756
775
  "cleanup_session_clip": cleanup_session_clip,
757
776
  }
758
- complete_result = ableton.send_command(
777
+ complete_result = await asyncio.to_thread(
778
+ ableton.send_command,
759
779
  "arrangement_automation_via_session_record_complete",
760
780
  complete_params,
761
781
  )
@@ -327,12 +327,12 @@ def load_browser_item(
327
327
 
328
328
  # Post-load: probe the loaded device once, then apply two layers of hygiene.
329
329
  #
330
- # 2026-05-02 — fixed device-detection bug. The TCP load_browser_item command
331
- # returns {loaded, name, device_count} with NO device_index and NO class_name,
332
- # so the previous detection (`result.get("device_index")` / `result.get("class_name")`)
333
- # always failed and the role-defaults branch was never entered. Resolution:
334
- # treat newly-loaded sample-on-empty-track as device_index=0 (Live places the
335
- # instrument at chain head) and verify class + name via get_device_info.
330
+ # 2026-06-24 — fixed wrong-device bug. Live APPENDS a newly-loaded device at
331
+ # the END of the chain, so on a NON-EMPTY track the loaded device is NOT at
332
+ # index 0. The remote handler now returns device_index (= device_count - 1);
333
+ # we use that (falling back to device_count - 1, then 0) and probe THAT index
334
+ # to verify class + name before applying any role-default / hygiene writes
335
+ # otherwise the writes would hit the pre-existing device 0.
336
336
  #
337
337
  # Layer 1 (gated on `role`): Simpler role-aware defaults — Snap/Volume/
338
338
  # Trigger Mode/Transpose for drum/melodic/texture roles.
@@ -342,18 +342,32 @@ def load_browser_item(
342
342
  device_class = ""
343
343
  device_name_loaded = ""
344
344
  if isinstance(result, dict) and result.get("loaded") and not result.get("error"):
345
+ # Resolve the index of the JUST-LOADED device. Live appends new devices
346
+ # at the END of the chain, so the loaded device is device_count - 1 —
347
+ # NOT index 0. Probing/writing index 0 on a NON-EMPTY track would
348
+ # read/mutate the WRONG (pre-existing) device while leaving the freshly
349
+ # loaded one at raw defaults (the documented drum-Simpler Transpose=+24
350
+ # silent-failure path). Prefer the remote-reported device_index, fall
351
+ # back to device_count - 1, then to 0 (empty track / unknown count).
345
352
  device_index_resolved = result.get("device_index")
353
+ if device_index_resolved is None:
354
+ dc = result.get("device_count")
355
+ device_index_resolved = (dc - 1) if isinstance(dc, int) and dc > 0 else 0
346
356
  try:
347
357
  probe = ableton.send_command("get_device_info", {
348
358
  "track_index": track_index,
349
- "device_index": 0,
359
+ "device_index": int(device_index_resolved),
350
360
  })
351
361
  device_class = str(probe.get("class_name", "") or "")
352
362
  device_name_loaded = str(probe.get("name", "") or result.get("name", "") or "")
353
- if device_index_resolved is None:
354
- device_index_resolved = 0
355
- except Exception:
356
- pass
363
+ except Exception as exc:
364
+ # Surface the probe failure instead of silently swallowing it: the
365
+ # role-defaults / M4L-hygiene blocks below are gated on
366
+ # device_class / device_name_loaded and will quietly skip, so
367
+ # without this the load reports success while post-load hygiene
368
+ # (drum Transpose=+24, Snap, Volume) never ran.
369
+ result["role_defaults_skipped"] = "device probe failed: %s" % exc
370
+ device_name_loaded = str(result.get("name", "") or "")
357
371
 
358
372
  # Layer 0 — atlas-aware preflight. Surface a follow-up hint when the
359
373
  # loaded device declares self_contained=false in its atlas enrichment
@@ -516,6 +516,8 @@ def get_clip_scale(ctx: Context, track_index: int, clip_index: int) -> dict:
516
516
  Returns {root_note (0-11), scale_mode (bool), scale_name (str)}.
517
517
  Raises if the clip slot is empty.
518
518
  """
519
+ _validate_track_index(track_index)
520
+ _validate_clip_index(clip_index)
519
521
  return _get_ableton(ctx).send_command("get_clip_scale", {
520
522
  "track_index": track_index,
521
523
  "clip_index": clip_index,
@@ -540,6 +542,8 @@ def set_clip_scale(
540
542
  scale_name: must match one of Live's built-in scales
541
543
  (call list_available_scales() if unsure)
542
544
  """
545
+ _validate_track_index(track_index)
546
+ _validate_clip_index(clip_index)
543
547
  if not 0 <= root_note <= 11:
544
548
  raise ValueError("root_note must be 0-11")
545
549
  if not scale_name.strip():
@@ -564,6 +568,8 @@ def set_clip_scale_mode(
564
568
  When enabled on a clip, its notes are constrained/highlighted
565
569
  by the clip's own root_note + scale_name (set via set_clip_scale).
566
570
  """
571
+ _validate_track_index(track_index)
572
+ _validate_clip_index(clip_index)
567
573
  return _get_ableton(ctx).send_command("set_clip_scale_mode", {
568
574
  "track_index": track_index,
569
575
  "clip_index": clip_index,
@@ -57,6 +57,27 @@ def _build_clip_matrix(ableton, scene_count: int, track_count: int) -> list[list
57
57
  return [[] for _ in range(scene_count)]
58
58
 
59
59
 
60
+ def _slot_has_clip(clip_matrix: list[list], scene_idx: int, track_idx: int) -> bool:
61
+ """Whether the (scene, track) session slot actually holds a clip.
62
+
63
+ Reads the clip_matrix produced by get_scene_matrix
64
+ (matrix[scene_index][track_index] = {"state": ...}). A slot holds a
65
+ clip when its state is anything other than "empty"/"missing". Used to
66
+ gate per-slot get_notes round-trips so empty slots — the bulk of a
67
+ sparse session grid — don't each cost a blocking TCP call on
68
+ Ableton's main thread.
69
+ """
70
+ if scene_idx >= len(clip_matrix):
71
+ return False
72
+ row = clip_matrix[scene_idx]
73
+ if track_idx >= len(row):
74
+ return False
75
+ cell = row[track_idx]
76
+ if not cell:
77
+ return False
78
+ return cell.get("state") not in ("empty", "missing")
79
+
80
+
60
81
  # ── analyze_composition ───────────────────────────────────────────────
61
82
 
62
83
 
@@ -126,9 +147,15 @@ def analyze_composition(ctx: Context) -> dict:
126
147
 
127
148
  for track in tracks:
128
149
  t_idx = track["index"]
129
- # Collect notes from all clips
150
+ # Collect notes only from slots that actually hold a clip. Empty
151
+ # slots are skipped — the clip_matrix already tells us they have no
152
+ # notes, so issuing a blocking get_notes round-trip per empty slot
153
+ # would burn O(tracks x scenes) synchronous TCP calls on Ableton's
154
+ # main thread for no data.
130
155
  track_notes = []
131
156
  for s_idx in range(len(scenes)):
157
+ if not _slot_has_clip(clip_matrix, s_idx, t_idx):
158
+ continue
132
159
  try:
133
160
  result = ableton.send_command("get_notes", {
134
161
  "track_index": t_idx, "clip_index": s_idx
@@ -241,7 +268,11 @@ def get_phrase_grid(
241
268
  # (which maps to the actual clip slot), not the section_index
242
269
  # (which is a position in the section graph)
243
270
  notes_by_track: dict[int, list] = {}
244
- scene_idx = section.scene_index if hasattr(section, "scene_index") else section_index
271
+ # scene_index now ALWAYS exists (dataclass default -1), so a hasattr() guard
272
+ # is always True and would forward -1 for arrangement-backed sections — and
273
+ # clip_index=-1 silently wraps to the LAST clip slot. Guard on the value
274
+ # being a real slot (>= 0), matching get_harmony_field's guard.
275
+ scene_idx = section.scene_index if getattr(section, "scene_index", -1) >= 0 else section_index
245
276
  for t_idx in section.tracks_active:
246
277
  try:
247
278
  result = ableton.send_command("get_notes", {
@@ -5,6 +5,7 @@
5
5
 
6
6
  from __future__ import annotations
7
7
 
8
+ import asyncio
8
9
  import json
9
10
  from typing import Any, Optional
10
11
 
@@ -252,40 +253,32 @@ def set_device_parameter(
252
253
 
253
254
  track_index: 0+ for regular tracks, -1/-2/... for return tracks (A/B/...), -1000 for master.
254
255
 
255
- ⚠️ PARAMETER RANGES ARE NOT ALWAYS 0-1 (BUG-B4 / B9 / 2026-04-26#2):
256
- Ableton devices use MIXED units depending on the parameter. Always
257
- read the `value_string` in the response (and the `min`/`max` from
258
- get_device_parameters) before assuming 0-1 semantics:
259
-
260
- - Auto Filter `Frequency`: 20-135 index (NOT normalized)
261
- - Auto Filter Legacy `LFO Amount`: 0-30 absolute (displays as %)
262
- - Auto Filter `Resonance`: 0-1.25 on legacy, 0-1 on AutoFilter2
263
- - Auto Filter `Env. Modulation`: -127..+127 on legacy
264
- - Compressor I (legacy): pre-2010 units (Threshold dB direct)
265
- - **Compressor 2 (modern, default)**: 0-1 NORMALIZED.
256
+ ⚠️ PARAMETER RANGES ARE NOT ALWAYS 0-1. Ableton devices use MIXED
257
+ units depending on the parameter. Always read `value_string` in the
258
+ response (or min/max from get_device_parameters) before assuming
259
+ 0-1 semantics — it's the SOURCE OF TRUTH for what the user sees.
260
+ Full per-device table: livepilot-devices references/
261
+ device-parameter-units.md. Quick hits:
262
+
263
+ - **Compressor 2** (modern, default): 0-1 NORMALIZED.
266
264
  `Threshold 0.85 ≈ 0 dB`, `Ratio 0.75 = 4:1`, `Release 0.16 = 30 ms`.
267
- Setting Threshold to a dB value like -22 will fail. Compute
268
- normalized: `(dB + 50) / 50` for typical dB→0-1 mapping, OR
269
- read the param's value_string after a probe write.
265
+ A dB value like -22 will fail here. Compressor I (legacy):
266
+ pre-2010 units, dB direct.
270
267
  - **Saturator** `Drive`, `Output`, `Threshold`, `Color *`: 0-1
271
268
  NORMALIZED (Drive 0.5 ≈ 0 dB, Drive 0.6 ≈ +7 dB).
272
- - Dynamic Tube, Vocoder: pre-2010 units
273
- - EQ Three `Frequency Hi/Lo`: 50Hz-15kHz absolute
274
- - Wavetable `Osc 1 Pos`: 0-1 normalized
275
- - Drift / Analog / Operator macros: 0-1 normalized ✓
276
- - Pedal `Output`: -20..+20 dB direct
277
- - Pedal `Bass / Mid / Treble`: -1..+1 direct
278
-
279
- The `value_string` field in the response is the SOURCE OF TRUTH
280
- for what the user sees. Automation recipes that assume 0-1 will
281
- clamp on legacy devices. When in doubt, call
282
- get_device_parameters first to inspect min/max/is_quantized.
283
-
284
- Error enrichment (BUG-2026-04-26#2): if the Remote Script rejects
285
- the value as out-of-range, this wrapper fetches the parameter's
286
- actual min/max/value_string and re-raises with that context inline.
287
- Saves a follow-up get_device_parameters round-trip in the agent
288
- loop after every miss.
269
+ - Auto Filter `Frequency`: 20-135 index (NOT normalized).
270
+ - EQ Three `Frequency Hi/Lo`: 50Hz-15kHz absolute.
271
+ - Wavetable / Drift / Analog / Operator macros: 0-1 normalized ✓.
272
+ - Pedal `Output`: -20..+20 dB direct; `Bass/Mid/Treble`: -1..+1.
273
+
274
+ When in doubt, call get_device_parameters first to inspect
275
+ min/max/is_quantized.
276
+
277
+ On out-of-range rejection, the error is enriched with the actual
278
+ min/max/value_string for that parameter no follow-up
279
+ get_device_parameters round-trip needed. Response includes
280
+ `snapped: bool` when Ableton silently quantized the value to the
281
+ nearest step (quantized-enum params).
289
282
  """
290
283
  _validate_track_index(track_index)
291
284
  _validate_device_index(device_index)
@@ -303,7 +296,20 @@ def set_device_parameter(
303
296
  if parameter_index is not None:
304
297
  params["parameter_index"] = parameter_index
305
298
  try:
306
- return _get_ableton(ctx).send_command("set_device_parameter", params)
299
+ response = _get_ableton(ctx).send_command("set_device_parameter", params)
300
+ # P2-51: apply the same silent-snap detection as batch_set_parameters so
301
+ # both sibling tools give a machine-readable ``snapped`` signal.
302
+ if isinstance(response, dict):
303
+ actual_val = response.get("value")
304
+ if actual_val is not None:
305
+ try:
306
+ req_f = float(value)
307
+ act_f = float(actual_val)
308
+ did_snap = abs(req_f - act_f) > _SNAP_EPSILON
309
+ except (TypeError, ValueError):
310
+ did_snap = value != actual_val
311
+ response["snapped"] = did_snap
312
+ return response
307
313
  except Exception as exc:
308
314
  # BUG-2026-04-26#2: enrich out-of-range errors with the actual
309
315
  # min/max/value_string from get_device_parameters so the caller
@@ -503,13 +509,11 @@ def batch_set_parameters(
503
509
 
504
510
  track_index: 0+ for regular tracks, -1/-2/... for return tracks (A/B/...), -1000 for master.
505
511
 
506
- Response (v1.20.2+): the dict now includes a ``snapped_params`` list
507
- when quantized-enum parameters were silently snapped by Ableton
508
- (requested 0.3, received 0). Empty list means every requested value
509
- round-tripped within 1e-5 tolerance. Callers using this tool to
510
- drive deterministic state should inspect ``snapped_params`` before
511
- assuming success — see BUG #4 in the v1.20 live-test campaign for
512
- the motivating case (Beat Repeat Gate).
512
+ Response includes a ``snapped_params`` list when quantized-enum
513
+ parameters were silently snapped by Ableton (requested 0.3, received
514
+ 0; e.g. Beat Repeat's Gate). Empty list means every requested value
515
+ round-tripped within 1e-5 tolerance. Callers driving deterministic
516
+ state should inspect ``snapped_params`` before assuming success.
513
517
  """
514
518
  _validate_track_index(track_index)
515
519
  _validate_device_index(device_index)
@@ -650,12 +654,11 @@ def insert_device(
650
654
  device_index: required when inserting into a rack chain (identifies the rack)
651
655
  chain_index: insert into this chain of a rack device (for building drum kits)
652
656
 
653
- Drum Rack construction workflow (12.3+):
654
- 1. insert_device(track_index, 'Drum Rack') — create empty rack
655
- 2. insert_rack_chain(track_index, device_index) add chains
656
- 3. set_drum_chain_note(chain_index, note=36) — assign C1 (kick)
657
- 4. insert_device(track_index, 'Simpler', — add instrument
658
- device_index=rack_idx, chain_index=0) into chain
657
+ Drum Rack construction workflow (12.3+, full detail: livepilot-devices
658
+ references/device-parameter-units.md#drum-rack-construction-workflow-123):
659
+ insert_device(track, 'Drum Rack') to create the rack, then
660
+ insert_rack_chain + set_drum_chain_note + insert_device(..., chain_index=0)
661
+ to add each pad's instrument.
659
662
 
660
663
  On Live < 12.3: returns an error suggesting find_and_load_device instead.
661
664
  """
@@ -690,11 +693,8 @@ def insert_rack_chain(
690
693
  ) -> dict:
691
694
  """Insert a new chain into a Rack device — Instrument Rack, Audio Effect Rack, or Drum Rack (Live 12.3+).
692
695
 
693
- Use with insert_device + set_drum_chain_note to build Drum Racks from scratch:
694
- 1. insert_device(track, 'Drum Rack') to create the rack
695
- 2. insert_rack_chain(track, rack_device_index) to add chains
696
- 3. set_drum_chain_note(chain_index=0, note=36) to assign C1 (kick)
697
- 4. insert_device(track, 'Simpler', device_index=rack, chain_index=0) into chain
696
+ Use with insert_device + set_drum_chain_note to build Drum Racks from
697
+ scratch see insert_device's docstring for the full 4-step workflow.
698
698
 
699
699
  track_index: track containing the rack
700
700
  device_index: rack device index on the track
@@ -932,7 +932,7 @@ async def get_plugin_parameters(
932
932
  _validate_track_index(track_index)
933
933
  _validate_device_index(device_index)
934
934
  cache = _get_spectral(ctx)
935
- _require_analyzer(cache)
935
+ await asyncio.to_thread(_require_analyzer, cache)
936
936
  bridge = _get_m4l(ctx)
937
937
  return await bridge.send_command("get_plugin_params", track_index, device_index, timeout=20.0)
938
938
 
@@ -956,7 +956,7 @@ async def map_plugin_parameter(
956
956
  if parameter_index < 0:
957
957
  raise ValueError("parameter_index must be >= 0")
958
958
  cache = _get_spectral(ctx)
959
- _require_analyzer(cache)
959
+ await asyncio.to_thread(_require_analyzer, cache)
960
960
  bridge = _get_m4l(ctx)
961
961
  return await bridge.send_command("map_plugin_param", track_index, device_index, parameter_index, timeout=10.0)
962
962
 
@@ -976,7 +976,7 @@ async def get_plugin_presets(
976
976
  _validate_track_index(track_index)
977
977
  _validate_device_index(device_index)
978
978
  cache = _get_spectral(ctx)
979
- _require_analyzer(cache)
979
+ await asyncio.to_thread(_require_analyzer, cache)
980
980
  bridge = _get_m4l(ctx)
981
981
  return await bridge.send_command("get_plugin_presets", track_index, device_index, timeout=15.0)
982
982
 
@@ -43,11 +43,11 @@ def generate_euclidean_rhythm(
43
43
  Returns note array — use add_notes to place in a clip.
44
44
  """
45
45
  if not 0 <= pulses <= 64:
46
- return {"error": "pulses must be 0-64"}
46
+ return {"error": "pulses must be 0-64", "code": "INVALID_PARAM"}
47
47
  if not 1 <= steps <= 64:
48
- return {"error": "steps must be 1-64"}
48
+ return {"error": "steps must be 1-64", "code": "INVALID_PARAM"}
49
49
  if pulses > steps:
50
- return {"error": "pulses must be <= steps"}
50
+ return {"error": "pulses must be <= steps", "code": "INVALID_PARAM"}
51
51
 
52
52
  pattern = gen.bjorklund(pulses, steps)
53
53
  if rotation:
@@ -85,7 +85,7 @@ def layer_euclidean_rhythms(
85
85
  """
86
86
  layers = _ensure_list(layers)
87
87
  if not layers:
88
- return {"error": "At least one layer required"}
88
+ return {"error": "At least one layer required", "code": "INVALID_PARAM"}
89
89
 
90
90
  all_notes: list[dict] = []
91
91
  layer_info: list[dict] = []
@@ -152,16 +152,16 @@ def generate_tintinnabuli(
152
152
  """
153
153
  melody_notes = _ensure_list(melody_notes)
154
154
  if not melody_notes:
155
- return {"error": "melody_notes cannot be empty"}
155
+ return {"error": "melody_notes cannot be empty", "code": "INVALID_PARAM"}
156
156
  if position not in ("above", "below", "nearest"):
157
- return {"error": "position must be 'above', 'below', or 'nearest'"}
157
+ return {"error": "position must be 'above', 'below', or 'nearest'", "code": "INVALID_PARAM"}
158
158
 
159
159
  try:
160
160
  parsed = theory.parse_key(triad)
161
161
  except ValueError:
162
- return {"error": f"Cannot parse triad: {triad}"}
162
+ return {"error": f"Cannot parse triad: {triad}", "code": "INVALID_PARAM"}
163
163
  if parsed["mode"] not in ("major", "minor"):
164
- return {"error": "Only major and minor triads are supported"}
164
+ return {"error": "Only major and minor triads are supported", "code": "INVALID_PARAM"}
165
165
 
166
166
  root = parsed["tonic"]
167
167
  if parsed["mode"] == "major":
@@ -208,11 +208,11 @@ def generate_phase_shift(
208
208
  """
209
209
  pattern_notes = _ensure_list(pattern_notes)
210
210
  if not pattern_notes:
211
- return {"error": "pattern_notes cannot be empty"}
211
+ return {"error": "pattern_notes cannot be empty", "code": "INVALID_PARAM"}
212
212
  if not 1 <= voices <= 8:
213
- return {"error": "voices must be 1-8"}
213
+ return {"error": "voices must be 1-8", "code": "INVALID_PARAM"}
214
214
  if shift_amount <= 0:
215
- return {"error": "shift_amount must be > 0"}
215
+ return {"error": "shift_amount must be > 0", "code": "INVALID_PARAM"}
216
216
 
217
217
  result_notes = gen.phase_shift(pattern_notes, voices, shift_amount, total_length)
218
218
 
@@ -251,11 +251,11 @@ def generate_additive_process(
251
251
  """
252
252
  melody_notes = _ensure_list(melody_notes)
253
253
  if not melody_notes:
254
- return {"error": "melody_notes cannot be empty"}
254
+ return {"error": "melody_notes cannot be empty", "code": "INVALID_PARAM"}
255
255
  if direction not in ("forward", "backward", "both"):
256
- return {"error": "direction must be 'forward', 'backward', or 'both'"}
256
+ return {"error": "direction must be 'forward', 'backward', or 'both'", "code": "INVALID_PARAM"}
257
257
  if repetitions_per_stage < 1:
258
- return {"error": "repetitions_per_stage must be >= 1"}
258
+ return {"error": "repetitions_per_stage must be >= 1", "code": "INVALID_PARAM"}
259
259
 
260
260
  result_notes = gen.additive_process(melody_notes, direction,
261
261
  repetitions_per_stage)
@@ -43,11 +43,11 @@ def navigate_tonnetz(
43
43
  Use depth 2-3 to see compound transforms (PL, PR, PRL, etc.).
44
44
  """
45
45
  if not 1 <= depth <= 3:
46
- return {"error": "depth must be 1-3"}
46
+ return {"error": "depth must be 1-3", "code": "INVALID_PARAM"}
47
47
  try:
48
48
  root_pc, quality = harmony.parse_chord(chord)
49
49
  except ValueError as e:
50
- return {"error": str(e)}
50
+ return {"error": str(e), "code": "INVALID_PARAM"}
51
51
 
52
52
  all_neighbors = harmony.get_neighbors(root_pc, quality, depth)
53
53
 
@@ -108,12 +108,12 @@ def find_voice_leading_path(
108
108
  hexatonic poles, and other cinematic chord moves.
109
109
  """
110
110
  if not 1 <= max_steps <= 6:
111
- return {"error": "max_steps must be 1-6"}
111
+ return {"error": "max_steps must be 1-6", "code": "INVALID_PARAM"}
112
112
  try:
113
113
  from_parsed = harmony.parse_chord(from_chord)
114
114
  to_parsed = harmony.parse_chord(to_chord)
115
115
  except ValueError as e:
116
- return {"error": str(e)}
116
+ return {"error": str(e), "code": "INVALID_PARAM"}
117
117
 
118
118
  result = harmony.find_shortest_path(from_parsed, to_parsed, max_steps)
119
119
 
@@ -221,7 +221,7 @@ def classify_progression(
221
221
  """
222
222
  chords = _ensure_list(chords)
223
223
  if len(chords) < 2:
224
- return {"error": "Need at least 2 chords to classify"}
224
+ return {"error": "Need at least 2 chords to classify", "code": "INVALID_PARAM"}
225
225
 
226
226
  # Normalize dict inputs like {"root": "F#", "quality": "minor"} to strings
227
227
  normalized = []
@@ -236,7 +236,7 @@ def classify_progression(
236
236
  try:
237
237
  parsed = [harmony.parse_chord(c) for c in normalized]
238
238
  except ValueError as e:
239
- return {"error": str(e)}
239
+ return {"error": str(e), "code": "INVALID_PARAM"}
240
240
 
241
241
  transforms = harmony.classify_transform_sequence(parsed)
242
242
  pattern = "".join(transforms)
@@ -336,7 +336,7 @@ def suggest_chromatic_mediants(
336
336
  try:
337
337
  root_pc, quality = harmony.parse_chord(chord)
338
338
  except ValueError as e:
339
- return {"error": str(e)}
339
+ return {"error": str(e), "code": "INVALID_PARAM"}
340
340
 
341
341
  mediants = harmony.get_chromatic_mediants(root_pc, quality)
342
342
 
@@ -130,13 +130,13 @@ async def get_track_meters(
130
130
  interval = max(0, sample_interval_ms) / 1000.0
131
131
  snapshots: list[dict] = []
132
132
  for i in range(samples):
133
- snap = ableton.send_command("get_track_meters", params)
133
+ snap = await asyncio.to_thread(ableton.send_command, "get_track_meters", params)
134
134
  if isinstance(snap, dict):
135
135
  snapshots.append(snap)
136
136
  if i < samples - 1 and interval > 0:
137
137
  await asyncio.sleep(interval)
138
138
  if not snapshots:
139
- return {"error": "No meter snapshots collected"}
139
+ return {"error": "No meter snapshots collected", "code": "STATE_ERROR"}
140
140
  # Take the first snapshot's structure and peak-combine across all.
141
141
  result = dict(snapshots[0])
142
142
  # Merge tracks field with peak-maxing
@@ -165,13 +165,13 @@ async def get_track_meters(
165
165
  result["samples_collected"] = len(snapshots)
166
166
  result["sample_interval_ms"] = sample_interval_ms
167
167
  else:
168
- result = ableton.send_command("get_track_meters", params)
168
+ result = await asyncio.to_thread(ableton.send_command, "get_track_meters", params)
169
169
  if not isinstance(result, dict):
170
170
  return result
171
171
 
172
172
  # Probe playback state once so we can annotate the response
173
173
  try:
174
- session = ableton.send_command("get_session_info", {})
174
+ session = await asyncio.to_thread(ableton.send_command, "get_session_info", {})
175
175
  is_playing = bool(session.get("is_playing", False))
176
176
  except Exception:
177
177
  is_playing = None # unknown — leave left/right as reported