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.
- package/.claude-plugin/marketplace.json +3 -3
- package/.mcp.json.disabled +9 -0
- package/.mcpbignore +3 -0
- package/AGENTS.md +3 -3
- package/BUGS.md +1570 -0
- package/CHANGELOG.md +92 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +7 -7
- package/bin/livepilot.js +28 -8
- package/livepilot/.Codex-plugin/plugin.json +2 -2
- package/livepilot/.claude-plugin/plugin.json +2 -2
- package/livepilot/skills/livepilot-core/SKILL.md +4 -4
- package/livepilot/skills/livepilot-core/references/overview.md +2 -2
- package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
- package/livepilot/skills/livepilot-release/SKILL.md +8 -8
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/LivePilot_Analyzer.amxd.pre-presentation-backup +0 -0
- package/m4l_device/LivePilot_Analyzer.maxproj +53 -0
- package/m4l_device/livepilot_bridge.js +226 -3
- package/manifest.json +3 -3
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/__init__.py +93 -26
- package/mcp_server/composer/sample_resolver.py +10 -6
- package/mcp_server/composer/tools.py +10 -6
- package/mcp_server/connection.py +6 -1
- package/mcp_server/creative_constraints/tools.py +214 -40
- package/mcp_server/experiment/engine.py +16 -14
- package/mcp_server/experiment/tools.py +9 -9
- package/mcp_server/hook_hunter/analyzer.py +62 -9
- package/mcp_server/hook_hunter/tools.py +74 -18
- package/mcp_server/m4l_bridge.py +32 -6
- package/mcp_server/memory/taste_graph.py +7 -2
- package/mcp_server/mix_engine/tools.py +8 -3
- package/mcp_server/musical_intelligence/detectors.py +32 -0
- package/mcp_server/musical_intelligence/tools.py +15 -10
- package/mcp_server/performance_engine/tools.py +117 -30
- package/mcp_server/preview_studio/engine.py +89 -8
- package/mcp_server/preview_studio/tools.py +43 -21
- package/mcp_server/project_brain/automation_graph.py +71 -19
- package/mcp_server/project_brain/builder.py +2 -0
- package/mcp_server/project_brain/tools.py +73 -15
- package/mcp_server/reference_engine/profile_builder.py +129 -3
- package/mcp_server/reference_engine/tools.py +54 -11
- package/mcp_server/runtime/capability_probe.py +10 -4
- package/mcp_server/runtime/execution_router.py +50 -0
- package/mcp_server/runtime/mcp_dispatch.py +75 -3
- package/mcp_server/runtime/remote_commands.py +4 -2
- package/mcp_server/runtime/tools.py +8 -2
- package/mcp_server/sample_engine/analyzer.py +131 -4
- package/mcp_server/sample_engine/critics.py +29 -8
- package/mcp_server/sample_engine/models.py +20 -1
- package/mcp_server/sample_engine/tools.py +74 -31
- package/mcp_server/semantic_moves/sound_design_compilers.py +22 -59
- package/mcp_server/semantic_moves/tools.py +5 -1
- package/mcp_server/semantic_moves/transition_compilers.py +12 -19
- package/mcp_server/server.py +78 -11
- package/mcp_server/services/motif_service.py +9 -3
- package/mcp_server/session_continuity/models.py +4 -0
- package/mcp_server/session_continuity/tools.py +7 -3
- package/mcp_server/session_continuity/tracker.py +23 -9
- package/mcp_server/song_brain/builder.py +110 -12
- package/mcp_server/song_brain/tools.py +94 -25
- package/mcp_server/sound_design/tools.py +112 -1
- package/mcp_server/splice_client/client.py +19 -6
- package/mcp_server/stuckness_detector/detector.py +90 -0
- package/mcp_server/stuckness_detector/tools.py +49 -5
- package/mcp_server/tools/_agent_os_engine/__init__.py +52 -0
- package/mcp_server/tools/_agent_os_engine/critics.py +158 -0
- package/mcp_server/tools/_agent_os_engine/evaluation.py +206 -0
- package/mcp_server/tools/_agent_os_engine/models.py +132 -0
- package/mcp_server/tools/_agent_os_engine/taste.py +192 -0
- package/mcp_server/tools/_agent_os_engine/techniques.py +161 -0
- package/mcp_server/tools/_agent_os_engine/world_model.py +170 -0
- package/mcp_server/tools/_composition_engine/__init__.py +67 -0
- package/mcp_server/tools/_composition_engine/analysis.py +174 -0
- package/mcp_server/tools/_composition_engine/critics.py +522 -0
- package/mcp_server/tools/_composition_engine/gestures.py +230 -0
- package/mcp_server/tools/_composition_engine/harmony.py +160 -0
- package/mcp_server/tools/_composition_engine/models.py +193 -0
- package/mcp_server/tools/_composition_engine/sections.py +414 -0
- package/mcp_server/tools/_harmony_engine.py +52 -8
- package/mcp_server/tools/_perception_engine.py +18 -11
- package/mcp_server/tools/_research_engine.py +98 -19
- package/mcp_server/tools/_theory_engine.py +138 -9
- package/mcp_server/tools/agent_os.py +43 -18
- package/mcp_server/tools/analyzer.py +105 -8
- package/mcp_server/tools/automation.py +6 -1
- package/mcp_server/tools/clips.py +45 -0
- package/mcp_server/tools/composition.py +90 -38
- package/mcp_server/tools/devices.py +32 -7
- package/mcp_server/tools/harmony.py +115 -14
- package/mcp_server/tools/midi_io.py +13 -1
- package/mcp_server/tools/mixing.py +35 -1
- package/mcp_server/tools/motif.py +56 -5
- package/mcp_server/tools/planner.py +6 -2
- package/mcp_server/tools/research.py +37 -10
- package/mcp_server/tools/theory.py +108 -16
- package/mcp_server/transition_engine/critics.py +18 -11
- package/mcp_server/transition_engine/tools.py +6 -1
- package/mcp_server/translation_engine/tools.py +8 -6
- package/mcp_server/wonder_mode/engine.py +8 -3
- package/mcp_server/wonder_mode/tools.py +29 -21
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +57 -2
- package/remote_script/LivePilot/clips.py +69 -0
- package/remote_script/LivePilot/mixing.py +117 -0
- package/remote_script/LivePilot/router.py +13 -1
- package/scripts/generate_tool_catalog.py +13 -38
- package/scripts/sync_metadata.py +231 -14
- package/mcp_server/tools/_agent_os_engine.py +0 -947
- 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(
|
|
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
|
-
|
|
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":
|
|
58
|
-
"motif_count": len(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
454
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
488
|
-
|
|
489
|
-
for degree in
|
|
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
|
-
|
|
493
|
-
break
|
|
546
|
+
candidates.append((degree, chord))
|
|
494
547
|
|
|
495
|
-
if
|
|
496
|
-
|
|
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
|
-
#
|
|
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
|
|
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 +=
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|