livepilot 1.27.2 → 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.
- package/CHANGELOG.md +66 -0
- package/README.md +10 -7
- package/bin/livepilot.js +97 -39
- package/livepilot/.Codex-plugin/plugin.json +1 -1
- package/livepilot/.claude-plugin/plugin.json +1 -1
- package/livepilot/skills/livepilot-core/SKILL.md +33 -1
- package/livepilot/skills/livepilot-core/references/atlas-tool-notes.md +69 -0
- package/livepilot/skills/livepilot-core/references/overview.md +18 -11
- package/livepilot/skills/livepilot-core/references/perception.md +125 -0
- package/livepilot/skills/livepilot-devices/references/device-parameter-units.md +66 -0
- package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
- package/livepilot/skills/livepilot-mix-engine/SKILL.md +8 -0
- package/livepilot/skills/livepilot-release/SKILL.md +1 -1
- package/livepilot/skills/livepilot-sample-engine/references/splice-tools-notes.md +56 -0
- package/livepilot/skills/livepilot-wonder/SKILL.md +6 -0
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/livepilot_bridge.js +1 -1
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/__init__.py +181 -40
- package/mcp_server/atlas/overlays.py +46 -3
- package/mcp_server/atlas/tools.py +226 -86
- package/mcp_server/audit/checks.py +50 -22
- package/mcp_server/audit/state.py +10 -2
- package/mcp_server/audit/tools.py +27 -6
- package/mcp_server/composer/develop/apply.py +7 -7
- package/mcp_server/composer/fast/apply.py +29 -23
- package/mcp_server/composer/framework/atlas_resolver.py +16 -1
- package/mcp_server/composer/full/apply.py +40 -34
- package/mcp_server/composer/full/engine.py +66 -32
- package/mcp_server/composer/tools.py +4 -3
- package/mcp_server/connection.py +67 -9
- package/mcp_server/creative_constraints/engine.py +10 -2
- package/mcp_server/creative_constraints/tools.py +24 -7
- package/mcp_server/curves.py +30 -7
- package/mcp_server/device_forge/builder.py +40 -11
- package/mcp_server/device_forge/models.py +9 -0
- package/mcp_server/device_forge/tools.py +30 -11
- package/mcp_server/experiment/engine.py +29 -22
- package/mcp_server/experiment/tools.py +15 -5
- package/mcp_server/m4l_bridge.py +69 -7
- package/mcp_server/memory/taste_graph.py +43 -0
- package/mcp_server/memory/technique_store.py +26 -3
- package/mcp_server/memory/tools.py +62 -4
- package/mcp_server/mix_engine/critics.py +187 -30
- package/mcp_server/mix_engine/models.py +21 -1
- package/mcp_server/mix_engine/state_builder.py +87 -8
- package/mcp_server/mix_engine/tools.py +16 -4
- package/mcp_server/performance_engine/tools.py +56 -8
- package/mcp_server/persistence/base_store.py +11 -0
- package/mcp_server/persistence/project_store.py +132 -8
- package/mcp_server/persistence/taste_store.py +90 -7
- package/mcp_server/preview_studio/engine.py +40 -10
- package/mcp_server/preview_studio/models.py +40 -0
- package/mcp_server/preview_studio/tools.py +56 -12
- package/mcp_server/project_brain/arrangement_graph.py +4 -0
- package/mcp_server/project_brain/models.py +2 -0
- package/mcp_server/project_brain/role_graph.py +13 -7
- package/mcp_server/reference_engine/gap_analyzer.py +58 -3
- package/mcp_server/reference_engine/profile_builder.py +47 -4
- package/mcp_server/reference_engine/tools.py +6 -0
- package/mcp_server/runtime/execution_router.py +51 -1
- package/mcp_server/runtime/tools.py +0 -1
- package/mcp_server/sample_engine/sources.py +0 -2
- package/mcp_server/sample_engine/tools.py +19 -38
- package/mcp_server/semantic_moves/mix_compilers.py +276 -51
- package/mcp_server/semantic_moves/performance_compilers.py +51 -17
- package/mcp_server/semantic_moves/resolvers.py +45 -0
- package/mcp_server/semantic_moves/sound_design_compilers.py +14 -6
- package/mcp_server/semantic_moves/tools.py +80 -2
- package/mcp_server/semantic_moves/transition_compilers.py +26 -9
- package/mcp_server/server.py +26 -24
- package/mcp_server/session_continuity/tracker.py +51 -3
- package/mcp_server/song_brain/builder.py +47 -5
- package/mcp_server/song_brain/tools.py +21 -7
- package/mcp_server/sound_design/critics.py +1 -0
- package/mcp_server/splice_client/client.py +117 -33
- package/mcp_server/splice_client/http_bridge.py +15 -3
- package/mcp_server/splice_client/quota.py +28 -0
- package/mcp_server/stuckness_detector/detector.py +8 -5
- package/mcp_server/synthesis_brain/adapters/drift.py +13 -0
- package/mcp_server/synthesis_brain/adapters/meld.py +13 -0
- package/mcp_server/tools/_analyzer_engine/sample.py +36 -10
- package/mcp_server/tools/_perception_engine.py +6 -0
- package/mcp_server/tools/agent_os.py +4 -1
- package/mcp_server/tools/analyzer.py +198 -209
- package/mcp_server/tools/arrangement.py +7 -4
- package/mcp_server/tools/automation.py +24 -4
- package/mcp_server/tools/browser.py +25 -11
- package/mcp_server/tools/clips.py +6 -0
- package/mcp_server/tools/composition.py +33 -2
- package/mcp_server/tools/devices.py +53 -53
- package/mcp_server/tools/generative.py +14 -14
- package/mcp_server/tools/harmony.py +7 -7
- package/mcp_server/tools/mixing.py +4 -4
- package/mcp_server/tools/planner.py +68 -6
- package/mcp_server/tools/research.py +20 -2
- package/mcp_server/tools/theory.py +10 -10
- package/mcp_server/tools/transport.py +7 -2
- package/mcp_server/transition_engine/critics.py +13 -1
- package/mcp_server/user_corpus/tools.py +30 -1
- package/mcp_server/wonder_mode/engine.py +82 -9
- package/mcp_server/wonder_mode/session.py +32 -10
- package/mcp_server/wonder_mode/tools.py +14 -1
- package/package.json +1 -1
- package/remote_script/LivePilot/__init__.py +1 -1
- package/remote_script/LivePilot/arrangement.py +93 -33
- package/remote_script/LivePilot/browser.py +60 -4
- package/remote_script/LivePilot/devices.py +132 -62
- package/remote_script/LivePilot/mixing.py +31 -5
- package/remote_script/LivePilot/server.py +94 -22
- package/remote_script/LivePilot/transport.py +11 -0
- package/requirements.txt +5 -5
- 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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
loop_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":
|
|
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 =
|
|
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 =
|
|
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-
|
|
331
|
-
#
|
|
332
|
-
#
|
|
333
|
-
#
|
|
334
|
-
#
|
|
335
|
-
#
|
|
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":
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
-
|
|
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
|
-
|
|
268
|
-
|
|
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
|
-
-
|
|
273
|
-
- EQ Three `Frequency Hi/Lo`:
|
|
274
|
-
- Wavetable
|
|
275
|
-
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
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
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
round-tripped within 1e-5 tolerance. Callers
|
|
510
|
-
|
|
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
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
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
|
|
694
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|