livepilot 1.10.6 → 1.10.7

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 (78) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/.mcp.json.disabled +9 -0
  3. package/.mcpbignore +3 -0
  4. package/AGENTS.md +3 -3
  5. package/BUGS.md +1570 -0
  6. package/CHANGELOG.md +42 -0
  7. package/CONTRIBUTING.md +1 -1
  8. package/README.md +7 -7
  9. package/bin/livepilot.js +28 -8
  10. package/livepilot/.Codex-plugin/plugin.json +2 -2
  11. package/livepilot/.claude-plugin/plugin.json +2 -2
  12. package/livepilot/skills/livepilot-core/SKILL.md +4 -4
  13. package/livepilot/skills/livepilot-core/references/overview.md +2 -2
  14. package/livepilot/skills/livepilot-release/SKILL.md +8 -8
  15. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  16. package/m4l_device/LivePilot_Analyzer.amxd.pre-presentation-backup +0 -0
  17. package/m4l_device/LivePilot_Analyzer.maxproj +53 -0
  18. package/m4l_device/livepilot_bridge.js +214 -2
  19. package/manifest.json +3 -3
  20. package/mcp_server/__init__.py +1 -1
  21. package/mcp_server/atlas/__init__.py +93 -26
  22. package/mcp_server/creative_constraints/tools.py +206 -33
  23. package/mcp_server/experiment/engine.py +7 -9
  24. package/mcp_server/hook_hunter/analyzer.py +62 -9
  25. package/mcp_server/hook_hunter/tools.py +60 -9
  26. package/mcp_server/m4l_bridge.py +21 -6
  27. package/mcp_server/musical_intelligence/detectors.py +32 -0
  28. package/mcp_server/performance_engine/tools.py +112 -29
  29. package/mcp_server/preview_studio/engine.py +89 -8
  30. package/mcp_server/preview_studio/tools.py +22 -6
  31. package/mcp_server/project_brain/automation_graph.py +71 -19
  32. package/mcp_server/project_brain/builder.py +2 -0
  33. package/mcp_server/project_brain/tools.py +55 -5
  34. package/mcp_server/reference_engine/profile_builder.py +129 -3
  35. package/mcp_server/reference_engine/tools.py +47 -6
  36. package/mcp_server/runtime/execution_router.py +50 -0
  37. package/mcp_server/runtime/mcp_dispatch.py +75 -3
  38. package/mcp_server/runtime/remote_commands.py +4 -2
  39. package/mcp_server/sample_engine/analyzer.py +131 -4
  40. package/mcp_server/sample_engine/critics.py +29 -8
  41. package/mcp_server/sample_engine/models.py +20 -1
  42. package/mcp_server/sample_engine/tools.py +48 -14
  43. package/mcp_server/semantic_moves/sound_design_compilers.py +22 -59
  44. package/mcp_server/semantic_moves/transition_compilers.py +12 -19
  45. package/mcp_server/server.py +68 -2
  46. package/mcp_server/session_continuity/models.py +4 -0
  47. package/mcp_server/session_continuity/tracker.py +14 -1
  48. package/mcp_server/song_brain/builder.py +110 -12
  49. package/mcp_server/song_brain/tools.py +77 -13
  50. package/mcp_server/sound_design/tools.py +112 -1
  51. package/mcp_server/stuckness_detector/detector.py +90 -0
  52. package/mcp_server/stuckness_detector/tools.py +41 -0
  53. package/mcp_server/tools/_agent_os_engine/critics.py +24 -0
  54. package/mcp_server/tools/_composition_engine/__init__.py +2 -2
  55. package/mcp_server/tools/_composition_engine/harmony.py +90 -0
  56. package/mcp_server/tools/_composition_engine/sections.py +47 -4
  57. package/mcp_server/tools/_harmony_engine.py +52 -8
  58. package/mcp_server/tools/_research_engine.py +98 -19
  59. package/mcp_server/tools/_theory_engine.py +138 -9
  60. package/mcp_server/tools/agent_os.py +20 -3
  61. package/mcp_server/tools/analyzer.py +98 -0
  62. package/mcp_server/tools/clips.py +45 -0
  63. package/mcp_server/tools/composition.py +66 -23
  64. package/mcp_server/tools/devices.py +22 -1
  65. package/mcp_server/tools/harmony.py +115 -14
  66. package/mcp_server/tools/midi_io.py +13 -1
  67. package/mcp_server/tools/mixing.py +35 -1
  68. package/mcp_server/tools/motif.py +49 -3
  69. package/mcp_server/tools/research.py +24 -0
  70. package/mcp_server/tools/theory.py +108 -16
  71. package/mcp_server/transition_engine/critics.py +18 -11
  72. package/package.json +2 -2
  73. package/remote_script/LivePilot/__init__.py +57 -2
  74. package/remote_script/LivePilot/clips.py +69 -0
  75. package/remote_script/LivePilot/mixing.py +117 -0
  76. package/remote_script/LivePilot/router.py +13 -1
  77. package/scripts/generate_tool_catalog.py +13 -38
  78. package/scripts/sync_metadata.py +231 -14
@@ -115,26 +115,70 @@ class AtlasManager:
115
115
  query_words = query_lower.split()
116
116
  results: List[Dict[str, Any]] = []
117
117
 
118
+ # BUG-B39: the real atlas scanner emits "instruments" /
119
+ # "audio_effects" but older callers and test fixtures sometimes
120
+ # pass the singular "instrument" / "effect". Build a tolerant
121
+ # category alias set so both forms work.
122
+ _CAT_ALIASES = {
123
+ "instrument": {"instrument", "instruments"},
124
+ "instruments": {"instrument", "instruments"},
125
+ "effect": {"effect", "effects", "audio_effects"},
126
+ "effects": {"effect", "effects", "audio_effects"},
127
+ "audio_effect": {"effect", "effects", "audio_effects",
128
+ "audio_effect"},
129
+ "audio_effects": {"effect", "effects", "audio_effects",
130
+ "audio_effect"},
131
+ }
132
+ allowed_cats = (
133
+ _CAT_ALIASES.get(category, {category})
134
+ if category != "all" else None
135
+ )
136
+
118
137
  for dev in self._devices:
119
138
  # Category filter
120
- if category != "all" and dev.get("category", "") != category:
139
+ if allowed_cats is not None and dev.get("category", "") not in allowed_cats:
121
140
  continue
122
141
 
123
142
  score = 0
124
143
  dev_name = dev.get("name", "")
125
144
  dev_name_lower = dev_name.lower()
126
145
 
127
- # Name scoring: 100pts exact, 50pts substring
146
+ # Name scoring. BUG-B41 fix: dropped weight dramatically
147
+ # (was 100 exact / 50 substring) so a device literally
148
+ # named "Bass" no longer blows past character-tag matches
149
+ # for a sonic query like "warm analog bass". An exact name
150
+ # match is still the strongest single signal, but a device
151
+ # with 2+ matching character-tags now beats a name-only
152
+ # accident.
128
153
  if dev_name_lower == query_lower:
129
- score += 100
154
+ score += 45 # was 100
130
155
  elif query_lower in dev_name_lower:
131
- score += 50
132
-
133
- # Tag scoring: 30pts per matching tag
134
- dev_tags = [t.lower() for t in dev.get("tags", [])]
156
+ score += 20 # was 50
157
+ else:
158
+ # Partial: any query word present in name — small signal
159
+ for word in query_words:
160
+ if len(word) >= 3 and word in dev_name_lower:
161
+ score += 5
162
+
163
+ # Tag scoring — prefer enriched character_tags.
164
+ # BUG-B40 / B41: also read character_tags so enriched devices
165
+ # actually compete with name-based matches.
166
+ dev_tags = [
167
+ t.lower() for t in (
168
+ dev.get("character_tags") or dev.get("tags", [])
169
+ )
170
+ ]
171
+ # BUG-B41: bumped to 35pts per tag so multi-tag matches beat
172
+ # a single substring-name match.
135
173
  for word in query_words:
136
174
  if word in dev_tags:
137
- score += 30
175
+ score += 35
176
+ # Partial tag match (word appears as substring in a tag)
177
+ else:
178
+ for tag in dev_tags:
179
+ if word in tag:
180
+ score += 10
181
+ break
138
182
 
139
183
  # Use case scoring: 25pts per match
140
184
  for use_case in dev.get("use_cases", []):
@@ -142,10 +186,11 @@ class AtlasManager:
142
186
  for word in query_words:
143
187
  if word in use_lower:
144
188
  score += 25
145
- break # one match per use_case
189
+ break
146
190
 
147
- # Genre scoring: 20pts primary, 10pts secondary
148
- genres = dev.get("genres", {})
191
+ # Genre scoring: 20pts primary, 10pts secondary.
192
+ # BUG-B40: also read genre_affinity (enriched field).
193
+ genres = dev.get("genre_affinity") or dev.get("genres", {}) or {}
149
194
  for genre in genres.get("primary", []):
150
195
  if query_lower in genre.lower() or genre.lower() in query_lower:
151
196
  score += 20
@@ -153,8 +198,11 @@ class AtlasManager:
153
198
  if query_lower in genre.lower() or genre.lower() in query_lower:
154
199
  score += 10
155
200
 
156
- # Description keyword scoring: 15pts
157
- description = dev.get("description", "").lower()
201
+ # Description keyword scoring: 15pts.
202
+ # BUG-B40: prefer sonic_description when present.
203
+ description = (
204
+ dev.get("sonic_description") or dev.get("description", "")
205
+ ).lower()
158
206
  for word in query_words:
159
207
  if len(word) >= 3 and word in description:
160
208
  score += 15
@@ -224,7 +272,13 @@ class AtlasManager:
224
272
  def chain_suggest(
225
273
  self, role: str, genre: str = ""
226
274
  ) -> Dict[str, Any]:
227
- """Suggest a device chain for a given role (e.g., 'bass', 'lead', 'pad')."""
275
+ """Suggest a device chain for a given role (e.g., 'bass', 'lead', 'pad').
276
+
277
+ BUG-B39 fix: the old code passed category="instrument" (singular)
278
+ and category="effect" to self.search(), but the atlas stores
279
+ devices with category="instruments" / "audio_effects" (plural).
280
+ Every filtered search missed and the chain came back empty.
281
+ """
228
282
  chain: List[Dict[str, Any]] = []
229
283
  position = 0
230
284
 
@@ -244,8 +298,8 @@ class AtlasManager:
244
298
  intent = instrument_intents.get(role_lower, role_lower)
245
299
  search_q = f"{intent} {genre}" if genre else intent
246
300
 
247
- # Find instrument
248
- instrument_candidates = self.search(search_q, category="instrument", limit=3)
301
+ # Find instrument — atlas category is "instruments" (plural)
302
+ instrument_candidates = self.search(search_q, category="instruments", limit=3)
249
303
  if instrument_candidates:
250
304
  best = instrument_candidates[0]["device"]
251
305
  chain.append({
@@ -255,7 +309,7 @@ class AtlasManager:
255
309
  })
256
310
  position += 1
257
311
 
258
- # Stage 2: Effects
312
+ # Stage 2: Effects — atlas category is "audio_effects"
259
313
  effect_stages = [
260
314
  ("eq", f"Shape the {role} tone"),
261
315
  ("compression", f"Control {role} dynamics"),
@@ -264,7 +318,9 @@ class AtlasManager:
264
318
 
265
319
  for effect_type, reason in effect_stages:
266
320
  effect_q = f"{effect_type} {genre}" if genre else effect_type
267
- effect_candidates = self.search(effect_q, category="effect", limit=2)
321
+ effect_candidates = self.search(
322
+ effect_q, category="audio_effects", limit=2,
323
+ )
268
324
  if effect_candidates:
269
325
  best = effect_candidates[0]["device"]
270
326
  chain.append({
@@ -295,37 +351,48 @@ class AtlasManager:
295
351
  return {"error": f"Device not found: {device_b}"}
296
352
 
297
353
  def _summarize(dev: Dict[str, Any]) -> Dict[str, Any]:
354
+ # BUG-B40 fix: enriched atlas entries use character_tags /
355
+ # sonic_description / genre_affinity — the old _summarize
356
+ # looked for "tags" / "description" / "genres" which are
357
+ # the UN-enriched raw scanner fields. We prefer enriched
358
+ # fields, fall back to raw when enrichment is absent.
298
359
  return {
299
360
  "name": dev.get("name", ""),
300
361
  "category": dev.get("category", ""),
301
- "tags": dev.get("tags", []),
302
- "genres": dev.get("genres", {}),
362
+ "tags": dev.get("character_tags") or dev.get("tags", []),
363
+ "genres": dev.get("genre_affinity") or dev.get("genres", {}),
303
364
  "use_cases": dev.get("use_cases", []),
304
- "description": dev.get("description", ""),
365
+ "description": (
366
+ dev.get("sonic_description")
367
+ or dev.get("description", "")
368
+ ),
305
369
  "cpu_weight": dev.get("cpu_weight", "unknown"),
306
370
  "sweet_spot": dev.get("sweet_spot", ""),
371
+ "enriched": dev.get("enriched", False),
307
372
  }
308
373
 
309
374
  summary_a = _summarize(dev_a)
310
375
  summary_b = _summarize(dev_b)
311
376
 
312
- # Recommendation logic: score each for the role
377
+ # Recommendation logic: score each for the role.
378
+ # BUG-B40: scorer also reads the enriched field names.
313
379
  score_a = 0
314
380
  score_b = 0
315
381
  if role:
316
382
  role_lower = role.lower()
317
- # Check use_cases
318
383
  for uc in dev_a.get("use_cases", []):
319
384
  if role_lower in uc.lower():
320
385
  score_a += 20
321
386
  for uc in dev_b.get("use_cases", []):
322
387
  if role_lower in uc.lower():
323
388
  score_b += 20
324
- # Check tags
325
- for tag in dev_a.get("tags", []):
389
+ # Tag scoring — prefer character_tags (enriched)
390
+ a_tags = dev_a.get("character_tags") or dev_a.get("tags", [])
391
+ b_tags = dev_b.get("character_tags") or dev_b.get("tags", [])
392
+ for tag in a_tags:
326
393
  if role_lower in tag.lower():
327
394
  score_a += 10
328
- for tag in dev_b.get("tags", []):
395
+ for tag in b_tags:
329
396
  if role_lower in tag.lower():
330
397
  score_b += 10
331
398
 
@@ -94,10 +94,13 @@ def distill_reference_principles(
94
94
  if not reference_description.strip() and not style_name.strip():
95
95
  return {"error": "Provide reference_description or style_name"}
96
96
 
97
- # Build a reference profile from available data
97
+ # BUG-B17 fix: collect profile fragments from all sources and MERGE.
98
+ # The old flow stopped at the first non-empty source, so if
99
+ # get_style_tactics returned a half-filled profile the text-keyword
100
+ # fallback never ran and the description's rich content was lost.
101
+ # Now we always run the text fallback too and fill missing fields.
98
102
  reference_profile: dict = {}
99
103
 
100
- # Try to get style tactics if style_name is provided
101
104
  if style_name:
102
105
  try:
103
106
  from ..tools._research_engine import get_style_tactics
@@ -114,18 +117,34 @@ def distill_reference_principles(
114
117
  }
115
118
  except Exception as exc:
116
119
  logger.debug("distill_reference_principles failed: %s", exc)
117
- # Try to get a reference profile from the reference engine
120
+
121
+ # Try the built-in style profile builder
118
122
  if not reference_profile:
119
123
  try:
120
124
  from ..reference_engine.profile_builder import build_style_reference_profile
121
125
  profile = build_style_reference_profile(
122
126
  style_name or reference_description
123
127
  )
124
- reference_profile = profile.to_dict()
128
+ reference_profile = profile.to_dict() if profile else {}
125
129
  except Exception as exc:
126
130
  logger.debug("distill_reference_principles failed: %s", exc)
127
- # Fallback: build from description keywords
128
- reference_profile = _profile_from_description(reference_description)
131
+
132
+ # Text-keyword fallback ALWAYS merges in. Style tactics + profile
133
+ # builder typically leave some fields empty; the description's
134
+ # keywords fill those gaps. This is the B17 fix that makes the
135
+ # Dabrye reproducer produce non-empty principles.
136
+ if reference_description.strip():
137
+ text_profile = _profile_from_description(reference_description)
138
+ for key, value in text_profile.items():
139
+ existing = reference_profile.get(key)
140
+ is_empty = (
141
+ existing is None
142
+ or existing == ""
143
+ or existing == []
144
+ or existing == {}
145
+ )
146
+ if is_empty and value:
147
+ reference_profile[key] = value
129
148
 
130
149
  distillation = engine.distill_reference_principles(
131
150
  reference_profile=reference_profile,
@@ -216,23 +235,65 @@ def generate_constrained_variants(
216
235
  taste_graph=taste_graph,
217
236
  )
218
237
 
219
- # Validate each variant's compiled_plan against constraints
238
+ # Validate each variant's compiled_plan against constraints.
239
+ # BUG-B46: two problems in the old code —
240
+ # 1) iterating `for step in v.compiled_plan` yields dict KEYS
241
+ # (compiled_plan is {'move_id': ..., 'steps': [...]}), so
242
+ # the validation ran on strings and silently passed.
243
+ # 2) when a variant was filtered, we only blanked compiled_plan
244
+ # and left status='pending' — callers had no way to tell
245
+ # which variants became shells.
246
+ # Now we iterate .get("steps", []) correctly, flip filtered
247
+ # variants to status='blocked', and count blocked_count in the
248
+ # response so callers can detect the "all variants filtered" case.
249
+ blocked_count = 0
220
250
  for v in ps.variants:
221
- v.what_preserved = f"{v.what_preserved} | Constraints: {', '.join(active.constraints)}"
251
+ v.what_preserved = (
252
+ f"{v.what_preserved} | Constraints: "
253
+ f"{', '.join(active.constraints)}"
254
+ )
222
255
  if v.compiled_plan:
223
- plan = {"steps": [
224
- {"action": step.get("tool", ""), **step}
225
- for step in v.compiled_plan
226
- ]}
227
- validation = engine.validate_plan_against_constraints(plan, active)
256
+ steps = v.compiled_plan.get("steps", []) if isinstance(
257
+ v.compiled_plan, dict
258
+ ) else []
259
+ plan = {
260
+ "steps": [
261
+ {"action": step.get("tool", ""), **step}
262
+ for step in steps
263
+ ]
264
+ }
265
+ validation = engine.validate_plan_against_constraints(
266
+ plan, active,
267
+ )
228
268
  if not validation["valid"]:
229
269
  v.compiled_plan = None
230
- v.what_changed = f"[FILTERED] {v.what_changed} — violates {', '.join(active.constraints)}"
270
+ v.status = "blocked"
271
+ v.what_changed = (
272
+ f"[FILTERED] {v.what_changed} — violates "
273
+ f"{', '.join(active.constraints)}"
274
+ )
275
+ blocked_count += 1
276
+ elif v.status == "blocked":
277
+ # Already blocked upstream (no compilable move)
278
+ blocked_count += 1
279
+
280
+ note = (
281
+ f"Variants with violating plans have been filtered "
282
+ f"({blocked_count}/{len(ps.variants)} blocked)"
283
+ )
284
+ if blocked_count == len(ps.variants) and ps.variants:
285
+ note = (
286
+ f"All {blocked_count} variants violate constraints "
287
+ f"{active.constraints!r}. Try loosening constraints or a "
288
+ f"different request."
289
+ )
231
290
 
232
291
  return {
233
292
  "preview_set": ps.to_dict(),
234
293
  "constraints_applied": active.constraints,
235
- "note": "Variants with violating plans have been filtered",
294
+ "blocked_count": blocked_count,
295
+ "executable_count": len(ps.variants) - blocked_count,
296
+ "note": note,
236
297
  }
237
298
  except Exception as e:
238
299
  return {"error": f"Failed to generate constrained variants: {e}"}
@@ -256,9 +317,29 @@ def generate_reference_inspired_variants(
256
317
  if _cached_distillation is None:
257
318
  return {"error": "No reference distilled yet — call distill_reference_principles first"}
258
319
 
320
+ # BUG-B54: the reference-engine chain (distill → map → generate_variants)
321
+ # used to silently degrade when distill_reference_principles returned
322
+ # empty principles (BUG-B17). Callers got 3 shell variants branded
323
+ # "reference-inspired" with no reference material driving them.
324
+ # Refuse to run when principles are empty — the user should fix the
325
+ # distillation step first.
326
+ principles_list = list(_cached_distillation.principles or [])
327
+ if not principles_list:
328
+ return {
329
+ "error": (
330
+ "distill_reference_principles returned no principles — "
331
+ "reference-inspired variant generation refuses to run on "
332
+ "empty input (would produce meaningless 'reference-inspired' "
333
+ "shell variants). Try a more specific reference description "
334
+ "or pick a reference covered by the built-in style corpus."
335
+ ),
336
+ "reference": _cached_distillation.reference_description,
337
+ "principles_applied": [],
338
+ }
339
+
259
340
  # Build request text from reference principles
260
341
  principles_text = ", ".join(
261
- p.principle for p in _cached_distillation.principles[:3]
342
+ p.principle for p in principles_list[:3]
262
343
  )
263
344
  full_request = (
264
345
  f"Inspired by: {_cached_distillation.reference_description}. "
@@ -288,7 +369,9 @@ def generate_reference_inspired_variants(
288
369
  return {
289
370
  "preview_set": ps.to_dict(),
290
371
  "reference": _cached_distillation.reference_description,
291
- "principles_applied": [p.to_dict() for p in _cached_distillation.principles[:5]],
372
+ "principles_applied": [
373
+ p.to_dict() for p in principles_list[:5]
374
+ ],
292
375
  "note": "Variants are shaped by reference principles, not surface imitation",
293
376
  }
294
377
  except Exception as e:
@@ -311,32 +394,122 @@ def _get_song_brain_dict() -> dict:
311
394
 
312
395
 
313
396
  def _profile_from_description(description: str) -> dict:
314
- """Build a rough reference profile from text description."""
397
+ """Build a rough reference profile from a free-text description.
398
+
399
+ BUG-B17 fix: the old version only mapped 8 emotion keywords and
400
+ left every other field empty, so distill_reference_principles
401
+ returned empty principles for any description that didn't include
402
+ exactly one of those 8 words. We now scan for a rich keyword set
403
+ across emotional / spectral / width / groove / harmonic / density
404
+ dimensions so a description like "cold 90s hip-hop with ghostly
405
+ vocal chops and dusty drums" actually produces principles.
406
+ """
315
407
  desc_lower = description.lower()
316
408
 
409
+ # Emotional stance
317
410
  emotional_map = {
318
- "dark": "tense",
319
- "bright": "euphoric",
320
- "sad": "melancholic",
321
- "aggressive": "aggressive",
322
- "dreamy": "dreamy",
323
- "chill": "relaxed",
324
- "intense": "aggressive",
325
- "minimal": "restrained",
411
+ "dark": "tense", "cold": "tense", "ominous": "tense", "eerie": "tense",
412
+ "bright": "euphoric", "warm": "warm", "sunny": "euphoric",
413
+ "sad": "melancholic", "longing": "melancholic", "wistful": "melancholic",
414
+ "nostalgic": "nostalgic", "dust": "nostalgic", "dusty": "nostalgic",
415
+ "aggressive": "aggressive", "violent": "aggressive", "intense": "aggressive",
416
+ "dreamy": "dreamy", "dream": "dreamy", "floaty": "dreamy",
417
+ "chill": "relaxed", "meditative": "relaxed",
418
+ "minimal": "restrained", "restrained": "restrained",
419
+ "ghostly": "haunted", "haunted": "haunted", "ghost": "haunted",
420
+ "euphoric": "euphoric", "ecstatic": "euphoric",
326
421
  }
327
-
328
422
  emotional = ""
329
423
  for keyword, stance in emotional_map.items():
330
424
  if keyword in desc_lower:
331
425
  emotional = stance
332
426
  break
333
427
 
428
+ # Spectral contour — from brightness / color keywords
429
+ spectral_contour: dict = {}
430
+ if any(k in desc_lower for k in ("dark", "muddy", "lo-fi", "lofi",
431
+ "dusty", "cold", "underwater",
432
+ "warm", "vintage")):
433
+ spectral_contour = {
434
+ "band_balance": {"sub": 0.4, "low": 0.5, "mid": 0.35,
435
+ "high_mid": 0.2, "high": 0.1},
436
+ "centroid_hint": "dark / roll-off near 4kHz",
437
+ }
438
+ elif any(k in desc_lower for k in ("bright", "crisp", "shiny", "airy",
439
+ "glittery", "sparkly", "cinematic")):
440
+ spectral_contour = {
441
+ "band_balance": {"sub": 0.25, "low": 0.3, "mid": 0.4,
442
+ "high_mid": 0.55, "high": 0.6},
443
+ "centroid_hint": "bright / open high shelf",
444
+ }
445
+
446
+ # Width / depth — mono vs wide vs deep
447
+ width_depth: dict = {}
448
+ if any(k in desc_lower for k in ("narrow", "mono", "focused", "tight",
449
+ "centered")):
450
+ width_depth = {"stereo_width": 0.25, "depth_hint": "close, upfront"}
451
+ elif any(k in desc_lower for k in ("wide", "spacious", "spatial",
452
+ "ambient", "washy", "drifting")):
453
+ width_depth = {"stereo_width": 0.85, "depth_hint": "deep, spatial"}
454
+ elif any(k in desc_lower for k in ("intimate", "dry")):
455
+ width_depth = {"stereo_width": 0.4, "depth_hint": "dry, intimate"}
456
+
457
+ # Groove posture — rhythm keywords
458
+ groove_posture: dict = {}
459
+ if any(k in desc_lower for k in ("swing", "shuffle", "dilla", "slouchy")):
460
+ groove_posture = {"feel": "swung", "stiffness": 0.25}
461
+ elif any(k in desc_lower for k in ("tight", "clean", "quantized",
462
+ "precise", "crispy")):
463
+ groove_posture = {"feel": "straight", "stiffness": 0.9}
464
+ elif any(k in desc_lower for k in ("loose", "sloppy", "drunk",
465
+ "organic", "human")):
466
+ groove_posture = {"feel": "humanized", "stiffness": 0.3}
467
+ elif any(k in desc_lower for k in ("driving", "motorik", "pulsing",
468
+ "throbbing", "hypnotic")):
469
+ groove_posture = {"feel": "driving", "stiffness": 0.8}
470
+
471
+ # Density motion — when the user hints at pacing
472
+ density_arc: list[float] = []
473
+ if any(k in desc_lower for k in ("slow burn", "patient", "gradually",
474
+ "builds", "buildup")):
475
+ density_arc = [0.2, 0.3, 0.5, 0.7, 0.9]
476
+ elif any(k in desc_lower for k in ("explodes", "immediate", "front-loaded",
477
+ "hits from the start")):
478
+ density_arc = [0.9, 0.85, 0.8, 0.5, 0.3]
479
+ elif any(k in desc_lower for k in ("dual drop", "return", "second wind")):
480
+ density_arc = [0.4, 0.8, 0.5, 0.3, 0.9]
481
+
482
+ # Harmonic character
483
+ harmonic = ""
484
+ if any(k in desc_lower for k in ("minor", "dorian", "phrygian",
485
+ "melancholic", "tense")):
486
+ harmonic = "minor_modal"
487
+ elif any(k in desc_lower for k in ("major", "ionian", "lydian",
488
+ "euphoric", "triumphant")):
489
+ harmonic = "major_modal"
490
+ elif any(k in desc_lower for k in ("dissonant", "dense", "clusters",
491
+ "microtonal")):
492
+ harmonic = "dissonant_clustered"
493
+ elif any(k in desc_lower for k in ("ambient", "drone", "pad",
494
+ "atmospheric", "washy")):
495
+ harmonic = "atmospheric_filtered"
496
+
497
+ # Payoff / section pacing
498
+ section_pacing: list[dict] = []
499
+ if any(k in desc_lower for k in ("sparse intro", "sparse", "slow start")):
500
+ section_pacing.append({"label": "sparse_intro", "bars": 16})
501
+ if any(k in desc_lower for k in ("buildup", "builds", "growing")):
502
+ section_pacing.append({"label": "gradual_buildup", "bars": 16})
503
+ if any(k in desc_lower for k in ("drop", "peak", "payoff",
504
+ "strip back", "pulled out")):
505
+ section_pacing.append({"label": "strip_back_payoff", "bars": 16})
506
+
334
507
  return {
335
508
  "emotional_stance": emotional,
336
- "density_arc": [],
337
- "section_pacing": [],
338
- "width_depth": {},
339
- "spectral_contour": {},
340
- "groove_posture": {},
341
- "harmonic_character": "",
509
+ "density_arc": density_arc,
510
+ "section_pacing": section_pacing,
511
+ "width_depth": width_depth,
512
+ "spectral_contour": spectral_contour,
513
+ "groove_posture": groove_posture,
514
+ "harmonic_character": harmonic,
342
515
  }
@@ -122,6 +122,8 @@ def _run_branch_sync(branch, ableton, compiled_plan, capture_fn):
122
122
  branch.compiled_plan = compiled_plan
123
123
  branch.before_snapshot = capture_fn()
124
124
 
125
+ from ..runtime.execution_router import READ_ONLY_TOOLS
126
+
125
127
  steps_executed = 0
126
128
  log = []
127
129
  for step in compiled_plan.get("steps", []):
@@ -129,7 +131,7 @@ def _run_branch_sync(branch, ableton, compiled_plan, capture_fn):
129
131
  params = step.get("params", {})
130
132
  if not tool:
131
133
  continue
132
- if tool in ("get_track_meters", "get_master_spectrum", "analyze_mix"):
134
+ if tool in READ_ONLY_TOOLS:
133
135
  continue
134
136
  try:
135
137
  result = ableton.send_command(tool, params)
@@ -173,21 +175,17 @@ async def run_branch_async(
173
175
  analyze_mix) are skipped in the apply pass — they're used for snapshot
174
176
  capture separately.
175
177
  """
176
- from ..runtime.execution_router import execute_plan_steps_async
178
+ from ..runtime.execution_router import execute_plan_steps_async, filter_apply_steps
177
179
 
178
180
  branch.status = "running"
179
181
  branch.compiled_plan = compiled_plan
180
182
 
181
183
  branch.before_snapshot = capture_fn()
182
184
 
183
- # Filter out read-only verification steps from the apply pass
185
+ # Filter out read-only verification steps from the apply pass (canonical
186
+ # list lives in execution_router.READ_ONLY_TOOLS).
184
187
  all_steps = compiled_plan.get("steps", []) or []
185
- apply_steps = [
186
- s for s in all_steps
187
- if s.get("tool") and s.get("tool") not in (
188
- "get_track_meters", "get_master_spectrum", "analyze_mix",
189
- )
190
- ]
188
+ apply_steps = filter_apply_steps(all_steps)
191
189
 
192
190
  exec_results = await execute_plan_steps_async(
193
191
  apply_steps,
@@ -33,14 +33,30 @@ def find_hook_candidates(
33
33
  candidates: list[HookCandidate] = []
34
34
 
35
35
  # 1. Motif-based hooks
36
- for motif in motif_data.get("motifs", []):
36
+ #
37
+ # BUG-B8 fix: the old code used motif.get('name', 'unknown'); the motif
38
+ # engine emits `motif_id` (not `name`), so every candidate collapsed
39
+ # onto hook_id="motif_unknown" and rank_hook_candidates returned 4+
40
+ # duplicate rows with empty location strings. We now prefer motif_id,
41
+ # then name, then a per-iteration index fallback to guarantee uniqueness.
42
+ # A final post-filter dedupes by (hook_id, hook_type, description) in
43
+ # case another producer slips a duplicate in.
44
+ for idx, motif in enumerate(motif_data.get("motifs", [])):
37
45
  salience = motif.get("salience", 0)
38
46
  recurrence = motif.get("recurrence", 0)
39
47
  if salience > 0.2 or recurrence > 0.3:
48
+ identifier = (
49
+ motif.get("motif_id")
50
+ or motif.get("name")
51
+ or f"idx{idx}"
52
+ )
40
53
  candidates.append(HookCandidate(
41
- hook_id=f"motif_{motif.get('name', 'unknown')}",
54
+ hook_id=f"motif_{identifier}",
42
55
  hook_type="melodic",
43
- description=motif.get("description", motif.get("name", "motif")),
56
+ description=motif.get(
57
+ "description",
58
+ motif.get("name") or motif.get("motif_id") or f"motif #{idx}",
59
+ ),
44
60
  location=motif.get("location", ""),
45
61
  memorability=min(1.0, salience * 1.2),
46
62
  recurrence=recurrence,
@@ -109,6 +125,20 @@ def find_hook_candidates(
109
125
  if "groove" in c.hook_id:
110
126
  c.evidence_sources.append("clip_reuse")
111
127
 
128
+ # BUG-B8: post-filter dedupe. Even after the motif_id fix above, other
129
+ # producers (track-name, groove-pattern) could collide on the same
130
+ # hook_id if session conventions repeat (e.g. two tracks named "Lead").
131
+ # Keep the first occurrence (sorted by salience below picks the winner
132
+ # among the originals), drop later duplicates by hook_id.
133
+ seen_ids: set[str] = set()
134
+ unique_candidates: list[HookCandidate] = []
135
+ for c in candidates:
136
+ if c.hook_id in seen_ids:
137
+ continue
138
+ seen_ids.add(c.hook_id)
139
+ unique_candidates.append(c)
140
+ candidates = unique_candidates
141
+
112
142
  # Sort by salience
113
143
  candidates.sort(key=lambda c: c.salience, reverse=True)
114
144
  return candidates
@@ -154,17 +184,40 @@ def score_phrase_impact(
154
184
  # Anticipation: was there a dip before?
155
185
  anticipation = min(1.0, max(0.0, (0.5 - prev_energy) * 2)) if prev_energy < 0.5 else 0.2
156
186
 
157
- # Contrast: density or energy change
158
- contrast = min(1.0, abs(density - prev_density) + abs(energy_delta))
187
+ # BUG-B51: note-content signals differentiate sections with
188
+ # identical energy/density. Without these, compare_phrase_impact
189
+ # emitted identical scores for every pair of same-density sections.
190
+ pitch_classes = int(section_data.get("unique_pitch_classes", 0) or 0)
191
+ note_count = int(section_data.get("note_count", 0) or 0)
192
+ velocity_variance = float(section_data.get("velocity_variance", 0) or 0)
193
+ # Pitch-class diversity → contrast lift: 0 classes = 0, 7+ = +0.3
194
+ pc_contrast_bonus = min(0.3, pitch_classes * 0.04)
195
+ # Note-density signal: more notes = richer content
196
+ note_density_signal = min(1.0, note_count / 50.0)
197
+ # Velocity variance → dynamic interest
198
+ dynamic_interest = min(1.0, velocity_variance / 200.0)
199
+
200
+ # Contrast: density / energy change + pitch-class diversity
201
+ contrast = min(
202
+ 1.0,
203
+ abs(density - prev_density) + abs(energy_delta) + pc_contrast_bonus,
204
+ )
159
205
 
160
- # Repetition fatigue: high density with no change = fatiguing
161
- fatigue = max(0.0, 1.0 - contrast) * 0.5
206
+ # Repetition fatigue: high density + low dynamic variance = fatiguing
207
+ base_fatigue = max(0.0, 1.0 - contrast) * 0.5
208
+ # Flat velocity → more fatigue; dynamic variation → less
209
+ fatigue = round(max(0.0, base_fatigue - dynamic_interest * 0.15), 3)
162
210
 
163
- # Section clarity: does it have a clear role?
164
- clarity = 0.7 if section_data.get("label") else 0.3
211
+ # Section clarity: does it have a clear role + content to back it up?
212
+ label_clarity = 0.7 if section_data.get("label") else 0.3
213
+ content_clarity = 0.1 * min(1.0, note_count / 20.0)
214
+ clarity = min(1.0, label_clarity + content_clarity)
165
215
 
166
216
  # Groove continuity: rhythm present
167
217
  groove = 0.7 if section_data.get("has_drums", True) else 0.3
218
+ # Boost groove continuity when the section has genuine rhythmic
219
+ # activity (note_density_signal nudges it up, flat sections down)
220
+ groove = min(1.0, groove + note_density_signal * 0.1)
168
221
 
169
222
  # Payoff balance
170
223
  payoff = min(1.0, (arrival + anticipation) / 2)