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.
Files changed (113) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +10 -7
  3. package/bin/livepilot.js +97 -39
  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 +1 -1
  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/framework/atlas_resolver.py +16 -1
  28. package/mcp_server/composer/full/apply.py +40 -34
  29. package/mcp_server/composer/full/engine.py +66 -32
  30. package/mcp_server/composer/tools.py +4 -3
  31. package/mcp_server/connection.py +67 -9
  32. package/mcp_server/creative_constraints/engine.py +10 -2
  33. package/mcp_server/creative_constraints/tools.py +24 -7
  34. package/mcp_server/curves.py +30 -7
  35. package/mcp_server/device_forge/builder.py +40 -11
  36. package/mcp_server/device_forge/models.py +9 -0
  37. package/mcp_server/device_forge/tools.py +30 -11
  38. package/mcp_server/experiment/engine.py +29 -22
  39. package/mcp_server/experiment/tools.py +15 -5
  40. package/mcp_server/m4l_bridge.py +69 -7
  41. package/mcp_server/memory/taste_graph.py +43 -0
  42. package/mcp_server/memory/technique_store.py +26 -3
  43. package/mcp_server/memory/tools.py +62 -4
  44. package/mcp_server/mix_engine/critics.py +187 -30
  45. package/mcp_server/mix_engine/models.py +21 -1
  46. package/mcp_server/mix_engine/state_builder.py +87 -8
  47. package/mcp_server/mix_engine/tools.py +16 -4
  48. package/mcp_server/performance_engine/tools.py +56 -8
  49. package/mcp_server/persistence/base_store.py +11 -0
  50. package/mcp_server/persistence/project_store.py +132 -8
  51. package/mcp_server/persistence/taste_store.py +90 -7
  52. package/mcp_server/preview_studio/engine.py +40 -10
  53. package/mcp_server/preview_studio/models.py +40 -0
  54. package/mcp_server/preview_studio/tools.py +56 -12
  55. package/mcp_server/project_brain/arrangement_graph.py +4 -0
  56. package/mcp_server/project_brain/models.py +2 -0
  57. package/mcp_server/project_brain/role_graph.py +13 -7
  58. package/mcp_server/reference_engine/gap_analyzer.py +58 -3
  59. package/mcp_server/reference_engine/profile_builder.py +47 -4
  60. package/mcp_server/reference_engine/tools.py +6 -0
  61. package/mcp_server/runtime/execution_router.py +51 -1
  62. package/mcp_server/runtime/tools.py +0 -1
  63. package/mcp_server/sample_engine/sources.py +0 -2
  64. package/mcp_server/sample_engine/tools.py +19 -38
  65. package/mcp_server/semantic_moves/mix_compilers.py +276 -51
  66. package/mcp_server/semantic_moves/performance_compilers.py +51 -17
  67. package/mcp_server/semantic_moves/resolvers.py +45 -0
  68. package/mcp_server/semantic_moves/sound_design_compilers.py +14 -6
  69. package/mcp_server/semantic_moves/tools.py +80 -2
  70. package/mcp_server/semantic_moves/transition_compilers.py +26 -9
  71. package/mcp_server/server.py +26 -24
  72. package/mcp_server/session_continuity/tracker.py +51 -3
  73. package/mcp_server/song_brain/builder.py +47 -5
  74. package/mcp_server/song_brain/tools.py +21 -7
  75. package/mcp_server/sound_design/critics.py +1 -0
  76. package/mcp_server/splice_client/client.py +117 -33
  77. package/mcp_server/splice_client/http_bridge.py +15 -3
  78. package/mcp_server/splice_client/quota.py +28 -0
  79. package/mcp_server/stuckness_detector/detector.py +8 -5
  80. package/mcp_server/synthesis_brain/adapters/drift.py +13 -0
  81. package/mcp_server/synthesis_brain/adapters/meld.py +13 -0
  82. package/mcp_server/tools/_analyzer_engine/sample.py +36 -10
  83. package/mcp_server/tools/_perception_engine.py +6 -0
  84. package/mcp_server/tools/agent_os.py +4 -1
  85. package/mcp_server/tools/analyzer.py +198 -209
  86. package/mcp_server/tools/arrangement.py +7 -4
  87. package/mcp_server/tools/automation.py +24 -4
  88. package/mcp_server/tools/browser.py +25 -11
  89. package/mcp_server/tools/clips.py +6 -0
  90. package/mcp_server/tools/composition.py +33 -2
  91. package/mcp_server/tools/devices.py +53 -53
  92. package/mcp_server/tools/generative.py +14 -14
  93. package/mcp_server/tools/harmony.py +7 -7
  94. package/mcp_server/tools/mixing.py +4 -4
  95. package/mcp_server/tools/planner.py +68 -6
  96. package/mcp_server/tools/research.py +20 -2
  97. package/mcp_server/tools/theory.py +10 -10
  98. package/mcp_server/tools/transport.py +7 -2
  99. package/mcp_server/transition_engine/critics.py +13 -1
  100. package/mcp_server/user_corpus/tools.py +30 -1
  101. package/mcp_server/wonder_mode/engine.py +82 -9
  102. package/mcp_server/wonder_mode/session.py +32 -10
  103. package/mcp_server/wonder_mode/tools.py +14 -1
  104. package/package.json +1 -1
  105. package/remote_script/LivePilot/__init__.py +1 -1
  106. package/remote_script/LivePilot/arrangement.py +93 -33
  107. package/remote_script/LivePilot/browser.py +60 -4
  108. package/remote_script/LivePilot/devices.py +132 -62
  109. package/remote_script/LivePilot/mixing.py +31 -5
  110. package/remote_script/LivePilot/server.py +94 -22
  111. package/remote_script/LivePilot/transport.py +11 -0
  112. package/requirements.txt +5 -5
  113. 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=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
@@ -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(mix_state)
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(mix_state)
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
- role = stype.value if hasattr(stype, "value") else str(stype)
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
- scene_roles.append(SceneRole(
161
- scene_index=i,
162
- name=name,
163
- energy_level=energy,
164
- role=role,
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: