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.
Files changed (105) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/AGENTS.md +3 -3
  3. package/CHANGELOG.md +51 -0
  4. package/CONTRIBUTING.md +1 -1
  5. package/README.md +7 -7
  6. package/bin/livepilot.js +32 -8
  7. package/installer/install.js +21 -2
  8. package/livepilot/.Codex-plugin/plugin.json +2 -2
  9. package/livepilot/.claude-plugin/plugin.json +2 -2
  10. package/livepilot/agents/livepilot-producer/AGENT.md +243 -49
  11. package/livepilot/skills/livepilot-core/SKILL.md +81 -6
  12. package/livepilot/skills/livepilot-core/references/m4l-devices.md +2 -2
  13. package/livepilot/skills/livepilot-core/references/overview.md +3 -3
  14. package/livepilot/skills/livepilot-core/references/sound-design.md +3 -2
  15. package/livepilot/skills/livepilot-release/SKILL.md +13 -13
  16. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  17. package/m4l_device/livepilot_bridge.js +6 -3
  18. package/mcp_server/__init__.py +1 -1
  19. package/mcp_server/curves.py +11 -3
  20. package/mcp_server/evaluation/__init__.py +1 -0
  21. package/mcp_server/evaluation/fabric.py +575 -0
  22. package/mcp_server/evaluation/feature_extractors.py +84 -0
  23. package/mcp_server/evaluation/policy.py +67 -0
  24. package/mcp_server/evaluation/tools.py +53 -0
  25. package/mcp_server/memory/__init__.py +11 -2
  26. package/mcp_server/memory/anti_memory.py +78 -0
  27. package/mcp_server/memory/promotion.py +94 -0
  28. package/mcp_server/memory/session_memory.py +108 -0
  29. package/mcp_server/memory/taste_memory.py +158 -0
  30. package/mcp_server/memory/technique_store.py +2 -1
  31. package/mcp_server/memory/tools.py +112 -0
  32. package/mcp_server/mix_engine/__init__.py +1 -0
  33. package/mcp_server/mix_engine/critics.py +299 -0
  34. package/mcp_server/mix_engine/models.py +152 -0
  35. package/mcp_server/mix_engine/planner.py +103 -0
  36. package/mcp_server/mix_engine/state_builder.py +316 -0
  37. package/mcp_server/mix_engine/tools.py +214 -0
  38. package/mcp_server/performance_engine/__init__.py +1 -0
  39. package/mcp_server/performance_engine/models.py +148 -0
  40. package/mcp_server/performance_engine/planner.py +267 -0
  41. package/mcp_server/performance_engine/safety.py +162 -0
  42. package/mcp_server/performance_engine/tools.py +183 -0
  43. package/mcp_server/project_brain/__init__.py +6 -0
  44. package/mcp_server/project_brain/arrangement_graph.py +64 -0
  45. package/mcp_server/project_brain/automation_graph.py +72 -0
  46. package/mcp_server/project_brain/builder.py +123 -0
  47. package/mcp_server/project_brain/capability_graph.py +64 -0
  48. package/mcp_server/project_brain/models.py +282 -0
  49. package/mcp_server/project_brain/refresh.py +80 -0
  50. package/mcp_server/project_brain/role_graph.py +103 -0
  51. package/mcp_server/project_brain/session_graph.py +51 -0
  52. package/mcp_server/project_brain/tools.py +144 -0
  53. package/mcp_server/reference_engine/__init__.py +1 -0
  54. package/mcp_server/reference_engine/gap_analyzer.py +239 -0
  55. package/mcp_server/reference_engine/models.py +105 -0
  56. package/mcp_server/reference_engine/profile_builder.py +149 -0
  57. package/mcp_server/reference_engine/tactic_router.py +117 -0
  58. package/mcp_server/reference_engine/tools.py +235 -0
  59. package/mcp_server/runtime/__init__.py +1 -0
  60. package/mcp_server/runtime/action_ledger.py +117 -0
  61. package/mcp_server/runtime/action_ledger_models.py +84 -0
  62. package/mcp_server/runtime/action_tools.py +57 -0
  63. package/mcp_server/runtime/capability_state.py +218 -0
  64. package/mcp_server/runtime/safety_kernel.py +339 -0
  65. package/mcp_server/runtime/safety_tools.py +42 -0
  66. package/mcp_server/runtime/tools.py +64 -0
  67. package/mcp_server/server.py +23 -1
  68. package/mcp_server/sound_design/__init__.py +1 -0
  69. package/mcp_server/sound_design/critics.py +297 -0
  70. package/mcp_server/sound_design/models.py +147 -0
  71. package/mcp_server/sound_design/planner.py +104 -0
  72. package/mcp_server/sound_design/tools.py +297 -0
  73. package/mcp_server/tools/_agent_os_engine.py +947 -0
  74. package/mcp_server/tools/_composition_engine.py +1530 -0
  75. package/mcp_server/tools/_conductor.py +199 -0
  76. package/mcp_server/tools/_conductor_budgets.py +222 -0
  77. package/mcp_server/tools/_evaluation_contracts.py +91 -0
  78. package/mcp_server/tools/_form_engine.py +416 -0
  79. package/mcp_server/tools/_motif_engine.py +351 -0
  80. package/mcp_server/tools/_planner_engine.py +516 -0
  81. package/mcp_server/tools/_research_engine.py +542 -0
  82. package/mcp_server/tools/_research_provider.py +185 -0
  83. package/mcp_server/tools/_snapshot_normalizer.py +49 -0
  84. package/mcp_server/tools/agent_os.py +440 -0
  85. package/mcp_server/tools/analyzer.py +18 -0
  86. package/mcp_server/tools/automation.py +25 -10
  87. package/mcp_server/tools/composition.py +563 -0
  88. package/mcp_server/tools/motif.py +104 -0
  89. package/mcp_server/tools/planner.py +144 -0
  90. package/mcp_server/tools/research.py +223 -0
  91. package/mcp_server/tools/tracks.py +18 -3
  92. package/mcp_server/tools/transport.py +10 -2
  93. package/mcp_server/transition_engine/__init__.py +6 -0
  94. package/mcp_server/transition_engine/archetypes.py +167 -0
  95. package/mcp_server/transition_engine/critics.py +340 -0
  96. package/mcp_server/transition_engine/models.py +90 -0
  97. package/mcp_server/transition_engine/tools.py +291 -0
  98. package/mcp_server/translation_engine/__init__.py +5 -0
  99. package/mcp_server/translation_engine/critics.py +297 -0
  100. package/mcp_server/translation_engine/models.py +27 -0
  101. package/mcp_server/translation_engine/tools.py +74 -0
  102. package/package.json +2 -2
  103. package/remote_script/LivePilot/__init__.py +1 -1
  104. package/remote_script/LivePilot/arrangement.py +12 -2
  105. 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
+ }