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:
parent
f2bcd37287
commit
aeb1fd9982
|
|
@ -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")
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue