livepilot 1.10.6 → 1.10.8

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 (163) hide show
  1. package/CHANGELOG.md +168 -0
  2. package/README.md +12 -10
  3. package/bin/livepilot.js +168 -30
  4. package/installer/install.js +117 -11
  5. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  6. package/m4l_device/livepilot_bridge.js +215 -3
  7. package/mcp_server/__init__.py +1 -1
  8. package/mcp_server/atlas/__init__.py +132 -33
  9. package/mcp_server/atlas/tools.py +56 -15
  10. package/mcp_server/composer/layer_planner.py +27 -0
  11. package/mcp_server/composer/prompt_parser.py +15 -6
  12. package/mcp_server/connection.py +11 -3
  13. package/mcp_server/corpus/__init__.py +14 -4
  14. package/mcp_server/creative_constraints/tools.py +206 -33
  15. package/mcp_server/experiment/engine.py +7 -9
  16. package/mcp_server/hook_hunter/analyzer.py +62 -9
  17. package/mcp_server/hook_hunter/tools.py +60 -9
  18. package/mcp_server/m4l_bridge.py +68 -12
  19. package/mcp_server/musical_intelligence/detectors.py +32 -0
  20. package/mcp_server/performance_engine/tools.py +112 -29
  21. package/mcp_server/preview_studio/engine.py +89 -8
  22. package/mcp_server/preview_studio/tools.py +22 -6
  23. package/mcp_server/project_brain/automation_graph.py +71 -19
  24. package/mcp_server/project_brain/builder.py +2 -0
  25. package/mcp_server/project_brain/tools.py +55 -5
  26. package/mcp_server/reference_engine/profile_builder.py +129 -3
  27. package/mcp_server/reference_engine/tools.py +47 -6
  28. package/mcp_server/runtime/execution_router.py +66 -2
  29. package/mcp_server/runtime/mcp_dispatch.py +75 -3
  30. package/mcp_server/runtime/remote_commands.py +10 -2
  31. package/mcp_server/sample_engine/analyzer.py +131 -4
  32. package/mcp_server/sample_engine/critics.py +29 -8
  33. package/mcp_server/sample_engine/models.py +42 -4
  34. package/mcp_server/sample_engine/tools.py +48 -14
  35. package/mcp_server/semantic_moves/__init__.py +1 -0
  36. package/mcp_server/semantic_moves/compiler.py +9 -1
  37. package/mcp_server/semantic_moves/device_creation_compilers.py +47 -0
  38. package/mcp_server/semantic_moves/mix_compilers.py +170 -0
  39. package/mcp_server/semantic_moves/mix_moves.py +1 -1
  40. package/mcp_server/semantic_moves/models.py +5 -0
  41. package/mcp_server/semantic_moves/sound_design_compilers.py +22 -59
  42. package/mcp_server/semantic_moves/tools.py +15 -4
  43. package/mcp_server/semantic_moves/transition_compilers.py +12 -19
  44. package/mcp_server/server.py +75 -5
  45. package/mcp_server/services/singletons.py +68 -0
  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/splice_client/client.py +29 -8
  52. package/mcp_server/stuckness_detector/detector.py +90 -0
  53. package/mcp_server/stuckness_detector/tools.py +41 -0
  54. package/mcp_server/tools/_agent_os_engine/critics.py +24 -0
  55. package/mcp_server/tools/_composition_engine/__init__.py +2 -2
  56. package/mcp_server/tools/_composition_engine/harmony.py +90 -0
  57. package/mcp_server/tools/_composition_engine/sections.py +47 -4
  58. package/mcp_server/tools/_harmony_engine.py +52 -8
  59. package/mcp_server/tools/_research_engine.py +98 -19
  60. package/mcp_server/tools/_theory_engine.py +138 -9
  61. package/mcp_server/tools/agent_os.py +20 -3
  62. package/mcp_server/tools/analyzer.py +105 -6
  63. package/mcp_server/tools/clips.py +46 -1
  64. package/mcp_server/tools/composition.py +66 -23
  65. package/mcp_server/tools/devices.py +22 -1
  66. package/mcp_server/tools/harmony.py +115 -14
  67. package/mcp_server/tools/midi_io.py +23 -1
  68. package/mcp_server/tools/mixing.py +35 -1
  69. package/mcp_server/tools/motif.py +49 -3
  70. package/mcp_server/tools/research.py +24 -0
  71. package/mcp_server/tools/theory.py +108 -16
  72. package/mcp_server/tools/tracks.py +1 -1
  73. package/mcp_server/tools/transport.py +1 -1
  74. package/mcp_server/transition_engine/critics.py +18 -11
  75. package/mcp_server/translation_engine/tools.py +8 -4
  76. package/package.json +25 -3
  77. package/remote_script/LivePilot/__init__.py +77 -2
  78. package/remote_script/LivePilot/arrangement.py +12 -2
  79. package/remote_script/LivePilot/browser.py +16 -6
  80. package/remote_script/LivePilot/clips.py +69 -0
  81. package/remote_script/LivePilot/devices.py +10 -5
  82. package/remote_script/LivePilot/mixing.py +117 -0
  83. package/remote_script/LivePilot/notes.py +13 -2
  84. package/remote_script/LivePilot/router.py +13 -1
  85. package/remote_script/LivePilot/server.py +51 -13
  86. package/remote_script/LivePilot/version_detect.py +7 -4
  87. package/server.json +20 -0
  88. package/.claude-plugin/marketplace.json +0 -21
  89. package/.mcpbignore +0 -57
  90. package/AGENTS.md +0 -46
  91. package/CODE_OF_CONDUCT.md +0 -27
  92. package/CONTRIBUTING.md +0 -131
  93. package/SECURITY.md +0 -48
  94. package/livepilot/.Codex-plugin/plugin.json +0 -8
  95. package/livepilot/.claude-plugin/plugin.json +0 -8
  96. package/livepilot/agents/livepilot-producer/AGENT.md +0 -313
  97. package/livepilot/commands/arrange.md +0 -47
  98. package/livepilot/commands/beat.md +0 -77
  99. package/livepilot/commands/evaluate.md +0 -49
  100. package/livepilot/commands/memory.md +0 -22
  101. package/livepilot/commands/mix.md +0 -44
  102. package/livepilot/commands/perform.md +0 -42
  103. package/livepilot/commands/session.md +0 -13
  104. package/livepilot/commands/sounddesign.md +0 -43
  105. package/livepilot/skills/livepilot-arrangement/SKILL.md +0 -155
  106. package/livepilot/skills/livepilot-composition-engine/SKILL.md +0 -107
  107. package/livepilot/skills/livepilot-composition-engine/references/form-patterns.md +0 -97
  108. package/livepilot/skills/livepilot-composition-engine/references/transition-archetypes.md +0 -102
  109. package/livepilot/skills/livepilot-core/SKILL.md +0 -184
  110. package/livepilot/skills/livepilot-core/references/ableton-workflow-patterns.md +0 -831
  111. package/livepilot/skills/livepilot-core/references/automation-atlas.md +0 -272
  112. package/livepilot/skills/livepilot-core/references/device-atlas/00-index.md +0 -110
  113. package/livepilot/skills/livepilot-core/references/device-atlas/distortion-and-character.md +0 -687
  114. package/livepilot/skills/livepilot-core/references/device-atlas/drums-and-percussion.md +0 -753
  115. package/livepilot/skills/livepilot-core/references/device-atlas/dynamics-and-punch.md +0 -525
  116. package/livepilot/skills/livepilot-core/references/device-atlas/eq-and-filtering.md +0 -402
  117. package/livepilot/skills/livepilot-core/references/device-atlas/midi-tools.md +0 -963
  118. package/livepilot/skills/livepilot-core/references/device-atlas/movement-and-modulation.md +0 -874
  119. package/livepilot/skills/livepilot-core/references/device-atlas/space-and-depth.md +0 -571
  120. package/livepilot/skills/livepilot-core/references/device-atlas/spectral-and-weird.md +0 -714
  121. package/livepilot/skills/livepilot-core/references/device-atlas/synths-native.md +0 -953
  122. package/livepilot/skills/livepilot-core/references/device-knowledge/00-index.md +0 -34
  123. package/livepilot/skills/livepilot-core/references/device-knowledge/automation-as-music.md +0 -204
  124. package/livepilot/skills/livepilot-core/references/device-knowledge/chains-genre.md +0 -173
  125. package/livepilot/skills/livepilot-core/references/device-knowledge/creative-thinking.md +0 -211
  126. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-distortion.md +0 -188
  127. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-space.md +0 -162
  128. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-spectral.md +0 -229
  129. package/livepilot/skills/livepilot-core/references/device-knowledge/instruments-synths.md +0 -243
  130. package/livepilot/skills/livepilot-core/references/m4l-devices.md +0 -352
  131. package/livepilot/skills/livepilot-core/references/memory-guide.md +0 -107
  132. package/livepilot/skills/livepilot-core/references/midi-recipes.md +0 -402
  133. package/livepilot/skills/livepilot-core/references/mixing-patterns.md +0 -578
  134. package/livepilot/skills/livepilot-core/references/overview.md +0 -290
  135. package/livepilot/skills/livepilot-core/references/sample-manipulation.md +0 -724
  136. package/livepilot/skills/livepilot-core/references/sound-design-deep.md +0 -140
  137. package/livepilot/skills/livepilot-core/references/sound-design.md +0 -393
  138. package/livepilot/skills/livepilot-devices/SKILL.md +0 -169
  139. package/livepilot/skills/livepilot-evaluation/SKILL.md +0 -156
  140. package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +0 -118
  141. package/livepilot/skills/livepilot-evaluation/references/evaluation-contracts.md +0 -121
  142. package/livepilot/skills/livepilot-evaluation/references/memory-promotion.md +0 -110
  143. package/livepilot/skills/livepilot-mix-engine/SKILL.md +0 -123
  144. package/livepilot/skills/livepilot-mix-engine/references/mix-critics.md +0 -143
  145. package/livepilot/skills/livepilot-mix-engine/references/mix-moves.md +0 -105
  146. package/livepilot/skills/livepilot-mixing/SKILL.md +0 -157
  147. package/livepilot/skills/livepilot-notes/SKILL.md +0 -130
  148. package/livepilot/skills/livepilot-performance-engine/SKILL.md +0 -122
  149. package/livepilot/skills/livepilot-performance-engine/references/performance-safety.md +0 -98
  150. package/livepilot/skills/livepilot-release/SKILL.md +0 -130
  151. package/livepilot/skills/livepilot-sample-engine/SKILL.md +0 -105
  152. package/livepilot/skills/livepilot-sample-engine/references/sample-critics.md +0 -87
  153. package/livepilot/skills/livepilot-sample-engine/references/sample-philosophy.md +0 -51
  154. package/livepilot/skills/livepilot-sample-engine/references/sample-techniques.md +0 -131
  155. package/livepilot/skills/livepilot-sound-design-engine/SKILL.md +0 -168
  156. package/livepilot/skills/livepilot-sound-design-engine/references/patch-model.md +0 -119
  157. package/livepilot/skills/livepilot-sound-design-engine/references/sound-design-critics.md +0 -118
  158. package/livepilot/skills/livepilot-wonder/SKILL.md +0 -79
  159. package/m4l_device/LivePilot_Analyzer.maxpat +0 -2705
  160. package/manifest.json +0 -91
  161. package/mcp_server/splice_client/protos/app_pb2.pyi +0 -1153
  162. package/scripts/generate_tool_catalog.py +0 -131
  163. package/scripts/sync_metadata.py +0 -132
@@ -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)
@@ -135,7 +135,18 @@ def develop_hook(
135
135
  # Look up the actual hook to adapt strategies by type
136
136
  hook_type = "melodic" # default
137
137
  hook_description = "the hook"
138
- if hook_id:
138
+ # BUG-B31: when no hook_id is provided, default to the session's primary
139
+ # hook. Previously the tool emitted generic advice even though
140
+ # find_primary_hook was already available — users had to manually chain
141
+ # find_primary_hook → develop_hook to get type-specific tactics.
142
+ if not hook_id:
143
+ tracks, scenes, motif_data = _fetch_tracks_and_scenes(ctx)
144
+ primary = analyzer.find_primary_hook(tracks, motif_data, scenes)
145
+ if primary is not None:
146
+ hook_id = primary.hook_id
147
+ hook_type = primary.hook_type
148
+ hook_description = primary.description
149
+ elif hook_id:
139
150
  tracks, scenes, motif_data = _fetch_tracks_and_scenes(ctx)
140
151
  candidates = analyzer.find_hook_candidates(tracks, motif_data, scenes)
141
152
  match = [c for c in candidates if c.hook_id == hook_id]
@@ -333,7 +344,14 @@ def suggest_payoff_repair(ctx: Context) -> dict:
333
344
 
334
345
 
335
346
  def _get_section_data(ableton) -> list[dict]:
336
- """Build section data from Ableton scenes with real energy/density/has_drums."""
347
+ """Build section data from Ableton scenes with real energy/density/has_drums.
348
+
349
+ BUG-B51 fix: also fetches per-section note signals (unique pitch
350
+ count, note count, velocity-variance) so compare_phrase_impact can
351
+ differentiate two sections that share energy/density but have
352
+ different clip contents. Without these, the old comparator emitted
353
+ identical scores for every pair of same-density sections.
354
+ """
337
355
  sections: list[dict] = []
338
356
  try:
339
357
  matrix = ableton.send_command("get_scene_matrix")
@@ -342,11 +360,6 @@ def _get_section_data(ableton) -> list[dict]:
342
360
 
343
361
  # Detect drum track indices by name
344
362
  drum_keywords = {"drum", "beat", "kick", "hat", "perc", "snare"}
345
- track_names = []
346
- # tracks may be in matrix metadata or session_info
347
- for ti, row_entry in enumerate(matrix_rows[0] if matrix_rows else []):
348
- track_names.append("") # placeholder — we'll use scenes_list tracks if available
349
- # Use scene matrix track info if available
350
363
  track_info = matrix.get("tracks", [])
351
364
  drum_indices = set()
352
365
  for ti, track in enumerate(track_info):
@@ -361,16 +374,49 @@ def _get_section_data(ableton) -> list[dict]:
361
374
  clip_count = sum(1 for c in row if c)
362
375
  total_tracks = max(len(row), 1)
363
376
 
364
- # has_drums: check if any drum track has a clip in this scene
365
377
  has_drums = any(
366
378
  di < len(row) and row[di]
367
379
  for di in drum_indices
368
380
  ) if drum_indices else False
369
381
 
370
382
  density = min(1.0, clip_count / total_tracks)
371
- # energy: density + drum bonus
372
383
  energy = min(1.0, density + (0.1 if has_drums else 0.0))
373
384
 
385
+ # BUG-B51: cheap per-section note signals. Sample up to 3
386
+ # non-drum tracks in this scene for a flavor of the
387
+ # section's harmonic/rhythmic content. Keeps the call
388
+ # count bounded so compare_phrase_impact doesn't explode.
389
+ unique_pitches: set = set()
390
+ note_count = 0
391
+ velocity_variance = 0.0
392
+ sampled = 0
393
+ for t_idx, cell in enumerate(row):
394
+ if sampled >= 3 or not cell:
395
+ continue
396
+ if t_idx in drum_indices:
397
+ continue
398
+ try:
399
+ notes_resp = ableton.send_command("get_notes", {
400
+ "track_index": t_idx, "clip_index": i,
401
+ })
402
+ except Exception:
403
+ continue
404
+ notes = notes_resp.get("notes", []) if isinstance(
405
+ notes_resp, dict
406
+ ) else []
407
+ if not notes:
408
+ continue
409
+ sampled += 1
410
+ note_count += len(notes)
411
+ for n in notes:
412
+ unique_pitches.add(int(n.get("pitch", 0)) % 12)
413
+ vels = [int(n.get("velocity", 0)) for n in notes]
414
+ if len(vels) >= 2:
415
+ mean_v = sum(vels) / len(vels)
416
+ velocity_variance += sum(
417
+ (v - mean_v) ** 2 for v in vels
418
+ ) / len(vels)
419
+
374
420
  sections.append({
375
421
  "id": f"scene_{i}",
376
422
  "name": scene.get("name", f"Scene {i}"),
@@ -378,6 +424,11 @@ def _get_section_data(ableton) -> list[dict]:
378
424
  "energy": round(energy, 3),
379
425
  "density": round(density, 3),
380
426
  "has_drums": has_drums,
427
+ # BUG-B51: these three differentiate otherwise-identical
428
+ # sections. Downstream phrase scorer reads them.
429
+ "unique_pitch_classes": len(unique_pitches),
430
+ "note_count": note_count,
431
+ "velocity_variance": round(velocity_variance, 3),
381
432
  })
382
433
  except Exception as exc:
383
434
  logger.debug("_get_section_data failed: %s", exc)