livepilot 1.10.5 → 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 (111) 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 +92 -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-evaluation/references/capability-modes.md +1 -1
  15. package/livepilot/skills/livepilot-release/SKILL.md +8 -8
  16. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  17. package/m4l_device/LivePilot_Analyzer.amxd.pre-presentation-backup +0 -0
  18. package/m4l_device/LivePilot_Analyzer.maxproj +53 -0
  19. package/m4l_device/livepilot_bridge.js +226 -3
  20. package/manifest.json +3 -3
  21. package/mcp_server/__init__.py +1 -1
  22. package/mcp_server/atlas/__init__.py +93 -26
  23. package/mcp_server/composer/sample_resolver.py +10 -6
  24. package/mcp_server/composer/tools.py +10 -6
  25. package/mcp_server/connection.py +6 -1
  26. package/mcp_server/creative_constraints/tools.py +214 -40
  27. package/mcp_server/experiment/engine.py +16 -14
  28. package/mcp_server/experiment/tools.py +9 -9
  29. package/mcp_server/hook_hunter/analyzer.py +62 -9
  30. package/mcp_server/hook_hunter/tools.py +74 -18
  31. package/mcp_server/m4l_bridge.py +32 -6
  32. package/mcp_server/memory/taste_graph.py +7 -2
  33. package/mcp_server/mix_engine/tools.py +8 -3
  34. package/mcp_server/musical_intelligence/detectors.py +32 -0
  35. package/mcp_server/musical_intelligence/tools.py +15 -10
  36. package/mcp_server/performance_engine/tools.py +117 -30
  37. package/mcp_server/preview_studio/engine.py +89 -8
  38. package/mcp_server/preview_studio/tools.py +43 -21
  39. package/mcp_server/project_brain/automation_graph.py +71 -19
  40. package/mcp_server/project_brain/builder.py +2 -0
  41. package/mcp_server/project_brain/tools.py +73 -15
  42. package/mcp_server/reference_engine/profile_builder.py +129 -3
  43. package/mcp_server/reference_engine/tools.py +54 -11
  44. package/mcp_server/runtime/capability_probe.py +10 -4
  45. package/mcp_server/runtime/execution_router.py +50 -0
  46. package/mcp_server/runtime/mcp_dispatch.py +75 -3
  47. package/mcp_server/runtime/remote_commands.py +4 -2
  48. package/mcp_server/runtime/tools.py +8 -2
  49. package/mcp_server/sample_engine/analyzer.py +131 -4
  50. package/mcp_server/sample_engine/critics.py +29 -8
  51. package/mcp_server/sample_engine/models.py +20 -1
  52. package/mcp_server/sample_engine/tools.py +74 -31
  53. package/mcp_server/semantic_moves/sound_design_compilers.py +22 -59
  54. package/mcp_server/semantic_moves/tools.py +5 -1
  55. package/mcp_server/semantic_moves/transition_compilers.py +12 -19
  56. package/mcp_server/server.py +78 -11
  57. package/mcp_server/services/motif_service.py +9 -3
  58. package/mcp_server/session_continuity/models.py +4 -0
  59. package/mcp_server/session_continuity/tools.py +7 -3
  60. package/mcp_server/session_continuity/tracker.py +23 -9
  61. package/mcp_server/song_brain/builder.py +110 -12
  62. package/mcp_server/song_brain/tools.py +94 -25
  63. package/mcp_server/sound_design/tools.py +112 -1
  64. package/mcp_server/splice_client/client.py +19 -6
  65. package/mcp_server/stuckness_detector/detector.py +90 -0
  66. package/mcp_server/stuckness_detector/tools.py +49 -5
  67. package/mcp_server/tools/_agent_os_engine/__init__.py +52 -0
  68. package/mcp_server/tools/_agent_os_engine/critics.py +158 -0
  69. package/mcp_server/tools/_agent_os_engine/evaluation.py +206 -0
  70. package/mcp_server/tools/_agent_os_engine/models.py +132 -0
  71. package/mcp_server/tools/_agent_os_engine/taste.py +192 -0
  72. package/mcp_server/tools/_agent_os_engine/techniques.py +161 -0
  73. package/mcp_server/tools/_agent_os_engine/world_model.py +170 -0
  74. package/mcp_server/tools/_composition_engine/__init__.py +67 -0
  75. package/mcp_server/tools/_composition_engine/analysis.py +174 -0
  76. package/mcp_server/tools/_composition_engine/critics.py +522 -0
  77. package/mcp_server/tools/_composition_engine/gestures.py +230 -0
  78. package/mcp_server/tools/_composition_engine/harmony.py +160 -0
  79. package/mcp_server/tools/_composition_engine/models.py +193 -0
  80. package/mcp_server/tools/_composition_engine/sections.py +414 -0
  81. package/mcp_server/tools/_harmony_engine.py +52 -8
  82. package/mcp_server/tools/_perception_engine.py +18 -11
  83. package/mcp_server/tools/_research_engine.py +98 -19
  84. package/mcp_server/tools/_theory_engine.py +138 -9
  85. package/mcp_server/tools/agent_os.py +43 -18
  86. package/mcp_server/tools/analyzer.py +105 -8
  87. package/mcp_server/tools/automation.py +6 -1
  88. package/mcp_server/tools/clips.py +45 -0
  89. package/mcp_server/tools/composition.py +90 -38
  90. package/mcp_server/tools/devices.py +32 -7
  91. package/mcp_server/tools/harmony.py +115 -14
  92. package/mcp_server/tools/midi_io.py +13 -1
  93. package/mcp_server/tools/mixing.py +35 -1
  94. package/mcp_server/tools/motif.py +56 -5
  95. package/mcp_server/tools/planner.py +6 -2
  96. package/mcp_server/tools/research.py +37 -10
  97. package/mcp_server/tools/theory.py +108 -16
  98. package/mcp_server/transition_engine/critics.py +18 -11
  99. package/mcp_server/transition_engine/tools.py +6 -1
  100. package/mcp_server/translation_engine/tools.py +8 -6
  101. package/mcp_server/wonder_mode/engine.py +8 -3
  102. package/mcp_server/wonder_mode/tools.py +29 -21
  103. package/package.json +2 -2
  104. package/remote_script/LivePilot/__init__.py +57 -2
  105. package/remote_script/LivePilot/clips.py +69 -0
  106. package/remote_script/LivePilot/mixing.py +117 -0
  107. package/remote_script/LivePilot/router.py +13 -1
  108. package/scripts/generate_tool_catalog.py +13 -38
  109. package/scripts/sync_metadata.py +231 -14
  110. package/mcp_server/tools/_agent_os_engine.py +0 -947
  111. package/mcp_server/tools/_composition_engine.py +0 -1530
@@ -33,20 +33,83 @@ def _identify_port_holder(port: int) -> str | None:
33
33
  text=True, timeout=2,
34
34
  ).strip()
35
35
  return f"{pid} ({cmdline[:60]})"
36
- except (subprocess.CalledProcessError, FileNotFoundError):
36
+ except (subprocess.CalledProcessError,
37
+ subprocess.TimeoutExpired,
38
+ FileNotFoundError):
37
39
  return str(pid)
38
40
  return None
39
- except (subprocess.CalledProcessError, FileNotFoundError, ValueError):
41
+ except (subprocess.CalledProcessError,
42
+ subprocess.TimeoutExpired,
43
+ FileNotFoundError,
44
+ ValueError):
45
+ # TimeoutExpired catches the busy-system case where lsof exceeds
46
+ # the 3-second budget; we treat it as "can't identify" and return
47
+ # None so startup never stalls for slow host diagnostics.
40
48
  return None
41
49
 
42
50
 
51
+ def _check_remote_script_version(ableton: AbletonConnection) -> None:
52
+ """BUG-A1: detect stale Remote Script installs at startup.
53
+
54
+ The installed Remote Script is loaded by Ableton at its own launch time
55
+ and cached in Python's module system — source-tree edits don't take
56
+ effect until the user reinstalls + restarts Live. When the installed
57
+ copy lags behind the MCP-server source, commands added after the install
58
+ date (e.g. ``insert_device`` in v1.10.6) return "Unknown command type".
59
+
60
+ This check pings the Remote Script, compares its reported version to
61
+ the MCP server version, and logs a loud warning on mismatch. We don't
62
+ abort — the server should still work for whatever handlers the older
63
+ Remote Script does support — but we make the drift visible.
64
+ """
65
+ import sys
66
+
67
+ try:
68
+ from . import __version__ as mcp_version
69
+ except ImportError:
70
+ mcp_version = "unknown"
71
+
72
+ try:
73
+ pong = ableton.send_command("ping")
74
+ except Exception as exc:
75
+ import logging as _logging
76
+ _logging.getLogger(__name__).debug(
77
+ "Remote Script version check failed: %s", exc,
78
+ )
79
+ return
80
+
81
+ if not isinstance(pong, dict):
82
+ return
83
+ rs_version = pong.get("remote_script_version")
84
+ if rs_version is None:
85
+ # Remote Script is old enough that it doesn't even embed its version
86
+ # in ping responses — definitely stale.
87
+ msg = (
88
+ "LivePilot: Remote Script is out of date (pre-version-handshake). "
89
+ "Run 'npx livepilot --install' and restart Ableton Live to fix "
90
+ "'Unknown command type' errors for newer tools (insert_device, "
91
+ "set_clip_pitch, etc)."
92
+ )
93
+ print(msg, file=sys.stderr)
94
+ return
95
+
96
+ if str(rs_version) != str(mcp_version):
97
+ msg = (
98
+ f"LivePilot: Remote Script version {rs_version} does not match "
99
+ f"MCP server version {mcp_version}. Newer tools may fail with "
100
+ f"'Unknown command type'. Run 'npx livepilot --install' and "
101
+ f"restart Ableton Live to resync."
102
+ )
103
+ print(msg, file=sys.stderr)
104
+
105
+
43
106
  def _master_has_livepilot_analyzer(ableton: AbletonConnection) -> bool:
44
107
  """Check whether the analyzer device is currently on the master track."""
45
108
  try:
46
109
  track = ableton.send_command("get_master_track")
47
- except Exception:
110
+ except Exception as exc:
111
+ logger.debug("_master_has_livepilot_analyzer failed: %s", exc)
48
112
  return False
49
-
50
113
  devices = track.get("devices", []) if isinstance(track, dict) else []
51
114
  for device in devices:
52
115
  normalized = " ".join(
@@ -92,7 +155,8 @@ async def lifespan(server):
92
155
  splice_client = SpliceGRPCClient()
93
156
  try:
94
157
  await splice_client.connect()
95
- except Exception:
158
+ except Exception as exc:
159
+ logger.debug("lifespan failed: %s", exc)
96
160
  pass # client remains in disconnected state
97
161
 
98
162
  # Start UDP listener for incoming M4L spectral data (port 9880)
@@ -127,6 +191,9 @@ async def lifespan(server):
127
191
  }
128
192
 
129
193
  try:
194
+ # BUG-A1: detect stale Remote Script installs early so the user
195
+ # sees a clear message instead of cryptic "Unknown command type" errors.
196
+ _check_remote_script_version(ableton)
130
197
  if bridge_state["transport"] is not None:
131
198
  await _warm_analyzer_bridge(ableton, spectral)
132
199
  yield {
@@ -144,10 +211,8 @@ async def lifespan(server):
144
211
  ableton.disconnect()
145
212
  try:
146
213
  await splice_client.disconnect()
147
- except Exception:
148
- pass
149
-
150
-
214
+ except Exception as exc:
215
+ logger.debug("lifespan failed: %s", exc)
151
216
  mcp = FastMCP("LivePilot", lifespan=lifespan)
152
217
 
153
218
  # Import tool modules so they register with `mcp`
@@ -199,7 +264,9 @@ from .device_forge import tools as device_forge_tools # noqa: F401, E40
199
264
  from .sample_engine import tools as sample_engine_tools # noqa: F401, E402
200
265
  from .atlas import tools as atlas_tools # noqa: F401, E402
201
266
  from .composer import tools as composer_tools # noqa: F401, E402
267
+ import logging
202
268
 
269
+ logger = logging.getLogger(__name__)
203
270
 
204
271
  # ---------------------------------------------------------------------------
205
272
  # Schema coercion patch — accept strings for numeric parameters
@@ -213,6 +280,7 @@ from .composer import tools as composer_tools # noqa: F401, E40
213
280
  # "5" → 5 and "0.75" → 0.75 automatically, so no tool code changes needed.
214
281
  # ---------------------------------------------------------------------------
215
282
 
283
+
216
284
  def _coerce_schema_property(prop: dict) -> None:
217
285
  """Widen a single JSON Schema property to also accept strings."""
218
286
  if prop.get("type") in ("integer", "number") and "anyOf" not in prop:
@@ -253,6 +321,7 @@ def _get_all_tools():
253
321
  if hasattr(mcp, "_local_provider") and hasattr(mcp._local_provider, "_components"):
254
322
  return list(mcp._local_provider._components.values())
255
323
  import sys
324
+
256
325
  print(
257
326
  "LivePilot: WARNING — could not access FastMCP tool registry, "
258
327
  "string-to-number schema coercion will not work",
@@ -273,7 +342,6 @@ def _patch_tool_schemas() -> None:
273
342
  if isinstance(definition, dict):
274
343
  _coerce_schema_property(definition)
275
344
 
276
-
277
345
  _patch_tool_schemas()
278
346
 
279
347
 
@@ -281,6 +349,5 @@ def main():
281
349
  """Run the MCP server over stdio."""
282
350
  mcp.run(transport="stdio")
283
351
 
284
-
285
352
  if __name__ == "__main__":
286
353
  main()
@@ -9,6 +9,9 @@ Pure computation — no I/O. Callers provide pre-fetched data.
9
9
  from __future__ import annotations
10
10
 
11
11
  from typing import Optional
12
+ import logging
13
+
14
+ logger = logging.getLogger(__name__)
12
15
 
13
16
 
14
17
  def get_motif_data(
@@ -28,13 +31,15 @@ def get_motif_data(
28
31
 
29
32
  try:
30
33
  from ..tools import _motif_engine as motif_engine
34
+
31
35
  motifs = motif_engine.detect_motifs(notes_by_track)
32
36
  return {
33
37
  "motifs": [m.to_dict() for m in motifs],
34
38
  "motif_count": len(motifs),
35
39
  "tracks_analyzed": len(notes_by_track),
36
40
  }
37
- except Exception:
41
+ except Exception as exc:
42
+ logger.debug("get_motif_data failed: %s", exc)
38
43
  return {"motifs": [], "motif_count": 0, "tracks_analyzed": 0}
39
44
 
40
45
 
@@ -60,8 +65,9 @@ def fetch_notes_from_ableton(ableton, tracks: list[dict], max_clips: int = 8) ->
60
65
  "clip_index": clip_idx,
61
66
  })
62
67
  track_notes.extend(result.get("notes", []))
63
- except Exception:
64
- pass
68
+ except Exception as exc:
69
+ logger.debug("fetch_notes_from_ableton failed: %s", exc)
70
+
65
71
  if track_notes:
66
72
  notes_by_track[t_idx] = track_notes
67
73
  return notes_by_track
@@ -50,6 +50,9 @@ class SessionStory:
50
50
  """The narrative of the current session."""
51
51
 
52
52
  song_id: str = ""
53
+ # BUG-B16: link back to the SongBrain snapshot that generated the
54
+ # identity_summary so callers can tell which brain was used.
55
+ song_brain_id: str = ""
53
56
  identity_summary: str = ""
54
57
  what_changed_last: str = ""
55
58
  what_still_feels_open: list[str] = field(default_factory=list)
@@ -60,6 +63,7 @@ class SessionStory:
60
63
  def to_dict(self) -> dict:
61
64
  return {
62
65
  "song_id": self.song_id,
66
+ "song_brain_id": self.song_brain_id,
63
67
  "identity_summary": self.identity_summary,
64
68
  "what_changed_last": self.what_changed_last,
65
69
  "what_still_feels_open": self.what_still_feels_open,
@@ -15,6 +15,9 @@ from fastmcp import Context
15
15
 
16
16
  from ..server import mcp
17
17
  from . import tracker
18
+ import logging
19
+
20
+ logger = logging.getLogger(__name__)
18
21
 
19
22
 
20
23
  @mcp.tool()
@@ -200,7 +203,6 @@ def explain_preference_vs_identity(
200
203
  "note": "Identity has stronger weight inside a session (0.65 vs 0.35)",
201
204
  }
202
205
 
203
-
204
206
  # ── Helpers ───────────────────────────────────────────────────────
205
207
 
206
208
 
@@ -222,9 +224,11 @@ def _get_taste_graph(ctx: Context) -> dict:
222
224
  from ..memory.taste_graph import build_taste_graph
223
225
  from ..memory.taste_memory import TasteMemoryStore
224
226
  from ..memory.anti_memory import AntiMemoryStore
227
+
225
228
  taste_store = ctx.lifespan_context.setdefault("taste_memory", TasteMemoryStore())
226
229
  anti_store = ctx.lifespan_context.setdefault("anti_memory", AntiMemoryStore())
227
230
  return build_taste_graph(taste_store=taste_store, anti_store=anti_store).to_dict()
228
- except Exception:
229
- pass
231
+ except Exception as exc:
232
+ logger.debug("_get_taste_graph failed: %s", exc)
233
+
230
234
  return {}
@@ -7,6 +7,7 @@ Separates taste (cross-session) from identity (in-song) ranking.
7
7
  from __future__ import annotations
8
8
 
9
9
  import hashlib
10
+ import logging
10
11
  import time
11
12
  from typing import Optional
12
13
 
@@ -17,6 +18,8 @@ from .models import (
17
18
  TurnResolution,
18
19
  )
19
20
 
21
+ logger = logging.getLogger(__name__)
22
+
20
23
 
21
24
  # ── In-memory state ───────────────────────────────────────────────
22
25
 
@@ -47,10 +50,23 @@ def reset_story() -> None:
47
50
  def get_session_story(
48
51
  song_brain: Optional[dict] = None,
49
52
  ) -> SessionStory:
50
- """Get the current session story with identity summary."""
53
+ """Get the current session story with identity summary.
54
+
55
+ BUG-B16: now also populates song_brain_id from the passed brain so
56
+ callers can tell which brain generated the identity_summary.
57
+ Previously the field was empty and users got a half-populated
58
+ response that read as "something's wrong" even though the partial
59
+ data was correct for a fresh session.
60
+ """
51
61
  song_brain = song_brain or {}
52
62
 
53
63
  _story.identity_summary = song_brain.get("identity_core", "")
64
+ _story.song_brain_id = str(song_brain.get("brain_id", "") or "")
65
+ # Carry song_id through when present on the brain — fresh sessions
66
+ # leave this empty, which is documented below.
67
+ if not _story.song_id and song_brain.get("song_id"):
68
+ _story.song_id = str(song_brain.get("song_id"))
69
+
54
70
  _story.threads = [t for t in _threads.values() if t.status == "open"]
55
71
  _story.turns = _turns
56
72
  _story.what_still_feels_open = [
@@ -129,9 +145,8 @@ def record_turn_resolution(
129
145
  if _project_store is not None:
130
146
  try:
131
147
  _project_store.save_turn(turn.to_dict())
132
- except Exception:
133
- pass
134
-
148
+ except Exception as exc:
149
+ logger.debug("record_turn_resolution failed: %s", exc)
135
150
  return turn
136
151
 
137
152
 
@@ -158,9 +173,8 @@ def open_thread(description: str, domain: str = "", priority: float = 0.5) -> Cr
158
173
  if _project_store is not None:
159
174
  try:
160
175
  _project_store.save_thread(thread.to_dict())
161
- except Exception:
162
- pass
163
-
176
+ except Exception as exc:
177
+ logger.debug("open_thread failed: %s", exc)
164
178
  return thread
165
179
 
166
180
 
@@ -173,8 +187,8 @@ def resolve_thread(thread_id: str) -> Optional[CreativeThread]:
173
187
  if _project_store is not None:
174
188
  try:
175
189
  _project_store.save_thread(thread.to_dict())
176
- except Exception:
177
- pass
190
+ except Exception as exc:
191
+ logger.debug("resolve_thread failed: %s", exc)
178
192
  return thread
179
193
 
180
194
 
@@ -127,6 +127,14 @@ def _infer_identity_core(
127
127
  """Infer the single strongest defining idea in the session.
128
128
 
129
129
  Returns (description, confidence).
130
+
131
+ BUG-B10 fix: the old logic picked "Dominant texture: drums" at
132
+ confidence 0.5 for almost every session — because drum tracks
133
+ typically have the most notes. We now consider richer signals:
134
+ featured vocals, scene-name aesthetics, tempo+key context, and
135
+ single-instrument dominance. When multiple low-confidence signals
136
+ align (e.g. "dust" aesthetic + vocal hook + D minor key), we
137
+ combine them into a compound identity string.
130
138
  """
131
139
  candidates: list[tuple[str, float]] = []
132
140
 
@@ -144,7 +152,7 @@ def _infer_identity_core(
144
152
  if arc_type:
145
153
  candidates.append((f"Emotional arc: {arc_type}", 0.6))
146
154
 
147
- # From role graph — dominant texture
155
+ # From role graph — dominant texture (kept but gently deranked)
148
156
  # role_graph format: {track_name: {index: int, role: str}}
149
157
  if role_graph:
150
158
  role_counts = Counter(
@@ -153,9 +161,15 @@ def _infer_identity_core(
153
161
  if isinstance(info, dict)
154
162
  )
155
163
  role_counts.pop("unknown", None)
156
- if role_counts:
157
- dominant_role = role_counts.most_common(1)[0]
158
- candidates.append((f"Dominant texture: {dominant_role[0]}", 0.5))
164
+ # B10 fix: drums being the "dominant texture" is almost never
165
+ # what the song is ABOUT — it's just that drum tracks have the
166
+ # most notes. Skip drums/perc from this candidate stream.
167
+ _BORING_DOMINANT = {"drums", "percussion", "kick", "snare", "hat"}
168
+ for role, _ in role_counts.most_common(3):
169
+ if role.lower() in _BORING_DOMINANT:
170
+ continue
171
+ candidates.append((f"Dominant texture: {role}", 0.55))
172
+ break
159
173
 
160
174
  # From track analysis — genre/style cues
161
175
  track_names = [t.get("name", "").lower() for t in tracks]
@@ -163,12 +177,65 @@ def _infer_identity_core(
163
177
  if genre_cues:
164
178
  candidates.append((f"Style: {genre_cues}", 0.4))
165
179
 
180
+ # BUG-B10: featured instrument — a named vocal/pad/lead track with
181
+ # an explicit function is usually more identity-defining than
182
+ # "dominant texture: drums".
183
+ _FEATURED_TOKENS = (
184
+ ("vocal", "vocal hook", 0.75),
185
+ ("vox", "vocal hook", 0.72),
186
+ ("pad", "pad-led atmosphere", 0.55),
187
+ ("lead", "lead synth melody", 0.65),
188
+ ("rhodes", "rhodes-keys texture", 0.60),
189
+ ("piano", "piano-led harmony", 0.60),
190
+ ("guitar", "guitar-led", 0.60),
191
+ ("saxophone", "saxophone solo", 0.65),
192
+ ("brass", "brass section", 0.55),
193
+ )
194
+ for name in track_names:
195
+ for token, label, conf in _FEATURED_TOKENS:
196
+ if token in name:
197
+ candidates.append((f"Featured element: {label}", conf))
198
+ break
199
+
200
+ # BUG-B10: scene-name aesthetic cues. A scene named "Intro Dust" /
201
+ # "Outro Dust" signals a deliberate dust/lo-fi aesthetic; "Sun Peak"
202
+ # / "Peak" signals a climax-oriented structure. Pull these from the
203
+ # composition-analysis section list if present.
204
+ _AESTHETIC_TOKENS = (
205
+ ("dust", "dust-toned lo-fi"),
206
+ ("sun", "warm/sun-peaked"),
207
+ ("fog", "foggy/dreamy"),
208
+ ("glass", "brittle/glass-like"),
209
+ ("void", "void/ambient-spatial"),
210
+ ("haze", "hazy/nostalgic"),
211
+ ("bloom", "blooming/evolving"),
212
+ )
213
+ sections = composition.get("sections", []) or []
214
+ section_names = " ".join(
215
+ str(s.get("name", "") or s.get("label", "")).lower()
216
+ for s in sections
217
+ )
218
+ for token, label in _AESTHETIC_TOKENS:
219
+ if token in section_names:
220
+ candidates.append((f"Aesthetic: {label}", 0.55))
221
+ break
222
+
166
223
  if not candidates:
167
224
  # Fallback: describe by track count and tempo
168
225
  return ("Emerging piece — identity not yet established", 0.2)
169
226
 
170
- best = max(candidates, key=lambda c: c[1])
171
- return best
227
+ # BUG-B10: when no single candidate is confident (>0.6), blend the
228
+ # top 2 into a compound identity — captures "vocal hook + dust
229
+ # aesthetic" style identity rather than picking one weak signal.
230
+ candidates.sort(key=lambda c: c[1], reverse=True)
231
+ top = candidates[0]
232
+ if top[1] >= 0.6 or len(candidates) < 2:
233
+ return top
234
+ # Blend top 2
235
+ second = candidates[1]
236
+ blended_desc = f"{top[0]} + {second[0].lower()}"
237
+ blended_conf = min(0.85, (top[1] + second[1]) / 2 + 0.1)
238
+ return (blended_desc, blended_conf)
172
239
 
173
240
 
174
241
  def _detect_genre_cues(track_names: list[str]) -> str:
@@ -260,6 +327,12 @@ def _detect_sacred_elements(
260
327
  # ── Section purposes ──────────────────────────────────────────────
261
328
 
262
329
 
330
+ # Section intents that imply this is a "payoff" / arrival moment.
331
+ # Used by _infer_section_purposes to derive is_payoff consistently
332
+ # when composition returns an intent label without the explicit flag.
333
+ _PAYOFF_INTENTS = frozenset({"payoff", "drop", "chorus", "hook"})
334
+
335
+
263
336
  def _infer_section_purposes(
264
337
  scenes: list[dict],
265
338
  composition: dict,
@@ -271,12 +344,27 @@ def _infer_section_purposes(
271
344
  comp_sections = composition.get("sections", [])
272
345
  if comp_sections:
273
346
  for sec in comp_sections:
347
+ name = str(sec.get("name", ""))
348
+ # BUG-B12: skip empty placeholder sections that pollute the
349
+ # energy_arc and section_purposes list. A section with no name
350
+ # AND zero energy corresponds to an unnamed empty scene slot.
351
+ if not name.strip() and not sec.get("energy", 0):
352
+ continue
353
+ intent = sec.get("intent", sec.get("purpose", "")) or ""
354
+ # BUG-B11: derive is_payoff from the intent label when the
355
+ # explicit flag isn't set. Composition engine returns
356
+ # intent="drop"/"chorus"/"hook"/"payoff" — these all mean the
357
+ # section IS a payoff, so is_payoff must reflect that.
358
+ is_payoff = bool(
359
+ sec.get("is_payoff", False)
360
+ or intent.lower() in _PAYOFF_INTENTS
361
+ )
274
362
  sections.append(SectionPurpose(
275
- section_id=sec.get("id", sec.get("name", "")),
276
- label=sec.get("label", sec.get("name", "")),
277
- emotional_intent=sec.get("intent", sec.get("purpose", "")),
363
+ section_id=sec.get("id", name),
364
+ label=sec.get("label", name),
365
+ emotional_intent=intent,
278
366
  energy_level=sec.get("energy", 0.5),
279
- is_payoff=sec.get("is_payoff", False),
367
+ is_payoff=is_payoff,
280
368
  confidence=0.7,
281
369
  ))
282
370
  return sections
@@ -284,6 +372,10 @@ def _infer_section_purposes(
284
372
  # Fallback: infer from scene names
285
373
  for i, scene in enumerate(scenes):
286
374
  name = scene.get("name", f"Scene {i}")
375
+ # BUG-B12 (fallback path): skip empty scenes so they don't pollute
376
+ # the output even when no composition data is available.
377
+ if not str(name).strip():
378
+ continue
287
379
  label, intent, energy, is_payoff = _classify_scene_name(name, i, len(scenes))
288
380
  sections.append(SectionPurpose(
289
381
  section_id=f"scene_{i}",
@@ -389,8 +481,14 @@ def _detect_open_questions(
389
481
  ))
390
482
 
391
483
  # Missing sections (common gaps)
392
- labels = {s.label for s in sections}
393
- if len(sections) > 3 and "intro" not in labels:
484
+ # BUG-B14: check substrings across labels AND emotional intents
485
+ # (case-insensitive) so scene names like "Intro Dust" or intent "intro"
486
+ # both satisfy the check. Exact-match on the label set missed those.
487
+ signal_text = " ".join(
488
+ f"{s.label} {s.emotional_intent}".lower() for s in sections
489
+ )
490
+ has_intro = any(kw in signal_text for kw in ("intro", "opening", "opener"))
491
+ if len(sections) > 3 and not has_intro:
394
492
  questions.append(OpenQuestion(
395
493
  question="No intro section — does the track need an opening?",
396
494
  domain="arrangement",