livepilot 1.9.14 → 1.9.16
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/AGENTS.md +3 -3
- package/CHANGELOG.md +82 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +8 -8
- package/livepilot/.Codex-plugin/plugin.json +2 -2
- package/livepilot/.claude-plugin/plugin.json +2 -2
- package/livepilot/agents/livepilot-producer/AGENT.md +243 -49
- package/livepilot/skills/livepilot-core/SKILL.md +81 -6
- package/livepilot/skills/livepilot-core/references/m4l-devices.md +2 -2
- package/livepilot/skills/livepilot-core/references/overview.md +3 -3
- package/livepilot/skills/livepilot-core/references/sound-design.md +3 -2
- package/livepilot/skills/livepilot-release/SKILL.md +13 -13
- package/m4l_device/livepilot_bridge.js +32 -15
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/connection.py +24 -2
- package/mcp_server/curves.py +14 -6
- package/mcp_server/evaluation/__init__.py +1 -0
- package/mcp_server/evaluation/fabric.py +575 -0
- package/mcp_server/evaluation/feature_extractors.py +84 -0
- package/mcp_server/evaluation/policy.py +67 -0
- package/mcp_server/evaluation/tools.py +53 -0
- package/mcp_server/m4l_bridge.py +9 -1
- package/mcp_server/memory/__init__.py +11 -2
- package/mcp_server/memory/anti_memory.py +78 -0
- package/mcp_server/memory/promotion.py +94 -0
- package/mcp_server/memory/session_memory.py +108 -0
- package/mcp_server/memory/taste_memory.py +158 -0
- package/mcp_server/memory/technique_store.py +27 -18
- package/mcp_server/memory/tools.py +112 -0
- package/mcp_server/mix_engine/__init__.py +1 -0
- package/mcp_server/mix_engine/critics.py +299 -0
- package/mcp_server/mix_engine/models.py +152 -0
- package/mcp_server/mix_engine/planner.py +103 -0
- package/mcp_server/mix_engine/state_builder.py +316 -0
- package/mcp_server/mix_engine/tools.py +220 -0
- package/mcp_server/performance_engine/__init__.py +1 -0
- package/mcp_server/performance_engine/models.py +148 -0
- package/mcp_server/performance_engine/planner.py +267 -0
- package/mcp_server/performance_engine/safety.py +165 -0
- package/mcp_server/performance_engine/tools.py +183 -0
- package/mcp_server/project_brain/__init__.py +6 -0
- package/mcp_server/project_brain/arrangement_graph.py +64 -0
- package/mcp_server/project_brain/automation_graph.py +72 -0
- package/mcp_server/project_brain/builder.py +123 -0
- package/mcp_server/project_brain/capability_graph.py +64 -0
- package/mcp_server/project_brain/models.py +282 -0
- package/mcp_server/project_brain/refresh.py +86 -0
- package/mcp_server/project_brain/role_graph.py +103 -0
- package/mcp_server/project_brain/session_graph.py +51 -0
- package/mcp_server/project_brain/tools.py +144 -0
- package/mcp_server/reference_engine/__init__.py +1 -0
- package/mcp_server/reference_engine/gap_analyzer.py +239 -0
- package/mcp_server/reference_engine/models.py +105 -0
- package/mcp_server/reference_engine/profile_builder.py +149 -0
- package/mcp_server/reference_engine/tactic_router.py +117 -0
- package/mcp_server/reference_engine/tools.py +236 -0
- package/mcp_server/runtime/__init__.py +1 -0
- package/mcp_server/runtime/action_ledger.py +117 -0
- package/mcp_server/runtime/action_ledger_models.py +91 -0
- package/mcp_server/runtime/action_tools.py +57 -0
- package/mcp_server/runtime/capability_state.py +219 -0
- package/mcp_server/runtime/safety_kernel.py +339 -0
- package/mcp_server/runtime/safety_tools.py +42 -0
- package/mcp_server/runtime/tools.py +67 -0
- package/mcp_server/server.py +17 -0
- package/mcp_server/sound_design/__init__.py +1 -0
- package/mcp_server/sound_design/critics.py +297 -0
- package/mcp_server/sound_design/models.py +147 -0
- package/mcp_server/sound_design/planner.py +104 -0
- package/mcp_server/sound_design/tools.py +297 -0
- package/mcp_server/tools/_agent_os_engine.py +947 -0
- package/mcp_server/tools/_composition_engine.py +1530 -0
- package/mcp_server/tools/_conductor.py +199 -0
- package/mcp_server/tools/_conductor_budgets.py +222 -0
- package/mcp_server/tools/_evaluation_contracts.py +91 -0
- package/mcp_server/tools/_form_engine.py +416 -0
- package/mcp_server/tools/_motif_engine.py +351 -0
- package/mcp_server/tools/_planner_engine.py +516 -0
- package/mcp_server/tools/_research_engine.py +542 -0
- package/mcp_server/tools/_research_provider.py +185 -0
- package/mcp_server/tools/_snapshot_normalizer.py +49 -0
- package/mcp_server/tools/agent_os.py +448 -0
- package/mcp_server/tools/analyzer.py +18 -0
- package/mcp_server/tools/automation.py +25 -10
- package/mcp_server/tools/composition.py +645 -0
- package/mcp_server/tools/devices.py +15 -1
- package/mcp_server/tools/midi_io.py +3 -1
- package/mcp_server/tools/motif.py +104 -0
- package/mcp_server/tools/planner.py +144 -0
- package/mcp_server/tools/research.py +223 -0
- package/mcp_server/tools/tracks.py +21 -6
- package/mcp_server/tools/transport.py +10 -2
- package/mcp_server/transition_engine/__init__.py +6 -0
- package/mcp_server/transition_engine/archetypes.py +167 -0
- package/mcp_server/transition_engine/critics.py +340 -0
- package/mcp_server/transition_engine/models.py +90 -0
- package/mcp_server/transition_engine/tools.py +291 -0
- package/mcp_server/translation_engine/__init__.py +5 -0
- package/mcp_server/translation_engine/critics.py +297 -0
- package/mcp_server/translation_engine/models.py +27 -0
- package/mcp_server/translation_engine/tools.py +108 -0
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +1 -1
- package/remote_script/LivePilot/arrangement.py +21 -3
- package/remote_script/LivePilot/clips.py +22 -6
- package/remote_script/LivePilot/notes.py +9 -1
- package/remote_script/LivePilot/server.py +6 -6
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
"""Composition Engine V1 MCP tools — structural and musical intelligence.
|
|
2
|
+
|
|
3
|
+
5 tools that connect the pure-computation engine (_composition_engine.py) to the
|
|
4
|
+
live Ableton session via the existing MCP infrastructure.
|
|
5
|
+
|
|
6
|
+
These tools power the composition intelligence layer:
|
|
7
|
+
analyze_composition — full structural analysis (sections, phrases, roles, issues)
|
|
8
|
+
get_section_graph — lightweight section inference only
|
|
9
|
+
get_phrase_grid — phrase boundaries for a section
|
|
10
|
+
plan_gesture — map musical intent to concrete automation plan
|
|
11
|
+
evaluate_composition_move — composition-specific keep/undo scoring
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
from fastmcp import Context
|
|
20
|
+
|
|
21
|
+
from ..server import mcp
|
|
22
|
+
from ..memory.technique_store import TechniqueStore
|
|
23
|
+
from . import _composition_engine as engine
|
|
24
|
+
|
|
25
|
+
_memory_store = TechniqueStore()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _get_ableton(ctx: Context):
|
|
29
|
+
return ctx.lifespan_context["ableton"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _parse_json_param(value, name: str) -> dict:
|
|
33
|
+
"""Parse a dict, JSON string, or None parameter."""
|
|
34
|
+
if value is None:
|
|
35
|
+
return {}
|
|
36
|
+
if isinstance(value, str):
|
|
37
|
+
try:
|
|
38
|
+
return json.loads(value)
|
|
39
|
+
except json.JSONDecodeError as exc:
|
|
40
|
+
raise ValueError(f"Invalid JSON in {name}: {exc}") from exc
|
|
41
|
+
if isinstance(value, dict):
|
|
42
|
+
return value
|
|
43
|
+
raise ValueError(f"{name} must be a dict or JSON string")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _build_clip_matrix(ableton, scene_count: int, track_count: int) -> list[list]:
|
|
47
|
+
"""Build the clip matrix from scene_matrix data."""
|
|
48
|
+
try:
|
|
49
|
+
matrix_data = ableton.send_command("get_scene_matrix")
|
|
50
|
+
raw_matrix = matrix_data.get("matrix", [])
|
|
51
|
+
return raw_matrix
|
|
52
|
+
except Exception:
|
|
53
|
+
return [[] for _ in range(scene_count)]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ── analyze_composition ───────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@mcp.tool()
|
|
60
|
+
def analyze_composition(ctx: Context) -> dict:
|
|
61
|
+
"""Run full composition analysis on the current Ableton session.
|
|
62
|
+
|
|
63
|
+
Returns section graph, phrase grid, role graph, and issues from
|
|
64
|
+
form/section-identity/phrase critics. This is the "one call to
|
|
65
|
+
understand the arrangement structure."
|
|
66
|
+
|
|
67
|
+
Uses scene names + clip activity to infer sections, note data for
|
|
68
|
+
phrases, and track names + note patterns for role assignment.
|
|
69
|
+
|
|
70
|
+
The issues section contains actionable structural recommendations.
|
|
71
|
+
"""
|
|
72
|
+
ableton = _get_ableton(ctx)
|
|
73
|
+
|
|
74
|
+
# 1. Get session info
|
|
75
|
+
session = ableton.send_command("get_session_info")
|
|
76
|
+
scenes = session.get("scenes", [])
|
|
77
|
+
tracks = session.get("tracks", [])
|
|
78
|
+
track_count = session.get("track_count", 0)
|
|
79
|
+
|
|
80
|
+
# 2. Get clip matrix for section inference
|
|
81
|
+
clip_matrix = _build_clip_matrix(ableton, len(scenes), track_count)
|
|
82
|
+
|
|
83
|
+
# 3. Build section graph (from scenes)
|
|
84
|
+
sections = engine.build_section_graph_from_scenes(
|
|
85
|
+
scenes, clip_matrix, track_count,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# 4. Try arrangement clips as supplement
|
|
89
|
+
arr_clips = {}
|
|
90
|
+
for track in tracks:
|
|
91
|
+
try:
|
|
92
|
+
arr = ableton.send_command("get_arrangement_clips", {
|
|
93
|
+
"track_index": track["index"]
|
|
94
|
+
})
|
|
95
|
+
clips = arr.get("clips", [])
|
|
96
|
+
if clips:
|
|
97
|
+
arr_clips[track["index"]] = clips
|
|
98
|
+
except Exception:
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
if not sections and arr_clips:
|
|
102
|
+
sections = engine.build_section_graph_from_arrangement(
|
|
103
|
+
arr_clips, track_count,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# 5. Get per-track info for role inference
|
|
107
|
+
track_data = []
|
|
108
|
+
for track in tracks:
|
|
109
|
+
try:
|
|
110
|
+
ti = ableton.send_command("get_track_info", {
|
|
111
|
+
"track_index": track["index"]
|
|
112
|
+
})
|
|
113
|
+
track_data.append(ti)
|
|
114
|
+
except Exception:
|
|
115
|
+
track_data.append({"index": track["index"], "name": track.get("name", ""),
|
|
116
|
+
"devices": []})
|
|
117
|
+
|
|
118
|
+
# 6. Get notes for phrase detection + role inference
|
|
119
|
+
notes_by_section_track: dict[str, dict[int, list]] = {}
|
|
120
|
+
all_notes_by_track: dict[int, list] = {}
|
|
121
|
+
|
|
122
|
+
for track in tracks:
|
|
123
|
+
t_idx = track["index"]
|
|
124
|
+
# Collect notes from all clips
|
|
125
|
+
track_notes = []
|
|
126
|
+
for s_idx in range(len(scenes)):
|
|
127
|
+
try:
|
|
128
|
+
result = ableton.send_command("get_notes", {
|
|
129
|
+
"track_index": t_idx, "clip_index": s_idx
|
|
130
|
+
})
|
|
131
|
+
notes = result.get("notes", [])
|
|
132
|
+
track_notes.extend(notes)
|
|
133
|
+
except Exception:
|
|
134
|
+
pass
|
|
135
|
+
all_notes_by_track[t_idx] = track_notes
|
|
136
|
+
|
|
137
|
+
# Map notes to sections
|
|
138
|
+
for section in sections:
|
|
139
|
+
notes_by_section_track[section.section_id] = {}
|
|
140
|
+
for t_idx in section.tracks_active:
|
|
141
|
+
notes_by_section_track[section.section_id][t_idx] = (
|
|
142
|
+
all_notes_by_track.get(t_idx, [])
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# 7. Build phrase grid
|
|
146
|
+
all_phrases = []
|
|
147
|
+
for section in sections:
|
|
148
|
+
section_notes = {t: all_notes_by_track.get(t, []) for t in section.tracks_active}
|
|
149
|
+
phrases = engine.detect_phrases(section, section_notes)
|
|
150
|
+
all_phrases.extend(phrases)
|
|
151
|
+
|
|
152
|
+
# 8. Build role graph
|
|
153
|
+
roles = engine.build_role_graph(sections, track_data, notes_by_section_track)
|
|
154
|
+
|
|
155
|
+
# 9. Run critics
|
|
156
|
+
form_issues = engine.run_form_critic(sections)
|
|
157
|
+
identity_issues = engine.run_section_identity_critic(sections, roles)
|
|
158
|
+
phrase_issues = engine.run_phrase_critic(all_phrases)
|
|
159
|
+
all_issues = form_issues + identity_issues + phrase_issues
|
|
160
|
+
|
|
161
|
+
# 10. Assemble result
|
|
162
|
+
analysis = engine.CompositionAnalysis(
|
|
163
|
+
sections=sections,
|
|
164
|
+
phrases=all_phrases,
|
|
165
|
+
roles=roles,
|
|
166
|
+
issues=all_issues,
|
|
167
|
+
)
|
|
168
|
+
return analysis.to_dict()
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ── get_section_graph ─────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@mcp.tool()
|
|
175
|
+
def get_section_graph(ctx: Context) -> dict:
|
|
176
|
+
"""Get just the section graph — lightweight structural overview.
|
|
177
|
+
|
|
178
|
+
Infers sections from scene names and clip activity. Returns
|
|
179
|
+
section types, energy levels, density, and active tracks per section.
|
|
180
|
+
Faster than analyze_composition when you only need structure.
|
|
181
|
+
"""
|
|
182
|
+
ableton = _get_ableton(ctx)
|
|
183
|
+
session = ableton.send_command("get_session_info")
|
|
184
|
+
scenes = session.get("scenes", [])
|
|
185
|
+
track_count = session.get("track_count", 0)
|
|
186
|
+
|
|
187
|
+
clip_matrix = _build_clip_matrix(ableton, len(scenes), track_count)
|
|
188
|
+
sections = engine.build_section_graph_from_scenes(
|
|
189
|
+
scenes, clip_matrix, track_count,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
"sections": [s.to_dict() for s in sections],
|
|
194
|
+
"section_count": len(sections),
|
|
195
|
+
"has_energy_arc": _has_energy_arc(sections),
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _has_energy_arc(sections: list[engine.SectionNode]) -> bool:
|
|
200
|
+
if len(sections) < 2:
|
|
201
|
+
return False
|
|
202
|
+
energies = [s.energy for s in sections]
|
|
203
|
+
return (max(energies) - min(energies)) >= 0.15
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ── get_phrase_grid ───────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@mcp.tool()
|
|
210
|
+
def get_phrase_grid(
|
|
211
|
+
ctx: Context,
|
|
212
|
+
section_index: int = 0,
|
|
213
|
+
) -> dict:
|
|
214
|
+
"""Get phrase boundaries for a specific section.
|
|
215
|
+
|
|
216
|
+
section_index: which section to analyze (0-based, from get_section_graph).
|
|
217
|
+
Returns phrase boundaries, cadence strengths, and note densities.
|
|
218
|
+
"""
|
|
219
|
+
ableton = _get_ableton(ctx)
|
|
220
|
+
session = ableton.send_command("get_session_info")
|
|
221
|
+
scenes = session.get("scenes", [])
|
|
222
|
+
tracks = session.get("tracks", [])
|
|
223
|
+
track_count = session.get("track_count", 0)
|
|
224
|
+
|
|
225
|
+
clip_matrix = _build_clip_matrix(ableton, len(scenes), track_count)
|
|
226
|
+
sections = engine.build_section_graph_from_scenes(
|
|
227
|
+
scenes, clip_matrix, track_count,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
if section_index < 0 or section_index >= len(sections):
|
|
231
|
+
return {"error": f"section_index {section_index} out of range (0-{len(sections) - 1})"}
|
|
232
|
+
|
|
233
|
+
section = sections[section_index]
|
|
234
|
+
|
|
235
|
+
# Collect notes for active tracks — use the section's scene_index
|
|
236
|
+
# (which maps to the actual clip slot), not the section_index
|
|
237
|
+
# (which is a position in the section graph)
|
|
238
|
+
notes_by_track: dict[int, list] = {}
|
|
239
|
+
scene_idx = section.scene_index if hasattr(section, "scene_index") else section_index
|
|
240
|
+
for t_idx in section.tracks_active:
|
|
241
|
+
try:
|
|
242
|
+
result = ableton.send_command("get_notes", {
|
|
243
|
+
"track_index": t_idx,
|
|
244
|
+
"clip_index": scene_idx,
|
|
245
|
+
})
|
|
246
|
+
notes_by_track[t_idx] = result.get("notes", [])
|
|
247
|
+
except Exception:
|
|
248
|
+
notes_by_track[t_idx] = []
|
|
249
|
+
|
|
250
|
+
phrases = engine.detect_phrases(section, notes_by_track)
|
|
251
|
+
return {
|
|
252
|
+
"section": section.to_dict(),
|
|
253
|
+
"phrases": [p.to_dict() for p in phrases],
|
|
254
|
+
"phrase_count": len(phrases),
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
# ── plan_gesture ──────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@mcp.tool()
|
|
262
|
+
def plan_gesture(
|
|
263
|
+
ctx: Context,
|
|
264
|
+
intent: str,
|
|
265
|
+
target_tracks: list | str = "[]",
|
|
266
|
+
start_bar: int = 0,
|
|
267
|
+
duration_bars: int = 0,
|
|
268
|
+
foreground: bool = False,
|
|
269
|
+
) -> dict:
|
|
270
|
+
"""Plan a musical gesture — map abstract intent to concrete automation.
|
|
271
|
+
|
|
272
|
+
intent: reveal | conceal | handoff | inhale | release | lift | sink | punctuate | drift
|
|
273
|
+
target_tracks: list of track indices the gesture applies to
|
|
274
|
+
start_bar: where the gesture begins
|
|
275
|
+
duration_bars: how long (0 = use gesture default)
|
|
276
|
+
foreground: is this a focal point or background motion?
|
|
277
|
+
|
|
278
|
+
Returns a GesturePlan with: curve_family, parameter_hints, direction,
|
|
279
|
+
and timing — ready for use with apply_automation_shape.
|
|
280
|
+
|
|
281
|
+
Example: plan_gesture(intent="reveal", target_tracks=[6], start_bar=8)
|
|
282
|
+
→ exponential curve on filter_cutoff, sweep up over 4 bars
|
|
283
|
+
"""
|
|
284
|
+
# Parse intent
|
|
285
|
+
try:
|
|
286
|
+
gesture_intent = engine.GestureIntent(intent)
|
|
287
|
+
except ValueError:
|
|
288
|
+
valid = [g.value for g in engine.GestureIntent]
|
|
289
|
+
raise ValueError(f"Unknown intent '{intent}'. Valid: {valid}")
|
|
290
|
+
|
|
291
|
+
# Parse target_tracks
|
|
292
|
+
if isinstance(target_tracks, str):
|
|
293
|
+
try:
|
|
294
|
+
target_tracks = json.loads(target_tracks)
|
|
295
|
+
except json.JSONDecodeError:
|
|
296
|
+
target_tracks = []
|
|
297
|
+
|
|
298
|
+
duration = duration_bars if duration_bars > 0 else None
|
|
299
|
+
gesture = engine.plan_gesture(
|
|
300
|
+
intent=gesture_intent,
|
|
301
|
+
target_tracks=target_tracks,
|
|
302
|
+
start_bar=start_bar,
|
|
303
|
+
duration_bars=duration,
|
|
304
|
+
foreground=foreground,
|
|
305
|
+
)
|
|
306
|
+
return gesture.to_dict()
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
# ── evaluate_composition_move ─────────────────────────────────────────
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
@mcp.tool()
|
|
313
|
+
def evaluate_composition_move(
|
|
314
|
+
ctx: Context,
|
|
315
|
+
before_issues: list | str,
|
|
316
|
+
after_issues: list | str,
|
|
317
|
+
target_dimensions: dict | str = "{}",
|
|
318
|
+
protect: dict | str = "{}",
|
|
319
|
+
) -> dict:
|
|
320
|
+
"""Evaluate whether a composition move improved the arrangement.
|
|
321
|
+
|
|
322
|
+
Takes before/after issue lists (from analyze_composition) and compares
|
|
323
|
+
severity and count. Returns a score and keep/undo recommendation.
|
|
324
|
+
|
|
325
|
+
before_issues: issues list from analyze_composition BEFORE the move
|
|
326
|
+
after_issues: issues list from analyze_composition AFTER the move
|
|
327
|
+
target_dimensions: optional composition dimensions being targeted
|
|
328
|
+
protect: optional dimensions to preserve
|
|
329
|
+
|
|
330
|
+
Returns: {score, keep_change, issue_delta, severity_improvement, notes}
|
|
331
|
+
"""
|
|
332
|
+
# Parse inputs
|
|
333
|
+
if isinstance(before_issues, str):
|
|
334
|
+
before_issues = json.loads(before_issues)
|
|
335
|
+
if isinstance(after_issues, str):
|
|
336
|
+
after_issues = json.loads(after_issues)
|
|
337
|
+
|
|
338
|
+
targets = _parse_json_param(target_dimensions, "target_dimensions")
|
|
339
|
+
prot = _parse_json_param(protect, "protect")
|
|
340
|
+
|
|
341
|
+
# Convert raw dicts back to CompositionIssue objects
|
|
342
|
+
before = [engine.CompositionIssue(**{k: v for k, v in i.items()
|
|
343
|
+
if k in ("issue_type", "critic", "severity", "confidence",
|
|
344
|
+
"scope", "recommended_moves", "evidence")})
|
|
345
|
+
for i in before_issues]
|
|
346
|
+
after = [engine.CompositionIssue(**{k: v for k, v in i.items()
|
|
347
|
+
if k in ("issue_type", "critic", "severity", "confidence",
|
|
348
|
+
"scope", "recommended_moves", "evidence")})
|
|
349
|
+
for i in after_issues]
|
|
350
|
+
|
|
351
|
+
return engine.evaluate_composition_move(before, after, targets, prot)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
# ── get_harmony_field (Round 1) ───────────────────────────────────────
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
@mcp.tool()
|
|
358
|
+
def get_harmony_field(
|
|
359
|
+
ctx: Context,
|
|
360
|
+
section_index: int = 0,
|
|
361
|
+
) -> dict:
|
|
362
|
+
"""Analyze the harmonic content of a section — key, chords, voice-leading, tension.
|
|
363
|
+
|
|
364
|
+
Combines identify_scale, analyze_harmony, classify_progression, and
|
|
365
|
+
find_voice_leading_path into a single structured HarmonyField.
|
|
366
|
+
|
|
367
|
+
section_index: which section to analyze (0-based, from get_section_graph).
|
|
368
|
+
Returns: key, mode, chord_progression, voice_leading_quality, instability,
|
|
369
|
+
resolution_potential.
|
|
370
|
+
"""
|
|
371
|
+
ableton = _get_ableton(ctx)
|
|
372
|
+
session = ableton.send_command("get_session_info")
|
|
373
|
+
scenes = session.get("scenes", [])
|
|
374
|
+
tracks = session.get("tracks", [])
|
|
375
|
+
track_count = session.get("track_count", 0)
|
|
376
|
+
|
|
377
|
+
clip_matrix = _build_clip_matrix(ableton, len(scenes), track_count)
|
|
378
|
+
sections = engine.build_section_graph_from_scenes(scenes, clip_matrix, track_count)
|
|
379
|
+
|
|
380
|
+
if section_index < 0 or section_index >= len(sections):
|
|
381
|
+
return {"error": f"section_index {section_index} out of range (0-{len(sections) - 1})"}
|
|
382
|
+
|
|
383
|
+
section = sections[section_index]
|
|
384
|
+
|
|
385
|
+
# Find a track with notes to analyze harmony
|
|
386
|
+
# Use theory engine functions directly instead of TCP calls to MCP tools
|
|
387
|
+
from . import _theory_engine as theory_engine
|
|
388
|
+
from . import _harmony_engine as harmony_engine
|
|
389
|
+
|
|
390
|
+
scale_info = None
|
|
391
|
+
harmony_analysis = None
|
|
392
|
+
progression_info = None
|
|
393
|
+
voice_leading_info = None
|
|
394
|
+
|
|
395
|
+
for t_idx in section.tracks_active:
|
|
396
|
+
try:
|
|
397
|
+
# Get notes via TCP (valid Remote Script command)
|
|
398
|
+
result = ableton.send_command("get_notes", {
|
|
399
|
+
"track_index": t_idx, "clip_index": section_index,
|
|
400
|
+
})
|
|
401
|
+
notes = result.get("notes", [])
|
|
402
|
+
if not notes:
|
|
403
|
+
continue
|
|
404
|
+
|
|
405
|
+
# identify_scale: run key detection directly
|
|
406
|
+
if not scale_info:
|
|
407
|
+
detected = theory_engine.detect_key(notes, mode_detection=True)
|
|
408
|
+
top = {
|
|
409
|
+
"key": f"{detected['tonic_name']} {detected['mode'].replace('_', ' ')}",
|
|
410
|
+
"confidence": detected["confidence"],
|
|
411
|
+
"mode": detected["mode"].replace("_", " "),
|
|
412
|
+
"mode_id": detected["mode"],
|
|
413
|
+
"tonic": detected["tonic_name"],
|
|
414
|
+
}
|
|
415
|
+
scale_info = {"top_match": top}
|
|
416
|
+
|
|
417
|
+
# analyze_harmony: chordify + roman numeral analysis directly
|
|
418
|
+
if not harmony_analysis:
|
|
419
|
+
key_info = theory_engine.detect_key(notes)
|
|
420
|
+
tonic = key_info["tonic"]
|
|
421
|
+
mode = key_info["mode"]
|
|
422
|
+
chord_groups = theory_engine.chordify(notes)
|
|
423
|
+
if chord_groups:
|
|
424
|
+
chords = []
|
|
425
|
+
for group in chord_groups:
|
|
426
|
+
pitches = group["pitches"]
|
|
427
|
+
pcs = group["pitch_classes"]
|
|
428
|
+
rn = theory_engine.roman_numeral(pcs, tonic, mode)
|
|
429
|
+
cn = theory_engine.chord_name(pitches)
|
|
430
|
+
chords.append({
|
|
431
|
+
"beat": group["beat"],
|
|
432
|
+
"duration": group["duration"],
|
|
433
|
+
"chord_name": cn,
|
|
434
|
+
"roman_numeral": rn["figure"],
|
|
435
|
+
"figure": rn["figure"],
|
|
436
|
+
"quality": rn["quality"],
|
|
437
|
+
})
|
|
438
|
+
if chords:
|
|
439
|
+
harmony_analysis = {
|
|
440
|
+
"key": f"{key_info['tonic_name']} {mode.replace('_', ' ')}",
|
|
441
|
+
"chords": chords,
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
# classify_progression directly
|
|
445
|
+
chord_names = [c["chord_name"] for c in chords if c.get("chord_name")]
|
|
446
|
+
if len(chord_names) >= 2:
|
|
447
|
+
try:
|
|
448
|
+
parsed = [harmony_engine.parse_chord(c) for c in chord_names[:8]]
|
|
449
|
+
transforms = harmony_engine.classify_transform_sequence(parsed)
|
|
450
|
+
pattern = "".join(transforms)
|
|
451
|
+
classification = "free neo-Riemannian progression"
|
|
452
|
+
clean = pattern.replace("?", "")
|
|
453
|
+
if len(clean) >= 2:
|
|
454
|
+
pair = clean[:2]
|
|
455
|
+
if pair in ("PL", "LP") and all(c in "PL" for c in clean):
|
|
456
|
+
classification = "hexatonic cycle fragment"
|
|
457
|
+
elif pair in ("PR", "RP") and all(c in "PR" for c in clean):
|
|
458
|
+
classification = "octatonic cycle fragment"
|
|
459
|
+
elif pair in ("LR", "RL") and all(c in "LR" for c in clean):
|
|
460
|
+
classification = "diatonic cycle fragment"
|
|
461
|
+
progression_info = {
|
|
462
|
+
"chords": chord_names[:8],
|
|
463
|
+
"transforms": transforms,
|
|
464
|
+
"pattern": pattern,
|
|
465
|
+
"classification": classification,
|
|
466
|
+
}
|
|
467
|
+
except Exception:
|
|
468
|
+
pass
|
|
469
|
+
|
|
470
|
+
# Populate voice_leading_info from chord groups
|
|
471
|
+
if harmony_analysis and not voice_leading_info:
|
|
472
|
+
try:
|
|
473
|
+
chord_groups_vl = theory_engine.chordify(notes)
|
|
474
|
+
if len(chord_groups_vl) >= 2:
|
|
475
|
+
all_vl_issues = []
|
|
476
|
+
for vi in range(1, min(len(chord_groups_vl), 9)):
|
|
477
|
+
prev_p = chord_groups_vl[vi - 1]["pitches"]
|
|
478
|
+
curr_p = chord_groups_vl[vi]["pitches"]
|
|
479
|
+
issues = theory_engine.check_voice_leading(prev_p, curr_p)
|
|
480
|
+
all_vl_issues.extend(issues)
|
|
481
|
+
voice_leading_info = {
|
|
482
|
+
"issues": all_vl_issues,
|
|
483
|
+
"issue_count": len(all_vl_issues),
|
|
484
|
+
"quality": "clean" if not all_vl_issues else "has_issues",
|
|
485
|
+
}
|
|
486
|
+
except Exception:
|
|
487
|
+
pass
|
|
488
|
+
|
|
489
|
+
if scale_info and harmony_analysis:
|
|
490
|
+
break
|
|
491
|
+
except Exception:
|
|
492
|
+
continue
|
|
493
|
+
|
|
494
|
+
hf = engine.build_harmony_field(
|
|
495
|
+
section_id=section.section_id,
|
|
496
|
+
harmony_analysis=harmony_analysis,
|
|
497
|
+
scale_info=scale_info,
|
|
498
|
+
progression_info=progression_info,
|
|
499
|
+
voice_leading_info=voice_leading_info,
|
|
500
|
+
)
|
|
501
|
+
result = hf.to_dict()
|
|
502
|
+
result["section_name"] = section.name
|
|
503
|
+
return result
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
# ── get_transition_analysis (Round 1) ─────────────────────────────────
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
@mcp.tool()
|
|
510
|
+
def get_transition_analysis(ctx: Context) -> dict:
|
|
511
|
+
"""Analyze transition quality between all adjacent sections.
|
|
512
|
+
|
|
513
|
+
Checks for: hard cuts, missing pre-arrival subtraction, groove breaks,
|
|
514
|
+
harmonic non-sequiturs, and weak builds without role rotation.
|
|
515
|
+
|
|
516
|
+
Returns issues with recommended composition moves for each boundary.
|
|
517
|
+
"""
|
|
518
|
+
ableton = _get_ableton(ctx)
|
|
519
|
+
session = ableton.send_command("get_session_info")
|
|
520
|
+
scenes = session.get("scenes", [])
|
|
521
|
+
tracks = session.get("tracks", [])
|
|
522
|
+
track_count = session.get("track_count", 0)
|
|
523
|
+
|
|
524
|
+
clip_matrix = _build_clip_matrix(ableton, len(scenes), track_count)
|
|
525
|
+
sections = engine.build_section_graph_from_scenes(scenes, clip_matrix, track_count)
|
|
526
|
+
|
|
527
|
+
if len(sections) < 2:
|
|
528
|
+
return {"issues": [], "note": "Need at least 2 sections for transition analysis"}
|
|
529
|
+
|
|
530
|
+
# Build role graph for transition critic
|
|
531
|
+
track_data = []
|
|
532
|
+
notes_map: dict[str, dict[int, list]] = {}
|
|
533
|
+
for track in tracks:
|
|
534
|
+
t_idx = track["index"]
|
|
535
|
+
try:
|
|
536
|
+
ti = ableton.send_command("get_track_info", {"track_index": t_idx})
|
|
537
|
+
track_data.append(ti)
|
|
538
|
+
except Exception:
|
|
539
|
+
track_data.append({"index": t_idx, "name": track.get("name", ""), "devices": []})
|
|
540
|
+
|
|
541
|
+
for section in sections:
|
|
542
|
+
notes_map[section.section_id] = {}
|
|
543
|
+
for t_idx in section.tracks_active:
|
|
544
|
+
notes_map[section.section_id][t_idx] = []
|
|
545
|
+
|
|
546
|
+
roles = engine.build_role_graph(sections, track_data, notes_map)
|
|
547
|
+
|
|
548
|
+
# Build harmony fields (lightweight — skip if tools fail)
|
|
549
|
+
harmony_fields = []
|
|
550
|
+
for i, section in enumerate(sections):
|
|
551
|
+
hf = engine.HarmonyField(section_id=section.section_id)
|
|
552
|
+
harmony_fields.append(hf)
|
|
553
|
+
|
|
554
|
+
issues = engine.run_transition_critic(sections, roles, harmony_fields)
|
|
555
|
+
|
|
556
|
+
return {
|
|
557
|
+
"transition_count": len(sections) - 1,
|
|
558
|
+
"issues": [i.to_dict() for i in issues],
|
|
559
|
+
"issue_count": len(issues),
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
# ── apply_gesture_template (Round 2) ──────────────────────────────────
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
@mcp.tool()
|
|
567
|
+
def apply_gesture_template(
|
|
568
|
+
ctx: Context,
|
|
569
|
+
template_name: str,
|
|
570
|
+
target_tracks: list | str = "[]",
|
|
571
|
+
anchor_bar: int = 0,
|
|
572
|
+
foreground: bool = False,
|
|
573
|
+
) -> dict:
|
|
574
|
+
"""Apply a compound gesture template — multiple coordinated automation gestures.
|
|
575
|
+
|
|
576
|
+
template_name: pre_arrival_vacuum | sectional_width_bloom | phrase_end_throw |
|
|
577
|
+
turnaround_accent | outro_decay_dissolve | bass_tuck_before_kick |
|
|
578
|
+
harmonic_tint_rise | response_echo | texture_drift_bed |
|
|
579
|
+
tension_ratchet | re_entry_spotlight
|
|
580
|
+
target_tracks: list of track indices
|
|
581
|
+
anchor_bar: reference point (section boundary bar number)
|
|
582
|
+
foreground: is this a focal point?
|
|
583
|
+
|
|
584
|
+
Returns: list of GesturePlans — execute each with apply_automation_shape.
|
|
585
|
+
"""
|
|
586
|
+
if isinstance(target_tracks, str):
|
|
587
|
+
try:
|
|
588
|
+
target_tracks = json.loads(target_tracks)
|
|
589
|
+
except json.JSONDecodeError:
|
|
590
|
+
target_tracks = []
|
|
591
|
+
|
|
592
|
+
plans = engine.resolve_gesture_template(
|
|
593
|
+
template_name, target_tracks, anchor_bar, foreground,
|
|
594
|
+
)
|
|
595
|
+
return {
|
|
596
|
+
"template": template_name,
|
|
597
|
+
"description": engine.GESTURE_TEMPLATES[template_name]["description"],
|
|
598
|
+
"gesture_count": len(plans),
|
|
599
|
+
"gestures": [g.to_dict() for g in plans],
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
# ── get_section_outcomes (Round 2) ────────────────────────────────────
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
@mcp.tool()
|
|
607
|
+
def get_section_outcomes(
|
|
608
|
+
ctx: Context,
|
|
609
|
+
section_type: str = "",
|
|
610
|
+
limit: int = 50,
|
|
611
|
+
) -> dict:
|
|
612
|
+
"""Get composition move success rates grouped by section type.
|
|
613
|
+
|
|
614
|
+
Analyzes stored composition outcomes to answer: which moves work
|
|
615
|
+
best in which section types? Use before making structural changes
|
|
616
|
+
to learn from past sessions.
|
|
617
|
+
|
|
618
|
+
section_type: filter to a specific type (intro, verse, chorus, etc.)
|
|
619
|
+
Leave empty for all types.
|
|
620
|
+
"""
|
|
621
|
+
# Fetch composition outcomes directly from TechniqueStore
|
|
622
|
+
try:
|
|
623
|
+
techniques = _memory_store.list_techniques(
|
|
624
|
+
type_filter="composition_outcome", sort_by="updated_at", limit=limit,
|
|
625
|
+
)
|
|
626
|
+
except Exception:
|
|
627
|
+
techniques = []
|
|
628
|
+
|
|
629
|
+
outcomes = []
|
|
630
|
+
for t in techniques:
|
|
631
|
+
try:
|
|
632
|
+
full = _memory_store.get(t["id"])
|
|
633
|
+
payload = full.get("payload", {})
|
|
634
|
+
if isinstance(payload, dict):
|
|
635
|
+
outcomes.append(payload)
|
|
636
|
+
except Exception:
|
|
637
|
+
pass
|
|
638
|
+
|
|
639
|
+
result = engine.analyze_section_outcomes(outcomes)
|
|
640
|
+
|
|
641
|
+
if section_type and section_type in result.get("outcomes_by_section", {}):
|
|
642
|
+
result["filtered_section"] = section_type
|
|
643
|
+
result["section_moves"] = result["outcomes_by_section"][section_type]
|
|
644
|
+
|
|
645
|
+
return result
|
|
@@ -145,11 +145,25 @@ def _postflight_loaded_device(ctx: Context, result: dict) -> dict:
|
|
|
145
145
|
if match is None:
|
|
146
146
|
match = devices[-1]
|
|
147
147
|
|
|
148
|
+
# get_track_info returns device summaries without a parameters list,
|
|
149
|
+
# so use the 'parameter_count' field if present, otherwise fetch
|
|
150
|
+
# the actual device info for an accurate count.
|
|
151
|
+
param_count = match.get("parameter_count", len(match.get("parameters", [])))
|
|
152
|
+
if param_count == 0 and match.get("index") is not None:
|
|
153
|
+
try:
|
|
154
|
+
full_info = _get_ableton(ctx).send_command("get_device_info", {
|
|
155
|
+
"track_index": int(track_index),
|
|
156
|
+
"device_index": int(match["index"]),
|
|
157
|
+
})
|
|
158
|
+
param_count = full_info.get("parameter_count", 0)
|
|
159
|
+
except Exception:
|
|
160
|
+
pass
|
|
161
|
+
|
|
148
162
|
device_info = _annotate_device_info({
|
|
149
163
|
"name": match.get("name"),
|
|
150
164
|
"class_name": match.get("class_name"),
|
|
151
165
|
"is_active": match.get("is_active"),
|
|
152
|
-
"parameter_count":
|
|
166
|
+
"parameter_count": param_count,
|
|
153
167
|
})
|
|
154
168
|
|
|
155
169
|
merged = dict(annotated)
|
|
@@ -62,7 +62,9 @@ def _safe_output_path(directory: Path, filename: str) -> Path:
|
|
|
62
62
|
if not safe_name:
|
|
63
63
|
raise ValueError(f"Invalid filename: {filename!r}")
|
|
64
64
|
out = (directory / safe_name).resolve()
|
|
65
|
-
|
|
65
|
+
# Use os.sep suffix to prevent prefix collisions (e.g., /foo/bar vs /foo/barbaz)
|
|
66
|
+
parent = str(directory.resolve()) + os.sep
|
|
67
|
+
if not (str(out) + os.sep).startswith(parent):
|
|
66
68
|
raise ValueError(f"Filename escapes output directory: {filename!r}")
|
|
67
69
|
return out
|
|
68
70
|
|