livepilot 1.23.6 → 1.24.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 +37 -0
- package/README.md +59 -13
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/__init__.py +17 -3
- 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/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 +49 -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 +144 -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 +162 -6
- package/mcp_server/tools/_planner_engine.py +25 -63
- package/mcp_server/tools/analyzer.py +10 -4
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +1 -1
- package/server.json +3 -3
|
@@ -0,0 +1,1139 @@
|
|
|
1
|
+
"""Full compose Phase-3 executor — applies engine-generated plan to live session."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import re as _re
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
from fastmcp import Context
|
|
10
|
+
|
|
11
|
+
from ..framework.applier import Applier
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
# Magic ratios for "smart" mode — common polyrhythmic + half/double-time
|
|
16
|
+
# relationships that produce musically interesting results when a loop
|
|
17
|
+
# plays un-warped against a project at a different tempo.
|
|
18
|
+
_MEANINGFUL_TEMPO_RATIOS: tuple[float, ...] = (
|
|
19
|
+
0.5, # half-time (project is 2× source)
|
|
20
|
+
0.667, # 2:3 polyrhythm (project is 1.5× source)
|
|
21
|
+
0.75, # 3:4 cross-rhythm (project is 1.333× source)
|
|
22
|
+
0.8, # 4:5
|
|
23
|
+
1.25, # 5:4
|
|
24
|
+
1.333, # 4:3 cross-rhythm (source is 1.333× project)
|
|
25
|
+
1.5, # 3:2 polyrhythm
|
|
26
|
+
2.0, # double-time
|
|
27
|
+
)
|
|
28
|
+
_MEANINGFUL_RATIO_TOLERANCE = 0.02 # ±2%
|
|
29
|
+
|
|
30
|
+
# BPM hint pattern — matches the same naming conventions as
|
|
31
|
+
# `_LOOP_FILENAME_RE` in `_analyzer_engine/sample.py`. Splice files use
|
|
32
|
+
# `_125_` or `_125bpm` or `125 BPM` style.
|
|
33
|
+
_BPM_FROM_FILENAME_RE = _re.compile(
|
|
34
|
+
r"(?:_|\b)(\d{2,3})\s*(?:_|bpm|\b)",
|
|
35
|
+
_re.IGNORECASE,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _extract_bpm_from_filename(file_path: str) -> int | None:
|
|
40
|
+
"""Pull a plausible BPM (60-200) from a sample's filename.
|
|
41
|
+
|
|
42
|
+
Splice files embed BPM in the basename: `lfh_drums_125_hubble.wav`
|
|
43
|
+
→ 125. Returns None if no plausible BPM hint exists (one-shots,
|
|
44
|
+
tonal samples named by key only, etc.). The 60-200 range filters
|
|
45
|
+
out catalog IDs that happen to be 3-digit numbers.
|
|
46
|
+
"""
|
|
47
|
+
if not file_path:
|
|
48
|
+
return None
|
|
49
|
+
import os as _os
|
|
50
|
+
stem = _os.path.splitext(_os.path.basename(file_path))[0]
|
|
51
|
+
for match in _BPM_FROM_FILENAME_RE.findall(stem):
|
|
52
|
+
try:
|
|
53
|
+
n = int(match)
|
|
54
|
+
except (ValueError, TypeError):
|
|
55
|
+
continue
|
|
56
|
+
if 60 <= n <= 200:
|
|
57
|
+
return n
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _is_meaningful_ratio(
|
|
62
|
+
source_bpm: int | float | None,
|
|
63
|
+
project_bpm: int | float | None,
|
|
64
|
+
tolerance: float = _MEANINGFUL_RATIO_TOLERANCE,
|
|
65
|
+
) -> bool:
|
|
66
|
+
"""Return True when source/project BPM ratio is in the magic set ±tol.
|
|
67
|
+
|
|
68
|
+
Used by 'smart' warp strategy to decide when to leave a loop
|
|
69
|
+
un-warped (because the tempo mismatch creates interesting chopping)
|
|
70
|
+
versus warping it to project tempo (the production-safe default).
|
|
71
|
+
|
|
72
|
+
Defensive on None / 0 inputs — returns False rather than blowing up
|
|
73
|
+
on missing BPM data.
|
|
74
|
+
"""
|
|
75
|
+
if not source_bpm or not project_bpm:
|
|
76
|
+
return False
|
|
77
|
+
try:
|
|
78
|
+
ratio = float(source_bpm) / float(project_bpm)
|
|
79
|
+
except (ZeroDivisionError, ValueError, TypeError):
|
|
80
|
+
return False
|
|
81
|
+
for magic in _MEANINGFUL_TEMPO_RATIOS:
|
|
82
|
+
if abs(ratio - magic) / magic <= tolerance:
|
|
83
|
+
return True
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# Roles whose layers should ALWAYS warp regardless of ratio. Tonal /
|
|
88
|
+
# harmonic content sounds wrong when un-warped — only drums benefit
|
|
89
|
+
# from intentional chopping.
|
|
90
|
+
_TONAL_ROLES_ALWAYS_WARP: frozenset[str] = frozenset({
|
|
91
|
+
"pad", "bass", "lead", "vocal", "texture", "fx",
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _decide_warp_loops(
|
|
96
|
+
role: str,
|
|
97
|
+
file_path: str,
|
|
98
|
+
project_tempo: int | float | None,
|
|
99
|
+
strategy: str,
|
|
100
|
+
) -> bool:
|
|
101
|
+
"""Decide whether to warp this loop based on strategy + role + ratio.
|
|
102
|
+
|
|
103
|
+
Strategy semantics:
|
|
104
|
+
- "always" → always True (production-safe default)
|
|
105
|
+
- "chop" → always False (creative chopping mode)
|
|
106
|
+
- "smart" → True for tonal roles; for drum/perc, False if the
|
|
107
|
+
source/project BPM ratio lands on a magic ratio.
|
|
108
|
+
|
|
109
|
+
Returns the boolean to pass as `warp_loops` to load_sample_to_simpler.
|
|
110
|
+
"""
|
|
111
|
+
s = (strategy or "always").lower().strip()
|
|
112
|
+
if s == "chop":
|
|
113
|
+
return False
|
|
114
|
+
if s == "always":
|
|
115
|
+
return True
|
|
116
|
+
if s == "smart":
|
|
117
|
+
# Tonal roles always warp — chopping a pad sounds glitchy bad
|
|
118
|
+
if (role or "").lower() in _TONAL_ROLES_ALWAYS_WARP:
|
|
119
|
+
return True
|
|
120
|
+
# Drum/perc with no project tempo → can't compute ratio → warp
|
|
121
|
+
if not project_tempo:
|
|
122
|
+
return True
|
|
123
|
+
source_bpm = _extract_bpm_from_filename(file_path)
|
|
124
|
+
if not source_bpm:
|
|
125
|
+
return True # no BPM hint → can't be sure → safe default
|
|
126
|
+
# Meaningful ratio → leave un-warped for creative chopping
|
|
127
|
+
if _is_meaningful_ratio(source_bpm, project_tempo):
|
|
128
|
+
return False
|
|
129
|
+
return True
|
|
130
|
+
# Unknown strategy → default to always
|
|
131
|
+
return True
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _resolve_from_step(value, step_results: dict):
|
|
135
|
+
"""Recursively substitute ``$from_step`` placeholders inside plan params.
|
|
136
|
+
|
|
137
|
+
The plan emits cross-step references for things like the device_index
|
|
138
|
+
of a freshly inserted device:
|
|
139
|
+
|
|
140
|
+
{"$from_step": "layer_0_dev_0", "path": "device_index"}
|
|
141
|
+
|
|
142
|
+
The walker captures every step's response keyed by its ``step_id``.
|
|
143
|
+
This helper walks the params tree and replaces those placeholders
|
|
144
|
+
with the actual values before dispatching the call.
|
|
145
|
+
"""
|
|
146
|
+
if isinstance(value, dict):
|
|
147
|
+
if "$from_step" in value:
|
|
148
|
+
ref_id = value["$from_step"]
|
|
149
|
+
path = str(value.get("path", "") or "")
|
|
150
|
+
if ref_id not in step_results:
|
|
151
|
+
raise ValueError(
|
|
152
|
+
f"$from_step references unknown step '{ref_id}' "
|
|
153
|
+
f"(known: {sorted(step_results.keys())})"
|
|
154
|
+
)
|
|
155
|
+
current = step_results[ref_id]
|
|
156
|
+
if path:
|
|
157
|
+
for key in path.split("."):
|
|
158
|
+
if not key:
|
|
159
|
+
continue
|
|
160
|
+
if not isinstance(current, dict) or key not in current:
|
|
161
|
+
raise ValueError(
|
|
162
|
+
f"$from_step path '{path}' not found in step "
|
|
163
|
+
f"'{ref_id}' result keys={list(current.keys()) if isinstance(current, dict) else type(current).__name__}"
|
|
164
|
+
)
|
|
165
|
+
current = current[key]
|
|
166
|
+
return current
|
|
167
|
+
return {k: _resolve_from_step(v, step_results) for k, v in value.items()}
|
|
168
|
+
if isinstance(value, list):
|
|
169
|
+
return [_resolve_from_step(v, step_results) for v in value]
|
|
170
|
+
return value
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# Plan tools that aren't direct Remote-Script TCP commands — they need
|
|
174
|
+
# special dispatch (either a Python function call or a multi-step bridge
|
|
175
|
+
# routine like `load_sample_to_simpler` which itself does multiple TCP
|
|
176
|
+
# operations under the hood).
|
|
177
|
+
_FULL_PLAN_TCP_TOOLS = {
|
|
178
|
+
"set_tempo",
|
|
179
|
+
"create_midi_track",
|
|
180
|
+
"create_audio_track",
|
|
181
|
+
"create_return_track",
|
|
182
|
+
"create_scene",
|
|
183
|
+
"set_track_name",
|
|
184
|
+
"set_track_volume",
|
|
185
|
+
"set_track_pan",
|
|
186
|
+
"set_track_send",
|
|
187
|
+
"insert_device",
|
|
188
|
+
"set_device_parameter",
|
|
189
|
+
"create_clip",
|
|
190
|
+
"add_notes",
|
|
191
|
+
"create_arrangement_clip",
|
|
192
|
+
"set_clip_color",
|
|
193
|
+
"set_track_color",
|
|
194
|
+
"set_clip_name",
|
|
195
|
+
"set_clip_loop",
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
async def apply_full_plan(
|
|
200
|
+
ctx: Context,
|
|
201
|
+
plan_response: dict,
|
|
202
|
+
warp_strategy: str = "always",
|
|
203
|
+
) -> dict:
|
|
204
|
+
"""DEPRECATED in v1.24 — use apply_full_plan_v2 instead.
|
|
205
|
+
|
|
206
|
+
The old deterministic engine path (compose → step_plan → apply_full_plan)
|
|
207
|
+
was prone to flat single-pattern arrangements (BUG-FULL-MODE-18). v1.24
|
|
208
|
+
replaces it with an LLM-creative two-phase flow:
|
|
209
|
+
compose(mode="full") → brief → agent designs plan → compose_full_apply
|
|
210
|
+
→ apply_full_plan_v2.
|
|
211
|
+
|
|
212
|
+
This function is preserved for any test that exercises the old shape but
|
|
213
|
+
new code should not call it.
|
|
214
|
+
|
|
215
|
+
Phase-3 full mode: server-side execute the planner's tool sequence.
|
|
216
|
+
|
|
217
|
+
Pre-flight handles the same fresh-project cleanup fast mode does
|
|
218
|
+
(BUG-FULL-MODE-4): detects default tracks, deletes them down to one
|
|
219
|
+
survivor, loads the LivePilot Analyzer on master, sets the project
|
|
220
|
+
tempo. Then walks the plan's `plan` array sequentially, resolving
|
|
221
|
+
`$from_step` references against accumulated step results. After the
|
|
222
|
+
walk, deletes the leftover default track if it's still empty
|
|
223
|
+
(BUG-FULL-MODE-5).
|
|
224
|
+
|
|
225
|
+
BUG-FULL-MODE-14 fix: bridge handshake uses Applier's retry loop
|
|
226
|
+
(up to 3 attempts with 200ms gaps) so load_sample_to_simpler doesn't
|
|
227
|
+
race against the M4L JS listener still binding its UDP socket.
|
|
228
|
+
|
|
229
|
+
BUG-FULL-MODE-17 fix: Applier.postflight() sets monitoring=In on
|
|
230
|
+
every newly-created track and calls back_to_arranger so arrangement
|
|
231
|
+
clips play without requiring manual arm-button toggle.
|
|
232
|
+
|
|
233
|
+
`warp_strategy` (BUG-FULL-MODE-12, 2026-05-01) controls per-step
|
|
234
|
+
Simpler warping behavior:
|
|
235
|
+
- "always" (default): every loop warps to project tempo
|
|
236
|
+
- "smart": tonal layers always warp; drum/perc loops un-warped
|
|
237
|
+
when source/project ratio is musically meaningful (creates
|
|
238
|
+
creative tempo-mismatch chopping — J Dilla / Madlib territory)
|
|
239
|
+
- "chop": no warping anywhere (pure creative chopping)
|
|
240
|
+
"""
|
|
241
|
+
from .. import fast as fast_compose
|
|
242
|
+
|
|
243
|
+
started = time.time()
|
|
244
|
+
ableton = ctx.lifespan_context.get("ableton") if hasattr(ctx, "lifespan_context") else None
|
|
245
|
+
if ableton is None:
|
|
246
|
+
return {"error": "Ableton connection not available", "phase": "apply"}
|
|
247
|
+
|
|
248
|
+
plan_steps = plan_response.get("plan") or []
|
|
249
|
+
if not plan_steps:
|
|
250
|
+
return {"error": "plan.plan is empty — nothing to apply", "phase": "apply"}
|
|
251
|
+
|
|
252
|
+
# ── Pre-flight: Applier handles analyzer load + bridge handshake ────
|
|
253
|
+
# Fixes BUG-FULL-MODE-14 (bridge race): the Applier's retry loop pings
|
|
254
|
+
# the bridge with up to 3 attempts / 200ms gap so the M4L JS listener
|
|
255
|
+
# has time to bind its UDP socket before load_sample_to_simpler runs.
|
|
256
|
+
fresh_actions: list[str] = []
|
|
257
|
+
|
|
258
|
+
from ...tools.analyzer import (
|
|
259
|
+
ensure_analyzer_on_master as _ensure_analyzer,
|
|
260
|
+
reconnect_bridge as _reconnect_bridge,
|
|
261
|
+
)
|
|
262
|
+
from ...tools._analyzer_engine.context import _get_m4l
|
|
263
|
+
from ...tools.arrangement import back_to_arranger as _back_to_arranger
|
|
264
|
+
from ...tools.tracks import set_track_input_monitoring as _set_track_input_monitoring
|
|
265
|
+
|
|
266
|
+
async def _ensure_analyzer_async(c):
|
|
267
|
+
return _ensure_analyzer(c)
|
|
268
|
+
|
|
269
|
+
async def _reconnect_bridge_async(c):
|
|
270
|
+
resp = await _reconnect_bridge(c)
|
|
271
|
+
# reconnect_bridge returns {"ok": True} on success; normalize to
|
|
272
|
+
# {"connected": True} so Applier.preflight can use a unified key.
|
|
273
|
+
if isinstance(resp, dict) and resp.get("ok"):
|
|
274
|
+
resp = dict(resp)
|
|
275
|
+
resp["connected"] = True
|
|
276
|
+
return resp
|
|
277
|
+
|
|
278
|
+
async def _bridge_ping_async(c):
|
|
279
|
+
bridge = _get_m4l(c)
|
|
280
|
+
return await bridge.send_command("ping", timeout=0.5)
|
|
281
|
+
|
|
282
|
+
async def _set_monitoring_async(c, *, track_index, state):
|
|
283
|
+
return _set_track_input_monitoring(c, track_index=track_index, state=state)
|
|
284
|
+
|
|
285
|
+
async def _back_to_arranger_async(c):
|
|
286
|
+
return _back_to_arranger(c)
|
|
287
|
+
|
|
288
|
+
applier = Applier(
|
|
289
|
+
ensure_analyzer_fn=_ensure_analyzer_async,
|
|
290
|
+
reconnect_bridge_fn=_reconnect_bridge_async,
|
|
291
|
+
bridge_ping_fn=_bridge_ping_async,
|
|
292
|
+
set_track_input_monitoring_fn=_set_monitoring_async,
|
|
293
|
+
back_to_arranger_fn=_back_to_arranger_async,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
preflight_result = await applier.preflight(ctx)
|
|
297
|
+
if preflight_result.get("analyzer_status") in ("loaded", "already_loaded"):
|
|
298
|
+
fresh_actions.append("analyzer_loaded_on_master")
|
|
299
|
+
if preflight_result.get("bridge_connected"):
|
|
300
|
+
fresh_actions.append("bridge_connected")
|
|
301
|
+
else:
|
|
302
|
+
logger.debug(
|
|
303
|
+
"full apply: bridge handshake failed after %d attempt(s): %s",
|
|
304
|
+
preflight_result.get("handshake_attempts", 0),
|
|
305
|
+
preflight_result.get("handshake_error", "unknown"),
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
# ── Detect + clean default tracks ──────────────────────────────────
|
|
309
|
+
session = ableton.send_command("get_session_info", {})
|
|
310
|
+
starting_track_count = int(session.get("track_count", 0))
|
|
311
|
+
|
|
312
|
+
fresh_project = fast_compose.detect_fresh_project(session)
|
|
313
|
+
if fresh_project:
|
|
314
|
+
candidates: list[int] = []
|
|
315
|
+
for i in range(starting_track_count):
|
|
316
|
+
try:
|
|
317
|
+
ti = ableton.send_command("get_track_info", {"track_index": i})
|
|
318
|
+
if fast_compose.track_is_empty(ti):
|
|
319
|
+
candidates.append(i)
|
|
320
|
+
except Exception as exc:
|
|
321
|
+
logger.debug("full apply: fresh-check get_track_info(%s) failed: %s", i, exc)
|
|
322
|
+
|
|
323
|
+
if len(candidates) == starting_track_count and starting_track_count > 0:
|
|
324
|
+
fresh_actions.append(f"detected_fresh_project_{starting_track_count}_default_tracks")
|
|
325
|
+
# Leave one survivor — Ableton requires ≥1 track at all times
|
|
326
|
+
deletable = sorted(candidates, reverse=True)[:-1]
|
|
327
|
+
deleted = 0
|
|
328
|
+
for idx in deletable:
|
|
329
|
+
try:
|
|
330
|
+
ableton.send_command("delete_track", {"track_index": idx})
|
|
331
|
+
deleted += 1
|
|
332
|
+
except Exception as exc:
|
|
333
|
+
logger.debug("full apply: delete_track(%s) failed: %s", idx, exc)
|
|
334
|
+
if deleted:
|
|
335
|
+
fresh_actions.append(f"deleted_{deleted}_default_tracks_preflight")
|
|
336
|
+
|
|
337
|
+
# ── Walk plan steps ────────────────────────────────────────────────
|
|
338
|
+
step_results: dict[str, dict] = {}
|
|
339
|
+
step_outcomes: list[dict] = []
|
|
340
|
+
failed_count = 0
|
|
341
|
+
# Track indices of newly-created tracks for postflight monitoring fix
|
|
342
|
+
created_track_indices: list[int] = []
|
|
343
|
+
|
|
344
|
+
for i, step in enumerate(plan_steps):
|
|
345
|
+
tool_name = (step.get("tool") or "").strip()
|
|
346
|
+
params = step.get("params") or {}
|
|
347
|
+
step_id = step.get("step_id")
|
|
348
|
+
description = step.get("description") or ""
|
|
349
|
+
role = step.get("role")
|
|
350
|
+
|
|
351
|
+
# Resolve $from_step refs inside params
|
|
352
|
+
try:
|
|
353
|
+
resolved_params = _resolve_from_step(params, step_results)
|
|
354
|
+
except Exception as exc:
|
|
355
|
+
failed_count += 1
|
|
356
|
+
step_outcomes.append({
|
|
357
|
+
"index": i, "tool": tool_name, "step_id": step_id,
|
|
358
|
+
"description": description, "role": role,
|
|
359
|
+
"ok": False, "error": f"$from_step resolution failed: {exc}",
|
|
360
|
+
})
|
|
361
|
+
continue
|
|
362
|
+
|
|
363
|
+
# Dispatch
|
|
364
|
+
result: dict = {}
|
|
365
|
+
ok = True
|
|
366
|
+
err_msg: str | None = None
|
|
367
|
+
try:
|
|
368
|
+
if tool_name == "load_sample_to_simpler":
|
|
369
|
+
# Special-case: this is an MCP tool that wraps multi-step
|
|
370
|
+
# bridge work (verify, replace, hygiene). Call it as a
|
|
371
|
+
# Python function rather than a single TCP command.
|
|
372
|
+
#
|
|
373
|
+
# BUG-FULL-MODE-12: translate warp_strategy → per-step
|
|
374
|
+
# warp_loops bool, based on this layer's role + the
|
|
375
|
+
# source loop's BPM ratio against project tempo.
|
|
376
|
+
project_tempo = (plan_response.get("intent") or {}).get("tempo")
|
|
377
|
+
warp_loops_decision = _decide_warp_loops(
|
|
378
|
+
role=role or "",
|
|
379
|
+
file_path=str(resolved_params.get("file_path", "")),
|
|
380
|
+
project_tempo=project_tempo,
|
|
381
|
+
strategy=warp_strategy,
|
|
382
|
+
)
|
|
383
|
+
# Don't override an explicit warp_loops in the plan
|
|
384
|
+
# params (lets the planner — or a manual edit — pin a
|
|
385
|
+
# specific layer's warp setting regardless of strategy).
|
|
386
|
+
if "warp_loops" not in resolved_params:
|
|
387
|
+
resolved_params["warp_loops"] = warp_loops_decision
|
|
388
|
+
|
|
389
|
+
from ...tools.analyzer import load_sample_to_simpler as _load_sample
|
|
390
|
+
# The MCP tool is async — await it with the resolved kwargs.
|
|
391
|
+
result = await _load_sample(ctx, **resolved_params)
|
|
392
|
+
elif tool_name in ("create_midi_track", "create_audio_track"):
|
|
393
|
+
# Track the index so postflight can set monitoring on them
|
|
394
|
+
result = ableton.send_command(tool_name, resolved_params) or {}
|
|
395
|
+
if ok and isinstance(result, dict):
|
|
396
|
+
track_idx = result.get("track_index")
|
|
397
|
+
if track_idx is not None:
|
|
398
|
+
created_track_indices.append(int(track_idx))
|
|
399
|
+
elif tool_name in _FULL_PLAN_TCP_TOOLS:
|
|
400
|
+
# Direct Remote-Script TCP command
|
|
401
|
+
result = ableton.send_command(tool_name, resolved_params) or {}
|
|
402
|
+
else:
|
|
403
|
+
# Unknown tool — try generic TCP send (most LivePilot tools
|
|
404
|
+
# have a 1:1 Remote-Script handler with the same name).
|
|
405
|
+
result = ableton.send_command(tool_name, resolved_params) or {}
|
|
406
|
+
except Exception as exc:
|
|
407
|
+
ok = False
|
|
408
|
+
err_msg = str(exc)
|
|
409
|
+
failed_count += 1
|
|
410
|
+
logger.debug("full apply step %s (%s) failed: %s", i, tool_name, exc)
|
|
411
|
+
|
|
412
|
+
if step_id and ok:
|
|
413
|
+
step_results[step_id] = result if isinstance(result, dict) else {}
|
|
414
|
+
|
|
415
|
+
step_outcomes.append({
|
|
416
|
+
"index": i,
|
|
417
|
+
"tool": tool_name,
|
|
418
|
+
"step_id": step_id,
|
|
419
|
+
"description": description,
|
|
420
|
+
"role": role,
|
|
421
|
+
"ok": ok,
|
|
422
|
+
"error": err_msg,
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
# ── Post-flight cleanup (Item 5) ───────────────────────────────────
|
|
426
|
+
# BUG-FULL-MODE-8 (2026-05-01): the original implementation only
|
|
427
|
+
# checked tracks[0] for a default-name leftover. That worked for
|
|
428
|
+
# fast mode where new tracks are appended at the end (so the
|
|
429
|
+
# survivor stays at index 0), but full mode's planner creates
|
|
430
|
+
# tracks at SPECIFIC indices (0, 1, 2, 3, 4...) which pushes the
|
|
431
|
+
# survivor to index N. Fix: scan ALL tracks and prune every empty
|
|
432
|
+
# default-named one. Walk highest-to-lowest so deletions don't
|
|
433
|
+
# invalidate the indices below.
|
|
434
|
+
final_cleanup_actions: list[str] = []
|
|
435
|
+
try:
|
|
436
|
+
post_session = ableton.send_command("get_session_info", {})
|
|
437
|
+
tracks = post_session.get("tracks", []) or []
|
|
438
|
+
if tracks and len(tracks) > 1:
|
|
439
|
+
default_indices: list[int] = []
|
|
440
|
+
for i, t in enumerate(tracks):
|
|
441
|
+
if fast_compose.is_default_track_name(t.get("name", "")):
|
|
442
|
+
try:
|
|
443
|
+
ti = ableton.send_command("get_track_info", {"track_index": i})
|
|
444
|
+
if fast_compose.track_is_empty(ti):
|
|
445
|
+
default_indices.append(i)
|
|
446
|
+
except Exception as exc:
|
|
447
|
+
logger.debug("full apply: cleanup get_track_info(%s) failed: %s", i, exc)
|
|
448
|
+
# Delete highest-to-lowest so earlier deletions don't shift
|
|
449
|
+
# the indices we still need to delete.
|
|
450
|
+
for idx in sorted(default_indices, reverse=True):
|
|
451
|
+
# Don't delete if we'd end up with zero tracks
|
|
452
|
+
if len(tracks) - len(final_cleanup_actions) <= 1:
|
|
453
|
+
break
|
|
454
|
+
try:
|
|
455
|
+
ableton.send_command("delete_track", {"track_index": idx})
|
|
456
|
+
final_cleanup_actions.append(f"deleted_leftover_default_track_at_{idx}")
|
|
457
|
+
except Exception as exc:
|
|
458
|
+
logger.debug("full apply: final cleanup delete_track(%s) failed: %s", idx, exc)
|
|
459
|
+
except Exception as exc:
|
|
460
|
+
logger.debug("full apply: post-session read failed: %s", exc)
|
|
461
|
+
|
|
462
|
+
# ── Applier post-flight: monitoring + back_to_arranger ────────────
|
|
463
|
+
# Fixes BUG-FULL-MODE-17: set current_monitoring_state=In on every
|
|
464
|
+
# newly-created track so arrangement clips play without manual toggle.
|
|
465
|
+
postflight_result = await applier.postflight(ctx, applied_track_indices=created_track_indices)
|
|
466
|
+
|
|
467
|
+
duration_ms = int((time.time() - started) * 1000)
|
|
468
|
+
return {
|
|
469
|
+
"phase": "apply",
|
|
470
|
+
"mode": "full",
|
|
471
|
+
"steps_executed": len(step_outcomes),
|
|
472
|
+
"steps_failed": failed_count,
|
|
473
|
+
"step_outcomes": step_outcomes,
|
|
474
|
+
"fresh_project_actions": fresh_actions,
|
|
475
|
+
"final_cleanup_actions": final_cleanup_actions,
|
|
476
|
+
"postflight": postflight_result,
|
|
477
|
+
"duration_ms": duration_ms,
|
|
478
|
+
"summary": (
|
|
479
|
+
f"{len(step_outcomes)} steps walked, "
|
|
480
|
+
f"{failed_count} failed, "
|
|
481
|
+
f"{len(fresh_actions)} pre-flight action(s), "
|
|
482
|
+
f"{len(final_cleanup_actions)} cleanup action(s), "
|
|
483
|
+
f"{postflight_result.get('tracks_set', 0)} tracks monitoring=In"
|
|
484
|
+
),
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
# ── v1.24 LLM-creative two-phase flow ─────────────────────────────
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _validate_v2_plan(plan: dict) -> str | None:
|
|
492
|
+
"""Return error message if plan is invalid, else None."""
|
|
493
|
+
if not isinstance(plan, dict):
|
|
494
|
+
return "plan must be a dict"
|
|
495
|
+
if plan.get("scope") not in (None, "full"):
|
|
496
|
+
return f"plan scope must be 'full' (got {plan.get('scope')!r})"
|
|
497
|
+
form = plan.get("form")
|
|
498
|
+
if not isinstance(form, list) or len(form) == 0:
|
|
499
|
+
return "plan.form must be a non-empty list of section descriptors"
|
|
500
|
+
tracks = plan.get("tracks")
|
|
501
|
+
if not isinstance(tracks, list) or len(tracks) == 0:
|
|
502
|
+
return "plan.tracks must be a non-empty list"
|
|
503
|
+
for ti, t in enumerate(tracks):
|
|
504
|
+
if not isinstance(t, dict):
|
|
505
|
+
return f"tracks[{ti}] must be a dict"
|
|
506
|
+
variants = t.get("variants", [])
|
|
507
|
+
if not isinstance(variants, list):
|
|
508
|
+
return f"tracks[{ti}].variants must be a list"
|
|
509
|
+
for vi, v in enumerate(variants):
|
|
510
|
+
if not isinstance(v, dict):
|
|
511
|
+
return f"tracks[{ti}].variants[{vi}] must be a dict"
|
|
512
|
+
if "id" not in v:
|
|
513
|
+
return f"tracks[{ti}].variants[{vi}] missing 'id'"
|
|
514
|
+
arr_clips = t.get("arrangement_clips", [])
|
|
515
|
+
if not isinstance(arr_clips, list):
|
|
516
|
+
return f"tracks[{ti}].arrangement_clips must be a list"
|
|
517
|
+
variant_ids = {v["id"] for v in variants}
|
|
518
|
+
for ci, ac in enumerate(arr_clips):
|
|
519
|
+
if "variant_id" in ac and ac["variant_id"] not in variant_ids:
|
|
520
|
+
return (
|
|
521
|
+
f"tracks[{ti}].arrangement_clips[{ci}] references unknown "
|
|
522
|
+
f"variant_id {ac['variant_id']!r} (known: {sorted(variant_ids)})"
|
|
523
|
+
)
|
|
524
|
+
return None
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
async def apply_full_plan_v2(ctx: Context, plan: dict) -> dict:
|
|
528
|
+
"""Apply an agent-designed full-mode plan to the live session.
|
|
529
|
+
|
|
530
|
+
The agent designs form + variants + events from the brief returned by
|
|
531
|
+
compose(mode="full"); this function validates + executes. Replaces the
|
|
532
|
+
deterministic engine path that was prone to flat single-pattern
|
|
533
|
+
arrangements (BUG-FULL-MODE-18).
|
|
534
|
+
|
|
535
|
+
Plan shape:
|
|
536
|
+
{
|
|
537
|
+
"scope": "full", # optional, must be "full" if present
|
|
538
|
+
"tempo": 128.0, # optional — applied if differs from session
|
|
539
|
+
"key": "Am", # optional — passed to set_song_scale
|
|
540
|
+
"form": [ # REQUIRED — list of section descriptors
|
|
541
|
+
{"name": "intro", "start_bar": 0, "bars": 16},
|
|
542
|
+
{"name": "main", "start_bar": 16, "bars": 32},
|
|
543
|
+
...
|
|
544
|
+
],
|
|
545
|
+
"tracks": [ # REQUIRED — list of track specs
|
|
546
|
+
{
|
|
547
|
+
"role": "bass",
|
|
548
|
+
"track_index": 1, # OPTIONAL — reuse existing; create new if absent
|
|
549
|
+
"instrument": {"uri": "atlas://...", "params": {}}, # OPTIONAL
|
|
550
|
+
"variants": [ # list of source clip definitions
|
|
551
|
+
{"id": "main_v", "notes": [...]},
|
|
552
|
+
{"id": "build", "notes": [...]},
|
|
553
|
+
],
|
|
554
|
+
"arrangement_clips": [
|
|
555
|
+
{"section_index": 0, "variant_id": "main_v", "loop_length": 4.0},
|
|
556
|
+
...
|
|
557
|
+
],
|
|
558
|
+
},
|
|
559
|
+
...
|
|
560
|
+
],
|
|
561
|
+
"events": [...], # Phase 4 structural events — accepted, not applied in Phase 3
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
Returns:
|
|
565
|
+
{
|
|
566
|
+
"status": "ok" | "partial" | "error",
|
|
567
|
+
"tracks_created": int,
|
|
568
|
+
"variants_created": int,
|
|
569
|
+
"arrangement_clips_created": int,
|
|
570
|
+
"events_applied": int,
|
|
571
|
+
"preflight": dict,
|
|
572
|
+
"postflight": dict,
|
|
573
|
+
"errors": list[dict],
|
|
574
|
+
"duration_ms": int,
|
|
575
|
+
}
|
|
576
|
+
"""
|
|
577
|
+
started = time.time()
|
|
578
|
+
|
|
579
|
+
err = _validate_v2_plan(plan)
|
|
580
|
+
if err:
|
|
581
|
+
return {"status": "error", "error": err, "phase": "validate"}
|
|
582
|
+
|
|
583
|
+
ableton = ctx.lifespan_context.get("ableton") if hasattr(ctx, "lifespan_context") else None
|
|
584
|
+
if ableton is None:
|
|
585
|
+
return {"status": "error", "error": "ableton client not available", "phase": "setup"}
|
|
586
|
+
|
|
587
|
+
# ── Build Applier from develop stubs ──────────────────────────────
|
|
588
|
+
from ..develop.apply import (
|
|
589
|
+
_ensure_analyzer_stub,
|
|
590
|
+
_reconnect_bridge_stub,
|
|
591
|
+
_bridge_ping_stub,
|
|
592
|
+
_back_to_arranger,
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
async def _set_track_input_monitoring(c, *, track_index, state):
|
|
596
|
+
ab = c.lifespan_context.get("ableton") if hasattr(c, "lifespan_context") else None
|
|
597
|
+
if ab is None:
|
|
598
|
+
return {"ok": False}
|
|
599
|
+
try:
|
|
600
|
+
return ab.send_command(
|
|
601
|
+
"set_track_input_monitoring",
|
|
602
|
+
{"track_index": track_index, "state": state},
|
|
603
|
+
)
|
|
604
|
+
except Exception:
|
|
605
|
+
return {"ok": False}
|
|
606
|
+
|
|
607
|
+
applier = Applier(
|
|
608
|
+
ensure_analyzer_fn=_ensure_analyzer_stub,
|
|
609
|
+
reconnect_bridge_fn=_reconnect_bridge_stub,
|
|
610
|
+
bridge_ping_fn=_bridge_ping_stub,
|
|
611
|
+
set_track_input_monitoring_fn=_set_track_input_monitoring,
|
|
612
|
+
back_to_arranger_fn=_back_to_arranger,
|
|
613
|
+
handshake_max_attempts=3,
|
|
614
|
+
handshake_gap_seconds=0.2,
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
preflight_result = await applier.preflight(ctx)
|
|
618
|
+
|
|
619
|
+
# ── Phase 4 Task 19: default-track auto-cleanup (parity with fast mode preflight)
|
|
620
|
+
# Detect fresh-project state and delete the default Ableton tracks
|
|
621
|
+
# (1-MIDI, 2-MIDI, 3-Audio, etc.) so the new compose-created tracks
|
|
622
|
+
# don't sit alongside leftover empties.
|
|
623
|
+
fresh_cleanup_actions: list[str] = []
|
|
624
|
+
try:
|
|
625
|
+
from ..fast.brief_builder import detect_fresh_project, is_default_track_name
|
|
626
|
+
session_preflight = ableton.send_command("get_session_info", {})
|
|
627
|
+
if detect_fresh_project(session_preflight):
|
|
628
|
+
# Delete default tracks in REVERSE order to keep indices stable.
|
|
629
|
+
# Ableton requires at least 1 track — keep the lowest-indexed default.
|
|
630
|
+
default_indices = sorted(
|
|
631
|
+
[
|
|
632
|
+
t["index"]
|
|
633
|
+
for t in session_preflight.get("tracks", [])
|
|
634
|
+
if is_default_track_name(t.get("name", ""))
|
|
635
|
+
],
|
|
636
|
+
reverse=True,
|
|
637
|
+
)
|
|
638
|
+
# Drop the LAST element (lowest index) so 1 track survives.
|
|
639
|
+
if len(default_indices) > 1:
|
|
640
|
+
for idx in default_indices[:-1]:
|
|
641
|
+
try:
|
|
642
|
+
ableton.send_command("delete_track", {"track_index": idx})
|
|
643
|
+
fresh_cleanup_actions.append(f"deleted_default_track_{idx}")
|
|
644
|
+
except Exception as exc:
|
|
645
|
+
logger.debug("apply_full_v2: delete_track(%d) failed: %s", idx, exc)
|
|
646
|
+
except Exception as exc:
|
|
647
|
+
logger.debug("apply_full_v2: fresh-project cleanup skipped: %s", exc)
|
|
648
|
+
|
|
649
|
+
# ── Tempo + key application ───────────────────────────────────────
|
|
650
|
+
plan_tempo = plan.get("tempo")
|
|
651
|
+
plan_key = plan.get("key")
|
|
652
|
+
if plan_tempo is not None:
|
|
653
|
+
try:
|
|
654
|
+
session = ableton.send_command("get_session_info", {})
|
|
655
|
+
current_tempo = float(session.get("tempo", 0.0))
|
|
656
|
+
if abs(current_tempo - float(plan_tempo)) > 0.01:
|
|
657
|
+
ableton.send_command("set_tempo", {"tempo": float(plan_tempo)})
|
|
658
|
+
except Exception as exc:
|
|
659
|
+
logger.warning("apply_full_v2: tempo set failed: %s", exc)
|
|
660
|
+
if plan_key:
|
|
661
|
+
try:
|
|
662
|
+
ableton.send_command("set_song_scale", {"root_note": plan_key})
|
|
663
|
+
except Exception as exc:
|
|
664
|
+
logger.debug("apply_full_v2: set_song_scale skipped: %s", exc)
|
|
665
|
+
|
|
666
|
+
form = plan["form"]
|
|
667
|
+
tracks_created = 0
|
|
668
|
+
variants_created = 0
|
|
669
|
+
arrangement_clips_created = 0
|
|
670
|
+
events_applied = 0
|
|
671
|
+
effects_loaded = 0
|
|
672
|
+
sends_set = 0
|
|
673
|
+
errors: list[dict] = []
|
|
674
|
+
applied_track_indices: list[int] = []
|
|
675
|
+
layer_analyses: list[dict] = [] # Phase 4 Task 20: per-layer analysis results
|
|
676
|
+
|
|
677
|
+
for ti, track_spec in enumerate(plan["tracks"]):
|
|
678
|
+
# Resolve track_index — create new if not provided
|
|
679
|
+
track_index = track_spec.get("track_index")
|
|
680
|
+
if track_index is None:
|
|
681
|
+
try:
|
|
682
|
+
result = ableton.send_command(
|
|
683
|
+
"create_midi_track",
|
|
684
|
+
{"index": -1, "name": track_spec.get("role", "")},
|
|
685
|
+
)
|
|
686
|
+
# BUG-FIX (post-v1.24-Task-14 live test): create_midi_track
|
|
687
|
+
# returns {"index": N}, NOT {"track_index": N}. The mocks
|
|
688
|
+
# used "track_index" but the real Remote Script uses "index".
|
|
689
|
+
# Fall back through both keys so legacy mocks still work.
|
|
690
|
+
track_index = int(result.get("index", result.get("track_index", -1)))
|
|
691
|
+
if track_index >= 0:
|
|
692
|
+
tracks_created += 1
|
|
693
|
+
applied_track_indices.append(track_index)
|
|
694
|
+
except Exception as exc:
|
|
695
|
+
errors.append({"track_index": ti, "phase": "create_track", "reason": str(exc)})
|
|
696
|
+
continue
|
|
697
|
+
else:
|
|
698
|
+
track_index = int(track_index)
|
|
699
|
+
|
|
700
|
+
# Optional instrument load
|
|
701
|
+
instrument = track_spec.get("instrument") or {}
|
|
702
|
+
if instrument.get("uri"):
|
|
703
|
+
try:
|
|
704
|
+
ableton.send_command(
|
|
705
|
+
"load_browser_item",
|
|
706
|
+
{"track_index": track_index, "uri": instrument["uri"]},
|
|
707
|
+
)
|
|
708
|
+
except Exception as exc:
|
|
709
|
+
errors.append({
|
|
710
|
+
"track_index": track_index,
|
|
711
|
+
"phase": "load_instrument",
|
|
712
|
+
"reason": str(exc),
|
|
713
|
+
})
|
|
714
|
+
|
|
715
|
+
# Phase 4 Task 21: drum-role auto-repair (parity with fast mode).
|
|
716
|
+
# load_browser_item's role='drum' silently fails to apply Vol=0/Snap=Off/root=C1
|
|
717
|
+
# when the track context has effects/sends. Re-apply deterministically post-load.
|
|
718
|
+
# Volume=0, Snap=Off, Transpose=+24 compensates for Simpler default C3=60 root
|
|
719
|
+
# vs drum-rack convention C1=36. Device-class-aware: drum synths (DS Kick etc)
|
|
720
|
+
# don't have Snap/Transpose, so only Volume gets set there.
|
|
721
|
+
from ..fast.apply import _is_drum_role, _apply_drum_role_repair
|
|
722
|
+
track_role = track_spec.get("role", "")
|
|
723
|
+
if _is_drum_role(track_role):
|
|
724
|
+
try:
|
|
725
|
+
_apply_drum_role_repair(ableton, track_index, device_index=0)
|
|
726
|
+
except Exception as exc:
|
|
727
|
+
logger.debug(
|
|
728
|
+
"apply_full_v2: drum_role_repair failed for track %s: %s",
|
|
729
|
+
track_index, exc,
|
|
730
|
+
)
|
|
731
|
+
|
|
732
|
+
# Phase 4 Task 19: per-layer effects (parity with fast mode)
|
|
733
|
+
# Insert each native device AFTER the instrument load and BEFORE clip
|
|
734
|
+
# creation so the chain is correct from the start.
|
|
735
|
+
for effect_spec in track_spec.get("effects", []) or []:
|
|
736
|
+
device_name = (effect_spec.get("device") or "").strip()
|
|
737
|
+
if not device_name:
|
|
738
|
+
continue
|
|
739
|
+
try:
|
|
740
|
+
ins_resp = ableton.send_command("insert_device", {
|
|
741
|
+
"track_index": track_index,
|
|
742
|
+
"device_name": device_name,
|
|
743
|
+
}) or {}
|
|
744
|
+
# insert_device returns device_index directly (Remote Script bakes it in).
|
|
745
|
+
device_index = ins_resp.get("device_index")
|
|
746
|
+
if device_index is None:
|
|
747
|
+
# Fallback: query track and take the last device
|
|
748
|
+
try:
|
|
749
|
+
track_info = ableton.send_command(
|
|
750
|
+
"get_track_info", {"track_index": track_index}
|
|
751
|
+
)
|
|
752
|
+
device_index = len(track_info.get("devices", [])) - 1
|
|
753
|
+
except Exception:
|
|
754
|
+
pass
|
|
755
|
+
for param_name, param_value in (effect_spec.get("params") or {}).items():
|
|
756
|
+
try:
|
|
757
|
+
ableton.send_command("set_device_parameter", {
|
|
758
|
+
"track_index": track_index,
|
|
759
|
+
"device_index": int(device_index),
|
|
760
|
+
"parameter_name": str(param_name),
|
|
761
|
+
"value": float(param_value),
|
|
762
|
+
})
|
|
763
|
+
except Exception as exc:
|
|
764
|
+
logger.debug(
|
|
765
|
+
"apply_full_v2: set_device_parameter(%s.%s) failed: %s",
|
|
766
|
+
device_name, param_name, exc,
|
|
767
|
+
)
|
|
768
|
+
effects_loaded += 1
|
|
769
|
+
except Exception as exc:
|
|
770
|
+
errors.append({
|
|
771
|
+
"track_index": track_index,
|
|
772
|
+
"phase": f"effect_{device_name}",
|
|
773
|
+
"reason": str(exc),
|
|
774
|
+
})
|
|
775
|
+
|
|
776
|
+
# Phase 4 Task 19: per-layer sends (parity with fast mode)
|
|
777
|
+
# Resolve return_name → send_index via session.return_tracks (case-insensitive).
|
|
778
|
+
for send_spec in track_spec.get("sends", []) or []:
|
|
779
|
+
return_name = (send_spec.get("return_name") or "").strip()
|
|
780
|
+
value = send_spec.get("value")
|
|
781
|
+
send_index = send_spec.get("send_index")
|
|
782
|
+
if return_name is None and send_index is None:
|
|
783
|
+
continue
|
|
784
|
+
try:
|
|
785
|
+
value = float(value or 0.0)
|
|
786
|
+
except (TypeError, ValueError):
|
|
787
|
+
continue
|
|
788
|
+
if send_index is None and return_name:
|
|
789
|
+
try:
|
|
790
|
+
session_info = ableton.send_command("get_session_info", {})
|
|
791
|
+
return_tracks = session_info.get("return_tracks", []) or []
|
|
792
|
+
for i, rt in enumerate(return_tracks):
|
|
793
|
+
if (rt.get("name") or "").lower() == return_name.lower():
|
|
794
|
+
send_index = i
|
|
795
|
+
break
|
|
796
|
+
except Exception as exc:
|
|
797
|
+
logger.debug("apply_full_v2: return_tracks lookup failed: %s", exc)
|
|
798
|
+
if send_index is None:
|
|
799
|
+
logger.debug(
|
|
800
|
+
"apply_full_v2: return_name %r not found in session, skipping send",
|
|
801
|
+
return_name,
|
|
802
|
+
)
|
|
803
|
+
# Still record as error so caller knows resolution failed
|
|
804
|
+
errors.append({
|
|
805
|
+
"track_index": track_index,
|
|
806
|
+
"phase": f"send_{return_name}",
|
|
807
|
+
"reason": "return track not found",
|
|
808
|
+
})
|
|
809
|
+
continue
|
|
810
|
+
try:
|
|
811
|
+
ableton.send_command("set_track_send", {
|
|
812
|
+
"track_index": track_index,
|
|
813
|
+
"send_index": int(send_index),
|
|
814
|
+
"value": value,
|
|
815
|
+
})
|
|
816
|
+
sends_set += 1
|
|
817
|
+
except Exception as exc:
|
|
818
|
+
errors.append({
|
|
819
|
+
"track_index": track_index,
|
|
820
|
+
"phase": f"send_{return_name}",
|
|
821
|
+
"reason": str(exc),
|
|
822
|
+
})
|
|
823
|
+
|
|
824
|
+
# Phase 4 Task 20: per-layer static analysis (best-effort, non-fatal).
|
|
825
|
+
# Goal: give the agent acoustic characteristics of the loaded sound so
|
|
826
|
+
# it can reason about fit. ONLY static analysis here (no playback);
|
|
827
|
+
# active solo-trigger analysis is Scope B / v1.25.
|
|
828
|
+
# Analysis is routed through ableton.send_command so it is intercepted
|
|
829
|
+
# by the same mock contract as all other commands (testable, consistent).
|
|
830
|
+
role = track_spec.get("role", "")
|
|
831
|
+
instrument_uri = (track_spec.get("instrument") or {}).get("uri", "")
|
|
832
|
+
layer_analysis: dict = {"status": "skipped", "reason": "no analyzer applicable"}
|
|
833
|
+
try:
|
|
834
|
+
if instrument_uri.startswith(("query:Synths#", "query:Sounds#")):
|
|
835
|
+
# Synth / preset — analyze the patch
|
|
836
|
+
try:
|
|
837
|
+
patch_result = ableton.send_command(
|
|
838
|
+
"analyze_synth_patch",
|
|
839
|
+
{"track_index": track_index, "device_index": 0},
|
|
840
|
+
)
|
|
841
|
+
if isinstance(patch_result, dict) and not patch_result.get("error"):
|
|
842
|
+
layer_analysis = {"status": "ok", "kind": "synth", "data": patch_result}
|
|
843
|
+
else:
|
|
844
|
+
layer_analysis = {
|
|
845
|
+
"status": "skipped",
|
|
846
|
+
"kind": "synth",
|
|
847
|
+
"reason": str(
|
|
848
|
+
patch_result.get("error") if isinstance(patch_result, dict) else patch_result
|
|
849
|
+
),
|
|
850
|
+
}
|
|
851
|
+
except Exception as exc:
|
|
852
|
+
layer_analysis = {"status": "error", "kind": "synth", "reason": str(exc)}
|
|
853
|
+
elif instrument_uri.startswith(("query:Drums#", "query:Samples#")) or \
|
|
854
|
+
any(instrument_uri.lower().endswith(ext) for ext in (".aif", ".wav", ".mp3", ".flac")):
|
|
855
|
+
# Sample-based — analyze via track reference (no file_path needed)
|
|
856
|
+
try:
|
|
857
|
+
sample_result = ableton.send_command(
|
|
858
|
+
"analyze_sample",
|
|
859
|
+
{"track_index": track_index, "clip_index": 0},
|
|
860
|
+
)
|
|
861
|
+
if isinstance(sample_result, dict) and not sample_result.get("error"):
|
|
862
|
+
layer_analysis = {"status": "ok", "kind": "sample", "data": sample_result}
|
|
863
|
+
else:
|
|
864
|
+
layer_analysis = {
|
|
865
|
+
"status": "skipped",
|
|
866
|
+
"kind": "sample",
|
|
867
|
+
"reason": str(
|
|
868
|
+
sample_result.get("error") if isinstance(sample_result, dict) else sample_result
|
|
869
|
+
),
|
|
870
|
+
}
|
|
871
|
+
except Exception as exc:
|
|
872
|
+
layer_analysis = {"status": "error", "kind": "sample", "reason": str(exc)}
|
|
873
|
+
# else: Drum Rack containers, plain URIs, or no instrument — leave as "skipped"
|
|
874
|
+
except Exception as exc:
|
|
875
|
+
layer_analysis = {"status": "error", "reason": str(exc)}
|
|
876
|
+
|
|
877
|
+
layer_analyses.append({
|
|
878
|
+
"track_index": track_index,
|
|
879
|
+
"role": role,
|
|
880
|
+
"uri": instrument_uri,
|
|
881
|
+
"analysis": layer_analysis,
|
|
882
|
+
})
|
|
883
|
+
|
|
884
|
+
# Variants → session source clips (slots 0..N)
|
|
885
|
+
variant_id_to_slot: dict[str, int] = {}
|
|
886
|
+
for vi, variant in enumerate(track_spec.get("variants", [])):
|
|
887
|
+
slot = vi
|
|
888
|
+
variant_id_to_slot[variant["id"]] = slot
|
|
889
|
+
try:
|
|
890
|
+
ableton.send_command("create_clip", {
|
|
891
|
+
"track_index": track_index,
|
|
892
|
+
"clip_index": slot,
|
|
893
|
+
"length": 4.0,
|
|
894
|
+
})
|
|
895
|
+
if variant.get("notes"):
|
|
896
|
+
ableton.send_command("add_notes", {
|
|
897
|
+
"track_index": track_index,
|
|
898
|
+
"clip_index": slot,
|
|
899
|
+
"notes": variant["notes"],
|
|
900
|
+
})
|
|
901
|
+
ableton.send_command("set_clip_name", {
|
|
902
|
+
"track_index": track_index,
|
|
903
|
+
"clip_index": slot,
|
|
904
|
+
"name": variant["id"],
|
|
905
|
+
})
|
|
906
|
+
variants_created += 1
|
|
907
|
+
except Exception as exc:
|
|
908
|
+
errors.append({
|
|
909
|
+
"track_index": track_index,
|
|
910
|
+
"phase": f"variant_{variant['id']}",
|
|
911
|
+
"reason": str(exc),
|
|
912
|
+
})
|
|
913
|
+
|
|
914
|
+
# Arrangement clips — Phase 4 Task 23 (BUG-FULL-MODE-23):
|
|
915
|
+
# Use create_native_arrangement_clip (Live 12.1.10+ API) instead of
|
|
916
|
+
# create_arrangement_clip (which duplicates a session clip and tiles).
|
|
917
|
+
# Old flow produced N tiny clips per section (32 × 4-beat duplicates
|
|
918
|
+
# for a 16-bar section). New flow creates ONE long native arrangement
|
|
919
|
+
# clip per section, writes variant notes into it, then sets an internal
|
|
920
|
+
# loop region so the pattern repeats inside the section length.
|
|
921
|
+
for ac in track_spec.get("arrangement_clips", []):
|
|
922
|
+
section_index = ac.get("section_index")
|
|
923
|
+
if section_index is None or section_index >= len(form):
|
|
924
|
+
errors.append({
|
|
925
|
+
"track_index": track_index,
|
|
926
|
+
"phase": "arrangement_clip",
|
|
927
|
+
"reason": f"invalid section_index {section_index}",
|
|
928
|
+
})
|
|
929
|
+
continue
|
|
930
|
+
section = form[section_index]
|
|
931
|
+
variant_id = ac.get("variant_id")
|
|
932
|
+
|
|
933
|
+
# Look up the variant's notes for the new flow.
|
|
934
|
+
# (The session-view source clips created earlier are kept for
|
|
935
|
+
# auditioning — they are NOT referenced here. The arrangement is
|
|
936
|
+
# now self-contained via add_arrangement_notes.)
|
|
937
|
+
variant_notes = next(
|
|
938
|
+
(v.get("notes", []) for v in track_spec.get("variants", []) if v.get("id") == variant_id),
|
|
939
|
+
None,
|
|
940
|
+
)
|
|
941
|
+
if variant_notes is None:
|
|
942
|
+
errors.append({
|
|
943
|
+
"track_index": track_index,
|
|
944
|
+
"phase": "arrangement_clip",
|
|
945
|
+
"reason": f"unknown variant_id {variant_id!r}",
|
|
946
|
+
})
|
|
947
|
+
continue
|
|
948
|
+
|
|
949
|
+
start_bar = float(section["start_bar"])
|
|
950
|
+
bars = float(section["bars"])
|
|
951
|
+
section_length_beats = bars * 4.0
|
|
952
|
+
section_start_beats = start_bar * 4.0
|
|
953
|
+
|
|
954
|
+
# Compute source pattern length from notes (snap up to nearest bar).
|
|
955
|
+
source_length_beats = max(
|
|
956
|
+
(float(n.get("start_time", 0)) + float(n.get("duration", 0)) for n in variant_notes),
|
|
957
|
+
default=4.0,
|
|
958
|
+
)
|
|
959
|
+
source_length_beats = max(4.0, ((source_length_beats + 3.99) // 4) * 4)
|
|
960
|
+
|
|
961
|
+
# NEW FLOW: create ONE native arrangement clip spanning the full
|
|
962
|
+
# section. Replaces create_arrangement_clip (session-clip duplication).
|
|
963
|
+
try:
|
|
964
|
+
native_resp = ableton.send_command("create_native_arrangement_clip", {
|
|
965
|
+
"track_index": track_index,
|
|
966
|
+
"start_time": section_start_beats,
|
|
967
|
+
"length": section_length_beats,
|
|
968
|
+
"name": variant_id or section.get("name", ""),
|
|
969
|
+
})
|
|
970
|
+
if not isinstance(native_resp, dict):
|
|
971
|
+
errors.append({
|
|
972
|
+
"track_index": track_index,
|
|
973
|
+
"phase": "arrangement_clip",
|
|
974
|
+
"reason": "create_native_arrangement_clip returned non-dict",
|
|
975
|
+
})
|
|
976
|
+
continue
|
|
977
|
+
new_clip_index = native_resp.get("clip_index")
|
|
978
|
+
if new_clip_index is None:
|
|
979
|
+
errors.append({
|
|
980
|
+
"track_index": track_index,
|
|
981
|
+
"phase": "arrangement_clip",
|
|
982
|
+
"reason": "create_native_arrangement_clip didn't return clip_index",
|
|
983
|
+
})
|
|
984
|
+
continue
|
|
985
|
+
|
|
986
|
+
# Write the variant's notes into the native clip (relative to
|
|
987
|
+
# clip start — same coordinate space the agent used).
|
|
988
|
+
if variant_notes:
|
|
989
|
+
try:
|
|
990
|
+
ableton.send_command("add_arrangement_notes", {
|
|
991
|
+
"track_index": track_index,
|
|
992
|
+
"clip_index": new_clip_index,
|
|
993
|
+
"notes": variant_notes,
|
|
994
|
+
})
|
|
995
|
+
except Exception as exc:
|
|
996
|
+
errors.append({
|
|
997
|
+
"track_index": track_index,
|
|
998
|
+
"phase": "arrangement_notes",
|
|
999
|
+
"reason": str(exc),
|
|
1000
|
+
})
|
|
1001
|
+
|
|
1002
|
+
# Set internal loop region so the pattern repeats within the
|
|
1003
|
+
# full section length (e.g. a 4-beat kick loops 16× in a 64-beat
|
|
1004
|
+
# verse section without requiring 64 beats of notes).
|
|
1005
|
+
try:
|
|
1006
|
+
ableton.send_command("set_clip_loop", {
|
|
1007
|
+
"track_index": track_index,
|
|
1008
|
+
"clip_index": new_clip_index,
|
|
1009
|
+
"enabled": True,
|
|
1010
|
+
"loop_start": 0.0,
|
|
1011
|
+
"loop_end": source_length_beats,
|
|
1012
|
+
})
|
|
1013
|
+
except Exception as exc:
|
|
1014
|
+
logger.debug(
|
|
1015
|
+
"apply_full_v2: set_clip_loop failed for track %s clip %s: %s",
|
|
1016
|
+
track_index, new_clip_index, exc,
|
|
1017
|
+
)
|
|
1018
|
+
|
|
1019
|
+
arrangement_clips_created += 1
|
|
1020
|
+
except Exception as exc:
|
|
1021
|
+
errors.append({
|
|
1022
|
+
"track_index": track_index,
|
|
1023
|
+
"phase": "arrangement_clip",
|
|
1024
|
+
"reason": str(exc),
|
|
1025
|
+
})
|
|
1026
|
+
|
|
1027
|
+
# Events — Phase 4 will populate real apply paths; Phase 3 stubs this
|
|
1028
|
+
for _event in plan.get("events", []):
|
|
1029
|
+
pass # Phase 3 no-op — events accepted but not applied
|
|
1030
|
+
|
|
1031
|
+
# Phase 4 Task 20: mix-level analysis post-apply. Master-spectrum view +
|
|
1032
|
+
# cross-layer masking detection. Best-effort, non-fatal — same pattern as
|
|
1033
|
+
# per-layer analysis. Runs AFTER all tracks are loaded so it sees the full
|
|
1034
|
+
# session state.
|
|
1035
|
+
mix_analysis: dict = {"status": "skipped", "reason": "not run"}
|
|
1036
|
+
try:
|
|
1037
|
+
mix_result = ableton.send_command("analyze_mix", {})
|
|
1038
|
+
if isinstance(mix_result, dict) and not mix_result.get("error"):
|
|
1039
|
+
try:
|
|
1040
|
+
masking_result = ableton.send_command("get_masking_report", {})
|
|
1041
|
+
except Exception as mask_exc:
|
|
1042
|
+
masking_result = {"error": str(mask_exc)}
|
|
1043
|
+
mix_analysis = {
|
|
1044
|
+
"status": "ok",
|
|
1045
|
+
"mix": mix_result,
|
|
1046
|
+
"masking": masking_result if isinstance(masking_result, dict) else None,
|
|
1047
|
+
}
|
|
1048
|
+
else:
|
|
1049
|
+
mix_analysis = {
|
|
1050
|
+
"status": "skipped",
|
|
1051
|
+
"reason": str(
|
|
1052
|
+
mix_result.get("error") if isinstance(mix_result, dict) else mix_result
|
|
1053
|
+
),
|
|
1054
|
+
}
|
|
1055
|
+
except Exception as exc:
|
|
1056
|
+
mix_analysis = {"status": "error", "reason": str(exc)}
|
|
1057
|
+
|
|
1058
|
+
# Phase 4 Task 21: postflight default-track + zombie cleanup.
|
|
1059
|
+
# The preflight cleanup keeps ≥1 default track (Ableton's minimum). Now
|
|
1060
|
+
# that compose tracks exist, the leftover default(s) can be safely deleted.
|
|
1061
|
+
#
|
|
1062
|
+
# BUG-FIX (post-live test): also delete TRUE ZOMBIE tracks — empty MIDI
|
|
1063
|
+
# tracks (no clips, no instrument) regardless of name. The previous
|
|
1064
|
+
# implementation only deleted "default-named" tracks (1-MIDI etc.), so
|
|
1065
|
+
# a leftover from a previous compose run named "kick" / "bass" / etc.
|
|
1066
|
+
# would survive cleanup.
|
|
1067
|
+
try:
|
|
1068
|
+
from ..fast.brief_builder import is_default_track_name as _is_default_track_name
|
|
1069
|
+
session_post = ableton.send_command("get_session_info", {})
|
|
1070
|
+
all_tracks = session_post.get("tracks", [])
|
|
1071
|
+
# Skip tracks we just created — only target leftover/zombie tracks
|
|
1072
|
+
compose_track_indices = set(applied_track_indices)
|
|
1073
|
+
|
|
1074
|
+
candidate_indices: list[int] = []
|
|
1075
|
+
for t in all_tracks:
|
|
1076
|
+
idx = t.get("index", -1)
|
|
1077
|
+
if idx in compose_track_indices:
|
|
1078
|
+
continue
|
|
1079
|
+
name = t.get("name", "")
|
|
1080
|
+
# Default-named (1-MIDI, 2-MIDI, etc.) — always delete
|
|
1081
|
+
if _is_default_track_name(name):
|
|
1082
|
+
candidate_indices.append(idx)
|
|
1083
|
+
continue
|
|
1084
|
+
# Zombie detection: track has NO clips AND NO instrument.
|
|
1085
|
+
# Indicates a leftover from a previous compose run that the
|
|
1086
|
+
# default-name detector missed.
|
|
1087
|
+
try:
|
|
1088
|
+
track_info = ableton.send_command("get_track_info", {"track_index": idx})
|
|
1089
|
+
devices = track_info.get("devices", []) or []
|
|
1090
|
+
clip_slots = track_info.get("clip_slots", []) or []
|
|
1091
|
+
has_instrument = any(
|
|
1092
|
+
d.get("type") == 1 or # type=1 is instrument category in Live's LOM
|
|
1093
|
+
d.get("class_name", "") in (
|
|
1094
|
+
"OriginalSimpler", "DrumGroupDevice", "InstrumentGroupDevice",
|
|
1095
|
+
"DrumCell", "InstrumentImpulse", "MxDeviceInstrument",
|
|
1096
|
+
)
|
|
1097
|
+
for d in devices
|
|
1098
|
+
)
|
|
1099
|
+
has_clips = any(slot.get("has_clip") for slot in clip_slots)
|
|
1100
|
+
if not has_instrument and not has_clips:
|
|
1101
|
+
candidate_indices.append(idx)
|
|
1102
|
+
except Exception as exc:
|
|
1103
|
+
logger.debug("apply_full_v2: zombie-detect get_track_info(%s) failed: %s", idx, exc)
|
|
1104
|
+
|
|
1105
|
+
# Delete in reverse-index order so indices stay stable
|
|
1106
|
+
for idx in sorted(candidate_indices, reverse=True):
|
|
1107
|
+
try:
|
|
1108
|
+
ableton.send_command("delete_track", {"track_index": idx})
|
|
1109
|
+
fresh_cleanup_actions.append(f"postflight_deleted_track_{idx}")
|
|
1110
|
+
except Exception as exc:
|
|
1111
|
+
logger.debug("apply_full_v2: postflight delete_track(%s) failed: %s", idx, exc)
|
|
1112
|
+
except Exception as exc:
|
|
1113
|
+
logger.debug("apply_full_v2: postflight cleanup skipped: %s", exc)
|
|
1114
|
+
|
|
1115
|
+
# Postflight — sets monitoring=In on new tracks + back_to_arranger
|
|
1116
|
+
postflight_result = await applier.postflight(
|
|
1117
|
+
ctx,
|
|
1118
|
+
applied_track_indices=applied_track_indices,
|
|
1119
|
+
)
|
|
1120
|
+
|
|
1121
|
+
ok_count = variants_created + arrangement_clips_created
|
|
1122
|
+
status = "ok" if not errors else ("partial" if ok_count > 0 else "error")
|
|
1123
|
+
|
|
1124
|
+
return {
|
|
1125
|
+
"status": status,
|
|
1126
|
+
"tracks_created": tracks_created,
|
|
1127
|
+
"variants_created": variants_created,
|
|
1128
|
+
"arrangement_clips_created": arrangement_clips_created,
|
|
1129
|
+
"events_applied": events_applied,
|
|
1130
|
+
"effects_loaded": effects_loaded,
|
|
1131
|
+
"sends_set": sends_set,
|
|
1132
|
+
"fresh_cleanup_actions": fresh_cleanup_actions,
|
|
1133
|
+
"layer_analysis": layer_analyses, # Phase 4 Task 20: per-layer static analysis
|
|
1134
|
+
"mix_analysis": mix_analysis, # Phase 4 Task 20: mix-level masking + balance
|
|
1135
|
+
"preflight": preflight_result,
|
|
1136
|
+
"postflight": postflight_result,
|
|
1137
|
+
"errors": errors,
|
|
1138
|
+
"duration_ms": int((time.time() - started) * 1000),
|
|
1139
|
+
}
|