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
@@ -17,6 +17,9 @@ from fastmcp import Context
17
17
 
18
18
  from ..server import mcp
19
19
  from . import analyzer
20
+ import logging
21
+
22
+ logger = logging.getLogger(__name__)
20
23
 
21
24
 
22
25
  def _get_ableton(ctx: Context):
@@ -39,8 +42,8 @@ def _fetch_tracks_and_scenes(ctx: Context) -> tuple[list[dict], list[dict], dict
39
42
  try:
40
43
  session = ableton.send_command("get_session_info", {})
41
44
  tracks = session.get("tracks", [])
42
- except Exception:
43
- pass
45
+ except Exception as exc:
46
+ logger.debug("_fetch_tracks_and_scenes failed: %s", exc)
44
47
 
45
48
  try:
46
49
  matrix = ableton.send_command("get_scene_matrix")
@@ -50,15 +53,16 @@ def _fetch_tracks_and_scenes(ctx: Context) -> tuple[list[dict], list[dict], dict
50
53
  zip(matrix.get("scenes", []), matrix.get("matrix", []))
51
54
  )
52
55
  ]
53
- except Exception:
54
- pass
56
+ except Exception as exc:
57
+ logger.debug("_fetch_tracks_and_scenes failed: %s", exc)
55
58
 
56
59
  # Fetch motif data — via shared motif service
57
60
  try:
58
61
  from ..services.motif_service import get_motif_data, fetch_notes_from_ableton
59
62
  notes_by_track = fetch_notes_from_ableton(ableton, tracks)
60
63
  motif_data = get_motif_data(notes_by_track)
61
- except Exception:
64
+ except Exception as exc:
65
+ logger.debug("_fetch_tracks_and_scenes failed: %s", exc)
62
66
  pass # Motif graph requires notes in clips; empty dict is valid fallback
63
67
 
64
68
  return tracks, scenes, motif_data
@@ -131,7 +135,18 @@ def develop_hook(
131
135
  # Look up the actual hook to adapt strategies by type
132
136
  hook_type = "melodic" # default
133
137
  hook_description = "the hook"
134
- 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:
135
150
  tracks, scenes, motif_data = _fetch_tracks_and_scenes(ctx)
136
151
  candidates = analyzer.find_hook_candidates(tracks, motif_data, scenes)
137
152
  match = [c for c in candidates if c.hook_id == hook_id]
@@ -325,12 +340,18 @@ def suggest_payoff_repair(ctx: Context) -> dict:
325
340
  "repair_count": len(repairs),
326
341
  }
327
342
 
328
-
329
343
  # ── Helpers ───────────────────────────────────────────────────────
330
344
 
331
345
 
332
346
  def _get_section_data(ableton) -> list[dict]:
333
- """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
+ """
334
355
  sections: list[dict] = []
335
356
  try:
336
357
  matrix = ableton.send_command("get_scene_matrix")
@@ -339,11 +360,6 @@ def _get_section_data(ableton) -> list[dict]:
339
360
 
340
361
  # Detect drum track indices by name
341
362
  drum_keywords = {"drum", "beat", "kick", "hat", "perc", "snare"}
342
- track_names = []
343
- # tracks may be in matrix metadata or session_info
344
- for ti, row_entry in enumerate(matrix_rows[0] if matrix_rows else []):
345
- track_names.append("") # placeholder — we'll use scenes_list tracks if available
346
- # Use scene matrix track info if available
347
363
  track_info = matrix.get("tracks", [])
348
364
  drum_indices = set()
349
365
  for ti, track in enumerate(track_info):
@@ -358,16 +374,49 @@ def _get_section_data(ableton) -> list[dict]:
358
374
  clip_count = sum(1 for c in row if c)
359
375
  total_tracks = max(len(row), 1)
360
376
 
361
- # has_drums: check if any drum track has a clip in this scene
362
377
  has_drums = any(
363
378
  di < len(row) and row[di]
364
379
  for di in drum_indices
365
380
  ) if drum_indices else False
366
381
 
367
382
  density = min(1.0, clip_count / total_tracks)
368
- # energy: density + drum bonus
369
383
  energy = min(1.0, density + (0.1 if has_drums else 0.0))
370
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
+
371
420
  sections.append({
372
421
  "id": f"scene_{i}",
373
422
  "name": scene.get("name", f"Scene {i}"),
@@ -375,9 +424,14 @@ def _get_section_data(ableton) -> list[dict]:
375
424
  "energy": round(energy, 3),
376
425
  "density": round(density, 3),
377
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),
378
432
  })
379
- except Exception:
380
- pass
433
+ except Exception as exc:
434
+ logger.debug("_get_section_data failed: %s", exc)
381
435
 
382
436
  return sections
383
437
 
@@ -391,6 +445,7 @@ def _get_song_brain_dict() -> dict:
391
445
  except Exception as _e:
392
446
  if __debug__:
393
447
  import sys
448
+
394
449
  print(f"LivePilot: SongBrain unavailable in hook_hunter: {_e}", file=sys.stderr)
395
450
  return {}
396
451
 
@@ -421,7 +476,8 @@ def detect_hook_neglect(ctx: Context) -> dict:
421
476
 
422
477
  try:
423
478
  matrix = ableton.send_command("get_scene_matrix")
424
- except Exception:
479
+ except Exception as exc:
480
+ logger.debug("detect_hook_neglect failed: %s", exc)
425
481
  return {
426
482
  "neglected": False,
427
483
  "hook": hook.to_dict(),
@@ -7,6 +7,17 @@ the M4L device on the master track. Sends commands back for deep LOM access.
7
7
  Architecture:
8
8
  M4L → UDP:9880 → SpectralReceiver → SpectralCache → MCP tools
9
9
  MCP tools → M4LBridge → UDP:9881 → M4L device
10
+
11
+ OSC address convention:
12
+ - OUTGOING (this side → M4L): address string is sent WITHOUT a leading
13
+ slash because Max's `udpreceive` treats a literal '/' as part of the
14
+ selector. The JS side (livepilot_bridge.js) routes on bare selectors
15
+ like "cmd" / "ping".
16
+ - INCOMING (M4L → this side): the M4L side uses Max's `udpsend`, whose
17
+ outlet messages include the leading slash (e.g. "/response"). The
18
+ `_parse_osc` helper normalizes with `rest = "/" + rest.lstrip("/\\")`
19
+ so both forms are tolerated — keep that normalization; both sides
20
+ bend toward leniency but the outgoing convention here is slash-less.
10
21
  """
11
22
 
12
23
  from __future__ import annotations
@@ -267,14 +278,26 @@ class SpectralReceiver(asyncio.DatagramProtocol):
267
278
  self._handle_chunk(int(args[0]), int(args[1]), str(args[2]))
268
279
 
269
280
  def _handle_response(self, encoded: str) -> None:
270
- """Decode a single-packet base64 response."""
281
+ """Decode a single-packet base64 response.
282
+
283
+ Resolves _response_callback exactly once, then clears it. Without the
284
+ clear, a second late packet could overwrite a future belonging to a
285
+ different in-flight command. The protocol has no request id yet
286
+ (livepilot_bridge.js:666 emits bare /response), so correlation relies
287
+ on the single-command-in-flight invariant enforced by M4LBridge._cmd_lock
288
+ plus this one-shot clear.
289
+ """
271
290
  try:
272
291
  # URL-safe base64 decode (- and _ instead of + and /)
273
292
  padded = encoded + "=" * (-len(encoded) % 4)
274
293
  decoded = base64.urlsafe_b64decode(padded).decode('utf-8')
275
294
  result = _normalize_bridge_payload(json.loads(decoded))
276
- if self._response_callback and not self._response_callback.done():
277
- self._response_callback.set_result(result)
295
+ cb = self._response_callback
296
+ if cb and not cb.done():
297
+ cb.set_result(result)
298
+ # Clear regardless — either we consumed it, or it was already
299
+ # done/abandoned. Future packets with no owner get dropped.
300
+ self._response_callback = None
278
301
  except Exception as exc:
279
302
  import sys
280
303
  print(f"LivePilot: failed to decode bridge response: {exc}", file=sys.stderr)
@@ -365,11 +388,14 @@ class M4LBridge:
365
388
  result = await asyncio.wait_for(future, timeout=timeout)
366
389
  return result
367
390
  except asyncio.TimeoutError:
368
- # Clear the stale future so a delayed response doesn't resolve
369
- # a future that no caller is waiting on
391
+ return {"error": "M4L bridge timeout device may be busy or removed"}
392
+ finally:
393
+ # Always clear the future — on success the receiver has already
394
+ # cleared it inside _handle_response, but calling again is a
395
+ # no-op. On timeout this is what prevents a delayed packet from
396
+ # resolving a future belonging to the next command.
370
397
  if self.receiver:
371
398
  self.receiver.set_response_future(None)
372
- return {"error": "M4L bridge timeout — device may be busy or removed"}
373
399
 
374
400
  async def send_capture(self, command: str, *args: Any, timeout: float = 35.0) -> dict:
375
401
  """Send a capture command to the M4L device and wait for /capture_complete."""
@@ -17,6 +17,9 @@ from __future__ import annotations
17
17
  import time
18
18
  from dataclasses import dataclass, field
19
19
  from typing import Optional
20
+ import logging
21
+
22
+ logger = logging.getLogger(__name__)
20
23
 
21
24
 
22
25
  @dataclass
@@ -127,7 +130,8 @@ class TasteGraph:
127
130
  if self._persistent_store is not None:
128
131
  try:
129
132
  self._persistent_store.record_move_outcome(move_id, family, kept, score)
130
- except Exception:
133
+ except Exception as exc:
134
+ logger.debug("record_move_outcome failed: %s", exc)
131
135
  pass # persistence is best-effort
132
136
 
133
137
  def record_device_use(self, device_name: str, positive: bool = True) -> None:
@@ -249,9 +253,9 @@ class TasteGraph:
249
253
  "explanations": explanations,
250
254
  }
251
255
 
252
-
253
256
  # ── Builder ──────────────────────────────────────────────────────────────────
254
257
 
258
+
255
259
  def build_taste_graph(
256
260
  taste_store=None, # TasteMemoryStore
257
261
  anti_store=None, # AntiMemoryStore
@@ -299,6 +303,7 @@ def build_taste_graph(
299
303
  # Device affinities
300
304
  for dev_name, dev_data in persisted.get("device_affinities", {}).items():
301
305
  from .taste_graph import DeviceAffinity
306
+
302
307
  graph.device_affinities[dev_name] = DeviceAffinity(
303
308
  device_name=dev_name,
304
309
  affinity=dev_data.get("affinity", 0.0),
@@ -15,6 +15,10 @@ from ..evaluation.fabric import evaluate_sonic_move
15
15
  from .state_builder import build_mix_state
16
16
  from .critics import run_all_mix_critics
17
17
  from .planner import plan_mix_moves
18
+ import logging
19
+
20
+ logger = logging.getLogger(__name__)
21
+
18
22
 
19
23
 
20
24
  # ── Helpers ─────────────────────────────────────────────────────────
@@ -32,7 +36,8 @@ def _fetch_mix_data(ctx: Context) -> dict:
32
36
  try:
33
37
  info = ableton.send_command("get_track_info", {"track_index": i})
34
38
  track_infos.append(info)
35
- except Exception:
39
+ except Exception as exc:
40
+ logger.debug("_fetch_mix_data failed: %s", exc)
36
41
  continue
37
42
 
38
43
  # Get spectrum and RMS data directly from SpectralCache (not TCP)
@@ -51,8 +56,8 @@ def _fetch_mix_data(ctx: Context) -> dict:
51
56
  rms_snap = spectral.get("rms")
52
57
  if rms_snap:
53
58
  rms_data = rms_snap["value"] if isinstance(rms_snap["value"], dict) else rms_snap["value"]
54
- except Exception:
55
- pass
59
+ except Exception as exc:
60
+ logger.debug("_fetch_mix_data failed: %s", exc)
56
61
 
57
62
  return {
58
63
  "session_info": session_info,
@@ -208,11 +208,43 @@ def detect_role_conflicts(
208
208
  "Layer drum parts into one Drum Rack or pan them apart"),
209
209
  }
210
210
 
211
+ # BUG-B1 fix: intentional drum + percussion layering is the core
212
+ # aesthetic in hip-hop / Dilla / lo-fi / beat-scene music, not a
213
+ # conflict. Heuristic to demote drum-role conflicts when the track
214
+ # names make that layering obvious (one "DRUMS" + one "PERC/CONGA/
215
+ # SHAKER" is distinct instruments, not a fight for the same role).
216
+ _PERC_NAMES = {
217
+ "perc", "percussion", "conga", "congas", "shaker",
218
+ "tambourine", "cowbell", "triangle", "bongo",
219
+ "djembe", "claves", "hi-hat", "hihat", "hat",
220
+ }
221
+
222
+ def _looks_like_layering(group: list[dict]) -> bool:
223
+ """True if at least one of the tracks has a percussion-specific
224
+ name (distinct from the main drum kit)."""
225
+ if len(group) < 2:
226
+ return False
227
+ perc_track_count = 0
228
+ for track in group:
229
+ name = str(track.get("name", "")).lower()
230
+ if any(tok in name for tok in _PERC_NAMES):
231
+ perc_track_count += 1
232
+ # Needs at least one main "drums" track AND one perc track
233
+ return 1 <= perc_track_count < len(group)
234
+
211
235
  conflicts = []
212
236
  for role, (desc, rec) in UNIQUE_ROLES.items():
213
237
  group = role_groups.get(role, [])
214
238
  if len(group) > 1:
215
239
  severity = min(0.9, 0.3 + (len(group) - 1) * 0.2)
240
+ if role == "drums" and _looks_like_layering(group):
241
+ # Demote severity — this looks intentional, not a conflict
242
+ severity = max(0.1, severity - 0.4)
243
+ rec = (
244
+ "Drum + percussion layering detected — if this is "
245
+ "intentional (hip-hop / Dilla / lo-fi), ignore. "
246
+ "Otherwise: " + rec
247
+ )
216
248
  conflicts.append(RoleConflict(
217
249
  role=role,
218
250
  tracks=group,
@@ -13,6 +13,9 @@ from fastmcp import Context
13
13
 
14
14
  from ..server import mcp
15
15
  from . import detectors
16
+ import logging
17
+
18
+ logger = logging.getLogger(__name__)
16
19
 
17
20
 
18
21
  def _get_ableton(ctx: Context):
@@ -34,7 +37,8 @@ def detect_repetition_fatigue(ctx: Context) -> dict:
34
37
  # Get scene matrix for clip reuse analysis
35
38
  try:
36
39
  matrix = ableton.send_command("get_scene_matrix")
37
- except Exception:
40
+ except Exception as exc:
41
+ logger.debug("detect_repetition_fatigue failed: %s", exc)
38
42
  matrix = {}
39
43
 
40
44
  scenes = []
@@ -53,8 +57,8 @@ def detect_repetition_fatigue(ctx: Context) -> dict:
53
57
  track_list = session_info.get("tracks", [])
54
58
  notes_by_track = fetch_notes_from_ableton(ableton, track_list)
55
59
  motif_graph = get_motif_data(notes_by_track)
56
- except Exception:
57
- pass
60
+ except Exception as exc:
61
+ logger.debug("detect_repetition_fatigue failed: %s", exc)
58
62
 
59
63
  report = detectors.detect_repetition_fatigue(scenes, motif_graph)
60
64
  return report.to_dict()
@@ -97,7 +101,8 @@ def infer_section_purposes(ctx: Context) -> dict:
97
101
  # Get scene matrix for density analysis
98
102
  try:
99
103
  matrix = ableton.send_command("get_scene_matrix")
100
- except Exception:
104
+ except Exception as exc:
105
+ logger.debug("infer_section_purposes failed: %s", exc)
101
106
  matrix = {}
102
107
 
103
108
  scenes = []
@@ -132,7 +137,8 @@ def score_emotional_arc(ctx: Context) -> dict:
132
137
 
133
138
  try:
134
139
  matrix = ableton.send_command("get_scene_matrix")
135
- except Exception:
140
+ except Exception as exc:
141
+ logger.debug("score_emotional_arc failed: %s", exc)
136
142
  matrix = {}
137
143
 
138
144
  scenes = []
@@ -147,7 +153,6 @@ def score_emotional_arc(ctx: Context) -> dict:
147
153
  arc = detectors.score_emotional_arc(purposes)
148
154
  return arc.to_dict()
149
155
 
150
-
151
156
  # ── Phrase Evaluation ────────────────────────────────────────────────
152
157
 
153
158
 
@@ -179,14 +184,14 @@ def analyze_phrase_arc(
179
184
  try:
180
185
  from ..tools._perception_engine import compute_loudness
181
186
  loudness_data = compute_loudness(file_path, detail="full")
182
- except Exception:
183
- pass
187
+ except Exception as exc:
188
+ logger.debug("analyze_phrase_arc failed: %s", exc)
184
189
 
185
190
  try:
186
191
  from ..tools._perception_engine import compute_spectral
187
192
  spectrum_data = compute_spectral(file_path)
188
- except Exception:
189
- pass
193
+ except Exception as exc:
194
+ logger.debug("analyze_phrase_arc failed: %s", exc)
190
195
 
191
196
  critique = phrase_critic.analyze_phrase(loudness_data, spectrum_data, target)
192
197
  critique.render_id = file_path.split("/")[-1] if "/" in file_path else file_path
@@ -12,39 +12,68 @@ from ..server import mcp
12
12
  from .models import EnergyWindow, SceneRole
13
13
  from .planner import build_performance_state, plan_scene_transition, suggest_energy_moves
14
14
  from .safety import classify_move_safety, get_blocked_moves, get_safe_moves
15
+ import logging
16
+
17
+ logger = logging.getLogger(__name__)
18
+
15
19
 
16
20
 
17
21
  # ── Helpers ─────────────────────────────────────────────────────────
18
22
 
19
23
 
20
- def _infer_role(name: str, index: int, scene_count: int) -> str:
21
- """Infer a scene's role from its name or position."""
22
- lower = name.lower()
23
- for role in ("intro", "verse", "chorus", "build", "drop", "breakdown", "outro", "transition"):
24
- if role in lower:
25
- return role
26
- # Positional fallback
24
+ # BUG-E4 / E5 fix: performance_engine used to have its own _infer_role() keyword
25
+ # list and _infer_energy() static {role number} table. Those diverged from
26
+ # _composition_engine's richer section classifier, which caused
27
+ # get_performance_state and analyze_composition to label the same scenes
28
+ # differently (Deep Flow: drop vs verse, Sun Peak: drop vs chorus) and to
29
+ # report dissimilar energies (composition derived from active-track density,
30
+ # performance looked up a hard-coded 0.2/0.4/0.7 table). Now performance
31
+ # consumes composition's section graph as the source of truth and only keeps
32
+ # a positional fallback for scenes without enough data.
33
+ _POSITIONAL_FALLBACK_ROLES = {
34
+ "first": "intro",
35
+ "last": "outro",
36
+ "early": "intro",
37
+ "middle_low": "verse",
38
+ "middle_high": "chorus",
39
+ "late": "outro",
40
+ "default": "verse",
41
+ }
42
+
43
+
44
+ def _positional_fallback_role(index: int, scene_count: int) -> str:
45
+ """Map a scene index to a role when no composition data is available.
46
+
47
+ Kept only as a last-resort so we still produce a sensible answer for
48
+ unnamed scenes or when build_section_graph_from_scenes returns empty.
49
+ Callers should prefer the composition-engine result when it exists.
50
+ """
51
+ if scene_count <= 0:
52
+ return _POSITIONAL_FALLBACK_ROLES["default"]
27
53
  if index == 0:
28
- return "intro"
54
+ return _POSITIONAL_FALLBACK_ROLES["first"]
29
55
  if index == scene_count - 1:
30
- return "outro"
56
+ return _POSITIONAL_FALLBACK_ROLES["last"]
31
57
  if scene_count > 4:
32
- quarter = scene_count / 4
58
+ quarter = scene_count / 4.0
33
59
  if index < quarter:
34
- return "intro"
35
- elif index < quarter * 2:
36
- return "verse"
37
- elif index < quarter * 3:
38
- return "chorus"
39
- else:
40
- return "outro"
41
- return "verse"
60
+ return _POSITIONAL_FALLBACK_ROLES["early"]
61
+ if index < quarter * 2:
62
+ return _POSITIONAL_FALLBACK_ROLES["middle_low"]
63
+ if index < quarter * 3:
64
+ return _POSITIONAL_FALLBACK_ROLES["middle_high"]
65
+ return _POSITIONAL_FALLBACK_ROLES["late"]
66
+ return _POSITIONAL_FALLBACK_ROLES["default"]
67
+
42
68
 
69
+ def _positional_fallback_energy(role: str) -> float:
70
+ """Static energy map used only when density is unavailable.
43
71
 
44
- def _infer_energy(role: str) -> float:
45
- """Infer energy level from scene role."""
46
- energy_map = {
47
- "intro": 0.2,
72
+ Kept tiny and explicit so the fallback path is obvious — the primary
73
+ source of energy is _composition_engine's density-based value.
74
+ """
75
+ return {
76
+ "intro": 0.3,
48
77
  "verse": 0.4,
49
78
  "build": 0.6,
50
79
  "chorus": 0.7,
@@ -52,23 +81,82 @@ def _infer_energy(role: str) -> float:
52
81
  "breakdown": 0.3,
53
82
  "transition": 0.5,
54
83
  "outro": 0.2,
55
- }
56
- return energy_map.get(role, 0.5)
84
+ }.get(role, 0.5)
57
85
 
58
86
 
59
87
  def _fetch_scene_data(ctx: Context) -> tuple[list[SceneRole], int]:
60
- """Fetch scene info from Ableton and build SceneRole list."""
88
+ """Fetch scene info + composition graph from Ableton and build SceneRole list.
89
+
90
+ BUG-E4 / E5 fix: roles + energies now flow from composition_engine's
91
+ build_section_graph_from_scenes, which uses keyword matching + active-
92
+ track density for energy. Unnamed scenes fall back to the positional
93
+ heuristic. This keeps get_performance_state in sync with
94
+ get_section_graph / analyze_composition.
95
+ """
96
+ from ..tools._composition_engine import (
97
+ build_section_graph_from_scenes,
98
+ SectionNode as CESectionNode,
99
+ )
100
+
61
101
  ableton = ctx.lifespan_context["ableton"]
62
102
 
63
103
  scenes_info = ableton.send_command("get_scenes_info", {})
64
104
  scenes_list = scenes_info.get("scenes", [])
65
105
  scene_count = len(scenes_list)
66
106
 
107
+ # Pull session topology + clip matrix so composition engine can compute
108
+ # active-track density. If any of these fails we fall back to the
109
+ # positional heuristic — preserving the old behavior as a safety net.
110
+ track_count = 0
111
+ clip_matrix: list[list[dict]] = []
112
+ try:
113
+ session_info = ableton.send_command("get_session_info", {})
114
+ track_count = int(session_info.get("track_count", 0))
115
+ except Exception as exc:
116
+ logger.debug("_fetch_scene_data session_info failed: %s", exc)
117
+ try:
118
+ mtx = ableton.send_command("get_scene_matrix", {})
119
+ if isinstance(mtx, dict):
120
+ clip_matrix = mtx.get("matrix", []) or []
121
+ except Exception as exc:
122
+ logger.debug("_fetch_scene_data scene_matrix failed: %s", exc)
123
+
124
+ # Build the composition section graph. Each SectionNode has
125
+ # section_id = f"sec_{raw_enumerate_index:02d}" per BUG-E1 fix, so we
126
+ # can index by scene position directly.
127
+ ce_sections: list[CESectionNode] = []
128
+ try:
129
+ if scenes_list and clip_matrix and track_count > 0:
130
+ ce_sections = build_section_graph_from_scenes(
131
+ scenes_list, clip_matrix, track_count,
132
+ )
133
+ except Exception as exc:
134
+ logger.debug("_fetch_scene_data section graph failed: %s", exc)
135
+
136
+ ce_by_scene_idx: dict[int, CESectionNode] = {}
137
+ for sec in ce_sections:
138
+ # section_id format "sec_02" → scene index 2 (raw enumerate index)
139
+ sid = str(sec.section_id)
140
+ if sid.startswith("sec_"):
141
+ try:
142
+ ce_by_scene_idx[int(sid[4:])] = sec
143
+ except ValueError:
144
+ pass
145
+
67
146
  scene_roles: list[SceneRole] = []
68
147
  for i, scene_data in enumerate(scenes_list):
69
148
  name = scene_data.get("name", f"Scene {i}")
70
- role = _infer_role(name, i, scene_count)
71
- energy = _infer_energy(role)
149
+ ce_sec = ce_by_scene_idx.get(i)
150
+ if ce_sec is not None:
151
+ # SectionType is an enum; .value gives the string vocabulary
152
+ stype = ce_sec.section_type
153
+ role = stype.value if hasattr(stype, "value") else str(stype)
154
+ energy = float(ce_sec.energy)
155
+ else:
156
+ # Unnamed scene or build failed — positional fallback
157
+ role = _positional_fallback_role(i, scene_count)
158
+ energy = _positional_fallback_energy(role)
159
+
72
160
  scene_roles.append(SceneRole(
73
161
  scene_index=i,
74
162
  name=name,
@@ -81,14 +169,13 @@ def _fetch_scene_data(ctx: Context) -> tuple[list[SceneRole], int]:
81
169
  current_scene = 0
82
170
  try:
83
171
  session_info = ableton.send_command("get_session_info", {})
84
- # Check if any scene is marked as triggered/playing
85
172
  session_scenes = session_info.get("scenes", [])
86
173
  for i, s in enumerate(session_scenes):
87
174
  if s.get("is_triggered", False):
88
175
  current_scene = i
89
176
  break
90
- except Exception:
91
- pass
177
+ except Exception as exc:
178
+ logger.debug("_fetch_scene_data current_scene failed: %s", exc)
92
179
 
93
180
  return scene_roles, current_scene
94
181