livepilot 1.23.6 → 1.25.0
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 +107 -0
- package/README.md +60 -14
- 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 +17 -3
- package/mcp_server/atlas/explore_tools.py +332 -0
- package/mcp_server/atlas/tools.py +161 -0
- package/mcp_server/audit/__init__.py +6 -0
- package/mcp_server/audit/checks.py +618 -0
- package/mcp_server/audit/tools.py +232 -0
- package/mcp_server/composer/branch_producer.py +5 -2
- package/mcp_server/composer/develop/__init__.py +19 -0
- package/mcp_server/composer/develop/apply.py +217 -0
- package/mcp_server/composer/develop/brief_builder.py +269 -0
- package/mcp_server/composer/develop/seed_introspector.py +195 -0
- package/mcp_server/composer/engine.py +15 -521
- package/mcp_server/composer/fast/__init__.py +62 -0
- package/mcp_server/composer/fast/apply.py +533 -0
- package/mcp_server/composer/fast/brief_builder.py +1479 -0
- package/mcp_server/composer/fast/tier_classification.py +159 -0
- package/mcp_server/composer/framework/__init__.py +0 -0
- package/mcp_server/composer/framework/applier.py +179 -0
- package/mcp_server/composer/framework/artist_loader.py +63 -0
- package/mcp_server/composer/framework/atlas_resolver.py +554 -0
- package/mcp_server/composer/framework/brief.py +79 -0
- package/mcp_server/composer/framework/event_lexicon.py +71 -0
- package/mcp_server/composer/framework/genre_loader.py +77 -0
- package/mcp_server/composer/framework/intent_source.py +137 -0
- package/mcp_server/composer/framework/knowledge_pack.py +140 -0
- package/mcp_server/composer/framework/plan_compiler.py +10 -0
- package/mcp_server/composer/full/__init__.py +10 -0
- package/mcp_server/composer/full/apply.py +1139 -0
- package/mcp_server/composer/full/brief_builder.py +227 -0
- package/mcp_server/composer/full/engine.py +541 -0
- package/mcp_server/composer/full/layer_planner.py +491 -0
- package/mcp_server/composer/layer_planner.py +19 -465
- package/mcp_server/composer/sample_resolver.py +80 -7
- package/mcp_server/composer/tools.py +626 -28
- package/mcp_server/server.py +1 -0
- package/mcp_server/splice_client/client.py +7 -0
- package/mcp_server/tools/_analyzer_engine/sample.py +172 -7
- package/mcp_server/tools/_planner_engine.py +25 -63
- package/mcp_server/tools/analyzer.py +10 -4
- package/mcp_server/tools/browser.py +102 -19
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +1 -1
- package/server.json +3 -3
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
"""Fast compose Phase-3 executor — applies agent-designed plan to live session."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
from fastmcp import Context
|
|
9
|
+
|
|
10
|
+
from .. import fast as fast_compose
|
|
11
|
+
from ..framework.applier import Applier
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
# ── v1.24 Phase 4 Tasks 18b + 18d: post-load repair helpers ────────
|
|
16
|
+
|
|
17
|
+
DRUM_ROLES = frozenset({"kick", "snare", "hat", "perc", "clap", "tom", "drum"})
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _is_drum_role(role: str) -> bool:
|
|
21
|
+
"""True if the role belongs to the drum family — gets role-default repair."""
|
|
22
|
+
return role.lower() in DRUM_ROLES
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _detect_silent_load(ableton, track_index: int, device_index: int = 0) -> tuple:
|
|
26
|
+
"""Detect if the loaded device is silently misconfigured (empty container).
|
|
27
|
+
|
|
28
|
+
Returns (is_silent: bool, reason: str).
|
|
29
|
+
"""
|
|
30
|
+
try:
|
|
31
|
+
device_info = ableton.send_command("get_device_info", {
|
|
32
|
+
"track_index": track_index, "device_index": device_index,
|
|
33
|
+
})
|
|
34
|
+
except Exception:
|
|
35
|
+
return False, ""
|
|
36
|
+
|
|
37
|
+
class_name = device_info.get("class_name", "")
|
|
38
|
+
name = device_info.get("name", "")
|
|
39
|
+
|
|
40
|
+
# DrumCell with no sample loaded — bare "Drum Sampler" container URI
|
|
41
|
+
if class_name == "DrumCell" and name == "Drum Sampler":
|
|
42
|
+
return True, "DrumCell loaded as bare 'Drum Sampler' container — needs sample inside"
|
|
43
|
+
|
|
44
|
+
# Simpler with Sample Length near zero
|
|
45
|
+
if class_name == "OriginalSimpler":
|
|
46
|
+
try:
|
|
47
|
+
params_resp = ableton.send_command("get_device_parameters", {
|
|
48
|
+
"track_index": track_index, "device_index": device_index,
|
|
49
|
+
})
|
|
50
|
+
for p in params_resp.get("parameters", []):
|
|
51
|
+
if p["name"] == "Sample Length" and p["value"] < 0.001:
|
|
52
|
+
return True, "Simpler has no sample loaded (Sample Length=0)"
|
|
53
|
+
except Exception:
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
# Drum Rack loaded as bare container
|
|
57
|
+
if class_name == "DrumGroupDevice" and name == "Drum Rack":
|
|
58
|
+
return True, "Drum Rack loaded as bare container — no kit pads"
|
|
59
|
+
|
|
60
|
+
return False, ""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _apply_drum_role_repair(ableton, track_index: int, device_index: int = 0) -> dict:
|
|
64
|
+
"""Apply role-default repair to a drum-role layer's instrument.
|
|
65
|
+
|
|
66
|
+
Defense in depth: load_browser_item's role='drum' silently fails to
|
|
67
|
+
apply these defaults when the track has audio effects. This function
|
|
68
|
+
re-applies them deterministically post-load.
|
|
69
|
+
|
|
70
|
+
Device-class-aware:
|
|
71
|
+
- For OriginalSimpler (sample-trigger): set Volume=0, Snap=Off, Transpose=+24
|
|
72
|
+
(Transpose compensates for Simpler's default C3=60 root vs drum-rack convention C1=36).
|
|
73
|
+
- For other drum devices (DS Kick, Drum Rack, Impulse, etc.): only attempt Volume=0
|
|
74
|
+
since they don't have Snap/Transpose params (would error otherwise).
|
|
75
|
+
|
|
76
|
+
BUG-FIX (post-Task-18d live test): Remote Script's batch_set_parameters
|
|
77
|
+
accepts `name_or_index` (legacy field), NOT `parameter_name`. Wrapper
|
|
78
|
+
docs claim both are supported but actual Remote Script reads only
|
|
79
|
+
name_or_index. Using parameter_name caused "Parameter 'None' not found"
|
|
80
|
+
errors. Always use name_or_index for compatibility.
|
|
81
|
+
|
|
82
|
+
Returns the repair result dict with per-param status.
|
|
83
|
+
"""
|
|
84
|
+
# Detect device class so we apply class-appropriate params
|
|
85
|
+
try:
|
|
86
|
+
device_info = ableton.send_command("get_device_info", {
|
|
87
|
+
"track_index": track_index, "device_index": device_index,
|
|
88
|
+
})
|
|
89
|
+
class_name = device_info.get("class_name", "")
|
|
90
|
+
except Exception as exc:
|
|
91
|
+
return {"applied": False, "error": f"get_device_info failed: {exc}"}
|
|
92
|
+
|
|
93
|
+
# Build the param list — Volume always; Snap+Transpose only for Simpler
|
|
94
|
+
params_to_set = [{"name_or_index": "Volume", "value": 0}]
|
|
95
|
+
if class_name == "OriginalSimpler":
|
|
96
|
+
params_to_set.extend([
|
|
97
|
+
{"name_or_index": "Snap", "value": 0},
|
|
98
|
+
{"name_or_index": "Transpose", "value": 24},
|
|
99
|
+
])
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
result = ableton.send_command("batch_set_parameters", {
|
|
103
|
+
"track_index": track_index,
|
|
104
|
+
"device_index": device_index,
|
|
105
|
+
"parameters": params_to_set,
|
|
106
|
+
})
|
|
107
|
+
return {
|
|
108
|
+
"applied": True,
|
|
109
|
+
"device_class": class_name,
|
|
110
|
+
"params": result.get("parameters", []),
|
|
111
|
+
}
|
|
112
|
+
except Exception as exc:
|
|
113
|
+
return {"applied": False, "device_class": class_name, "error": str(exc)}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
async def apply_fast_plan(
|
|
117
|
+
ctx: Context,
|
|
118
|
+
plan: dict,
|
|
119
|
+
) -> dict:
|
|
120
|
+
"""Phase-3 fast mode: server-side execute the agent's creative plan.
|
|
121
|
+
|
|
122
|
+
Plan shape:
|
|
123
|
+
{
|
|
124
|
+
"layers": [
|
|
125
|
+
{
|
|
126
|
+
"role": "kick" | "snare" | ...,
|
|
127
|
+
"uri": "atlas URI",
|
|
128
|
+
"track_name": "optional display name",
|
|
129
|
+
"notes": [{"pitch": int, "start_time": float, "duration": float, "velocity": int}, ...]
|
|
130
|
+
},
|
|
131
|
+
...
|
|
132
|
+
],
|
|
133
|
+
"scene_index": int | null,
|
|
134
|
+
"bars": int (optional, defaults to inferred from notes),
|
|
135
|
+
"tempo": int (optional, sets tempo if not already set),
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
Returns: tracks_created, scene_fired, per-layer load+note status.
|
|
139
|
+
"""
|
|
140
|
+
started = time.time()
|
|
141
|
+
ableton = ctx.lifespan_context.get("ableton") if hasattr(ctx, "lifespan_context") else None
|
|
142
|
+
if ableton is None:
|
|
143
|
+
return {"error": "Ableton connection not available", "phase": "apply"}
|
|
144
|
+
|
|
145
|
+
layers = plan.get("layers") or []
|
|
146
|
+
if not layers:
|
|
147
|
+
return {"error": "plan.layers is empty — nothing to apply", "phase": "apply"}
|
|
148
|
+
|
|
149
|
+
# ── Pre-flight: bridge handshake (BUG-FULL-MODE-14 parity) ────────
|
|
150
|
+
# Fast mode doesn't use the bridge directly, but running preflight
|
|
151
|
+
# ensures the bridge is warm for any tools that run afterward in the
|
|
152
|
+
# same session. Non-fatal: if bridge isn't available, we log and continue
|
|
153
|
+
# since the fast-mode layer loop uses only direct TCP commands.
|
|
154
|
+
try:
|
|
155
|
+
from ...tools.analyzer import (
|
|
156
|
+
ensure_analyzer_on_master as _ensure_analyzer,
|
|
157
|
+
reconnect_bridge as _reconnect_bridge,
|
|
158
|
+
)
|
|
159
|
+
from ...tools._analyzer_engine.context import _get_m4l
|
|
160
|
+
|
|
161
|
+
async def _ensure_analyzer_async(c):
|
|
162
|
+
return _ensure_analyzer(c)
|
|
163
|
+
|
|
164
|
+
async def _reconnect_bridge_async(c):
|
|
165
|
+
resp = await _reconnect_bridge(c)
|
|
166
|
+
# reconnect_bridge returns {"ok": True} on success; normalize to
|
|
167
|
+
# {"connected": True} so Applier.preflight can use a unified key.
|
|
168
|
+
if isinstance(resp, dict) and resp.get("ok"):
|
|
169
|
+
resp = dict(resp)
|
|
170
|
+
resp["connected"] = True
|
|
171
|
+
return resp
|
|
172
|
+
|
|
173
|
+
async def _bridge_ping_async(c):
|
|
174
|
+
bridge = _get_m4l(c)
|
|
175
|
+
return await bridge.send_command("ping", timeout=0.5)
|
|
176
|
+
|
|
177
|
+
applier = Applier(
|
|
178
|
+
ensure_analyzer_fn=_ensure_analyzer_async,
|
|
179
|
+
reconnect_bridge_fn=_reconnect_bridge_async,
|
|
180
|
+
bridge_ping_fn=_bridge_ping_async,
|
|
181
|
+
)
|
|
182
|
+
preflight_result = await applier.preflight(ctx)
|
|
183
|
+
if not preflight_result.get("bridge_connected"):
|
|
184
|
+
logger.debug(
|
|
185
|
+
"fast apply: bridge not ready (attempts=%d) — continuing without bridge",
|
|
186
|
+
preflight_result.get("handshake_attempts", 0),
|
|
187
|
+
)
|
|
188
|
+
except Exception as exc:
|
|
189
|
+
logger.debug("fast apply: preflight failed (bridge unavailable): %s", exc)
|
|
190
|
+
|
|
191
|
+
# Pre-flight: where do new tracks go, and which scene?
|
|
192
|
+
session = ableton.send_command("get_session_info", {})
|
|
193
|
+
starting_track_count = int(session.get("track_count", 0))
|
|
194
|
+
scene_count = int(session.get("scene_count", 0))
|
|
195
|
+
|
|
196
|
+
# Optional tempo override
|
|
197
|
+
if plan.get("tempo"):
|
|
198
|
+
try:
|
|
199
|
+
ableton.send_command("set_tempo", {"tempo": float(plan["tempo"])})
|
|
200
|
+
except Exception as exc:
|
|
201
|
+
logger.debug("apply: set_tempo failed: %s", exc)
|
|
202
|
+
|
|
203
|
+
# Pick the target scene
|
|
204
|
+
target_scene = plan.get("scene_index")
|
|
205
|
+
if target_scene is None:
|
|
206
|
+
scenes = session.get("scenes", []) or []
|
|
207
|
+
target_scene = next(
|
|
208
|
+
(i for i, s in enumerate(scenes) if not s.get("name")),
|
|
209
|
+
None,
|
|
210
|
+
)
|
|
211
|
+
if target_scene is None or target_scene >= scene_count:
|
|
212
|
+
target_scene = max(0, scene_count - 1)
|
|
213
|
+
target_scene = int(target_scene)
|
|
214
|
+
|
|
215
|
+
# Phase B: build return-name → send_index map up front so layers can name
|
|
216
|
+
# returns ("A-Reverb") instead of remembering integer send indices.
|
|
217
|
+
return_name_to_send_index: dict[str, int] = {}
|
|
218
|
+
try:
|
|
219
|
+
returns_resp = ableton.send_command("get_return_tracks", {}) or {}
|
|
220
|
+
for i, rt in enumerate(returns_resp.get("return_tracks", []) or []):
|
|
221
|
+
name = (rt.get("name") or "").strip()
|
|
222
|
+
if name:
|
|
223
|
+
return_name_to_send_index[name.lower()] = i
|
|
224
|
+
except Exception as exc:
|
|
225
|
+
logger.debug("apply: get_return_tracks failed: %s", exc)
|
|
226
|
+
|
|
227
|
+
layer_results: list[dict] = []
|
|
228
|
+
new_track_indices: list[int] = []
|
|
229
|
+
|
|
230
|
+
for layer in layers:
|
|
231
|
+
role = (layer.get("role") or "").strip()
|
|
232
|
+
uri = (layer.get("uri") or "").strip()
|
|
233
|
+
# BUG-N normalization (2026-05-01): search_browser returns URIs with
|
|
234
|
+
# literal `&` (e.g. "Sounds#Ambient & Evolving"), but agents may
|
|
235
|
+
# double-encode it to %26 thinking it's URL-spec — which makes the
|
|
236
|
+
# exact-match URI walk in load_browser_item miss the file. Normalize
|
|
237
|
+
# %26 → & and %2526 → & so URIs always match the form Live's browser
|
|
238
|
+
# exposes, regardless of how the agent encoded them.
|
|
239
|
+
if uri:
|
|
240
|
+
uri = uri.replace("%2526", "&").replace("%26", "&")
|
|
241
|
+
track_name = layer.get("track_name") or role.upper() or f"Layer {len(new_track_indices) + 1}"
|
|
242
|
+
notes = layer.get("notes") or []
|
|
243
|
+
|
|
244
|
+
new_track_idx = starting_track_count + len(new_track_indices)
|
|
245
|
+
try:
|
|
246
|
+
ableton.send_command("create_midi_track", {"index": -1, "name": track_name})
|
|
247
|
+
except Exception as exc:
|
|
248
|
+
logger.warning("apply: create_midi_track(%s) failed: %s", track_name, exc)
|
|
249
|
+
layer_results.append({
|
|
250
|
+
"role": role, "track_name": track_name, "ok": False,
|
|
251
|
+
"error": f"create_midi_track failed: {exc}",
|
|
252
|
+
})
|
|
253
|
+
continue
|
|
254
|
+
new_track_indices.append(new_track_idx)
|
|
255
|
+
|
|
256
|
+
loaded = False
|
|
257
|
+
silent_load_warning: str | None = None
|
|
258
|
+
role_repair: dict | None = None
|
|
259
|
+
|
|
260
|
+
if uri:
|
|
261
|
+
simpler_role = fast_compose.simpler_role_for(role)
|
|
262
|
+
try:
|
|
263
|
+
load_params: dict = {"track_index": new_track_idx, "uri": uri}
|
|
264
|
+
if simpler_role:
|
|
265
|
+
load_params["role"] = simpler_role
|
|
266
|
+
ableton.send_command("load_browser_item", load_params)
|
|
267
|
+
loaded = True
|
|
268
|
+
except Exception as exc:
|
|
269
|
+
logger.debug("apply: load_browser_item(%s, %s) failed: %s", new_track_idx, uri, exc)
|
|
270
|
+
|
|
271
|
+
if loaded:
|
|
272
|
+
# v1.24 Phase 4 Task 18b: detect empty containers post-load
|
|
273
|
+
is_silent, silent_reason = _detect_silent_load(ableton, new_track_idx, device_index=0)
|
|
274
|
+
if is_silent:
|
|
275
|
+
silent_load_warning = silent_reason
|
|
276
|
+
logger.warning(
|
|
277
|
+
"apply: silent load detected for role=%s track=%s: %s",
|
|
278
|
+
role, new_track_idx, silent_reason,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
# v1.24 Phase 4 Task 18d: drum role-default repair (defense in depth)
|
|
282
|
+
# load_browser_item role='drum' silently skips Vol/Snap/root fixes
|
|
283
|
+
# when the track already has FX. Re-apply deterministically.
|
|
284
|
+
if _is_drum_role(role):
|
|
285
|
+
role_repair = _apply_drum_role_repair(ableton, new_track_idx, device_index=0)
|
|
286
|
+
if not role_repair.get("applied"):
|
|
287
|
+
logger.debug(
|
|
288
|
+
"apply: drum role repair failed for track %s: %s",
|
|
289
|
+
new_track_idx, role_repair.get("error"),
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
# Determine clip length: max of (4 bars × 4 beats, end of last note + 1)
|
|
293
|
+
max_end = 0.0
|
|
294
|
+
for n in notes:
|
|
295
|
+
try:
|
|
296
|
+
end = float(n.get("start_time", 0)) + float(n.get("duration", 0))
|
|
297
|
+
max_end = max(max_end, end)
|
|
298
|
+
except (TypeError, ValueError):
|
|
299
|
+
pass
|
|
300
|
+
bars = int(plan.get("bars") or 4)
|
|
301
|
+
clip_length_beats = max(bars * 4, int(max_end) + 1) if notes else bars * 4
|
|
302
|
+
|
|
303
|
+
try:
|
|
304
|
+
ableton.send_command("create_clip", {
|
|
305
|
+
"track_index": new_track_idx,
|
|
306
|
+
"clip_index": target_scene,
|
|
307
|
+
"length": float(clip_length_beats),
|
|
308
|
+
})
|
|
309
|
+
except Exception as exc:
|
|
310
|
+
logger.warning("apply: create_clip(%s) failed: %s", role, exc)
|
|
311
|
+
layer_results.append({
|
|
312
|
+
"role": role, "track_index": new_track_idx, "uri": uri, "loaded": loaded,
|
|
313
|
+
"ok": False, "error": f"create_clip failed: {exc}",
|
|
314
|
+
})
|
|
315
|
+
continue
|
|
316
|
+
|
|
317
|
+
notes_added = 0
|
|
318
|
+
if notes:
|
|
319
|
+
try:
|
|
320
|
+
ableton.send_command("add_notes", {
|
|
321
|
+
"track_index": new_track_idx,
|
|
322
|
+
"clip_index": target_scene,
|
|
323
|
+
"notes": notes,
|
|
324
|
+
})
|
|
325
|
+
notes_added = len(notes)
|
|
326
|
+
except Exception as exc:
|
|
327
|
+
logger.warning("apply: add_notes(%s) failed: %s", role, exc)
|
|
328
|
+
|
|
329
|
+
# Phase B (2026-05-01): per-layer effect chain. Each effect inserts
|
|
330
|
+
# one native Live device on the track and (optionally) sets a few
|
|
331
|
+
# of its parameters. Failures are logged per-effect — the layer
|
|
332
|
+
# still succeeds even if one effect doesn't load.
|
|
333
|
+
effects_applied: list[dict] = []
|
|
334
|
+
for fx in layer.get("effects") or []:
|
|
335
|
+
device_name = (fx.get("device") or "").strip()
|
|
336
|
+
if not device_name:
|
|
337
|
+
continue
|
|
338
|
+
try:
|
|
339
|
+
ins_resp = ableton.send_command("insert_device", {
|
|
340
|
+
"track_index": new_track_idx,
|
|
341
|
+
"device_name": device_name,
|
|
342
|
+
}) or {}
|
|
343
|
+
device_index = ins_resp.get("device_index")
|
|
344
|
+
params_set: list[dict] = []
|
|
345
|
+
params_failed: list[dict] = []
|
|
346
|
+
if device_index is not None:
|
|
347
|
+
for pname, pvalue in (fx.get("params") or {}).items():
|
|
348
|
+
try:
|
|
349
|
+
ableton.send_command("set_device_parameter", {
|
|
350
|
+
"track_index": new_track_idx,
|
|
351
|
+
"device_index": int(device_index),
|
|
352
|
+
"parameter_name": str(pname),
|
|
353
|
+
"value": float(pvalue),
|
|
354
|
+
})
|
|
355
|
+
params_set.append({"name": str(pname), "value": float(pvalue)})
|
|
356
|
+
except Exception as exc:
|
|
357
|
+
logger.debug(
|
|
358
|
+
"apply: set_device_parameter(%s.%s=%s) failed: %s",
|
|
359
|
+
device_name, pname, pvalue, exc,
|
|
360
|
+
)
|
|
361
|
+
params_failed.append({"name": str(pname), "error": str(exc)})
|
|
362
|
+
effects_applied.append({
|
|
363
|
+
"device": device_name,
|
|
364
|
+
"device_index": device_index,
|
|
365
|
+
"params_set": params_set,
|
|
366
|
+
"params_failed": params_failed,
|
|
367
|
+
"ok": True,
|
|
368
|
+
})
|
|
369
|
+
except Exception as exc:
|
|
370
|
+
logger.warning("apply: insert_device(%s) on track %s failed: %s",
|
|
371
|
+
device_name, new_track_idx, exc)
|
|
372
|
+
effects_applied.append({
|
|
373
|
+
"device": device_name,
|
|
374
|
+
"ok": False,
|
|
375
|
+
"error": str(exc),
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
# Phase B: per-layer sends. Each entry is {return_name | send_index, value}.
|
|
379
|
+
# Names are case-insensitive; if the return doesn't exist, we record
|
|
380
|
+
# the miss and continue.
|
|
381
|
+
sends_applied: list[dict] = []
|
|
382
|
+
for snd in layer.get("sends") or []:
|
|
383
|
+
try:
|
|
384
|
+
value = float(snd.get("value", 0.0))
|
|
385
|
+
except (TypeError, ValueError):
|
|
386
|
+
continue
|
|
387
|
+
send_index = snd.get("send_index")
|
|
388
|
+
return_name = (snd.get("return_name") or "").strip()
|
|
389
|
+
if send_index is None and return_name:
|
|
390
|
+
send_index = return_name_to_send_index.get(return_name.lower())
|
|
391
|
+
if send_index is None:
|
|
392
|
+
sends_applied.append({
|
|
393
|
+
"return_name": return_name or None,
|
|
394
|
+
"ok": False,
|
|
395
|
+
"error": "return track not found",
|
|
396
|
+
})
|
|
397
|
+
continue
|
|
398
|
+
try:
|
|
399
|
+
ableton.send_command("set_track_send", {
|
|
400
|
+
"track_index": new_track_idx,
|
|
401
|
+
"send_index": int(send_index),
|
|
402
|
+
"value": value,
|
|
403
|
+
})
|
|
404
|
+
sends_applied.append({
|
|
405
|
+
"return_name": return_name or None,
|
|
406
|
+
"send_index": int(send_index),
|
|
407
|
+
"value": value,
|
|
408
|
+
"ok": True,
|
|
409
|
+
})
|
|
410
|
+
except Exception as exc:
|
|
411
|
+
logger.debug("apply: set_track_send(%s, %s, %s) failed: %s",
|
|
412
|
+
new_track_idx, send_index, value, exc)
|
|
413
|
+
sends_applied.append({
|
|
414
|
+
"return_name": return_name or None,
|
|
415
|
+
"send_index": int(send_index) if send_index is not None else None,
|
|
416
|
+
"value": value,
|
|
417
|
+
"ok": False,
|
|
418
|
+
"error": str(exc),
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
# Tier-1C: pass through any applied_technique attribution from the
|
|
422
|
+
# agent's plan — surfaced in the response's techniques_used array
|
|
423
|
+
# so the user sees provenance per layer.
|
|
424
|
+
applied_technique = layer.get("applied_technique") or None
|
|
425
|
+
|
|
426
|
+
layer_entry: dict = {
|
|
427
|
+
"role": role,
|
|
428
|
+
"track_name": track_name,
|
|
429
|
+
"track_index": new_track_idx,
|
|
430
|
+
"uri": uri,
|
|
431
|
+
"loaded": loaded,
|
|
432
|
+
"notes_added": notes_added,
|
|
433
|
+
"clip_length_beats": clip_length_beats,
|
|
434
|
+
"effects_applied": effects_applied,
|
|
435
|
+
"sends_applied": sends_applied,
|
|
436
|
+
"applied_technique": applied_technique,
|
|
437
|
+
"ok": True,
|
|
438
|
+
}
|
|
439
|
+
if silent_load_warning:
|
|
440
|
+
layer_entry["silent_load_warning"] = silent_load_warning
|
|
441
|
+
layer_entry["warnings"] = [silent_load_warning]
|
|
442
|
+
if role_repair is not None:
|
|
443
|
+
layer_entry["role_repair"] = role_repair
|
|
444
|
+
layer_results.append(layer_entry)
|
|
445
|
+
|
|
446
|
+
# Fire the scene
|
|
447
|
+
fired = False
|
|
448
|
+
try:
|
|
449
|
+
ableton.send_command("fire_scene", {"scene_index": target_scene})
|
|
450
|
+
fired = True
|
|
451
|
+
except Exception as exc:
|
|
452
|
+
logger.warning("apply: fire_scene(%s) failed: %s", target_scene, exc)
|
|
453
|
+
|
|
454
|
+
# Final fresh-project cleanup: delete the leftover default track if
|
|
455
|
+
# the brief left one in place to satisfy Ableton's "≥1 track" guard.
|
|
456
|
+
final_cleanup_actions: list[str] = []
|
|
457
|
+
new_session = ableton.send_command("get_session_info", {})
|
|
458
|
+
final_tracks = new_session.get("tracks", []) or []
|
|
459
|
+
# If track 0 is still default-named and empty, AND we just added new
|
|
460
|
+
# tracks, prune it now (we have ≥2 tracks total, safe to delete).
|
|
461
|
+
if final_tracks and len(final_tracks) > 1:
|
|
462
|
+
first = final_tracks[0]
|
|
463
|
+
if fast_compose.is_default_track_name(first.get("name", "")):
|
|
464
|
+
try:
|
|
465
|
+
ti0 = ableton.send_command("get_track_info", {"track_index": 0})
|
|
466
|
+
if fast_compose.track_is_empty(ti0):
|
|
467
|
+
ableton.send_command("delete_track", {"track_index": 0})
|
|
468
|
+
final_cleanup_actions.append("deleted_leftover_default_track")
|
|
469
|
+
# All track indices shift down by 1
|
|
470
|
+
new_track_indices = [i - 1 for i in new_track_indices]
|
|
471
|
+
for r in layer_results:
|
|
472
|
+
if r.get("ok") and isinstance(r.get("track_index"), int):
|
|
473
|
+
r["track_index"] = r["track_index"] - 1
|
|
474
|
+
except Exception as exc:
|
|
475
|
+
logger.debug("apply: final cleanup failed: %s", exc)
|
|
476
|
+
|
|
477
|
+
# Tier-1C: aggregate per-layer applied_technique attributions into a
|
|
478
|
+
# top-level techniques_used summary the user sees alongside the build.
|
|
479
|
+
techniques_used = [
|
|
480
|
+
{
|
|
481
|
+
"role": r.get("role"),
|
|
482
|
+
"track_index": r.get("track_index"),
|
|
483
|
+
"snippet": (r.get("applied_technique") or {}).get("snippet"),
|
|
484
|
+
"source": (r.get("applied_technique") or {}).get("source"),
|
|
485
|
+
"source_url": (r.get("applied_technique") or {}).get("source_url"),
|
|
486
|
+
"applied_in": (r.get("applied_technique") or {}).get("applied_in"),
|
|
487
|
+
}
|
|
488
|
+
for r in layer_results
|
|
489
|
+
if r.get("applied_technique")
|
|
490
|
+
]
|
|
491
|
+
|
|
492
|
+
# Phase B: aggregate effect + send totals across layers for the summary.
|
|
493
|
+
total_effects_loaded = sum(
|
|
494
|
+
1
|
|
495
|
+
for r in layer_results
|
|
496
|
+
for fx in (r.get("effects_applied") or [])
|
|
497
|
+
if fx.get("ok")
|
|
498
|
+
)
|
|
499
|
+
total_effects_failed = sum(
|
|
500
|
+
1
|
|
501
|
+
for r in layer_results
|
|
502
|
+
for fx in (r.get("effects_applied") or [])
|
|
503
|
+
if not fx.get("ok")
|
|
504
|
+
)
|
|
505
|
+
total_sends_set = sum(
|
|
506
|
+
1
|
|
507
|
+
for r in layer_results
|
|
508
|
+
for s in (r.get("sends_applied") or [])
|
|
509
|
+
if s.get("ok")
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
duration_ms = int((time.time() - started) * 1000)
|
|
513
|
+
return {
|
|
514
|
+
"phase": "apply",
|
|
515
|
+
"tracks_created": len(new_track_indices),
|
|
516
|
+
"track_indices": new_track_indices,
|
|
517
|
+
"scene_fired": target_scene if fired else None,
|
|
518
|
+
"layers": layer_results,
|
|
519
|
+
"techniques_used": techniques_used,
|
|
520
|
+
"effects_loaded": total_effects_loaded,
|
|
521
|
+
"effects_failed": total_effects_failed,
|
|
522
|
+
"sends_set": total_sends_set,
|
|
523
|
+
"final_cleanup_actions": final_cleanup_actions,
|
|
524
|
+
"duration_ms": duration_ms,
|
|
525
|
+
"summary": (
|
|
526
|
+
f"{len(new_track_indices)} tracks created, "
|
|
527
|
+
f"{sum(1 for r in layer_results if r.get('loaded'))} instruments loaded, "
|
|
528
|
+
f"{total_effects_loaded} effects loaded, "
|
|
529
|
+
f"{total_sends_set} sends set, "
|
|
530
|
+
f"scene {target_scene} {'fired' if fired else 'NOT fired'}, "
|
|
531
|
+
f"{len(techniques_used)} technique(s) attributed"
|
|
532
|
+
),
|
|
533
|
+
}
|