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],
symdiff_min: int = 2,
symdiff_max: int = 2,
melodic_threshold_cents: float | None = None,
) -> nx.MultiDiGraph:
"""
Build a voice leading graph from a set of chords.
@ -376,7 +375,6 @@ class HarmonicSpace:
chords: Set of Chord objects
symdiff_min: Minimum symmetric difference between chords
symdiff_max: Maximum symmetric difference between chords
melodic_threshold_cents: If set, filter edges by max pitch movement
Returns:
NetworkX MultiDiGraph
@ -391,34 +389,49 @@ class HarmonicSpace:
# Add edges based on local morphological constraints
for c1, c2 in combinations(chords, 2):
edges = self._find_valid_edges(
c1, c2, symdiff_range, melodic_threshold_cents
)
edges = self._find_valid_edges(c1, c2, symdiff_range)
for edge_data in edges:
trans, weight = edge_data
graph.add_edge(c1, c2, transposition=trans, weight=weight)
trans, weight, movements = edge_data
graph.add_edge(
c1, c2, transposition=trans, weight=weight, movements=movements
)
graph.add_edge(
c2,
c1,
transposition=self._invert_transposition(trans),
weight=weight,
movements=self._reverse_movements(movements),
)
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(
self,
c1: Chord,
c2: Chord,
symdiff_range: tuple[int, int],
melodic_threshold_cents: float | None,
) -> list[tuple[Pitch, float]]:
) -> list[tuple[Pitch, float, dict]]:
"""
Find all valid edges between two chords.
Tests all transpositions of c2 to find ones that satisfy
the symmetric difference constraint AND each changing pitch
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 = []
@ -444,18 +457,99 @@ class HarmonicSpace:
if not voice_lead_ok:
continue
# Check melodic threshold if specified
if melodic_threshold_cents is not None:
if not self._check_melodic_threshold(
c1.pitches, c2_transposed.pitches, melodic_threshold_cents
):
continue
# Build all valid movement maps (one per permutation of changing pitches)
movement_maps = self._build_movement_maps(
c1.pitches, c2_transposed.pitches
)
# Valid edge found
edges.append((trans, 1.0))
# Create one edge per movement map
for movements in movement_maps:
edges.append((trans, 1.0, movements))
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:
"""Calculate symmetric difference on transposed (expanded) pitches.
@ -512,29 +606,24 @@ class HarmonicSpace:
def _check_melodic_threshold(
self,
c1,
c2,
movements: dict,
threshold_cents: float,
) -> bool:
"""Check if 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]
"""Check if changing pitch movements stay within melodic threshold.
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:
return False
# Check movements from common pitches
for p1 in c1:
p1_c = p1.collapse()
if p1_c in common:
for p2 in c2:
p2_c = p2.collapse()
if p1_c == p2_c:
# Found matching pitch, check cent difference
cents = abs(p1.to_cents() - p2.to_cents())
Returns:
True if all movements are within threshold.
Common pitches (0 cents) always pass.
Changing pitches must have cent_difference <= threshold.
"""
for src, data in movements.items():
cents = data["cent_difference"]
# Common pitches have 0 cent difference - always pass
# Changing pitches: check if movement is within threshold
if cents > threshold_cents:
return False
@ -644,8 +733,8 @@ class PathFinder:
"contrary_motion": True,
"direct_tuning": True,
"voice_crossing": True,
"sustained_voice": False,
"transposition": False,
"melodic_threshold_min": 0,
"melodic_threshold_max": 500,
}
def _calculate_edge_weights(
@ -658,10 +747,41 @@ class PathFinder:
"""Calculate weights for edges based on configuration."""
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:
w = 1.0
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
if config.get("movement_size", False):
movements = edge_data.get("movements", {})
@ -777,6 +897,18 @@ def main():
default=2,
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(
"--dims", type=int, default=7, help="Number of prime dimensions (4, 5, 7, or 8)"
)
@ -815,7 +947,6 @@ def main():
chords,
symdiff_min=args.symdiff_min,
symdiff_max=args.symdiff_max,
melodic_threshold_cents=200,
)
print(f"Graph: {graph.number_of_nodes()} nodes, {graph.number_of_edges()} edges")
@ -823,7 +954,15 @@ def main():
print("Finding stochastic path...")
path_finder = PathFinder(graph)
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)}")
# Write output