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.
- package/CHANGELOG.md +68 -0
- package/README.md +26 -7
- package/bin/livepilot.js +86 -23
- package/installer/install.js +21 -12
- package/livepilot/.Codex-plugin/plugin.json +1 -1
- package/livepilot/.claude-plugin/plugin.json +1 -1
- package/livepilot/skills/livepilot-core/SKILL.md +8 -8
- package/livepilot/skills/livepilot-core/references/overview.md +2 -2
- package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/livepilot_bridge.js +48 -11
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/__init__.py +20 -1
- package/mcp_server/atlas/tools.py +83 -22
- package/mcp_server/composer/fast/brief_builder.py +6 -2
- package/mcp_server/composer/full/apply.py +9 -0
- package/mcp_server/composer/full/layer_planner.py +16 -6
- package/mcp_server/composer/tools.py +12 -6
- package/mcp_server/connection.py +2 -1
- package/mcp_server/creative_constraints/engine.py +46 -0
- package/mcp_server/experiment/engine.py +10 -3
- package/mcp_server/grader/client.py +30 -2
- package/mcp_server/grader/tools.py +8 -2
- package/mcp_server/hook_hunter/analyzer.py +15 -2
- package/mcp_server/m4l_bridge.py +145 -54
- package/mcp_server/memory/taste_graph.py +16 -0
- package/mcp_server/memory/technique_store.py +23 -3
- package/mcp_server/mix_engine/state_builder.py +4 -2
- package/mcp_server/musical_intelligence/detectors.py +11 -2
- package/mcp_server/musical_intelligence/tools.py +15 -2
- package/mcp_server/preview_studio/engine.py +30 -1
- package/mcp_server/project_brain/tools.py +56 -52
- package/mcp_server/reference_engine/tools.py +22 -2
- package/mcp_server/runtime/execution_router.py +6 -0
- package/mcp_server/runtime/live_version.py +27 -8
- package/mcp_server/runtime/mcp_dispatch.py +22 -0
- package/mcp_server/runtime/remote_commands.py +9 -4
- package/mcp_server/runtime/safety_kernel.py +11 -0
- package/mcp_server/sample_engine/critics.py +7 -3
- package/mcp_server/sample_engine/slice_workflow.py +3 -2
- package/mcp_server/sample_engine/tools.py +53 -21
- package/mcp_server/server.py +1 -1
- package/mcp_server/song_brain/builder.py +17 -1
- package/mcp_server/sound_design/tools.py +1 -1
- package/mcp_server/splice_client/http_bridge.py +43 -1
- package/mcp_server/splice_client/models.py +7 -3
- package/mcp_server/synthesis_brain/adapters/analog.py +13 -1
- package/mcp_server/synthesis_brain/adapters/operator.py +5 -2
- package/mcp_server/synthesis_brain/adapters/wavetable.py +4 -4
- package/mcp_server/tools/_composition_engine/models.py +6 -0
- package/mcp_server/tools/_composition_engine/sections.py +4 -0
- package/mcp_server/tools/clips.py +5 -4
- package/mcp_server/tools/composition.py +5 -1
- package/mcp_server/tools/midi_io.py +40 -1
- package/mcp_server/tools/transport.py +1 -1
- package/mcp_server/user_corpus/plugin_engine/detector.py +66 -11
- package/mcp_server/user_corpus/runner.py +7 -1
- package/mcp_server/user_corpus/scanners/amxd.py +24 -13
- package/mcp_server/user_corpus/tools.py +45 -9
- package/mcp_server/wonder_mode/tools.py +66 -27
- package/package.json +1 -1
- package/remote_script/LivePilot/__init__.py +21 -8
- package/remote_script/LivePilot/server.py +23 -3
- package/remote_script/LivePilot/tracks.py +11 -5
- package/requirements.txt +1 -1
- 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.
|
|
88
|
-
#
|
|
89
|
-
#
|
|
90
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
160
|
-
if
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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"
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
|
306
|
-
|
|
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
|
|
16
|
-
|
|
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
|
-
|
|
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=[
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
"
|
|
342
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1055
|
-
|
|
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"] = (
|
package/mcp_server/server.py
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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, "
|
|
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}",
|