livepilot 1.27.1 → 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.
Files changed (117) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +10 -7
  3. package/bin/livepilot.js +156 -37
  4. package/livepilot/.Codex-plugin/plugin.json +1 -1
  5. package/livepilot/.claude-plugin/plugin.json +1 -1
  6. package/livepilot/skills/livepilot-core/SKILL.md +33 -1
  7. package/livepilot/skills/livepilot-core/references/atlas-tool-notes.md +69 -0
  8. package/livepilot/skills/livepilot-core/references/overview.md +18 -11
  9. package/livepilot/skills/livepilot-core/references/perception.md +125 -0
  10. package/livepilot/skills/livepilot-devices/references/device-parameter-units.md +66 -0
  11. package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
  12. package/livepilot/skills/livepilot-mix-engine/SKILL.md +8 -0
  13. package/livepilot/skills/livepilot-release/SKILL.md +1 -1
  14. package/livepilot/skills/livepilot-sample-engine/references/splice-tools-notes.md +56 -0
  15. package/livepilot/skills/livepilot-wonder/SKILL.md +6 -0
  16. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  17. package/m4l_device/livepilot_bridge.js +48 -11
  18. package/mcp_server/__init__.py +1 -1
  19. package/mcp_server/atlas/__init__.py +181 -40
  20. package/mcp_server/atlas/overlays.py +46 -3
  21. package/mcp_server/atlas/tools.py +226 -86
  22. package/mcp_server/audit/checks.py +50 -22
  23. package/mcp_server/audit/state.py +10 -2
  24. package/mcp_server/audit/tools.py +27 -6
  25. package/mcp_server/composer/develop/apply.py +7 -7
  26. package/mcp_server/composer/fast/apply.py +29 -23
  27. package/mcp_server/composer/fast/brief_builder.py +6 -2
  28. package/mcp_server/composer/framework/atlas_resolver.py +16 -1
  29. package/mcp_server/composer/full/apply.py +40 -34
  30. package/mcp_server/composer/full/engine.py +66 -32
  31. package/mcp_server/composer/tools.py +16 -9
  32. package/mcp_server/connection.py +69 -10
  33. package/mcp_server/creative_constraints/engine.py +10 -2
  34. package/mcp_server/creative_constraints/tools.py +24 -7
  35. package/mcp_server/curves.py +30 -7
  36. package/mcp_server/device_forge/builder.py +40 -11
  37. package/mcp_server/device_forge/models.py +9 -0
  38. package/mcp_server/device_forge/tools.py +30 -11
  39. package/mcp_server/experiment/engine.py +29 -22
  40. package/mcp_server/experiment/tools.py +15 -5
  41. package/mcp_server/m4l_bridge.py +217 -62
  42. package/mcp_server/memory/taste_graph.py +43 -0
  43. package/mcp_server/memory/technique_store.py +26 -3
  44. package/mcp_server/memory/tools.py +62 -4
  45. package/mcp_server/mix_engine/critics.py +187 -30
  46. package/mcp_server/mix_engine/models.py +21 -1
  47. package/mcp_server/mix_engine/state_builder.py +87 -8
  48. package/mcp_server/mix_engine/tools.py +16 -4
  49. package/mcp_server/performance_engine/tools.py +56 -8
  50. package/mcp_server/persistence/base_store.py +11 -0
  51. package/mcp_server/persistence/project_store.py +132 -8
  52. package/mcp_server/persistence/taste_store.py +90 -7
  53. package/mcp_server/preview_studio/engine.py +40 -10
  54. package/mcp_server/preview_studio/models.py +40 -0
  55. package/mcp_server/preview_studio/tools.py +56 -12
  56. package/mcp_server/project_brain/arrangement_graph.py +4 -0
  57. package/mcp_server/project_brain/models.py +2 -0
  58. package/mcp_server/project_brain/role_graph.py +13 -7
  59. package/mcp_server/reference_engine/gap_analyzer.py +58 -3
  60. package/mcp_server/reference_engine/profile_builder.py +47 -4
  61. package/mcp_server/reference_engine/tools.py +6 -0
  62. package/mcp_server/runtime/execution_router.py +57 -1
  63. package/mcp_server/runtime/mcp_dispatch.py +22 -0
  64. package/mcp_server/runtime/remote_commands.py +9 -4
  65. package/mcp_server/runtime/tools.py +0 -1
  66. package/mcp_server/sample_engine/sources.py +0 -2
  67. package/mcp_server/sample_engine/tools.py +19 -38
  68. package/mcp_server/semantic_moves/mix_compilers.py +276 -51
  69. package/mcp_server/semantic_moves/performance_compilers.py +51 -17
  70. package/mcp_server/semantic_moves/resolvers.py +45 -0
  71. package/mcp_server/semantic_moves/sound_design_compilers.py +14 -6
  72. package/mcp_server/semantic_moves/tools.py +80 -2
  73. package/mcp_server/semantic_moves/transition_compilers.py +26 -9
  74. package/mcp_server/server.py +27 -25
  75. package/mcp_server/session_continuity/tracker.py +51 -3
  76. package/mcp_server/song_brain/builder.py +47 -5
  77. package/mcp_server/song_brain/tools.py +21 -7
  78. package/mcp_server/sound_design/critics.py +1 -0
  79. package/mcp_server/splice_client/client.py +117 -33
  80. package/mcp_server/splice_client/http_bridge.py +15 -3
  81. package/mcp_server/splice_client/quota.py +28 -0
  82. package/mcp_server/stuckness_detector/detector.py +8 -5
  83. package/mcp_server/synthesis_brain/adapters/drift.py +13 -0
  84. package/mcp_server/synthesis_brain/adapters/meld.py +13 -0
  85. package/mcp_server/tools/_analyzer_engine/sample.py +36 -10
  86. package/mcp_server/tools/_perception_engine.py +6 -0
  87. package/mcp_server/tools/agent_os.py +4 -1
  88. package/mcp_server/tools/analyzer.py +198 -209
  89. package/mcp_server/tools/arrangement.py +7 -4
  90. package/mcp_server/tools/automation.py +24 -4
  91. package/mcp_server/tools/browser.py +25 -11
  92. package/mcp_server/tools/clips.py +6 -0
  93. package/mcp_server/tools/composition.py +33 -2
  94. package/mcp_server/tools/devices.py +53 -53
  95. package/mcp_server/tools/generative.py +14 -14
  96. package/mcp_server/tools/harmony.py +7 -7
  97. package/mcp_server/tools/mixing.py +4 -4
  98. package/mcp_server/tools/planner.py +68 -6
  99. package/mcp_server/tools/research.py +20 -2
  100. package/mcp_server/tools/theory.py +10 -10
  101. package/mcp_server/tools/transport.py +7 -2
  102. package/mcp_server/transition_engine/critics.py +13 -1
  103. package/mcp_server/user_corpus/tools.py +30 -1
  104. package/mcp_server/wonder_mode/engine.py +82 -9
  105. package/mcp_server/wonder_mode/session.py +32 -10
  106. package/mcp_server/wonder_mode/tools.py +14 -1
  107. package/package.json +1 -1
  108. package/remote_script/LivePilot/__init__.py +21 -8
  109. package/remote_script/LivePilot/arrangement.py +93 -33
  110. package/remote_script/LivePilot/browser.py +60 -4
  111. package/remote_script/LivePilot/devices.py +132 -62
  112. package/remote_script/LivePilot/mixing.py +31 -5
  113. package/remote_script/LivePilot/server.py +94 -22
  114. package/remote_script/LivePilot/tracks.py +11 -5
  115. package/remote_script/LivePilot/transport.py +11 -0
  116. package/requirements.txt +5 -5
  117. package/server.json +2 -2
@@ -6,6 +6,8 @@
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ import logging
10
+
9
11
  from fastmcp import Context
10
12
 
11
13
  from ..server import mcp
@@ -14,12 +16,29 @@ from .promotion import batch_evaluate_promotions
14
16
  from .session_memory import SessionMemoryStore
15
17
  from .taste_memory import TasteMemoryStore
16
18
 
19
+ logger = logging.getLogger(__name__)
20
+
17
21
 
18
22
  def _get_anti_memory(ctx: Context) -> AntiMemoryStore:
19
23
  """Get or create the session-scoped AntiMemoryStore."""
20
24
  return ctx.lifespan_context.setdefault("anti_memory", AntiMemoryStore())
21
25
 
22
26
 
27
+ def _get_persistent_taste_store(ctx: Context):
28
+ """Return the persistent taste store from the live server context, or None.
29
+
30
+ Only the live server lifespan injects "persistent_taste" (the same key the
31
+ wonder/runtime/preview_studio tools use, so all share ONE instance); tests
32
+ build their own context without it, so taste tools stay session-only
33
+ (hermetic) there. When present, taste write-backs and reads persist across
34
+ restarts (P2-29).
35
+ """
36
+ try:
37
+ return ctx.lifespan_context.get("persistent_taste")
38
+ except AttributeError:
39
+ return None
40
+
41
+
23
42
  def _get_session_memory(ctx: Context) -> SessionMemoryStore:
24
43
  """Get or create the session-scoped SessionMemoryStore."""
25
44
  return ctx.lifespan_context.setdefault("session_memory", SessionMemoryStore())
@@ -43,12 +62,25 @@ def record_anti_preference(
43
62
  ) -> dict:
44
63
  """Record a user dislike for a dimension+direction. direction must be 'increase' or 'decrease'."""
45
64
  if direction not in ("increase", "decrease"):
46
- return {"error": "direction must be 'increase' or 'decrease'"}
65
+ return {"error": "direction must be 'increase' or 'decrease'",
66
+ "code": "INVALID_PARAM"}
47
67
  store = _get_anti_memory(ctx)
48
68
  pref = store.record_dislike(dimension, direction)
69
+ # P2-29: persist to the taste store so the anti-preference survives a
70
+ # server restart (best-effort — the session store above is authoritative
71
+ # for the in-session response).
72
+ persistent = _get_persistent_taste_store(ctx)
73
+ persisted = False
74
+ if persistent is not None:
75
+ try:
76
+ persistent.record_anti_preference(dimension, direction)
77
+ persisted = True
78
+ except Exception as exc: # pragma: no cover - defensive
79
+ logger.debug("persist anti_preference failed: %s", exc)
49
80
  return {
50
81
  "recorded": pref.to_dict(),
51
82
  "should_caution": store.should_caution(dimension, direction),
83
+ "persisted": persisted,
52
84
  }
53
85
 
54
86
 
@@ -143,7 +175,10 @@ def get_taste_graph(ctx: Context) -> dict:
143
175
 
144
176
  taste_store = _get_taste_memory(ctx)
145
177
  anti_store = _get_anti_memory(ctx)
146
- graph = build_taste_graph(taste_store=taste_store, anti_store=anti_store)
178
+ graph = build_taste_graph(
179
+ taste_store=taste_store, anti_store=anti_store,
180
+ persistent_store=_get_persistent_taste_store(ctx),
181
+ )
147
182
  return graph.to_dict()
148
183
 
149
184
 
@@ -158,7 +193,10 @@ def explain_taste_inference(ctx: Context) -> dict:
158
193
 
159
194
  taste_store = _get_taste_memory(ctx)
160
195
  anti_store = _get_anti_memory(ctx)
161
- graph = build_taste_graph(taste_store=taste_store, anti_store=anti_store)
196
+ graph = build_taste_graph(
197
+ taste_store=taste_store, anti_store=anti_store,
198
+ persistent_store=_get_persistent_taste_store(ctx),
199
+ )
162
200
  return graph.explain()
163
201
 
164
202
 
@@ -178,7 +216,10 @@ def rank_moves_by_taste(
178
216
 
179
217
  taste_store = _get_taste_memory(ctx)
180
218
  anti_store = _get_anti_memory(ctx)
181
- graph = build_taste_graph(taste_store=taste_store, anti_store=anti_store)
219
+ graph = build_taste_graph(
220
+ taste_store=taste_store, anti_store=anti_store,
221
+ persistent_store=_get_persistent_taste_store(ctx),
222
+ )
182
223
 
183
224
  if isinstance(move_specs, str):
184
225
  import json
@@ -218,10 +259,27 @@ def record_positive_preference(
218
259
  matching_signals.append(sig_name)
219
260
  if matching_signals:
220
261
  taste_store.update_from_outcome({"signals": matching_signals})
262
+ # P2-29 (dimension-weight half): persist the updated dimension weight so it
263
+ # survives a server restart — symmetric with record_anti_preference. Without
264
+ # this the persisted dimension_weights hydration branch in build_taste_graph
265
+ # stayed dead (no production writer). Best-effort; session store is
266
+ # authoritative for the in-session response.
267
+ persisted = False
268
+ persistent = _get_persistent_taste_store(ctx)
269
+ if persistent is not None and matching_signals:
270
+ try:
271
+ for dim in taste_store.get_taste_dimensions():
272
+ if dim.name == dimension and dim.evidence_count > 0:
273
+ persistent.record_dimension_weight(dimension, dim.value)
274
+ persisted = True
275
+ break
276
+ except Exception as exc: # pragma: no cover - defensive
277
+ logger.debug("persist dimension_weight failed: %s", exc)
221
278
  return {
222
279
  "recorded": bool(matching_signals),
223
280
  "dimension": dimension,
224
281
  "direction": direction,
225
282
  "signals_matched": matching_signals,
226
283
  "evidence": evidence,
284
+ "persisted": persisted,
227
285
  }
@@ -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=0.7,
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=0.6,
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(dynamics: DynamicsState) -> list[MixIssue]:
120
- """Detect dynamics problems: over-compression, flat dynamics, low headroom."""
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
- 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
- ))
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
- confidence=0.8,
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
- confidence=0.9,
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=0.5,
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=0.5,
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=0.6,
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=0.5,
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(mix_state: MixState) -> list[MixIssue]:
291
- """Run all six critics and aggregate issues."""
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
- Uses per-track spectrum bands if available, otherwise returns empty.
165
- Spectrum shape: {"tracks": {track_idx_str: {band: value, ...}, ...}}
166
- or flat {"bands": {band: value}} for master-only.
167
-
168
- For Phase 1 we detect masking heuristically from role collisions
169
- in known problem bands (kick/bass in sub/low, bass/chords in low_mid).
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=base_severity,
281
+ severity=severity,
282
+ measured=measured,
283
+ severity_basis=severity_basis,
205
284
  ))
206
285
 
207
286
  worst = None