diff --git a/src/analyze.py b/src/analyze.py index dedab00..daa0ab3 100644 --- a/src/analyze.py +++ b/src/analyze.py @@ -45,6 +45,7 @@ def analyze_chords( # ========== Contrary Motion ========== contrary_motion_steps = 0 + contrary_motion_score = 0.0 # ========== DCA (Voice Stay Counts) ========== # Track how long each voice stays before changing @@ -85,10 +86,20 @@ def analyze_chords( unique_nodes.add(node_hash) node_hashes.append(node_hash) - # Contrary motion: sorted_diffs[0] < 0 and sorted_diffs[-1] > 0 - if len(cent_diffs) >= 2: - sorted_diffs = sorted(cent_diffs) - if sorted_diffs[0] < 0 and sorted_diffs[-1] > 0: + # Contrary motion: weighted by closeness to half split + # Count moving voices (cent_diff != 0) + num_up = sum(1 for d in cent_diffs if d > 0) + num_down = sum(1 for d in cent_diffs if d < 0) + num_moving = num_up + num_down + + if num_moving >= 2: + ideal_up = num_moving / 2 + distance = abs(num_up - ideal_up) + # Factor = 1.0 - (distance / ideal_up) + # Returns 0.0 if all move same direction, 1.0 if exact half split + contrary_factor = max(0.0, 1.0 - (distance / ideal_up)) + contrary_motion_score += contrary_factor + if contrary_factor > 0: contrary_motion_steps += 1 # DCA: Track stay counts per voice @@ -147,6 +158,9 @@ def analyze_chords( "contrary_motion_percent": ( (contrary_motion_steps / num_steps * 100) if num_steps > 0 else 0 ), + "contrary_motion_score": contrary_motion_score / num_steps + if num_steps > 0 + else 0, # DCA "dca_avg_voice_stay": avg_voice_stay, "dca_max_voice_stay": max_voice_stay, @@ -179,6 +193,7 @@ def format_analysis(metrics: dict) -> str: "--- Contrary Motion ---", f"Steps with contrary: {metrics['contrary_motion_steps']}", f"Percentage: {metrics['contrary_motion_percent']:.1f}%", + f"Avg score: {metrics['contrary_motion_score']:.2f}", "", "--- DCA (Voice Stay) ---", f"Avg stay count: {metrics['dca_avg_voice_stay']:.2f} steps", diff --git a/src/graph.py b/src/graph.py index d808024..942d11c 100644 --- a/src/graph.py +++ b/src/graph.py @@ -257,20 +257,27 @@ class PathFinder: return 1.0 def _factor_contrary_motion(self, edge_data: dict, config: dict) -> float: - """Returns 1.0 if voices move in contrary motion, 0.0 otherwise.""" - # Check weight - if 0, return 1.0 (neutral) + """Returns factor based on contrary motion. + + Contrary motion: half of moving voices go one direction, half go opposite. + Weighted by closeness to ideal half split. + factor = 1.0 - (distance_from_half / half) + """ if config.get("weight_contrary_motion", 0) == 0: return 1.0 - if not config.get("contrary_motion", False): - return 1.0 # neutral if not configured - cent_diffs = edge_data.get("cent_diffs", []) - if len(cent_diffs) >= 3: - sorted_diffs = sorted(cent_diffs) - if sorted_diffs[0] < 0 and sorted_diffs[-1] > 0: - return 1.0 - return 0.0 + + num_up = sum(1 for d in cent_diffs if d > 0) + num_down = sum(1 for d in cent_diffs if d < 0) + num_moving = num_up + num_down + + if num_moving < 2: + return 0.0 # Need at least 2 moving voices for contrary motion + + ideal_up = num_moving / 2 + distance = abs(num_up - ideal_up) + return max(0.0, 1.0 - (distance / ideal_up)) def _factor_hamiltonian( self, edge: tuple, graph_path: list | None, config: dict