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
|
@@ -47,7 +47,12 @@ def create_arrangement_clip(song, params):
|
|
|
47
47
|
|
|
48
48
|
Uses Live 12's Track.duplicate_clip_to_arrangement(clip, time) API.
|
|
49
49
|
When the requested length exceeds the source clip, multiple adjacent
|
|
50
|
-
copies are placed to fill the timeline region seamlessly.
|
|
50
|
+
copies are placed to fill the timeline region seamlessly. Copies are
|
|
51
|
+
tiled every min(loop_length, source_length) beats so that a
|
|
52
|
+
loop_length larger than the source never leaves a silent gap between
|
|
53
|
+
copies. loop_length sets the internal loop region only when
|
|
54
|
+
loop_length < source_length; when larger, copies tile by source_length
|
|
55
|
+
and each plays its full native content.
|
|
51
56
|
|
|
52
57
|
Required: track_index, clip_slot_index, start_time, length
|
|
53
58
|
Optional: loop_length (defaults to session clip length), name, color_index
|
|
@@ -83,40 +88,61 @@ def create_arrangement_clip(song, params):
|
|
|
83
88
|
name = str(params.get("name", ""))
|
|
84
89
|
color_index = params.get("color_index")
|
|
85
90
|
|
|
86
|
-
#
|
|
91
|
+
# Tile the source across [start_time, end_pos). A single duplicate only
|
|
92
|
+
# carries `source_length` beats of content, so to fill the region
|
|
93
|
+
# seamlessly we step by `min(loop_length, source_length)` — stepping by a
|
|
94
|
+
# larger loop_length would leave a silent (loop_length - source_length)
|
|
95
|
+
# gap between copies (P2-54). loop_length sets the internal loop region
|
|
96
|
+
# only when loop_length < source_length; when larger, copies tile by
|
|
97
|
+
# source_length and each plays its full native content.
|
|
98
|
+
step = min(loop_length, source_length)
|
|
99
|
+
# A zero/negative effective step would make the tiling loop below never
|
|
100
|
+
# advance -> unbounded duplicate_clip_to_arrangement on Live's main thread
|
|
101
|
+
# = DAW hard-freeze. A source clip can report length 0 (LOM state), so
|
|
102
|
+
# floor the step into a clean structured error instead of looping forever.
|
|
103
|
+
if step <= 0:
|
|
104
|
+
raise ValueError(
|
|
105
|
+
"source clip has zero/invalid length; cannot tile arrangement copies")
|
|
87
106
|
song.begin_undo_step()
|
|
88
107
|
try:
|
|
89
108
|
pos = start_time
|
|
90
109
|
end_pos = start_time + length
|
|
91
110
|
clip_count = 0
|
|
92
|
-
|
|
111
|
+
# Record only the placement position per copy. We defer
|
|
112
|
+
# name/color/loop-region edits to a single post-loop pass below so
|
|
113
|
+
# the hot loop does NOT re-materialize the full arrangement_clips
|
|
114
|
+
# vector every iteration (was O(K^2) list builds + K linear scans —
|
|
115
|
+
# P2-52). duplicate_clip_to_arrangement places the new clip at `pos`,
|
|
116
|
+
# which is the key we use to find it once afterward.
|
|
117
|
+
placements = [] # list of (pos, target_len)
|
|
93
118
|
|
|
94
119
|
while pos < end_pos:
|
|
95
120
|
track.duplicate_clip_to_arrangement(source_clip, pos)
|
|
121
|
+
# When loop_length < source_length, only loop_length beats of
|
|
122
|
+
# content should play per copy; otherwise the copy plays its full
|
|
123
|
+
# source content (capped at the remaining region).
|
|
124
|
+
remaining = end_pos - pos
|
|
125
|
+
target_len = min(loop_length, remaining)
|
|
126
|
+
placements.append((pos, target_len))
|
|
127
|
+
clip_count += 1
|
|
128
|
+
pos += step
|
|
96
129
|
|
|
97
|
-
|
|
98
|
-
|
|
130
|
+
# Single post-loop pass: one list() materialization, then apply
|
|
131
|
+
# name/color/loop-region to each placed clip located by start_time.
|
|
132
|
+
if placements:
|
|
99
133
|
arr_clips = list(track.arrangement_clips)
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
for
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
if new_clip is not None:
|
|
109
|
-
if first_clip_index is None:
|
|
110
|
-
first_clip_index = new_clip_idx
|
|
134
|
+
# Index clips by rounded start_time for O(1) lookup.
|
|
135
|
+
by_start = {}
|
|
136
|
+
for c in arr_clips:
|
|
137
|
+
by_start.setdefault(round(c.start_time, 2), c)
|
|
138
|
+
for place_pos, target_len in placements:
|
|
139
|
+
new_clip = by_start.get(round(place_pos, 2))
|
|
140
|
+
if new_clip is None:
|
|
141
|
+
continue
|
|
111
142
|
if name:
|
|
112
143
|
new_clip.name = name
|
|
113
144
|
if color_index is not None:
|
|
114
145
|
new_clip.color_index = int(color_index)
|
|
115
|
-
|
|
116
|
-
# When loop_length < source_length, set the internal
|
|
117
|
-
# loop region so only loop_length beats of content play.
|
|
118
|
-
remaining = end_pos - pos
|
|
119
|
-
target_len = min(loop_length, remaining)
|
|
120
146
|
if target_len < source_length:
|
|
121
147
|
try:
|
|
122
148
|
new_clip.looping = True
|
|
@@ -125,15 +151,21 @@ def create_arrangement_clip(song, params):
|
|
|
125
151
|
except (AttributeError, RuntimeError):
|
|
126
152
|
pass
|
|
127
153
|
|
|
128
|
-
clip_count += 1
|
|
129
|
-
pos += loop_length
|
|
130
|
-
|
|
131
154
|
# Trim the last clip's overshoot: if the last duplicate extends
|
|
132
155
|
# past end_pos, remove notes beyond the requested region and
|
|
133
156
|
# set loop_end so only the needed portion plays.
|
|
157
|
+
#
|
|
158
|
+
# Restrict the scan to clips THIS call placed (matched by their
|
|
159
|
+
# placement start). Scanning all arrangement_clips would also match a
|
|
160
|
+
# pre-existing clip on the same track that happens to start at/after
|
|
161
|
+
# start_time and end past end_pos — trimming/removing notes from the
|
|
162
|
+
# user's existing content (silent data loss).
|
|
134
163
|
if clip_count > 0:
|
|
164
|
+
placed_starts = {round(p, 2) for p, _ in placements}
|
|
135
165
|
arr_clips = list(track.arrangement_clips)
|
|
136
166
|
for c in arr_clips:
|
|
167
|
+
if round(c.start_time, 2) not in placed_starts:
|
|
168
|
+
continue
|
|
137
169
|
clip_end = c.start_time + c.length
|
|
138
170
|
if c.start_time >= start_time and clip_end > end_pos + 0.01:
|
|
139
171
|
# This clip overshoots — trim its content
|
|
@@ -840,14 +872,33 @@ def force_arrangement(song, params):
|
|
|
840
872
|
song.stop_playing()
|
|
841
873
|
|
|
842
874
|
# 2. Stop playing clip slots individually to release session overrides
|
|
843
|
-
# (track.stop_all_clips() throws STATE_ERROR when tracks have no clips)
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
875
|
+
# (track.stop_all_clips() throws STATE_ERROR when tracks have no clips).
|
|
876
|
+
# §9c: a session clip that fails to stop re-asserts the override and
|
|
877
|
+
# makes playback start mid-song, so genuine stop failures must NOT be
|
|
878
|
+
# silently discarded — collect them and surface to the caller (P2-53).
|
|
879
|
+
stop_errors = []
|
|
880
|
+
# Only regular tracks hold session clips. Return tracks (and the master)
|
|
881
|
+
# cannot have clip slots — iterating their `clip_slots` raises
|
|
882
|
+
# AttributeError on the Live 12.4.x LOM, which would either crash this
|
|
883
|
+
# tool or (when guarded) inject one spurious stop-error per return track,
|
|
884
|
+
# forcing arrangement_active=False on a clean run and breaking the §9c
|
|
885
|
+
# finalize workflow. Session overrides only ever come from song.tracks.
|
|
886
|
+
for track_idx, track in enumerate(song.tracks):
|
|
887
|
+
for slot_idx, slot in enumerate(track.clip_slots):
|
|
888
|
+
try:
|
|
889
|
+
# ClipSlot.is_playing is not exposed on every Live build —
|
|
890
|
+
# guard it (mirrors the defensive read elsewhere in this file)
|
|
891
|
+
# so a missing attribute isn't logged as a spurious stop-error
|
|
892
|
+
# for every clip-bearing slot, which would force
|
|
893
|
+
# arrangement_active=False on a clean run (P2-53).
|
|
894
|
+
if slot.has_clip and getattr(slot, "is_playing", False):
|
|
848
895
|
slot.clip.stop()
|
|
849
|
-
|
|
850
|
-
|
|
896
|
+
except Exception as exc:
|
|
897
|
+
stop_errors.append({
|
|
898
|
+
"track": track_idx,
|
|
899
|
+
"slot": slot_idx,
|
|
900
|
+
"error": str(exc),
|
|
901
|
+
})
|
|
851
902
|
|
|
852
903
|
# 3. Global back-to-arranger
|
|
853
904
|
song.back_to_arranger = True
|
|
@@ -867,12 +918,21 @@ def force_arrangement(song, params):
|
|
|
867
918
|
if play:
|
|
868
919
|
song.start_playing()
|
|
869
920
|
|
|
870
|
-
|
|
871
|
-
|
|
921
|
+
# A clip that failed to stop may still be overriding the arrangement, so
|
|
922
|
+
# report partial failure rather than claiming unconditional success (P2-53).
|
|
923
|
+
result = {
|
|
924
|
+
"arrangement_active": not stop_errors,
|
|
872
925
|
"position": song.current_song_time,
|
|
873
926
|
"is_playing": song.is_playing,
|
|
874
927
|
"loop": song.loop,
|
|
928
|
+
"stop_errors": stop_errors,
|
|
875
929
|
}
|
|
930
|
+
if stop_errors:
|
|
931
|
+
result["warning"] = (
|
|
932
|
+
"%d session clip(s) failed to stop and may still override the "
|
|
933
|
+
"arrangement — playback could start mid-song" % len(stop_errors)
|
|
934
|
+
)
|
|
935
|
+
return result
|
|
876
936
|
|
|
877
937
|
|
|
878
938
|
# ── Session-record arrangement automation (T5 workaround) ────────────
|
|
@@ -282,6 +282,10 @@ def load_browser_item(song, params):
|
|
|
282
282
|
"loaded": True,
|
|
283
283
|
"name": found.name,
|
|
284
284
|
"device_count": device_count,
|
|
285
|
+
# Live appends the loaded device at the END of the chain, so the
|
|
286
|
+
# just-loaded device is the last one. The MCP wrapper applies
|
|
287
|
+
# role-defaults / hygiene to THIS index (see tools/browser.py).
|
|
288
|
+
"device_index": device_count - 1 if device_count > 0 else 0,
|
|
285
289
|
}
|
|
286
290
|
|
|
287
291
|
# ── Strategy 2: extract name from URI, search by name ────────────
|
|
@@ -337,6 +341,8 @@ def load_browser_item(song, params):
|
|
|
337
341
|
"loaded": True,
|
|
338
342
|
"name": found.name,
|
|
339
343
|
"device_count": device_count,
|
|
344
|
+
# Loaded device is appended last → its index is count-1.
|
|
345
|
+
"device_index": device_count - 1 if device_count > 0 else 0,
|
|
340
346
|
}
|
|
341
347
|
|
|
342
348
|
raise ValueError(
|
|
@@ -394,6 +400,10 @@ def load_browser_item(song, params):
|
|
|
394
400
|
"loaded": True,
|
|
395
401
|
"name": found.name,
|
|
396
402
|
"device_count": device_count,
|
|
403
|
+
# Live appends the loaded device at the END of the chain, so the
|
|
404
|
+
# just-loaded device is the last one. The MCP wrapper applies
|
|
405
|
+
# role-defaults / hygiene to THIS index (see tools/browser.py).
|
|
406
|
+
"device_index": device_count - 1 if device_count > 0 else 0,
|
|
397
407
|
}
|
|
398
408
|
|
|
399
409
|
raise ValueError(
|
|
@@ -401,6 +411,13 @@ def load_browser_item(song, params):
|
|
|
401
411
|
)
|
|
402
412
|
|
|
403
413
|
|
|
414
|
+
# Global safety bound on total LOM child-accesses across the ENTIRE scan
|
|
415
|
+
# (all categories combined) — see P3-47. This used to reset per category
|
|
416
|
+
# because `_counter` defaulted to a fresh [0] on every top-level
|
|
417
|
+
# `_scan_recursive` call, so the real worst case was
|
|
418
|
+
# _SCAN_MAX_ITERATIONS * len(categories) synchronous main-thread LOM
|
|
419
|
+
# accesses. scan_browser_deep now threads a single shared counter through
|
|
420
|
+
# every category's recursion so this constant is an honest total budget.
|
|
404
421
|
_SCAN_MAX_ITERATIONS = 100000
|
|
405
422
|
|
|
406
423
|
|
|
@@ -438,22 +455,61 @@ def scan_browser_deep(song, params):
|
|
|
438
455
|
Parameters
|
|
439
456
|
----------
|
|
440
457
|
max_per_category : int, optional
|
|
441
|
-
Maximum items to collect per top-level category (default
|
|
458
|
+
Maximum items to collect per top-level category (default 25000).
|
|
459
|
+
Raised from the original 1000 (BUG-2026-06-21 #11 /
|
|
460
|
+
DEEP_REVIEW P1-11): a default of 1000 silently truncated large
|
|
461
|
+
alphabetically-ordered categories before reaching most of the
|
|
462
|
+
alphabet — e.g. drum_kits stopped at "Crash" (0 kicks, 2 hats),
|
|
463
|
+
and `sounds` stopped inside "Brass" (no Pads/Keys/Leads at all).
|
|
464
|
+
25000 comfortably covers every known category in the full
|
|
465
|
+
factory + pack library while `_SCAN_MAX_ITERATIONS` still bounds
|
|
466
|
+
worst-case main-thread work.
|
|
442
467
|
max_depth : int, optional
|
|
443
468
|
Maximum recursion depth into the browser tree (default 4).
|
|
469
|
+
|
|
470
|
+
Returns
|
|
471
|
+
-------
|
|
472
|
+
dict
|
|
473
|
+
``categories``: ``{cat_name: [{"name", "uri", "is_loadable"}, ...]}``
|
|
474
|
+
``counts``: ``{cat_name: <item count>}`` — per-category item counts,
|
|
475
|
+
always present so callers can detect truncation without
|
|
476
|
+
recomputing `len()` on the (potentially large) categories dict.
|
|
477
|
+
``category_truncated``: ``{cat_name: bool}`` — True when a category
|
|
478
|
+
either hit `max_per_category` directly, OR the scan overall ran
|
|
479
|
+
out of its shared `_SCAN_MAX_ITERATIONS` iteration budget before
|
|
480
|
+
finishing that category (P3-47) — in both cases the category's
|
|
481
|
+
item list is a lower bound, not the true total.
|
|
444
482
|
"""
|
|
445
|
-
max_per_category = int(params.get("max_per_category",
|
|
483
|
+
max_per_category = int(params.get("max_per_category", 25000))
|
|
446
484
|
max_depth = int(params.get("max_depth", 4))
|
|
447
485
|
browser = _get_browser()
|
|
448
486
|
categories = _get_categories(browser)
|
|
449
487
|
|
|
450
488
|
result = {}
|
|
489
|
+
counts = {}
|
|
490
|
+
truncated = {}
|
|
491
|
+
# P3-47 fix: ONE counter object shared across every top-level category's
|
|
492
|
+
# _scan_recursive call, instead of a fresh [0] per category. This makes
|
|
493
|
+
# _SCAN_MAX_ITERATIONS a true global ceiling on main-thread LOM accesses
|
|
494
|
+
# for the whole scan_browser_deep call, not a per-category one.
|
|
495
|
+
shared_counter = [0]
|
|
451
496
|
for cat_name, cat_item in categories.items():
|
|
452
497
|
items = []
|
|
453
|
-
_scan_recursive(
|
|
498
|
+
_scan_recursive(
|
|
499
|
+
cat_item, items, 0, max_depth, max_per_category, shared_counter
|
|
500
|
+
)
|
|
454
501
|
result[cat_name] = items
|
|
502
|
+
counts[cat_name] = len(items)
|
|
503
|
+
truncated[cat_name] = (
|
|
504
|
+
len(items) >= max_per_category
|
|
505
|
+
or shared_counter[0] > _SCAN_MAX_ITERATIONS
|
|
506
|
+
)
|
|
455
507
|
|
|
456
|
-
return {
|
|
508
|
+
return {
|
|
509
|
+
"categories": result,
|
|
510
|
+
"counts": counts,
|
|
511
|
+
"category_truncated": truncated,
|
|
512
|
+
}
|
|
457
513
|
|
|
458
514
|
|
|
459
515
|
@register("get_device_presets")
|
|
@@ -14,6 +14,30 @@ def _get_browser():
|
|
|
14
14
|
return Live.Application.get_application().browser
|
|
15
15
|
|
|
16
16
|
|
|
17
|
+
def _safe_value_string(param):
|
|
18
|
+
"""Best-effort ``str_for_value`` readback that never raises.
|
|
19
|
+
|
|
20
|
+
Live raises RuntimeError("Invalid display value") from str_for_value on
|
|
21
|
+
Operator / Compressor2 / AutoFilter2 for some in-range values. On the
|
|
22
|
+
write path the ``param.value`` assignment has ALREADY landed before this
|
|
23
|
+
readback runs, so a raise here would convert a successful write into a
|
|
24
|
+
reported error (the agent then wrongly retries/undoes). Fall back to None
|
|
25
|
+
so a display-string failure never masks an applied write.
|
|
26
|
+
"""
|
|
27
|
+
try:
|
|
28
|
+
return param.str_for_value(param.value)
|
|
29
|
+
except Exception:
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _safe_display_value(param):
|
|
34
|
+
"""Best-effort ``display_value`` readback that never raises (see above)."""
|
|
35
|
+
try:
|
|
36
|
+
return param.display_value
|
|
37
|
+
except Exception:
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
17
41
|
@register("get_device_info")
|
|
18
42
|
def get_device_info(song, params):
|
|
19
43
|
"""Return detailed info for a single device."""
|
|
@@ -118,24 +142,33 @@ def set_device_parameter(song, params):
|
|
|
118
142
|
raise ValueError("Must provide parameter_name or parameter_index")
|
|
119
143
|
|
|
120
144
|
param.value = value
|
|
145
|
+
# Readbacks are best-effort: the write above already landed, so a
|
|
146
|
+
# str_for_value / display_value failure must not surface a successful
|
|
147
|
+
# write as an error.
|
|
121
148
|
result = {
|
|
122
149
|
"name": param.name,
|
|
123
150
|
"value": param.value,
|
|
124
|
-
"value_string":
|
|
151
|
+
"value_string": _safe_value_string(param),
|
|
125
152
|
"min": param.min,
|
|
126
153
|
"max": param.max,
|
|
154
|
+
"display_value": _safe_display_value(param),
|
|
127
155
|
}
|
|
128
|
-
# 12.2+: include display_value
|
|
129
|
-
try:
|
|
130
|
-
result["display_value"] = param.display_value
|
|
131
|
-
except AttributeError:
|
|
132
|
-
pass
|
|
133
156
|
return result
|
|
134
157
|
|
|
135
158
|
|
|
136
159
|
@register("batch_set_parameters")
|
|
137
160
|
def batch_set_parameters(song, params):
|
|
138
|
-
"""Set multiple device parameters in one call.
|
|
161
|
+
"""Set multiple device parameters in one call.
|
|
162
|
+
|
|
163
|
+
Partial-success contract: LOM cannot truly roll back per-parameter
|
|
164
|
+
writes, so a mid-batch failure (typo'd name, out-of-range index) must
|
|
165
|
+
NOT abort the call and strand earlier writes as an opaque exception.
|
|
166
|
+
Instead every entry gets an ``{ok, ...}`` / ``{ok: False, error}`` result,
|
|
167
|
+
the whole loop is wrapped in one begin/end_undo_step so the applied
|
|
168
|
+
writes are a single undo unit, and the response reports ``applied`` /
|
|
169
|
+
``failed`` counts plus a top-level ``ok`` that is True only if all
|
|
170
|
+
entries succeeded.
|
|
171
|
+
"""
|
|
139
172
|
track_index = int(params["track_index"])
|
|
140
173
|
device_index = int(params["device_index"])
|
|
141
174
|
parameters = params["parameters"]
|
|
@@ -144,58 +177,86 @@ def batch_set_parameters(song, params):
|
|
|
144
177
|
|
|
145
178
|
dev_params = list(device.parameters)
|
|
146
179
|
results = []
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
180
|
+
applied = 0
|
|
181
|
+
failed = 0
|
|
182
|
+
song.begin_undo_step()
|
|
183
|
+
try:
|
|
184
|
+
for entry in parameters:
|
|
185
|
+
name_or_index = entry.get("name_or_index")
|
|
186
|
+
try:
|
|
187
|
+
# A missing key would otherwise fall through to the name-search
|
|
188
|
+
# branch as str(None) == "None" and surface as a misleading
|
|
189
|
+
# "Parameter 'None' not found" — making the agent retry a
|
|
190
|
+
# phantom name instead of fixing the malformed entry.
|
|
191
|
+
if name_or_index is None:
|
|
192
|
+
raise ValueError(
|
|
193
|
+
"entry missing required 'name_or_index' key (got keys: %s)"
|
|
194
|
+
% sorted(entry.keys())
|
|
195
|
+
)
|
|
196
|
+
value = float(entry["value"])
|
|
197
|
+
|
|
198
|
+
if isinstance(name_or_index, int) or (
|
|
199
|
+
isinstance(name_or_index, str) and name_or_index.isdigit()
|
|
200
|
+
):
|
|
201
|
+
idx = int(name_or_index)
|
|
202
|
+
if idx < 0 or idx >= len(dev_params):
|
|
203
|
+
raise IndexError(
|
|
204
|
+
"Parameter index %d out of range (0..%d)"
|
|
205
|
+
% (idx, len(dev_params) - 1)
|
|
206
|
+
)
|
|
207
|
+
param = dev_params[idx]
|
|
208
|
+
else:
|
|
209
|
+
param = None
|
|
210
|
+
target = str(name_or_index)
|
|
211
|
+
# Try exact match first
|
|
212
|
+
for p in dev_params:
|
|
213
|
+
if p.name == target:
|
|
214
|
+
param = p
|
|
215
|
+
break
|
|
216
|
+
# Fallback: case-insensitive match
|
|
217
|
+
if param is None:
|
|
218
|
+
target_lower = target.lower()
|
|
219
|
+
for p in dev_params:
|
|
220
|
+
if p.name.lower() == target_lower:
|
|
221
|
+
param = p
|
|
222
|
+
break
|
|
223
|
+
if param is None:
|
|
224
|
+
# List similar parameter names for debugging
|
|
225
|
+
available = [p.name for p in dev_params[:20]]
|
|
226
|
+
raise ValueError(
|
|
227
|
+
"Parameter '%s' not found on device '%s'. "
|
|
228
|
+
"Available (first 20): %s"
|
|
229
|
+
% (name_or_index, device.name, ", ".join(available))
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
param.value = value
|
|
233
|
+
# Readbacks are best-effort: the write already landed, so a
|
|
234
|
+
# str_for_value / display_value failure must not flip this
|
|
235
|
+
# entry to failed.
|
|
236
|
+
results.append({
|
|
237
|
+
"ok": True,
|
|
238
|
+
"name": param.name,
|
|
239
|
+
"value": param.value,
|
|
240
|
+
"value_string": _safe_value_string(param),
|
|
241
|
+
"display_value": _safe_display_value(param),
|
|
242
|
+
})
|
|
243
|
+
applied += 1
|
|
244
|
+
except Exception as exc:
|
|
245
|
+
results.append({
|
|
246
|
+
"ok": False,
|
|
247
|
+
"name_or_index": name_or_index,
|
|
248
|
+
"error": str(exc),
|
|
249
|
+
})
|
|
250
|
+
failed += 1
|
|
251
|
+
finally:
|
|
252
|
+
song.end_undo_step()
|
|
197
253
|
|
|
198
|
-
return {
|
|
254
|
+
return {
|
|
255
|
+
"ok": failed == 0,
|
|
256
|
+
"applied": applied,
|
|
257
|
+
"failed": failed,
|
|
258
|
+
"parameters": results,
|
|
259
|
+
}
|
|
199
260
|
|
|
200
261
|
|
|
201
262
|
@register("toggle_device")
|
|
@@ -1299,10 +1360,19 @@ def _get_wavetable(song, params):
|
|
|
1299
1360
|
track = get_track(song, int(params["track_index"]))
|
|
1300
1361
|
device = get_device(track, int(params["device_index"]))
|
|
1301
1362
|
class_name = str(getattr(device, "class_name", ""))
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1363
|
+
# Fast path: explicit Wavetable class_name.
|
|
1364
|
+
if "Wavetable" in class_name:
|
|
1365
|
+
return device
|
|
1366
|
+
# Ableton reports the Wavetable instrument as "InstrumentVector" in some
|
|
1367
|
+
# Live versions. Accept it only when a Wavetable-specific parameter
|
|
1368
|
+
# ("Osc 1 Pos") is present so that M4L devices that also use the
|
|
1369
|
+
# InstrumentVector class (e.g. Vector FM / Vector Grain) are rejected.
|
|
1370
|
+
if class_name == "InstrumentVector":
|
|
1371
|
+
param_names = {str(getattr(p, "name", "")) for p in getattr(device, "parameters", [])}
|
|
1372
|
+
if any("Osc 1 Pos" in n for n in param_names):
|
|
1373
|
+
return device
|
|
1374
|
+
raise ValueError("Device at index %d is not Wavetable (class_name=%s)"
|
|
1375
|
+
% (int(params["device_index"]), class_name))
|
|
1306
1376
|
|
|
1307
1377
|
|
|
1308
1378
|
@register("get_wavetable_mod_targets")
|
|
@@ -426,6 +426,36 @@ def _collect_routing_diagnostic(device):
|
|
|
426
426
|
return " | ".join(parts) if parts else "<none>"
|
|
427
427
|
|
|
428
428
|
|
|
429
|
+
def _match_routing_type(available, source_type):
|
|
430
|
+
"""Resolve a caller-supplied sidechain source against Live's routing menu.
|
|
431
|
+
|
|
432
|
+
Matching ladder (first hit wins):
|
|
433
|
+
1. Exact display-name match — preserves the "1-KICK" contract.
|
|
434
|
+
2. Case-insensitive exact match — Live 12.4 routing menus can show the
|
|
435
|
+
BARE track name with no index prefix ("Kick"), so a lowercase caller
|
|
436
|
+
("kick") must still resolve. Found live 2026-07-10: the suffix
|
|
437
|
+
fallback below never fires for unprefixed display names.
|
|
438
|
+
3. Case-insensitive "-<name>" suffix match — callers (e.g. the
|
|
439
|
+
semantic-move compilers) may pass a bare track name while the menu
|
|
440
|
+
is index-prefixed ("1-Kick"); casefold both sides so "1-KICK" /
|
|
441
|
+
"1-Kick" / "1-kick" all resolve regardless of menu case convention.
|
|
442
|
+
|
|
443
|
+
Returns the matched routing object, or None.
|
|
444
|
+
"""
|
|
445
|
+
for rt in available:
|
|
446
|
+
if rt.display_name == source_type:
|
|
447
|
+
return rt
|
|
448
|
+
want_cf = str(source_type).casefold()
|
|
449
|
+
for rt in available:
|
|
450
|
+
if rt.display_name.casefold() == want_cf:
|
|
451
|
+
return rt
|
|
452
|
+
suffix = "-" + want_cf
|
|
453
|
+
for rt in available:
|
|
454
|
+
if rt.display_name.casefold().endswith(suffix):
|
|
455
|
+
return rt
|
|
456
|
+
return None
|
|
457
|
+
|
|
458
|
+
|
|
429
459
|
@register("set_compressor_sidechain")
|
|
430
460
|
def set_compressor_sidechain(song, params):
|
|
431
461
|
"""Configure a Compressor's sidechain input routing (BUG-A3).
|
|
@@ -497,11 +527,7 @@ def set_compressor_sidechain(song, params):
|
|
|
497
527
|
|
|
498
528
|
if want_type:
|
|
499
529
|
available = surface["types"]
|
|
500
|
-
matched =
|
|
501
|
-
for rt in available:
|
|
502
|
-
if rt.display_name == source_type:
|
|
503
|
-
matched = rt
|
|
504
|
-
break
|
|
530
|
+
matched = _match_routing_type(available, source_type)
|
|
505
531
|
if matched is None:
|
|
506
532
|
options = [rt.display_name for rt in available]
|
|
507
533
|
raise ValueError(
|