Implement weighted contrary motion factor

- Half of moving voices should go one direction, half opposite
- Weighted by closeness to ideal half split
- factor = 1.0 - (distance_from_half / half)
- Works with odd (near-half) and even (exact half) voices
- Analysis shows contrary_motion_steps, percent, and avg_score
This commit is contained in:
Michael Winter 2026-03-15 11:31:24 +01:00
parent 559c868313
commit ebbb288844
2 changed files with 36 additions and 14 deletions

View file

@ -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",

View file

@ -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