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
This commit is contained in:
Michael Winter 2026-03-12 16:59:44 +01:00
parent f2bcd37287
commit aeb1fd9982

View file

@ -365,7 +365,8 @@ class HarmonicSpace:
def build_voice_leading_graph( def build_voice_leading_graph(
self, self,
chords: set[Chord], chords: set[Chord],
change: int = 1, symdiff_min: int = 2,
symdiff_max: int = 2,
melodic_threshold_cents: float | None = None, melodic_threshold_cents: float | None = None,
) -> nx.MultiDiGraph: ) -> nx.MultiDiGraph:
""" """
@ -373,16 +374,14 @@ class HarmonicSpace:
Args: Args:
chords: Set of Chord objects 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 melodic_threshold_cents: If set, filter edges by max pitch movement
Returns: Returns:
NetworkX MultiDiGraph NetworkX MultiDiGraph
""" """
# Calculate symdiff from change symdiff_range = (symdiff_min, symdiff_max)
# For chords of size n: symdiff = 2 * change
chord_size = len(list(chords)[0]) if chords else 3
symdiff_range = (2 * change, 2 * change)
graph = nx.MultiDiGraph() graph = nx.MultiDiGraph()
@ -431,8 +430,8 @@ class HarmonicSpace:
# Transpose c2 # Transpose c2
c2_transposed = c2.transpose(trans) c2_transposed = c2.transpose(trans)
# Check symmetric difference on COLLAPSED pitch classes # Check symmetric difference on transposed pitches (not collapsed)
symdiff = self._calc_symdiff_collapsed(c1, c2_transposed) symdiff = self._calc_symdiff_expanded(c1, c2_transposed)
if not (symdiff_range[0] <= symdiff <= symdiff_range[1]): if not (symdiff_range[0] <= symdiff <= symdiff_range[1]):
continue continue
@ -457,10 +456,13 @@ class HarmonicSpace:
return edges return edges
def _calc_symdiff_collapsed(self, c1: Chord, c2: Chord) -> int: def _calc_symdiff_expanded(self, c1: Chord, c2: Chord) -> int:
"""Calculate symmetric difference on COLLAPSED pitch classes.""" """Calculate symmetric difference on transposed (expanded) pitches.
set1 = set(p.collapse() for p in c1.pitches)
set2 = set(p.collapse() for p in c2.pitches) Uses the transposed pitches directly without collapsing.
"""
set1 = set(c1.pitches)
set2 = set(c2.pitches)
return len(set1.symmetric_difference(set2)) return len(set1.symmetric_difference(set2))
def _check_voice_leading_connectivity(self, c1: Chord, c2: Chord) -> bool: 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) Check that each pitch that changes is connected (adjacent in lattice)
to some pitch in the previous chord. to some pitch in the previous chord.
A pitch changes if it's not in the common set (collapsed). Uses transposed pitches directly without collapsing.
Each changing pitch must be adjacent (±1 in one dimension) to a pitch in c1.
""" """
c1_collapsed = set(p.collapse() for p in c1.pitches) # Use pitches directly (transposed form)
c2_collapsed = set(p.collapse() for p in c2.pitches) c1_pitches = set(c1.pitches)
c2_pitches = set(c2.pitches)
# Find pitches that change # Find pitches that change
common = c1_collapsed & c2_collapsed common = c1_pitches & c2_pitches
changing = c2_collapsed - c1_collapsed changing = c2_pitches - c1_pitches
if not changing: if not changing:
return False # No change = no edge 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 each changing pitch, check if it's adjacent to any pitch in c1
for p2 in changing: for p2 in changing:
is_adjacent = False is_adjacent = False
for p1 in c1_collapsed: for p1 in c1_pitches:
if self._is_adjacent_pitches(p1, p2): if self._is_adjacent_pitches(p1, p2):
is_adjacent = True is_adjacent = True
break break
@ -582,13 +584,15 @@ class PathFinder:
return [] return []
path = [current] 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,) 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): 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)) out_edges = list(self.graph.out_edges(current, data=True))
if not out_edges: if not out_edges:
@ -601,17 +605,17 @@ class PathFinder:
# Select edge stochastically # Select edge stochastically
edge = choices(out_edges, weights=weights)[0] edge = choices(out_edges, weights=weights)[0]
next_node = edge[1] # Original chord in graph next_node = edge[1]
trans = edge[2].get("transposition", None) trans = edge[2].get("transposition")
# Accumulate transposition # Add this edge's transposition to cumulative
if trans is not None: if trans is not None:
cumulative_trans = cumulative_trans.transpose(trans) 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) 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 current = next_node
path.append(sounding_chord) path.append(sounding_chord)
@ -762,10 +766,16 @@ def main():
description="Generate chord paths in harmonic space" description="Generate chord paths in harmonic space"
) )
parser.add_argument( parser.add_argument(
"--change", "--symdiff-min",
type=int, type=int,
default=1, default=2,
help="Number of pitches that change between chords", help="Minimum symmetric difference between chords",
)
parser.add_argument(
"--symdiff-max",
type=int,
default=2,
help="Maximum symmetric difference between chords",
) )
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)"
@ -790,7 +800,7 @@ def main():
# Set up harmonic space # Set up harmonic space
space = HarmonicSpace(dims, collapsed=True) space = HarmonicSpace(dims, collapsed=True)
print(f"Space: {space}") 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 # Generate connected sets
print("Generating connected sets...") print("Generating connected sets...")
@ -802,7 +812,10 @@ def main():
# Build voice leading graph # Build voice leading graph
print("Building voice leading graph...") print("Building voice leading graph...")
graph = space.build_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") print(f"Graph: {graph.number_of_nodes()} nodes, {graph.number_of_edges()} edges")