livepilot 1.23.6 → 1.25.0

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