From aeb1fd9982fd3b92d14cda085f0b2e227149e84b Mon Sep 17 00:00:00 2001 From: Michael Winter Date: Thu, 12 Mar 2026 16:59:44 +0100 Subject: [PATCH] Fix path finding to use cumulative transpositions - Track cumulative transposition across steps so output = destination + (T1 + T2 + ... + TN) - Fix symdiff calculation to use expanded (transposed) pitches instead of collapsed - Update CLI from --change to --symdiff-min/symdiff-max --- compact_sets.py | 79 ++++++++++++++++++++++++++++--------------------- 1 file changed, 46 insertions(+), 33 deletions(-) diff --git a/compact_sets.py b/compact_sets.py index f1bd890..237eb42 100644 --- a/compact_sets.py +++ b/compact_sets.py @@ -365,7 +365,8 @@ class HarmonicSpace: def build_voice_leading_graph( self, chords: set[Chord], - change: int = 1, + symdiff_min: int = 2, + symdiff_max: int = 2, melodic_threshold_cents: float | None = None, ) -> nx.MultiDiGraph: """ @@ -373,16 +374,14 @@ class HarmonicSpace: Args: chords: Set of Chord objects - change: Number of pitches that change between chords + 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 """ - # Calculate symdiff from change - # For chords of size n: symdiff = 2 * change - chord_size = len(list(chords)[0]) if chords else 3 - symdiff_range = (2 * change, 2 * change) + symdiff_range = (symdiff_min, symdiff_max) graph = nx.MultiDiGraph() @@ -431,8 +430,8 @@ class HarmonicSpace: # Transpose c2 c2_transposed = c2.transpose(trans) - # Check symmetric difference on COLLAPSED pitch classes - symdiff = self._calc_symdiff_collapsed(c1, c2_transposed) + # Check symmetric difference on transposed pitches (not collapsed) + symdiff = self._calc_symdiff_expanded(c1, c2_transposed) if not (symdiff_range[0] <= symdiff <= symdiff_range[1]): continue @@ -457,10 +456,13 @@ class HarmonicSpace: return edges - def _calc_symdiff_collapsed(self, c1: Chord, c2: Chord) -> int: - """Calculate symmetric difference on COLLAPSED pitch classes.""" - set1 = set(p.collapse() for p in c1.pitches) - set2 = set(p.collapse() for p in c2.pitches) + def _calc_symdiff_expanded(self, c1: Chord, c2: Chord) -> int: + """Calculate symmetric difference on transposed (expanded) pitches. + + Uses the transposed pitches directly without collapsing. + """ + set1 = set(c1.pitches) + set2 = set(c2.pitches) return len(set1.symmetric_difference(set2)) def _check_voice_leading_connectivity(self, c1: Chord, c2: Chord) -> bool: @@ -468,15 +470,15 @@ class HarmonicSpace: Check that each pitch that changes is connected (adjacent in lattice) to some pitch in the previous chord. - A pitch changes if it's not in the common set (collapsed). - Each changing pitch must be adjacent (±1 in one dimension) to a pitch in c1. + Uses transposed pitches directly without collapsing. """ - c1_collapsed = set(p.collapse() for p in c1.pitches) - c2_collapsed = set(p.collapse() for p in c2.pitches) + # Use pitches directly (transposed form) + c1_pitches = set(c1.pitches) + c2_pitches = set(c2.pitches) # Find pitches that change - common = c1_collapsed & c2_collapsed - changing = c2_collapsed - c1_collapsed + common = c1_pitches & c2_pitches + changing = c2_pitches - c1_pitches if not changing: return False # No change = no edge @@ -484,7 +486,7 @@ class HarmonicSpace: # For each changing pitch, check if it's adjacent to any pitch in c1 for p2 in changing: is_adjacent = False - for p1 in c1_collapsed: + for p1 in c1_pitches: if self._is_adjacent_pitches(p1, p2): is_adjacent = True break @@ -582,13 +584,15 @@ class PathFinder: return [] path = [current] - # Cumulative transposition - starts as identity (no transposition) - identity = Pitch(tuple(0 for _ in current.dims), current.dims) - cumulative_trans = identity last_graph_nodes = (current,) + # Track cumulative transposition across all steps + # Start with identity (zero transposition) + dims = current.dims + cumulative_trans = Pitch(tuple(0 for _ in range(len(dims))), dims) + for _ in range(max_length): - # Always find edges from original graph node (not transposed) + # Find edges from original graph node out_edges = list(self.graph.out_edges(current, data=True)) if not out_edges: @@ -601,17 +605,17 @@ class PathFinder: # Select edge stochastically edge = choices(out_edges, weights=weights)[0] - next_node = edge[1] # Original chord in graph - trans = edge[2].get("transposition", None) + next_node = edge[1] + trans = edge[2].get("transposition") - # Accumulate transposition + # Add this edge's transposition to cumulative if trans is not None: cumulative_trans = cumulative_trans.transpose(trans) - # Output = next_node transposed by cumulative_trans + # Output = next_node transposed by CUMULATIVE transposition sounding_chord = next_node.transpose(cumulative_trans) - # Move to next graph node (original form for edge lookup) + # Move to next graph node current = next_node path.append(sounding_chord) @@ -762,10 +766,16 @@ def main(): description="Generate chord paths in harmonic space" ) parser.add_argument( - "--change", + "--symdiff-min", type=int, - default=1, - help="Number of pitches that change between chords", + default=2, + help="Minimum symmetric difference between chords", + ) + parser.add_argument( + "--symdiff-max", + type=int, + default=2, + help="Maximum symmetric difference between chords", ) parser.add_argument( "--dims", type=int, default=7, help="Number of prime dimensions (4, 5, 7, or 8)" @@ -790,7 +800,7 @@ def main(): # Set up harmonic space space = HarmonicSpace(dims, collapsed=True) print(f"Space: {space}") - print(f"Change: {args.change} pitch(es) per transition") + print(f"Symdiff: {args.symdiff_min} to {args.symdiff_max}") # Generate connected sets print("Generating connected sets...") @@ -802,7 +812,10 @@ def main(): # Build voice leading graph print("Building voice leading graph...") graph = space.build_voice_leading_graph( - chords, change=args.change, melodic_threshold_cents=200 + 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")