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()
|
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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue