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,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
|