livepilot 1.9.13 → 1.9.15
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 +51 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +7 -7
- package/bin/livepilot.js +32 -8
- package/installer/install.js +21 -2
- 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_Analyzer.amxd +0 -0
- package/m4l_device/livepilot_bridge.js +6 -3
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/curves.py +11 -3
- 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/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 +2 -1
- 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 +214 -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 +162 -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 +80 -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 +235 -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 +84 -0
- package/mcp_server/runtime/action_tools.py +57 -0
- package/mcp_server/runtime/capability_state.py +218 -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 +64 -0
- package/mcp_server/server.py +23 -1
- 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 +440 -0
- package/mcp_server/tools/analyzer.py +18 -0
- package/mcp_server/tools/automation.py +25 -10
- package/mcp_server/tools/composition.py +563 -0
- 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 +18 -3
- 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 +74 -0
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +1 -1
- package/remote_script/LivePilot/arrangement.py +12 -2
- package/requirements.txt +1 -1
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Performance Engine planner — scene transitions and energy steering.
|
|
2
|
+
|
|
3
|
+
Pure computation — builds HandoffPlans and suggests energy-aware moves.
|
|
4
|
+
|
|
5
|
+
Zero I/O.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .models import (
|
|
11
|
+
EnergyWindow,
|
|
12
|
+
HandoffPlan,
|
|
13
|
+
LiveSafeMove,
|
|
14
|
+
PerformanceState,
|
|
15
|
+
SceneRole,
|
|
16
|
+
)
|
|
17
|
+
from .safety import get_blocked_moves, get_safe_moves
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ── Scene transition planning ─────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def plan_scene_transition(
|
|
24
|
+
from_scene: SceneRole,
|
|
25
|
+
to_scene: SceneRole,
|
|
26
|
+
) -> HandoffPlan:
|
|
27
|
+
"""Plan a transition from one scene to another.
|
|
28
|
+
|
|
29
|
+
Generates an energy path (linear interpolation) and
|
|
30
|
+
appropriate transition gestures based on the energy delta
|
|
31
|
+
and role pairing.
|
|
32
|
+
"""
|
|
33
|
+
energy_delta = to_scene.energy_level - from_scene.energy_level
|
|
34
|
+
abs_delta = abs(energy_delta)
|
|
35
|
+
|
|
36
|
+
# Determine number of steps based on energy distance
|
|
37
|
+
if abs_delta < 0.2:
|
|
38
|
+
steps = 2
|
|
39
|
+
elif abs_delta < 0.5:
|
|
40
|
+
steps = 4
|
|
41
|
+
else:
|
|
42
|
+
steps = 6
|
|
43
|
+
|
|
44
|
+
# Build linear energy path
|
|
45
|
+
energy_path: list[float] = []
|
|
46
|
+
for i in range(steps + 1):
|
|
47
|
+
t = i / steps
|
|
48
|
+
energy = from_scene.energy_level + t * energy_delta
|
|
49
|
+
energy_path.append(round(energy, 3))
|
|
50
|
+
|
|
51
|
+
# Build gesture sequence
|
|
52
|
+
gestures: list[dict] = []
|
|
53
|
+
|
|
54
|
+
# Pre-transition: prepare
|
|
55
|
+
gestures.append({
|
|
56
|
+
"type": "prepare",
|
|
57
|
+
"step": 0,
|
|
58
|
+
"description": f"Prepare transition from '{from_scene.name}' to '{to_scene.name}'",
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
# Energy-based transition gestures
|
|
62
|
+
if energy_delta > 0.3:
|
|
63
|
+
# Building energy — use additive layering
|
|
64
|
+
gestures.append({
|
|
65
|
+
"type": "filter_sweep",
|
|
66
|
+
"step": 1,
|
|
67
|
+
"description": "Open filter to build energy",
|
|
68
|
+
"parameters": {"direction": "up"},
|
|
69
|
+
})
|
|
70
|
+
gestures.append({
|
|
71
|
+
"type": "send_nudge",
|
|
72
|
+
"step": 2,
|
|
73
|
+
"description": "Increase send levels for spatial build",
|
|
74
|
+
"parameters": {"delta": 0.1},
|
|
75
|
+
})
|
|
76
|
+
elif energy_delta < -0.3:
|
|
77
|
+
# Dropping energy — use subtractive approach
|
|
78
|
+
gestures.append({
|
|
79
|
+
"type": "filter_sweep",
|
|
80
|
+
"step": 1,
|
|
81
|
+
"description": "Close filter to reduce energy",
|
|
82
|
+
"parameters": {"direction": "down"},
|
|
83
|
+
})
|
|
84
|
+
gestures.append({
|
|
85
|
+
"type": "mute_toggle",
|
|
86
|
+
"step": 2,
|
|
87
|
+
"description": "Strip layers to reduce density",
|
|
88
|
+
"parameters": {},
|
|
89
|
+
})
|
|
90
|
+
else:
|
|
91
|
+
# Similar energy — smooth crossfade
|
|
92
|
+
gestures.append({
|
|
93
|
+
"type": "volume_nudge",
|
|
94
|
+
"step": 1,
|
|
95
|
+
"description": "Smooth crossfade between scenes",
|
|
96
|
+
"parameters": {"delta_db": 0.0},
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
# Scene launch at the midpoint
|
|
100
|
+
gestures.append({
|
|
101
|
+
"type": "scene_launch",
|
|
102
|
+
"step": steps // 2,
|
|
103
|
+
"description": f"Launch scene {to_scene.scene_index} ('{to_scene.name}')",
|
|
104
|
+
"parameters": {"scene_index": to_scene.scene_index},
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
# Post-transition: settle
|
|
108
|
+
gestures.append({
|
|
109
|
+
"type": "settle",
|
|
110
|
+
"step": steps,
|
|
111
|
+
"description": f"Settle into '{to_scene.name}' ({to_scene.role})",
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
return HandoffPlan(
|
|
115
|
+
from_scene=from_scene.scene_index,
|
|
116
|
+
to_scene=to_scene.scene_index,
|
|
117
|
+
gestures=gestures,
|
|
118
|
+
energy_path=energy_path,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ── Energy-aware move suggestions ─────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def suggest_energy_moves(
|
|
126
|
+
energy_window: EnergyWindow,
|
|
127
|
+
scene: SceneRole,
|
|
128
|
+
) -> list[LiveSafeMove]:
|
|
129
|
+
"""Suggest moves to steer energy toward the target.
|
|
130
|
+
|
|
131
|
+
Considers the current scene's role to tailor suggestions.
|
|
132
|
+
"""
|
|
133
|
+
moves: list[LiveSafeMove] = []
|
|
134
|
+
gap = energy_window.target_energy - energy_window.current_energy
|
|
135
|
+
|
|
136
|
+
if abs(gap) < 0.05:
|
|
137
|
+
# Close enough — hold steady
|
|
138
|
+
moves.append(LiveSafeMove(
|
|
139
|
+
move_type="macro_nudge",
|
|
140
|
+
target="performance_macro",
|
|
141
|
+
description="Maintain current energy — micro-adjust macro for variation",
|
|
142
|
+
risk_level="safe",
|
|
143
|
+
parameters={"delta": 0.01},
|
|
144
|
+
reversible=True,
|
|
145
|
+
))
|
|
146
|
+
return moves
|
|
147
|
+
|
|
148
|
+
if gap > 0:
|
|
149
|
+
# Need more energy
|
|
150
|
+
moves.append(LiveSafeMove(
|
|
151
|
+
move_type="volume_nudge",
|
|
152
|
+
target="master",
|
|
153
|
+
description=f"Lift volume toward target energy ({energy_window.target_energy:.1f})",
|
|
154
|
+
risk_level="safe",
|
|
155
|
+
parameters={"delta_db": min(2.0, gap * 6.0)},
|
|
156
|
+
reversible=True,
|
|
157
|
+
))
|
|
158
|
+
if scene.role in ("build", "chorus", "drop"):
|
|
159
|
+
moves.append(LiveSafeMove(
|
|
160
|
+
move_type="send_nudge",
|
|
161
|
+
target="delay_send",
|
|
162
|
+
description="Add delay send for rhythmic energy boost",
|
|
163
|
+
risk_level="safe",
|
|
164
|
+
parameters={"delta": min(0.15, gap * 0.3)},
|
|
165
|
+
reversible=True,
|
|
166
|
+
))
|
|
167
|
+
moves.append(LiveSafeMove(
|
|
168
|
+
move_type="filter_sweep",
|
|
169
|
+
target="master_filter",
|
|
170
|
+
description="Open high-pass for brightness lift",
|
|
171
|
+
risk_level="safe",
|
|
172
|
+
parameters={"direction": "up", "range": [2000, 16000]},
|
|
173
|
+
reversible=True,
|
|
174
|
+
))
|
|
175
|
+
else:
|
|
176
|
+
# Need less energy
|
|
177
|
+
moves.append(LiveSafeMove(
|
|
178
|
+
move_type="filter_sweep",
|
|
179
|
+
target="master_filter",
|
|
180
|
+
description=f"Low-pass sweep toward target energy ({energy_window.target_energy:.1f})",
|
|
181
|
+
risk_level="safe",
|
|
182
|
+
parameters={"direction": "down", "range": [200, 8000]},
|
|
183
|
+
reversible=True,
|
|
184
|
+
))
|
|
185
|
+
if scene.role in ("breakdown", "outro", "intro"):
|
|
186
|
+
moves.append(LiveSafeMove(
|
|
187
|
+
move_type="mute_toggle",
|
|
188
|
+
target="percussion_layer",
|
|
189
|
+
description="Mute percussion to soften breakdown",
|
|
190
|
+
risk_level="safe",
|
|
191
|
+
parameters={},
|
|
192
|
+
reversible=True,
|
|
193
|
+
))
|
|
194
|
+
moves.append(LiveSafeMove(
|
|
195
|
+
move_type="volume_nudge",
|
|
196
|
+
target="master",
|
|
197
|
+
description="Reduce volume toward target energy",
|
|
198
|
+
risk_level="safe",
|
|
199
|
+
parameters={"delta_db": max(-2.0, gap * 6.0)},
|
|
200
|
+
reversible=True,
|
|
201
|
+
))
|
|
202
|
+
|
|
203
|
+
return moves
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ── Build full performance state ──────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def build_performance_state(
|
|
210
|
+
scenes: list[SceneRole],
|
|
211
|
+
current_scene_index: int,
|
|
212
|
+
) -> PerformanceState:
|
|
213
|
+
"""Build a complete PerformanceState from scene roles.
|
|
214
|
+
|
|
215
|
+
Computes energy window from current scene context and
|
|
216
|
+
populates safe/blocked move lists.
|
|
217
|
+
"""
|
|
218
|
+
# Find current scene
|
|
219
|
+
current = None
|
|
220
|
+
for s in scenes:
|
|
221
|
+
if s.scene_index == current_scene_index:
|
|
222
|
+
current = s
|
|
223
|
+
break
|
|
224
|
+
|
|
225
|
+
if current is None:
|
|
226
|
+
# Fallback: use defaults
|
|
227
|
+
current_energy = 0.5
|
|
228
|
+
target_energy = 0.5
|
|
229
|
+
direction = "hold"
|
|
230
|
+
else:
|
|
231
|
+
current_energy = current.energy_level
|
|
232
|
+
|
|
233
|
+
# Infer target from adjacent scenes
|
|
234
|
+
next_scenes = [s for s in scenes if s.scene_index > current_scene_index]
|
|
235
|
+
if next_scenes:
|
|
236
|
+
next_scene = min(next_scenes, key=lambda s: s.scene_index)
|
|
237
|
+
target_energy = next_scene.energy_level
|
|
238
|
+
else:
|
|
239
|
+
target_energy = current_energy
|
|
240
|
+
|
|
241
|
+
delta = target_energy - current_energy
|
|
242
|
+
if delta > 0.05:
|
|
243
|
+
direction = "up"
|
|
244
|
+
elif delta < -0.05:
|
|
245
|
+
direction = "down"
|
|
246
|
+
else:
|
|
247
|
+
direction = "hold"
|
|
248
|
+
|
|
249
|
+
urgency = min(1.0, abs(target_energy - current_energy) * 2.0)
|
|
250
|
+
|
|
251
|
+
energy_window = EnergyWindow(
|
|
252
|
+
current_energy=current_energy,
|
|
253
|
+
target_energy=target_energy,
|
|
254
|
+
direction=direction,
|
|
255
|
+
urgency=round(urgency, 3),
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
safe_moves = get_safe_moves(scenes, current_scene_index, energy_window)
|
|
259
|
+
blocked = get_blocked_moves()
|
|
260
|
+
|
|
261
|
+
return PerformanceState(
|
|
262
|
+
scenes=scenes,
|
|
263
|
+
current_scene=current_scene_index,
|
|
264
|
+
energy_window=energy_window,
|
|
265
|
+
safe_moves=safe_moves,
|
|
266
|
+
blocked_moves=blocked,
|
|
267
|
+
)
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Performance Engine safety — move classification and policy.
|
|
2
|
+
|
|
3
|
+
Defines which move types are safe during live performance,
|
|
4
|
+
which require caution, and which are blocked entirely.
|
|
5
|
+
|
|
6
|
+
Zero I/O.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from .models import EnergyWindow, LiveSafeMove, SceneRole
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# ── Move classification sets ──────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
SAFE_MOVE_TYPES: frozenset = frozenset({
|
|
17
|
+
"scene_launch",
|
|
18
|
+
"send_nudge",
|
|
19
|
+
"macro_nudge",
|
|
20
|
+
"filter_sweep",
|
|
21
|
+
"mute_toggle",
|
|
22
|
+
"volume_nudge",
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
CAUTION_MOVE_TYPES: frozenset = frozenset({
|
|
26
|
+
"tempo_nudge",
|
|
27
|
+
"device_toggle",
|
|
28
|
+
"pan_nudge",
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
BLOCKED_MOVE_TYPES: frozenset = frozenset({
|
|
32
|
+
"device_chain_surgery",
|
|
33
|
+
"arrangement_edit",
|
|
34
|
+
"track_create_delete",
|
|
35
|
+
"note_edit",
|
|
36
|
+
"clip_create_delete",
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ── Classification ────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def classify_move_safety(move_type: str) -> str:
|
|
44
|
+
"""Classify a move type as 'safe', 'caution', or 'blocked'.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
'safe' if the move is in SAFE_MOVE_TYPES,
|
|
48
|
+
'blocked' if in BLOCKED_MOVE_TYPES,
|
|
49
|
+
'caution' otherwise (unknown or caution-class moves).
|
|
50
|
+
"""
|
|
51
|
+
if move_type in SAFE_MOVE_TYPES:
|
|
52
|
+
return "safe"
|
|
53
|
+
if move_type in BLOCKED_MOVE_TYPES:
|
|
54
|
+
return "blocked"
|
|
55
|
+
return "caution"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ── Safe move suggestions ─────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_safe_moves(
|
|
62
|
+
scene_roles: list[SceneRole],
|
|
63
|
+
current_scene: int,
|
|
64
|
+
energy_window: EnergyWindow,
|
|
65
|
+
) -> list[LiveSafeMove]:
|
|
66
|
+
"""Suggest safe moves based on current scene and energy state.
|
|
67
|
+
|
|
68
|
+
Returns a list of LiveSafeMove instances — only safe-class moves.
|
|
69
|
+
"""
|
|
70
|
+
moves: list[LiveSafeMove] = []
|
|
71
|
+
|
|
72
|
+
# Scene launch suggestions: suggest adjacent scenes
|
|
73
|
+
for scene in scene_roles:
|
|
74
|
+
if scene.scene_index == current_scene:
|
|
75
|
+
continue
|
|
76
|
+
energy_delta = scene.energy_level - energy_window.current_energy
|
|
77
|
+
# Suggest scenes that move toward the target energy
|
|
78
|
+
if energy_window.direction == "up" and energy_delta > 0:
|
|
79
|
+
moves.append(LiveSafeMove(
|
|
80
|
+
move_type="scene_launch",
|
|
81
|
+
target=f"scene_{scene.scene_index}",
|
|
82
|
+
description=f"Launch '{scene.name}' (energy {scene.energy_level:.1f}, role: {scene.role})",
|
|
83
|
+
risk_level="safe",
|
|
84
|
+
parameters={"scene_index": scene.scene_index},
|
|
85
|
+
reversible=True,
|
|
86
|
+
))
|
|
87
|
+
elif energy_window.direction == "down" and energy_delta < 0:
|
|
88
|
+
moves.append(LiveSafeMove(
|
|
89
|
+
move_type="scene_launch",
|
|
90
|
+
target=f"scene_{scene.scene_index}",
|
|
91
|
+
description=f"Launch '{scene.name}' (energy {scene.energy_level:.1f}, role: {scene.role})",
|
|
92
|
+
risk_level="safe",
|
|
93
|
+
parameters={"scene_index": scene.scene_index},
|
|
94
|
+
reversible=True,
|
|
95
|
+
))
|
|
96
|
+
elif energy_window.direction == "hold":
|
|
97
|
+
# Suggest scenes with similar energy
|
|
98
|
+
if abs(energy_delta) < 0.2:
|
|
99
|
+
moves.append(LiveSafeMove(
|
|
100
|
+
move_type="scene_launch",
|
|
101
|
+
target=f"scene_{scene.scene_index}",
|
|
102
|
+
description=f"Launch '{scene.name}' (similar energy {scene.energy_level:.1f})",
|
|
103
|
+
risk_level="safe",
|
|
104
|
+
parameters={"scene_index": scene.scene_index},
|
|
105
|
+
reversible=True,
|
|
106
|
+
))
|
|
107
|
+
|
|
108
|
+
# Energy-based nudge suggestions
|
|
109
|
+
if energy_window.direction == "up":
|
|
110
|
+
moves.append(LiveSafeMove(
|
|
111
|
+
move_type="volume_nudge",
|
|
112
|
+
target="master",
|
|
113
|
+
description="Nudge master volume up for energy lift",
|
|
114
|
+
risk_level="safe",
|
|
115
|
+
parameters={"delta_db": 1.0},
|
|
116
|
+
reversible=True,
|
|
117
|
+
))
|
|
118
|
+
moves.append(LiveSafeMove(
|
|
119
|
+
move_type="send_nudge",
|
|
120
|
+
target="reverb_send",
|
|
121
|
+
description="Increase reverb send for spatial expansion",
|
|
122
|
+
risk_level="safe",
|
|
123
|
+
parameters={"delta": 0.05},
|
|
124
|
+
reversible=True,
|
|
125
|
+
))
|
|
126
|
+
elif energy_window.direction == "down":
|
|
127
|
+
moves.append(LiveSafeMove(
|
|
128
|
+
move_type="filter_sweep",
|
|
129
|
+
target="master",
|
|
130
|
+
description="Low-pass filter sweep to reduce energy",
|
|
131
|
+
risk_level="safe",
|
|
132
|
+
parameters={"direction": "down", "range": [200, 8000]},
|
|
133
|
+
reversible=True,
|
|
134
|
+
))
|
|
135
|
+
moves.append(LiveSafeMove(
|
|
136
|
+
move_type="mute_toggle",
|
|
137
|
+
target="high_energy_track",
|
|
138
|
+
description="Mute a high-energy layer to reduce intensity",
|
|
139
|
+
risk_level="safe",
|
|
140
|
+
parameters={},
|
|
141
|
+
reversible=True,
|
|
142
|
+
))
|
|
143
|
+
|
|
144
|
+
# Always offer macro nudge as a safe option
|
|
145
|
+
moves.append(LiveSafeMove(
|
|
146
|
+
move_type="macro_nudge",
|
|
147
|
+
target="performance_macro",
|
|
148
|
+
description="Adjust performance macro for subtle variation",
|
|
149
|
+
risk_level="safe",
|
|
150
|
+
parameters={"delta": 0.02},
|
|
151
|
+
reversible=True,
|
|
152
|
+
))
|
|
153
|
+
|
|
154
|
+
return moves
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ── Blocked move list ─────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def get_blocked_moves() -> list[str]:
|
|
161
|
+
"""Return all blocked move types as a sorted list."""
|
|
162
|
+
return sorted(BLOCKED_MOVE_TYPES)
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Performance Engine MCP tools — 3 tools for live performance mode.
|
|
2
|
+
|
|
3
|
+
Each tool fetches scene data from Ableton via the shared connection,
|
|
4
|
+
then delegates to pure-computation modules.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from mcp.server.fastmcp import Context
|
|
10
|
+
|
|
11
|
+
from ..server import mcp
|
|
12
|
+
from .models import EnergyWindow, SceneRole
|
|
13
|
+
from .planner import build_performance_state, plan_scene_transition, suggest_energy_moves
|
|
14
|
+
from .safety import classify_move_safety, get_blocked_moves, get_safe_moves
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ── Helpers ─────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _infer_role(name: str, index: int, scene_count: int) -> str:
|
|
21
|
+
"""Infer a scene's role from its name or position."""
|
|
22
|
+
lower = name.lower()
|
|
23
|
+
for role in ("intro", "verse", "chorus", "build", "drop", "breakdown", "outro", "transition"):
|
|
24
|
+
if role in lower:
|
|
25
|
+
return role
|
|
26
|
+
# Positional fallback
|
|
27
|
+
if index == 0:
|
|
28
|
+
return "intro"
|
|
29
|
+
if index == scene_count - 1:
|
|
30
|
+
return "outro"
|
|
31
|
+
if scene_count > 4:
|
|
32
|
+
quarter = scene_count / 4
|
|
33
|
+
if index < quarter:
|
|
34
|
+
return "intro"
|
|
35
|
+
elif index < quarter * 2:
|
|
36
|
+
return "verse"
|
|
37
|
+
elif index < quarter * 3:
|
|
38
|
+
return "chorus"
|
|
39
|
+
else:
|
|
40
|
+
return "outro"
|
|
41
|
+
return "verse"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _infer_energy(role: str) -> float:
|
|
45
|
+
"""Infer energy level from scene role."""
|
|
46
|
+
energy_map = {
|
|
47
|
+
"intro": 0.2,
|
|
48
|
+
"verse": 0.4,
|
|
49
|
+
"build": 0.6,
|
|
50
|
+
"chorus": 0.7,
|
|
51
|
+
"drop": 0.9,
|
|
52
|
+
"breakdown": 0.3,
|
|
53
|
+
"transition": 0.5,
|
|
54
|
+
"outro": 0.2,
|
|
55
|
+
}
|
|
56
|
+
return energy_map.get(role, 0.5)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _fetch_scene_data(ctx: Context) -> tuple[list[SceneRole], int]:
|
|
60
|
+
"""Fetch scene info from Ableton and build SceneRole list."""
|
|
61
|
+
ableton = ctx.lifespan_context["ableton"]
|
|
62
|
+
|
|
63
|
+
scenes_info = ableton.send_command("get_scenes_info", {})
|
|
64
|
+
scenes_list = scenes_info.get("scenes", [])
|
|
65
|
+
scene_count = len(scenes_list)
|
|
66
|
+
|
|
67
|
+
scene_roles: list[SceneRole] = []
|
|
68
|
+
for i, scene_data in enumerate(scenes_list):
|
|
69
|
+
name = scene_data.get("name", f"Scene {i}")
|
|
70
|
+
role = _infer_role(name, i, scene_count)
|
|
71
|
+
energy = _infer_energy(role)
|
|
72
|
+
scene_roles.append(SceneRole(
|
|
73
|
+
scene_index=i,
|
|
74
|
+
name=name,
|
|
75
|
+
energy_level=energy,
|
|
76
|
+
role=role,
|
|
77
|
+
))
|
|
78
|
+
|
|
79
|
+
# Determine current scene — default to 0 since session_info
|
|
80
|
+
# doesn't expose a selected_scene field
|
|
81
|
+
current_scene = 0
|
|
82
|
+
try:
|
|
83
|
+
session_info = ableton.send_command("get_session_info", {})
|
|
84
|
+
# Check if any scene is marked as triggered/playing
|
|
85
|
+
session_scenes = session_info.get("scenes", [])
|
|
86
|
+
for i, s in enumerate(session_scenes):
|
|
87
|
+
if s.get("is_triggered", False):
|
|
88
|
+
current_scene = i
|
|
89
|
+
break
|
|
90
|
+
except Exception:
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
return scene_roles, current_scene
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ── MCP Tools ───────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@mcp.tool()
|
|
100
|
+
def get_performance_state(ctx: Context) -> dict:
|
|
101
|
+
"""Get current live performance overview — scenes, energy, safe moves.
|
|
102
|
+
|
|
103
|
+
Returns scene roles with energy levels, current energy window
|
|
104
|
+
with steering direction, available safe moves, and blocked move types.
|
|
105
|
+
Use this to understand the performance context before making changes.
|
|
106
|
+
"""
|
|
107
|
+
scene_roles, current_scene = _fetch_scene_data(ctx)
|
|
108
|
+
state = build_performance_state(scene_roles, current_scene)
|
|
109
|
+
return state.to_dict()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@mcp.tool()
|
|
113
|
+
def get_performance_safe_moves(ctx: Context) -> dict:
|
|
114
|
+
"""Get available safe moves for live performance.
|
|
115
|
+
|
|
116
|
+
Returns only performance-safe moves based on current scene
|
|
117
|
+
and energy direction. All moves are reversible and low-risk.
|
|
118
|
+
Also returns the full blocked move list for transparency.
|
|
119
|
+
"""
|
|
120
|
+
scene_roles, current_scene = _fetch_scene_data(ctx)
|
|
121
|
+
state = build_performance_state(scene_roles, current_scene)
|
|
122
|
+
|
|
123
|
+
# Also get energy-specific suggestions
|
|
124
|
+
current = None
|
|
125
|
+
for s in scene_roles:
|
|
126
|
+
if s.scene_index == current_scene:
|
|
127
|
+
current = s
|
|
128
|
+
break
|
|
129
|
+
|
|
130
|
+
energy_moves: list[dict] = []
|
|
131
|
+
if current is not None:
|
|
132
|
+
em = suggest_energy_moves(state.energy_window, current)
|
|
133
|
+
energy_moves = [m.to_dict() for m in em]
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
"safe_moves": [m.to_dict() for m in state.safe_moves],
|
|
137
|
+
"energy_moves": energy_moves,
|
|
138
|
+
"blocked_moves": state.blocked_moves,
|
|
139
|
+
"safe_move_count": len(state.safe_moves),
|
|
140
|
+
"energy_move_count": len(energy_moves),
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@mcp.tool()
|
|
145
|
+
def plan_scene_handoff(
|
|
146
|
+
ctx: Context,
|
|
147
|
+
from_scene: int,
|
|
148
|
+
to_scene: int,
|
|
149
|
+
) -> dict:
|
|
150
|
+
"""Plan a safe transition between two scenes.
|
|
151
|
+
|
|
152
|
+
Generates an energy path and gesture sequence for smooth
|
|
153
|
+
scene-to-scene handoffs during live performance.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
from_scene: Source scene index.
|
|
157
|
+
to_scene: Destination scene index.
|
|
158
|
+
"""
|
|
159
|
+
scene_roles, _ = _fetch_scene_data(ctx)
|
|
160
|
+
|
|
161
|
+
# Find the two scenes
|
|
162
|
+
from_role = None
|
|
163
|
+
to_role = None
|
|
164
|
+
for s in scene_roles:
|
|
165
|
+
if s.scene_index == from_scene:
|
|
166
|
+
from_role = s
|
|
167
|
+
if s.scene_index == to_scene:
|
|
168
|
+
to_role = s
|
|
169
|
+
|
|
170
|
+
if from_role is None:
|
|
171
|
+
return {"error": f"Scene {from_scene} not found", "code": "NOT_FOUND"}
|
|
172
|
+
if to_role is None:
|
|
173
|
+
return {"error": f"Scene {to_scene} not found", "code": "NOT_FOUND"}
|
|
174
|
+
|
|
175
|
+
plan = plan_scene_transition(from_role, to_role)
|
|
176
|
+
return {
|
|
177
|
+
"handoff_plan": plan.to_dict(),
|
|
178
|
+
"from_scene": from_role.to_dict(),
|
|
179
|
+
"to_scene": to_role.to_dict(),
|
|
180
|
+
"energy_delta": round(to_role.energy_level - from_role.energy_level, 3),
|
|
181
|
+
"gesture_count": len(plan.gestures),
|
|
182
|
+
"step_count": len(plan.energy_path),
|
|
183
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Arrangement graph builder — transforms scenes + clip matrix into ArrangementGraph.
|
|
2
|
+
|
|
3
|
+
Reuses _composition_engine.build_section_graph_from_scenes for the heavy
|
|
4
|
+
inference, then converts composition SectionNodes to brain SectionNodes.
|
|
5
|
+
|
|
6
|
+
Pure computation, zero I/O.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from ..tools._composition_engine import (
|
|
12
|
+
build_section_graph_from_scenes as _ce_build_sections,
|
|
13
|
+
)
|
|
14
|
+
from .models import ArrangementGraph, SectionNode
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def build_arrangement_graph(
|
|
18
|
+
scenes: list[dict],
|
|
19
|
+
clip_matrix: list[list[dict]],
|
|
20
|
+
track_count: int,
|
|
21
|
+
beats_per_bar: int = 4,
|
|
22
|
+
) -> ArrangementGraph:
|
|
23
|
+
"""Build an ArrangementGraph from session-view scenes and clip matrix.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
scenes: list of {index, name, tempo, color_index}.
|
|
27
|
+
clip_matrix: [scene_index][track_index] = {state, name, ...} or None.
|
|
28
|
+
track_count: total number of tracks in the session.
|
|
29
|
+
beats_per_bar: beats per bar (default 4).
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
ArrangementGraph with sections, boundaries, and freshness (unfreshed).
|
|
33
|
+
"""
|
|
34
|
+
graph = ArrangementGraph()
|
|
35
|
+
|
|
36
|
+
if not scenes:
|
|
37
|
+
return graph
|
|
38
|
+
|
|
39
|
+
# Delegate to composition engine for section inference
|
|
40
|
+
ce_sections = _ce_build_sections(scenes, clip_matrix, track_count, beats_per_bar)
|
|
41
|
+
|
|
42
|
+
# Convert composition SectionNodes -> brain SectionNodes
|
|
43
|
+
for ce_sec in ce_sections:
|
|
44
|
+
graph.sections.append(SectionNode(
|
|
45
|
+
section_id=ce_sec.section_id,
|
|
46
|
+
start_bar=ce_sec.start_bar,
|
|
47
|
+
end_bar=ce_sec.end_bar,
|
|
48
|
+
section_type=ce_sec.section_type.value,
|
|
49
|
+
energy=ce_sec.energy,
|
|
50
|
+
density=ce_sec.density,
|
|
51
|
+
))
|
|
52
|
+
|
|
53
|
+
# Build boundary list (transitions between adjacent sections)
|
|
54
|
+
for i in range(len(graph.sections) - 1):
|
|
55
|
+
curr = graph.sections[i]
|
|
56
|
+
nxt = graph.sections[i + 1]
|
|
57
|
+
graph.boundaries.append({
|
|
58
|
+
"from_section": curr.section_id,
|
|
59
|
+
"to_section": nxt.section_id,
|
|
60
|
+
"bar": curr.end_bar,
|
|
61
|
+
"energy_delta": round(nxt.energy - curr.energy, 3),
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
return graph
|