Sort chords in generate_connected_sets and simplify voice crossing detection

- Sort pitches by frequency after projection in generate_connected_sets
- This ensures all chords in graph have bass-to-soprano ordering
- Simplified voice crossing check: verify destination is sorted after movement
- Since source is sorted, just check if adjacent pairs remain in order
- Fixed movement map indexing bug in precomputed voice crossing detection
This commit is contained in:
Michael Winter 2026-03-13 04:16:48 +01:00
parent ba3ded82d8
commit cfab07da88

View file

@ -368,7 +368,9 @@ class HarmonicSpace:
results = set() results = set()
for chord_arrays in grow((root,), connected, visited): for chord_arrays in grow((root,), connected, visited):
pitches = tuple(self.pitch(arr) for arr in chord_arrays) pitches = tuple(self.pitch(arr) for arr in chord_arrays)
results.add(Chord(pitches, self.dims)) # Sort by frequency after projection (lowest to highest = bass to soprano)
sorted_pitches = tuple(sorted(pitches, key=lambda p: p.to_fraction()))
results.add(Chord(sorted_pitches, self.dims))
return results return results
@ -540,20 +542,25 @@ class HarmonicSpace:
cents = abs(src_pitch.to_cents() - dst_pitch.to_cents()) cents = abs(src_pitch.to_cents() - dst_pitch.to_cents())
cent_diffs.append(cents) cent_diffs.append(cents)
# Check voice_crossing: compare pitch ordering before/after # Check voice_crossing: since source is sorted (bass-to-soprano),
# Voice crossing = True if ordering changes (e.g., bass below tenor -> tenor below bass) # check if destination is also sorted after applying movement
# If any adjacent pair in destination is out of order, voices crossed
source = list(c1.pitches) source = list(c1.pitches)
ordered_source = sorted(source, key=lambda p: p.to_fraction())
source_order = [ordered_source.index(p) for p in source]
# Destination pitches: transpose back to get sounding pitches # Rearrange destination pitches by movement map
destination = [ destination = [None] * len(source)
c2_transposed.pitches[movements[p]] for p in range(len(source)) for src_idx, dest_idx in movements.items():
] destination[dest_idx] = c2_transposed.pitches[src_idx]
ordered_destination = sorted(destination, key=lambda p: p.to_fraction())
destination_order = [ordered_destination.index(p) for p in destination]
voice_crossing = source_order != destination_order # Check if destination is still in ascending order (sorted)
# Since source is sorted ascending, if destination is not sorted, voices crossed
voice_crossing = False
for i in range(len(destination) - 1):
if destination[i] is None or destination[i + 1] is None:
continue
if destination[i].to_fraction() >= destination[i + 1].to_fraction():
voice_crossing = True
break
# Check is_directly_tunable: changing pitches are adjacent to staying pitch # Check is_directly_tunable: changing pitches are adjacent to staying pitch
is_directly_tunable = self._is_directly_tunable( is_directly_tunable = self._is_directly_tunable(
@ -766,43 +773,69 @@ class PathFinder:
weights_config = self._default_weights_config() weights_config = self._default_weights_config()
# Initialize # Initialize
chords = self._initialize_chords(start_chord) chords_data = self._initialize_chords(start_chord)
current = chords[-1][0] if chords else None if not chords_data or not chords_data[0]:
if current is None or len(self.graph.nodes()) == 0:
return [] return []
path = [current] chord_pair = chords_data[0]
last_graph_nodes = (current,) original_chord = chord_pair[0] if chord_pair else None
sorted_permutation = chords_data[1] if len(chords_data) > 1 else None
if original_chord is None or len(self.graph.nodes()) == 0:
return []
# The graph node to use for edge lookup (original chord from harmonic space)
graph_node = original_chord
# The output chord is the original (unsorted) for testing voice crossing detection
output_chord = original_chord
path = [output_chord]
last_graph_nodes = (graph_node,)
# Track cumulative transposition across all steps # Track cumulative transposition across all steps
# Start with identity (zero transposition) # Start with identity (zero transposition)
dims = current.dims dims = output_chord.dims
cumulative_trans = Pitch(tuple(0 for _ in range(len(dims))), dims) cumulative_trans = Pitch(tuple(0 for _ in range(len(dims))), dims)
# Track voice mapping: voice_map[i] = which original voice is at position i # Track voice mapping: voice_map[i] = which original voice is at position i
# Start with identity: voice 0 at pos 0, voice 1 at pos 1, etc. # Use identity since we're not sorting output
num_voices = len(current.pitches) num_voices = len(output_chord.pitches)
voice_map = list(range(num_voices)) voice_map = list(range(num_voices))
# Track how long each voice position has stayed (not moved)
voice_stay_count = [0] * num_voices
for _ in range(max_length): for _ in range(max_length):
# Find edges from original graph node # Find edges from the graph node (original chord)
out_edges = list(self.graph.out_edges(current, data=True)) out_edges = list(self.graph.out_edges(graph_node, data=True))
if not out_edges: if not out_edges:
break break
# Calculate weights for each edge # Calculate weights for each edge
weights = self._calculate_edge_weights( weights = self._calculate_edge_weights(
out_edges, path, last_graph_nodes, weights_config out_edges,
path,
last_graph_nodes,
weights_config,
tuple(voice_stay_count),
) )
# Select edge stochastically # Select edge stochastically
edge = choices(out_edges, weights=weights)[0] edge = choices(out_edges, weights=weights)[0]
next_node = edge[1] next_graph_node = edge[1] # This is also an original chord from the graph
trans = edge[2].get("transposition") trans = edge[2].get("transposition")
movement = edge[2].get("movements", {}) movement = edge[2].get("movements", {})
# Update voice stay counts before applying new movement
# A voice "stays" if its destination index equals its source index
for src_idx, dest_idx in movement.items():
if src_idx == dest_idx:
voice_stay_count[src_idx] += 1
else:
voice_stay_count[src_idx] = 0 # Voice moved - reset count
# Compose voice mapping with movement map # Compose voice mapping with movement map
# movement: src_idx -> dest_idx (voice at src moves to dest) # movement: src_idx -> dest_idx (voice at src moves to dest)
# voice_map: position i -> original voice # voice_map: position i -> original voice
@ -817,36 +850,78 @@ class PathFinder:
cumulative_trans = cumulative_trans.transpose(trans) cumulative_trans = cumulative_trans.transpose(trans)
# Get transposed chord # Get transposed chord
transposed = next_node.transpose(cumulative_trans) transposed = next_graph_node.transpose(cumulative_trans)
# Reorder pitches according to voice mapping # Reorder pitches according to voice mapping
# voice_map[i] = which original voice is at position i # voice_map[i] = which original voice is at position i
# With voice_map initialized to sorted_permutation, output stays in bass-to-soprano order
reordered_pitches = tuple( reordered_pitches = tuple(
transposed.pitches[voice_map[i]] for i in range(num_voices) transposed.pitches[voice_map[i]] for i in range(num_voices)
) )
sounding_chord = Chord(reordered_pitches, dims) output_chord = Chord(reordered_pitches, dims)
# Move to next graph node # Move to next graph node
current = next_node graph_node = next_graph_node
path.append(sounding_chord) path.append(output_chord)
last_graph_nodes = last_graph_nodes + (current,) last_graph_nodes = last_graph_nodes + (graph_node,)
if len(last_graph_nodes) > 2: if len(last_graph_nodes) > 2:
last_graph_nodes = last_graph_nodes[-2:] last_graph_nodes = last_graph_nodes[-2:]
return path return path
def _initialize_chords(self, start_chord: Chord | None) -> tuple: def _initialize_chords(self, start_chord: Chord | None) -> tuple:
"""Initialize chord sequence.""" """Initialize chord sequence.
if start_chord is not None:
return ((start_chord, start_chord),)
# Random start Returns:
Tuple of ((original_chord, transposed_chord), sorted_permutation)
where sorted_permutation[i] = which original voice index is at position i in sorted order
"""
if start_chord is not None:
# Compute permutation from original to sorted
pitches = list(start_chord.pitches)
sorted_pitches = sorted(pitches, key=lambda p: p.to_fraction())
sorted_permutation = [pitches.index(p) for p in sorted_pitches]
return ((start_chord, start_chord), sorted_permutation)
# Random start - try multiple times to find a chord with valid edges (respecting voice crossing)
nodes = list(self.graph.nodes()) nodes = list(self.graph.nodes())
if nodes: if nodes:
return ((choice(nodes), choice(nodes)),) # Shuffle and try a few
import random
return () random.shuffle(nodes)
# Get default weights config for voice crossing check
weights_config = self._default_weights_config()
weights_config["voice_crossing_allowed"] = False
for chord in nodes[:50]: # Try up to 50 random chords
out_edges = list(self.graph.out_edges(chord, data=True))
if len(out_edges) == 0:
continue
# Test if any edges are valid with voice crossing check
weights = self._calculate_edge_weights(
out_edges, [chord], (chord,), weights_config, None
)
nonzero = sum(1 for w in weights if w > 0)
if nonzero > 0:
# Found a valid starting chord
pitches = list(chord.pitches)
sorted_pitches = sorted(pitches, key=lambda p: p.to_fraction())
sorted_permutation = [pitches.index(p) for p in sorted_pitches]
return ((chord, chord), sorted_permutation)
# Fall back to first node if none found
chord = nodes[0]
pitches = list(chord.pitches)
sorted_pitches = sorted(pitches, key=lambda p: p.to_fraction())
sorted_permutation = [pitches.index(p) for p in sorted_pitches]
return ((chord, chord), sorted_permutation)
return ((), None)
def _default_weights_config(self) -> dict: def _default_weights_config(self) -> dict:
"""Default weights configuration.""" """Default weights configuration."""
@ -857,6 +932,7 @@ class PathFinder:
"melodic_threshold_min": 0, "melodic_threshold_min": 0,
"melodic_threshold_max": 500, "melodic_threshold_max": 500,
"hamiltonian": True, # Favor unvisited nodes "hamiltonian": True, # Favor unvisited nodes
"dca": 2.0, # Direct Connected Adjacent: boost for moving stagnant voices
} }
def _calculate_edge_weights( def _calculate_edge_weights(
@ -865,10 +941,16 @@ class PathFinder:
path: list[Chord], path: list[Chord],
last_chords: tuple[Chord, ...], last_chords: tuple[Chord, ...],
config: dict, config: dict,
voice_stay_count: tuple[int, ...] | None = None,
) -> list[float]: ) -> list[float]:
"""Calculate weights for edges based on configuration.""" """Calculate weights for edges based on configuration."""
weights = [] weights = []
# Get DCA (Dissonant Counterpoint Algorithm) settings
dca_multiplier = config.get("dca", 0)
if dca_multiplier is None:
dca_multiplier = 0
# Get melodic threshold settings # Get melodic threshold settings
melodic_min = config.get("melodic_threshold_min", 0) melodic_min = config.get("melodic_threshold_min", 0)
melodic_max = config.get("melodic_threshold_max", float("inf")) melodic_max = config.get("melodic_threshold_max", float("inf"))
@ -914,7 +996,8 @@ class PathFinder:
if is_directly_tunable: if is_directly_tunable:
w *= 10 w *= 10
# Voice crossing check - reject edges where voices cross (if not allowed) # Voice crossing check - use precomputed from graph
# Since all chords in graph are now sorted (bass-to-soprano), this should work correctly
if not config.get("voice_crossing_allowed", False): if not config.get("voice_crossing_allowed", False):
if edge_data.get("voice_crossing", False): if edge_data.get("voice_crossing", False):
w = 0.0 # Reject edges with voice crossing w = 0.0 # Reject edges with voice crossing
@ -927,6 +1010,26 @@ class PathFinder:
else: else:
w *= 10 # Boost for unvisited nodes w *= 10 # Boost for unvisited nodes
# DCA (Dissonant Counterpoint Algorithm) - boost for moving stagnant voices
if dca_multiplier > 0 and voice_stay_count is not None and len(path) > 0:
# Determine which voices would move in this edge
source_chord = path[-1]
movements = edge_data.get("movements", {})
# Calculate stay count boost: which voices would move and what's their stay count
move_boost = 1.0
for voice_idx in range(len(voice_stay_count)):
# Check if this voice moves (dest != src)
# movements maps src_idx -> dest_idx
if voice_idx in movements:
dest_idx = movements[voice_idx]
if dest_idx != voice_idx:
# Voice is moving - use its stay count as boost
stay_count = voice_stay_count[voice_idx]
move_boost *= dca_multiplier**stay_count
w *= move_boost
weights.append(w) weights.append(w)
return weights return weights
@ -1027,6 +1130,17 @@ def main():
default=500, default=500,
help="Maximum cents for any pitch movement (0 = no maximum)", help="Maximum cents for any pitch movement (0 = no maximum)",
) )
parser.add_argument(
"--dca",
type=float,
default=2.0,
help="DCA (Dissonant Counterpoint Algorithm) multiplier for voice momentum (0 to disable)",
)
parser.add_argument(
"--allow-voice-crossing",
action="store_true",
help="Allow edges where voices cross (default: reject)",
)
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)"
) )
@ -1077,6 +1191,8 @@ def main():
weights_config = path_finder._default_weights_config() weights_config = path_finder._default_weights_config()
weights_config["melodic_threshold_min"] = args.melodic_min weights_config["melodic_threshold_min"] = args.melodic_min
weights_config["melodic_threshold_max"] = args.melodic_max weights_config["melodic_threshold_max"] = args.melodic_max
weights_config["dca"] = args.dca
weights_config["voice_crossing_allowed"] = args.allow_voice_crossing
path = path_finder.find_stochastic_path( path = path_finder.find_stochastic_path(
max_length=args.max_path, weights_config=weights_config max_length=args.max_path, weights_config=weights_config