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(
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")