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,316 @@
|
|
|
1
|
+
"""State builder — construct MixState from session data.
|
|
2
|
+
|
|
3
|
+
Pure computation, zero I/O. MCP tool wrappers fetch data from Ableton
|
|
4
|
+
and pass it here.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from .models import (
|
|
13
|
+
BalanceState,
|
|
14
|
+
DepthState,
|
|
15
|
+
DynamicsState,
|
|
16
|
+
MaskingEntry,
|
|
17
|
+
MaskingMap,
|
|
18
|
+
MixState,
|
|
19
|
+
StereoState,
|
|
20
|
+
TrackMixState,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# Roles considered "anchor" — should be prominent in the mix.
|
|
25
|
+
_ANCHOR_ROLES = frozenset({"kick", "bass", "vocal", "lead", "drums"})
|
|
26
|
+
|
|
27
|
+
# Frequency bands where masking is most problematic.
|
|
28
|
+
_MASKING_BANDS = ("sub", "low", "low_mid", "mid", "high_mid", "presence", "high")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ── Balance ─────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def build_balance_state(
|
|
35
|
+
track_infos: list[dict],
|
|
36
|
+
role_hints: Optional[dict[int, str]] = None,
|
|
37
|
+
) -> BalanceState:
|
|
38
|
+
"""Build BalanceState from track info dicts.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
track_infos: list of dicts from get_track_info (Remote Script format).
|
|
42
|
+
Volume/panning are nested under "mixer", sends under "sends".
|
|
43
|
+
role_hints: optional {track_index: role_name} overrides.
|
|
44
|
+
"""
|
|
45
|
+
from ..tools._agent_os_engine import infer_track_role
|
|
46
|
+
|
|
47
|
+
role_hints = role_hints or {}
|
|
48
|
+
states: list[TrackMixState] = []
|
|
49
|
+
anchor_indices: list[int] = []
|
|
50
|
+
loudest_idx = -1
|
|
51
|
+
quietest_idx = -1
|
|
52
|
+
loudest_vol = -math.inf
|
|
53
|
+
quietest_vol = math.inf
|
|
54
|
+
|
|
55
|
+
for info in track_infos:
|
|
56
|
+
idx = info.get("index", 0)
|
|
57
|
+
name = info.get("name", "")
|
|
58
|
+
# Infer role from track name if no explicit hint
|
|
59
|
+
role = role_hints.get(idx, infer_track_role(name))
|
|
60
|
+
# Extract mixer values from nested Remote Script response
|
|
61
|
+
mixer = info.get("mixer", {})
|
|
62
|
+
vol = mixer.get("volume", 0.0) if mixer else info.get("volume", 0.0)
|
|
63
|
+
pan = mixer.get("panning", 0.0) if mixer else info.get("pan", 0.0)
|
|
64
|
+
# Extract send levels from sends array
|
|
65
|
+
sends_raw = info.get("sends", [])
|
|
66
|
+
send_levels = [s.get("value", 0.0) for s in sends_raw] if sends_raw else []
|
|
67
|
+
|
|
68
|
+
ts = TrackMixState(
|
|
69
|
+
track_index=idx,
|
|
70
|
+
name=name,
|
|
71
|
+
role=role,
|
|
72
|
+
volume=vol,
|
|
73
|
+
pan=pan,
|
|
74
|
+
mute=info.get("mute", False),
|
|
75
|
+
solo=info.get("solo", False),
|
|
76
|
+
send_levels=send_levels,
|
|
77
|
+
)
|
|
78
|
+
states.append(ts)
|
|
79
|
+
|
|
80
|
+
if role in _ANCHOR_ROLES:
|
|
81
|
+
anchor_indices.append(idx)
|
|
82
|
+
|
|
83
|
+
if not ts.mute:
|
|
84
|
+
if vol > loudest_vol:
|
|
85
|
+
loudest_vol = vol
|
|
86
|
+
loudest_idx = idx
|
|
87
|
+
if vol < quietest_vol:
|
|
88
|
+
quietest_vol = vol
|
|
89
|
+
quietest_idx = idx
|
|
90
|
+
|
|
91
|
+
return BalanceState(
|
|
92
|
+
track_states=states,
|
|
93
|
+
anchor_tracks=anchor_indices,
|
|
94
|
+
loudest_track=loudest_idx,
|
|
95
|
+
quietest_track=quietest_idx,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ── Masking ─────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def build_masking_map(
|
|
103
|
+
spectrum: Optional[dict],
|
|
104
|
+
track_roles: Optional[dict[int, str]] = None,
|
|
105
|
+
) -> MaskingMap:
|
|
106
|
+
"""Build MaskingMap from spectrum data.
|
|
107
|
+
|
|
108
|
+
Uses per-track spectrum bands if available, otherwise returns empty.
|
|
109
|
+
Spectrum shape: {"tracks": {track_idx_str: {band: value, ...}, ...}}
|
|
110
|
+
or flat {"bands": {band: value}} for master-only.
|
|
111
|
+
|
|
112
|
+
For Phase 1 we detect masking heuristically from role collisions
|
|
113
|
+
in known problem bands (kick/bass in sub/low, bass/chords in low_mid).
|
|
114
|
+
"""
|
|
115
|
+
entries: list[MaskingEntry] = []
|
|
116
|
+
track_roles = track_roles or {}
|
|
117
|
+
|
|
118
|
+
if not spectrum or not track_roles:
|
|
119
|
+
return MaskingMap(entries=[], worst_pair=None)
|
|
120
|
+
|
|
121
|
+
# Build role->indices mapping
|
|
122
|
+
role_to_indices: dict[str, list[int]] = {}
|
|
123
|
+
for idx, role in track_roles.items():
|
|
124
|
+
role_to_indices.setdefault(role, []).append(idx)
|
|
125
|
+
|
|
126
|
+
# Known problematic role pairs and their collision bands
|
|
127
|
+
collision_rules: list[tuple[str, str, str, float]] = [
|
|
128
|
+
("kick", "bass", "sub", 0.7),
|
|
129
|
+
("kick", "bass", "low", 0.6),
|
|
130
|
+
("bass", "chords", "low_mid", 0.5),
|
|
131
|
+
("bass", "keys", "low_mid", 0.5),
|
|
132
|
+
("vocal", "lead", "presence", 0.4),
|
|
133
|
+
("vocal", "lead", "high_mid", 0.4),
|
|
134
|
+
("lead", "synth", "mid", 0.3),
|
|
135
|
+
("chords", "pad", "mid", 0.3),
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
for role_a, role_b, band, base_severity in collision_rules:
|
|
139
|
+
indices_a = role_to_indices.get(role_a, [])
|
|
140
|
+
indices_b = role_to_indices.get(role_b, [])
|
|
141
|
+
for ia in indices_a:
|
|
142
|
+
for ib in indices_b:
|
|
143
|
+
if ia != ib:
|
|
144
|
+
entries.append(MaskingEntry(
|
|
145
|
+
track_a=ia,
|
|
146
|
+
track_b=ib,
|
|
147
|
+
overlap_band=band,
|
|
148
|
+
severity=base_severity,
|
|
149
|
+
))
|
|
150
|
+
|
|
151
|
+
worst = None
|
|
152
|
+
if entries:
|
|
153
|
+
worst_entry = max(entries, key=lambda e: e.severity)
|
|
154
|
+
worst = (worst_entry.track_a, worst_entry.track_b)
|
|
155
|
+
|
|
156
|
+
return MaskingMap(entries=entries, worst_pair=worst)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ── Dynamics ────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def build_dynamics_state(
|
|
163
|
+
rms: Optional[float],
|
|
164
|
+
peak: Optional[float],
|
|
165
|
+
) -> DynamicsState:
|
|
166
|
+
"""Build DynamicsState from RMS and peak values.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
rms: master RMS level in linear (0-1) or dB.
|
|
170
|
+
peak: master peak level in linear (0-1) or dB.
|
|
171
|
+
"""
|
|
172
|
+
if rms is None or peak is None or rms <= 0:
|
|
173
|
+
return DynamicsState(crest_factor_db=0.0, over_compressed=False, headroom=0.0)
|
|
174
|
+
|
|
175
|
+
# If values look like they're in dB (negative), convert to linear
|
|
176
|
+
if rms < 0:
|
|
177
|
+
rms_linear = 10 ** (rms / 20.0)
|
|
178
|
+
peak_linear = 10 ** ((peak or 0) / 20.0)
|
|
179
|
+
else:
|
|
180
|
+
rms_linear = rms
|
|
181
|
+
peak_linear = peak if peak else rms
|
|
182
|
+
|
|
183
|
+
if rms_linear <= 0:
|
|
184
|
+
return DynamicsState(crest_factor_db=0.0, over_compressed=False, headroom=0.0)
|
|
185
|
+
|
|
186
|
+
crest = 20 * math.log10(max(peak_linear, 1e-10) / max(rms_linear, 1e-10))
|
|
187
|
+
|
|
188
|
+
# Over-compressed when crest factor < 6 dB (flat dynamics)
|
|
189
|
+
over_compressed = crest < 6.0
|
|
190
|
+
|
|
191
|
+
# Headroom = distance from peak to 0 dBFS
|
|
192
|
+
if peak_linear > 0:
|
|
193
|
+
headroom = -20 * math.log10(max(peak_linear, 1e-10))
|
|
194
|
+
else:
|
|
195
|
+
headroom = 100.0 # effectively infinite headroom
|
|
196
|
+
|
|
197
|
+
return DynamicsState(
|
|
198
|
+
crest_factor_db=round(crest, 2),
|
|
199
|
+
over_compressed=over_compressed,
|
|
200
|
+
headroom=round(headroom, 2),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# ── Composite builder ──────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def build_mix_state(
|
|
208
|
+
session_info: Optional[dict] = None,
|
|
209
|
+
track_infos: Optional[list[dict]] = None,
|
|
210
|
+
spectrum: Optional[dict] = None,
|
|
211
|
+
rms_data: Optional[float] = None,
|
|
212
|
+
role_hints: Optional[dict[int, str]] = None,
|
|
213
|
+
) -> MixState:
|
|
214
|
+
"""Build a full MixState from session data.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
session_info: session-level info (tempo, etc.) — reserved for future.
|
|
218
|
+
track_infos: per-track info dicts.
|
|
219
|
+
spectrum: spectrum data (master or per-track).
|
|
220
|
+
rms_data: master RMS value.
|
|
221
|
+
role_hints: {track_index: role_str} overrides.
|
|
222
|
+
"""
|
|
223
|
+
track_infos = track_infos or []
|
|
224
|
+
role_hints = role_hints or {}
|
|
225
|
+
|
|
226
|
+
balance = build_balance_state(track_infos, role_hints)
|
|
227
|
+
masking = build_masking_map(spectrum, role_hints)
|
|
228
|
+
|
|
229
|
+
# Extract peak from spectrum if available
|
|
230
|
+
peak = None
|
|
231
|
+
if spectrum:
|
|
232
|
+
peak = spectrum.get("peak")
|
|
233
|
+
|
|
234
|
+
dynamics = build_dynamics_state(rms_data, peak)
|
|
235
|
+
|
|
236
|
+
# Stereo and depth require per-track analysis not yet available.
|
|
237
|
+
# Build from track send levels as a proxy.
|
|
238
|
+
stereo = _build_stereo_from_tracks(balance.track_states)
|
|
239
|
+
depth = _build_depth_from_tracks(balance.track_states)
|
|
240
|
+
|
|
241
|
+
return MixState(
|
|
242
|
+
balance=balance,
|
|
243
|
+
masking=masking,
|
|
244
|
+
dynamics=dynamics,
|
|
245
|
+
stereo=stereo,
|
|
246
|
+
depth=depth,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
# ── Internal helpers ────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _build_stereo_from_tracks(tracks: list[TrackMixState]) -> StereoState:
|
|
254
|
+
"""Estimate stereo field from pan positions."""
|
|
255
|
+
if not tracks:
|
|
256
|
+
return StereoState(center_strength=1.0, side_activity=0.0, mono_risk=False)
|
|
257
|
+
|
|
258
|
+
center_count = 0
|
|
259
|
+
total_side = 0.0
|
|
260
|
+
active = [t for t in tracks if not t.mute]
|
|
261
|
+
|
|
262
|
+
if not active:
|
|
263
|
+
return StereoState(center_strength=1.0, side_activity=0.0, mono_risk=False)
|
|
264
|
+
|
|
265
|
+
for t in active:
|
|
266
|
+
if abs(t.pan) < 0.1:
|
|
267
|
+
center_count += 1
|
|
268
|
+
total_side += abs(t.pan)
|
|
269
|
+
|
|
270
|
+
center_strength = center_count / len(active)
|
|
271
|
+
side_activity = total_side / len(active)
|
|
272
|
+
|
|
273
|
+
# Mono risk: everything is centered
|
|
274
|
+
mono_risk = center_strength > 0.85 and side_activity < 0.05
|
|
275
|
+
|
|
276
|
+
return StereoState(
|
|
277
|
+
center_strength=round(center_strength, 3),
|
|
278
|
+
side_activity=round(side_activity, 3),
|
|
279
|
+
mono_risk=mono_risk,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _build_depth_from_tracks(tracks: list[TrackMixState]) -> DepthState:
|
|
284
|
+
"""Estimate depth from send levels (reverb/delay sends)."""
|
|
285
|
+
if not tracks:
|
|
286
|
+
return DepthState(wet_dry_ratio=0.0, depth_separation=0.0, wash_risk=False)
|
|
287
|
+
|
|
288
|
+
active = [t for t in tracks if not t.mute]
|
|
289
|
+
if not active:
|
|
290
|
+
return DepthState(wet_dry_ratio=0.0, depth_separation=0.0, wash_risk=False)
|
|
291
|
+
|
|
292
|
+
total_send = 0.0
|
|
293
|
+
send_values: list[float] = []
|
|
294
|
+
|
|
295
|
+
for t in active:
|
|
296
|
+
avg_send = sum(t.send_levels) / max(len(t.send_levels), 1) if t.send_levels else 0.0
|
|
297
|
+
total_send += avg_send
|
|
298
|
+
send_values.append(avg_send)
|
|
299
|
+
|
|
300
|
+
avg_wet = total_send / len(active)
|
|
301
|
+
|
|
302
|
+
# Depth separation: variance in send levels
|
|
303
|
+
if len(send_values) > 1:
|
|
304
|
+
mean = sum(send_values) / len(send_values)
|
|
305
|
+
variance = sum((v - mean) ** 2 for v in send_values) / len(send_values)
|
|
306
|
+
depth_sep = math.sqrt(variance)
|
|
307
|
+
else:
|
|
308
|
+
depth_sep = 0.0
|
|
309
|
+
|
|
310
|
+
wash_risk = avg_wet > 0.6
|
|
311
|
+
|
|
312
|
+
return DepthState(
|
|
313
|
+
wet_dry_ratio=round(avg_wet, 3),
|
|
314
|
+
depth_separation=round(depth_sep, 3),
|
|
315
|
+
wash_risk=wash_risk,
|
|
316
|
+
)
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Mix Engine MCP tools — 6 tools for mix analysis and move planning.
|
|
2
|
+
|
|
3
|
+
Each tool fetches 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 ..tools._evaluation_contracts import EvaluationRequest
|
|
13
|
+
from ..tools._snapshot_normalizer import normalize_sonic_snapshot
|
|
14
|
+
from ..evaluation.fabric import evaluate_sonic_move
|
|
15
|
+
from .state_builder import build_mix_state
|
|
16
|
+
from .critics import run_all_mix_critics
|
|
17
|
+
from .planner import plan_mix_moves
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ── Helpers ─────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _fetch_mix_data(ctx: Context) -> dict:
|
|
24
|
+
"""Fetch all data needed to build a MixState from Ableton."""
|
|
25
|
+
ableton = ctx.lifespan_context["ableton"]
|
|
26
|
+
|
|
27
|
+
session_info = ableton.send_command("get_session_info", {})
|
|
28
|
+
track_count = session_info.get("track_count", 0)
|
|
29
|
+
|
|
30
|
+
track_infos: list[dict] = []
|
|
31
|
+
for i in range(track_count):
|
|
32
|
+
try:
|
|
33
|
+
info = ableton.send_command("get_track_info", {"track_index": i})
|
|
34
|
+
track_infos.append(info)
|
|
35
|
+
except Exception:
|
|
36
|
+
continue
|
|
37
|
+
|
|
38
|
+
# Try to get spectrum and RMS data
|
|
39
|
+
spectrum = None
|
|
40
|
+
rms_data = None
|
|
41
|
+
try:
|
|
42
|
+
spectrum = ableton.send_command("get_master_spectrum", {})
|
|
43
|
+
except Exception:
|
|
44
|
+
pass
|
|
45
|
+
try:
|
|
46
|
+
rms_result = ableton.send_command("get_master_rms", {})
|
|
47
|
+
rms_data = rms_result.get("rms") if isinstance(rms_result, dict) else None
|
|
48
|
+
except Exception:
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
"session_info": session_info,
|
|
53
|
+
"track_infos": track_infos,
|
|
54
|
+
"spectrum": spectrum,
|
|
55
|
+
"rms_data": rms_data,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ── MCP Tools ───────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@mcp.tool()
|
|
63
|
+
def analyze_mix(ctx: Context) -> dict:
|
|
64
|
+
"""Build full mix state and run all critics.
|
|
65
|
+
|
|
66
|
+
Returns the complete mix analysis including all sub-states
|
|
67
|
+
(balance, masking, dynamics, stereo, depth) and all detected issues.
|
|
68
|
+
"""
|
|
69
|
+
data = _fetch_mix_data(ctx)
|
|
70
|
+
mix_state = build_mix_state(
|
|
71
|
+
session_info=data["session_info"],
|
|
72
|
+
track_infos=data["track_infos"],
|
|
73
|
+
spectrum=data["spectrum"],
|
|
74
|
+
rms_data=data["rms_data"],
|
|
75
|
+
)
|
|
76
|
+
issues = run_all_mix_critics(mix_state)
|
|
77
|
+
moves = plan_mix_moves(issues, mix_state)
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
"mix_state": mix_state.to_dict(),
|
|
81
|
+
"issues": [i.to_dict() for i in issues],
|
|
82
|
+
"suggested_moves": [m.to_dict() for m in moves],
|
|
83
|
+
"issue_count": len(issues),
|
|
84
|
+
"move_count": len(moves),
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@mcp.tool()
|
|
89
|
+
def get_mix_issues(ctx: Context) -> dict:
|
|
90
|
+
"""Run all mix critics and return detected issues only.
|
|
91
|
+
|
|
92
|
+
Lighter than analyze_mix — skips move planning.
|
|
93
|
+
"""
|
|
94
|
+
data = _fetch_mix_data(ctx)
|
|
95
|
+
mix_state = build_mix_state(
|
|
96
|
+
session_info=data["session_info"],
|
|
97
|
+
track_infos=data["track_infos"],
|
|
98
|
+
spectrum=data["spectrum"],
|
|
99
|
+
rms_data=data["rms_data"],
|
|
100
|
+
)
|
|
101
|
+
issues = run_all_mix_critics(mix_state)
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
"issues": [i.to_dict() for i in issues],
|
|
105
|
+
"issue_count": len(issues),
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@mcp.tool()
|
|
110
|
+
def plan_mix_move(ctx: Context) -> dict:
|
|
111
|
+
"""Get ranked move suggestions based on current mix issues.
|
|
112
|
+
|
|
113
|
+
Runs critics and planner, returns sorted moves with
|
|
114
|
+
estimated impact and risk scores.
|
|
115
|
+
"""
|
|
116
|
+
data = _fetch_mix_data(ctx)
|
|
117
|
+
mix_state = build_mix_state(
|
|
118
|
+
session_info=data["session_info"],
|
|
119
|
+
track_infos=data["track_infos"],
|
|
120
|
+
spectrum=data["spectrum"],
|
|
121
|
+
rms_data=data["rms_data"],
|
|
122
|
+
)
|
|
123
|
+
issues = run_all_mix_critics(mix_state)
|
|
124
|
+
moves = plan_mix_moves(issues, mix_state)
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
"moves": [m.to_dict() for m in moves],
|
|
128
|
+
"move_count": len(moves),
|
|
129
|
+
"issue_count": len(issues),
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@mcp.tool()
|
|
134
|
+
def evaluate_mix_move(
|
|
135
|
+
ctx: Context,
|
|
136
|
+
before_snapshot: dict,
|
|
137
|
+
after_snapshot: dict,
|
|
138
|
+
targets: dict | None = None,
|
|
139
|
+
protect: dict | None = None,
|
|
140
|
+
) -> dict:
|
|
141
|
+
"""Score a mix change using the evaluation fabric.
|
|
142
|
+
|
|
143
|
+
Compare before/after spectral snapshots and evaluate whether
|
|
144
|
+
the mix move improved the targeted dimensions without harming
|
|
145
|
+
protected ones.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
before_snapshot: Spectral snapshot before the move.
|
|
149
|
+
after_snapshot: Spectral snapshot after the move.
|
|
150
|
+
targets: Goal targets {dimension: weight} (e.g. {"clarity": 0.5}).
|
|
151
|
+
protect: Protected dimensions {dimension: threshold}.
|
|
152
|
+
"""
|
|
153
|
+
targets = targets or {}
|
|
154
|
+
protect = protect or {}
|
|
155
|
+
|
|
156
|
+
request = EvaluationRequest(
|
|
157
|
+
engine="mix_engine",
|
|
158
|
+
goal={"targets": targets},
|
|
159
|
+
before=before_snapshot,
|
|
160
|
+
after=after_snapshot,
|
|
161
|
+
protect=protect,
|
|
162
|
+
)
|
|
163
|
+
result = evaluate_sonic_move(request)
|
|
164
|
+
return result.to_dict()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@mcp.tool()
|
|
168
|
+
def get_masking_report(ctx: Context) -> dict:
|
|
169
|
+
"""Get detailed frequency collision report.
|
|
170
|
+
|
|
171
|
+
Shows all detected masking pairs, severity, and the
|
|
172
|
+
worst collision pair.
|
|
173
|
+
"""
|
|
174
|
+
data = _fetch_mix_data(ctx)
|
|
175
|
+
mix_state = build_mix_state(
|
|
176
|
+
session_info=data["session_info"],
|
|
177
|
+
track_infos=data["track_infos"],
|
|
178
|
+
spectrum=data["spectrum"],
|
|
179
|
+
rms_data=data["rms_data"],
|
|
180
|
+
)
|
|
181
|
+
masking = mix_state.masking
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
"masking": masking.to_dict(),
|
|
185
|
+
"collision_count": len(masking.entries),
|
|
186
|
+
"worst_pair": list(masking.worst_pair) if masking.worst_pair else None,
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@mcp.tool()
|
|
191
|
+
def get_mix_summary(ctx: Context) -> dict:
|
|
192
|
+
"""Lightweight mix overview — track count, issue count, dynamics state.
|
|
193
|
+
|
|
194
|
+
Faster than full analysis for quick status checks.
|
|
195
|
+
"""
|
|
196
|
+
data = _fetch_mix_data(ctx)
|
|
197
|
+
mix_state = build_mix_state(
|
|
198
|
+
session_info=data["session_info"],
|
|
199
|
+
track_infos=data["track_infos"],
|
|
200
|
+
spectrum=data["spectrum"],
|
|
201
|
+
rms_data=data["rms_data"],
|
|
202
|
+
)
|
|
203
|
+
issues = run_all_mix_critics(mix_state)
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
"track_count": len(mix_state.balance.track_states),
|
|
207
|
+
"issue_count": len(issues),
|
|
208
|
+
"dynamics": mix_state.dynamics.to_dict(),
|
|
209
|
+
"stereo": mix_state.stereo.to_dict(),
|
|
210
|
+
"depth": mix_state.depth.to_dict(),
|
|
211
|
+
"anchor_tracks": mix_state.balance.anchor_tracks,
|
|
212
|
+
"loudest_track": mix_state.balance.loudest_track,
|
|
213
|
+
"quietest_track": mix_state.balance.quietest_track,
|
|
214
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Performance Engine V1 — live-safe mode, scene steering, safety policies."""
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Performance Engine state models — all dataclasses with to_dict().
|
|
2
|
+
|
|
3
|
+
Pure data structures for live performance mode:
|
|
4
|
+
SceneRole, EnergyWindow, LiveSafeMove, HandoffPlan, PerformanceState.
|
|
5
|
+
|
|
6
|
+
Zero I/O.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import asdict, dataclass, field
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# ── Valid values ──────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
VALID_ROLES = frozenset({
|
|
17
|
+
"intro", "verse", "chorus", "build", "drop",
|
|
18
|
+
"breakdown", "outro", "transition",
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
VALID_RISK_LEVELS = frozenset({"safe", "caution", "blocked"})
|
|
22
|
+
|
|
23
|
+
VALID_DIRECTIONS = frozenset({"up", "down", "hold"})
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ── SceneRole ─────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class SceneRole:
|
|
31
|
+
"""A scene's structural role and energy level."""
|
|
32
|
+
|
|
33
|
+
scene_index: int = 0
|
|
34
|
+
name: str = ""
|
|
35
|
+
energy_level: float = 0.5
|
|
36
|
+
role: str = "verse"
|
|
37
|
+
|
|
38
|
+
def __post_init__(self) -> None:
|
|
39
|
+
if not 0.0 <= self.energy_level <= 1.0:
|
|
40
|
+
raise ValueError(
|
|
41
|
+
f"energy_level must be 0.0-1.0, got {self.energy_level}"
|
|
42
|
+
)
|
|
43
|
+
if self.role not in VALID_ROLES:
|
|
44
|
+
raise ValueError(
|
|
45
|
+
f"role must be one of {sorted(VALID_ROLES)}, got {self.role!r}"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def to_dict(self) -> dict:
|
|
49
|
+
return asdict(self)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ── EnergyWindow ──────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class EnergyWindow:
|
|
57
|
+
"""Current energy state and steering intent."""
|
|
58
|
+
|
|
59
|
+
current_energy: float = 0.5
|
|
60
|
+
target_energy: float = 0.5
|
|
61
|
+
direction: str = "hold"
|
|
62
|
+
urgency: float = 0.0
|
|
63
|
+
|
|
64
|
+
def __post_init__(self) -> None:
|
|
65
|
+
if not 0.0 <= self.current_energy <= 1.0:
|
|
66
|
+
raise ValueError(
|
|
67
|
+
f"current_energy must be 0.0-1.0, got {self.current_energy}"
|
|
68
|
+
)
|
|
69
|
+
if not 0.0 <= self.target_energy <= 1.0:
|
|
70
|
+
raise ValueError(
|
|
71
|
+
f"target_energy must be 0.0-1.0, got {self.target_energy}"
|
|
72
|
+
)
|
|
73
|
+
if self.direction not in VALID_DIRECTIONS:
|
|
74
|
+
raise ValueError(
|
|
75
|
+
f"direction must be one of {sorted(VALID_DIRECTIONS)}, "
|
|
76
|
+
f"got {self.direction!r}"
|
|
77
|
+
)
|
|
78
|
+
if not 0.0 <= self.urgency <= 1.0:
|
|
79
|
+
raise ValueError(
|
|
80
|
+
f"urgency must be 0.0-1.0, got {self.urgency}"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def to_dict(self) -> dict:
|
|
84
|
+
return asdict(self)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ── LiveSafeMove ──────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class LiveSafeMove:
|
|
92
|
+
"""A single performance-safe move suggestion."""
|
|
93
|
+
|
|
94
|
+
move_type: str = ""
|
|
95
|
+
target: str = ""
|
|
96
|
+
description: str = ""
|
|
97
|
+
risk_level: str = "safe"
|
|
98
|
+
parameters: dict = field(default_factory=dict)
|
|
99
|
+
reversible: bool = True
|
|
100
|
+
|
|
101
|
+
def __post_init__(self) -> None:
|
|
102
|
+
if self.risk_level not in VALID_RISK_LEVELS:
|
|
103
|
+
raise ValueError(
|
|
104
|
+
f"risk_level must be one of {sorted(VALID_RISK_LEVELS)}, "
|
|
105
|
+
f"got {self.risk_level!r}"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def to_dict(self) -> dict:
|
|
109
|
+
return asdict(self)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ── HandoffPlan ───────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass
|
|
116
|
+
class HandoffPlan:
|
|
117
|
+
"""Transition plan from one scene to another."""
|
|
118
|
+
|
|
119
|
+
from_scene: int = 0
|
|
120
|
+
to_scene: int = 0
|
|
121
|
+
gestures: list[dict] = field(default_factory=list)
|
|
122
|
+
energy_path: list[float] = field(default_factory=list)
|
|
123
|
+
|
|
124
|
+
def to_dict(self) -> dict:
|
|
125
|
+
return asdict(self)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ── PerformanceState ──────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass
|
|
132
|
+
class PerformanceState:
|
|
133
|
+
"""Top-level container for live performance state."""
|
|
134
|
+
|
|
135
|
+
scenes: list[SceneRole] = field(default_factory=list)
|
|
136
|
+
current_scene: int = 0
|
|
137
|
+
energy_window: EnergyWindow = field(default_factory=EnergyWindow)
|
|
138
|
+
safe_moves: list[LiveSafeMove] = field(default_factory=list)
|
|
139
|
+
blocked_moves: list[str] = field(default_factory=list)
|
|
140
|
+
|
|
141
|
+
def to_dict(self) -> dict:
|
|
142
|
+
return {
|
|
143
|
+
"scenes": [s.to_dict() for s in self.scenes],
|
|
144
|
+
"current_scene": self.current_scene,
|
|
145
|
+
"energy_window": self.energy_window.to_dict(),
|
|
146
|
+
"safe_moves": [m.to_dict() for m in self.safe_moves],
|
|
147
|
+
"blocked_moves": list(self.blocked_moves),
|
|
148
|
+
}
|