livepilot 1.27.0 → 1.27.2

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 (66) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/README.md +26 -7
  3. package/bin/livepilot.js +86 -23
  4. package/installer/install.js +21 -12
  5. package/livepilot/.Codex-plugin/plugin.json +1 -1
  6. package/livepilot/.claude-plugin/plugin.json +1 -1
  7. package/livepilot/skills/livepilot-core/SKILL.md +8 -8
  8. package/livepilot/skills/livepilot-core/references/overview.md +2 -2
  9. package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
  10. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  11. package/m4l_device/livepilot_bridge.js +48 -11
  12. package/mcp_server/__init__.py +1 -1
  13. package/mcp_server/atlas/__init__.py +20 -1
  14. package/mcp_server/atlas/tools.py +83 -22
  15. package/mcp_server/composer/fast/brief_builder.py +6 -2
  16. package/mcp_server/composer/full/apply.py +9 -0
  17. package/mcp_server/composer/full/layer_planner.py +16 -6
  18. package/mcp_server/composer/tools.py +12 -6
  19. package/mcp_server/connection.py +2 -1
  20. package/mcp_server/creative_constraints/engine.py +46 -0
  21. package/mcp_server/experiment/engine.py +10 -3
  22. package/mcp_server/grader/client.py +30 -2
  23. package/mcp_server/grader/tools.py +8 -2
  24. package/mcp_server/hook_hunter/analyzer.py +15 -2
  25. package/mcp_server/m4l_bridge.py +145 -54
  26. package/mcp_server/memory/taste_graph.py +16 -0
  27. package/mcp_server/memory/technique_store.py +23 -3
  28. package/mcp_server/mix_engine/state_builder.py +4 -2
  29. package/mcp_server/musical_intelligence/detectors.py +11 -2
  30. package/mcp_server/musical_intelligence/tools.py +15 -2
  31. package/mcp_server/preview_studio/engine.py +30 -1
  32. package/mcp_server/project_brain/tools.py +56 -52
  33. package/mcp_server/reference_engine/tools.py +22 -2
  34. package/mcp_server/runtime/execution_router.py +6 -0
  35. package/mcp_server/runtime/live_version.py +27 -8
  36. package/mcp_server/runtime/mcp_dispatch.py +22 -0
  37. package/mcp_server/runtime/remote_commands.py +9 -4
  38. package/mcp_server/runtime/safety_kernel.py +11 -0
  39. package/mcp_server/sample_engine/critics.py +7 -3
  40. package/mcp_server/sample_engine/slice_workflow.py +3 -2
  41. package/mcp_server/sample_engine/tools.py +53 -21
  42. package/mcp_server/server.py +1 -1
  43. package/mcp_server/song_brain/builder.py +17 -1
  44. package/mcp_server/sound_design/tools.py +1 -1
  45. package/mcp_server/splice_client/http_bridge.py +43 -1
  46. package/mcp_server/splice_client/models.py +7 -3
  47. package/mcp_server/synthesis_brain/adapters/analog.py +13 -1
  48. package/mcp_server/synthesis_brain/adapters/operator.py +5 -2
  49. package/mcp_server/synthesis_brain/adapters/wavetable.py +4 -4
  50. package/mcp_server/tools/_composition_engine/models.py +6 -0
  51. package/mcp_server/tools/_composition_engine/sections.py +4 -0
  52. package/mcp_server/tools/clips.py +5 -4
  53. package/mcp_server/tools/composition.py +5 -1
  54. package/mcp_server/tools/midi_io.py +40 -1
  55. package/mcp_server/tools/transport.py +1 -1
  56. package/mcp_server/user_corpus/plugin_engine/detector.py +66 -11
  57. package/mcp_server/user_corpus/runner.py +7 -1
  58. package/mcp_server/user_corpus/scanners/amxd.py +24 -13
  59. package/mcp_server/user_corpus/tools.py +45 -9
  60. package/mcp_server/wonder_mode/tools.py +66 -27
  61. package/package.json +1 -1
  62. package/remote_script/LivePilot/__init__.py +21 -8
  63. package/remote_script/LivePilot/server.py +23 -3
  64. package/remote_script/LivePilot/tracks.py +11 -5
  65. package/requirements.txt +1 -1
  66. package/server.json +2 -2
@@ -84,17 +84,41 @@ def build_project_brain(ctx: Context) -> dict:
84
84
  except Exception as exc:
85
85
  logger.debug("build_project_brain failed: %s", exc)
86
86
 
87
- # 5b. Build notes_map for role inference.
88
- # Shape: {section_id: {track_index: [notes]}}. Without this, role_graph
89
- # falls back to "assume all tracks active in every section" which destroys
90
- # section-scoped role confidence.
87
+ # 5b/5c. Single combined grid sweep for notes (role inference) and clip
88
+ # automation envelopes. Previously these were two separate
89
+ # N_tracks x N_scenes loops, each issuing a remote round-trip per slot
90
+ # including empty slots. We now consult the get_scene_matrix presence grid
91
+ # (already fetched at step 3 as clip_matrix) and skip slots that are
92
+ # positively empty, collapsing both sweeps into one pass over the grid.
91
93
  #
92
94
  # BUG-E1: section_id must match what build_section_graph_from_scenes emits.
93
95
  # The composition engine emits `sec_{i:02d}` using the RAW enumerate index
94
96
  # of the scene — it skips unnamed scenes (gap-preserving), so e.g. scenes
95
97
  # ["Intro", "", "Verse"] become sections sec_00 and sec_02, not sec_01.
96
- # Our notes_map must mirror that or keys won't align.
98
+ # Both maps mirror that or keys won't align.
99
+ #
100
+ # clips_scanned is the denominator for coverage_pct (BUG-D2): it counts
101
+ # the slots we actually probed for envelopes. Slots skipped as empty are
102
+ # not counted, so coverage_pct stays "fraction of present clips automated".
103
+ def _slot_is_empty(s_idx: int, t_idx: int) -> bool:
104
+ """True only when the presence grid positively reports no clip.
105
+
106
+ Unknown / out-of-range / malformed cells return False so we still
107
+ issue the round-trip (safe fallback — never skip on ambiguity).
108
+ """
109
+ try:
110
+ cell = clip_matrix[s_idx][t_idx]
111
+ except (IndexError, TypeError, KeyError):
112
+ return False
113
+ if not isinstance(cell, dict):
114
+ return False
115
+ if cell.get("has_clip"):
116
+ return False
117
+ return cell.get("state") in ("empty", "missing")
118
+
97
119
  notes_map: dict[str, dict[int, list[dict]]] = {}
120
+ clip_automation: list[dict] = []
121
+ clips_scanned = 0
98
122
  try:
99
123
  for scene_idx, scene in enumerate(scenes or []):
100
124
  scene_name = str(scene.get("name", "")).strip()
@@ -105,6 +129,11 @@ def build_project_brain(ctx: Context) -> dict:
105
129
  per_track: dict[int, list[dict]] = {}
106
130
  for track in tracks:
107
131
  t_idx = track.get("index", 0)
132
+ if _slot_is_empty(scene_idx, t_idx):
133
+ continue # no clip in this slot — skip both round-trips
134
+ clips_scanned += 1
135
+
136
+ # Notes for role inference.
108
137
  try:
109
138
  notes_resp = ableton.send_command("get_notes", {
110
139
  "track_index": t_idx,
@@ -115,39 +144,9 @@ def build_project_brain(ctx: Context) -> dict:
115
144
  if notes:
116
145
  per_track[t_idx] = notes
117
146
  except Exception as exc:
118
- logger.debug("build_project_brain failed: %s", exc)
119
- # Individual note fetch failing is fine — continue with others
120
- continue
121
- if per_track:
122
- notes_map[section_id] = per_track
123
- except Exception as exc:
124
- logger.debug("build_project_brain failed: %s", exc)
125
- # Overall failure: empty map, degrade to "all tracks active" fallback
126
- notes_map = {}
147
+ logger.debug("build_project_brain notes fetch failed: %s", exc)
127
148
 
128
- # 5c. Scan clip automation across the session (BUG-E2).
129
- # Device-parameter is_automated flags only reflect whether a parameter
130
- # is mapped somewhere — they don't reveal clip envelopes. Ableton's
131
- # automation actually lives on each clip (session + arrangement). We
132
- # walk every clip slot that has a clip and ask get_clip_automation, then
133
- # aggregate into a flat list keyed by section.
134
- #
135
- # clips_scanned is the denominator for coverage_pct (BUG-D2) — it
136
- # counts how many (track, scene) slots we probed, regardless of
137
- # whether an envelope came back. Without this, a session with zero
138
- # automation would be indistinguishable from a session where we
139
- # failed to probe, which is exactly the ambiguity BUG-D2 flagged.
140
- clip_automation: list[dict] = []
141
- clips_scanned = 0
142
- try:
143
- for scene_idx, scene in enumerate(scenes or []):
144
- scene_name = str(scene.get("name", "")).strip()
145
- if not scene_name:
146
- continue
147
- section_id = f"sec_{scene_idx:02d}"
148
- for track in tracks:
149
- t_idx = track.get("index", 0)
150
- clips_scanned += 1
149
+ # Clip automation envelopes (BUG-E2).
151
150
  try:
152
151
  auto_resp = ableton.send_command("get_clip_automation", {
153
152
  "track_index": t_idx,
@@ -156,22 +155,27 @@ def build_project_brain(ctx: Context) -> dict:
156
155
  except Exception as exc:
157
156
  # No clip in slot, or remote script rejected — skip
158
157
  logger.debug("build_project_brain automation skip: %s", exc)
159
- continue
160
- if not isinstance(auto_resp, dict):
161
- continue
162
- envs = auto_resp.get("envelopes") or []
163
- for env in envs:
164
- clip_automation.append({
165
- "section_id": section_id,
166
- "track_index": t_idx,
167
- "track_name": track.get("name", ""),
168
- "clip_index": scene_idx,
169
- "parameter_name": env.get("parameter_name", ""),
170
- "parameter_type": env.get("parameter_type", ""),
171
- "device_name": env.get("device_name"),
172
- })
158
+ auto_resp = None
159
+ if isinstance(auto_resp, dict):
160
+ for env in (auto_resp.get("envelopes") or []):
161
+ clip_automation.append({
162
+ "section_id": section_id,
163
+ "track_index": t_idx,
164
+ "track_name": track.get("name", ""),
165
+ "clip_index": scene_idx,
166
+ "parameter_name": env.get("parameter_name", ""),
167
+ "parameter_type": env.get("parameter_type", ""),
168
+ "device_name": env.get("device_name"),
169
+ })
170
+
171
+ if per_track:
172
+ notes_map[section_id] = per_track
173
173
  except Exception as exc:
174
- logger.debug("build_project_brain automation scan failed: %s", exc)
174
+ logger.debug("build_project_brain grid sweep failed: %s", exc)
175
+ # Overall failure: empty maps, degrade to "all tracks active" fallback
176
+ notes_map = {}
177
+ clip_automation = []
178
+ clips_scanned = 0
175
179
 
176
180
  # 6. Probe capabilities (direct SpectralCache access, not TCP)
177
181
  analyzer_ok = False
@@ -71,14 +71,18 @@ def _fetch_project_snapshot(ctx: Context) -> dict:
71
71
 
72
72
  spec_data = spectral.get("spectrum")
73
73
  if spec_data:
74
- snapshot["spectral"] = {"bands": spec_data["value"]}
74
+ # gap_analyzer.analyze_gaps reads proj_spectral["band_balance"]
75
+ # and the reference profile stores its bands under the same
76
+ # key, so the project spectrum MUST go under band_balance too
77
+ # (writing it under "bands" made every band read as 0.0).
78
+ snapshot["spectral"] = {"band_balance": spec_data["value"]}
75
79
  key_data = spectral.get("key")
76
80
  if key_data:
77
81
  snapshot["spectral"]["detected_key"] = key_data["value"]
78
82
  except Exception as exc:
79
83
  logger.debug("_fetch_project_snapshot failed: %s", exc)
80
84
 
81
- # Try to get session info for pacing / density
85
+ # Try to get session info for pacing / density / stereo width
82
86
  try:
83
87
  session_info = ableton.send_command("get_session_info", {})
84
88
  track_count = session_info.get("track_count", 0)
@@ -86,6 +90,22 @@ def _fetch_project_snapshot(ctx: Context) -> dict:
86
90
  # Rough density estimate
87
91
  snapshot["density"] = min(1.0, track_count / 20.0)
88
92
  snapshot["pacing"] = [{"label": f"scene_{i}", "bars": 8} for i in range(scene_count)]
93
+
94
+ # Estimate project stereo width from track pans (mirrors the
95
+ # pan-spread estimate in translation_engine/tools.py). Without this,
96
+ # snapshot["width"] stayed 0.0 and the width gap always reported the
97
+ # full reference width.
98
+ tracks = session_info.get("tracks", [])
99
+ if tracks:
100
+ def _get_pan(t: dict) -> float:
101
+ mixer = t.get("mixer")
102
+ if isinstance(mixer, dict):
103
+ return abs(mixer.get("panning", 0.0))
104
+ return abs(t.get("pan", 0.0))
105
+
106
+ pan_values = [_get_pan(t) for t in tracks if not t.get("muted", False)]
107
+ if pan_values:
108
+ snapshot["width"] = min(1.0, sum(pan_values) / max(len(pan_values), 1))
89
109
  except Exception as exc:
90
110
  logger.debug("_fetch_project_snapshot failed: %s", exc)
91
111
 
@@ -66,6 +66,12 @@ MCP_TOOLS: frozenset[str] = frozenset({
66
66
  # set_drum_chain_note + insert_device + replace_sample_native. Used by
67
67
  # the create_drum_rack_pad semantic move.
68
68
  "add_drum_rack_pad",
69
+ # Routing-correctness (v1.27.2): these have @mcp.tool wrappers that call
70
+ # the TCP Remote Script / read the SpectralCache in-process. They must
71
+ # classify as mcp_tool so plan steps take the SAME path as direct callers
72
+ # — not the M4L JS bridge, and not the "unknown" dead-end.
73
+ "compressor_set_sidechain", # was mis-listed in BRIDGE_COMMANDS → JS bridge
74
+ "get_master_rms", # was READ_ONLY but unclassified → plan failure
69
75
  })
70
76
 
71
77
 
@@ -6,6 +6,7 @@ responses and exposes feature flags for tool routing.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ import re
9
10
  from dataclasses import dataclass
10
11
 
11
12
 
@@ -19,21 +20,39 @@ class LiveVersionCapabilities:
19
20
 
20
21
  @classmethod
21
22
  def from_version_string(cls, version_str: str) -> LiveVersionCapabilities:
22
- """Parse '12.3.6' into a capabilities instance."""
23
- parts = version_str.split(".")
24
- major = int(parts[0]) if len(parts) > 0 else 12
25
- minor = int(parts[1]) if len(parts) > 1 else 0
26
- patch = int(parts[2]) if len(parts) > 2 else 0
23
+ """Parse '12.3.6' into a capabilities instance.
24
+
25
+ Tolerant of malformed input: extracts the leading integer of each
26
+ dotted component and falls back to the conservative floor
27
+ (major=12, minor=0, patch=0) for missing or non-numeric components.
28
+ Mirrors the Remote Script's degrade-to-(12,0,0) contract
29
+ (remote_script/LivePilot/version_detect.py) so a junk live_version
30
+ string never crashes get_capability_state / get_session_kernel /
31
+ --doctor.
32
+ """
33
+ def _component(parts: list[str], index: int, default: int) -> int:
34
+ if index >= len(parts):
35
+ return default
36
+ match = re.match(r"\s*(\d+)", parts[index])
37
+ return int(match.group(1)) if match else default
38
+
39
+ parts = (version_str or "").split(".")
40
+ major = _component(parts, 0, 12)
41
+ minor = _component(parts, 1, 0)
42
+ patch = _component(parts, 2, 0)
27
43
  return cls(major=major, minor=minor, patch=patch)
28
44
 
29
45
  @classmethod
30
46
  def from_session_info(cls, session_info: dict) -> LiveVersionCapabilities:
31
47
  """Extract version from get_session_info response.
32
48
 
33
- Looks for 'live_version' field. Falls back to 12.0.0 if absent
34
- (pre-upgrade Remote Script).
49
+ Looks for 'live_version' field. Falls back to 12.0.0 if absent,
50
+ null, or empty (pre-upgrade Remote Script). Uses a falsy-guard
51
+ rather than a key-presence default so a present-but-empty value
52
+ degrades to the conservative floor, aligning with the sibling
53
+ readers in runtime/tools.py and runtime/capability_probe.py.
35
54
  """
36
- version_str = session_info.get("live_version", "12.0.0")
55
+ version_str = session_info.get("live_version") or "12.0.0"
37
56
  return cls.from_version_string(version_str)
38
57
 
39
58
  @property
@@ -153,6 +153,25 @@ async def _add_drum_rack_pad(params: dict, ctx: Any = None) -> dict:
153
153
  return await _call(add_drum_rack_pad, ctx, params)
154
154
 
155
155
 
156
+ # ── Routing-correctness (v1.27.2) ─────────────────────────────────────────
157
+ #
158
+ # Both have @mcp.tool wrappers in tools/analyzer.py. compressor_set_sidechain
159
+ # was mis-listed in BRIDGE_COMMANDS, so plan steps silently routed to the M4L
160
+ # JS bridge while direct callers used the TCP Remote Script — divergent paths
161
+ # with different error handling. get_master_rms was tagged READ_ONLY but never
162
+ # classified (classify_step returned "unknown"), so plans could not use it at
163
+ # all. Both now dispatch in-process here, matching their direct @mcp.tool path.
164
+
165
+ async def _compressor_set_sidechain(params: dict, ctx: Any = None) -> dict:
166
+ from ..tools.analyzer import compressor_set_sidechain
167
+ return await _call(compressor_set_sidechain, ctx, params)
168
+
169
+
170
+ async def _get_master_rms(params: dict, ctx: Any = None) -> dict:
171
+ from ..tools.analyzer import get_master_rms
172
+ return await _call(get_master_rms, ctx, params)
173
+
174
+
156
175
  def build_mcp_dispatch_registry() -> dict[str, Callable]:
157
176
  """Return the canonical registry of MCP-only tools for plan execution.
158
177
 
@@ -186,4 +205,7 @@ def build_mcp_dispatch_registry() -> dict[str, Callable]:
186
205
  "add_session_memory": _add_session_memory,
187
206
  # v1.20 — drum rack pad construction (async orchestrator).
188
207
  "add_drum_rack_pad": _add_drum_rack_pad,
208
+ # v1.27.2 — routing-correctness (see adapters above).
209
+ "compressor_set_sidechain": _compressor_set_sidechain,
210
+ "get_master_rms": _get_master_rms,
189
211
  }
@@ -139,9 +139,11 @@ BRIDGE_COMMANDS: frozenset[str] = frozenset({
139
139
  "get_params", "get_hidden_params", "get_auto_state", "walk_rack",
140
140
  "get_chains_deep", "get_track_cpu", "get_selected", "get_key",
141
141
  "get_clip_file_path", "replace_simpler_sample", "get_simpler_slices",
142
- "get_simpler_file_path", # v1.23.3closes the v1.12 follow-up that
143
- # left classify_simpler_slices unable to
144
- # auto-resolve the Simpler's sample path
142
+ # NOTE: get_simpler_file_path is NOT here it lives in REMOTE_COMMANDS
143
+ # (the primary Python LOM path since v1.23.3). classify_step checks
144
+ # REMOTE first, so listing it here too was dead for dispatch. The
145
+ # backwards-compat bridge call still exists, but analyzer.py invokes it
146
+ # directly via bridge.send_command(), bypassing classify_step.
145
147
  "crop_simpler", "reverse_simpler", "warp_simpler",
146
148
  "get_warp_markers", "add_warp_marker", "move_warp_marker",
147
149
  "remove_warp_marker", "capture_audio", "capture_stop",
@@ -152,7 +154,10 @@ BRIDGE_COMMANDS: frozenset[str] = frozenset({
152
154
  # only Max JS LiveAPI exposes). See mcp_server/tools/analyzer.py for
153
155
  # the matching MCP tools that route through bridge.send_command.
154
156
  "simpler_set_warp",
155
- "compressor_set_sidechain",
157
+ # NOTE: compressor_set_sidechain is NOT here — its @mcp.tool wrapper calls
158
+ # the TCP Remote Script ("set_compressor_sidechain", the BUG-A3 Python
159
+ # path), so it belongs in MCP_TOOLS. Listing it here routed plan steps to
160
+ # the M4L JS bridge, a divergent path. Moved to execution_router.MCP_TOOLS.
156
161
  # NOTE: load_sample_to_simpler used to live here, but it's actually an
157
162
  # async Python MCP tool in mcp_server/tools/analyzer.py, not a bridge
158
163
  # command. It has no case in livepilot_bridge.js and no @register handler
@@ -56,6 +56,15 @@ CONFIRM_REQUIRED_ACTIONS: set[str] = {
56
56
  "replace_simpler_sample",
57
57
  }
58
58
 
59
+ # Names that LOOK read-only by prefix but actually mutate session state.
60
+ # These must NOT be classified as read-only — they are intersected against
61
+ # the prefix matcher below so a mutating verb never slips through a read
62
+ # prefix (e.g. ``find_and_load_device`` starts with ``find_`` but loads a
63
+ # device, a real mutation routed through REMOTE_COMMANDS).
64
+ _MUTATING_OVERRIDES: set[str] = {
65
+ "find_and_load_device",
66
+ }
67
+
59
68
  # Read-only prefixes — any action starting with one of these is a read.
60
69
  _READ_ONLY_PREFIXES = (
61
70
  "get_",
@@ -203,6 +212,8 @@ _WIDE_SCOPE_THRESHOLD = 5 # tracks
203
212
 
204
213
  def is_read_only_action(action: str) -> bool:
205
214
  """Return True if *action* is a non-mutating read/query."""
215
+ if action in _MUTATING_OVERRIDES:
216
+ return False
206
217
  if action in SAFE_ACTIONS:
207
218
  return True
208
219
  return action.startswith(_READ_ONLY_PREFIXES)
@@ -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"] = (
@@ -362,7 +362,7 @@ def _get_all_tools():
362
362
  to the next path rather than exploding.
363
363
 
364
364
  WARNING: Accesses FastMCP private internals. Pinned to
365
- fastmcp>=3.3.1,<3.4.0 in requirements.txt. The startup self-test
365
+ fastmcp>=3.4.2,<3.5.0 in requirements.txt. The startup self-test
366
366
  (_assert_tool_registry_accessible) will fail loudly if every probe
367
367
  returns empty — better than silently returning [] and disabling
368
368
  schema coercion.
@@ -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
@@ -45,6 +45,7 @@ import logging
45
45
  import os
46
46
  import ssl
47
47
  import urllib.error
48
+ import urllib.parse
48
49
  import urllib.request
49
50
  from dataclasses import dataclass, field
50
51
  from typing import Any, Optional
@@ -89,6 +90,10 @@ class SpliceHTTPConfig:
89
90
  user_agent: str = "LivePilot/1.16 (+splice-http-bridge)"
90
91
  timeout_sec: float = 30.0
91
92
  max_retries: int = 2
93
+ # Opt-in escape hatch: when True, the bearer-token host guard in
94
+ # SpliceHTTPBridge._request is bypassed so a custom (non-splice.com)
95
+ # base_url may receive the session token. Defaults off.
96
+ allow_unverified_endpoints: bool = False
92
97
  # Whether any of the above values came from user config (file or env)
93
98
  # rather than the built-in defaults. Used by `is_user_configured`.
94
99
  _user_configured: bool = False
@@ -135,6 +140,7 @@ class SpliceHTTPConfig:
135
140
  "splice.json: %s must be an integer", key,
136
141
  )
137
142
  if data.get("allow_unverified_endpoints"):
143
+ instance.allow_unverified_endpoints = True
138
144
  loaded_from_file = True
139
145
  except (OSError, json.JSONDecodeError) as exc:
140
146
  logger.warning(
@@ -161,6 +167,9 @@ class SpliceHTTPConfig:
161
167
  "Env %s has invalid value: %s", env_name, exc,
162
168
  )
163
169
 
170
+ if os.environ.get("SPLICE_ALLOW_UNVERIFIED_ENDPOINTS") == "1":
171
+ instance.allow_unverified_endpoints = True
172
+
164
173
  instance._user_configured = (
165
174
  loaded_from_file
166
175
  or env_configured
@@ -406,6 +415,23 @@ class SpliceHTTPError(Exception):
406
415
  }
407
416
 
408
417
 
418
+ def _is_trusted_splice_host(url: str) -> bool:
419
+ """True only for HTTPS URLs on a splice.com-owned host.
420
+
421
+ Gates attaching the rotating session bearer token: a poisoned base_url
422
+ (custom config file or SPLICE_API_BASE_URL env) must not be able to
423
+ exfiltrate the token to an attacker-controlled endpoint.
424
+ """
425
+ try:
426
+ parsed = urllib.parse.urlparse(url)
427
+ except (ValueError, AttributeError):
428
+ return False
429
+ if parsed.scheme != "https":
430
+ return False
431
+ host = (parsed.hostname or "").lower()
432
+ return host == "splice.com" or host.endswith(".splice.com")
433
+
434
+
409
435
  class SpliceHTTPBridge:
410
436
  """Low-level HTTPS client for Splice cloud APIs.
411
437
 
@@ -442,10 +468,26 @@ class SpliceHTTPBridge:
442
468
 
443
469
  url = self.config.base_url.rstrip("/") + path
444
470
  if query:
445
- import urllib.parse
446
471
  qs = urllib.parse.urlencode(query)
447
472
  url = f"{url}?{qs}"
448
473
 
474
+ # Token-exfil guard: only attach the Splice session bearer when the
475
+ # destination is a splice.com-owned HTTPS host. A poisoned base_url
476
+ # (custom config/env) otherwise leaks the rotating session token to an
477
+ # attacker-controlled endpoint. Explicit opt-in via
478
+ # allow_unverified_endpoints / SPLICE_ALLOW_UNVERIFIED_ENDPOINTS=1.
479
+ if not _is_trusted_splice_host(url) and not self.config.allow_unverified_endpoints:
480
+ raise SpliceHTTPError(
481
+ code="UNTRUSTED_ENDPOINT",
482
+ message=(
483
+ f"Refusing to send the Splice session token to non-Splice host "
484
+ f"'{self.config.base_url}'. Set allow_unverified_endpoints in "
485
+ f"~/.livepilot/splice.json (or SPLICE_ALLOW_UNVERIFIED_ENDPOINTS=1) "
486
+ f"to override."
487
+ ),
488
+ endpoint=path,
489
+ )
490
+
449
491
  data_bytes = None
450
492
  headers = {
451
493
  "Authorization": f"Bearer {token}",