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.
Files changed (42) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +59 -13
  3. package/mcp_server/__init__.py +1 -1
  4. package/mcp_server/atlas/__init__.py +17 -3
  5. package/mcp_server/audit/__init__.py +6 -0
  6. package/mcp_server/audit/checks.py +618 -0
  7. package/mcp_server/audit/tools.py +232 -0
  8. package/mcp_server/composer/branch_producer.py +5 -2
  9. package/mcp_server/composer/develop/__init__.py +19 -0
  10. package/mcp_server/composer/develop/apply.py +217 -0
  11. package/mcp_server/composer/develop/brief_builder.py +269 -0
  12. package/mcp_server/composer/develop/seed_introspector.py +195 -0
  13. package/mcp_server/composer/engine.py +15 -521
  14. package/mcp_server/composer/fast/__init__.py +62 -0
  15. package/mcp_server/composer/fast/apply.py +533 -0
  16. package/mcp_server/composer/fast/brief_builder.py +1479 -0
  17. package/mcp_server/composer/fast/tier_classification.py +159 -0
  18. package/mcp_server/composer/framework/__init__.py +0 -0
  19. package/mcp_server/composer/framework/applier.py +179 -0
  20. package/mcp_server/composer/framework/artist_loader.py +63 -0
  21. package/mcp_server/composer/framework/brief.py +79 -0
  22. package/mcp_server/composer/framework/event_lexicon.py +71 -0
  23. package/mcp_server/composer/framework/genre_loader.py +77 -0
  24. package/mcp_server/composer/framework/intent_source.py +137 -0
  25. package/mcp_server/composer/framework/knowledge_pack.py +49 -0
  26. package/mcp_server/composer/framework/plan_compiler.py +10 -0
  27. package/mcp_server/composer/full/__init__.py +10 -0
  28. package/mcp_server/composer/full/apply.py +1139 -0
  29. package/mcp_server/composer/full/brief_builder.py +144 -0
  30. package/mcp_server/composer/full/engine.py +541 -0
  31. package/mcp_server/composer/full/layer_planner.py +491 -0
  32. package/mcp_server/composer/layer_planner.py +19 -465
  33. package/mcp_server/composer/sample_resolver.py +80 -7
  34. package/mcp_server/composer/tools.py +626 -28
  35. package/mcp_server/server.py +1 -0
  36. package/mcp_server/splice_client/client.py +7 -0
  37. package/mcp_server/tools/_analyzer_engine/sample.py +162 -6
  38. package/mcp_server/tools/_planner_engine.py +25 -63
  39. package/mcp_server/tools/analyzer.py +10 -4
  40. package/package.json +2 -2
  41. package/remote_script/LivePilot/__init__.py +1 -1
  42. 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
+ }