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
@@ -11,6 +11,10 @@ from fastmcp import Context
11
11
 
12
12
  from ..server import mcp
13
13
  from . import _motif_engine as motif_engine
14
+ import logging
15
+
16
+ logger = logging.getLogger(__name__)
17
+
14
18
 
15
19
 
16
20
  def _get_ableton(ctx: Context):
@@ -18,7 +22,12 @@ def _get_ableton(ctx: Context):
18
22
 
19
23
 
20
24
  @mcp.tool()
21
- def get_motif_graph(ctx: Context) -> dict:
25
+ def get_motif_graph(
26
+ ctx: Context,
27
+ limit: int = 50,
28
+ offset: int = 0,
29
+ summary_only: bool = False,
30
+ ) -> dict:
22
31
  """Detect recurring melodic and rhythmic patterns across all tracks.
23
32
 
24
33
  Scans note data from all session clips to find repeated interval
@@ -27,7 +36,27 @@ def get_motif_graph(ctx: Context) -> dict:
27
36
 
28
37
  Use this to understand what musical ideas are present and which
29
38
  ones need development or variation.
39
+
40
+ BUG-B7 fix: sessions with many clips produced 90 KB+ payloads that
41
+ exceeded inline-tool-response limits. Callers now page the list and
42
+ can opt into a compact summary view that drops per-motif occurrence
43
+ arrays and suggested_developments.
44
+
45
+ Args:
46
+ limit: maximum motifs returned per call (default 50, max 500).
47
+ offset: skip this many of the highest-salience motifs (for paging).
48
+ summary_only: return only motif_id + kind + salience + fatigue_risk
49
+ + occurrence_count per motif, dropping occurrences
50
+ and other lists. Use when you need a bird's-eye view.
30
51
  """
52
+ # Cheap input validation — these bounds match the tool contract the
53
+ # rest of the server relies on for inline responses.
54
+ if limit < 0:
55
+ raise ValueError("limit must be >= 0")
56
+ if offset < 0:
57
+ raise ValueError("offset must be >= 0")
58
+ limit = min(limit, 500)
59
+
31
60
  ableton = _get_ableton(ctx)
32
61
  session = ableton.send_command("get_session_info")
33
62
  tracks = session.get("tracks", [])
@@ -46,16 +75,38 @@ def get_motif_graph(ctx: Context) -> dict:
46
75
  "clip_index": clip_idx,
47
76
  })
48
77
  track_notes.extend(result.get("notes", []))
49
- except Exception:
50
- pass
78
+ except Exception as exc:
79
+ logger.debug("get_motif_graph failed: %s", exc)
80
+
51
81
  if track_notes:
52
82
  notes_by_track[t_idx] = track_notes
53
83
 
54
84
  motifs = motif_engine.detect_motifs(notes_by_track)
85
+ total = len(motifs)
86
+ page = motifs[offset:offset + limit] if limit > 0 else []
87
+
88
+ if summary_only:
89
+ # Compact per-motif record: keep identity + scoring signals, drop
90
+ # occurrences / developments / pitch/rhythm payloads that balloon
91
+ # the response on complex sessions.
92
+ motif_dicts = [{
93
+ "motif_id": m.motif_id,
94
+ "kind": m.kind,
95
+ "salience": m.salience,
96
+ "fatigue_risk": m.fatigue_risk,
97
+ "occurrence_count": len(m.occurrences),
98
+ } for m in page]
99
+ else:
100
+ motif_dicts = [m.to_dict() for m in page]
55
101
 
56
102
  return {
57
- "motifs": [m.to_dict() for m in motifs],
58
- "motif_count": len(motifs),
103
+ "motifs": motif_dicts,
104
+ "motif_count": len(motif_dicts),
105
+ "total_motifs": total,
106
+ "offset": offset,
107
+ "limit": limit,
108
+ "summary_only": summary_only,
109
+ "has_more": offset + len(motif_dicts) < total,
59
110
  "tracks_analyzed": len(notes_by_track),
60
111
  }
61
112
 
@@ -17,6 +17,9 @@ from fastmcp import Context
17
17
  from ..server import mcp
18
18
  from . import _composition_engine as comp_engine
19
19
  from . import _planner_engine as planner_engine
20
+ import logging
21
+
22
+ logger = logging.getLogger(__name__)
20
23
 
21
24
 
22
25
  def _get_ableton(ctx: Context):
@@ -67,7 +70,8 @@ def plan_arrangement(
67
70
  try:
68
71
  ti = ableton.send_command("get_track_info", {"track_index": t_idx})
69
72
  track_data.append(ti)
70
- except Exception:
73
+ except Exception as exc:
74
+ logger.debug("plan_arrangement failed: %s", exc)
71
75
  track_data.append({"index": t_idx, "name": track.get("name", ""), "devices": []})
72
76
 
73
77
  for section in sections:
@@ -96,7 +100,6 @@ def plan_arrangement(
96
100
  result["available_styles"] = sorted(planner_engine.VALID_STYLES)
97
101
  return result
98
102
 
99
-
100
103
  # ── transform_section (Round 4) ─────────────────────────────────────
101
104
 
102
105
 
@@ -130,6 +133,7 @@ def transform_section(
130
133
  track_count = session.get("track_count", 0)
131
134
 
132
135
  from .composition import _build_clip_matrix
136
+
133
137
  clip_matrix = _build_clip_matrix(ableton, len(scenes), track_count)
134
138
  sections = comp_engine.build_section_graph_from_scenes(scenes, clip_matrix, track_count)
135
139
 
@@ -17,6 +17,9 @@ from fastmcp import Context
17
17
  from ..server import mcp
18
18
  from ..memory.technique_store import TechniqueStore
19
19
  from . import _research_engine as research_engine
20
+ import logging
21
+
22
+ logger = logging.getLogger(__name__)
20
23
 
21
24
  _memory_store = TechniqueStore()
22
25
 
@@ -66,8 +69,8 @@ def research_technique(
66
69
  if ref and not ref.get("error") and ref.get("count", 0) > 0:
67
70
  device_atlas_results.append(ref)
68
71
  break # Found in this category, skip others
69
- except Exception:
70
- pass
72
+ except Exception as exc:
73
+ logger.debug("research_technique failed: %s", exc)
71
74
 
72
75
  # 3. Search memory for related techniques (direct TechniqueStore)
73
76
  memory_results = []
@@ -75,16 +78,16 @@ def research_technique(
75
78
  memory_results.extend(
76
79
  _memory_store.list_techniques(type_filter="technique_card", sort_by="updated_at", limit=10)
77
80
  )
78
- except Exception:
79
- pass
81
+ except Exception as exc:
82
+ logger.debug("research_technique failed: %s", exc)
80
83
 
81
84
  try:
82
85
  # "research" is not a valid type in TechniqueStore — search broadly
83
86
  memory_results.extend(
84
87
  _memory_store.search(query=query, limit=5)
85
88
  )
86
- except Exception:
87
- pass
89
+ except Exception as exc:
90
+ logger.debug("research_technique failed: %s", exc)
88
91
 
89
92
  if scope == "targeted":
90
93
  result = research_engine.targeted_research(
@@ -118,6 +121,30 @@ def get_emotional_arc(ctx: Context) -> dict:
118
121
  no resolution at the end, peak too early.
119
122
 
120
123
  Returns: tension curve and issues with recommended composition moves.
124
+
125
+ 📌 On the `tension_curve` vs other energy metrics (BUG-B21 clarification):
126
+ LivePilot exposes THREE intentionally different "energy-like"
127
+ signals — they are NOT scaled versions of each other:
128
+
129
+ 1. `get_section_graph.energy` / `get_performance_state.energy_level`
130
+ → density-based (active-track ratio per section). After the
131
+ Batch 6 cross-engine unification these two are identical.
132
+ Use when asking "how busy is this section?"
133
+
134
+ 2. `get_emotional_arc.tension` (this tool)
135
+ → narrative-arc signal weighted by harmonic instability,
136
+ section placement, and payoff/contrast. Use when asking
137
+ "where does the song want to go emotionally?" — tension
138
+ can be HIGH in a sparse-but-anticipatory section (low
139
+ density) and LOW in a busy-but-resolved section (high
140
+ density).
141
+
142
+ 3. `get_performance_state.energy_window.target_energy`
143
+ → forward-looking — next-scene target, not current state.
144
+
145
+ If the three readings disagree for the same section, that's the
146
+ DESIGN: density ≠ tension ≠ intended destination. Pick the one
147
+ that matches your question.
121
148
  """
122
149
  from . import _composition_engine as engine
123
150
 
@@ -159,7 +186,8 @@ def get_emotional_arc(ctx: Context) -> dict:
159
186
  hf.mode = detected.get("mode", "")
160
187
  hf.confidence = detected.get("confidence", 0.0)
161
188
  break
162
- except Exception:
189
+ except Exception as exc:
190
+ logger.debug("get_emotional_arc failed: %s", exc)
163
191
  continue
164
192
  harmony_fields.append(hf)
165
193
 
@@ -186,7 +214,6 @@ def get_emotional_arc(ctx: Context) -> dict:
186
214
  "section_count": len(sections),
187
215
  }
188
216
 
189
-
190
217
  # ── get_style_tactics (Round 4) ─────────────────────────────────────
191
218
 
192
219
 
@@ -213,8 +240,8 @@ def get_style_tactics(
213
240
  memory_tactics = _memory_store.search(
214
241
  query=artist_or_genre, limit=10,
215
242
  )
216
- except Exception:
217
- pass
243
+ except Exception as exc:
244
+ logger.debug("get_style_tactics failed: %s", exc)
218
245
 
219
246
  tactics = research_engine.get_style_tactics(artist_or_genre, memory_tactics)
220
247
 
@@ -245,7 +245,15 @@ def suggest_next_chord(
245
245
  candidates = style_map[k]
246
246
  break
247
247
  if not candidates:
248
- candidates = style_map.get("I", ["IV", "V"])
248
+ # BUG-B23: the old fallback hard-coded ["IV", "V"] — uppercase
249
+ # literals that lie about chord quality in a minor key (where
250
+ # both iv and v are minor triads). Pick the tonic key ("i" in
251
+ # minor, "I" in major) and let the style_map's own conventions
252
+ # populate a minor-appropriate default when available.
253
+ tonic_key = "i" if is_minor else "I"
254
+ candidates = style_map.get(tonic_key)
255
+ if not candidates:
256
+ candidates = ["iv", "v"] if is_minor else ["IV", "V"]
249
257
 
250
258
  # Build concrete suggestions with MIDI pitches
251
259
  suggestions = []
@@ -450,11 +458,24 @@ def harmonize_melody(
450
458
  ) -> dict:
451
459
  """Generate a multi-voice harmonization of a melody from a MIDI clip.
452
460
 
453
- Finds diatonic chords containing each melody note and voices them
454
- following basic voice leading rules (smooth bass motion, no crossing).
461
+ Hymn-style SATB convention: the original melody IS the soprano voice.
462
+ The algorithm finds diatonic chords containing each melody note and
463
+ voices them below the melody (bass + tenor + alto for 4-voice mode;
464
+ just bass for 2-voice mode).
455
465
 
456
466
  voices: 2 (melody + bass) or 4 (SATB). Default 4.
457
- Returns note data ready for add_notes on new tracks.
467
+
468
+ Response keys:
469
+ - melody: the input melody as passed in (identical pitches to soprano)
470
+ - soprano: same as melody (hymn-style convention)
471
+ - alto / tenor: inner voices (4-voice only)
472
+ - bass: root-aware bass line — BUG-B26 fix prevents tonic pedal
473
+ - chord_sequence: the chord chosen per melody note (degree + name)
474
+
475
+ BUG-B27: `soprano` and `melody` are intentionally identical — the
476
+ tool's job is to add harmony UNDER an existing melody, not replace
477
+ it. Both fields are returned so callers can pipe whichever makes
478
+ their downstream code cleaner.
458
479
 
459
480
  Processing time: 2-5s.
460
481
  """
@@ -477,6 +498,39 @@ def harmonize_melody(
477
498
  result_voices["tenor"] = []
478
499
 
479
500
  prev_bass_midi = None
501
+ prev_degree: Optional[int] = None
502
+
503
+ # BUG-B26 fix: the old chord-selection walked scale degrees in a fixed
504
+ # preference order [I, IV, V, vi, ii, iii, vii] and picked the FIRST
505
+ # one containing the melody note — in practice this meant I (tonic)
506
+ # won whenever the melody hit a chord tone of I, so the bass pedaled
507
+ # on the tonic. We now build ALL candidate diatonic chords that
508
+ # contain the note, then score them to prefer harmonic motion:
509
+ # - strong penalty for picking the same chord twice in a row
510
+ # (breaks the pedal pattern)
511
+ # - mild preference for functional moves (I→IV, IV→V, V→I etc)
512
+ # - fallback to the original [I, IV, V, …] order on ties
513
+ _DEGREE_ORDER = [0, 3, 4, 5, 1, 2, 6] # I, IV, V, vi, ii, iii, vii
514
+
515
+ def _score_chord(degree: int, prev: Optional[int]) -> float:
516
+ score = 10.0 - _DEGREE_ORDER.index(degree) * 0.5
517
+ if prev is None:
518
+ return score
519
+ if degree == prev:
520
+ # Avoid repeating the same chord — the big lever for B26
521
+ score -= 20.0
522
+ # Functional pair bonuses (classical/pop voice-leading)
523
+ functional_pairs = {
524
+ (0, 3), (0, 4), (0, 5), # I → IV, V, vi
525
+ (3, 4), (3, 0), (3, 1), # IV → V, I, ii
526
+ (4, 0), (4, 5), # V → I, vi
527
+ (5, 1), (5, 3), # vi → ii, IV
528
+ (1, 4), (1, 6), # ii → V, vii°
529
+ (6, 0), (6, 2), # vii° → I, iii
530
+ }
531
+ if (prev, degree) in functional_pairs:
532
+ score += 5.0
533
+ return score
480
534
 
481
535
  for n in melody:
482
536
  melody_pitch = n["pitch"]
@@ -484,16 +538,23 @@ def harmonize_melody(
484
538
  dur = n["duration"]
485
539
  mel_pc = melody_pitch % 12
486
540
 
487
- # Find the best diatonic chord containing this pitch
488
- best_chord = None
489
- for degree in [0, 3, 4, 5, 1, 2, 6]: # I, IV, V, vi, ii, iii, vii
541
+ # Collect every diatonic chord that contains this melody note
542
+ candidates: list[tuple[int, dict]] = []
543
+ for degree in _DEGREE_ORDER:
490
544
  chord = engine.build_chord(degree, tonic, mode)
491
545
  if mel_pc in chord["pitch_classes"]:
492
- best_chord = chord
493
- break
546
+ candidates.append((degree, chord))
494
547
 
495
- if best_chord is None:
496
- best_chord = engine.build_chord(0, tonic, mode)
548
+ if not candidates:
549
+ candidates = [(0, engine.build_chord(0, tonic, mode))]
550
+
551
+ # Pick the highest-scoring candidate — break ties by preference order
552
+ candidates.sort(
553
+ key=lambda pair: (-_score_chord(pair[0], prev_degree),
554
+ _DEGREE_ORDER.index(pair[0]))
555
+ )
556
+ best_degree, best_chord = candidates[0]
557
+ prev_degree = best_degree
497
558
 
498
559
  # Build MIDI pitches for the chord
499
560
  chord_midis = sorted([
@@ -553,10 +614,13 @@ def harmonize_melody(
553
614
  "duration": dur, "velocity": int(vel * 0.7),
554
615
  })
555
616
 
617
+ # BUG-B27: expose the input melody under `melody` (alias of soprano)
618
+ # so downstream code doesn't have to guess which field carries it.
556
619
  result = {
557
620
  "key": _key_display(key_info),
558
621
  "voices": voices,
559
622
  "melody_notes": len(melody),
623
+ "melody": list(result_voices["soprano"]),
560
624
  }
561
625
  for voice_name, voice_notes in result_voices.items():
562
626
  if voice_notes:
@@ -608,9 +672,15 @@ def generate_countermelody(
608
672
 
609
673
  # Consonant intervals (semitones mod 12): P1, m3, M3, P4, P5, m6, M6, P8
610
674
  consonant = {0, 3, 4, 5, 7, 8, 9}
675
+ # BUG-B28: "imperfect" consonances (thirds + sixths) are more
676
+ # colorful than perfect ones (unison, 4th, 5th, octave). Prefer them
677
+ # so the countermelody doesn't collapse onto a static pedal of 1/4/5/8s.
678
+ imperfect_consonant = {3, 4, 8, 9} # m3, M3, m6, M6
611
679
 
612
680
  counter_notes = []
613
681
  prev_cp = None
682
+ # Track recently-used pitches to reward range exploration (B28)
683
+ recent_pitches: list[int] = []
614
684
 
615
685
  for i, n in enumerate(melody):
616
686
  mel_pitch = n["pitch"]
@@ -626,29 +696,47 @@ def generate_countermelody(
626
696
 
627
697
  score = 0.0
628
698
 
629
- # Contrary motion bonus
699
+ # Prefer imperfect consonances (thirds + sixths) — more
700
+ # colorful than unison/4th/5th/octave. Without this the
701
+ # counter collapses onto perfect intervals (B28).
702
+ if iv in imperfect_consonant:
703
+ score += 4.0
704
+
705
+ # Contrary motion bonus (amplified for B28)
630
706
  if prev_cp is not None and i > 0:
631
707
  mel_dir = mel_pitch - melody[i - 1]["pitch"]
632
708
  cp_dir = cp - prev_cp
633
709
  if (mel_dir > 0 and cp_dir < 0) or (mel_dir < 0 and cp_dir > 0):
634
- score += 10
710
+ score += 15 # was 10 — make contrary motion dominant
635
711
  # Penalize parallel perfect intervals
636
712
  prev_iv = abs(prev_cp - melody[i - 1]["pitch"]) % 12
637
713
  if prev_iv == iv and iv in (0, 7):
638
714
  score -= 50
639
715
 
640
- # Stepwise motion bonus
716
+ # Stepwise motion bonus — but don't let it pin us to one pitch
641
717
  if prev_cp is not None:
642
718
  step = abs(cp - prev_cp)
643
- if step <= 2:
719
+ if step == 0:
720
+ # Penalize exact repetition (was implicit-neutral)
721
+ score -= 6
722
+ elif step <= 2:
644
723
  score += 5
645
724
  elif step <= 4:
646
- score += 2
725
+ score += 3
647
726
  elif step > 7:
648
727
  score -= 3
649
728
  else:
650
729
  score += 3
651
730
 
731
+ # BUG-B28: range-exploration bonus. Penalize pitches used
732
+ # in the last 4 notes; this forces the counter to walk
733
+ # through the pool instead of hovering on 2-3 pitches.
734
+ if cp in recent_pitches:
735
+ score -= 4
736
+ # Modest bonus for pitches we haven't visited recently
737
+ if cp not in set(recent_pitches):
738
+ score += 1
739
+
652
740
  score += random.uniform(0, 2)
653
741
  scored.append((cp, score))
654
742
 
@@ -657,6 +745,10 @@ def generate_countermelody(
657
745
 
658
746
  scored.sort(key=lambda x: -x[1])
659
747
  chosen = scored[0][0]
748
+ # Maintain a sliding window of the last 4 counter pitches
749
+ recent_pitches.append(chosen)
750
+ if len(recent_pitches) > 4:
751
+ recent_pitches.pop(0)
660
752
 
661
753
  counter_notes.append({
662
754
  "pitch": chosen,
@@ -270,17 +270,24 @@ def run_gesture_fit_critic(
270
270
  boundary = plan.boundary
271
271
  archetype = plan.archetype
272
272
 
273
- # Check if the section pair matches any of the archetype's use cases
274
- pair_tags = {
275
- f"{boundary.from_type}_to_{boundary.to_type}",
276
- boundary.from_type,
277
- boundary.to_type,
278
- }
279
- use_case_match = any(
280
- tag in uc or uc in tag
281
- for tag in pair_tags
282
- for uc in archetype.use_cases
283
- )
273
+ # Check if the section pair matches any of the archetype's use cases.
274
+ # BUG-B15: "any_section_change" is a wildcard that matches any transition —
275
+ # honor it explicitly so archetypes documented as universal don't get
276
+ # flagged as mismatched on specific pairs like intro→build.
277
+ WILDCARDS = {"any_section_change", "any"}
278
+ if any(uc in WILDCARDS for uc in archetype.use_cases):
279
+ use_case_match = True
280
+ else:
281
+ pair_tags = {
282
+ f"{boundary.from_type}_to_{boundary.to_type}",
283
+ boundary.from_type,
284
+ boundary.to_type,
285
+ }
286
+ use_case_match = any(
287
+ tag in uc or uc in tag
288
+ for tag in pair_tags
289
+ for uc in archetype.use_cases
290
+ )
284
291
 
285
292
  if not use_case_match:
286
293
  issues.append(TransitionIssue(
@@ -14,6 +14,10 @@ from ..tools import _composition_engine as comp_engine
14
14
  from .archetypes import TRANSITION_ARCHETYPES, select_archetype
15
15
  from .critics import run_all_transition_critics
16
16
  from .models import TransitionBoundary, TransitionPlan, TransitionScore
17
+ import logging
18
+
19
+ logger = logging.getLogger(__name__)
20
+
17
21
 
18
22
 
19
23
  # ── Helpers ───────────────────────────────────────────────────────────
@@ -29,7 +33,8 @@ def _build_sections_from_ableton(ctx: Context) -> list[comp_engine.SectionNode]:
29
33
  try:
30
34
  matrix_data = ableton.send_command("get_scene_matrix")
31
35
  clip_matrix = matrix_data.get("matrix", [])
32
- except Exception:
36
+ except Exception as exc:
37
+ logger.debug("_build_sections_from_ableton failed: %s", exc)
33
38
  clip_matrix = [[] for _ in range(len(scenes))]
34
39
 
35
40
  return comp_engine.build_section_graph_from_scenes(
@@ -33,9 +33,8 @@ def _fetch_translation_data(ctx: Context) -> dict:
33
33
  spec_data = spectral.get("spectrum")
34
34
  if spec_data and isinstance(spec_data["value"], dict):
35
35
  spectrum_bands = spec_data["value"]
36
- except Exception:
37
- pass
38
-
36
+ except Exception as exc:
37
+ logger.debug("_fetch_translation_data failed: %s", exc)
39
38
  # Estimate stereo width from track pans via session info
40
39
  stereo_width = 0.0
41
40
  center_strength = 0.5
@@ -61,9 +60,8 @@ def _fetch_translation_data(ctx: Context) -> dict:
61
60
 
62
61
  # Simple foreground detection: at least one unmuted, non-quiet track
63
62
  has_foreground = any(not t.get("muted", False) for t in tracks)
64
- except Exception:
65
- pass
66
-
63
+ except Exception as exc:
64
+ logger.debug("_get_pan failed: %s", exc)
67
65
  return {
68
66
  "stereo_width": stereo_width,
69
67
  "center_strength": center_strength,
@@ -99,6 +97,10 @@ def get_translation_issues(ctx: Context) -> dict:
99
97
 
100
98
  Lighter than check_translation — returns only detected issues
101
99
  from the 5 playback robustness critics.
100
+ import logging
101
+
102
+ logger = logging.getLogger(__name__)
103
+
102
104
  """
103
105
  mix_snapshot = _fetch_translation_data(ctx)
104
106
  issues = run_all_translation_critics(mix_snapshot)
@@ -9,9 +9,12 @@ from __future__ import annotations
9
9
 
10
10
  import hashlib
11
11
  import json
12
+ import logging
12
13
  import math
13
14
  from typing import Optional
14
15
 
16
+ logger = logging.getLogger(__name__)
17
+
15
18
 
16
19
  # ── Move discovery ───────────────────────────────────────────────
17
20
 
@@ -104,8 +107,9 @@ def discover_moves(
104
107
  if validation["valid"]:
105
108
  filtered.append(move)
106
109
  result = filtered
107
- except Exception:
108
- pass # constraint filtering is optional
110
+ except Exception as exc:
111
+ # constraint filtering is optional — keep the unfiltered list
112
+ logger.warning("constraint filtering skipped: %s", exc)
109
113
 
110
114
  return result
111
115
 
@@ -212,7 +216,8 @@ def _compile_variant_plan(move_dict: dict, kernel: dict | None) -> dict | None:
212
216
  try:
213
217
  plan = sem_compile(move_obj, kernel)
214
218
  return plan.to_dict()
215
- except Exception:
219
+ except Exception as exc:
220
+ logger.warning("sem_compile(%s) failed: %s", move_obj, exc)
216
221
  return None
217
222
 
218
223