livepilot 1.26.2 → 1.27.1

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 (69) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/README.md +34 -14
  3. package/bin/livepilot.js +4 -2
  4. package/installer/install.js +21 -12
  5. package/livepilot/.Codex-plugin/plugin.json +2 -2
  6. package/livepilot/.claude-plugin/plugin.json +2 -2
  7. package/livepilot/skills/livepilot-core/SKILL.md +12 -12
  8. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-space.md +2 -1
  9. package/livepilot/skills/livepilot-core/references/overview.md +6 -4
  10. package/livepilot/skills/livepilot-core/references/sound-design.md +5 -2
  11. package/livepilot/skills/livepilot-creative-director/SKILL.md +36 -0
  12. package/livepilot/skills/livepilot-creative-director/references/move-family-diversity-rule.md +1 -1
  13. package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +15 -9
  14. package/livepilot/skills/livepilot-release/SKILL.md +7 -5
  15. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  16. package/m4l_device/livepilot_bridge.js +1 -1
  17. package/mcp_server/__init__.py +1 -1
  18. package/mcp_server/atlas/__init__.py +20 -1
  19. package/mcp_server/atlas/tools.py +83 -22
  20. package/mcp_server/composer/full/apply.py +9 -0
  21. package/mcp_server/composer/full/layer_planner.py +16 -6
  22. package/mcp_server/creative_constraints/engine.py +46 -0
  23. package/mcp_server/experiment/engine.py +10 -3
  24. package/mcp_server/grader/client.py +30 -2
  25. package/mcp_server/grader/tools.py +8 -2
  26. package/mcp_server/hook_hunter/analyzer.py +15 -2
  27. package/mcp_server/m4l_bridge.py +19 -21
  28. package/mcp_server/memory/taste_graph.py +16 -0
  29. package/mcp_server/memory/technique_store.py +23 -3
  30. package/mcp_server/mix_engine/state_builder.py +4 -2
  31. package/mcp_server/musical_intelligence/detectors.py +11 -2
  32. package/mcp_server/musical_intelligence/tools.py +15 -2
  33. package/mcp_server/preview_studio/engine.py +30 -1
  34. package/mcp_server/project_brain/tools.py +56 -52
  35. package/mcp_server/reference_engine/tools.py +22 -2
  36. package/mcp_server/runtime/capability_probe.py +1 -1
  37. package/mcp_server/runtime/capability_state.py +66 -9
  38. package/mcp_server/runtime/live_version.py +45 -8
  39. package/mcp_server/runtime/safety_kernel.py +11 -0
  40. package/mcp_server/runtime/session_kernel.py +7 -0
  41. package/mcp_server/runtime/tools.py +324 -22
  42. package/mcp_server/sample_engine/critics.py +7 -3
  43. package/mcp_server/sample_engine/slice_workflow.py +3 -2
  44. package/mcp_server/sample_engine/tools.py +53 -21
  45. package/mcp_server/song_brain/builder.py +17 -1
  46. package/mcp_server/sound_design/tools.py +1 -1
  47. package/mcp_server/splice_client/http_bridge.py +43 -1
  48. package/mcp_server/splice_client/models.py +7 -3
  49. package/mcp_server/synthesis_brain/adapters/analog.py +13 -1
  50. package/mcp_server/synthesis_brain/adapters/operator.py +5 -2
  51. package/mcp_server/synthesis_brain/adapters/wavetable.py +4 -4
  52. package/mcp_server/tools/_composition_engine/models.py +6 -0
  53. package/mcp_server/tools/_composition_engine/sections.py +4 -0
  54. package/mcp_server/tools/analyzer.py +38 -1
  55. package/mcp_server/tools/clips.py +5 -4
  56. package/mcp_server/tools/composition.py +5 -1
  57. package/mcp_server/tools/midi_io.py +40 -1
  58. package/mcp_server/tools/transport.py +1 -1
  59. package/mcp_server/user_corpus/plugin_engine/detector.py +66 -11
  60. package/mcp_server/user_corpus/runner.py +7 -1
  61. package/mcp_server/user_corpus/scanners/amxd.py +24 -13
  62. package/mcp_server/user_corpus/tools.py +45 -9
  63. package/mcp_server/wonder_mode/tools.py +66 -27
  64. package/package.json +2 -2
  65. package/remote_script/LivePilot/__init__.py +1 -1
  66. package/remote_script/LivePilot/server.py +23 -3
  67. package/remote_script/LivePilot/version_detect.py +3 -0
  68. package/requirements.txt +4 -4
  69. package/server.json +3 -3
@@ -7,10 +7,11 @@ Tools:
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
- import importlib.util
11
10
  import logging
11
+ import os
12
+ from pathlib import Path
12
13
  import urllib.request
13
- from typing import Optional
14
+ from typing import Any, Optional
14
15
 
15
16
  from fastmcp import Context
16
17
 
@@ -21,6 +22,14 @@ from .capability_state import build_capability_state
21
22
  logger = logging.getLogger(__name__)
22
23
 
23
24
  _memory_store = TechniqueStore()
25
+ _FLUCOMA_STREAM_KEYS = (
26
+ "spectral_shape",
27
+ "mel_bands",
28
+ "chroma",
29
+ "onset",
30
+ "novelty",
31
+ "loudness",
32
+ )
24
33
 
25
34
 
26
35
  # ── Capability probes ──────────────────────────────────────────────────
@@ -51,21 +60,296 @@ def _probe_web(timeout: float = 0.5) -> bool:
51
60
  return False
52
61
 
53
62
 
54
- def _probe_flucoma() -> bool:
55
- """Check whether the ``flucoma`` Python package is importable.
63
+ def _flucoma_package_dirs() -> list[Path]:
64
+ """Return likely Max package locations for FluCoMa."""
65
+ home = Path.home()
66
+ if os.name == "nt":
67
+ docs = Path(os.environ.get("USERPROFILE", str(home))) / "Documents"
68
+ else:
69
+ docs = home / "Documents"
70
+ return [
71
+ docs / "Max 9" / "Packages" / "FluidCorpusManipulation",
72
+ docs / "Max 8" / "Packages" / "FluidCorpusManipulation",
73
+ ]
56
74
 
57
- Uses ``importlib.util.find_spec`` so no import side-effects fire
58
- (matching the pattern already used for optional capability probes
59
- elsewhere in the codebase). Returns False if the package is missing
60
- or if the spec lookup itself raises.
75
+
76
+ def _probe_flucoma_package() -> bool:
77
+ """Check whether the FluCoMa Max package exists on disk.
78
+
79
+ LivePilot's real-time FluCoMa support is Max-for-Live based. There is
80
+ no required Python package named ``flucoma``; probing importability
81
+ caused false ``flucoma_not_installed`` reports on healthy systems.
61
82
  """
62
83
  try:
63
- return importlib.util.find_spec("flucoma") is not None
84
+ for package_dir in _flucoma_package_dirs():
85
+ if not package_dir.exists():
86
+ continue
87
+ if (package_dir / "package-info.json").exists():
88
+ return True
89
+ externals = package_dir / "externals"
90
+ if externals.exists() and any(externals.glob("fluid.*")):
91
+ return True
92
+ return True
93
+ return False
64
94
  except Exception as exc: # noqa: BLE001
65
- logger.debug("_probe_flucoma failed: %s", exc)
95
+ logger.debug("_probe_flucoma_package failed: %s", exc)
66
96
  return False
67
97
 
68
98
 
99
+ def _probe_flucoma(spectral=None) -> dict:
100
+ """Probe the Max/bridge-backed FluCoMa runtime.
101
+
102
+ ``available`` means at least one FluCoMa stream has reached the
103
+ spectral cache. ``device_loaded`` means the external package appears
104
+ installed (or streams prove it is working from a frozen analyzer).
105
+ """
106
+ package_installed = _probe_flucoma_package()
107
+ bridge_connected = bool(
108
+ spectral is not None and getattr(spectral, "is_connected", False)
109
+ )
110
+
111
+ streams: dict[str, bool] = {}
112
+ if bridge_connected:
113
+ for key in _FLUCOMA_STREAM_KEYS:
114
+ try:
115
+ streams[key] = spectral.get(key) is not None
116
+ except Exception as exc: # noqa: BLE001
117
+ logger.debug("_probe_flucoma stream %s failed: %s", key, exc)
118
+ streams[key] = False
119
+
120
+ active_streams = sum(1 for present in streams.values() if present)
121
+ available = active_streams > 0
122
+ device_loaded = package_installed or available
123
+
124
+ reasons: list[str] = []
125
+ if not device_loaded:
126
+ reasons.append("flucoma_not_installed")
127
+ if not bridge_connected:
128
+ reasons.append("flucoma_bridge_unavailable")
129
+ elif not available:
130
+ reasons.append("flucoma_no_streams")
131
+
132
+ return {
133
+ "available": available,
134
+ "device_loaded": device_loaded,
135
+ "bridge_connected": bridge_connected,
136
+ "active_streams": active_streams,
137
+ "streams": streams,
138
+ "reasons": reasons,
139
+ }
140
+
141
+
142
+ def _normalize_flucoma_probe(raw) -> dict:
143
+ """Back-compat shim for tests/extensions that monkeypatch a bool probe."""
144
+ if isinstance(raw, bool):
145
+ return {
146
+ "available": raw,
147
+ "device_loaded": raw,
148
+ "reasons": [] if raw else ["flucoma_not_installed"],
149
+ }
150
+ if isinstance(raw, dict):
151
+ available = bool(raw.get("available", False))
152
+ return {
153
+ **raw,
154
+ "available": available,
155
+ "device_loaded": bool(raw.get("device_loaded", available)),
156
+ "reasons": list(raw.get("reasons", [])),
157
+ }
158
+ return {
159
+ "available": False,
160
+ "device_loaded": False,
161
+ "reasons": ["flucoma_probe_failed"],
162
+ }
163
+
164
+
165
+ def _run_flucoma_probe(spectral=None) -> dict:
166
+ try:
167
+ return _normalize_flucoma_probe(_probe_flucoma(spectral))
168
+ except TypeError:
169
+ # Older tests monkeypatch _probe_flucoma as a no-arg callable.
170
+ return _normalize_flucoma_probe(_probe_flucoma())
171
+
172
+
173
+ def _session_info(ableton) -> dict:
174
+ try:
175
+ info = ableton.send_command("get_session_info")
176
+ return info if isinstance(info, dict) else {}
177
+ except Exception as exc: # noqa: BLE001
178
+ logger.debug("_session_info probe failed: %s", exc)
179
+ return {}
180
+
181
+
182
+ def _live_version_from_session(session_info: dict) -> str:
183
+ version = session_info.get("live_version")
184
+ return str(version) if version else "12.0.0"
185
+
186
+
187
+ def _normalize_probe_surface(
188
+ raw: Any,
189
+ *,
190
+ default_reason: str,
191
+ default_mode: str = "manual_only",
192
+ ) -> dict:
193
+ if isinstance(raw, dict):
194
+ mode = str(raw.get("mode") or default_mode)
195
+ available = bool(raw.get("available", mode in {"readable", "routable", "callable"}))
196
+ reasons = list(raw.get("reasons") or [])
197
+ if not available and not reasons:
198
+ reasons.append(default_reason)
199
+ return {
200
+ "mode": mode,
201
+ "available": available,
202
+ "reasons": reasons,
203
+ "observed": dict(raw.get("observed") or {}),
204
+ }
205
+ return {
206
+ "mode": default_mode,
207
+ "available": False,
208
+ "reasons": [default_reason],
209
+ "observed": {},
210
+ }
211
+
212
+
213
+ def _probe_link_audio_surface(ableton, session_info: Optional[dict] = None) -> dict:
214
+ """Read-only Link Audio surface probe.
215
+
216
+ Current Live 12.4 builds expose Link Audio as product UX, but no stable
217
+ Remote Script/M4L routing contract has been proven. This helper looks for
218
+ explicit evidence if future Remote Scripts surface it; otherwise it reports
219
+ manual_only with a reason instead of claiming support from version alone.
220
+ """
221
+ info = session_info or _session_info(ableton)
222
+ observed = {
223
+ "peers_visible": bool(info.get("link_audio_peers") or info.get("link_peers")),
224
+ "inputs_visible": bool(info.get("link_audio_inputs") or info.get("audio_inputs")),
225
+ "routing_visible": bool(info.get("link_audio_routing")),
226
+ }
227
+ if observed["peers_visible"] and observed["inputs_visible"] and observed["routing_visible"]:
228
+ return {
229
+ "mode": "routable",
230
+ "available": True,
231
+ "reasons": [],
232
+ "observed": observed,
233
+ }
234
+ if observed["peers_visible"] or observed["inputs_visible"]:
235
+ return {
236
+ "mode": "readable",
237
+ "available": True,
238
+ "reasons": [],
239
+ "observed": observed,
240
+ }
241
+ return {
242
+ "mode": "manual_only",
243
+ "available": False,
244
+ "reasons": ["link_audio_not_exposed"],
245
+ "observed": observed,
246
+ }
247
+
248
+
249
+ def _probe_stem_workflow_surface(ableton, session_info: Optional[dict] = None) -> dict:
250
+ """Read-only selected-time stem workflow probe.
251
+
252
+ This intentionally does not invoke stem separation. It only reports
253
+ callable evidence if a future Remote Script/M4L surface advertises a
254
+ stable command path.
255
+ """
256
+ info = session_info or _session_info(ableton)
257
+ callable_paths = list(info.get("stem_callable_paths") or [])
258
+ if callable_paths:
259
+ return {
260
+ "mode": "callable",
261
+ "available": True,
262
+ "reasons": [],
263
+ "observed": {"callable_paths": callable_paths},
264
+ }
265
+ return {
266
+ "mode": "manual_only",
267
+ "available": False,
268
+ "reasons": ["stem_command_not_observable"],
269
+ "observed": {"callable_paths": []},
270
+ }
271
+
272
+
273
+ def _probe_live_12_4_domain(
274
+ *,
275
+ ableton,
276
+ session_info: dict,
277
+ feature_available: bool,
278
+ surface_probe,
279
+ default_reason: str,
280
+ ) -> dict:
281
+ live_version = _live_version_from_session(session_info)
282
+ if not feature_available:
283
+ return {
284
+ "ok": True,
285
+ "live_version": live_version,
286
+ "mode": "unavailable",
287
+ "available": False,
288
+ "reasons": ["live_version_below_12_4"],
289
+ "observed": {},
290
+ }
291
+ try:
292
+ surface = _normalize_probe_surface(
293
+ surface_probe(ableton, session_info=session_info),
294
+ default_reason=default_reason,
295
+ )
296
+ except Exception as exc: # noqa: BLE001
297
+ logger.debug("Live 12.4 surface probe failed: %s", exc)
298
+ surface = {
299
+ "mode": "manual_only",
300
+ "available": False,
301
+ "reasons": [f"probe_failed: {type(exc).__name__}"],
302
+ "observed": {},
303
+ }
304
+ return {
305
+ "ok": True,
306
+ "live_version": live_version,
307
+ **surface,
308
+ }
309
+
310
+
311
+ def _probe_link_audio_domain(ableton, session_info: dict) -> dict:
312
+ from .live_version import LiveVersionCapabilities
313
+
314
+ caps = LiveVersionCapabilities.from_session_info(session_info)
315
+ return _probe_live_12_4_domain(
316
+ ableton=ableton,
317
+ session_info=session_info,
318
+ feature_available=caps.has_link_audio,
319
+ surface_probe=_probe_link_audio_surface,
320
+ default_reason="link_audio_not_exposed",
321
+ )
322
+
323
+
324
+ def _probe_stem_workflow_domain(ableton, session_info: dict) -> dict:
325
+ from .live_version import LiveVersionCapabilities
326
+
327
+ caps = LiveVersionCapabilities.from_session_info(session_info)
328
+ return _probe_live_12_4_domain(
329
+ ableton=ableton,
330
+ session_info=session_info,
331
+ feature_available=caps.has_stem_time_selection,
332
+ surface_probe=_probe_stem_workflow_surface,
333
+ default_reason="stem_command_not_observable",
334
+ )
335
+
336
+
337
+ @mcp.tool()
338
+ def probe_link_audio(ctx: Context) -> dict:
339
+ """Read-only probe for Live 12.4 Link Audio MCP controllability."""
340
+ ableton = ctx.lifespan_context["ableton"]
341
+ session_info = _session_info(ableton)
342
+ return _probe_link_audio_domain(ableton, session_info)
343
+
344
+
345
+ @mcp.tool()
346
+ def probe_stem_workflow(ctx: Context) -> dict:
347
+ """Read-only probe for Live 12.4 selected-time stem workflow support."""
348
+ ableton = ctx.lifespan_context["ableton"]
349
+ session_info = _session_info(ableton)
350
+ return _probe_stem_workflow_domain(ableton, session_info)
351
+
352
+
69
353
  @mcp.tool()
70
354
  def get_capability_state(ctx: Context) -> dict:
71
355
  """Probe the runtime and return a capability state snapshot.
@@ -77,13 +361,8 @@ def get_capability_state(ctx: Context) -> dict:
77
361
  spectral = ctx.lifespan_context.get("spectral")
78
362
 
79
363
  # ── Probe session ───────────────────────────────────────────────
80
- session_ok = False
81
- try:
82
- result = ableton.send_command("get_session_info")
83
- session_ok = isinstance(result, dict) and "error" not in result
84
- except Exception as exc:
85
- logger.debug("get_capability_state failed: %s", exc)
86
- session_ok = False
364
+ session_info = _session_info(ableton)
365
+ session_ok = bool(session_info) and "error" not in session_info
87
366
 
88
367
  # ── Probe analyzer (M4L bridge) ─────────────────────────────────
89
368
  analyzer_ok = False
@@ -109,8 +388,12 @@ def get_capability_state(ctx: Context) -> dict:
109
388
  # a curated research corpus is installed (see ``research`` domain).
110
389
  web_ok = _probe_web()
111
390
 
112
- # ── FluCoMa — optional import via find_spec (no side effects) ───
113
- flucoma_ok = _probe_flucoma()
391
+ # ── FluCoMa — Max package + live bridge streams ─────────────────
392
+ flucoma_probe = _run_flucoma_probe(spectral)
393
+
394
+ # ── Live 12.4 probe-first surfaces ──────────────────────────────
395
+ link_probe = _probe_link_audio_domain(ableton, session_info)
396
+ stem_probe = _probe_stem_workflow_domain(ableton, session_info)
114
397
 
115
398
  state = build_capability_state(
116
399
  session_ok=session_ok,
@@ -118,7 +401,13 @@ def get_capability_state(ctx: Context) -> dict:
118
401
  analyzer_fresh=analyzer_fresh,
119
402
  memory_ok=memory_ok,
120
403
  web_ok=web_ok,
121
- flucoma_ok=flucoma_ok,
404
+ flucoma_ok=flucoma_probe["available"],
405
+ flucoma_device_loaded=flucoma_probe["device_loaded"],
406
+ flucoma_reasons=flucoma_probe["reasons"],
407
+ link_audio_mode=link_probe["mode"],
408
+ link_audio_reasons=link_probe["reasons"],
409
+ stem_workflow_mode=stem_probe["mode"],
410
+ stem_workflow_reasons=stem_probe["reasons"],
122
411
  )
123
412
 
124
413
  return state.to_dict()
@@ -134,6 +423,7 @@ def get_session_kernel(
134
423
  creativity_profile: str = "",
135
424
  sacred_elements: Optional[list] = None,
136
425
  synth_hints: Optional[dict] = None,
426
+ operation_profile: str = "studio_deep",
137
427
  ) -> dict:
138
428
  """Build the unified turn snapshot for V2 orchestration.
139
429
 
@@ -159,6 +449,9 @@ def get_session_kernel(
159
449
  synth_hints: focus hints for synthesis_brain; shape is open in PR2
160
450
  and firms up in PR9. Typical keys: track_indices, device_paths,
161
451
  target_timbre, preferred_devices.
452
+ operation_profile: safety/intent posture for this turn. Known values:
453
+ safe_live, studio_deep, arrangement_build, sound_design_deep,
454
+ release_audit.
162
455
 
163
456
  Returns: SessionKernel dict with kernel_id, session topology, capabilities,
164
457
  memory context, routing hints, and (if provided) creative controls.
@@ -183,7 +476,9 @@ def get_session_kernel(
183
476
  # does, and propagate through. Without this the kernel's capability view
184
477
  # lies to orchestration planners.
185
478
  web_ok = _probe_web()
186
- flucoma_ok = _probe_flucoma()
479
+ flucoma_probe = _run_flucoma_probe(spectral)
480
+ link_probe = _probe_link_audio_domain(ableton, session_info if isinstance(session_info, dict) else {})
481
+ stem_probe = _probe_stem_workflow_domain(ableton, session_info if isinstance(session_info, dict) else {})
187
482
 
188
483
  # v1.17.4: probe memory the same way too. Previously memory_ok=True was
189
484
  # hardcoded — if the store raised, the kernel still reported memory
@@ -201,7 +496,13 @@ def get_session_kernel(
201
496
  analyzer_fresh=analyzer_fresh,
202
497
  memory_ok=memory_ok,
203
498
  web_ok=web_ok,
204
- flucoma_ok=flucoma_ok,
499
+ flucoma_ok=flucoma_probe["available"],
500
+ flucoma_device_loaded=flucoma_probe["device_loaded"],
501
+ flucoma_reasons=flucoma_probe["reasons"],
502
+ link_audio_mode=link_probe["mode"],
503
+ link_audio_reasons=link_probe["reasons"],
504
+ stem_workflow_mode=stem_probe["mode"],
505
+ stem_workflow_reasons=stem_probe["reasons"],
205
506
  )
206
507
 
207
508
  # Optional subcomponents — degrade gracefully, but reach into the SAME
@@ -286,6 +587,7 @@ def get_session_kernel(
286
587
  anti_preferences=anti_prefs,
287
588
  freshness=freshness,
288
589
  creativity_profile=creativity_profile,
590
+ operation_profile=operation_profile,
289
591
  sacred_elements=sacred_elements,
290
592
  synth_hints=synth_hints,
291
593
  )
@@ -301,9 +301,13 @@ def run_vibe_fit_critic(
301
301
  recommendation="No taste evidence yet — neutral score",
302
302
  )
303
303
 
304
- # Compute energy proxy from sample characteristics (0.0 - 1.0)
305
- # brightness and transient_density are both 0.0-1.0 range
306
- energy = (profile.brightness + profile.transient_density) / 2.0
304
+ # Compute energy proxy from sample characteristics (0.0 - 1.0).
305
+ # brightness is already a 0.0-1.0 fraction, but transient_density is peaks
306
+ # PER SECOND (unbounded). Normalize it through a saturating curve before
307
+ # averaging so a busy break does not pin the proxy to 1.0 for every sample.
308
+ # ~12 peaks/s (busy 16ths) maps to ~1.0.
309
+ transient_norm = max(0.0, min(1.0, profile.transient_density / 12.0))
310
+ energy = (profile.brightness + transient_norm) / 2.0
307
311
  energy = max(0.0, min(1.0, energy))
308
312
 
309
313
  # Compare against novelty_band as taste proxy
@@ -12,8 +12,9 @@ from __future__ import annotations
12
12
 
13
13
  import hashlib
14
14
 
15
- # Simpler maps slices to MIDI notes starting at C3 (60)
16
- SLICE_BASE_NOTE = 60
15
+ # Simpler maps slices to MIDI notes starting at C1 (36): slice N -> pitch 36+N.
16
+ # Notes at 60+ (C3) trigger no slice and produce silence.
17
+ SLICE_BASE_NOTE = 36
17
18
 
18
19
 
19
20
  def plan_slice_steps(
@@ -6,6 +6,7 @@ direct Splice online catalog hunt/download via the gRPC client.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ import asyncio
9
10
  import logging
10
11
  import os
11
12
  from typing import Optional
@@ -57,7 +58,11 @@ async def analyze_sample(
57
58
  return {"error": "Could not determine file path — provide file_path directly"}
58
59
 
59
60
  source = "session_clip" if track_index is not None else "filesystem"
60
- profile = build_profile_from_filename(file_path, source=source)
61
+ # Offload audio decode + numpy FFT off the event loop (heavy CPU/IO).
62
+ loop = asyncio.get_running_loop()
63
+ profile = await loop.run_in_executor(
64
+ None, build_profile_from_filename, file_path, source
65
+ )
61
66
  return profile.to_dict()
62
67
 
63
68
 
@@ -86,6 +91,11 @@ def evaluate_sample_fit(
86
91
  song_key = None
87
92
  session_tempo = 120.0
88
93
  existing_roles: list[str] = []
94
+ # Map track index -> name so the key-detection loop below can look a
95
+ # track's name up by its real index. existing_roles is a *packed*
96
+ # list (unnamed/errored tracks are skipped), so indexing it by track
97
+ # index misaligns names with the clip that produced the notes.
98
+ track_names_by_index: dict[int, str] = {}
89
99
 
90
100
  try:
91
101
  ableton = ctx.lifespan_context["ableton"]
@@ -100,6 +110,7 @@ def evaluate_sample_fit(
100
110
  name = track_info.get("name", "").lower()
101
111
  if name:
102
112
  existing_roles.append(name)
113
+ track_names_by_index[i] = name
103
114
  except Exception as exc:
104
115
  logger.debug("get_track_info(%d) skipped: %s", i, exc)
105
116
  continue
@@ -147,9 +158,7 @@ def evaluate_sample_fit(
147
158
  ) else []
148
159
  if not notes:
149
160
  continue
150
- track_name = (
151
- existing_roles[i] if i < len(existing_roles) else ""
152
- )
161
+ track_name = track_names_by_index.get(i, "")
153
162
  if harmonic_score(notes, track_name) >= 0.3:
154
163
  harmonic_pool.extend(notes)
155
164
 
@@ -189,7 +198,11 @@ def evaluate_sample_fit(
189
198
  recommended_intent=intent,
190
199
  surgeon_plan=surgeon_plan,
191
200
  alchemist_plan=alchemist_plan,
192
- warnings=[c.recommendation for c in critics.values() if c.score < 0.5],
201
+ warnings=[
202
+ c.recommendation
203
+ for c in critics.values()
204
+ if getattr(c, "available", True) and c.score < 0.5
205
+ ],
193
206
  )
194
207
  return report.to_dict()
195
208
 
@@ -316,12 +329,16 @@ async def search_samples(
316
329
  if not used_grpc:
317
330
  splice = SpliceSource()
318
331
  if splice.enabled:
319
- splice_results = splice.search(
320
- query=query,
321
- max_results=max_results,
322
- key=key,
323
- bpm_min=bpm_min,
324
- bpm_max=bpm_max,
332
+ # Offload blocking SQLite query off the event loop.
333
+ splice_results = await asyncio.get_running_loop().run_in_executor(
334
+ None,
335
+ lambda: splice.search(
336
+ query=query,
337
+ max_results=max_results,
338
+ key=key,
339
+ bpm_min=bpm_min,
340
+ bpm_max=bpm_max,
341
+ ),
325
342
  )
326
343
  for candidate in splice_results:
327
344
  d = candidate.to_dict()
@@ -335,12 +352,16 @@ async def search_samples(
335
352
  browser = BrowserSource()
336
353
  for category in browser.DEFAULT_CATEGORIES:
337
354
  try:
338
- search_result = ableton.send_command("search_browser", {
339
- "path": category,
340
- "name_filter": query,
341
- "loadable_only": True,
342
- "max_results": max_results,
343
- })
355
+ # Offload blocking TCP round-trip off the event loop.
356
+ search_result = await asyncio.get_running_loop().run_in_executor(
357
+ None,
358
+ lambda: ableton.send_command("search_browser", {
359
+ "path": category,
360
+ "name_filter": query,
361
+ "loadable_only": True,
362
+ "max_results": max_results,
363
+ }),
364
+ )
344
365
  raw = search_result.get("results", [])
345
366
  parsed = browser.parse_results(raw, category)
346
367
  for candidate in parsed:
@@ -359,7 +380,10 @@ async def search_samples(
359
380
  "~/Music", "~/Documents/Samples",
360
381
  "~/Documents/LivePilot/downloads",
361
382
  ])
362
- fs_results = fs.search(query, max_results=max_results)
383
+ # Offload blocking recursive filesystem scan off the event loop.
384
+ fs_results = await asyncio.get_running_loop().run_in_executor(
385
+ None, lambda: fs.search(query, max_results=max_results)
386
+ )
363
387
  for candidate in fs_results:
364
388
  d = candidate.to_dict()
365
389
  d["source_priority"] = 3
@@ -1049,10 +1073,18 @@ async def splice_download_sample(
1049
1073
  if copy_to_user_library:
1050
1074
  dest_dir = os.path.expanduser(_SPLICE_USER_LIB_DEST)
1051
1075
  try:
1052
- os.makedirs(dest_dir, exist_ok=True)
1053
1076
  dest_path = os.path.join(dest_dir, os.path.basename(local_path))
1054
- if not os.path.exists(dest_path):
1055
- shutil.copy2(local_path, dest_path)
1077
+
1078
+ def _copy_into_user_library():
1079
+ os.makedirs(dest_dir, exist_ok=True)
1080
+ if not os.path.exists(dest_path):
1081
+ shutil.copy2(local_path, dest_path)
1082
+
1083
+ # Offload the blocking filesystem copy off the event loop,
1084
+ # mirroring the run_in_executor used in splice_preview_sample.
1085
+ await asyncio.get_running_loop().run_in_executor(
1086
+ None, _copy_into_user_library
1087
+ )
1056
1088
  response["user_library_path"] = dest_path
1057
1089
  # URI format Ableton uses for user_library samples
1058
1090
  response["browser_uri"] = (
@@ -566,7 +566,23 @@ def detect_identity_drift(
566
566
  drift.drift_score += 0.2 * len(lost)
567
567
 
568
568
  # Energy arc shift
569
- if before.energy_arc and after.energy_arc:
569
+ # Align on section_id rather than list position: inserting/removing/
570
+ # renaming-to-empty a scene shifts energy_arc indices (the BUG-B12 skip
571
+ # drops empty sections), so a positional diff fabricates drift on every
572
+ # section after the change. Diffing the shared section_ids compares like
573
+ # with like and is stable under reordering/insertion.
574
+ before_energy = {s.section_id: s.energy_level for s in before.section_purposes}
575
+ after_energy = {s.section_id: s.energy_level for s in after.section_purposes}
576
+ shared_ids = before_energy.keys() & after_energy.keys()
577
+ if shared_ids:
578
+ diff = sum(
579
+ abs(before_energy[sid] - after_energy[sid]) for sid in shared_ids
580
+ ) / len(shared_ids)
581
+ drift.energy_arc_shift = round(diff, 3)
582
+ drift.drift_score += diff * 0.2
583
+ elif before.energy_arc and after.energy_arc:
584
+ # Fallback for snapshots that carry energy_arc but no section_purposes
585
+ # (e.g. older brains): keep the legacy positional comparison.
570
586
  min_len = min(len(before.energy_arc), len(after.energy_arc))
571
587
  if min_len > 0:
572
588
  diff = sum(
@@ -408,7 +408,7 @@ def _cross_engine_hint_for_track(
408
408
  return None
409
409
  track_issues = [
410
410
  i for i in mix_issues
411
- if getattr(i, "track_index", None) == track_index
411
+ if track_index in (getattr(i, "affected_tracks", []) or [])
412
412
  ]
413
413
  if not track_issues:
414
414
  return None