Fix movement tracking and add melodic threshold weights

- Add movement tracking to edges: {source: {destination, cent_difference}}
- Fix movement map to handle multiple changing pitches with permutations
- Remove melodic threshold from graph building (apply in weight calculation)
- Add melodic_threshold_min/max to weight config
- Add CLI args --melodic-min and --melodic-max
This commit is contained in:
Michael Winter 2026-03-12 17:44:54 +01:00
parent aeb1fd9982
commit ccf90d19e1

View file

@ -367,7 +367,6 @@ class HarmonicSpace:
chords: set[Chord], chords: set[Chord],
symdiff_min: int = 2, symdiff_min: int = 2,
symdiff_max: int = 2, symdiff_max: int = 2,
melodic_threshold_cents: float | None = None,
) -> nx.MultiDiGraph: ) -> nx.MultiDiGraph:
""" """
Build a voice leading graph from a set of chords. Build a voice leading graph from a set of chords.
@ -376,7 +375,6 @@ class HarmonicSpace:
chords: Set of Chord objects chords: Set of Chord objects
symdiff_min: Minimum symmetric difference between chords symdiff_min: Minimum symmetric difference between chords
symdiff_max: Maximum symmetric difference between chords symdiff_max: Maximum symmetric difference between chords
melodic_threshold_cents: If set, filter edges by max pitch movement
Returns: Returns:
NetworkX MultiDiGraph NetworkX MultiDiGraph
@ -391,34 +389,49 @@ class HarmonicSpace:
# Add edges based on local morphological constraints # Add edges based on local morphological constraints
for c1, c2 in combinations(chords, 2): for c1, c2 in combinations(chords, 2):
edges = self._find_valid_edges( edges = self._find_valid_edges(c1, c2, symdiff_range)
c1, c2, symdiff_range, melodic_threshold_cents
)
for edge_data in edges: for edge_data in edges:
trans, weight = edge_data trans, weight, movements = edge_data
graph.add_edge(c1, c2, transposition=trans, weight=weight) graph.add_edge(
c1, c2, transposition=trans, weight=weight, movements=movements
)
graph.add_edge( graph.add_edge(
c2, c2,
c1, c1,
transposition=self._invert_transposition(trans), transposition=self._invert_transposition(trans),
weight=weight, weight=weight,
movements=self._reverse_movements(movements),
) )
return graph return graph
def _reverse_movements(self, movements: dict) -> dict:
"""Reverse the movement mappings."""
reversed_movements = {}
for src, data in movements.items():
dst = data["destination"]
reversed_movements[dst] = {
"destination": src,
"cent_difference": -data["cent_difference"],
}
return reversed_movements
def _find_valid_edges( def _find_valid_edges(
self, self,
c1: Chord, c1: Chord,
c2: Chord, c2: Chord,
symdiff_range: tuple[int, int], symdiff_range: tuple[int, int],
melodic_threshold_cents: float | None, ) -> list[tuple[Pitch, float, dict]]:
) -> list[tuple[Pitch, float]]:
""" """
Find all valid edges between two chords. Find all valid edges between two chords.
Tests all transpositions of c2 to find ones that satisfy Tests all transpositions of c2 to find ones that satisfy
the symmetric difference constraint AND each changing pitch the symmetric difference constraint AND each changing pitch
is connected (adjacent) to a pitch in the previous chord. is connected (adjacent) to a pitch in the previous chord.
Returns:
List of (transposition, weight, movements) tuples.
movements is a dict: {source_pitch: {"destination": dest_pitch, "cent_difference": cents}}
""" """
edges = [] edges = []
@ -444,18 +457,99 @@ class HarmonicSpace:
if not voice_lead_ok: if not voice_lead_ok:
continue continue
# Check melodic threshold if specified # Build all valid movement maps (one per permutation of changing pitches)
if melodic_threshold_cents is not None: movement_maps = self._build_movement_maps(
if not self._check_melodic_threshold( c1.pitches, c2_transposed.pitches
c1.pitches, c2_transposed.pitches, melodic_threshold_cents )
):
continue
# Valid edge found # Create one edge per movement map
edges.append((trans, 1.0)) for movements in movement_maps:
edges.append((trans, 1.0, movements))
return edges return edges
def _build_movement_maps(
self, c1_pitches: tuple[Pitch, ...], c2_transposed_pitches: tuple[Pitch, ...]
) -> list[dict]:
"""
Build all valid movement maps for c1 -> c2_transposed.
A movement map shows which pitch in c1 maps to which pitch in c2,
including the cent difference for each movement.
Returns:
List of movement maps. Each map is {source_pitch: {"destination": dest_pitch, "cent_difference": cents}}
There may be multiple valid maps if multiple changing pitches can be permuted.
"""
# Find common pitches (same pitch class in both)
c1_collapsed = [p.collapse() for p in c1_pitches]
c2_collapsed = [p.collapse() for p in c2_transposed_pitches]
common_indices_c1 = []
common_indices_c2 = []
for i, pc1 in enumerate(c1_collapsed):
for j, pc2 in enumerate(c2_collapsed):
if pc1 == pc2:
common_indices_c1.append(i)
common_indices_c2.append(j)
break
# Get changing pitch indices
changing_indices_c1 = [
i for i in range(len(c1_pitches)) if i not in common_indices_c1
]
changing_indices_c2 = [
i for i in range(len(c2_transposed_pitches)) if i not in common_indices_c2
]
# Build base map for common pitches (movement = 0 cents)
base_map = {}
for i in common_indices_c1:
p1 = c1_pitches[i]
p2 = c2_transposed_pitches[common_indices_c2[common_indices_c1.index(i)]]
base_map[p1] = {"destination": p2, "cent_difference": 0}
# If no changing pitches, return just the base map
if not changing_indices_c1:
return [base_map]
# For changing pitches, find all valid permutations
# Each changing pitch in c2 must be adjacent to some pitch in c1
c1_changing = [c1_pitches[i] for i in changing_indices_c1]
c2_changing = [c2_transposed_pitches[i] for i in changing_indices_c2]
# Find valid pairings: which c1 pitch can map to which c2 pitch (must be adjacent)
valid_pairings = []
for p1 in c1_changing:
pairings = []
for p2 in c2_changing:
if self._is_adjacent_pitches(p1, p2):
cents = abs(p1.to_cents() - p2.to_cents())
pairings.append((p1, p2, cents))
valid_pairings.append(pairings)
# Generate all permutations and filter valid ones
from itertools import permutations
all_maps = []
num_changing = len(c2_changing)
# For each permutation of c2_changing indices
for perm in permutations(range(num_changing)):
new_map = dict(base_map) # Start with common pitches
valid = True
for i, c1_idx in enumerate(changing_indices_c1):
p1 = c1_pitches[c1_idx]
p2 = c2_changing[perm[i]]
cents = abs(p1.to_cents() - p2.to_cents())
new_map[p1] = {"destination": p2, "cent_difference": cents}
if valid:
all_maps.append(new_map)
return all_maps
def _calc_symdiff_expanded(self, c1: Chord, c2: Chord) -> int: def _calc_symdiff_expanded(self, c1: Chord, c2: Chord) -> int:
"""Calculate symmetric difference on transposed (expanded) pitches. """Calculate symmetric difference on transposed (expanded) pitches.
@ -512,29 +606,24 @@ class HarmonicSpace:
def _check_melodic_threshold( def _check_melodic_threshold(
self, self,
c1, movements: dict,
c2,
threshold_cents: float, threshold_cents: float,
) -> bool: ) -> bool:
"""Check if pitch movements stay within melodic threshold.""" """Check if changing pitch movements stay within melodic threshold.
# Find common pitches (ignoring octaves)
c1_collapsed = [p.collapse() for p in c1]
c2_collapsed = [p.collapse() for p in c2]
common = set(c1_collapsed) & set(c2_collapsed) Args:
movements: Dict mapping source pitch -> {destination, cent_difference}
threshold_cents: Maximum allowed movement in cents
if not common: Returns:
return False True if all movements are within threshold.
Common pitches (0 cents) always pass.
# Check movements from common pitches Changing pitches must have cent_difference <= threshold.
for p1 in c1: """
p1_c = p1.collapse() for src, data in movements.items():
if p1_c in common: cents = data["cent_difference"]
for p2 in c2: # Common pitches have 0 cent difference - always pass
p2_c = p2.collapse() # Changing pitches: check if movement is within threshold
if p1_c == p2_c:
# Found matching pitch, check cent difference
cents = abs(p1.to_cents() - p2.to_cents())
if cents > threshold_cents: if cents > threshold_cents:
return False return False
@ -644,8 +733,8 @@ class PathFinder:
"contrary_motion": True, "contrary_motion": True,
"direct_tuning": True, "direct_tuning": True,
"voice_crossing": True, "voice_crossing": True,
"sustained_voice": False, "melodic_threshold_min": 0,
"transposition": False, "melodic_threshold_max": 500,
} }
def _calculate_edge_weights( def _calculate_edge_weights(
@ -658,10 +747,41 @@ class PathFinder:
"""Calculate weights for edges based on configuration.""" """Calculate weights for edges based on configuration."""
weights = [] weights = []
# Get melodic threshold settings
melodic_min = config.get("melodic_threshold_min", 0)
melodic_max = config.get("melodic_threshold_max", float("inf"))
for edge in out_edges: for edge in out_edges:
w = 1.0 w = 1.0
edge_data = edge[2] edge_data = edge[2]
# Get movements
movements = edge_data.get("movements", {})
# Melodic threshold check: ALL movements must be within min/max range (using absolute values)
if melodic_min is not None or melodic_max is not None:
cent_diffs = [
abs(v.get("cent_difference", 0)) for v in movements.values()
]
all_within_range = True
for cents in cent_diffs:
if melodic_min is not None and cents < melodic_min:
all_within_range = False
break
if melodic_max is not None and cents > melodic_max:
all_within_range = False
break
if all_within_range:
w *= 10 # Boost for within range
else:
w = 0.0 # Penalty for outside range
if w == 0.0:
weights.append(w)
continue
# Movement size weight # Movement size weight
if config.get("movement_size", False): if config.get("movement_size", False):
movements = edge_data.get("movements", {}) movements = edge_data.get("movements", {})
@ -777,6 +897,18 @@ def main():
default=2, default=2,
help="Maximum symmetric difference between chords", help="Maximum symmetric difference between chords",
) )
parser.add_argument(
"--melodic-min",
type=int,
default=0,
help="Minimum cents for any pitch movement (0 = no minimum)",
)
parser.add_argument(
"--melodic-max",
type=int,
default=500,
help="Maximum cents for any pitch movement (0 = no maximum)",
)
parser.add_argument( parser.add_argument(
"--dims", type=int, default=7, help="Number of prime dimensions (4, 5, 7, or 8)" "--dims", type=int, default=7, help="Number of prime dimensions (4, 5, 7, or 8)"
) )
@ -815,7 +947,6 @@ def main():
chords, chords,
symdiff_min=args.symdiff_min, symdiff_min=args.symdiff_min,
symdiff_max=args.symdiff_max, symdiff_max=args.symdiff_max,
melodic_threshold_cents=200,
) )
print(f"Graph: {graph.number_of_nodes()} nodes, {graph.number_of_edges()} edges") print(f"Graph: {graph.number_of_nodes()} nodes, {graph.number_of_edges()} edges")
@ -823,7 +954,15 @@ def main():
print("Finding stochastic path...") print("Finding stochastic path...")
path_finder = PathFinder(graph) path_finder = PathFinder(graph)
seed(args.seed) seed(args.seed)
path = path_finder.find_stochastic_path(max_length=args.max_path)
# Set up weights config with melodic thresholds
weights_config = path_finder._default_weights_config()
weights_config["melodic_threshold_min"] = args.melodic_min
weights_config["melodic_threshold_max"] = args.melodic_max
path = path_finder.find_stochastic_path(
max_length=args.max_path, weights_config=weights_config
)
print(f"Path length: {len(path)}") print(f"Path length: {len(path)}")
# Write output # Write output