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,588 @@
1
+ """Hook Hunter MCP tools — 9 tools for hook and phrase intelligence.
2
+
3
+ find_primary_hook — detect the most salient hook in the session
4
+ rank_hook_candidates — list and rank all hook candidates
5
+ develop_hook — suggest development strategies for a hook
6
+ measure_hook_salience — score a specific hook's salience
7
+ score_phrase_impact — score a section's emotional landing
8
+ detect_payoff_failure — find where the song should deliver but doesn't
9
+ suggest_payoff_repair — generate repair strategies for payoff failures
10
+ detect_hook_neglect — check if a strong hook is underused across sections
11
+ compare_phrase_impact — compare emotional impact across multiple sections
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from fastmcp import Context
17
+
18
+ from ..server import mcp
19
+ from . import analyzer
20
+
21
+
22
+ def _get_ableton(ctx: Context):
23
+ return ctx.lifespan_context["ableton"]
24
+
25
+
26
+ def _fetch_tracks_and_scenes(ctx: Context) -> tuple[list[dict], list[dict], dict]:
27
+ """Fetch tracks, scenes, and motif data from Ableton.
28
+
29
+ Motif data comes from the motif engine (get_motif_graph). When available,
30
+ it enables the analyzer's strongest path: motif recurrence and salience
31
+ scoring. Falls back to track-name + clip-reuse heuristics when no
32
+ motif data exists (e.g., clips have no MIDI notes).
33
+ """
34
+ ableton = _get_ableton(ctx)
35
+ tracks: list[dict] = []
36
+ scenes: list[dict] = []
37
+ motif_data: dict = {}
38
+
39
+ try:
40
+ session = ableton.send_command("get_session_info", {})
41
+ tracks = session.get("tracks", [])
42
+ except Exception:
43
+ pass
44
+
45
+ try:
46
+ matrix = ableton.send_command("get_scene_matrix")
47
+ scenes = [
48
+ {"name": s.get("name", f"Scene {i}"), "clips": row}
49
+ for i, (s, row) in enumerate(
50
+ zip(matrix.get("scenes", []), matrix.get("matrix", []))
51
+ )
52
+ ]
53
+ except Exception:
54
+ pass
55
+
56
+ # Fetch motif data — via shared motif service
57
+ try:
58
+ from ..services.motif_service import get_motif_data, fetch_notes_from_ableton
59
+ notes_by_track = fetch_notes_from_ableton(ableton, tracks)
60
+ motif_data = get_motif_data(notes_by_track)
61
+ except Exception:
62
+ pass # Motif graph requires notes in clips; empty dict is valid fallback
63
+
64
+ return tracks, scenes, motif_data
65
+
66
+
67
+ @mcp.tool()
68
+ def find_primary_hook(ctx: Context) -> dict:
69
+ """Find the most salient hook in the current session.
70
+
71
+ Analyzes melodic motifs, distinctive rhythmic cells, and signature
72
+ textures to identify what the track is most "about."
73
+
74
+ Returns the primary hook with salience scores, or a note if no
75
+ clear hook is detected.
76
+ """
77
+ tracks, scenes, motif_data = _fetch_tracks_and_scenes(ctx)
78
+
79
+ hook = analyzer.find_primary_hook(tracks, motif_data, scenes)
80
+ if hook:
81
+ return {
82
+ "found": True,
83
+ **hook.to_dict(),
84
+ }
85
+
86
+ return {
87
+ "found": False,
88
+ "note": "No clear primary hook detected — consider developing a defining element",
89
+ "suggestion": "Try creating a memorable melodic phrase, distinctive rhythm, or signature texture",
90
+ }
91
+
92
+
93
+ @mcp.tool()
94
+ def rank_hook_candidates(ctx: Context, limit: int = 5) -> dict:
95
+ """List and rank all hook candidates in the session.
96
+
97
+ Returns candidates sorted by salience — a composite of memorability,
98
+ recurrence, contrast potential, and development potential.
99
+
100
+ limit: max candidates to return (default 5)
101
+ """
102
+ tracks, scenes, motif_data = _fetch_tracks_and_scenes(ctx)
103
+
104
+ candidates = analyzer.find_hook_candidates(tracks, motif_data, scenes)
105
+ top = candidates[:limit]
106
+
107
+ return {
108
+ "candidates": [c.to_dict() for c in top],
109
+ "total_found": len(candidates),
110
+ "shown": len(top),
111
+ }
112
+
113
+
114
+ @mcp.tool()
115
+ def develop_hook(
116
+ ctx: Context,
117
+ hook_id: str = "",
118
+ mode: str = "chorus",
119
+ ) -> dict:
120
+ """Suggest development strategies for a hook.
121
+
122
+ hook_id: the hook to develop (from rank_hook_candidates).
123
+ If provided, strategies are adapted to the hook's type
124
+ (melodic, rhythmic, timbral, harmonic, textural).
125
+ mode: development style — "chorus" (lift/strengthen), "variation"
126
+ (melodic variation), "counterline" (complementary line),
127
+ "breakdown" (stripped version), "fill" (ornamental version)
128
+
129
+ Returns development strategies with musical explanations.
130
+ """
131
+ # Look up the actual hook to adapt strategies by type
132
+ hook_type = "melodic" # default
133
+ hook_description = "the hook"
134
+ if hook_id:
135
+ tracks, scenes, motif_data = _fetch_tracks_and_scenes(ctx)
136
+ candidates = analyzer.find_hook_candidates(tracks, motif_data, scenes)
137
+ match = [c for c in candidates if c.hook_id == hook_id]
138
+ if match:
139
+ hook_type = match[0].hook_type
140
+ hook_description = match[0].description
141
+
142
+ # Type-specific focus areas
143
+ _type_focus = {
144
+ "melodic": {"dimension": "melodic contour and pitch", "double": "octave or harmony", "strip": "melodic core", "ornament": "grace notes and embellishments"},
145
+ "rhythmic": {"dimension": "rhythmic pattern and groove", "double": "layered percussion or polyrhythm", "strip": "rhythmic skeleton", "ornament": "ghost notes and syncopation"},
146
+ "timbral": {"dimension": "timbre and texture", "double": "parallel processing or layered timbres", "strip": "raw unprocessed sound", "ornament": "modulation and movement"},
147
+ "harmonic": {"dimension": "harmonic movement and voicing", "double": "extended voicings or inversions", "strip": "root notes only", "ornament": "passing tones and suspensions"},
148
+ "textural": {"dimension": "spatial and textural quality", "double": "stereo widening or reverb layers", "strip": "dry mono version", "ornament": "granular or delay effects"},
149
+ }
150
+ focus = _type_focus.get(hook_type, _type_focus["melodic"])
151
+
152
+ strategies = {
153
+ "chorus": {
154
+ "approach": f"Lift and strengthen the {hook_type} hook for maximum impact",
155
+ "tactics": [
156
+ f"Double {hook_description} with {focus['double']}",
157
+ f"Add supporting harmonic movement underneath the {focus['dimension']}",
158
+ f"Increase rhythmic density around {hook_description}",
159
+ f"Layer complementary textures that frame the {focus['dimension']}",
160
+ ],
161
+ "identity_effect": "preserves — amplifies the core idea",
162
+ },
163
+ "variation": {
164
+ "approach": f"Create {hook_type} variations of {hook_description}",
165
+ "tactics": [
166
+ f"Transpose or shift the {focus['dimension']} to a different register",
167
+ f"Invert or retrograde the {focus['dimension']}",
168
+ "Apply rhythmic displacement (shift by 1/8 or 1/16)",
169
+ f"Fragment {hook_description} — use only the first half or last half",
170
+ ],
171
+ "identity_effect": "evolves — develops the idea further",
172
+ },
173
+ "counterline": {
174
+ "approach": f"Write a complementary line that dialogues with the {hook_type} hook",
175
+ "tactics": [
176
+ f"Use contrary motion against the {focus['dimension']}",
177
+ f"Fill rhythmic gaps in {hook_description} with the counterline",
178
+ "Match the harmonic context but use different intervals or timbre",
179
+ f"Use a contrasting {hook_type} character to distinguish the counter",
180
+ ],
181
+ "identity_effect": "evolves — adds depth without replacing the core",
182
+ },
183
+ "breakdown": {
184
+ "approach": f"Create a stripped-down version of {hook_description} for contrast",
185
+ "tactics": [
186
+ f"Isolate the {focus['strip']} — remove everything else",
187
+ "Use a different instrument/timbre for the stripped version",
188
+ "Slow down or halve the rhythmic density",
189
+ "Add space and reverb to create distance",
190
+ ],
191
+ "identity_effect": "preserves — the hook is still recognizable in reduced form",
192
+ },
193
+ "fill": {
194
+ "approach": f"Create ornamental variations of {hook_description} for transitions",
195
+ "tactics": [
196
+ f"Add {focus['ornament']}",
197
+ "Create a call-and-response pattern",
198
+ f"Use the hook's rhythm with new {focus['dimension']} material",
199
+ f"Build a riser or fill from {hook_description} fragments",
200
+ ],
201
+ "identity_effect": "evolves — decorates without replacing",
202
+ },
203
+ }
204
+
205
+ if mode not in strategies:
206
+ return {
207
+ "error": f"Unknown mode: {mode}",
208
+ "available_modes": list(strategies.keys()),
209
+ }
210
+
211
+ strategy = strategies[mode]
212
+ return {
213
+ "hook_id": hook_id,
214
+ "hook_type": hook_type,
215
+ "hook_description": hook_description,
216
+ "mode": mode,
217
+ **strategy,
218
+ }
219
+
220
+
221
+ @mcp.tool()
222
+ def measure_hook_salience(ctx: Context, hook_id: str = "") -> dict:
223
+ """Measure the salience of a specific hook or the primary hook.
224
+
225
+ Returns detailed scores for memorability, recurrence, contrast
226
+ potential, and development potential.
227
+ """
228
+ tracks, scenes, motif_data = _fetch_tracks_and_scenes(ctx)
229
+ candidates = analyzer.find_hook_candidates(tracks, motif_data, scenes)
230
+
231
+ if hook_id:
232
+ match = [c for c in candidates if c.hook_id == hook_id]
233
+ if not match:
234
+ return {
235
+ "error": f"Hook {hook_id} not found",
236
+ "available_hooks": [c.hook_id for c in candidates[:5]],
237
+ }
238
+ hook = match[0]
239
+ elif candidates:
240
+ hook = candidates[0]
241
+ else:
242
+ return {"error": "No hooks detected in the session"}
243
+
244
+ return {
245
+ **hook.to_dict(),
246
+ "interpretation": _interpret_salience(hook),
247
+ }
248
+
249
+
250
+ @mcp.tool()
251
+ def score_phrase_impact(
252
+ ctx: Context,
253
+ section_index: int = 0,
254
+ target: str = "hook",
255
+ ) -> dict:
256
+ """Score a section's emotional impact as a musical phrase.
257
+
258
+ Evaluates arrival strength, anticipation, contrast, groove
259
+ continuity, and payoff balance. Phrase-level judgment outranks
260
+ parameter-only evaluation for arrangement and transition decisions.
261
+
262
+ section_index: which section/scene to evaluate (0-based)
263
+ target: what it should function as — "hook", "drop", "chorus",
264
+ "transition", or "loop"
265
+ """
266
+ ableton = _get_ableton(ctx)
267
+
268
+ # Build section data from scenes
269
+ sections = _get_section_data(ableton)
270
+ if section_index >= len(sections):
271
+ return {"error": f"Section index {section_index} out of range (have {len(sections)})"}
272
+
273
+ section = sections[section_index]
274
+ prev_section = sections[section_index - 1] if section_index > 0 else {}
275
+
276
+ # Get song brain for context
277
+ song_brain = _get_song_brain_dict()
278
+
279
+ impact = analyzer.score_phrase_impact(section, target, song_brain, prev_section)
280
+ return impact.to_dict()
281
+
282
+
283
+ @mcp.tool()
284
+ def detect_payoff_failure(ctx: Context) -> dict:
285
+ """Detect where the song should deliver a payoff but doesn't.
286
+
287
+ Checks chorus, drop, and hook sections for flat arrivals,
288
+ weak contrast, missing setups, and absent hooks.
289
+
290
+ Returns failures with severity and repair suggestions.
291
+ """
292
+ ableton = _get_ableton(ctx)
293
+ sections = _get_section_data(ableton)
294
+ song_brain = _get_song_brain_dict()
295
+
296
+ failures = analyzer.detect_payoff_failures(sections, song_brain)
297
+
298
+ return {
299
+ "failures": [f.to_dict() for f in failures],
300
+ "failure_count": len(failures),
301
+ "overall_health": "healthy" if not failures else (
302
+ "needs_attention" if len(failures) <= 2 else "significant_issues"
303
+ ),
304
+ }
305
+
306
+
307
+ @mcp.tool()
308
+ def suggest_payoff_repair(ctx: Context) -> dict:
309
+ """Generate repair strategies for detected payoff failures.
310
+
311
+ Runs payoff detection first, then suggests specific fixes
312
+ for each failure.
313
+ """
314
+ ableton = _get_ableton(ctx)
315
+ sections = _get_section_data(ableton)
316
+ song_brain = _get_song_brain_dict()
317
+
318
+ failures = analyzer.detect_payoff_failures(sections, song_brain)
319
+ if not failures:
320
+ return {"note": "No payoff failures detected — the song delivers where expected"}
321
+
322
+ repairs = analyzer.suggest_payoff_repairs(failures)
323
+ return {
324
+ "repairs": repairs,
325
+ "repair_count": len(repairs),
326
+ }
327
+
328
+
329
+ # ── Helpers ───────────────────────────────────────────────────────
330
+
331
+
332
+ def _get_section_data(ableton) -> list[dict]:
333
+ """Build section data from Ableton scenes with real energy/density/has_drums."""
334
+ sections: list[dict] = []
335
+ try:
336
+ matrix = ableton.send_command("get_scene_matrix")
337
+ scenes_list = matrix.get("scenes", [])
338
+ matrix_rows = matrix.get("matrix", [])
339
+
340
+ # Detect drum track indices by name
341
+ drum_keywords = {"drum", "beat", "kick", "hat", "perc", "snare"}
342
+ track_names = []
343
+ # tracks may be in matrix metadata or session_info
344
+ for ti, row_entry in enumerate(matrix_rows[0] if matrix_rows else []):
345
+ track_names.append("") # placeholder — we'll use scenes_list tracks if available
346
+ # Use scene matrix track info if available
347
+ track_info = matrix.get("tracks", [])
348
+ drum_indices = set()
349
+ for ti, track in enumerate(track_info):
350
+ name_lower = track.get("name", "").lower() if isinstance(track, dict) else ""
351
+ if any(kw in name_lower for kw in drum_keywords):
352
+ drum_indices.add(ti)
353
+
354
+ for i, scene in enumerate(scenes_list):
355
+ row = matrix_rows[i] if i < len(matrix_rows) else []
356
+ if not isinstance(row, list):
357
+ row = []
358
+ clip_count = sum(1 for c in row if c)
359
+ total_tracks = max(len(row), 1)
360
+
361
+ # has_drums: check if any drum track has a clip in this scene
362
+ has_drums = any(
363
+ di < len(row) and row[di]
364
+ for di in drum_indices
365
+ ) if drum_indices else False
366
+
367
+ density = min(1.0, clip_count / total_tracks)
368
+ # energy: density + drum bonus
369
+ energy = min(1.0, density + (0.1 if has_drums else 0.0))
370
+
371
+ sections.append({
372
+ "id": f"scene_{i}",
373
+ "name": scene.get("name", f"Scene {i}"),
374
+ "label": scene.get("name", "").lower(),
375
+ "energy": round(energy, 3),
376
+ "density": round(density, 3),
377
+ "has_drums": has_drums,
378
+ })
379
+ except Exception:
380
+ pass
381
+
382
+ return sections
383
+
384
+
385
+ def _get_song_brain_dict() -> dict:
386
+ """Get current SongBrain as dict, or empty dict."""
387
+ try:
388
+ from ..song_brain.tools import _current_brain
389
+ if _current_brain is not None:
390
+ return _current_brain.to_dict()
391
+ except Exception as _e:
392
+ if __debug__:
393
+ import sys
394
+ print(f"LivePilot: SongBrain unavailable in hook_hunter: {_e}", file=sys.stderr)
395
+ return {}
396
+
397
+
398
+ @mcp.tool()
399
+ def detect_hook_neglect(ctx: Context) -> dict:
400
+ """Detect if a strong hook exists but is underused across sections.
401
+
402
+ Checks whether the primary hook appears in enough sections to
403
+ create adequate repetition and memorability. A hook that only
404
+ appears in one section is "neglected" — it needs to recur.
405
+
406
+ Returns neglect analysis with underused sections and suggestions.
407
+ """
408
+ tracks, scenes, motif_data = _fetch_tracks_and_scenes(ctx)
409
+
410
+ hook = analyzer.find_primary_hook(tracks, motif_data, scenes)
411
+ if not hook:
412
+ return {
413
+ "neglected": False,
414
+ "note": "No primary hook detected — hook neglect N/A",
415
+ "suggestion": "Create a defining hook before checking for neglect",
416
+ }
417
+
418
+ # Check per-track hook presence across scenes using scene matrix
419
+ hook_location = hook.location if hook.location else ""
420
+ ableton = _get_ableton(ctx)
421
+
422
+ try:
423
+ matrix = ableton.send_command("get_scene_matrix")
424
+ except Exception:
425
+ return {
426
+ "neglected": False,
427
+ "hook": hook.to_dict(),
428
+ "note": "Could not fetch scene matrix to assess neglect",
429
+ }
430
+
431
+ scenes_list = matrix.get("scenes", [])
432
+ matrix_rows = matrix.get("matrix", [])
433
+ track_info = matrix.get("tracks", [])
434
+
435
+ if not scenes_list or not hook_location:
436
+ return {
437
+ "neglected": False,
438
+ "hook": hook.to_dict(),
439
+ "note": "Insufficient section data to assess neglect",
440
+ }
441
+
442
+ # Find the hook's track index by matching location to track names
443
+ hook_track_idx = None
444
+ hook_loc_lower = hook_location.lower()
445
+ for ti, track in enumerate(track_info):
446
+ track_name = track.get("name", "") if isinstance(track, dict) else ""
447
+ if track_name.lower() == hook_loc_lower or hook_loc_lower in track_name.lower():
448
+ hook_track_idx = ti
449
+ break
450
+
451
+ if hook_track_idx is None:
452
+ # Fallback: can't find the track, use density proxy
453
+ sections = _get_section_data(ableton)
454
+ present_count = sum(1 for s in sections if s.get("density", 0) > 0.3)
455
+ total = max(len(sections), 1)
456
+ return {
457
+ "neglected": present_count / total < 0.5 and hook.salience > 0.3,
458
+ "hook": hook.to_dict(),
459
+ "presence_ratio": round(present_count / total, 2),
460
+ "note": f"Could not find track '{hook_location}' — used density fallback",
461
+ }
462
+
463
+ # Check each scene for hook track clip presence
464
+ present_count = 0
465
+ absent_sections = []
466
+ for i, scene in enumerate(scenes_list):
467
+ scene_name = scene.get("name", f"Scene {i}")
468
+ # Skip intro — hook absence there is normal
469
+ if i == 0 and "intro" in scene_name.lower():
470
+ continue
471
+
472
+ row = matrix_rows[i] if i < len(matrix_rows) else []
473
+ if isinstance(row, list) and hook_track_idx < len(row) and row[hook_track_idx]:
474
+ present_count += 1
475
+ else:
476
+ absent_sections.append(scene_name)
477
+
478
+ total_eligible = max(len(scenes_list) - 1, 1) # exclude first intro
479
+ presence_ratio = present_count / total_eligible
480
+
481
+ neglected = presence_ratio < 0.5 and hook.salience > 0.3
482
+
483
+ return {
484
+ "neglected": neglected,
485
+ "hook": hook.to_dict(),
486
+ "hook_track": hook_location,
487
+ "hook_track_index": hook_track_idx,
488
+ "presence_ratio": round(presence_ratio, 2),
489
+ "present_in_sections": present_count,
490
+ "absent_from": absent_sections,
491
+ "suggestion": (
492
+ f"The hook ({hook.description}) on track '{hook_location}' only has clips in "
493
+ f"{presence_ratio:.0%} of sections. Consider adding variations in: {', '.join(absent_sections)}"
494
+ ) if neglected else "Hook track has clips in most sections — well-distributed",
495
+ }
496
+
497
+
498
+ @mcp.tool()
499
+ def compare_phrase_impact(
500
+ ctx: Context,
501
+ section_indices: list[int] | None = None,
502
+ target: str = "hook",
503
+ ) -> dict:
504
+ """Compare phrase-level emotional impact across multiple sections.
505
+
506
+ Runs score_phrase_impact for each section and returns a ranked
507
+ comparison with delta analysis between the strongest and weakest.
508
+
509
+ section_indices: list of 0-based section indices to compare
510
+ target: what the sections should function as — "hook", "drop",
511
+ "chorus", "transition", or "loop"
512
+ """
513
+ if not section_indices or len(section_indices) < 2:
514
+ return {"error": "Provide at least 2 section_indices to compare"}
515
+
516
+ ableton = _get_ableton(ctx)
517
+ sections = _get_section_data(ableton)
518
+ song_brain = _get_song_brain_dict()
519
+
520
+ results = []
521
+ for idx in section_indices:
522
+ if idx >= len(sections):
523
+ results.append({
524
+ "section_index": idx,
525
+ "error": f"Index {idx} out of range (have {len(sections)} sections)",
526
+ })
527
+ continue
528
+
529
+ section = sections[idx]
530
+ prev_section = sections[idx - 1] if idx > 0 else {}
531
+ impact = analyzer.score_phrase_impact(section, target, song_brain, prev_section)
532
+ results.append({
533
+ "section_index": idx,
534
+ "section_name": section.get("name", f"Section {idx}"),
535
+ **impact.to_dict(),
536
+ })
537
+
538
+ # Rank by composite impact
539
+ valid = [r for r in results if "composite_impact" in r]
540
+ valid.sort(key=lambda r: r.get("composite_impact", 0), reverse=True)
541
+
542
+ # Delta analysis between best and worst
543
+ delta = {}
544
+ if len(valid) >= 2:
545
+ best, worst = valid[0], valid[-1]
546
+ delta = {
547
+ "strongest": best["section_name"],
548
+ "weakest": worst["section_name"],
549
+ "composite_delta": round(
550
+ best.get("composite_impact", 0) - worst.get("composite_impact", 0), 3
551
+ ),
552
+ "biggest_gap_dimension": _find_biggest_gap(best, worst),
553
+ }
554
+
555
+ return {
556
+ "target": target,
557
+ "rankings": valid,
558
+ "delta_analysis": delta,
559
+ "section_count": len(section_indices),
560
+ }
561
+
562
+
563
+ def _find_biggest_gap(best: dict, worst: dict) -> str:
564
+ """Find which impact dimension has the biggest gap between best and worst."""
565
+ dimensions = [
566
+ "arrival_strength", "anticipation_strength", "contrast_quality",
567
+ "groove_continuity", "payoff_balance", "section_clarity",
568
+ ]
569
+ max_gap = 0.0
570
+ max_dim = ""
571
+ for dim in dimensions:
572
+ gap = abs(best.get(dim, 0) - worst.get(dim, 0))
573
+ if gap > max_gap:
574
+ max_gap = gap
575
+ max_dim = dim
576
+ return max_dim
577
+
578
+
579
+ def _interpret_salience(hook) -> str:
580
+ """Human-readable interpretation of salience score."""
581
+ if hook.salience > 0.7:
582
+ return "Strong hook — this is clearly the track's defining element"
583
+ elif hook.salience > 0.4:
584
+ return "Moderate hook — recognizable but could be developed further"
585
+ elif hook.salience > 0.2:
586
+ return "Emerging hook — has potential but needs more prominence"
587
+ else:
588
+ return "Weak hook candidate — consider strengthening or replacing"