diff --git a/compact_sets.py b/compact_sets.py index 3aabbbb..daa8c83 100644 --- a/compact_sets.py +++ b/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