livepilot 1.27.2 → 1.27.3
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/CHANGELOG.md +66 -0
- package/README.md +10 -7
- package/bin/livepilot.js +97 -39
- package/livepilot/.Codex-plugin/plugin.json +1 -1
- package/livepilot/.claude-plugin/plugin.json +1 -1
- package/livepilot/skills/livepilot-core/SKILL.md +33 -1
- package/livepilot/skills/livepilot-core/references/atlas-tool-notes.md +69 -0
- package/livepilot/skills/livepilot-core/references/overview.md +18 -11
- package/livepilot/skills/livepilot-core/references/perception.md +125 -0
- package/livepilot/skills/livepilot-devices/references/device-parameter-units.md +66 -0
- package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
- package/livepilot/skills/livepilot-mix-engine/SKILL.md +8 -0
- package/livepilot/skills/livepilot-release/SKILL.md +1 -1
- package/livepilot/skills/livepilot-sample-engine/references/splice-tools-notes.md +56 -0
- package/livepilot/skills/livepilot-wonder/SKILL.md +6 -0
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/livepilot_bridge.js +1 -1
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/__init__.py +181 -40
- package/mcp_server/atlas/overlays.py +46 -3
- package/mcp_server/atlas/tools.py +226 -86
- package/mcp_server/audit/checks.py +50 -22
- package/mcp_server/audit/state.py +10 -2
- package/mcp_server/audit/tools.py +27 -6
- package/mcp_server/composer/develop/apply.py +7 -7
- package/mcp_server/composer/fast/apply.py +29 -23
- package/mcp_server/composer/framework/atlas_resolver.py +16 -1
- package/mcp_server/composer/full/apply.py +40 -34
- package/mcp_server/composer/full/engine.py +66 -32
- package/mcp_server/composer/tools.py +4 -3
- package/mcp_server/connection.py +67 -9
- package/mcp_server/creative_constraints/engine.py +10 -2
- package/mcp_server/creative_constraints/tools.py +24 -7
- package/mcp_server/curves.py +30 -7
- package/mcp_server/device_forge/builder.py +40 -11
- package/mcp_server/device_forge/models.py +9 -0
- package/mcp_server/device_forge/tools.py +30 -11
- package/mcp_server/experiment/engine.py +29 -22
- package/mcp_server/experiment/tools.py +15 -5
- package/mcp_server/m4l_bridge.py +69 -7
- package/mcp_server/memory/taste_graph.py +43 -0
- package/mcp_server/memory/technique_store.py +26 -3
- package/mcp_server/memory/tools.py +62 -4
- package/mcp_server/mix_engine/critics.py +187 -30
- package/mcp_server/mix_engine/models.py +21 -1
- package/mcp_server/mix_engine/state_builder.py +87 -8
- package/mcp_server/mix_engine/tools.py +16 -4
- package/mcp_server/performance_engine/tools.py +56 -8
- package/mcp_server/persistence/base_store.py +11 -0
- package/mcp_server/persistence/project_store.py +132 -8
- package/mcp_server/persistence/taste_store.py +90 -7
- package/mcp_server/preview_studio/engine.py +40 -10
- package/mcp_server/preview_studio/models.py +40 -0
- package/mcp_server/preview_studio/tools.py +56 -12
- package/mcp_server/project_brain/arrangement_graph.py +4 -0
- package/mcp_server/project_brain/models.py +2 -0
- package/mcp_server/project_brain/role_graph.py +13 -7
- package/mcp_server/reference_engine/gap_analyzer.py +58 -3
- package/mcp_server/reference_engine/profile_builder.py +47 -4
- package/mcp_server/reference_engine/tools.py +6 -0
- package/mcp_server/runtime/execution_router.py +51 -1
- package/mcp_server/runtime/tools.py +0 -1
- package/mcp_server/sample_engine/sources.py +0 -2
- package/mcp_server/sample_engine/tools.py +19 -38
- package/mcp_server/semantic_moves/mix_compilers.py +276 -51
- package/mcp_server/semantic_moves/performance_compilers.py +51 -17
- package/mcp_server/semantic_moves/resolvers.py +45 -0
- package/mcp_server/semantic_moves/sound_design_compilers.py +14 -6
- package/mcp_server/semantic_moves/tools.py +80 -2
- package/mcp_server/semantic_moves/transition_compilers.py +26 -9
- package/mcp_server/server.py +26 -24
- package/mcp_server/session_continuity/tracker.py +51 -3
- package/mcp_server/song_brain/builder.py +47 -5
- package/mcp_server/song_brain/tools.py +21 -7
- package/mcp_server/sound_design/critics.py +1 -0
- package/mcp_server/splice_client/client.py +117 -33
- package/mcp_server/splice_client/http_bridge.py +15 -3
- package/mcp_server/splice_client/quota.py +28 -0
- package/mcp_server/stuckness_detector/detector.py +8 -5
- package/mcp_server/synthesis_brain/adapters/drift.py +13 -0
- package/mcp_server/synthesis_brain/adapters/meld.py +13 -0
- package/mcp_server/tools/_analyzer_engine/sample.py +36 -10
- package/mcp_server/tools/_perception_engine.py +6 -0
- package/mcp_server/tools/agent_os.py +4 -1
- package/mcp_server/tools/analyzer.py +198 -209
- package/mcp_server/tools/arrangement.py +7 -4
- package/mcp_server/tools/automation.py +24 -4
- package/mcp_server/tools/browser.py +25 -11
- package/mcp_server/tools/clips.py +6 -0
- package/mcp_server/tools/composition.py +33 -2
- package/mcp_server/tools/devices.py +53 -53
- package/mcp_server/tools/generative.py +14 -14
- package/mcp_server/tools/harmony.py +7 -7
- package/mcp_server/tools/mixing.py +4 -4
- package/mcp_server/tools/planner.py +68 -6
- package/mcp_server/tools/research.py +20 -2
- package/mcp_server/tools/theory.py +10 -10
- package/mcp_server/tools/transport.py +7 -2
- package/mcp_server/transition_engine/critics.py +13 -1
- package/mcp_server/user_corpus/tools.py +30 -1
- package/mcp_server/wonder_mode/engine.py +82 -9
- package/mcp_server/wonder_mode/session.py +32 -10
- package/mcp_server/wonder_mode/tools.py +14 -1
- package/package.json +1 -1
- package/remote_script/LivePilot/__init__.py +1 -1
- package/remote_script/LivePilot/arrangement.py +93 -33
- package/remote_script/LivePilot/browser.py +60 -4
- package/remote_script/LivePilot/devices.py +132 -62
- package/remote_script/LivePilot/mixing.py +31 -5
- package/remote_script/LivePilot/server.py +94 -22
- package/remote_script/LivePilot/transport.py +11 -0
- package/requirements.txt +5 -5
- package/server.json +2 -2
|
@@ -2,11 +2,36 @@
|
|
|
2
2
|
|
|
3
3
|
Six critics: balance, masking, dynamics, stereo, depth, translation.
|
|
4
4
|
All pure computation, zero I/O.
|
|
5
|
+
|
|
6
|
+
Confidence values
|
|
7
|
+
------------------
|
|
8
|
+
Every issue's ``confidence`` is a BASE value (the original hand-tuned
|
|
9
|
+
literal, preserved as the per-issue-type prior) MODULATED by a data-quality
|
|
10
|
+
signal actually present in the state being examined — never a decorative
|
|
11
|
+
flat constant. Three modulation shapes are used, each documented at its
|
|
12
|
+
helper:
|
|
13
|
+
|
|
14
|
+
- ``_sample_size_confidence`` — more contributing tracks behind an
|
|
15
|
+
aggregate (e.g. the average volume `anchor_too_weak` compares against)
|
|
16
|
+
means the aggregate is more trustworthy.
|
|
17
|
+
- ``_measurement_confidence`` — real per-track spectral overlap
|
|
18
|
+
(``severity_basis == "spectral_overlap"``) earns a premium over a
|
|
19
|
+
role-pair heuristic prior (no per-track spectrum available).
|
|
20
|
+
- ``_margin_confidence`` / ``_band_interior_confidence`` — how far a
|
|
21
|
+
measurement sits from the classification boundary. Values right at an
|
|
22
|
+
edge are one measurement-noise-width from flipping to the neighboring
|
|
23
|
+
bucket, so they're discounted; values solidly inside the flagged
|
|
24
|
+
region earn the full base confidence.
|
|
25
|
+
|
|
26
|
+
All modulated values are bounded to [0.3, 0.95] — even a single strong
|
|
27
|
+
signal shouldn't claim near-certainty, and even a borderline call keeps a
|
|
28
|
+
floor of honesty (it's still a real detection, just a weak one).
|
|
5
29
|
"""
|
|
6
30
|
|
|
7
31
|
from __future__ import annotations
|
|
8
32
|
|
|
9
33
|
from dataclasses import asdict, dataclass, field
|
|
34
|
+
from typing import Optional
|
|
10
35
|
|
|
11
36
|
from .models import (
|
|
12
37
|
BalanceState,
|
|
@@ -18,6 +43,65 @@ from .models import (
|
|
|
18
43
|
)
|
|
19
44
|
|
|
20
45
|
|
|
46
|
+
_CONF_FLOOR = 0.3
|
|
47
|
+
_CONF_CEIL = 0.95
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _sample_size_confidence(n: int, base: float, full_n: int) -> float:
|
|
51
|
+
"""Scale `base` by how many tracks contributed to the aggregate it rests on.
|
|
52
|
+
|
|
53
|
+
n=1 -> 70% of base (a lone track's "average" isn't a meaningful
|
|
54
|
+
baseline to compare against). n>=full_n -> 100% of base (a well-
|
|
55
|
+
supported average). Linear ramp in between.
|
|
56
|
+
"""
|
|
57
|
+
frac = min(1.0, max(0, n - 1) / max(1, full_n - 1))
|
|
58
|
+
scaled = base * (0.7 + 0.3 * frac)
|
|
59
|
+
return round(max(_CONF_FLOOR, min(_CONF_CEIL, scaled)), 3)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _measurement_confidence(measured: bool, base: float) -> float:
|
|
63
|
+
"""Scale `base` by whether the severity came from a real measurement.
|
|
64
|
+
|
|
65
|
+
measured=True -> per-track spectral overlap was available (this
|
|
66
|
+
session's actual tracks were compared) -> premium.
|
|
67
|
+
measured=False -> role-pair heuristic prior only (no per-track
|
|
68
|
+
spectrum) -> discounted, it's a population prior,
|
|
69
|
+
not a measurement of THIS mix.
|
|
70
|
+
"""
|
|
71
|
+
modifier = 1.2 if measured else 0.8
|
|
72
|
+
return round(max(_CONF_FLOOR, min(_CONF_CEIL, base * modifier)), 3)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _margin_confidence(margin: float, full_margin: float, base: float) -> float:
|
|
76
|
+
"""Scale `base` by how far past a decision boundary a measurement sits.
|
|
77
|
+
|
|
78
|
+
margin<=0 (right at the boundary) -> 70% of base — the critic could
|
|
79
|
+
flip on measurement noise alone, so confidence is discounted.
|
|
80
|
+
margin>=full_margin -> 100% of base — solidly inside the flagged
|
|
81
|
+
region.
|
|
82
|
+
"""
|
|
83
|
+
frac = min(1.0, max(0.0, margin) / full_margin) if full_margin > 0 else 1.0
|
|
84
|
+
scaled = base * (0.7 + 0.3 * frac)
|
|
85
|
+
return round(max(_CONF_FLOOR, min(_CONF_CEIL, scaled)), 3)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _band_interior_confidence(value: float, low: float, high: float, base: float) -> float:
|
|
89
|
+
"""Scale `base` by how centrally `value` sits within a two-sided band.
|
|
90
|
+
|
|
91
|
+
Values near either edge of [low, high] are one measurement-noise-width
|
|
92
|
+
from being classified into the neighboring bucket, so they're
|
|
93
|
+
discounted; values near the band's center get the full base value.
|
|
94
|
+
"""
|
|
95
|
+
half_width = (high - low) / 2.0
|
|
96
|
+
if half_width <= 0:
|
|
97
|
+
return round(max(_CONF_FLOOR, min(_CONF_CEIL, base)), 3)
|
|
98
|
+
center = (high + low) / 2.0
|
|
99
|
+
distance_from_center = abs(value - center)
|
|
100
|
+
frac = 1.0 - min(1.0, distance_from_center / half_width)
|
|
101
|
+
scaled = base * (0.7 + 0.3 * frac)
|
|
102
|
+
return round(max(_CONF_FLOOR, min(_CONF_CEIL, scaled)), 3)
|
|
103
|
+
|
|
104
|
+
|
|
21
105
|
# ── MixIssue ───────────────────────────────────────────────────────
|
|
22
106
|
|
|
23
107
|
|
|
@@ -51,6 +135,14 @@ def run_balance_critic(balance: BalanceState) -> list[MixIssue]:
|
|
|
51
135
|
# Compute average volume of active tracks
|
|
52
136
|
avg_vol = sum(t.volume for t in active) / len(active)
|
|
53
137
|
|
|
138
|
+
# Confidence scales with how many tracks fed the average being compared
|
|
139
|
+
# against — an average of 2 tracks is a much weaker baseline than an
|
|
140
|
+
# average of 8. full_n=6 is where the average is considered well-
|
|
141
|
+
# supported; below that it's discounted toward 70% of the base value.
|
|
142
|
+
n_active = len(active)
|
|
143
|
+
anchor_confidence = _sample_size_confidence(n_active, base=0.7, full_n=6)
|
|
144
|
+
support_confidence = _sample_size_confidence(n_active, base=0.6, full_n=6)
|
|
145
|
+
|
|
54
146
|
# Check if anchor tracks are too quiet
|
|
55
147
|
for t in active:
|
|
56
148
|
if t.track_index in balance.anchor_tracks:
|
|
@@ -59,7 +151,7 @@ def run_balance_critic(balance: BalanceState) -> list[MixIssue]:
|
|
|
59
151
|
issue_type="anchor_too_weak",
|
|
60
152
|
critic="balance",
|
|
61
153
|
severity=min(1.0, (avg_vol - t.volume) / max(avg_vol, 0.01)),
|
|
62
|
-
confidence=
|
|
154
|
+
confidence=anchor_confidence,
|
|
63
155
|
affected_tracks=[t.track_index],
|
|
64
156
|
evidence=(
|
|
65
157
|
f"Anchor track '{t.name}' (role={t.role}) at volume "
|
|
@@ -76,7 +168,7 @@ def run_balance_critic(balance: BalanceState) -> list[MixIssue]:
|
|
|
76
168
|
issue_type="support_too_loud",
|
|
77
169
|
critic="balance",
|
|
78
170
|
severity=min(1.0, (t.volume - avg_vol) / max(avg_vol, 0.01)),
|
|
79
|
-
confidence=
|
|
171
|
+
confidence=support_confidence,
|
|
80
172
|
affected_tracks=[t.track_index],
|
|
81
173
|
evidence=(
|
|
82
174
|
f"Support track '{t.name}' (role={t.role}) at volume "
|
|
@@ -101,7 +193,7 @@ def run_masking_critic(masking: MaskingMap) -> list[MixIssue]:
|
|
|
101
193
|
issue_type="frequency_collision",
|
|
102
194
|
critic="masking",
|
|
103
195
|
severity=entry.severity,
|
|
104
|
-
confidence=0.6,
|
|
196
|
+
confidence=_measurement_confidence(entry.measured, base=0.6),
|
|
105
197
|
affected_tracks=[entry.track_a, entry.track_b],
|
|
106
198
|
evidence=(
|
|
107
199
|
f"Tracks {entry.track_a} and {entry.track_b} collide "
|
|
@@ -116,30 +208,64 @@ def run_masking_critic(masking: MaskingMap) -> list[MixIssue]:
|
|
|
116
208
|
# ── Dynamics Critic ─────────────────────────────────────────────────
|
|
117
209
|
|
|
118
210
|
|
|
119
|
-
def run_dynamics_critic(
|
|
120
|
-
|
|
211
|
+
def run_dynamics_critic(
|
|
212
|
+
dynamics: DynamicsState,
|
|
213
|
+
context: Optional[dict] = None,
|
|
214
|
+
) -> list[MixIssue]:
|
|
215
|
+
"""Detect dynamics problems: over-compression, flat dynamics, low headroom.
|
|
216
|
+
|
|
217
|
+
context: optional hint about the session's intended dynamics target.
|
|
218
|
+
{"target_style": "loud_master"} tells the critic this mix is
|
|
219
|
+
deliberately targeting a loud, heavily-limited master — a
|
|
220
|
+
legitimate style choice (not a defect) — so the `over_compressed`
|
|
221
|
+
check is suppressed rather than false-positiving on intentional
|
|
222
|
+
design. Omitting `context` (the default) preserves the original
|
|
223
|
+
behavior byte-for-byte EXCEPT the evidence string, which now
|
|
224
|
+
states the "assumes a dynamic mix target" assumption explicitly
|
|
225
|
+
so a reader doesn't over-trust the flag for a style that
|
|
226
|
+
intentionally lives in the 3-6dB crest band.
|
|
227
|
+
"""
|
|
228
|
+
context = context or {}
|
|
229
|
+
target_style = context.get("target_style", "dynamic")
|
|
121
230
|
issues: list[MixIssue] = []
|
|
122
231
|
|
|
123
232
|
if dynamics.over_compressed:
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
233
|
+
if target_style == "loud_master":
|
|
234
|
+
# Deliberately loud-master styles target exactly this crest
|
|
235
|
+
# band — that's the intended sound, not flat/over-compressed.
|
|
236
|
+
pass
|
|
237
|
+
else:
|
|
238
|
+
issues.append(MixIssue(
|
|
239
|
+
issue_type="over_compressed",
|
|
240
|
+
critic="dynamics",
|
|
241
|
+
severity=min(1.0, max(0.0, (6.0 - dynamics.crest_factor_db) / 6.0)),
|
|
242
|
+
# Confidence is highest at the center of the 3-6dB band and
|
|
243
|
+
# discounted near either edge, where a small measurement
|
|
244
|
+
# wobble could reclassify the mix as healthy or flat instead.
|
|
245
|
+
confidence=_band_interior_confidence(
|
|
246
|
+
dynamics.crest_factor_db, 3.0, 6.0, base=0.7
|
|
247
|
+
),
|
|
248
|
+
affected_tracks=[],
|
|
249
|
+
evidence=(
|
|
250
|
+
f"Crest factor {dynamics.crest_factor_db:.1f} dB — dynamics "
|
|
251
|
+
f"read as flat/over-compressed. Assumes a dynamic mix target; "
|
|
252
|
+
f"loud-master styles may intentionally sit at 3-6dB crest — "
|
|
253
|
+
f"pass context={{'target_style': 'loud_master'}} if that's the intent."
|
|
254
|
+
),
|
|
255
|
+
recommended_moves=["bus_compression", "transient_shaping"],
|
|
256
|
+
))
|
|
136
257
|
|
|
137
258
|
elif dynamics.crest_factor_db < 3.0 and dynamics.crest_factor_db > 0:
|
|
138
259
|
issues.append(MixIssue(
|
|
139
260
|
issue_type="flat_dynamics",
|
|
140
261
|
critic="dynamics",
|
|
141
262
|
severity=0.8,
|
|
142
|
-
|
|
263
|
+
# Confidence scales with how far below the 3.0dB threshold the
|
|
264
|
+
# crest factor sits — deep in flat territory (near 0dB) is a
|
|
265
|
+
# much more confident call than crest sitting just under 3.0.
|
|
266
|
+
confidence=_margin_confidence(
|
|
267
|
+
3.0 - dynamics.crest_factor_db, full_margin=3.0, base=0.8
|
|
268
|
+
),
|
|
143
269
|
affected_tracks=[],
|
|
144
270
|
evidence=(
|
|
145
271
|
f"Crest factor {dynamics.crest_factor_db:.1f} dB — "
|
|
@@ -153,7 +279,12 @@ def run_dynamics_critic(dynamics: DynamicsState) -> list[MixIssue]:
|
|
|
153
279
|
issue_type="low_headroom",
|
|
154
280
|
critic="dynamics",
|
|
155
281
|
severity=min(1.0, (1.0 - dynamics.headroom)),
|
|
156
|
-
|
|
282
|
+
# Confidence scales with how far below the 1.0dB threshold
|
|
283
|
+
# headroom sits — near-zero headroom is an unambiguous clip
|
|
284
|
+
# risk, while headroom just under 1.0dB is a borderline call.
|
|
285
|
+
confidence=_margin_confidence(
|
|
286
|
+
1.0 - dynamics.headroom, full_margin=1.0, base=0.9
|
|
287
|
+
),
|
|
157
288
|
affected_tracks=[],
|
|
158
289
|
evidence=f"Only {dynamics.headroom:.1f} dB headroom — clipping risk",
|
|
159
290
|
recommended_moves=["gain_staging"],
|
|
@@ -170,11 +301,16 @@ def run_stereo_critic(stereo: StereoState) -> list[MixIssue]:
|
|
|
170
301
|
issues: list[MixIssue] = []
|
|
171
302
|
|
|
172
303
|
if stereo.mono_risk:
|
|
304
|
+
# mono_risk requires center_strength > 0.85 AND side_activity < 0.05;
|
|
305
|
+
# confidence scales with how far past BOTH thresholds the mix sits —
|
|
306
|
+
# a mix that's barely over 0.85/barely under 0.05 is a much weaker
|
|
307
|
+
# "essentially mono" call than one deep in that territory.
|
|
308
|
+
margin = (stereo.center_strength - 0.85) + (0.05 - stereo.side_activity)
|
|
173
309
|
issues.append(MixIssue(
|
|
174
310
|
issue_type="center_collapse",
|
|
175
311
|
critic="stereo",
|
|
176
312
|
severity=0.6,
|
|
177
|
-
confidence=0.7,
|
|
313
|
+
confidence=_margin_confidence(margin, full_margin=0.2, base=0.7),
|
|
178
314
|
affected_tracks=[],
|
|
179
315
|
evidence=(
|
|
180
316
|
f"Center strength {stereo.center_strength:.2f}, "
|
|
@@ -189,7 +325,9 @@ def run_stereo_critic(stereo: StereoState) -> list[MixIssue]:
|
|
|
189
325
|
issue_type="overwide",
|
|
190
326
|
critic="stereo",
|
|
191
327
|
severity=min(1.0, stereo.side_activity - 0.5),
|
|
192
|
-
confidence=
|
|
328
|
+
confidence=_margin_confidence(
|
|
329
|
+
stereo.side_activity - 0.7, full_margin=0.3, base=0.5
|
|
330
|
+
),
|
|
193
331
|
affected_tracks=[],
|
|
194
332
|
evidence=(
|
|
195
333
|
f"Side activity {stereo.side_activity:.2f} — "
|
|
@@ -213,7 +351,9 @@ def run_depth_critic(depth: DepthState) -> list[MixIssue]:
|
|
|
213
351
|
issue_type="no_depth_separation",
|
|
214
352
|
critic="depth",
|
|
215
353
|
severity=0.5,
|
|
216
|
-
confidence=
|
|
354
|
+
confidence=_margin_confidence(
|
|
355
|
+
0.05 - depth.depth_separation, full_margin=0.05, base=0.5
|
|
356
|
+
),
|
|
217
357
|
affected_tracks=[],
|
|
218
358
|
evidence=(
|
|
219
359
|
f"Depth separation {depth.depth_separation:.3f} — "
|
|
@@ -227,7 +367,9 @@ def run_depth_critic(depth: DepthState) -> list[MixIssue]:
|
|
|
227
367
|
issue_type="excessive_wash",
|
|
228
368
|
critic="depth",
|
|
229
369
|
severity=min(1.0, depth.wet_dry_ratio),
|
|
230
|
-
confidence=
|
|
370
|
+
confidence=_margin_confidence(
|
|
371
|
+
depth.wet_dry_ratio - 0.6, full_margin=0.4, base=0.6
|
|
372
|
+
),
|
|
231
373
|
affected_tracks=[],
|
|
232
374
|
evidence=(
|
|
233
375
|
f"Wet/dry ratio {depth.wet_dry_ratio:.2f} — "
|
|
@@ -249,13 +391,17 @@ def run_translation_critic(
|
|
|
249
391
|
"""Detect translation risks: mono weakness, harshness risk."""
|
|
250
392
|
issues: list[MixIssue] = []
|
|
251
393
|
|
|
252
|
-
# Mono weakness: wide mix with weak center
|
|
394
|
+
# Mono weakness: wide mix with weak center. Confidence scales with how
|
|
395
|
+
# far past BOTH thresholds (side_activity > 0.5, center_strength < 0.3)
|
|
396
|
+
# the mix sits — deep in that territory is a much stronger call than
|
|
397
|
+
# barely past either edge.
|
|
253
398
|
if stereo.side_activity > 0.5 and stereo.center_strength < 0.3:
|
|
399
|
+
margin = (stereo.side_activity - 0.5) + (0.3 - stereo.center_strength)
|
|
254
400
|
issues.append(MixIssue(
|
|
255
401
|
issue_type="mono_weakness",
|
|
256
402
|
critic="translation",
|
|
257
403
|
severity=0.7,
|
|
258
|
-
confidence=0.6,
|
|
404
|
+
confidence=_margin_confidence(margin, full_margin=0.4, base=0.6),
|
|
259
405
|
affected_tracks=[],
|
|
260
406
|
evidence=(
|
|
261
407
|
f"Side activity {stereo.side_activity:.2f} with center "
|
|
@@ -265,13 +411,16 @@ def run_translation_critic(
|
|
|
265
411
|
recommended_moves=["width_adjustment", "gain_staging"],
|
|
266
412
|
))
|
|
267
413
|
|
|
268
|
-
# Harshness risk: over-compressed + low headroom
|
|
414
|
+
# Harshness risk: over-compressed + low headroom. Confidence scales with
|
|
415
|
+
# how far below the 3.0dB headroom threshold the mix sits.
|
|
269
416
|
if dynamics.over_compressed and dynamics.headroom is not None and dynamics.headroom < 3.0:
|
|
270
417
|
issues.append(MixIssue(
|
|
271
418
|
issue_type="harshness_risk",
|
|
272
419
|
critic="translation",
|
|
273
420
|
severity=0.6,
|
|
274
|
-
confidence=
|
|
421
|
+
confidence=_margin_confidence(
|
|
422
|
+
3.0 - dynamics.headroom, full_margin=3.0, base=0.5
|
|
423
|
+
),
|
|
275
424
|
affected_tracks=[],
|
|
276
425
|
evidence=(
|
|
277
426
|
f"Over-compressed (crest {dynamics.crest_factor_db:.1f} dB) "
|
|
@@ -287,12 +436,20 @@ def run_translation_critic(
|
|
|
287
436
|
# ── Run all critics ─────────────────────────────────────────────────
|
|
288
437
|
|
|
289
438
|
|
|
290
|
-
def run_all_mix_critics(
|
|
291
|
-
|
|
439
|
+
def run_all_mix_critics(
|
|
440
|
+
mix_state: MixState,
|
|
441
|
+
dynamics_context: Optional[dict] = None,
|
|
442
|
+
) -> list[MixIssue]:
|
|
443
|
+
"""Run all six critics and aggregate issues.
|
|
444
|
+
|
|
445
|
+
dynamics_context: optional hint forwarded to run_dynamics_critic (see
|
|
446
|
+
its docstring) — e.g. {"target_style": "loud_master"}. Omitting it
|
|
447
|
+
(the default) preserves existing behavior for every current caller.
|
|
448
|
+
"""
|
|
292
449
|
issues: list[MixIssue] = []
|
|
293
450
|
issues.extend(run_balance_critic(mix_state.balance))
|
|
294
451
|
issues.extend(run_masking_critic(mix_state.masking))
|
|
295
|
-
issues.extend(run_dynamics_critic(mix_state.dynamics))
|
|
452
|
+
issues.extend(run_dynamics_critic(mix_state.dynamics, dynamics_context))
|
|
296
453
|
issues.extend(run_stereo_critic(mix_state.stereo))
|
|
297
454
|
issues.extend(run_depth_critic(mix_state.depth))
|
|
298
455
|
issues.extend(run_translation_critic(mix_state.dynamics, mix_state.stereo))
|
|
@@ -59,12 +59,32 @@ class BalanceState:
|
|
|
59
59
|
|
|
60
60
|
@dataclass
|
|
61
61
|
class MaskingEntry:
|
|
62
|
-
"""A single frequency masking collision between two tracks.
|
|
62
|
+
"""A single frequency masking collision between two tracks.
|
|
63
|
+
|
|
64
|
+
Fields
|
|
65
|
+
------
|
|
66
|
+
track_a, track_b : int
|
|
67
|
+
Track indices of the colliding pair.
|
|
68
|
+
overlap_band : str
|
|
69
|
+
Frequency band where the collision is strongest (e.g. "sub", "low").
|
|
70
|
+
severity : float
|
|
71
|
+
0–1 collision severity. When ``measured`` is True this reflects
|
|
72
|
+
actual per-track band energy overlap; when False it is a role-pair
|
|
73
|
+
heuristic constant from the collision-rule table.
|
|
74
|
+
measured : bool
|
|
75
|
+
True → severity was scaled by real per-track spectral data.
|
|
76
|
+
False → severity is a role-prior heuristic (no per-track spectrum).
|
|
77
|
+
severity_basis : str
|
|
78
|
+
Human-readable label for the severity origin.
|
|
79
|
+
Either "spectral_overlap" (measured) or "role_heuristic" (heuristic).
|
|
80
|
+
"""
|
|
63
81
|
|
|
64
82
|
track_a: int = 0
|
|
65
83
|
track_b: int = 0
|
|
66
84
|
overlap_band: str = ""
|
|
67
85
|
severity: float = 0.0
|
|
86
|
+
measured: bool = False
|
|
87
|
+
severity_basis: str = "role_heuristic"
|
|
68
88
|
|
|
69
89
|
def to_dict(self) -> dict:
|
|
70
90
|
return asdict(self)
|
|
@@ -155,18 +155,73 @@ def build_balance_state(
|
|
|
155
155
|
# ── Masking ─────────────────────────────────────────────────────────
|
|
156
156
|
|
|
157
157
|
|
|
158
|
+
def _extract_per_track_bands(spectrum: dict) -> dict[int, dict[str, float]]:
|
|
159
|
+
"""Extract per-track band-energy dicts from a spectrum payload.
|
|
160
|
+
|
|
161
|
+
Supports two shapes:
|
|
162
|
+
- Per-track: {"tracks": {"0": {"sub": 0.8, "low": 0.6, ...}, ...}}
|
|
163
|
+
- Master-only: {"bands": {...}} — no per-track data, returns empty dict.
|
|
164
|
+
|
|
165
|
+
Returns {track_index: {band_lower: energy_float, ...}}.
|
|
166
|
+
Band keys are lowercased so callers can use the collision-rule vocabulary
|
|
167
|
+
("sub", "low", "low_mid", "mid", "high_mid", "presence", "high") directly.
|
|
168
|
+
"""
|
|
169
|
+
raw_tracks = spectrum.get("tracks")
|
|
170
|
+
if not raw_tracks or not isinstance(raw_tracks, dict):
|
|
171
|
+
return {}
|
|
172
|
+
result: dict[int, dict[str, float]] = {}
|
|
173
|
+
for key, bands in raw_tracks.items():
|
|
174
|
+
try:
|
|
175
|
+
idx = int(key)
|
|
176
|
+
except (ValueError, TypeError):
|
|
177
|
+
continue
|
|
178
|
+
if isinstance(bands, dict):
|
|
179
|
+
result[idx] = {k.lower(): float(v) for k, v in bands.items()}
|
|
180
|
+
return result
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _overlap_score(bands_a: dict[str, float], bands_b: dict[str, float], band: str) -> float:
|
|
184
|
+
"""Compute normalised spectral overlap for a single band.
|
|
185
|
+
|
|
186
|
+
Uses the geometric mean of the two tracks' energy in that band so that
|
|
187
|
+
the score is high only when BOTH tracks are active there. Returns a
|
|
188
|
+
value in [0, 1] (or 0.0 when the band is absent from either track).
|
|
189
|
+
"""
|
|
190
|
+
ea = bands_a.get(band, 0.0)
|
|
191
|
+
eb = bands_b.get(band, 0.0)
|
|
192
|
+
return math.sqrt(max(ea, 0.0) * max(eb, 0.0))
|
|
193
|
+
|
|
194
|
+
|
|
158
195
|
def build_masking_map(
|
|
159
196
|
spectrum: Optional[dict],
|
|
160
197
|
track_roles: Optional[dict[int, str]] = None,
|
|
161
198
|
) -> MaskingMap:
|
|
162
199
|
"""Build MaskingMap from spectrum data.
|
|
163
200
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
201
|
+
Spectrum shapes accepted
|
|
202
|
+
------------------------
|
|
203
|
+
Per-track (preferred):
|
|
204
|
+
{"tracks": {"0": {"sub": 0.8, "low": 0.6, ...}, "1": {...}, ...}}
|
|
205
|
+
Master-only (fallback):
|
|
206
|
+
{"bands": {"sub": 0.9, ...}}
|
|
207
|
+
|
|
208
|
+
Severity calculation (hybrid)
|
|
209
|
+
-----------------------------
|
|
210
|
+
When per-track band energy IS available for both members of a colliding
|
|
211
|
+
pair, ``severity`` is scaled by the geometric-mean overlap in the
|
|
212
|
+
colliding band:
|
|
213
|
+
|
|
214
|
+
severity = base_severity * overlap_score(band_a, band_b, band)
|
|
215
|
+
|
|
216
|
+
This means two tracks that are perfectly EQ-separated in that band
|
|
217
|
+
(one of them has ~0 energy there) produce a near-zero severity, while
|
|
218
|
+
tracks that are both loud in the same band produce a severity close to
|
|
219
|
+
the role-prior constant.
|
|
220
|
+
|
|
221
|
+
When per-track data is absent (master-bus spectrum only), the original
|
|
222
|
+
role-prior constant is kept unchanged. Each entry is labelled with
|
|
223
|
+
``measured=True/False`` and ``severity_basis`` so downstream consumers
|
|
224
|
+
can distinguish real measurements from heuristics.
|
|
170
225
|
"""
|
|
171
226
|
entries: list[MaskingEntry] = []
|
|
172
227
|
track_roles = track_roles or {}
|
|
@@ -174,12 +229,18 @@ def build_masking_map(
|
|
|
174
229
|
if not spectrum or not track_roles:
|
|
175
230
|
return MaskingMap(entries=[], worst_pair=None)
|
|
176
231
|
|
|
232
|
+
# Extract per-track bands if available (empty dict = master-bus only)
|
|
233
|
+
per_track_bands = _extract_per_track_bands(spectrum)
|
|
234
|
+
|
|
177
235
|
# Build role->indices mapping
|
|
178
236
|
role_to_indices: dict[str, list[int]] = {}
|
|
179
237
|
for idx, role in track_roles.items():
|
|
180
238
|
role_to_indices.setdefault(_masking_role(role), []).append(idx)
|
|
181
239
|
|
|
182
|
-
# Known problematic role pairs and their collision bands
|
|
240
|
+
# Known problematic role pairs and their collision bands.
|
|
241
|
+
# Each tuple: (role_a, role_b, band, base_severity)
|
|
242
|
+
# base_severity is the heuristic prior; it may be scaled by spectral
|
|
243
|
+
# overlap when per-track data is present.
|
|
183
244
|
collision_rules: list[tuple[str, str, str, float]] = [
|
|
184
245
|
("kick", "bass", "sub", 0.7),
|
|
185
246
|
("kick", "bass", "low", 0.6),
|
|
@@ -197,11 +258,29 @@ def build_masking_map(
|
|
|
197
258
|
for ia in indices_a:
|
|
198
259
|
for ib in indices_b:
|
|
199
260
|
if ia != ib:
|
|
261
|
+
bands_a = per_track_bands.get(ia)
|
|
262
|
+
bands_b = per_track_bands.get(ib)
|
|
263
|
+
|
|
264
|
+
if bands_a is not None and bands_b is not None:
|
|
265
|
+
# Scale the role-prior by actual spectral overlap.
|
|
266
|
+
overlap = _overlap_score(bands_a, bands_b, band)
|
|
267
|
+
severity = base_severity * overlap
|
|
268
|
+
measured = True
|
|
269
|
+
severity_basis = "spectral_overlap"
|
|
270
|
+
else:
|
|
271
|
+
# No per-track data — keep the role-prior constant and
|
|
272
|
+
# label it so callers know it is not a measurement.
|
|
273
|
+
severity = base_severity
|
|
274
|
+
measured = False
|
|
275
|
+
severity_basis = "role_heuristic"
|
|
276
|
+
|
|
200
277
|
entries.append(MaskingEntry(
|
|
201
278
|
track_a=ia,
|
|
202
279
|
track_b=ib,
|
|
203
280
|
overlap_band=band,
|
|
204
|
-
severity=
|
|
281
|
+
severity=severity,
|
|
282
|
+
measured=measured,
|
|
283
|
+
severity_basis=severity_basis,
|
|
205
284
|
))
|
|
206
285
|
|
|
207
286
|
worst = None
|
|
@@ -84,11 +84,17 @@ def _fetch_mix_data(ctx: Context) -> dict:
|
|
|
84
84
|
|
|
85
85
|
|
|
86
86
|
@mcp.tool()
|
|
87
|
-
def analyze_mix(ctx: Context) -> dict:
|
|
87
|
+
def analyze_mix(ctx: Context, target_style: str = "dynamic") -> dict:
|
|
88
88
|
"""Build full mix state and run all critics.
|
|
89
89
|
|
|
90
90
|
Returns the complete mix analysis including all sub-states
|
|
91
91
|
(balance, masking, dynamics, stereo, depth) and all detected issues.
|
|
92
|
+
|
|
93
|
+
target_style: intended dynamics target for the dynamics critic.
|
|
94
|
+
"dynamic" (default) — standard mix expectations.
|
|
95
|
+
"loud_master" — deliberately loud, heavily-limited master: the
|
|
96
|
+
over_compressed check is suppressed (3-6dB crest is the intended
|
|
97
|
+
sound there, not a defect).
|
|
92
98
|
"""
|
|
93
99
|
data = _fetch_mix_data(ctx)
|
|
94
100
|
mix_state = build_mix_state(
|
|
@@ -97,7 +103,9 @@ def analyze_mix(ctx: Context) -> dict:
|
|
|
97
103
|
spectrum=data["spectrum"],
|
|
98
104
|
rms_data=data["rms_data"],
|
|
99
105
|
)
|
|
100
|
-
issues = run_all_mix_critics(
|
|
106
|
+
issues = run_all_mix_critics(
|
|
107
|
+
mix_state, dynamics_context={"target_style": target_style}
|
|
108
|
+
)
|
|
101
109
|
moves = plan_mix_moves(issues, mix_state)
|
|
102
110
|
sonic_snapshot = normalize_sonic_snapshot(data["spectrum"], source="mix_engine")
|
|
103
111
|
sonic_character = extract_character_profile(sonic_snapshot or {})
|
|
@@ -113,10 +121,12 @@ def analyze_mix(ctx: Context) -> dict:
|
|
|
113
121
|
|
|
114
122
|
|
|
115
123
|
@mcp.tool()
|
|
116
|
-
def get_mix_issues(ctx: Context) -> dict:
|
|
124
|
+
def get_mix_issues(ctx: Context, target_style: str = "dynamic") -> dict:
|
|
117
125
|
"""Run all mix critics and return detected issues only.
|
|
118
126
|
|
|
119
127
|
Lighter than analyze_mix — skips move planning.
|
|
128
|
+
|
|
129
|
+
target_style: "dynamic" (default) or "loud_master" — see analyze_mix.
|
|
120
130
|
"""
|
|
121
131
|
data = _fetch_mix_data(ctx)
|
|
122
132
|
mix_state = build_mix_state(
|
|
@@ -125,7 +135,9 @@ def get_mix_issues(ctx: Context) -> dict:
|
|
|
125
135
|
spectrum=data["spectrum"],
|
|
126
136
|
rms_data=data["rms_data"],
|
|
127
137
|
)
|
|
128
|
-
issues = run_all_mix_critics(
|
|
138
|
+
issues = run_all_mix_critics(
|
|
139
|
+
mix_state, dynamics_context={"target_style": target_style}
|
|
140
|
+
)
|
|
129
141
|
sonic_snapshot = normalize_sonic_snapshot(data["spectrum"], source="mix_engine")
|
|
130
142
|
|
|
131
143
|
return {
|
|
@@ -40,6 +40,39 @@ _POSITIONAL_FALLBACK_ROLES = {
|
|
|
40
40
|
"default": "verse",
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
# _composition_engine's SectionType vocabulary is RICHER than the performance
|
|
44
|
+
# VALID_ROLES set (it adds loop / pre_chorus / bridge / unknown / transition_fx).
|
|
45
|
+
# Feeding one of those straight into SceneRole(role=...) trips its __post_init__
|
|
46
|
+
# guard and raises ValueError, which crashes get_performance_state /
|
|
47
|
+
# get_performance_safe_moves / plan_scene_handoff the moment ANY scene resolves
|
|
48
|
+
# to an out-of-vocabulary type (e.g. a scene named "Drums"/"My Idea" → unknown).
|
|
49
|
+
# Map every composition type into the performance vocabulary by musical role.
|
|
50
|
+
_SECTION_TYPE_TO_PERF_ROLE = {
|
|
51
|
+
"intro": "intro",
|
|
52
|
+
"verse": "verse",
|
|
53
|
+
"chorus": "chorus",
|
|
54
|
+
"build": "build",
|
|
55
|
+
"drop": "drop",
|
|
56
|
+
"breakdown": "breakdown",
|
|
57
|
+
"outro": "outro",
|
|
58
|
+
"transition": "transition",
|
|
59
|
+
"transition_fx": "transition",
|
|
60
|
+
"pre_chorus": "build", # a pre-chorus builds toward the chorus
|
|
61
|
+
"bridge": "breakdown", # a bridge is a contrasting, lower-energy section
|
|
62
|
+
"loop": "verse", # a bare loop / idea reads as a steady verse
|
|
63
|
+
"unknown": "verse", # neutral default
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _to_performance_role(value: str) -> str:
|
|
68
|
+
"""Coerce any composition SectionType value into the performance vocabulary.
|
|
69
|
+
|
|
70
|
+
Falls back to 'verse' for any value not in the map so a future SectionType
|
|
71
|
+
can never crash the performance tools.
|
|
72
|
+
"""
|
|
73
|
+
role = _SECTION_TYPE_TO_PERF_ROLE.get(value)
|
|
74
|
+
return role if role is not None else "verse"
|
|
75
|
+
|
|
43
76
|
|
|
44
77
|
def _positional_fallback_role(index: int, scene_count: int) -> str:
|
|
45
78
|
"""Map a scene index to a role when no composition data is available.
|
|
@@ -148,21 +181,36 @@ def _fetch_scene_data(ctx: Context) -> tuple[list[SceneRole], int]:
|
|
|
148
181
|
name = scene_data.get("name", f"Scene {i}")
|
|
149
182
|
ce_sec = ce_by_scene_idx.get(i)
|
|
150
183
|
if ce_sec is not None:
|
|
151
|
-
# SectionType is an enum; .value gives the string vocabulary
|
|
184
|
+
# SectionType is an enum; .value gives the string vocabulary, which
|
|
185
|
+
# we then coerce into the (narrower) performance VALID_ROLES set.
|
|
152
186
|
stype = ce_sec.section_type
|
|
153
|
-
|
|
187
|
+
raw = stype.value if hasattr(stype, "value") else str(stype)
|
|
188
|
+
role = _to_performance_role(raw)
|
|
154
189
|
energy = float(ce_sec.energy)
|
|
155
190
|
else:
|
|
156
191
|
# Unnamed scene or build failed — positional fallback
|
|
157
192
|
role = _positional_fallback_role(i, scene_count)
|
|
158
193
|
energy = _positional_fallback_energy(role)
|
|
159
194
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
195
|
+
# Clamp energy and defensively construct: SceneRole.__post_init__ raises
|
|
196
|
+
# on an out-of-range energy or an unmapped role, and a single raise here
|
|
197
|
+
# would take down the whole tool. A bad value degrades to a safe scene.
|
|
198
|
+
energy = max(0.0, min(1.0, energy))
|
|
199
|
+
try:
|
|
200
|
+
scene_roles.append(SceneRole(
|
|
201
|
+
scene_index=i,
|
|
202
|
+
name=name,
|
|
203
|
+
energy_level=energy,
|
|
204
|
+
role=role,
|
|
205
|
+
))
|
|
206
|
+
except ValueError as exc:
|
|
207
|
+
logger.debug("_fetch_scene_data SceneRole(%r) invalid: %s", role, exc)
|
|
208
|
+
scene_roles.append(SceneRole(
|
|
209
|
+
scene_index=i,
|
|
210
|
+
name=name,
|
|
211
|
+
energy_level=energy,
|
|
212
|
+
role="verse",
|
|
213
|
+
))
|
|
166
214
|
|
|
167
215
|
# Determine current scene — default to 0 since session_info
|
|
168
216
|
# doesn't expose a selected_scene field
|
|
@@ -70,6 +70,17 @@ class PersistentJsonStore:
|
|
|
70
70
|
try:
|
|
71
71
|
return json.loads(self._path.read_text(encoding="utf-8"))
|
|
72
72
|
except (json.JSONDecodeError, OSError):
|
|
73
|
+
# Mirror the read() corruption-recovery path: preserve the
|
|
74
|
+
# corrupt file as <name>.corrupt so it can be inspected/recovered
|
|
75
|
+
# rather than being silently overwritten by the next _write_unlocked
|
|
76
|
+
# call (which update() always performs after _read_unlocked).
|
|
77
|
+
# Best-effort — a backup failure must not prevent the store from
|
|
78
|
+
# recovering with defaults.
|
|
79
|
+
corrupt = self._path.with_suffix(self._path.suffix + ".corrupt")
|
|
80
|
+
try:
|
|
81
|
+
self._path.rename(corrupt)
|
|
82
|
+
except OSError:
|
|
83
|
+
pass
|
|
73
84
|
return {}
|
|
74
85
|
|
|
75
86
|
def _write_unlocked(self, data: dict) -> None:
|