From ccf90d19e11dd1882ec0b8054b9d320bebf78fc7 Mon Sep 17 00:00:00 2001 From: Michael Winter Date: Thu, 12 Mar 2026 17:44:54 +0100 Subject: [PATCH] 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 --- compact_sets.py | 223 +++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 181 insertions(+), 42 deletions(-) diff --git a/compact_sets.py b/compact_sets.py index 237eb42..481ec04 100644 --- a/compact_sets.py +++ b/compact_sets.py @@ -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,31 +606,26 @@ 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()) - if cents > threshold_cents: - return False + 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 return True @@ -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