livepilot 1.9.22 → 1.9.24

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 (118) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/.mcpbignore +40 -0
  3. package/AGENTS.md +3 -3
  4. package/CHANGELOG.md +84 -0
  5. package/CONTRIBUTING.md +1 -1
  6. package/README.md +141 -72
  7. package/bin/livepilot.js +135 -0
  8. package/livepilot/.Codex-plugin/plugin.json +2 -2
  9. package/livepilot/.claude-plugin/plugin.json +2 -2
  10. package/livepilot/agents/livepilot-producer/AGENT.md +13 -0
  11. package/livepilot/commands/arrange.md +42 -23
  12. package/livepilot/commands/mix.md +34 -19
  13. package/livepilot/commands/perform.md +31 -19
  14. package/livepilot/commands/sounddesign.md +38 -25
  15. package/livepilot/skills/livepilot-arrangement/SKILL.md +2 -1
  16. package/livepilot/skills/livepilot-composition-engine/references/transition-archetypes.md +2 -2
  17. package/livepilot/skills/livepilot-core/SKILL.md +60 -4
  18. package/livepilot/skills/livepilot-core/references/device-atlas/distortion-and-character.md +11 -11
  19. package/livepilot/skills/livepilot-core/references/device-atlas/drums-and-percussion.md +25 -25
  20. package/livepilot/skills/livepilot-core/references/device-atlas/dynamics-and-punch.md +21 -21
  21. package/livepilot/skills/livepilot-core/references/device-atlas/eq-and-filtering.md +13 -13
  22. package/livepilot/skills/livepilot-core/references/device-atlas/midi-tools.md +13 -13
  23. package/livepilot/skills/livepilot-core/references/device-atlas/movement-and-modulation.md +5 -5
  24. package/livepilot/skills/livepilot-core/references/device-atlas/space-and-depth.md +16 -16
  25. package/livepilot/skills/livepilot-core/references/device-atlas/spectral-and-weird.md +40 -40
  26. package/livepilot/skills/livepilot-core/references/m4l-devices.md +3 -3
  27. package/livepilot/skills/livepilot-core/references/overview.md +4 -4
  28. package/livepilot/skills/livepilot-evaluation/SKILL.md +12 -8
  29. package/livepilot/skills/livepilot-evaluation/references/memory-promotion.md +2 -2
  30. package/livepilot/skills/livepilot-mix-engine/SKILL.md +1 -1
  31. package/livepilot/skills/livepilot-mix-engine/references/mix-moves.md +2 -2
  32. package/livepilot/skills/livepilot-mixing/SKILL.md +3 -1
  33. package/livepilot/skills/livepilot-notes/SKILL.md +2 -1
  34. package/livepilot/skills/livepilot-release/SKILL.md +29 -15
  35. package/livepilot/skills/livepilot-sound-design-engine/SKILL.md +2 -2
  36. package/livepilot/skills/livepilot-wonder/SKILL.md +62 -0
  37. package/livepilot.mcpb +0 -0
  38. package/manifest.json +91 -0
  39. package/mcp_server/__init__.py +1 -1
  40. package/mcp_server/creative_constraints/__init__.py +6 -0
  41. package/mcp_server/creative_constraints/engine.py +277 -0
  42. package/mcp_server/creative_constraints/models.py +75 -0
  43. package/mcp_server/creative_constraints/tools.py +341 -0
  44. package/mcp_server/experiment/__init__.py +6 -0
  45. package/mcp_server/experiment/engine.py +213 -0
  46. package/mcp_server/experiment/models.py +120 -0
  47. package/mcp_server/experiment/tools.py +263 -0
  48. package/mcp_server/hook_hunter/__init__.py +5 -0
  49. package/mcp_server/hook_hunter/analyzer.py +365 -0
  50. package/mcp_server/hook_hunter/models.py +58 -0
  51. package/mcp_server/hook_hunter/tools.py +588 -0
  52. package/mcp_server/memory/taste_graph.py +328 -0
  53. package/mcp_server/memory/tools.py +99 -0
  54. package/mcp_server/mix_engine/critics.py +2 -2
  55. package/mcp_server/mix_engine/models.py +1 -1
  56. package/mcp_server/mix_engine/state_builder.py +2 -2
  57. package/mcp_server/musical_intelligence/__init__.py +8 -0
  58. package/mcp_server/musical_intelligence/detectors.py +434 -0
  59. package/mcp_server/musical_intelligence/phrase_critic.py +163 -0
  60. package/mcp_server/musical_intelligence/tools.py +224 -0
  61. package/mcp_server/persistence/__init__.py +1 -0
  62. package/mcp_server/persistence/base_store.py +82 -0
  63. package/mcp_server/persistence/project_store.py +106 -0
  64. package/mcp_server/persistence/taste_store.py +122 -0
  65. package/mcp_server/preview_studio/__init__.py +5 -0
  66. package/mcp_server/preview_studio/engine.py +280 -0
  67. package/mcp_server/preview_studio/models.py +74 -0
  68. package/mcp_server/preview_studio/tools.py +466 -0
  69. package/mcp_server/runtime/capability.py +66 -0
  70. package/mcp_server/runtime/capability_probe.py +118 -0
  71. package/mcp_server/runtime/execution_router.py +139 -0
  72. package/mcp_server/runtime/remote_commands.py +82 -0
  73. package/mcp_server/runtime/session_kernel.py +96 -0
  74. package/mcp_server/runtime/tools.py +90 -1
  75. package/mcp_server/semantic_moves/__init__.py +13 -0
  76. package/mcp_server/semantic_moves/compiler.py +116 -0
  77. package/mcp_server/semantic_moves/mix_compilers.py +291 -0
  78. package/mcp_server/semantic_moves/mix_moves.py +157 -0
  79. package/mcp_server/semantic_moves/models.py +46 -0
  80. package/mcp_server/semantic_moves/performance_compilers.py +208 -0
  81. package/mcp_server/semantic_moves/performance_moves.py +81 -0
  82. package/mcp_server/semantic_moves/registry.py +32 -0
  83. package/mcp_server/semantic_moves/resolvers.py +126 -0
  84. package/mcp_server/semantic_moves/sound_design_compilers.py +266 -0
  85. package/mcp_server/semantic_moves/sound_design_moves.py +78 -0
  86. package/mcp_server/semantic_moves/tools.py +205 -0
  87. package/mcp_server/semantic_moves/transition_compilers.py +222 -0
  88. package/mcp_server/semantic_moves/transition_moves.py +76 -0
  89. package/mcp_server/server.py +10 -0
  90. package/mcp_server/services/__init__.py +1 -0
  91. package/mcp_server/services/motif_service.py +67 -0
  92. package/mcp_server/session_continuity/__init__.py +6 -0
  93. package/mcp_server/session_continuity/models.py +86 -0
  94. package/mcp_server/session_continuity/tools.py +230 -0
  95. package/mcp_server/session_continuity/tracker.py +263 -0
  96. package/mcp_server/song_brain/__init__.py +6 -0
  97. package/mcp_server/song_brain/builder.py +504 -0
  98. package/mcp_server/song_brain/models.py +136 -0
  99. package/mcp_server/song_brain/tools.py +312 -0
  100. package/mcp_server/stuckness_detector/__init__.py +5 -0
  101. package/mcp_server/stuckness_detector/detector.py +400 -0
  102. package/mcp_server/stuckness_detector/models.py +66 -0
  103. package/mcp_server/stuckness_detector/tools.py +195 -0
  104. package/mcp_server/tools/_conductor.py +104 -6
  105. package/mcp_server/tools/analyzer.py +1 -1
  106. package/mcp_server/tools/devices.py +34 -0
  107. package/mcp_server/wonder_mode/__init__.py +6 -0
  108. package/mcp_server/wonder_mode/diagnosis.py +84 -0
  109. package/mcp_server/wonder_mode/engine.py +493 -0
  110. package/mcp_server/wonder_mode/session.py +114 -0
  111. package/mcp_server/wonder_mode/tools.py +290 -0
  112. package/package.json +2 -2
  113. package/remote_script/LivePilot/__init__.py +1 -1
  114. package/remote_script/LivePilot/browser.py +4 -1
  115. package/remote_script/LivePilot/devices.py +29 -0
  116. package/remote_script/LivePilot/tracks.py +11 -4
  117. package/scripts/generate_tool_catalog.py +131 -0
  118. package/scripts/sync_metadata.py +132 -0
@@ -0,0 +1,365 @@
1
+ """Hook Hunter analysis — pure computation, zero I/O.
2
+
3
+ Identifies hooks, ranks candidates, scores phrase impact, and
4
+ detects payoff failures.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ from collections import Counter
11
+ from typing import Optional
12
+
13
+ from .models import HookCandidate, PayoffFailure, PhraseImpact
14
+
15
+
16
+ # ── Hook detection ────────────────────────────────────────────────
17
+
18
+
19
+ def find_hook_candidates(
20
+ tracks: list[dict],
21
+ motif_data: Optional[dict] = None,
22
+ scene_data: Optional[list[dict]] = None,
23
+ composition: Optional[dict] = None,
24
+ ) -> list[HookCandidate]:
25
+ """Detect and rank hook candidates from session data.
26
+
27
+ Looks for: salient melodic motifs, distinctive rhythmic cells,
28
+ signature timbral textures, recurring harmonic progressions.
29
+ """
30
+ motif_data = motif_data or {}
31
+ scene_data = scene_data or []
32
+ composition = composition or {}
33
+ candidates: list[HookCandidate] = []
34
+
35
+ # 1. Motif-based hooks
36
+ for motif in motif_data.get("motifs", []):
37
+ salience = motif.get("salience", 0)
38
+ recurrence = motif.get("recurrence", 0)
39
+ if salience > 0.2 or recurrence > 0.3:
40
+ candidates.append(HookCandidate(
41
+ hook_id=f"motif_{motif.get('name', 'unknown')}",
42
+ hook_type="melodic",
43
+ description=motif.get("description", motif.get("name", "motif")),
44
+ location=motif.get("location", ""),
45
+ memorability=min(1.0, salience * 1.2),
46
+ recurrence=recurrence,
47
+ contrast_potential=motif.get("contrast", 0.5),
48
+ development_potential=_estimate_development_potential(motif),
49
+ ))
50
+
51
+ # 2. Track-name-based detection (lead, hook, melody, riff)
52
+ hook_keywords = {"lead", "hook", "melody", "riff", "main", "top", "vocal", "synth"}
53
+ for track in tracks:
54
+ name = track.get("name", "").lower()
55
+ if any(kw in name for kw in hook_keywords):
56
+ candidates.append(HookCandidate(
57
+ hook_id=f"track_{name.replace(' ', '_')}",
58
+ hook_type="melodic" if "melody" in name or "vocal" in name else "timbral",
59
+ description=f"Track: {track.get('name', name)}",
60
+ location=track.get("name", ""),
61
+ memorability=0.5,
62
+ recurrence=0.6, # present across scenes typically
63
+ contrast_potential=0.5,
64
+ development_potential=0.6,
65
+ ))
66
+
67
+ # 3. Rhythmic hooks from drum/percussion patterns
68
+ rhythm_keywords = {"drum", "beat", "perc", "hat", "kick", "clap"}
69
+ groove_tracks = [t for t in tracks if any(kw in t.get("name", "").lower() for kw in rhythm_keywords)]
70
+ if groove_tracks:
71
+ # Check for distinctive rhythmic patterns via clip reuse
72
+ clip_reuse = _measure_clip_reuse(scene_data, groove_tracks)
73
+ if clip_reuse > 0.5:
74
+ candidates.append(HookCandidate(
75
+ hook_id="groove_pattern",
76
+ hook_type="rhythmic",
77
+ description="Primary groove pattern",
78
+ location=groove_tracks[0].get("name", "drums"),
79
+ memorability=0.5,
80
+ recurrence=clip_reuse,
81
+ contrast_potential=0.4,
82
+ development_potential=0.5,
83
+ ))
84
+
85
+ # 4. Section-placement analysis: boost hooks that appear in payoff sections
86
+ payoff_sections = {
87
+ s.get("label", "").lower()
88
+ for s in (composition.get("sections", []) if composition else [])
89
+ if s.get("is_payoff")
90
+ } or {"chorus", "drop", "hook"}
91
+
92
+ for c in candidates:
93
+ # Check if hook is present in payoff sections (via motif locations)
94
+ if c.hook_type == "melodic" and motif_data:
95
+ for motif in motif_data.get("motifs", []):
96
+ if motif.get("name", "") in c.hook_id:
97
+ # Motif with high recurrence across sections = stronger hook
98
+ c.memorability = min(1.0, c.memorability + motif.get("recurrence", 0) * 0.2)
99
+
100
+ # Score all candidates
101
+ for c in candidates:
102
+ c.salience = _compute_salience(c)
103
+ # Add evidence sources
104
+ c.evidence_sources = []
105
+ if "motif_" in c.hook_id:
106
+ c.evidence_sources.append("motif_recurrence")
107
+ if "track_" in c.hook_id:
108
+ c.evidence_sources.append("track_name")
109
+ if "groove" in c.hook_id:
110
+ c.evidence_sources.append("clip_reuse")
111
+
112
+ # Sort by salience
113
+ candidates.sort(key=lambda c: c.salience, reverse=True)
114
+ return candidates
115
+
116
+
117
+ def find_primary_hook(
118
+ tracks: list[dict],
119
+ motif_data: Optional[dict] = None,
120
+ scene_data: Optional[list[dict]] = None,
121
+ composition: Optional[dict] = None,
122
+ ) -> Optional[HookCandidate]:
123
+ """Find the single most salient hook in the session."""
124
+ candidates = find_hook_candidates(tracks, motif_data, scene_data, composition)
125
+ return candidates[0] if candidates else None
126
+
127
+
128
+ # ── Phrase impact scoring ─────────────────────────────────────────
129
+
130
+
131
+ def score_phrase_impact(
132
+ section_data: dict,
133
+ target: str = "hook",
134
+ song_brain: Optional[dict] = None,
135
+ prev_section: Optional[dict] = None,
136
+ ) -> PhraseImpact:
137
+ """Score the emotional impact of a musical phrase/section.
138
+
139
+ Uses contrast, density shift, harmonic support, and energy
140
+ to judge whether the phrase "lands" emotionally.
141
+ """
142
+ song_brain = song_brain or {}
143
+ prev_section = prev_section or {}
144
+
145
+ energy = section_data.get("energy", 0.5)
146
+ prev_energy = prev_section.get("energy", 0.5)
147
+ density = section_data.get("density", 0.5)
148
+ prev_density = prev_section.get("density", 0.5)
149
+
150
+ # Arrival: big energy jump = strong arrival
151
+ energy_delta = energy - prev_energy
152
+ arrival = min(1.0, max(0.0, energy_delta * 2 + 0.3))
153
+
154
+ # Anticipation: was there a dip before?
155
+ anticipation = min(1.0, max(0.0, (0.5 - prev_energy) * 2)) if prev_energy < 0.5 else 0.2
156
+
157
+ # Contrast: density or energy change
158
+ contrast = min(1.0, abs(density - prev_density) + abs(energy_delta))
159
+
160
+ # Repetition fatigue: high density with no change = fatiguing
161
+ fatigue = max(0.0, 1.0 - contrast) * 0.5
162
+
163
+ # Section clarity: does it have a clear role?
164
+ clarity = 0.7 if section_data.get("label") else 0.3
165
+
166
+ # Groove continuity: rhythm present
167
+ groove = 0.7 if section_data.get("has_drums", True) else 0.3
168
+
169
+ # Payoff balance
170
+ payoff = min(1.0, (arrival + anticipation) / 2)
171
+
172
+ # Composite — target-specific weighting
173
+ weights = _get_target_weights(target)
174
+ composite = (
175
+ arrival * weights.get("arrival", 0.2)
176
+ + anticipation * weights.get("anticipation", 0.15)
177
+ + contrast * weights.get("contrast", 0.2)
178
+ + (1.0 - fatigue) * weights.get("freshness", 0.1)
179
+ + clarity * weights.get("clarity", 0.1)
180
+ + groove * weights.get("groove", 0.1)
181
+ + payoff * weights.get("payoff", 0.15)
182
+ )
183
+
184
+ section_id = section_data.get("id", section_data.get("name", ""))
185
+
186
+ return PhraseImpact(
187
+ phrase_id=f"phrase_{hashlib.sha256(str(section_id).encode()).hexdigest()[:8]}",
188
+ target=target,
189
+ arrival_strength=round(arrival, 3),
190
+ anticipation_strength=round(anticipation, 3),
191
+ contrast_quality=round(contrast, 3),
192
+ repetition_fatigue=round(fatigue, 3),
193
+ section_clarity=round(clarity, 3),
194
+ groove_continuity=round(groove, 3),
195
+ payoff_balance=round(payoff, 3),
196
+ composite_impact=round(composite, 3),
197
+ )
198
+
199
+
200
+ def compare_phrase_impacts(
201
+ impacts: list[PhraseImpact],
202
+ ) -> list[dict]:
203
+ """Rank multiple phrase impacts by composite score."""
204
+ ranked = sorted(impacts, key=lambda i: i.composite_impact, reverse=True)
205
+ return [
206
+ {
207
+ "rank": idx + 1,
208
+ "phrase_id": imp.phrase_id,
209
+ "target": imp.target,
210
+ "composite_impact": imp.composite_impact,
211
+ "arrival_strength": imp.arrival_strength,
212
+ "contrast_quality": imp.contrast_quality,
213
+ }
214
+ for idx, imp in enumerate(ranked)
215
+ ]
216
+
217
+
218
+ # ── Payoff failure detection ─────────────────────────────────────
219
+
220
+
221
+ def detect_payoff_failures(
222
+ sections: list[dict],
223
+ song_brain: Optional[dict] = None,
224
+ ) -> list[PayoffFailure]:
225
+ """Detect sections that should deliver a payoff but don't."""
226
+ song_brain = song_brain or {}
227
+ payoff_targets = song_brain.get("payoff_targets", [])
228
+ failures: list[PayoffFailure] = []
229
+
230
+ for i, section in enumerate(sections):
231
+ section_id = section.get("id", section.get("name", f"section_{i}"))
232
+ label = section.get("label", "").lower()
233
+ energy = section.get("energy", 0.5)
234
+ prev_energy = sections[i - 1].get("energy", 0.5) if i > 0 else 0.3
235
+
236
+ is_payoff = (
237
+ section_id in payoff_targets
238
+ or label in ("chorus", "drop", "hook")
239
+ or section.get("is_payoff", False)
240
+ )
241
+
242
+ if not is_payoff:
243
+ continue
244
+
245
+ # Check for flat arrival (no energy increase)
246
+ if energy - prev_energy < 0.1:
247
+ failures.append(PayoffFailure(
248
+ section_id=section_id,
249
+ expected_target=label or "payoff",
250
+ failure_type="flat_arrival",
251
+ severity=0.6,
252
+ suggestion="Increase energy contrast — try subtracting before the payoff section",
253
+ ))
254
+
255
+ # Check for weak contrast (only if flat_arrival didn't already fire)
256
+ elif i > 0 and abs(energy - prev_energy) < 0.05:
257
+ failures.append(PayoffFailure(
258
+ section_id=section_id,
259
+ expected_target=label or "payoff",
260
+ failure_type="weak_contrast",
261
+ severity=0.5,
262
+ suggestion="Add density or timbral contrast leading into this section",
263
+ ))
264
+
265
+ return failures
266
+
267
+
268
+ def suggest_payoff_repairs(
269
+ failures: list[PayoffFailure],
270
+ ) -> list[dict]:
271
+ """Generate repair suggestions for payoff failures."""
272
+ repairs = []
273
+ for f in failures:
274
+ repair = {
275
+ "section_id": f.section_id,
276
+ "failure_type": f.failure_type,
277
+ "severity": f.severity,
278
+ "suggestion": f.suggestion,
279
+ }
280
+
281
+ # Add specific repair strategies
282
+ if f.failure_type == "flat_arrival":
283
+ repair["strategies"] = [
284
+ "Add a 2-4 bar breakdown before this section",
285
+ "Use a filter sweep or riser to build anticipation",
286
+ "Strip elements in the preceding section to create contrast",
287
+ ]
288
+ elif f.failure_type == "weak_contrast":
289
+ repair["strategies"] = [
290
+ "Increase track count or add a new element at the payoff",
291
+ "Change the harmonic content (key change, chord substitution)",
292
+ "Add rhythmic variation (double-time feel, new percussion)",
293
+ ]
294
+ elif f.failure_type == "no_setup":
295
+ repair["strategies"] = [
296
+ "Add a buildup section before the payoff",
297
+ "Use automation to create a gradual energy ramp",
298
+ ]
299
+ else:
300
+ repair["strategies"] = [f.suggestion]
301
+
302
+ repairs.append(repair)
303
+
304
+ return repairs
305
+
306
+
307
+ # ── Helpers ───────────────────────────────────────────────────────
308
+
309
+
310
+ def _compute_salience(c: HookCandidate) -> float:
311
+ """Compute composite salience score for a hook candidate."""
312
+ return round(
313
+ c.memorability * 0.35
314
+ + c.recurrence * 0.25
315
+ + c.contrast_potential * 0.2
316
+ + c.development_potential * 0.2,
317
+ 3,
318
+ )
319
+
320
+
321
+ def _estimate_development_potential(motif: dict) -> float:
322
+ """Estimate how much room a motif has for development."""
323
+ # Simple heuristic: shorter motifs have more development room
324
+ length = motif.get("length_beats", 4)
325
+ if length <= 2:
326
+ return 0.8
327
+ elif length <= 4:
328
+ return 0.6
329
+ elif length <= 8:
330
+ return 0.4
331
+ return 0.3
332
+
333
+
334
+ def _measure_clip_reuse(scene_data: list[dict], target_tracks: list[dict]) -> float:
335
+ """Measure how much clips are reused across scenes for target tracks."""
336
+ if not scene_data:
337
+ return 0.0
338
+
339
+ target_names = {t.get("name", "").lower() for t in target_tracks}
340
+ clip_names = Counter()
341
+
342
+ for scene in scene_data:
343
+ for clip in scene.get("clips", []):
344
+ clip_name = clip.get("name", "") if isinstance(clip, dict) else str(clip)
345
+ track_name = clip.get("track", "") if isinstance(clip, dict) else ""
346
+ if track_name.lower() in target_names and clip_name:
347
+ clip_names[clip_name] += 1
348
+
349
+ if not clip_names:
350
+ return 0.0
351
+
352
+ max_reuse = max(clip_names.values())
353
+ return min(1.0, max_reuse / max(len(scene_data), 1))
354
+
355
+
356
+ def _get_target_weights(target: str) -> dict:
357
+ """Get scoring weights based on target type."""
358
+ presets = {
359
+ "hook": {"arrival": 0.15, "anticipation": 0.1, "contrast": 0.2, "freshness": 0.15, "clarity": 0.1, "groove": 0.1, "payoff": 0.2},
360
+ "drop": {"arrival": 0.3, "anticipation": 0.2, "contrast": 0.2, "freshness": 0.05, "clarity": 0.05, "groove": 0.1, "payoff": 0.1},
361
+ "chorus": {"arrival": 0.2, "anticipation": 0.15, "contrast": 0.15, "freshness": 0.1, "clarity": 0.15, "groove": 0.1, "payoff": 0.15},
362
+ "transition": {"arrival": 0.1, "anticipation": 0.1, "contrast": 0.3, "freshness": 0.1, "clarity": 0.1, "groove": 0.15, "payoff": 0.15},
363
+ "loop": {"arrival": 0.05, "anticipation": 0.05, "contrast": 0.1, "freshness": 0.25, "clarity": 0.1, "groove": 0.3, "payoff": 0.15},
364
+ }
365
+ return presets.get(target, presets["hook"])
@@ -0,0 +1,58 @@
1
+ """Hook Hunter data models — pure dataclasses, zero I/O."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict, dataclass, field
6
+ from typing import Optional
7
+
8
+
9
+ @dataclass
10
+ class HookCandidate:
11
+ """A potential hook element in the track."""
12
+
13
+ hook_id: str = ""
14
+ hook_type: str = "" # "melodic", "rhythmic", "timbral", "harmonic", "textural"
15
+ description: str = ""
16
+ location: str = "" # track/clip reference
17
+ memorability: float = 0.0 # 0-1 how catchy/memorable
18
+ recurrence: float = 0.0 # 0-1 how often it appears
19
+ contrast_potential: float = 0.0 # 0-1 how well it stands out
20
+ development_potential: float = 0.0 # 0-1 how much room to develop
21
+ salience: float = 0.0 # composite score
22
+ evidence_sources: list[str] = field(default_factory=list) # what data informed this
23
+
24
+ def to_dict(self) -> dict:
25
+ return asdict(self)
26
+
27
+
28
+ @dataclass
29
+ class PhraseImpact:
30
+ """Phrase-level emotional impact scoring."""
31
+
32
+ phrase_id: str = ""
33
+ target: str = "" # "hook", "drop", "chorus", "transition", "loop"
34
+ arrival_strength: float = 0.0 # 0-1 does it feel like an arrival?
35
+ anticipation_strength: float = 0.0 # 0-1 does the setup work?
36
+ contrast_quality: float = 0.0 # 0-1 is there enough change?
37
+ repetition_fatigue: float = 0.0 # 0-1 is it overused?
38
+ section_clarity: float = 0.0 # 0-1 is the section role clear?
39
+ groove_continuity: float = 0.0 # 0-1 does the groove carry through?
40
+ payoff_balance: float = 0.0 # 0-1 setup vs payoff balance
41
+ composite_impact: float = 0.0 # weighted aggregate
42
+
43
+ def to_dict(self) -> dict:
44
+ return asdict(self)
45
+
46
+
47
+ @dataclass
48
+ class PayoffFailure:
49
+ """A detected payoff failure — where the song should deliver but doesn't."""
50
+
51
+ section_id: str = ""
52
+ expected_target: str = "" # "drop", "chorus", "hook"
53
+ failure_type: str = "" # "flat_arrival", "weak_contrast", "no_setup", "hook_absent"
54
+ severity: float = 0.0 # 0-1
55
+ suggestion: str = ""
56
+
57
+ def to_dict(self) -> dict:
58
+ return asdict(self)