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:
parent
ba3ded82d8
commit
cfab07da88
190
compact_sets.py
190
compact_sets.py
|
|
@ -368,7 +368,9 @@ class HarmonicSpace:
|
|||
results = set()
|
||||
for chord_arrays in grow((root,), connected, visited):
|
||||
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
|
||||
|
||||
|
|
@ -540,20 +542,25 @@ class HarmonicSpace:
|
|||
cents = abs(src_pitch.to_cents() - dst_pitch.to_cents())
|
||||
cent_diffs.append(cents)
|
||||
|
||||
# Check voice_crossing: compare pitch ordering before/after
|
||||
# Voice crossing = True if ordering changes (e.g., bass below tenor -> tenor below bass)
|
||||
# Check voice_crossing: since source is sorted (bass-to-soprano),
|
||||
# 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)
|
||||
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
|
||||
destination = [
|
||||
c2_transposed.pitches[movements[p]] for p in range(len(source))
|
||||
]
|
||||
ordered_destination = sorted(destination, key=lambda p: p.to_fraction())
|
||||
destination_order = [ordered_destination.index(p) for p in destination]
|
||||
# Rearrange destination pitches by movement map
|
||||
destination = [None] * len(source)
|
||||
for src_idx, dest_idx in movements.items():
|
||||
destination[dest_idx] = c2_transposed.pitches[src_idx]
|
||||
|
||||
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
|
||||
is_directly_tunable = self._is_directly_tunable(
|
||||
|
|
@ -766,43 +773,69 @@ class PathFinder:
|
|||
weights_config = self._default_weights_config()
|
||||
|
||||
# Initialize
|
||||
chords = self._initialize_chords(start_chord)
|
||||
current = chords[-1][0] if chords else None
|
||||
|
||||
if current is None or len(self.graph.nodes()) == 0:
|
||||
chords_data = self._initialize_chords(start_chord)
|
||||
if not chords_data or not chords_data[0]:
|
||||
return []
|
||||
|
||||
path = [current]
|
||||
last_graph_nodes = (current,)
|
||||
chord_pair = chords_data[0]
|
||||
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
|
||||
# Start with identity (zero transposition)
|
||||
dims = current.dims
|
||||
dims = output_chord.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
|
||||
# Start with identity: voice 0 at pos 0, voice 1 at pos 1, etc.
|
||||
num_voices = len(current.pitches)
|
||||
# Use identity since we're not sorting output
|
||||
num_voices = len(output_chord.pitches)
|
||||
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):
|
||||
# Find edges from original graph node
|
||||
out_edges = list(self.graph.out_edges(current, data=True))
|
||||
# Find edges from the graph node (original chord)
|
||||
out_edges = list(self.graph.out_edges(graph_node, data=True))
|
||||
|
||||
if not out_edges:
|
||||
break
|
||||
|
||||
# Calculate weights for each edge
|
||||
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
|
||||
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")
|
||||
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
|
||||
# movement: src_idx -> dest_idx (voice at src moves to dest)
|
||||
# voice_map: position i -> original voice
|
||||
|
|
@ -817,36 +850,78 @@ class PathFinder:
|
|||
cumulative_trans = cumulative_trans.transpose(trans)
|
||||
|
||||
# Get transposed chord
|
||||
transposed = next_node.transpose(cumulative_trans)
|
||||
transposed = next_graph_node.transpose(cumulative_trans)
|
||||
|
||||
# Reorder pitches according to voice mapping
|
||||
# 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(
|
||||
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
|
||||
current = next_node
|
||||
graph_node = next_graph_node
|
||||
|
||||
path.append(sounding_chord)
|
||||
last_graph_nodes = last_graph_nodes + (current,)
|
||||
path.append(output_chord)
|
||||
last_graph_nodes = last_graph_nodes + (graph_node,)
|
||||
if len(last_graph_nodes) > 2:
|
||||
last_graph_nodes = last_graph_nodes[-2:]
|
||||
|
||||
return path
|
||||
|
||||
def _initialize_chords(self, start_chord: Chord | None) -> tuple:
|
||||
"""Initialize chord sequence."""
|
||||
if start_chord is not None:
|
||||
return ((start_chord, start_chord),)
|
||||
"""Initialize chord sequence.
|
||||
|
||||
# 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())
|
||||
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:
|
||||
"""Default weights configuration."""
|
||||
|
|
@ -857,6 +932,7 @@ class PathFinder:
|
|||
"melodic_threshold_min": 0,
|
||||
"melodic_threshold_max": 500,
|
||||
"hamiltonian": True, # Favor unvisited nodes
|
||||
"dca": 2.0, # Direct Connected Adjacent: boost for moving stagnant voices
|
||||
}
|
||||
|
||||
def _calculate_edge_weights(
|
||||
|
|
@ -865,10 +941,16 @@ class PathFinder:
|
|||
path: list[Chord],
|
||||
last_chords: tuple[Chord, ...],
|
||||
config: dict,
|
||||
voice_stay_count: tuple[int, ...] | None = None,
|
||||
) -> list[float]:
|
||||
"""Calculate weights for edges based on configuration."""
|
||||
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
|
||||
melodic_min = config.get("melodic_threshold_min", 0)
|
||||
melodic_max = config.get("melodic_threshold_max", float("inf"))
|
||||
|
|
@ -914,7 +996,8 @@ class PathFinder:
|
|||
if is_directly_tunable:
|
||||
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 edge_data.get("voice_crossing", False):
|
||||
w = 0.0 # Reject edges with voice crossing
|
||||
|
|
@ -927,6 +1010,26 @@ class PathFinder:
|
|||
else:
|
||||
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)
|
||||
|
||||
return weights
|
||||
|
|
@ -1027,6 +1130,17 @@ def main():
|
|||
default=500,
|
||||
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(
|
||||
"--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["melodic_threshold_min"] = args.melodic_min
|
||||
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(
|
||||
max_length=args.max_path, weights_config=weights_config
|
||||
|
|
|
|||
Loading…
Reference in a new issue