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,112 @@
1
+ """Memory Fabric V2 MCP tools — anti-memory, promotion, session, and taste endpoints.
2
+
3
+ 6 tools: get_anti_preferences, record_anti_preference, get_promotion_candidates,
4
+ get_session_memory, add_session_memory, get_taste_dimensions.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from fastmcp import Context
10
+
11
+ from ..server import mcp
12
+ from .anti_memory import AntiMemoryStore
13
+ from .promotion import batch_evaluate_promotions
14
+ from .session_memory import SessionMemoryStore
15
+ from .taste_memory import TasteMemoryStore
16
+
17
+
18
+ def _get_anti_memory(ctx: Context) -> AntiMemoryStore:
19
+ """Get or create the session-scoped AntiMemoryStore."""
20
+ return ctx.lifespan_context.setdefault("anti_memory", AntiMemoryStore())
21
+
22
+
23
+ def _get_session_memory(ctx: Context) -> SessionMemoryStore:
24
+ """Get or create the session-scoped SessionMemoryStore."""
25
+ return ctx.lifespan_context.setdefault("session_memory", SessionMemoryStore())
26
+
27
+
28
+ def _get_taste_memory(ctx: Context) -> TasteMemoryStore:
29
+ """Get or create the session-scoped TasteMemoryStore."""
30
+ return ctx.lifespan_context.setdefault("taste_memory", TasteMemoryStore())
31
+
32
+
33
+ @mcp.tool()
34
+ def get_anti_preferences(ctx: Context) -> dict:
35
+ """Return all recorded anti-preferences — dimensions the user has repeatedly disliked."""
36
+ store = _get_anti_memory(ctx)
37
+ return store.to_dict()
38
+
39
+
40
+ @mcp.tool()
41
+ def record_anti_preference(
42
+ ctx: Context, dimension: str, direction: str
43
+ ) -> dict:
44
+ """Record a user dislike for a dimension+direction. direction must be 'increase' or 'decrease'."""
45
+ if direction not in ("increase", "decrease"):
46
+ return {"error": "direction must be 'increase' or 'decrease'"}
47
+ store = _get_anti_memory(ctx)
48
+ pref = store.record_dislike(dimension, direction)
49
+ return {
50
+ "recorded": pref.to_dict(),
51
+ "should_caution": store.should_caution(dimension, direction),
52
+ }
53
+
54
+
55
+ @mcp.tool()
56
+ def get_promotion_candidates(ctx: Context, limit: int = 10) -> dict:
57
+ """Check the session ledger for entries eligible for memory promotion."""
58
+ ledger = ctx.lifespan_context.get("action_ledger")
59
+ if ledger is None:
60
+ return {"candidates": [], "count": 0, "note": "no session ledger active"}
61
+
62
+ # Get memory candidates from ledger and evaluate
63
+ raw_candidates = ledger.get_memory_candidates()
64
+ entry_dicts = [e.to_dict() for e in raw_candidates]
65
+ eligible = batch_evaluate_promotions(entry_dicts)
66
+
67
+ # Apply limit
68
+ eligible = eligible[:limit]
69
+ return {
70
+ "candidates": [c.to_dict() for c in eligible],
71
+ "count": len(eligible),
72
+ }
73
+
74
+
75
+ # ── Session Memory ──────────────────────────────────────────────────
76
+
77
+
78
+ @mcp.tool()
79
+ def get_session_memory(
80
+ ctx: Context, limit: int = 10, category: str = ""
81
+ ) -> dict:
82
+ """Return recent session memory entries — ephemeral observations, hypotheses, decisions."""
83
+ store = _get_session_memory(ctx)
84
+ cat = category.strip() or None
85
+ entries = store.get_recent(limit=limit, category=cat)
86
+ return {
87
+ "entries": [e.to_dict() for e in entries],
88
+ "count": len(entries),
89
+ }
90
+
91
+
92
+ @mcp.tool()
93
+ def add_session_memory(
94
+ ctx: Context, category: str, content: str, engine: str = "agent_os"
95
+ ) -> dict:
96
+ """Add an ephemeral session memory entry (observation, hypothesis, decision, issue)."""
97
+ store = _get_session_memory(ctx)
98
+ try:
99
+ entry_id = store.add(category=category, content=content, engine=engine)
100
+ except ValueError as exc:
101
+ return {"error": str(exc)}
102
+ return {"id": entry_id, "status": "added"}
103
+
104
+
105
+ # ── Taste Memory ────────────────────────────────────────────────────
106
+
107
+
108
+ @mcp.tool()
109
+ def get_taste_dimensions(ctx: Context) -> dict:
110
+ """Return all taste dimensions — user preferences inferred from kept/undone outcomes."""
111
+ store = _get_taste_memory(ctx)
112
+ return store.to_dict()
@@ -0,0 +1 @@
1
+ """Mix Engine V1 — dedicated mixing intelligence."""
@@ -0,0 +1,299 @@
1
+ """Mix Engine critics — detect mix issues from state data.
2
+
3
+ Six critics: balance, masking, dynamics, stereo, depth, translation.
4
+ All pure computation, zero I/O.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import asdict, dataclass, field
10
+
11
+ from .models import (
12
+ BalanceState,
13
+ DepthState,
14
+ DynamicsState,
15
+ MaskingMap,
16
+ MixState,
17
+ StereoState,
18
+ )
19
+
20
+
21
+ # ── MixIssue ───────────────────────────────────────────────────────
22
+
23
+
24
+ @dataclass
25
+ class MixIssue:
26
+ """A single detected mix issue."""
27
+
28
+ issue_type: str = ""
29
+ critic: str = ""
30
+ severity: float = 0.0
31
+ confidence: float = 0.0
32
+ affected_tracks: list[int] = field(default_factory=list)
33
+ evidence: str = ""
34
+ recommended_moves: list[str] = field(default_factory=list)
35
+
36
+ def to_dict(self) -> dict:
37
+ return asdict(self)
38
+
39
+
40
+ # ── Balance Critic ──────────────────────────────────────────────────
41
+
42
+
43
+ def run_balance_critic(balance: BalanceState) -> list[MixIssue]:
44
+ """Detect balance problems: anchor too weak, support too loud."""
45
+ issues: list[MixIssue] = []
46
+ active = [t for t in balance.track_states if not t.mute]
47
+
48
+ if not active:
49
+ return issues
50
+
51
+ # Compute average volume of active tracks
52
+ avg_vol = sum(t.volume for t in active) / len(active)
53
+
54
+ # Check if anchor tracks are too quiet
55
+ for t in active:
56
+ if t.track_index in balance.anchor_tracks:
57
+ if t.volume < avg_vol * 0.6:
58
+ issues.append(MixIssue(
59
+ issue_type="anchor_too_weak",
60
+ critic="balance",
61
+ severity=min(1.0, (avg_vol - t.volume) / max(avg_vol, 0.01)),
62
+ confidence=0.7,
63
+ affected_tracks=[t.track_index],
64
+ evidence=(
65
+ f"Anchor track '{t.name}' (role={t.role}) at volume "
66
+ f"{t.volume:.2f}, average is {avg_vol:.2f}"
67
+ ),
68
+ recommended_moves=["gain_staging"],
69
+ ))
70
+
71
+ # Check if non-anchor tracks are too loud
72
+ for t in active:
73
+ if t.track_index not in balance.anchor_tracks:
74
+ if t.volume > avg_vol * 1.5 and t.role not in ("kick", "bass", "vocal", "lead"):
75
+ issues.append(MixIssue(
76
+ issue_type="support_too_loud",
77
+ critic="balance",
78
+ severity=min(1.0, (t.volume - avg_vol) / max(avg_vol, 0.01)),
79
+ confidence=0.6,
80
+ affected_tracks=[t.track_index],
81
+ evidence=(
82
+ f"Support track '{t.name}' (role={t.role}) at volume "
83
+ f"{t.volume:.2f}, average is {avg_vol:.2f}"
84
+ ),
85
+ recommended_moves=["gain_staging"],
86
+ ))
87
+
88
+ return issues
89
+
90
+
91
+ # ── Masking Critic ──────────────────────────────────────────────────
92
+
93
+
94
+ def run_masking_critic(masking: MaskingMap) -> list[MixIssue]:
95
+ """Detect frequency collision issues from masking map."""
96
+ issues: list[MixIssue] = []
97
+
98
+ for entry in masking.entries:
99
+ if entry.severity >= 0.4:
100
+ issues.append(MixIssue(
101
+ issue_type="frequency_collision",
102
+ critic="masking",
103
+ severity=entry.severity,
104
+ confidence=0.6,
105
+ affected_tracks=[entry.track_a, entry.track_b],
106
+ evidence=(
107
+ f"Tracks {entry.track_a} and {entry.track_b} collide "
108
+ f"in {entry.overlap_band} band (severity {entry.severity:.2f})"
109
+ ),
110
+ recommended_moves=["eq_correction"],
111
+ ))
112
+
113
+ return issues
114
+
115
+
116
+ # ── Dynamics Critic ─────────────────────────────────────────────────
117
+
118
+
119
+ def run_dynamics_critic(dynamics: DynamicsState) -> list[MixIssue]:
120
+ """Detect dynamics problems: over-compression, flat dynamics, low headroom."""
121
+ issues: list[MixIssue] = []
122
+
123
+ if dynamics.over_compressed:
124
+ issues.append(MixIssue(
125
+ issue_type="over_compressed",
126
+ critic="dynamics",
127
+ severity=min(1.0, max(0.0, (6.0 - dynamics.crest_factor_db) / 6.0)),
128
+ confidence=0.7,
129
+ affected_tracks=[],
130
+ evidence=(
131
+ f"Crest factor {dynamics.crest_factor_db:.1f} dB — "
132
+ f"dynamics are flat, likely over-compressed"
133
+ ),
134
+ recommended_moves=["bus_compression", "transient_shaping"],
135
+ ))
136
+
137
+ if dynamics.crest_factor_db < 3.0 and dynamics.crest_factor_db > 0:
138
+ issues.append(MixIssue(
139
+ issue_type="flat_dynamics",
140
+ critic="dynamics",
141
+ severity=0.8,
142
+ confidence=0.8,
143
+ affected_tracks=[],
144
+ evidence=(
145
+ f"Crest factor {dynamics.crest_factor_db:.1f} dB — "
146
+ f"extremely flat, transients are lost"
147
+ ),
148
+ recommended_moves=["transient_shaping", "gain_staging"],
149
+ ))
150
+
151
+ if dynamics.headroom < 1.0:
152
+ issues.append(MixIssue(
153
+ issue_type="low_headroom",
154
+ critic="dynamics",
155
+ severity=min(1.0, (1.0 - dynamics.headroom)),
156
+ confidence=0.9,
157
+ affected_tracks=[],
158
+ evidence=f"Only {dynamics.headroom:.1f} dB headroom — clipping risk",
159
+ recommended_moves=["gain_staging"],
160
+ ))
161
+
162
+ return issues
163
+
164
+
165
+ # ── Stereo Critic ───────────────────────────────────────────────────
166
+
167
+
168
+ def run_stereo_critic(stereo: StereoState) -> list[MixIssue]:
169
+ """Detect stereo problems: center collapse, overwide."""
170
+ issues: list[MixIssue] = []
171
+
172
+ if stereo.mono_risk:
173
+ issues.append(MixIssue(
174
+ issue_type="center_collapse",
175
+ critic="stereo",
176
+ severity=0.6,
177
+ confidence=0.7,
178
+ affected_tracks=[],
179
+ evidence=(
180
+ f"Center strength {stereo.center_strength:.2f}, "
181
+ f"side activity {stereo.side_activity:.2f} — "
182
+ f"mix is essentially mono"
183
+ ),
184
+ recommended_moves=["width_adjustment"],
185
+ ))
186
+
187
+ if stereo.side_activity > 0.7:
188
+ issues.append(MixIssue(
189
+ issue_type="overwide",
190
+ critic="stereo",
191
+ severity=min(1.0, stereo.side_activity - 0.5),
192
+ confidence=0.5,
193
+ affected_tracks=[],
194
+ evidence=(
195
+ f"Side activity {stereo.side_activity:.2f} — "
196
+ f"mix may be too wide, center elements could be weak"
197
+ ),
198
+ recommended_moves=["width_adjustment"],
199
+ ))
200
+
201
+ return issues
202
+
203
+
204
+ # ── Depth Critic ────────────────────────────────────────────────────
205
+
206
+
207
+ def run_depth_critic(depth: DepthState) -> list[MixIssue]:
208
+ """Detect depth problems: no separation, excessive wash."""
209
+ issues: list[MixIssue] = []
210
+
211
+ if depth.depth_separation < 0.05 and depth.wet_dry_ratio > 0.0:
212
+ issues.append(MixIssue(
213
+ issue_type="no_depth_separation",
214
+ critic="depth",
215
+ severity=0.5,
216
+ confidence=0.5,
217
+ affected_tracks=[],
218
+ evidence=(
219
+ f"Depth separation {depth.depth_separation:.3f} — "
220
+ f"all tracks at similar depth, no front/back contrast"
221
+ ),
222
+ recommended_moves=["send_rebalance"],
223
+ ))
224
+
225
+ if depth.wash_risk:
226
+ issues.append(MixIssue(
227
+ issue_type="excessive_wash",
228
+ critic="depth",
229
+ severity=min(1.0, depth.wet_dry_ratio),
230
+ confidence=0.6,
231
+ affected_tracks=[],
232
+ evidence=(
233
+ f"Wet/dry ratio {depth.wet_dry_ratio:.2f} — "
234
+ f"excessive reverb/delay washing out the mix"
235
+ ),
236
+ recommended_moves=["send_rebalance"],
237
+ ))
238
+
239
+ return issues
240
+
241
+
242
+ # ── Translation Critic ──────────────────────────────────────────────
243
+
244
+
245
+ def run_translation_critic(
246
+ dynamics: DynamicsState,
247
+ stereo: StereoState,
248
+ ) -> list[MixIssue]:
249
+ """Detect translation risks: mono weakness, harshness risk."""
250
+ issues: list[MixIssue] = []
251
+
252
+ # Mono weakness: wide mix with weak center
253
+ if stereo.side_activity > 0.5 and stereo.center_strength < 0.3:
254
+ issues.append(MixIssue(
255
+ issue_type="mono_weakness",
256
+ critic="translation",
257
+ severity=0.7,
258
+ confidence=0.6,
259
+ affected_tracks=[],
260
+ evidence=(
261
+ f"Side activity {stereo.side_activity:.2f} with center "
262
+ f"strength {stereo.center_strength:.2f} — mono playback "
263
+ f"will lose significant content"
264
+ ),
265
+ recommended_moves=["width_adjustment", "gain_staging"],
266
+ ))
267
+
268
+ # Harshness risk: over-compressed + low headroom
269
+ if dynamics.over_compressed and dynamics.headroom < 3.0:
270
+ issues.append(MixIssue(
271
+ issue_type="harshness_risk",
272
+ critic="translation",
273
+ severity=0.6,
274
+ confidence=0.5,
275
+ affected_tracks=[],
276
+ evidence=(
277
+ f"Over-compressed (crest {dynamics.crest_factor_db:.1f} dB) "
278
+ f"with only {dynamics.headroom:.1f} dB headroom — "
279
+ f"will sound harsh on smaller speakers"
280
+ ),
281
+ recommended_moves=["gain_staging", "bus_compression"],
282
+ ))
283
+
284
+ return issues
285
+
286
+
287
+ # ── Run all critics ─────────────────────────────────────────────────
288
+
289
+
290
+ def run_all_mix_critics(mix_state: MixState) -> list[MixIssue]:
291
+ """Run all six critics and aggregate issues."""
292
+ issues: list[MixIssue] = []
293
+ issues.extend(run_balance_critic(mix_state.balance))
294
+ issues.extend(run_masking_critic(mix_state.masking))
295
+ issues.extend(run_dynamics_critic(mix_state.dynamics))
296
+ issues.extend(run_stereo_critic(mix_state.stereo))
297
+ issues.extend(run_depth_critic(mix_state.depth))
298
+ issues.extend(run_translation_critic(mix_state.dynamics, mix_state.stereo))
299
+ return issues
@@ -0,0 +1,152 @@
1
+ """Mix Engine state models — all dataclasses with to_dict().
2
+
3
+ Pure data structures representing the five mix subgraphs
4
+ (BalanceState, MaskingMap, DynamicsState, StereoState, DepthState)
5
+ plus the composite MixState container.
6
+
7
+ Zero I/O.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import asdict, dataclass, field
13
+ from typing import Optional
14
+
15
+
16
+ # ── Track-level state ───────────────────────────────────────────────
17
+
18
+
19
+ @dataclass
20
+ class TrackMixState:
21
+ """Mix-relevant state for a single track."""
22
+
23
+ track_index: int = 0
24
+ name: str = ""
25
+ role: str = "unknown"
26
+ volume: float = 0.0
27
+ pan: float = 0.0
28
+ mute: bool = False
29
+ solo: bool = False
30
+ send_levels: list[float] = field(default_factory=list)
31
+
32
+ def to_dict(self) -> dict:
33
+ return asdict(self)
34
+
35
+
36
+ # ── Balance ─────────────────────────────────────────────────────────
37
+
38
+
39
+ @dataclass
40
+ class BalanceState:
41
+ """Track-level and role-weighted loudness balance."""
42
+
43
+ track_states: list[TrackMixState] = field(default_factory=list)
44
+ anchor_tracks: list[int] = field(default_factory=list)
45
+ loudest_track: int = -1
46
+ quietest_track: int = -1
47
+
48
+ def to_dict(self) -> dict:
49
+ return {
50
+ "track_states": [t.to_dict() for t in self.track_states],
51
+ "anchor_tracks": list(self.anchor_tracks),
52
+ "loudest_track": self.loudest_track,
53
+ "quietest_track": self.quietest_track,
54
+ }
55
+
56
+
57
+ # ── Masking ─────────────────────────────────────────────────────────
58
+
59
+
60
+ @dataclass
61
+ class MaskingEntry:
62
+ """A single frequency masking collision between two tracks."""
63
+
64
+ track_a: int = 0
65
+ track_b: int = 0
66
+ overlap_band: str = ""
67
+ severity: float = 0.0
68
+
69
+ def to_dict(self) -> dict:
70
+ return asdict(self)
71
+
72
+
73
+ @dataclass
74
+ class MaskingMap:
75
+ """All detected frequency masking collisions."""
76
+
77
+ entries: list[MaskingEntry] = field(default_factory=list)
78
+ worst_pair: Optional[tuple[int, int]] = None
79
+
80
+ def to_dict(self) -> dict:
81
+ return {
82
+ "entries": [e.to_dict() for e in self.entries],
83
+ "worst_pair": list(self.worst_pair) if self.worst_pair else None,
84
+ }
85
+
86
+
87
+ # ── Dynamics ────────────────────────────────────────────────────────
88
+
89
+
90
+ @dataclass
91
+ class DynamicsState:
92
+ """Master dynamics condition."""
93
+
94
+ crest_factor_db: float = 0.0
95
+ over_compressed: bool = False
96
+ headroom: float = 0.0
97
+
98
+ def to_dict(self) -> dict:
99
+ return asdict(self)
100
+
101
+
102
+ # ── Stereo ──────────────────────────────────────────────────────────
103
+
104
+
105
+ @dataclass
106
+ class StereoState:
107
+ """Stereo field condition."""
108
+
109
+ center_strength: float = 0.0
110
+ side_activity: float = 0.0
111
+ mono_risk: bool = False
112
+
113
+ def to_dict(self) -> dict:
114
+ return asdict(self)
115
+
116
+
117
+ # ── Depth ───────────────────────────────────────────────────────────
118
+
119
+
120
+ @dataclass
121
+ class DepthState:
122
+ """Front-to-back depth separation."""
123
+
124
+ wet_dry_ratio: float = 0.0
125
+ depth_separation: float = 0.0
126
+ wash_risk: bool = False
127
+
128
+ def to_dict(self) -> dict:
129
+ return asdict(self)
130
+
131
+
132
+ # ── Composite container ────────────────────────────────────────────
133
+
134
+
135
+ @dataclass
136
+ class MixState:
137
+ """Top-level container for all mix sub-states."""
138
+
139
+ balance: BalanceState = field(default_factory=BalanceState)
140
+ masking: MaskingMap = field(default_factory=MaskingMap)
141
+ dynamics: DynamicsState = field(default_factory=DynamicsState)
142
+ stereo: StereoState = field(default_factory=StereoState)
143
+ depth: DepthState = field(default_factory=DepthState)
144
+
145
+ def to_dict(self) -> dict:
146
+ return {
147
+ "balance": self.balance.to_dict(),
148
+ "masking": self.masking.to_dict(),
149
+ "dynamics": self.dynamics.to_dict(),
150
+ "stereo": self.stereo.to_dict(),
151
+ "depth": self.depth.to_dict(),
152
+ }
@@ -0,0 +1,103 @@
1
+ """Mix Engine planner — rank and suggest mix moves.
2
+
3
+ Pure computation, zero I/O. Takes critic issues and mix state,
4
+ returns a ranked list of reversible moves.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import asdict, dataclass, field
10
+
11
+ from .critics import MixIssue
12
+ from .models import MixState
13
+
14
+
15
+ # ── MixMove ─────────────────────────────────────────────────────────
16
+
17
+
18
+ @dataclass
19
+ class MixMove:
20
+ """A single suggested mix move."""
21
+
22
+ move_type: str = ""
23
+ target_tracks: list[int] = field(default_factory=list)
24
+ description: str = ""
25
+ estimated_impact: float = 0.0
26
+ risk: float = 0.0
27
+ parameters: dict = field(default_factory=dict)
28
+
29
+ def to_dict(self) -> dict:
30
+ return asdict(self)
31
+
32
+
33
+ # Move type metadata: (scope, base_risk)
34
+ # scope: "track" < "bus" < "master" — prefer smallest scope
35
+ _MOVE_META: dict[str, tuple[str, float]] = {
36
+ "eq_correction": ("track", 0.1),
37
+ "transient_shaping": ("track", 0.15),
38
+ "saturation_adjustment": ("track", 0.2),
39
+ "width_adjustment": ("track", 0.15),
40
+ "send_rebalance": ("track", 0.1),
41
+ "gain_staging": ("track", 0.05),
42
+ "bus_compression": ("bus", 0.25),
43
+ }
44
+
45
+ _SCOPE_PENALTY: dict[str, float] = {
46
+ "track": 0.0,
47
+ "bus": 0.1,
48
+ "master": 0.2,
49
+ }
50
+
51
+
52
+ # ── Planner ─────────────────────────────────────────────────────────
53
+
54
+
55
+ def plan_mix_moves(
56
+ issues: list[MixIssue],
57
+ mix_state: MixState,
58
+ ) -> list[MixMove]:
59
+ """Generate a ranked list of suggested moves from detected issues.
60
+
61
+ Ranking: estimated_impact * (1 - risk), highest first.
62
+ Prefers track-level moves over bus-level moves.
63
+
64
+ Returns an empty list if no issues are present.
65
+ """
66
+ if not issues:
67
+ return []
68
+
69
+ moves: list[MixMove] = []
70
+
71
+ for issue in issues:
72
+ for move_type in issue.recommended_moves:
73
+ scope, base_risk = _MOVE_META.get(move_type, ("track", 0.2))
74
+ risk = min(1.0, base_risk + _SCOPE_PENALTY.get(scope, 0.0))
75
+ impact = issue.severity * issue.confidence
76
+
77
+ move = MixMove(
78
+ move_type=move_type,
79
+ target_tracks=list(issue.affected_tracks),
80
+ description=(
81
+ f"{move_type} to address {issue.issue_type} "
82
+ f"({issue.critic} critic)"
83
+ ),
84
+ estimated_impact=round(impact, 3),
85
+ risk=round(risk, 3),
86
+ parameters={
87
+ "source_issue": issue.issue_type,
88
+ "source_critic": issue.critic,
89
+ "severity": issue.severity,
90
+ },
91
+ )
92
+ moves.append(move)
93
+
94
+ # Rank: impact * (1 - risk), highest first; tie-break by preferring
95
+ # track-level (lower scope penalty).
96
+ def _rank_key(m: MixMove) -> tuple[float, float]:
97
+ score = m.estimated_impact * (1.0 - m.risk)
98
+ scope, _ = _MOVE_META.get(m.move_type, ("track", 0.2))
99
+ scope_order = _SCOPE_PENALTY.get(scope, 0.0)
100
+ return (-score, scope_order)
101
+
102
+ moves.sort(key=_rank_key)
103
+ return moves