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,434 @@
1
+ """Musical intelligence detectors — pure computation, no I/O.
2
+
3
+ Each detector takes session data dicts and returns structured findings.
4
+ These feed into arrangement, transition, and diagnostic workflows.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections import Counter, defaultdict
10
+ from dataclasses import dataclass, field
11
+ from typing import Optional
12
+
13
+
14
+ # ═══════════════════════════════════════════════════════════════════════
15
+ # Repetition Fatigue
16
+ # ═══════════════════════════════════════════════════════════════════════
17
+
18
+ @dataclass
19
+ class FatigueReport:
20
+ """Report on repetition fatigue across the arrangement."""
21
+ fatigue_level: float = 0.0 # 0 = fresh, 1 = extremely fatigued
22
+ issues: list[dict] = field(default_factory=list)
23
+ section_staleness: dict[str, float] = field(default_factory=dict)
24
+ recommendations: list[str] = field(default_factory=list)
25
+
26
+ def to_dict(self) -> dict:
27
+ return {
28
+ "fatigue_level": round(self.fatigue_level, 3),
29
+ "issue_count": len(self.issues),
30
+ "issues": self.issues,
31
+ "section_staleness": self.section_staleness,
32
+ "recommendations": self.recommendations,
33
+ }
34
+
35
+
36
+ def detect_repetition_fatigue(
37
+ scenes: list[dict],
38
+ motif_graph: Optional[dict] = None,
39
+ ) -> FatigueReport:
40
+ """Detect repetition fatigue from scene/clip data.
41
+
42
+ Analyzes:
43
+ - How many scenes share the same clips (pattern reuse)
44
+ - Motif overuse from motif_graph if available
45
+ - Density stability (everything at same level = fatiguing)
46
+
47
+ scenes: list of scene dicts with clip names per track
48
+ motif_graph: optional output from get_motif_graph
49
+ """
50
+ report = FatigueReport()
51
+
52
+ if not scenes:
53
+ return report
54
+
55
+ # 1. Clip reuse across scenes
56
+ clip_usage = Counter()
57
+ for scene in scenes:
58
+ clips = scene.get("clips", [])
59
+ if isinstance(clips, list):
60
+ for clip in clips:
61
+ name = clip.get("name", "") if isinstance(clip, dict) else str(clip)
62
+ if name:
63
+ clip_usage[name] += 1
64
+
65
+ overused = {name: count for name, count in clip_usage.items() if count >= 3}
66
+ if overused:
67
+ report.issues.append({
68
+ "type": "clip_overuse",
69
+ "severity": min(0.8, len(overused) * 0.15),
70
+ "detail": f"{len(overused)} clip(s) used 3+ times",
71
+ "clips": dict(overused),
72
+ })
73
+
74
+ # 2. Scene similarity (how many scenes have identical clip sets)
75
+ scene_fingerprints = []
76
+ for scene in scenes:
77
+ clips = scene.get("clips", [])
78
+ names = sorted(
79
+ (c.get("name", "") if isinstance(c, dict) else str(c))
80
+ for c in (clips if isinstance(clips, list) else [])
81
+ if (c.get("name", "") if isinstance(c, dict) else str(c))
82
+ )
83
+ scene_fingerprints.append(tuple(names))
84
+
85
+ duplicate_scenes = sum(
86
+ 1 for i, fp in enumerate(scene_fingerprints)
87
+ if fp and scene_fingerprints.index(fp) != i
88
+ )
89
+ if duplicate_scenes > 0:
90
+ report.issues.append({
91
+ "type": "duplicate_scenes",
92
+ "severity": min(0.7, duplicate_scenes * 0.2),
93
+ "detail": f"{duplicate_scenes} scene(s) are identical to earlier ones",
94
+ })
95
+
96
+ # 3. Motif fatigue from motif_graph
97
+ if motif_graph:
98
+ motifs = motif_graph.get("motifs", [])
99
+ num_sections = max(1, len(scenes))
100
+ for motif in motifs:
101
+ fatigue_risk = motif.get("fatigue_risk", 0)
102
+ recurrence = motif.get("recurrence", 0)
103
+
104
+ # Motif appearing in >60% of sections = fatigue signal
105
+ if recurrence > 0.6 and num_sections >= 3:
106
+ adjusted_fatigue = max(fatigue_risk, recurrence * 0.8)
107
+ report.issues.append({
108
+ "type": "motif_overuse",
109
+ "severity": round(adjusted_fatigue, 3),
110
+ "detail": f"Motif {motif.get('name', motif.get('motif_id', '?'))} appears in {recurrence:.0%} of sections",
111
+ "motif_id": motif.get("motif_id", motif.get("name", "")),
112
+ "evidence": "motif_recurrence",
113
+ })
114
+ elif fatigue_risk > 0.6:
115
+ report.issues.append({
116
+ "type": "motif_overuse",
117
+ "severity": fatigue_risk,
118
+ "detail": f"Motif {motif.get('motif_id', '?')} fatigue risk {fatigue_risk:.2f}",
119
+ "motif_id": motif.get("motif_id"),
120
+ })
121
+
122
+ # 4. Section staleness (per named scene)
123
+ for i, scene in enumerate(scenes):
124
+ name = scene.get("name", f"Scene {i}")
125
+ if not name:
126
+ continue
127
+ clips = scene.get("clips", [])
128
+ clip_names = [
129
+ (c.get("name", "") if isinstance(c, dict) else "")
130
+ for c in (clips if isinstance(clips, list) else [])
131
+ ]
132
+ reuse_count = sum(clip_usage.get(n, 0) for n in clip_names if n)
133
+ total = max(1, len([n for n in clip_names if n]))
134
+ staleness = min(1.0, (reuse_count / total - 1) * 0.3) if total else 0
135
+ report.section_staleness[name] = round(max(0, staleness), 3)
136
+
137
+ # Overall fatigue level
138
+ if report.issues:
139
+ report.fatigue_level = min(1.0, sum(i["severity"] for i in report.issues) / max(1, len(report.issues)))
140
+
141
+ # Recommendations
142
+ if report.fatigue_level > 0.5:
143
+ report.recommendations.append("Add variation clips to overused patterns")
144
+ report.recommendations.append("Use transform_motif (inversion, retrograde) to refresh stale melodic ideas")
145
+ if duplicate_scenes > 1:
146
+ report.recommendations.append("Create unique clip variations for duplicate scenes")
147
+ if report.fatigue_level > 0.3:
148
+ report.recommendations.append("Add perlin automation for organic movement within loops")
149
+
150
+ return report
151
+
152
+
153
+ # ═══════════════════════════════════════════════════════════════════════
154
+ # Role Conflict Detection
155
+ # ═══════════════════════════════════════════════════════════════════════
156
+
157
+ @dataclass
158
+ class RoleConflict:
159
+ """A detected conflict where multiple tracks compete for the same musical role."""
160
+ role: str
161
+ tracks: list[dict] # [{index, name}]
162
+ severity: float = 0.0
163
+ recommendation: str = ""
164
+
165
+ def to_dict(self) -> dict:
166
+ return {
167
+ "role": self.role,
168
+ "tracks": self.tracks,
169
+ "severity": round(self.severity, 3),
170
+ "recommendation": self.recommendation,
171
+ }
172
+
173
+
174
+ def detect_role_conflicts(
175
+ tracks: list[dict],
176
+ role_fn=None,
177
+ ) -> list[RoleConflict]:
178
+ """Detect tracks competing for the same musical role.
179
+
180
+ Roles that should be unique: sub_anchor (only 1 bass), foreground (only 1 lead),
181
+ transient_anchor (only 1 main drum track).
182
+
183
+ tracks: list of track dicts with at least 'name' and 'index'
184
+ role_fn: optional function(track_name) -> role_str
185
+ """
186
+ if role_fn is None:
187
+ from ..semantic_moves.resolvers import infer_role
188
+ role_fn = infer_role
189
+
190
+ # Group tracks by role
191
+ role_groups: dict[str, list[dict]] = defaultdict(list)
192
+ for track in tracks:
193
+ name = track.get("name", "")
194
+ role = role_fn(name)
195
+ if role != "unknown":
196
+ role_groups[role].append({
197
+ "index": track.get("index", 0),
198
+ "name": name,
199
+ })
200
+
201
+ # Roles that should be unique (1 track only)
202
+ UNIQUE_ROLES = {
203
+ "bass": ("Sub/bass conflict — multiple bass tracks compete for the low end",
204
+ "Consider merging bass parts or using EQ to give each a distinct range"),
205
+ "lead": ("Lead conflict — multiple foreground melodies compete for attention",
206
+ "Mute one lead or use arrangement to alternate them across sections"),
207
+ "drums": ("Drum conflict — multiple drum tracks may mask each other's transients",
208
+ "Layer drum parts into one Drum Rack or pan them apart"),
209
+ }
210
+
211
+ conflicts = []
212
+ for role, (desc, rec) in UNIQUE_ROLES.items():
213
+ group = role_groups.get(role, [])
214
+ if len(group) > 1:
215
+ severity = min(0.9, 0.3 + (len(group) - 1) * 0.2)
216
+ conflicts.append(RoleConflict(
217
+ role=role,
218
+ tracks=group,
219
+ severity=severity,
220
+ recommendation=rec,
221
+ ))
222
+
223
+ # Check for missing essential roles
224
+ essential = {"bass", "drums"}
225
+ for role in essential:
226
+ if role not in role_groups:
227
+ conflicts.append(RoleConflict(
228
+ role=role,
229
+ tracks=[],
230
+ severity=0.3,
231
+ recommendation=f"No {role} track detected — the mix may lack foundation",
232
+ ))
233
+
234
+ return conflicts
235
+
236
+
237
+ # ═══════════════════════════════════════════════════════════════════════
238
+ # Section Purpose Inference
239
+ # ═══════════════════════════════════════════════════════════════════════
240
+
241
+ @dataclass
242
+ class SectionPurpose:
243
+ """Inferred musical purpose of a section/scene."""
244
+ name: str
245
+ purpose: str # setup | tension | payoff | contrast | release | outro | unknown
246
+ energy: float = 0.0 # 0-1
247
+ density: float = 0.0 # 0-1 (how many tracks are active)
248
+ confidence: float = 0.5
249
+
250
+ def to_dict(self) -> dict:
251
+ return {
252
+ "name": self.name,
253
+ "purpose": self.purpose,
254
+ "energy": round(self.energy, 3),
255
+ "density": round(self.density, 3),
256
+ "confidence": round(self.confidence, 3),
257
+ }
258
+
259
+
260
+ def infer_section_purposes(
261
+ scenes: list[dict],
262
+ total_tracks: int = 6,
263
+ ) -> list[SectionPurpose]:
264
+ """Infer the musical purpose of each scene based on density and position.
265
+
266
+ Uses heuristics:
267
+ - Low density at start → setup/intro
268
+ - Increasing density → tension/build
269
+ - Maximum density → payoff/drop
270
+ - Sudden density drop → contrast/breakdown
271
+ - Low density at end → release/outro
272
+ - Decreasing density → outro/dissolve
273
+
274
+ scenes: list of scene dicts with name and clip count
275
+ total_tracks: total track count for density calculation
276
+ """
277
+ if not scenes:
278
+ return []
279
+
280
+ # Calculate density for each scene
281
+ densities = []
282
+ for scene in scenes:
283
+ clips = scene.get("clips", [])
284
+ if isinstance(clips, list):
285
+ active = sum(1 for c in clips
286
+ if isinstance(c, dict) and c.get("state") not in ("empty", None))
287
+ else:
288
+ active = 0
289
+ density = active / max(1, total_tracks)
290
+ densities.append(density)
291
+
292
+ results = []
293
+ n = len(scenes)
294
+
295
+ for i, scene in enumerate(scenes):
296
+ name = scene.get("name", f"Scene {i}")
297
+ density = densities[i]
298
+ position = i / max(1, n - 1) # 0 = first, 1 = last
299
+
300
+ # Density change from previous
301
+ prev_density = densities[i - 1] if i > 0 else 0
302
+ density_delta = density - prev_density
303
+
304
+ # Infer purpose
305
+ if position < 0.15 and density < 0.5:
306
+ purpose = "setup"
307
+ confidence = 0.7
308
+ elif density_delta > 0.2:
309
+ purpose = "tension"
310
+ confidence = 0.6
311
+ elif density >= 0.8:
312
+ purpose = "payoff"
313
+ confidence = 0.65
314
+ elif density_delta < -0.3:
315
+ purpose = "contrast"
316
+ confidence = 0.6
317
+ elif position > 0.8 and density < 0.5:
318
+ purpose = "release"
319
+ confidence = 0.65
320
+ elif position > 0.85 and density_delta < 0:
321
+ purpose = "outro"
322
+ confidence = 0.6
323
+ else:
324
+ purpose = "development"
325
+ confidence = 0.4
326
+
327
+ results.append(SectionPurpose(
328
+ name=name,
329
+ purpose=purpose,
330
+ energy=density,
331
+ density=density,
332
+ confidence=confidence,
333
+ ))
334
+
335
+ return results
336
+
337
+
338
+ # ═══════════════════════════════════════════════════════════════════════
339
+ # Emotional Arc Scoring
340
+ # ═══════════════════════════════════════════════════════════════════════
341
+
342
+ @dataclass
343
+ class ArcScore:
344
+ """Score for the overall emotional arc of the arrangement."""
345
+ arc_clarity: float = 0.0 # How clear is the build → climax → resolve?
346
+ contrast: float = 0.0 # How different are sections from each other?
347
+ payoff_strength: float = 0.0 # Does the climax feel earned?
348
+ resolution: float = 0.0 # Does the ending resolve tension?
349
+ issues: list[str] = field(default_factory=list)
350
+
351
+ @property
352
+ def overall(self) -> float:
353
+ return round(
354
+ (self.arc_clarity + self.contrast + self.payoff_strength + self.resolution) / 4, 3
355
+ )
356
+
357
+ def to_dict(self) -> dict:
358
+ return {
359
+ "overall": self.overall,
360
+ "arc_clarity": round(self.arc_clarity, 3),
361
+ "contrast": round(self.contrast, 3),
362
+ "payoff_strength": round(self.payoff_strength, 3),
363
+ "resolution": round(self.resolution, 3),
364
+ "issues": self.issues,
365
+ }
366
+
367
+
368
+ def score_emotional_arc(sections: list[SectionPurpose]) -> ArcScore:
369
+ """Score the emotional arc from inferred section purposes.
370
+
371
+ Checks for:
372
+ - Build before payoff (tension should precede climax)
373
+ - Variety of purposes (not all the same energy level)
374
+ - Resolution at the end (shouldn't end at peak tension)
375
+ - Clear climax point (should have at least one payoff section)
376
+ """
377
+ score = ArcScore()
378
+
379
+ if not sections:
380
+ score.issues.append("No sections to analyze")
381
+ return score
382
+
383
+ purposes = [s.purpose for s in sections]
384
+ energies = [s.energy for s in sections]
385
+
386
+ # Arc clarity: do we have a clear build → peak → resolve shape?
387
+ has_setup = "setup" in purposes
388
+ has_tension = "tension" in purposes
389
+ has_payoff = "payoff" in purposes
390
+ has_release = "release" in purposes or "outro" in purposes
391
+
392
+ clarity_points = sum([has_setup, has_tension, has_payoff, has_release])
393
+ score.arc_clarity = clarity_points / 4
394
+
395
+ if not has_payoff:
396
+ score.issues.append("No clear climax/payoff section")
397
+ if not has_setup and not has_tension:
398
+ score.issues.append("No build — payoff arrives without anticipation")
399
+
400
+ # Contrast: how different are sections?
401
+ if len(energies) >= 2:
402
+ energy_range = max(energies) - min(energies)
403
+ score.contrast = min(1.0, energy_range * 1.5)
404
+ if energy_range < 0.2:
405
+ score.issues.append("Low contrast — sections are too similar in energy")
406
+ else:
407
+ score.contrast = 0.0
408
+
409
+ # Payoff strength: does tension precede the peak?
410
+ if has_payoff:
411
+ payoff_idx = purposes.index("payoff")
412
+ if payoff_idx > 0 and sections[payoff_idx - 1].energy < sections[payoff_idx].energy:
413
+ score.payoff_strength = 0.8
414
+ else:
415
+ score.payoff_strength = 0.4
416
+ score.issues.append("Payoff doesn't feel earned — no energy build before it")
417
+ else:
418
+ score.payoff_strength = 0.0
419
+
420
+ # Resolution: does energy decrease at the end?
421
+ if len(energies) >= 3:
422
+ final_energy = energies[-1]
423
+ peak_energy = max(energies)
424
+ if final_energy < peak_energy * 0.7:
425
+ score.resolution = 0.8
426
+ elif final_energy < peak_energy:
427
+ score.resolution = 0.5
428
+ else:
429
+ score.resolution = 0.2
430
+ score.issues.append("No resolution — ending at or near peak energy")
431
+ else:
432
+ score.resolution = 0.3
433
+
434
+ return score
@@ -0,0 +1,163 @@
1
+ """Phrase-level evaluation — judges musical phrases, not just parameter deltas.
2
+
3
+ Operates on 8-16 bar windows. Analyzes arc clarity, contrast, fatigue risk,
4
+ payoff strength, and translation risk from audio captures and spectral data.
5
+
6
+ Pure computation — receives analysis data, returns structured critique.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from typing import Optional
13
+
14
+
15
+ @dataclass
16
+ class PhraseCritique:
17
+ """Evaluation of a rendered musical phrase."""
18
+ render_id: str = ""
19
+ arc_clarity: float = 0.0 # How clear is the phrase's tension shape?
20
+ contrast: float = 0.0 # How different are the beginning and end?
21
+ fatigue_risk: float = 0.0 # How repetitive is the material?
22
+ payoff_strength: float = 0.0 # Does the phrase deliver on its promise?
23
+ identity_strength: float = 0.0 # How distinct is this from other phrases?
24
+ translation_risk: float = 0.0 # How likely to sound bad on small speakers?
25
+ notes: list[str] = field(default_factory=list)
26
+
27
+ @property
28
+ def overall(self) -> float:
29
+ scores = [
30
+ self.arc_clarity,
31
+ self.contrast,
32
+ 1.0 - self.fatigue_risk,
33
+ self.payoff_strength,
34
+ self.identity_strength,
35
+ 1.0 - self.translation_risk,
36
+ ]
37
+ return round(sum(scores) / len(scores), 3)
38
+
39
+ def to_dict(self) -> dict:
40
+ return {
41
+ "render_id": self.render_id,
42
+ "overall": self.overall,
43
+ "arc_clarity": round(self.arc_clarity, 3),
44
+ "contrast": round(self.contrast, 3),
45
+ "fatigue_risk": round(self.fatigue_risk, 3),
46
+ "payoff_strength": round(self.payoff_strength, 3),
47
+ "identity_strength": round(self.identity_strength, 3),
48
+ "translation_risk": round(self.translation_risk, 3),
49
+ "notes": self.notes,
50
+ }
51
+
52
+
53
+ def analyze_phrase(
54
+ loudness_data: Optional[dict] = None,
55
+ spectrum_data: Optional[dict] = None,
56
+ target: str = "loop",
57
+ ) -> PhraseCritique:
58
+ """Analyze a captured phrase from loudness and spectral data.
59
+
60
+ loudness_data: output from analyze_loudness (LUFS, LRA, peak, short_term_lufs)
61
+ spectrum_data: output from analyze_spectrum_offline (centroid, rolloff, balance)
62
+ target: what the phrase is supposed to be: "loop", "drop", "chorus", "transition", "intro", "outro"
63
+ """
64
+ critique = PhraseCritique()
65
+
66
+ if not loudness_data and not spectrum_data:
67
+ critique.notes.append("No analysis data — capture audio first")
68
+ return critique
69
+
70
+ # Arc clarity from short-term LUFS variation
71
+ if loudness_data:
72
+ stl = loudness_data.get("short_term_lufs", [])
73
+ if len(stl) >= 3:
74
+ lufs_range = max(stl) - min(stl)
75
+ # Good arc = variation between 2-8 LU
76
+ if 2 <= lufs_range <= 8:
77
+ critique.arc_clarity = 0.8
78
+ elif lufs_range > 8:
79
+ critique.arc_clarity = 0.5
80
+ critique.notes.append("Loudness variation too extreme — may feel chaotic")
81
+ else:
82
+ critique.arc_clarity = 0.3 + lufs_range * 0.1
83
+ if lufs_range < 1:
84
+ critique.notes.append("Very flat dynamics — phrase sounds static")
85
+
86
+ # Fatigue risk from LRA
87
+ lra = loudness_data.get("lra_lu", 0)
88
+ if lra < 1:
89
+ critique.fatigue_risk = 0.8
90
+ critique.notes.append(f"LRA {lra:.1f} LU — extremely repetitive")
91
+ elif lra < 3:
92
+ critique.fatigue_risk = 0.5
93
+ else:
94
+ critique.fatigue_risk = max(0, 0.3 - lra * 0.03)
95
+
96
+ # Translation risk from true peak
97
+ peak = loudness_data.get("true_peak_dbtp", 0)
98
+ if peak > -1:
99
+ critique.translation_risk = 0.7
100
+ critique.notes.append(f"True peak {peak:.1f} dBTP — clipping risk on playback")
101
+ elif peak > -3:
102
+ critique.translation_risk = 0.3
103
+ else:
104
+ critique.translation_risk = 0.1
105
+
106
+ # Spectral analysis
107
+ if spectrum_data:
108
+ balance = spectrum_data.get("band_balance", {})
109
+ sub = balance.get("sub_60hz", 0)
110
+ mid = balance.get("mid_2khz", 0)
111
+ high = balance.get("high_8khz", 0)
112
+
113
+ # Identity strength: how distinctive is the spectral shape?
114
+ if sub > 0.5:
115
+ critique.identity_strength = 0.6
116
+ critique.notes.append("Sub-heavy identity — bass-driven phrase")
117
+ elif mid > 0.5:
118
+ critique.identity_strength = 0.7
119
+ critique.notes.append("Mid-focused — melodic/harmonic identity")
120
+ elif high > 0.3:
121
+ critique.identity_strength = 0.5
122
+ critique.notes.append("Bright character — texture-driven")
123
+ else:
124
+ critique.identity_strength = 0.4
125
+
126
+ # Contrast from centroid
127
+ centroid = spectrum_data.get("centroid_hz", 500)
128
+ if centroid < 200:
129
+ critique.contrast = 0.3
130
+ critique.notes.append("Very dark — limited spectral contrast")
131
+ elif centroid > 2000:
132
+ critique.contrast = 0.6
133
+ else:
134
+ critique.contrast = 0.5
135
+
136
+ # Payoff strength depends on target type
137
+ _payoff_targets = {
138
+ "drop": 0.8, # Drops need high payoff
139
+ "chorus": 0.7, # Choruses need good payoff
140
+ "loop": 0.5, # Loops are neutral
141
+ "transition": 0.4,
142
+ "intro": 0.3,
143
+ "outro": 0.3,
144
+ }
145
+ critique.payoff_strength = _payoff_targets.get(target, 0.5)
146
+
147
+ return critique
148
+
149
+
150
+ def compare_phrases(critiques: list[PhraseCritique]) -> list[dict]:
151
+ """Rank multiple phrase critiques by overall score."""
152
+ ranked = sorted(critiques, key=lambda c: -c.overall)
153
+ return [
154
+ {
155
+ "rank": i + 1,
156
+ "render_id": c.render_id,
157
+ "overall": c.overall,
158
+ "arc_clarity": c.arc_clarity,
159
+ "fatigue_risk": c.fatigue_risk,
160
+ "notes": c.notes[:3],
161
+ }
162
+ for i, c in enumerate(ranked)
163
+ ]