livepilot 1.10.5 → 1.10.7
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/.claude-plugin/marketplace.json +3 -3
- package/.mcp.json.disabled +9 -0
- package/.mcpbignore +3 -0
- package/AGENTS.md +3 -3
- package/BUGS.md +1570 -0
- package/CHANGELOG.md +92 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +7 -7
- package/bin/livepilot.js +28 -8
- package/livepilot/.Codex-plugin/plugin.json +2 -2
- package/livepilot/.claude-plugin/plugin.json +2 -2
- package/livepilot/skills/livepilot-core/SKILL.md +4 -4
- package/livepilot/skills/livepilot-core/references/overview.md +2 -2
- package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
- package/livepilot/skills/livepilot-release/SKILL.md +8 -8
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/LivePilot_Analyzer.amxd.pre-presentation-backup +0 -0
- package/m4l_device/LivePilot_Analyzer.maxproj +53 -0
- package/m4l_device/livepilot_bridge.js +226 -3
- package/manifest.json +3 -3
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/__init__.py +93 -26
- package/mcp_server/composer/sample_resolver.py +10 -6
- package/mcp_server/composer/tools.py +10 -6
- package/mcp_server/connection.py +6 -1
- package/mcp_server/creative_constraints/tools.py +214 -40
- package/mcp_server/experiment/engine.py +16 -14
- package/mcp_server/experiment/tools.py +9 -9
- package/mcp_server/hook_hunter/analyzer.py +62 -9
- package/mcp_server/hook_hunter/tools.py +74 -18
- package/mcp_server/m4l_bridge.py +32 -6
- package/mcp_server/memory/taste_graph.py +7 -2
- package/mcp_server/mix_engine/tools.py +8 -3
- package/mcp_server/musical_intelligence/detectors.py +32 -0
- package/mcp_server/musical_intelligence/tools.py +15 -10
- package/mcp_server/performance_engine/tools.py +117 -30
- package/mcp_server/preview_studio/engine.py +89 -8
- package/mcp_server/preview_studio/tools.py +43 -21
- package/mcp_server/project_brain/automation_graph.py +71 -19
- package/mcp_server/project_brain/builder.py +2 -0
- package/mcp_server/project_brain/tools.py +73 -15
- package/mcp_server/reference_engine/profile_builder.py +129 -3
- package/mcp_server/reference_engine/tools.py +54 -11
- package/mcp_server/runtime/capability_probe.py +10 -4
- package/mcp_server/runtime/execution_router.py +50 -0
- package/mcp_server/runtime/mcp_dispatch.py +75 -3
- package/mcp_server/runtime/remote_commands.py +4 -2
- package/mcp_server/runtime/tools.py +8 -2
- package/mcp_server/sample_engine/analyzer.py +131 -4
- package/mcp_server/sample_engine/critics.py +29 -8
- package/mcp_server/sample_engine/models.py +20 -1
- package/mcp_server/sample_engine/tools.py +74 -31
- package/mcp_server/semantic_moves/sound_design_compilers.py +22 -59
- package/mcp_server/semantic_moves/tools.py +5 -1
- package/mcp_server/semantic_moves/transition_compilers.py +12 -19
- package/mcp_server/server.py +78 -11
- package/mcp_server/services/motif_service.py +9 -3
- package/mcp_server/session_continuity/models.py +4 -0
- package/mcp_server/session_continuity/tools.py +7 -3
- package/mcp_server/session_continuity/tracker.py +23 -9
- package/mcp_server/song_brain/builder.py +110 -12
- package/mcp_server/song_brain/tools.py +94 -25
- package/mcp_server/sound_design/tools.py +112 -1
- package/mcp_server/splice_client/client.py +19 -6
- package/mcp_server/stuckness_detector/detector.py +90 -0
- package/mcp_server/stuckness_detector/tools.py +49 -5
- package/mcp_server/tools/_agent_os_engine/__init__.py +52 -0
- package/mcp_server/tools/_agent_os_engine/critics.py +158 -0
- package/mcp_server/tools/_agent_os_engine/evaluation.py +206 -0
- package/mcp_server/tools/_agent_os_engine/models.py +132 -0
- package/mcp_server/tools/_agent_os_engine/taste.py +192 -0
- package/mcp_server/tools/_agent_os_engine/techniques.py +161 -0
- package/mcp_server/tools/_agent_os_engine/world_model.py +170 -0
- package/mcp_server/tools/_composition_engine/__init__.py +67 -0
- package/mcp_server/tools/_composition_engine/analysis.py +174 -0
- package/mcp_server/tools/_composition_engine/critics.py +522 -0
- package/mcp_server/tools/_composition_engine/gestures.py +230 -0
- package/mcp_server/tools/_composition_engine/harmony.py +160 -0
- package/mcp_server/tools/_composition_engine/models.py +193 -0
- package/mcp_server/tools/_composition_engine/sections.py +414 -0
- package/mcp_server/tools/_harmony_engine.py +52 -8
- package/mcp_server/tools/_perception_engine.py +18 -11
- package/mcp_server/tools/_research_engine.py +98 -19
- package/mcp_server/tools/_theory_engine.py +138 -9
- package/mcp_server/tools/agent_os.py +43 -18
- package/mcp_server/tools/analyzer.py +105 -8
- package/mcp_server/tools/automation.py +6 -1
- package/mcp_server/tools/clips.py +45 -0
- package/mcp_server/tools/composition.py +90 -38
- package/mcp_server/tools/devices.py +32 -7
- package/mcp_server/tools/harmony.py +115 -14
- package/mcp_server/tools/midi_io.py +13 -1
- package/mcp_server/tools/mixing.py +35 -1
- package/mcp_server/tools/motif.py +56 -5
- package/mcp_server/tools/planner.py +6 -2
- package/mcp_server/tools/research.py +37 -10
- package/mcp_server/tools/theory.py +108 -16
- package/mcp_server/transition_engine/critics.py +18 -11
- package/mcp_server/transition_engine/tools.py +6 -1
- package/mcp_server/translation_engine/tools.py +8 -6
- package/mcp_server/wonder_mode/engine.py +8 -3
- package/mcp_server/wonder_mode/tools.py +29 -21
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +57 -2
- package/remote_script/LivePilot/clips.py +69 -0
- package/remote_script/LivePilot/mixing.py +117 -0
- package/remote_script/LivePilot/router.py +13 -1
- package/scripts/generate_tool_catalog.py +13 -38
- package/scripts/sync_metadata.py +231 -14
- package/mcp_server/tools/_agent_os_engine.py +0 -947
- package/mcp_server/tools/_composition_engine.py +0 -1530
|
@@ -96,11 +96,22 @@ def build_style_reference_profile(style_tactics: list[dict]) -> ReferenceProfile
|
|
|
96
96
|
# Estimate density from arrangement pattern count
|
|
97
97
|
density_arc = _estimate_density_from_patterns(style_tactics)
|
|
98
98
|
|
|
99
|
+
# BUG-B50 fix: the old code hardcoded loudness_posture=0.0 and
|
|
100
|
+
# empty spectral_contour / width_depth because StyleTactic entries
|
|
101
|
+
# don't carry those fields. We now derive them heuristically from
|
|
102
|
+
# the device_chain (each device's params leak its intended sonic
|
|
103
|
+
# shape — e.g., Auto Filter at 800Hz low-pass = darker spectrum,
|
|
104
|
+
# Utility Width > 0.6 = wider stereo). Rough but non-empty values
|
|
105
|
+
# are better than zeros for downstream reference-gap analysis.
|
|
106
|
+
loudness_posture = _derive_loudness_posture(style_tactics)
|
|
107
|
+
spectral_contour = _derive_spectral_contour(style_tactics)
|
|
108
|
+
width_depth = _derive_width_depth(style_tactics)
|
|
109
|
+
|
|
99
110
|
return ReferenceProfile(
|
|
100
111
|
source_type="style",
|
|
101
|
-
loudness_posture=
|
|
102
|
-
spectral_contour=
|
|
103
|
-
width_depth=
|
|
112
|
+
loudness_posture=loudness_posture,
|
|
113
|
+
spectral_contour=spectral_contour,
|
|
114
|
+
width_depth=width_depth,
|
|
104
115
|
density_arc=density_arc,
|
|
105
116
|
section_pacing=section_pacing,
|
|
106
117
|
harmonic_character=harmonic_character,
|
|
@@ -147,3 +158,118 @@ def _estimate_density_from_patterns(style_tactics: list[dict]) -> list[float]:
|
|
|
147
158
|
densities.append(min(0.9, 0.3 + n * 0.15))
|
|
148
159
|
|
|
149
160
|
return densities
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ── BUG-B50 derivations — style → loudness/spectral/width heuristics ──────
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _derive_loudness_posture(style_tactics: list[dict]) -> float:
|
|
167
|
+
"""Heuristic: compression / saturation / limiter devices imply a
|
|
168
|
+
specific loudness posture. Glue / Ratio>3 → loud, saturator drive
|
|
169
|
+
high → cranked/loud, delay/reverb-heavy → quieter mix."""
|
|
170
|
+
loud_signals = 0
|
|
171
|
+
quiet_signals = 0
|
|
172
|
+
for tactic in style_tactics:
|
|
173
|
+
for dev in tactic.get("device_chain", []):
|
|
174
|
+
name = str(dev.get("name", "")).lower()
|
|
175
|
+
params = dev.get("params", {}) or {}
|
|
176
|
+
if "glue" in name or "limiter" in name:
|
|
177
|
+
loud_signals += 2
|
|
178
|
+
if name == "saturator" or "overdrive" in name:
|
|
179
|
+
drive = float(params.get("Drive", 0) or 0)
|
|
180
|
+
if drive >= 6:
|
|
181
|
+
loud_signals += 1
|
|
182
|
+
if name == "compressor":
|
|
183
|
+
ratio = float(params.get("Ratio", 0) or 0)
|
|
184
|
+
if ratio >= 4:
|
|
185
|
+
loud_signals += 1
|
|
186
|
+
if name == "reverb":
|
|
187
|
+
# Heavy reverb tails push the mix quieter
|
|
188
|
+
wet = float(params.get("Dry/Wet", 0) or 0)
|
|
189
|
+
if wet >= 0.6:
|
|
190
|
+
quiet_signals += 1
|
|
191
|
+
# Normalize to -1..+1 range (loud=+1, quiet=-1, neutral=0)
|
|
192
|
+
if loud_signals == 0 and quiet_signals == 0:
|
|
193
|
+
return 0.0
|
|
194
|
+
net = (loud_signals - quiet_signals) / max(
|
|
195
|
+
loud_signals + quiet_signals, 1
|
|
196
|
+
)
|
|
197
|
+
return round(net, 2)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _derive_spectral_contour(style_tactics: list[dict]) -> dict:
|
|
201
|
+
"""Heuristic: Auto Filter frequencies + device names leak the
|
|
202
|
+
target spectrum. Low-pass filters → dark, EQ Eight with no params
|
|
203
|
+
→ neutral, saturators → mid-forward."""
|
|
204
|
+
brightness = 0.5 # neutral starting point
|
|
205
|
+
mid_emphasis = 0.5
|
|
206
|
+
dark_hits = 0
|
|
207
|
+
bright_hits = 0
|
|
208
|
+
for tactic in style_tactics:
|
|
209
|
+
for dev in tactic.get("device_chain", []):
|
|
210
|
+
name = str(dev.get("name", "")).lower()
|
|
211
|
+
params = dev.get("params", {}) or {}
|
|
212
|
+
if "auto filter" in name or name == "autofilter":
|
|
213
|
+
freq = float(params.get("Frequency", 1500) or 1500)
|
|
214
|
+
# Ableton Auto Filter Frequency is 20-22000; below 2k hints dark
|
|
215
|
+
if freq < 2000:
|
|
216
|
+
dark_hits += 1
|
|
217
|
+
elif freq > 5000:
|
|
218
|
+
bright_hits += 1
|
|
219
|
+
if "saturator" in name or "overdrive" in name:
|
|
220
|
+
mid_emphasis += 0.1
|
|
221
|
+
if dark_hits > bright_hits:
|
|
222
|
+
brightness = 0.3
|
|
223
|
+
elif bright_hits > dark_hits:
|
|
224
|
+
brightness = 0.75
|
|
225
|
+
return {
|
|
226
|
+
"band_balance": {
|
|
227
|
+
"sub": 0.45,
|
|
228
|
+
"low": 0.5,
|
|
229
|
+
"mid": min(1.0, mid_emphasis),
|
|
230
|
+
"high_mid": round(0.3 + brightness * 0.4, 2),
|
|
231
|
+
"high": round(0.2 + brightness * 0.5, 2),
|
|
232
|
+
},
|
|
233
|
+
"brightness": round(brightness, 2),
|
|
234
|
+
"centroid_hint": "dark" if brightness < 0.4
|
|
235
|
+
else "bright" if brightness > 0.65
|
|
236
|
+
else "neutral",
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _derive_width_depth(style_tactics: list[dict]) -> dict:
|
|
241
|
+
"""Heuristic: Utility Width, Chorus-Ensemble, or heavy reverb
|
|
242
|
+
implies a wider stereo field; dry chains imply narrow."""
|
|
243
|
+
width_value = 0.5
|
|
244
|
+
depth_value = 0.5
|
|
245
|
+
for tactic in style_tactics:
|
|
246
|
+
for dev in tactic.get("device_chain", []):
|
|
247
|
+
name = str(dev.get("name", "")).lower()
|
|
248
|
+
params = dev.get("params", {}) or {}
|
|
249
|
+
if name == "utility":
|
|
250
|
+
w = params.get("Width")
|
|
251
|
+
if w is not None:
|
|
252
|
+
try:
|
|
253
|
+
width_value = max(width_value, float(w))
|
|
254
|
+
except (TypeError, ValueError):
|
|
255
|
+
pass
|
|
256
|
+
if "reverb" in name:
|
|
257
|
+
wet = float(params.get("Dry/Wet", 0) or 0)
|
|
258
|
+
if wet > 0.4:
|
|
259
|
+
depth_value = max(depth_value, 0.7)
|
|
260
|
+
width_value = max(width_value, 0.65)
|
|
261
|
+
if "chorus" in name or "ensemble" in name:
|
|
262
|
+
width_value = max(width_value, 0.75)
|
|
263
|
+
if "delay" in name:
|
|
264
|
+
wet = float(params.get("Dry/Wet", 0) or 0)
|
|
265
|
+
if wet > 0.2:
|
|
266
|
+
depth_value = max(depth_value, 0.6)
|
|
267
|
+
return {
|
|
268
|
+
"stereo_width": round(width_value, 2),
|
|
269
|
+
"depth": round(depth_value, 2),
|
|
270
|
+
"depth_hint": (
|
|
271
|
+
"deep" if depth_value > 0.6 else
|
|
272
|
+
"intimate" if depth_value < 0.4 else
|
|
273
|
+
"neutral"
|
|
274
|
+
),
|
|
275
|
+
}
|
|
@@ -13,7 +13,9 @@ from ..tools._research_engine import get_style_tactics
|
|
|
13
13
|
from .profile_builder import build_audio_reference_profile, build_style_reference_profile
|
|
14
14
|
from .gap_analyzer import analyze_gaps, classify_gap_relevance, detect_identity_warnings
|
|
15
15
|
from .tactic_router import build_reference_plan
|
|
16
|
+
import logging
|
|
16
17
|
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
17
19
|
|
|
18
20
|
# ── Helpers ────────────────────────────────────────────────────────
|
|
19
21
|
|
|
@@ -28,6 +30,20 @@ def _fetch_comparison_data(ctx: Context, mix_path: str, reference_path: str) ->
|
|
|
28
30
|
return compare_to_reference(mix_path, reference_path, normalize=True)
|
|
29
31
|
|
|
30
32
|
|
|
33
|
+
def _fetch_memory_tactics(style: str) -> list[dict]:
|
|
34
|
+
"""BUG-B19: pull user-saved techniques tagged with *style* so they
|
|
35
|
+
feed into the reference-profile lookup path. Best-effort — returns
|
|
36
|
+
empty list when the memory store isn't available."""
|
|
37
|
+
if not style:
|
|
38
|
+
return []
|
|
39
|
+
try:
|
|
40
|
+
from ..tools.research import _memory_store
|
|
41
|
+
return _memory_store.search(query=style, limit=10)
|
|
42
|
+
except Exception as exc:
|
|
43
|
+
logger.debug("_fetch_memory_tactics failed for %r: %s", style, exc)
|
|
44
|
+
return []
|
|
45
|
+
|
|
46
|
+
|
|
31
47
|
def _fetch_project_snapshot(ctx: Context) -> dict:
|
|
32
48
|
"""Build a lightweight project snapshot for gap analysis."""
|
|
33
49
|
ableton = ctx.lifespan_context["ableton"]
|
|
@@ -50,6 +66,7 @@ def _fetch_project_snapshot(ctx: Context) -> dict:
|
|
|
50
66
|
rms = rms_snap["value"] if isinstance(rms_snap["value"], (int, float)) else 0.0
|
|
51
67
|
if rms > 0:
|
|
52
68
|
import math
|
|
69
|
+
|
|
53
70
|
snapshot["loudness"] = round(20 * math.log10(max(rms, 1e-10)), 2)
|
|
54
71
|
|
|
55
72
|
spec_data = spectral.get("spectrum")
|
|
@@ -58,8 +75,8 @@ def _fetch_project_snapshot(ctx: Context) -> dict:
|
|
|
58
75
|
key_data = spectral.get("key")
|
|
59
76
|
if key_data:
|
|
60
77
|
snapshot["spectral"]["detected_key"] = key_data["value"]
|
|
61
|
-
except Exception:
|
|
62
|
-
|
|
78
|
+
except Exception as exc:
|
|
79
|
+
logger.debug("_fetch_project_snapshot failed: %s", exc)
|
|
63
80
|
|
|
64
81
|
# Try to get session info for pacing / density
|
|
65
82
|
try:
|
|
@@ -69,12 +86,11 @@ def _fetch_project_snapshot(ctx: Context) -> dict:
|
|
|
69
86
|
# Rough density estimate
|
|
70
87
|
snapshot["density"] = min(1.0, track_count / 20.0)
|
|
71
88
|
snapshot["pacing"] = [{"label": f"scene_{i}", "bars": 8} for i in range(scene_count)]
|
|
72
|
-
except Exception:
|
|
73
|
-
|
|
89
|
+
except Exception as exc:
|
|
90
|
+
logger.debug("_fetch_project_snapshot failed: %s", exc)
|
|
74
91
|
|
|
75
92
|
return snapshot
|
|
76
93
|
|
|
77
|
-
|
|
78
94
|
# ── MCP Tools ──────────────────────────────────────────────────────
|
|
79
95
|
|
|
80
96
|
|
|
@@ -108,10 +124,21 @@ def build_reference_profile(
|
|
|
108
124
|
return profile.to_dict()
|
|
109
125
|
|
|
110
126
|
if style:
|
|
111
|
-
tactics
|
|
127
|
+
# BUG-B19 fix: also pass user-memory tactics so custom styles
|
|
128
|
+
# (e.g. "prefuse73" saved via memory_learn) resolve to real
|
|
129
|
+
# profiles instead of NOT_FOUND. Silently ignore if memory
|
|
130
|
+
# isn't available (keeps the built-in path working).
|
|
131
|
+
memory_tactics = _fetch_memory_tactics(style)
|
|
132
|
+
tactics = get_style_tactics(style, memory_tactics=memory_tactics)
|
|
112
133
|
if not tactics:
|
|
113
134
|
return {
|
|
114
|
-
"error":
|
|
135
|
+
"error": (
|
|
136
|
+
f"No style tactics found for '{style}' — neither in the "
|
|
137
|
+
f"built-in library (burial / daft punk / techno / ambient / "
|
|
138
|
+
f"trap / lo-fi) nor in your saved memories. Use "
|
|
139
|
+
f"memory_learn to save a '{style}' technique or try "
|
|
140
|
+
f"reference_path=<audio-file> for an audio-based profile."
|
|
141
|
+
),
|
|
115
142
|
"code": "NOT_FOUND",
|
|
116
143
|
}
|
|
117
144
|
tactic_dicts = [t.to_dict() for t in tactics]
|
|
@@ -156,9 +183,17 @@ def analyze_reference_gaps(
|
|
|
156
183
|
return comparison
|
|
157
184
|
profile = build_audio_reference_profile(comparison)
|
|
158
185
|
elif style:
|
|
159
|
-
|
|
186
|
+
# BUG-B19: hydrate from saved memories too
|
|
187
|
+
memory_tactics = _fetch_memory_tactics(style)
|
|
188
|
+
tactics = get_style_tactics(style, memory_tactics=memory_tactics)
|
|
160
189
|
if not tactics:
|
|
161
|
-
return {
|
|
190
|
+
return {
|
|
191
|
+
"error": (
|
|
192
|
+
f"No style tactics found for '{style}' — neither in the "
|
|
193
|
+
f"built-in library nor in saved memories."
|
|
194
|
+
),
|
|
195
|
+
"code": "NOT_FOUND",
|
|
196
|
+
}
|
|
162
197
|
tactic_dicts = [t.to_dict() for t in tactics]
|
|
163
198
|
profile = build_style_reference_profile(tactic_dicts)
|
|
164
199
|
else:
|
|
@@ -210,9 +245,17 @@ def plan_reference_moves(
|
|
|
210
245
|
return comparison
|
|
211
246
|
profile = build_audio_reference_profile(comparison)
|
|
212
247
|
elif style:
|
|
213
|
-
|
|
248
|
+
# BUG-B19: hydrate from saved memories too
|
|
249
|
+
memory_tactics = _fetch_memory_tactics(style)
|
|
250
|
+
tactics = get_style_tactics(style, memory_tactics=memory_tactics)
|
|
214
251
|
if not tactics:
|
|
215
|
-
return {
|
|
252
|
+
return {
|
|
253
|
+
"error": (
|
|
254
|
+
f"No style tactics found for '{style}' — neither in the "
|
|
255
|
+
f"built-in library nor in saved memories."
|
|
256
|
+
),
|
|
257
|
+
"code": "NOT_FOUND",
|
|
258
|
+
}
|
|
216
259
|
tactic_dicts = [t.to_dict() for t in tactics]
|
|
217
260
|
profile = build_style_reference_profile(tactic_dicts)
|
|
218
261
|
else:
|
|
@@ -9,6 +9,9 @@ from __future__ import annotations
|
|
|
9
9
|
import os
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
from typing import Any
|
|
12
|
+
import logging
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
12
15
|
|
|
13
16
|
|
|
14
17
|
def probe_capabilities(
|
|
@@ -27,8 +30,9 @@ def probe_capabilities(
|
|
|
27
30
|
try:
|
|
28
31
|
info = ableton.send_command("ping")
|
|
29
32
|
ableton_ok = info is not None
|
|
30
|
-
except Exception:
|
|
31
|
-
|
|
33
|
+
except Exception as exc:
|
|
34
|
+
logger.debug("probe_capabilities failed: %s", exc)
|
|
35
|
+
|
|
32
36
|
report["ableton"] = {
|
|
33
37
|
"status": "ok" if ableton_ok else "unavailable",
|
|
34
38
|
"detail": "TCP 9878 connection active" if ableton_ok else "Not connected",
|
|
@@ -40,8 +44,9 @@ def probe_capabilities(
|
|
|
40
44
|
try:
|
|
41
45
|
info = ableton.send_command("get_session_info")
|
|
42
46
|
live_version_str = info.get("live_version", "12.0.0")
|
|
43
|
-
except Exception:
|
|
44
|
-
|
|
47
|
+
except Exception as exc:
|
|
48
|
+
logger.debug("probe_capabilities failed: %s", exc)
|
|
49
|
+
|
|
45
50
|
from .live_version import LiveVersionCapabilities
|
|
46
51
|
version_caps = LiveVersionCapabilities.from_version_string(live_version_str)
|
|
47
52
|
report["live_version"] = {
|
|
@@ -75,6 +80,7 @@ def probe_capabilities(
|
|
|
75
80
|
numpy_ok = False
|
|
76
81
|
try:
|
|
77
82
|
import numpy # noqa: F401
|
|
83
|
+
|
|
78
84
|
numpy_ok = True
|
|
79
85
|
except ImportError:
|
|
80
86
|
pass
|
|
@@ -50,6 +50,56 @@ MCP_TOOLS: frozenset[str] = frozenset({
|
|
|
50
50
|
})
|
|
51
51
|
|
|
52
52
|
|
|
53
|
+
# Tools that observe session state without mutating it. Executors use this set
|
|
54
|
+
# to separate "apply pass" steps (writes) from "verification reads". Counting
|
|
55
|
+
# reads toward applied_count and then calling undo that many times walks back
|
|
56
|
+
# earlier user edits — see preview_studio/tools.py and experiment/engine.py.
|
|
57
|
+
READ_ONLY_TOOLS: frozenset[str] = frozenset({
|
|
58
|
+
"get_track_meters",
|
|
59
|
+
"get_master_spectrum",
|
|
60
|
+
"get_master_meters",
|
|
61
|
+
"get_master_rms",
|
|
62
|
+
"get_mix_snapshot",
|
|
63
|
+
"get_session_info",
|
|
64
|
+
"get_track_info",
|
|
65
|
+
"get_track_routing",
|
|
66
|
+
"get_return_tracks",
|
|
67
|
+
"get_device_info",
|
|
68
|
+
"get_device_parameters",
|
|
69
|
+
"get_clip_info",
|
|
70
|
+
"get_notes",
|
|
71
|
+
"get_arrangement_notes",
|
|
72
|
+
"get_arrangement_clips",
|
|
73
|
+
"get_scenes_info",
|
|
74
|
+
"get_scene_matrix",
|
|
75
|
+
"get_playing_clips",
|
|
76
|
+
"get_cue_points",
|
|
77
|
+
"get_rack_chains",
|
|
78
|
+
"get_clip_automation",
|
|
79
|
+
"analyze_mix",
|
|
80
|
+
"get_emotional_arc",
|
|
81
|
+
"get_motif_graph",
|
|
82
|
+
"get_session_diagnostics",
|
|
83
|
+
"ping",
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def filter_apply_steps(steps: list) -> list:
|
|
88
|
+
"""Return only the steps that mutate session state.
|
|
89
|
+
|
|
90
|
+
Read-only steps (meters, spectrum, info) do not create undo points in
|
|
91
|
+
Ableton. Including them in an applied_count and then undoing that many
|
|
92
|
+
times walks back earlier user edits. Always filter writes from reads
|
|
93
|
+
before the apply pass and before the undo loop.
|
|
94
|
+
"""
|
|
95
|
+
out = []
|
|
96
|
+
for s in steps:
|
|
97
|
+
tool = (s.get("tool") if isinstance(s, dict) else getattr(s, "tool", "")) or ""
|
|
98
|
+
if tool and tool not in READ_ONLY_TOOLS:
|
|
99
|
+
out.append(s)
|
|
100
|
+
return out
|
|
101
|
+
|
|
102
|
+
|
|
53
103
|
@dataclass
|
|
54
104
|
class ExecutionResult:
|
|
55
105
|
"""Result of executing a single plan step."""
|
|
@@ -14,18 +14,33 @@ To add a new in-process tool to plans:
|
|
|
14
14
|
2. Add an _adapter function here that imports the real implementation and
|
|
15
15
|
adapts its kwargs from a plan-style params dict.
|
|
16
16
|
3. Register the adapter in build_mcp_dispatch_registry.
|
|
17
|
+
|
|
18
|
+
Every entry in MCP_TOOLS must have a matching adapter here — the contract
|
|
19
|
+
test tests/test_mcp_dispatch_contract.py enforces this.
|
|
17
20
|
"""
|
|
18
21
|
|
|
19
22
|
from __future__ import annotations
|
|
20
23
|
|
|
24
|
+
import inspect
|
|
21
25
|
from typing import Any, Callable
|
|
22
26
|
|
|
23
27
|
|
|
24
|
-
async def
|
|
25
|
-
"""
|
|
28
|
+
async def _call(fn, ctx, params: dict) -> Any:
|
|
29
|
+
"""Call an MCP tool with ctx + kwargs from a plan params dict.
|
|
26
30
|
|
|
27
|
-
|
|
31
|
+
Filters params to the tool's declared parameters, awaits if coroutine.
|
|
32
|
+
Unknown params are dropped silently — plans may carry extra metadata.
|
|
28
33
|
"""
|
|
34
|
+
sig = inspect.signature(fn)
|
|
35
|
+
accepted = set(sig.parameters.keys())
|
|
36
|
+
kwargs = {k: v for k, v in params.items() if k in accepted}
|
|
37
|
+
result = fn(ctx, **kwargs)
|
|
38
|
+
if inspect.isawaitable(result):
|
|
39
|
+
result = await result
|
|
40
|
+
return result
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def _load_sample_to_simpler(params: dict, ctx: Any = None) -> dict:
|
|
29
44
|
from ..tools.analyzer import load_sample_to_simpler
|
|
30
45
|
return await load_sample_to_simpler(
|
|
31
46
|
ctx,
|
|
@@ -35,12 +50,69 @@ async def _load_sample_to_simpler(params: dict, ctx: Any = None) -> dict:
|
|
|
35
50
|
)
|
|
36
51
|
|
|
37
52
|
|
|
53
|
+
async def _apply_automation_shape(params: dict, ctx: Any = None) -> dict:
|
|
54
|
+
from ..tools.automation import apply_automation_shape
|
|
55
|
+
return await _call(apply_automation_shape, ctx, params)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def _apply_gesture_template(params: dict, ctx: Any = None) -> dict:
|
|
59
|
+
from ..tools.composition import apply_gesture_template
|
|
60
|
+
return await _call(apply_gesture_template, ctx, params)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def _analyze_mix(params: dict, ctx: Any = None) -> dict:
|
|
64
|
+
from ..mix_engine.tools import analyze_mix
|
|
65
|
+
return await _call(analyze_mix, ctx, params)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def _get_master_spectrum(params: dict, ctx: Any = None) -> dict:
|
|
69
|
+
from ..tools.analyzer import get_master_spectrum
|
|
70
|
+
return await _call(get_master_spectrum, ctx, params)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def _get_emotional_arc(params: dict, ctx: Any = None) -> dict:
|
|
74
|
+
from ..tools.research import get_emotional_arc
|
|
75
|
+
return await _call(get_emotional_arc, ctx, params)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def _get_motif_graph(params: dict, ctx: Any = None) -> dict:
|
|
79
|
+
from ..tools.motif import get_motif_graph
|
|
80
|
+
return await _call(get_motif_graph, ctx, params)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def _generate_m4l_effect(params: dict, ctx: Any = None) -> dict:
|
|
84
|
+
from ..device_forge.tools import generate_m4l_effect
|
|
85
|
+
return await _call(generate_m4l_effect, ctx, params)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
async def _install_m4l_device(params: dict, ctx: Any = None) -> dict:
|
|
89
|
+
from ..device_forge.tools import install_m4l_device
|
|
90
|
+
return await _call(install_m4l_device, ctx, params)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def _list_genexpr_templates(params: dict, ctx: Any = None) -> dict:
|
|
94
|
+
from ..device_forge.tools import list_genexpr_templates
|
|
95
|
+
return await _call(list_genexpr_templates, ctx, params)
|
|
96
|
+
|
|
97
|
+
|
|
38
98
|
def build_mcp_dispatch_registry() -> dict[str, Callable]:
|
|
39
99
|
"""Return the canonical registry of MCP-only tools for plan execution.
|
|
40
100
|
|
|
41
101
|
Callers (typically the server lifespan init) should call this once and
|
|
42
102
|
pass the registry to execute_plan_steps_async via the mcp_registry kwarg.
|
|
103
|
+
|
|
104
|
+
INVARIANT: the set of keys here must equal MCP_TOOLS in execution_router.
|
|
105
|
+
Enforced by tests/test_mcp_dispatch_contract.py.
|
|
43
106
|
"""
|
|
44
107
|
return {
|
|
45
108
|
"load_sample_to_simpler": _load_sample_to_simpler,
|
|
109
|
+
"apply_automation_shape": _apply_automation_shape,
|
|
110
|
+
"apply_gesture_template": _apply_gesture_template,
|
|
111
|
+
"analyze_mix": _analyze_mix,
|
|
112
|
+
"get_master_spectrum": _get_master_spectrum,
|
|
113
|
+
"get_emotional_arc": _get_emotional_arc,
|
|
114
|
+
"get_motif_graph": _get_motif_graph,
|
|
115
|
+
"generate_m4l_effect": _generate_m4l_effect,
|
|
116
|
+
"install_m4l_device": _install_m4l_device,
|
|
117
|
+
"list_genexpr_templates": _list_genexpr_templates,
|
|
46
118
|
}
|
|
@@ -21,18 +21,20 @@ REMOTE_COMMANDS: frozenset[str] = frozenset({
|
|
|
21
21
|
"set_track_solo", "set_track_arm", "stop_track_clips",
|
|
22
22
|
"set_group_fold", "set_track_input_monitoring",
|
|
23
23
|
"get_freeze_status", "freeze_track", "flatten_track",
|
|
24
|
-
# clips (
|
|
24
|
+
# clips (12)
|
|
25
25
|
"get_clip_info", "create_clip", "delete_clip", "duplicate_clip",
|
|
26
26
|
"fire_clip", "stop_clip", "set_clip_name", "set_clip_color",
|
|
27
27
|
"set_clip_loop", "set_clip_launch", "set_clip_warp_mode",
|
|
28
|
+
"set_clip_pitch",
|
|
28
29
|
# notes (8)
|
|
29
30
|
"add_notes", "get_notes", "remove_notes", "remove_notes_by_id",
|
|
30
31
|
"modify_notes", "duplicate_notes", "transpose_notes", "quantize_clip",
|
|
31
|
-
# mixing (
|
|
32
|
+
# mixing (12)
|
|
32
33
|
"set_track_volume", "set_track_pan", "set_track_send",
|
|
33
34
|
"get_return_tracks", "get_master_track", "set_master_volume",
|
|
34
35
|
"get_track_routing", "get_track_meters", "get_master_meters",
|
|
35
36
|
"get_mix_snapshot", "set_track_routing",
|
|
37
|
+
"set_compressor_sidechain", # BUG-A3 — Python LOM path (was M4L bridge)
|
|
36
38
|
# scenes (12)
|
|
37
39
|
"get_scenes_info", "create_scene", "delete_scene", "duplicate_scene",
|
|
38
40
|
"fire_scene", "set_scene_name", "set_scene_color", "set_scene_tempo",
|
|
@@ -12,6 +12,9 @@ from fastmcp import Context
|
|
|
12
12
|
from ..server import mcp
|
|
13
13
|
from ..memory.technique_store import TechniqueStore
|
|
14
14
|
from .capability_state import build_capability_state
|
|
15
|
+
import logging
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
15
18
|
|
|
16
19
|
_memory_store = TechniqueStore()
|
|
17
20
|
|
|
@@ -31,7 +34,8 @@ def get_capability_state(ctx: Context) -> dict:
|
|
|
31
34
|
try:
|
|
32
35
|
result = ableton.send_command("get_session_info")
|
|
33
36
|
session_ok = isinstance(result, dict) and "error" not in result
|
|
34
|
-
except Exception:
|
|
37
|
+
except Exception as exc:
|
|
38
|
+
logger.debug("get_capability_state failed: %s", exc)
|
|
35
39
|
session_ok = False
|
|
36
40
|
|
|
37
41
|
# ── Probe analyzer (M4L bridge) ─────────────────────────────────
|
|
@@ -49,7 +53,8 @@ def get_capability_state(ctx: Context) -> dict:
|
|
|
49
53
|
try:
|
|
50
54
|
_memory_store.list_techniques(limit=1)
|
|
51
55
|
memory_ok = True
|
|
52
|
-
except Exception:
|
|
56
|
+
except Exception as exc:
|
|
57
|
+
logger.debug("get_capability_state failed: %s", exc)
|
|
53
58
|
memory_ok = False
|
|
54
59
|
|
|
55
60
|
# ── Web / FluCoMa — not probed live, default to False ───────────
|
|
@@ -180,6 +185,7 @@ def get_session_kernel(
|
|
|
180
185
|
if request_text.strip():
|
|
181
186
|
try:
|
|
182
187
|
from ..tools._conductor import classify_request
|
|
188
|
+
|
|
183
189
|
plan = classify_request(request_text)
|
|
184
190
|
kernel.recommended_engines = [r.engine for r in plan.routes[:3]]
|
|
185
191
|
kernel.recommended_workflow = plan.workflow_mode
|