livepilot 1.23.2 → 1.23.4
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 +124 -0
- package/README.md +108 -10
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/livepilot_bridge.js +39 -1
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/cross_pack_chain.py +658 -0
- package/mcp_server/atlas/demo_story.py +700 -0
- package/mcp_server/atlas/extract_chain.py +786 -0
- package/mcp_server/atlas/macro_fingerprint.py +554 -0
- package/mcp_server/atlas/overlays.py +95 -3
- package/mcp_server/atlas/pack_aware_compose.py +1255 -0
- package/mcp_server/atlas/preset_resolver.py +238 -0
- package/mcp_server/atlas/tools.py +1001 -31
- package/mcp_server/atlas/transplant.py +1177 -0
- package/mcp_server/mix_engine/state_builder.py +44 -1
- package/mcp_server/runtime/capability_state.py +34 -3
- package/mcp_server/runtime/remote_commands.py +10 -0
- package/mcp_server/server.py +45 -24
- package/mcp_server/tools/agent_os.py +33 -9
- package/mcp_server/tools/analyzer.py +84 -23
- package/mcp_server/tools/browser.py +20 -1
- package/mcp_server/tools/devices.py +78 -11
- package/mcp_server/tools/perception.py +5 -1
- package/mcp_server/tools/tracks.py +39 -2
- package/mcp_server/user_corpus/__init__.py +48 -0
- package/mcp_server/user_corpus/manifest.py +142 -0
- package/mcp_server/user_corpus/plugin_engine/__init__.py +39 -0
- package/mcp_server/user_corpus/plugin_engine/detector.py +579 -0
- package/mcp_server/user_corpus/plugin_engine/manual.py +347 -0
- package/mcp_server/user_corpus/plugin_engine/research.py +247 -0
- package/mcp_server/user_corpus/runner.py +261 -0
- package/mcp_server/user_corpus/scanner.py +115 -0
- package/mcp_server/user_corpus/scanners/__init__.py +18 -0
- package/mcp_server/user_corpus/scanners/adg.py +79 -0
- package/mcp_server/user_corpus/scanners/als.py +144 -0
- package/mcp_server/user_corpus/scanners/amxd.py +374 -0
- package/mcp_server/user_corpus/scanners/plugin_preset.py +202 -0
- package/mcp_server/user_corpus/tools.py +904 -0
- package/mcp_server/user_corpus/wizard.py +224 -0
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +1 -1
- package/remote_script/LivePilot/browser.py +7 -2
- package/remote_script/LivePilot/devices.py +9 -0
- package/remote_script/LivePilot/simpler_sample.py +98 -0
- package/requirements.txt +3 -3
- package/server.json +2 -2
|
@@ -0,0 +1,786 @@
|
|
|
1
|
+
"""Pack-Atlas Phase E — Extract Chain.
|
|
2
|
+
|
|
3
|
+
Surgically rebuilds a specific demo track's device chain in the user's current
|
|
4
|
+
project. Returns a dry-run execution plan (or live execution result when
|
|
5
|
+
target_track_index >= 0). All source data from local JSON sidecars.
|
|
6
|
+
|
|
7
|
+
Real sidecar schema note (from Phase C appendix):
|
|
8
|
+
Track devices: {class, user_name, params (null in demos), macros [{index, value}]}
|
|
9
|
+
Macros in demo sidecars have NO names — only {index, value}.
|
|
10
|
+
The device class identifies the device type; user_name is the preset's name.
|
|
11
|
+
|
|
12
|
+
Parameter fidelity modes:
|
|
13
|
+
"exact" — emit set_device_parameter for every non-default macro
|
|
14
|
+
"approximate" — top 5 most-committed macros (highest deviation from 0)
|
|
15
|
+
"structure-only"— skip parameter setting; chain structure only
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from .transplant import (
|
|
24
|
+
DEMO_PARSES_ROOT,
|
|
25
|
+
_load_demo_sidecar,
|
|
26
|
+
_resolve_demo_slug,
|
|
27
|
+
)
|
|
28
|
+
from .preset_resolver import resolve_preset_for_device
|
|
29
|
+
|
|
30
|
+
# ─── Known native Live device class names ────────────────────────────────────
|
|
31
|
+
# These are built-in Live devices loadable by class name via insert_device.
|
|
32
|
+
# GroupDevice/Rack classes handled separately.
|
|
33
|
+
|
|
34
|
+
_NATIVE_INSTRUMENT_CLASSES = {
|
|
35
|
+
# NOTE: "PluginDevice" intentionally excluded — third-party VST/AU plugins
|
|
36
|
+
# cannot be inserted by class name. See BUG-E2#PluginDevice fix.
|
|
37
|
+
"OriginalSimpler", "MultiSampler",
|
|
38
|
+
"AnalogSynth", "InstrumentVector", "DrumSynthBass", "DrumSynthBell",
|
|
39
|
+
"DrumSynthCymbal", "DrumSynthHihat", "DrumSynthSnare",
|
|
40
|
+
"Tension", "Electric", "Collision", "Mallet",
|
|
41
|
+
# Common aliases
|
|
42
|
+
"Simpler", "Sampler", "Operator", "Analog", "Drift", "Wavetable",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
# Third-party plugin classes that cannot be inserted by class name.
|
|
46
|
+
# These require a manual_rebuild step with plugin name/vendor from the user.
|
|
47
|
+
_PLUGIN_DEVICE_CLASSES = {"PluginDevice"}
|
|
48
|
+
|
|
49
|
+
_NATIVE_AUDIO_EFFECT_CLASSES = {
|
|
50
|
+
"Reverb", "Delay", "Echo", "Chorus", "Phaser", "Flanger",
|
|
51
|
+
"Compressor2", "MultibandDynamics", "Limiter", "GlueCompressor",
|
|
52
|
+
"EQ8", "EQ3", "AutoFilter", "AutoPan",
|
|
53
|
+
"Saturator", "DynamicTube", "Redux", "Erosion",
|
|
54
|
+
"Vinyl Distortion", "VinylDistortion", "Cabinet",
|
|
55
|
+
"Corpus", "Resonator", "FrequencyShifter", "PitchHack",
|
|
56
|
+
"BeatRepeat", "Grain", "Looper",
|
|
57
|
+
"Amp", "Overdrive", "PedalDistortion", "ExternalInstrument",
|
|
58
|
+
"FilterDelay", "SpectralBlur", "SpectralResonator", "SpectralTime",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
_NATIVE_MIDI_EFFECT_CLASSES = {
|
|
62
|
+
"MidiRandom", "MidiArpeggiator", "MidiChord", "MidiPitcher",
|
|
63
|
+
"MidiScale", "MidiVelocity", "MidiSysexFlag",
|
|
64
|
+
"MidiNoteLength", "MidiSustain",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
_GROUP_DEVICE_CLASSES = {
|
|
68
|
+
"InstrumentGroupDevice",
|
|
69
|
+
"AudioEffectGroupDevice",
|
|
70
|
+
"DrumGroupDevice",
|
|
71
|
+
"MidiEffectGroupDevice",
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
_MAX_DEVICE_CLASSES = {
|
|
75
|
+
"MaxAudioEffect",
|
|
76
|
+
"MaxInstrument",
|
|
77
|
+
"MaxMidiEffect",
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
_ALL_KNOWN_NATIVE = (
|
|
81
|
+
_NATIVE_INSTRUMENT_CLASSES
|
|
82
|
+
| _NATIVE_AUDIO_EFFECT_CLASSES
|
|
83
|
+
| _NATIVE_MIDI_EFFECT_CLASSES
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _is_group_device(class_name: str) -> bool:
|
|
88
|
+
return class_name in _GROUP_DEVICE_CLASSES
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _is_max_device(class_name: str) -> bool:
|
|
92
|
+
return class_name in _MAX_DEVICE_CLASSES
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _is_native_device(class_name: str) -> bool:
|
|
96
|
+
return class_name in _ALL_KNOWN_NATIVE
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ─── Track finder ────────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
def _find_track_by_name(
|
|
102
|
+
demo_dict: dict, track_name: str
|
|
103
|
+
) -> tuple[dict | None, list[str]]:
|
|
104
|
+
"""Fuzzy-match a track by name.
|
|
105
|
+
|
|
106
|
+
Resolution order:
|
|
107
|
+
1. Exact match
|
|
108
|
+
2. Case-insensitive substring match (track_name in t_name)
|
|
109
|
+
3. Tokenized any-token match (any word of track_name in t_name)
|
|
110
|
+
4. Reverse substring (t_name in track_name, i.e. partial query)
|
|
111
|
+
|
|
112
|
+
Returns (matched_track_dict_or_None, other_candidate_names).
|
|
113
|
+
When fuzzy match has >=2 candidates, other_candidate_names is populated
|
|
114
|
+
so callers can surface an ambiguity_warning.
|
|
115
|
+
"""
|
|
116
|
+
tracks = demo_dict.get("tracks") or []
|
|
117
|
+
name_lower = track_name.lower().strip()
|
|
118
|
+
|
|
119
|
+
# Pass 1: exact — unambiguous
|
|
120
|
+
for t in tracks:
|
|
121
|
+
if t.get("name", "") == track_name:
|
|
122
|
+
return t, []
|
|
123
|
+
|
|
124
|
+
# Pass 2: case-insensitive substring — collect all candidates
|
|
125
|
+
candidates_2 = [t for t in tracks if name_lower in t.get("name", "").lower()]
|
|
126
|
+
if candidates_2:
|
|
127
|
+
others = [t.get("name", "") for t in candidates_2[1:]]
|
|
128
|
+
return candidates_2[0], others
|
|
129
|
+
|
|
130
|
+
# Pass 3: any token of query present in track name
|
|
131
|
+
tokens = [tok for tok in re.split(r"\s+", name_lower) if tok]
|
|
132
|
+
candidates_3 = [
|
|
133
|
+
t for t in tracks
|
|
134
|
+
if any(tok in t.get("name", "").lower() for tok in tokens)
|
|
135
|
+
]
|
|
136
|
+
if candidates_3:
|
|
137
|
+
others = [t.get("name", "") for t in candidates_3[1:]]
|
|
138
|
+
return candidates_3[0], others
|
|
139
|
+
|
|
140
|
+
# Pass 4: reverse — track name is substring of query
|
|
141
|
+
candidates_4 = [
|
|
142
|
+
t for t in tracks if t.get("name", "").lower() in name_lower
|
|
143
|
+
]
|
|
144
|
+
if candidates_4:
|
|
145
|
+
others = [t.get("name", "") for t in candidates_4[1:]]
|
|
146
|
+
return candidates_4[0], others
|
|
147
|
+
|
|
148
|
+
return None, []
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ─── Device chain walker ─────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
_MAX_CHAIN_DEPTH = 4 # matches transplant._walk_device_chain cap and als_deep_parse
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _collect_inner_chain_classes(dev: dict, depth: int = 0) -> list[str]:
|
|
157
|
+
"""Recursively collect class names from a rack device's inner chains.
|
|
158
|
+
|
|
159
|
+
Used to build chain_summary for group-device steps without blowing up
|
|
160
|
+
the execution plan with nested steps.
|
|
161
|
+
|
|
162
|
+
Caps at _MAX_CHAIN_DEPTH to match the parser's depth limit.
|
|
163
|
+
"""
|
|
164
|
+
if depth > _MAX_CHAIN_DEPTH:
|
|
165
|
+
return []
|
|
166
|
+
result: list[str] = []
|
|
167
|
+
for chain in dev.get("chains") or []:
|
|
168
|
+
for inner_dev in chain.get("devices") or []:
|
|
169
|
+
cls = inner_dev.get("class", "") or ""
|
|
170
|
+
uname = inner_dev.get("user_name") or ""
|
|
171
|
+
label = uname if (uname and uname != cls) else cls
|
|
172
|
+
if label:
|
|
173
|
+
result.append(label)
|
|
174
|
+
# Recurse into nested racks (e.g. rack-within-rack)
|
|
175
|
+
result.extend(_collect_inner_chain_classes(inner_dev, depth + 1))
|
|
176
|
+
return result
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _walk_device_chain(track: dict) -> list[dict]:
|
|
180
|
+
"""Return devices in topological order from a track dict.
|
|
181
|
+
|
|
182
|
+
For each device, returns:
|
|
183
|
+
{class, user_name, macros, depth, inner_chain_classes}
|
|
184
|
+
|
|
185
|
+
BUG-INT#2 fix: v1.23.5+ sidecars include a `chains` field on rack
|
|
186
|
+
devices (Schema A — nested). We now populate `inner_chain_classes` by
|
|
187
|
+
recursing into dev.chains up to _MAX_CHAIN_DEPTH levels deep so that
|
|
188
|
+
callers (device_chain_summary, _emit_execution_steps) can surface inner
|
|
189
|
+
devices without emitting thousands of nested execution steps.
|
|
190
|
+
"""
|
|
191
|
+
devices = track.get("devices") or []
|
|
192
|
+
result = []
|
|
193
|
+
for dev in devices:
|
|
194
|
+
inner_classes = _collect_inner_chain_classes(dev)
|
|
195
|
+
result.append({
|
|
196
|
+
"class": dev.get("class", ""),
|
|
197
|
+
"user_name": dev.get("user_name") or "",
|
|
198
|
+
"macros": dev.get("macros") or [],
|
|
199
|
+
"params": dev.get("params"),
|
|
200
|
+
"depth": 0,
|
|
201
|
+
"inner_chain_classes": inner_classes,
|
|
202
|
+
})
|
|
203
|
+
return result
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ─── Macro extraction helpers ─────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
def _safe_float(v: Any) -> float:
|
|
209
|
+
try:
|
|
210
|
+
return float(str(v))
|
|
211
|
+
except (ValueError, TypeError):
|
|
212
|
+
return 0.0
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _get_nonzero_macros(macros: list[dict]) -> list[dict]:
|
|
216
|
+
"""Return macro entries with non-zero values."""
|
|
217
|
+
return [m for m in macros if _safe_float(m.get("value", "0")) != 0.0]
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _load_preset_defaults(pack_name: str, preset_user_name: str) -> dict[int, float] | None:
|
|
221
|
+
"""Load factory-default macro values from a preset sidecar JSON.
|
|
222
|
+
|
|
223
|
+
Sidecars live at:
|
|
224
|
+
~/.livepilot/atlas-overlays/packs/_preset_parses/<pack>/<preset_slug>.json
|
|
225
|
+
|
|
226
|
+
Matching: tries exact name match on sidecar["name"] field, then falls back
|
|
227
|
+
to filename-slug comparison (lowercased, spaces→hyphens).
|
|
228
|
+
|
|
229
|
+
Returns {macro_index: default_value} or None if no sidecar found.
|
|
230
|
+
"""
|
|
231
|
+
import json
|
|
232
|
+
import os
|
|
233
|
+
|
|
234
|
+
preset_root = os.path.expanduser(
|
|
235
|
+
"~/.livepilot/atlas-overlays/packs/_preset_parses"
|
|
236
|
+
)
|
|
237
|
+
pack_dir = os.path.join(preset_root, pack_name)
|
|
238
|
+
if not os.path.isdir(pack_dir):
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
name_lower = preset_user_name.lower().strip()
|
|
242
|
+
slug = re.sub(r"[^a-z0-9]+", "-", name_lower).strip("-")
|
|
243
|
+
|
|
244
|
+
for fname in os.listdir(pack_dir):
|
|
245
|
+
if not fname.endswith(".json"):
|
|
246
|
+
continue
|
|
247
|
+
fpath = os.path.join(pack_dir, fname)
|
|
248
|
+
try:
|
|
249
|
+
with open(fpath, encoding="utf-8") as fh:
|
|
250
|
+
data = json.load(fh)
|
|
251
|
+
except Exception:
|
|
252
|
+
continue
|
|
253
|
+
# Name match
|
|
254
|
+
if data.get("name", "").lower().strip() == name_lower:
|
|
255
|
+
macros = data.get("macros") or []
|
|
256
|
+
return {int(m["index"]): _safe_float(m.get("value", "0")) for m in macros}
|
|
257
|
+
# Slug match
|
|
258
|
+
file_slug = re.sub(r"[^a-z0-9]+", "-", fname[:-5].lower()).strip("-")
|
|
259
|
+
if file_slug == slug or file_slug.endswith("-" + slug) or slug in file_slug:
|
|
260
|
+
macros = data.get("macros") or []
|
|
261
|
+
return {int(m["index"]): _safe_float(m.get("value", "0")) for m in macros}
|
|
262
|
+
|
|
263
|
+
return None
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _top_k_macros_by_deviation(
|
|
267
|
+
macros: list[dict],
|
|
268
|
+
k: int = 5,
|
|
269
|
+
preset_defaults: dict[int, float] | None = None,
|
|
270
|
+
) -> list[dict]:
|
|
271
|
+
"""Return the k macros with the largest deviation from factory default.
|
|
272
|
+
|
|
273
|
+
In "approximate" fidelity mode — the macros most committed away from their
|
|
274
|
+
factory default are the most production-meaningful.
|
|
275
|
+
|
|
276
|
+
If preset_defaults is provided (from a matching preset sidecar), deviation
|
|
277
|
+
is computed as abs(live_value - factory_default). If not provided (no
|
|
278
|
+
sidecar match), falls back to abs(live_value) (deviation from zero).
|
|
279
|
+
"""
|
|
280
|
+
nonzero = _get_nonzero_macros(macros)
|
|
281
|
+
if preset_defaults is not None:
|
|
282
|
+
def _deviation(m: dict) -> float:
|
|
283
|
+
idx = int(m.get("index", 0))
|
|
284
|
+
live = _safe_float(m.get("value", "0"))
|
|
285
|
+
default = preset_defaults.get(idx, 0.0)
|
|
286
|
+
return abs(live - default)
|
|
287
|
+
else:
|
|
288
|
+
# Fallback: deviation from zero
|
|
289
|
+
def _deviation(m: dict) -> float: # type: ignore[misc]
|
|
290
|
+
return abs(_safe_float(m.get("value", "0")))
|
|
291
|
+
|
|
292
|
+
sorted_macros = sorted(nonzero, key=_deviation, reverse=True)
|
|
293
|
+
return sorted_macros[:k]
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# ─── Execution step emitter ───────────────────────────────────────────────────
|
|
297
|
+
|
|
298
|
+
def _emit_execution_steps(
|
|
299
|
+
device: dict,
|
|
300
|
+
fidelity: str,
|
|
301
|
+
track_name: str,
|
|
302
|
+
device_index: int,
|
|
303
|
+
pack_name: str = "",
|
|
304
|
+
) -> tuple[list[dict], list[str]]:
|
|
305
|
+
"""Emit executable plan steps for one device.
|
|
306
|
+
|
|
307
|
+
Returns (steps_list, warnings_list).
|
|
308
|
+
|
|
309
|
+
Logic:
|
|
310
|
+
- PluginDevice (third-party VST/AU)
|
|
311
|
+
→ manual_rebuild: cannot be inserted by class name; agent must locate
|
|
312
|
+
the plugin by vendor/name. See BUG-E2#PluginDevice.
|
|
313
|
+
- GroupDevice rack (InstrumentGroupDevice, AudioEffectGroupDevice, etc.)
|
|
314
|
+
→ try atlas resolution; emit load_browser_item if name is meaningful,
|
|
315
|
+
else manual_rebuild with macro values
|
|
316
|
+
- MaxAudioEffect / MaxInstrument / MaxMidiEffect
|
|
317
|
+
→ load_browser_item for the .amxd (user_name is the device label)
|
|
318
|
+
- Known native device
|
|
319
|
+
→ insert_device + set_device_parameter (based on fidelity)
|
|
320
|
+
- Unknown class
|
|
321
|
+
→ insert_device best-effort + warning
|
|
322
|
+
|
|
323
|
+
Parameter fidelity "approximate" uses preset sidecar factory defaults when
|
|
324
|
+
available to sort by deviation-from-default rather than abs(value).
|
|
325
|
+
# TODO(URI-helper): emit load_device_by_uri when a browser URI is resolved.
|
|
326
|
+
"""
|
|
327
|
+
steps: list[dict] = []
|
|
328
|
+
warnings: list[str] = []
|
|
329
|
+
|
|
330
|
+
cls = device["class"]
|
|
331
|
+
uname = device["user_name"] or ""
|
|
332
|
+
macros = device["macros"]
|
|
333
|
+
|
|
334
|
+
label = uname or cls or "unknown-device"
|
|
335
|
+
|
|
336
|
+
# ── PluginDevice — third-party VST/AU, not insertable by class name ───────
|
|
337
|
+
if cls in _PLUGIN_DEVICE_CLASSES:
|
|
338
|
+
plugin_meta = device.get("plugin") or {}
|
|
339
|
+
plugin_name = plugin_meta.get("name") or uname or "unknown plugin"
|
|
340
|
+
manufacturer = plugin_meta.get("manufacturer") or ""
|
|
341
|
+
plugin_format = plugin_meta.get("format") or "unknown"
|
|
342
|
+
# Build a browser-search-hint the agent can pass to search_browser
|
|
343
|
+
# to locate the plugin in Live's plugins folder. The plugin's display
|
|
344
|
+
# name (from PluginDesc) is more reliable than the rack's user_name
|
|
345
|
+
# since the user might have renamed the rack but not the plugin.
|
|
346
|
+
load_step = {
|
|
347
|
+
"action": "manual_rebuild",
|
|
348
|
+
"device_class": cls,
|
|
349
|
+
"device_name": uname or plugin_name,
|
|
350
|
+
"plugin": plugin_meta if plugin_meta else None,
|
|
351
|
+
"browser_search_hint": {
|
|
352
|
+
"name_filter": plugin_name,
|
|
353
|
+
"suggested_path": "plugins",
|
|
354
|
+
},
|
|
355
|
+
"note": (
|
|
356
|
+
f"'{uname or plugin_name}' is a {plugin_format} plugin"
|
|
357
|
+
+ (f" by {manufacturer}" if manufacturer else "")
|
|
358
|
+
+ ". Cannot be inserted via insert_device(class='PluginDevice'). "
|
|
359
|
+
"Use search_browser(**browser_search_hint) to locate it under "
|
|
360
|
+
"the plugins/ category, then load_browser_item with the resolved URI. "
|
|
361
|
+
"Parameter values are stored in an opaque per-plugin binary buffer "
|
|
362
|
+
"and aren't recoverable from the .als — agent must re-dial by ear."
|
|
363
|
+
),
|
|
364
|
+
"device_index": device_index,
|
|
365
|
+
}
|
|
366
|
+
# Drop the plugin field if empty — keeps step shape clean for old corpora
|
|
367
|
+
if not plugin_meta:
|
|
368
|
+
load_step.pop("plugin")
|
|
369
|
+
steps.append(load_step)
|
|
370
|
+
warnings.append(
|
|
371
|
+
f"Device {device_index} '{uname or plugin_name}' is a "
|
|
372
|
+
f"{plugin_format} plugin"
|
|
373
|
+
+ (f" ({manufacturer})" if manufacturer else "")
|
|
374
|
+
+ ". manual_rebuild step emitted; param values opaque."
|
|
375
|
+
)
|
|
376
|
+
return steps, warnings
|
|
377
|
+
|
|
378
|
+
# ── Group device (rack) ───────────────────────────────────────────────────
|
|
379
|
+
if _is_group_device(cls):
|
|
380
|
+
# Look up preset sidecar for deviation-from-default macro sorting
|
|
381
|
+
preset_defaults: dict[int, float] | None = None
|
|
382
|
+
if fidelity == "approximate" and uname and pack_name:
|
|
383
|
+
preset_defaults = _load_preset_defaults(pack_name, uname)
|
|
384
|
+
|
|
385
|
+
# BUG-INT#2: surface inner chain devices via chain_summary so the agent
|
|
386
|
+
# has visibility into nested devices without emitting nested steps.
|
|
387
|
+
inner_classes: list[str] = device.get("inner_chain_classes") or []
|
|
388
|
+
inner_chain_summary: str | None = (
|
|
389
|
+
" → ".join(inner_classes) if inner_classes else None
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
if uname:
|
|
393
|
+
# Resolve matching preset sidecar → producer-assigned macro names + URI hint.
|
|
394
|
+
# BUG-E2#1 (Macro N labeling) + BUG-E2#4 (load_browser_item URI hint).
|
|
395
|
+
preset_match = (
|
|
396
|
+
resolve_preset_for_device(pack_name, cls, uname)
|
|
397
|
+
if pack_name else {"found": False, "macro_names": {}, "browser_search_hint": None}
|
|
398
|
+
)
|
|
399
|
+
macro_names: dict[int, str] = preset_match.get("macro_names") or {}
|
|
400
|
+
|
|
401
|
+
load_step = {
|
|
402
|
+
"action": "load_browser_item",
|
|
403
|
+
"name": uname,
|
|
404
|
+
"device_class": cls,
|
|
405
|
+
"comment": (
|
|
406
|
+
f"Load '{uname}' rack from browser. "
|
|
407
|
+
"Resolve URI via search_browser(**browser_search_hint) "
|
|
408
|
+
"before calling load_browser_item. "
|
|
409
|
+
"If not found, use manual_rebuild steps below."
|
|
410
|
+
),
|
|
411
|
+
"device_index": device_index,
|
|
412
|
+
}
|
|
413
|
+
if inner_chain_summary:
|
|
414
|
+
load_step["chain_summary"] = inner_chain_summary
|
|
415
|
+
if preset_match.get("found") and preset_match.get("browser_search_hint"):
|
|
416
|
+
load_step["browser_search_hint"] = preset_match["browser_search_hint"]
|
|
417
|
+
load_step["preset_match"] = preset_match.get("match_type")
|
|
418
|
+
else:
|
|
419
|
+
load_step["browser_search_hint"] = {
|
|
420
|
+
"name_filter": uname,
|
|
421
|
+
"suggested_path": "sounds",
|
|
422
|
+
}
|
|
423
|
+
load_step["preset_match"] = "none"
|
|
424
|
+
steps.append(load_step)
|
|
425
|
+
|
|
426
|
+
# Emit macro set steps based on fidelity
|
|
427
|
+
if fidelity != "structure-only":
|
|
428
|
+
macro_subset = (
|
|
429
|
+
_get_nonzero_macros(macros)
|
|
430
|
+
if fidelity == "exact"
|
|
431
|
+
else _top_k_macros_by_deviation(
|
|
432
|
+
macros, k=5, preset_defaults=preset_defaults
|
|
433
|
+
)
|
|
434
|
+
)
|
|
435
|
+
for m in macro_subset:
|
|
436
|
+
macro_idx = m["index"]
|
|
437
|
+
# Prefer producer-assigned macro name from preset sidecar; fall back
|
|
438
|
+
# to "Macro N" when no name was recorded for that index.
|
|
439
|
+
resolved_name = macro_names.get(macro_idx) or f"Macro {macro_idx + 1}"
|
|
440
|
+
val = round(_safe_float(m.get("value", "0")), 2)
|
|
441
|
+
steps.append({
|
|
442
|
+
"action": "set_device_parameter",
|
|
443
|
+
"device_index": device_index,
|
|
444
|
+
"parameter_name": resolved_name,
|
|
445
|
+
"parameter_index": macro_idx + 1, # fallback addressing
|
|
446
|
+
"value": val,
|
|
447
|
+
"comment": (
|
|
448
|
+
f"Set {resolved_name} (idx {macro_idx + 1}) = {val} "
|
|
449
|
+
f"[SOURCE: als-parse{'+adg-parse' if resolved_name != f'Macro {macro_idx + 1}' else ''}]"
|
|
450
|
+
),
|
|
451
|
+
})
|
|
452
|
+
else:
|
|
453
|
+
# No user_name — structural rack with no named preset
|
|
454
|
+
# Emit manual_rebuild with macro values as structured data
|
|
455
|
+
nonzero = _get_nonzero_macros(macros)
|
|
456
|
+
macro_data = [
|
|
457
|
+
{"index": m["index"], "value": round(_safe_float(m.get("value", "0")), 2)}
|
|
458
|
+
for m in nonzero
|
|
459
|
+
]
|
|
460
|
+
unnamed_step: dict = {
|
|
461
|
+
"action": "manual_rebuild",
|
|
462
|
+
"device_class": cls,
|
|
463
|
+
"note": (
|
|
464
|
+
f"Unnamed {cls} rack — no browser-loadable preset available. "
|
|
465
|
+
"Rebuild manually using the macro values below."
|
|
466
|
+
),
|
|
467
|
+
"macro_values": macro_data,
|
|
468
|
+
"device_index": device_index,
|
|
469
|
+
}
|
|
470
|
+
if inner_chain_summary:
|
|
471
|
+
unnamed_step["chain_summary"] = inner_chain_summary
|
|
472
|
+
steps.append(unnamed_step)
|
|
473
|
+
if nonzero:
|
|
474
|
+
warnings.append(
|
|
475
|
+
f"Device {device_index} is an unnamed {cls} rack — "
|
|
476
|
+
"no browser URI available. "
|
|
477
|
+
f"Macro values ({len(nonzero)} non-default) documented in manual_rebuild step."
|
|
478
|
+
)
|
|
479
|
+
return steps, warnings
|
|
480
|
+
|
|
481
|
+
# ── Max device (.amxd) ────────────────────────────────────────────────────
|
|
482
|
+
if _is_max_device(cls):
|
|
483
|
+
name_for_load = uname or cls
|
|
484
|
+
steps.append({
|
|
485
|
+
"action": "load_browser_item",
|
|
486
|
+
"name": name_for_load,
|
|
487
|
+
"device_class": cls,
|
|
488
|
+
"comment": (
|
|
489
|
+
f"Load M4L device '{name_for_load}' from browser. "
|
|
490
|
+
"Search in Max for Live category."
|
|
491
|
+
),
|
|
492
|
+
"device_index": device_index,
|
|
493
|
+
})
|
|
494
|
+
if fidelity != "structure-only" and macros:
|
|
495
|
+
macro_subset = (
|
|
496
|
+
_get_nonzero_macros(macros)
|
|
497
|
+
if fidelity == "exact"
|
|
498
|
+
else _top_k_macros_by_deviation(macros, k=5)
|
|
499
|
+
)
|
|
500
|
+
for m in macro_subset:
|
|
501
|
+
steps.append({
|
|
502
|
+
"action": "set_device_parameter",
|
|
503
|
+
"device_index": device_index,
|
|
504
|
+
"parameter_name": f"Macro {m['index'] + 1}",
|
|
505
|
+
"value": round(_safe_float(m.get("value", "0")), 2),
|
|
506
|
+
"comment": f"M4L macro {m['index'] + 1} [SOURCE: als-parse]",
|
|
507
|
+
})
|
|
508
|
+
return steps, warnings
|
|
509
|
+
|
|
510
|
+
# ── Native Live device ────────────────────────────────────────────────────
|
|
511
|
+
if _is_native_device(cls) or cls:
|
|
512
|
+
device_name = uname or cls
|
|
513
|
+
steps.append({
|
|
514
|
+
"action": "insert_device",
|
|
515
|
+
"device_class": cls,
|
|
516
|
+
"device_name": device_name,
|
|
517
|
+
"comment": f"Insert {device_name}",
|
|
518
|
+
"device_index": device_index,
|
|
519
|
+
})
|
|
520
|
+
if fidelity != "structure-only" and macros:
|
|
521
|
+
macro_subset = (
|
|
522
|
+
_get_nonzero_macros(macros)
|
|
523
|
+
if fidelity == "exact"
|
|
524
|
+
else _top_k_macros_by_deviation(macros, k=5)
|
|
525
|
+
)
|
|
526
|
+
for m in macro_subset:
|
|
527
|
+
steps.append({
|
|
528
|
+
"action": "set_device_parameter",
|
|
529
|
+
"device_index": device_index,
|
|
530
|
+
"parameter_name": f"Macro {m['index'] + 1}",
|
|
531
|
+
"value": round(_safe_float(m.get("value", "0")), 2),
|
|
532
|
+
"comment": f"[SOURCE: als-parse]",
|
|
533
|
+
})
|
|
534
|
+
|
|
535
|
+
if not _is_native_device(cls):
|
|
536
|
+
warnings.append(
|
|
537
|
+
f"Device class '{cls}' is not a recognised native Live device. "
|
|
538
|
+
"insert_device step is best-effort — verify in Live."
|
|
539
|
+
)
|
|
540
|
+
return steps, warnings
|
|
541
|
+
|
|
542
|
+
# Unknown class
|
|
543
|
+
warnings.append(f"Unknown device class '{cls}' — skipped.")
|
|
544
|
+
return steps, warnings
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
# ─── Execution plan builder ───────────────────────────────────────────────────
|
|
548
|
+
|
|
549
|
+
def _build_execution_plan(
|
|
550
|
+
track: dict,
|
|
551
|
+
target_track_index: int,
|
|
552
|
+
fidelity: str,
|
|
553
|
+
demo_entity_id: str,
|
|
554
|
+
track_name: str,
|
|
555
|
+
pack_name: str = "",
|
|
556
|
+
) -> tuple[list[dict], list[str]]:
|
|
557
|
+
"""Build the full execution plan for extracting one track's device chain.
|
|
558
|
+
|
|
559
|
+
Returns (steps_list, warnings_list).
|
|
560
|
+
The plan is a DRY-RUN — executed: false always from this function.
|
|
561
|
+
|
|
562
|
+
Track-type → create action mapping (BUG-E2#3+#7 fix):
|
|
563
|
+
MidiTrack → create_midi_track
|
|
564
|
+
GroupTrack → manual_step (no create_group_track MCP tool exists)
|
|
565
|
+
ReturnTrack → create_return_track
|
|
566
|
+
AudioTrack → create_audio_track (default)
|
|
567
|
+
"""
|
|
568
|
+
steps: list[dict] = []
|
|
569
|
+
warnings: list[str] = []
|
|
570
|
+
|
|
571
|
+
t_name = track.get("name", track_name)
|
|
572
|
+
t_type = track.get("type", "")
|
|
573
|
+
|
|
574
|
+
# Step 0 (conditional): create a new track if target_track_index < 0
|
|
575
|
+
if target_track_index < 0:
|
|
576
|
+
if t_type == "MidiTrack":
|
|
577
|
+
steps.append({
|
|
578
|
+
"action": "create_midi_track",
|
|
579
|
+
"name": f"{t_name} (extracted from {demo_entity_id})",
|
|
580
|
+
"comment": "Create new MIDI track for extracted chain",
|
|
581
|
+
})
|
|
582
|
+
elif t_type == "GroupTrack":
|
|
583
|
+
# LivePilot has no create_group_track MCP tool — manual step required
|
|
584
|
+
steps.append({
|
|
585
|
+
"action": "manual_step",
|
|
586
|
+
"note": (
|
|
587
|
+
f"Source track '{t_name}' is a GroupTrack. "
|
|
588
|
+
"LivePilot does not have a create_group_track tool. "
|
|
589
|
+
"Manually group the target tracks in Ableton (Cmd+G / Ctrl+G), "
|
|
590
|
+
"then re-run extract_chain with target_track_index pointing at "
|
|
591
|
+
"the new group track."
|
|
592
|
+
),
|
|
593
|
+
"name": f"{t_name} (extracted from {demo_entity_id})",
|
|
594
|
+
"comment": "GroupTrack requires manual creation — no MCP tool available",
|
|
595
|
+
})
|
|
596
|
+
warnings.append(
|
|
597
|
+
f"Track '{t_name}' is a GroupTrack. No create_group_track MCP tool "
|
|
598
|
+
"exists in LivePilot. A manual_step was emitted — create the group "
|
|
599
|
+
"in Ableton manually, then pass its index via target_track_index."
|
|
600
|
+
)
|
|
601
|
+
elif t_type == "ReturnTrack":
|
|
602
|
+
steps.append({
|
|
603
|
+
"action": "create_return_track",
|
|
604
|
+
"name": f"{t_name} (extracted from {demo_entity_id})",
|
|
605
|
+
"comment": "Create new return track for extracted chain",
|
|
606
|
+
})
|
|
607
|
+
else:
|
|
608
|
+
# AudioTrack and any unknown type default to audio
|
|
609
|
+
steps.append({
|
|
610
|
+
"action": "create_audio_track",
|
|
611
|
+
"name": f"{t_name} (extracted from {demo_entity_id})",
|
|
612
|
+
"comment": "Create new audio track for extracted chain",
|
|
613
|
+
})
|
|
614
|
+
else:
|
|
615
|
+
steps.append({
|
|
616
|
+
"action": "target_existing_track",
|
|
617
|
+
"track_index": target_track_index,
|
|
618
|
+
"comment": f"Write chain to existing track {target_track_index}",
|
|
619
|
+
})
|
|
620
|
+
|
|
621
|
+
# Walk device chain and emit steps
|
|
622
|
+
device_chain = _walk_device_chain(track)
|
|
623
|
+
for i, dev in enumerate(device_chain):
|
|
624
|
+
dev_steps, dev_warnings = _emit_execution_steps(
|
|
625
|
+
dev, fidelity, t_name, i, pack_name=pack_name
|
|
626
|
+
)
|
|
627
|
+
steps.extend(dev_steps)
|
|
628
|
+
warnings.extend(dev_warnings)
|
|
629
|
+
|
|
630
|
+
if not device_chain:
|
|
631
|
+
warnings.append(f"Track '{t_name}' has no devices — only a track creation step emitted.")
|
|
632
|
+
|
|
633
|
+
return steps, warnings
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
# ─── Main entry point ─────────────────────────────────────────────────────────
|
|
637
|
+
|
|
638
|
+
def extract_chain(
|
|
639
|
+
demo_entity_id: str,
|
|
640
|
+
track_name: str,
|
|
641
|
+
target_track_index: int = -1,
|
|
642
|
+
parameter_fidelity: str = "exact",
|
|
643
|
+
) -> dict:
|
|
644
|
+
"""Build a dry-run device-chain extraction plan for a demo track.
|
|
645
|
+
|
|
646
|
+
Called by the MCP tool wrapper in tools.py.
|
|
647
|
+
|
|
648
|
+
Parameters
|
|
649
|
+
----------
|
|
650
|
+
demo_entity_id : str
|
|
651
|
+
Entity ID, e.g. "drone_lab__emergent_planes".
|
|
652
|
+
track_name : str
|
|
653
|
+
Name of the track to extract (fuzzy matched).
|
|
654
|
+
target_track_index : int
|
|
655
|
+
Target track index in the live project. -1 = create new track (dry-run).
|
|
656
|
+
Phase E ships dry-run only; execution against live project is Phase F.
|
|
657
|
+
parameter_fidelity : str
|
|
658
|
+
"exact" | "approximate" | "structure-only"
|
|
659
|
+
|
|
660
|
+
Returns
|
|
661
|
+
-------
|
|
662
|
+
dict matching the spec Extract Chain return shape.
|
|
663
|
+
"""
|
|
664
|
+
# ── 0. Guard: track_name must not be empty ────────────────────────────────
|
|
665
|
+
# BUG-EDGE#8: "" matches every track via the fuzzy pass-2 (`"" in any_string`
|
|
666
|
+
# is always True), silently returning the first track. Reject before loading.
|
|
667
|
+
if not track_name or not track_name.strip():
|
|
668
|
+
# Load sidecar to return available_tracks in the error (best-effort).
|
|
669
|
+
_sidecar_for_avail = _load_demo_sidecar(demo_entity_id)
|
|
670
|
+
_available: list[str] = []
|
|
671
|
+
if _sidecar_for_avail is not None:
|
|
672
|
+
_available = [t.get("name", "") for t in (_sidecar_for_avail.get("tracks") or [])]
|
|
673
|
+
return {
|
|
674
|
+
"error": "track_name is required and cannot be empty.",
|
|
675
|
+
"status": "error",
|
|
676
|
+
"entity_id": demo_entity_id,
|
|
677
|
+
"available_tracks": _available,
|
|
678
|
+
"executed": False,
|
|
679
|
+
"sources": [],
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
# ── 1. Load sidecar ───────────────────────────────────────────────────────
|
|
683
|
+
sidecar = _load_demo_sidecar(demo_entity_id)
|
|
684
|
+
if sidecar is None:
|
|
685
|
+
return {
|
|
686
|
+
"error": (
|
|
687
|
+
f"Demo sidecar not found for entity_id='{demo_entity_id}'. "
|
|
688
|
+
"Check ~/.livepilot/atlas-overlays/packs/_demo_parses/."
|
|
689
|
+
),
|
|
690
|
+
"entity_id": demo_entity_id,
|
|
691
|
+
"executed": False,
|
|
692
|
+
"sources": [],
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
sidecar_path = _resolve_demo_slug(demo_entity_id)
|
|
696
|
+
|
|
697
|
+
# ── 2. Find track ─────────────────────────────────────────────────────────
|
|
698
|
+
track, fuzzy_other_candidates = _find_track_by_name(sidecar, track_name)
|
|
699
|
+
if track is None:
|
|
700
|
+
available = [t.get("name", "") for t in (sidecar.get("tracks") or [])]
|
|
701
|
+
return {
|
|
702
|
+
"error": (
|
|
703
|
+
f"Track '{track_name}' not found in demo '{demo_entity_id}'. "
|
|
704
|
+
"Fuzzy match (substring, token) also failed."
|
|
705
|
+
),
|
|
706
|
+
"available_tracks": available,
|
|
707
|
+
"entity_id": demo_entity_id,
|
|
708
|
+
"executed": False,
|
|
709
|
+
"sources": [f"als-parse: {sidecar_path} [SOURCE: als-parse]"],
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
resolved_track_name = track.get("name", track_name)
|
|
713
|
+
# BUG-extract_chain-fuzzy: surface ambiguity when multiple candidates matched
|
|
714
|
+
ambiguity_warning: str | None = None
|
|
715
|
+
if fuzzy_other_candidates:
|
|
716
|
+
ambiguity_warning = (
|
|
717
|
+
f"Fuzzy match picked '{resolved_track_name}' but {len(fuzzy_other_candidates)} "
|
|
718
|
+
f"other candidate(s) also matched: {fuzzy_other_candidates}. "
|
|
719
|
+
"Use an exact track name to disambiguate."
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
# ── 3. Walk device chain ──────────────────────────────────────────────────
|
|
723
|
+
device_chain = _walk_device_chain(track)
|
|
724
|
+
|
|
725
|
+
# Build device_chain summary for return shape
|
|
726
|
+
device_chain_summary = []
|
|
727
|
+
for dev in device_chain:
|
|
728
|
+
cls = dev["class"]
|
|
729
|
+
uname = dev["user_name"]
|
|
730
|
+
macros = dev["macros"]
|
|
731
|
+
nonzero = [m for m in macros if _safe_float(m.get("value", "0")) != 0.0]
|
|
732
|
+
entry: dict[str, Any] = {
|
|
733
|
+
"class": cls,
|
|
734
|
+
"user_name": uname,
|
|
735
|
+
"chain_depth": dev["depth"],
|
|
736
|
+
}
|
|
737
|
+
if nonzero:
|
|
738
|
+
entry["macros"] = [
|
|
739
|
+
{"index": m["index"], "value": round(_safe_float(m.get("value", "0")), 2)}
|
|
740
|
+
for m in nonzero
|
|
741
|
+
]
|
|
742
|
+
# BUG-INT#2: include inner_chain_classes in device_chain_summary so the
|
|
743
|
+
# agent can see nested devices (e.g. Erosion inside InstrumentGroupDevice)
|
|
744
|
+
inner = dev.get("inner_chain_classes") or []
|
|
745
|
+
if inner:
|
|
746
|
+
entry["inner_chain_classes"] = inner
|
|
747
|
+
device_chain_summary.append(entry)
|
|
748
|
+
|
|
749
|
+
# ── 4. Build execution plan ───────────────────────────────────────────────
|
|
750
|
+
# Derive pack_name from demo_entity_id (first segment before "__")
|
|
751
|
+
# e.g. "drone_lab__emergent_planes" → "drone-lab" (match preset_parses dir)
|
|
752
|
+
pack_slug = demo_entity_id.split("__")[0].replace("_", "-")
|
|
753
|
+
|
|
754
|
+
steps, warnings = _build_execution_plan(
|
|
755
|
+
track=track,
|
|
756
|
+
target_track_index=target_track_index,
|
|
757
|
+
fidelity=parameter_fidelity,
|
|
758
|
+
demo_entity_id=demo_entity_id,
|
|
759
|
+
track_name=resolved_track_name,
|
|
760
|
+
pack_name=pack_slug,
|
|
761
|
+
)
|
|
762
|
+
|
|
763
|
+
result: dict = {
|
|
764
|
+
"source": {
|
|
765
|
+
"demo": demo_entity_id,
|
|
766
|
+
"track": resolved_track_name,
|
|
767
|
+
"track_type": track.get("type", ""),
|
|
768
|
+
"device_count": len(device_chain),
|
|
769
|
+
"device_chain": device_chain_summary,
|
|
770
|
+
},
|
|
771
|
+
"execution_plan": steps,
|
|
772
|
+
"executed": False, # Phase E is dry-run only
|
|
773
|
+
"parameter_fidelity": parameter_fidelity,
|
|
774
|
+
"warnings": warnings,
|
|
775
|
+
"sources": [
|
|
776
|
+
f"als-parse: {sidecar_path} [SOURCE: als-parse]",
|
|
777
|
+
"agent-inference: execution step generation [SOURCE: agent-inference]",
|
|
778
|
+
],
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
# BUG-extract_chain-fuzzy: include ambiguity fields when fuzzy match was ambiguous
|
|
782
|
+
if ambiguity_warning:
|
|
783
|
+
result["matched_track"] = resolved_track_name
|
|
784
|
+
result["ambiguity_warning"] = ambiguity_warning
|
|
785
|
+
|
|
786
|
+
return result
|