diff --git a/compact_sets.py b/compact_sets.py index fdadf55..ed2020e 100644 --- a/compact_sets.py +++ b/compact_sets.py @@ -391,9 +391,23 @@ class HarmonicSpace: for c1, c2 in combinations(chords, 2): edges = self._find_valid_edges(c1, c2, symdiff_range) for edge_data in edges: - trans, weight, movements = edge_data + ( + trans, + weight, + movements, + cent_diffs, + voice_crossing, + is_directly_tunable, + ) = edge_data graph.add_edge( - c1, c2, transposition=trans, weight=weight, movements=movements + c1, + c2, + transposition=trans, + weight=weight, + movements=movements, + cent_diffs=cent_diffs, + voice_crossing=voice_crossing, + is_directly_tunable=is_directly_tunable, ) graph.add_edge( c2, @@ -401,6 +415,11 @@ class HarmonicSpace: transposition=self._invert_transposition(trans), weight=weight, movements=self._reverse_movements(movements), + cent_diffs=list( + reversed(cent_diffs) + ), # reverse for opposite direction + voice_crossing=voice_crossing, # same in reverse + is_directly_tunable=is_directly_tunable, ) return graph @@ -412,12 +431,54 @@ class HarmonicSpace: reversed_movements[dest_idx] = src_idx return reversed_movements + def _is_directly_tunable( + self, + c1_pitches: tuple[Pitch, ...], + c2_transposed_pitches: tuple[Pitch, ...], + movements: dict, + ) -> bool: + """ + Check if all changing pitches are adjacent (directly tunable) to a staying pitch. + + A changing pitch is directly tunable if it differs from a staying pitch + by exactly one prime dimension (±1 in one dimension, 0 in all others). + """ + # Find staying pitches (where movement is identity: i -> i) + staying_indices = [i for i in range(len(c1_pitches)) if movements.get(i) == i] + + if not staying_indices: + return False # No staying pitch to tune to + + # Find changing pitches + changing_indices = [ + i for i in range(len(c1_pitches)) if i not in staying_indices + ] + + if not changing_indices: + return True # No changing pitches = directly tunable + + # For each changing pitch, check if it's adjacent to any staying pitch + for ch_idx in changing_indices: + ch_pitch = c2_transposed_pitches[ch_idx] + is_adjacent_to_staying = False + + for st_idx in staying_indices: + st_pitch = c1_pitches[st_idx] + if self._is_adjacent_pitches(st_pitch, ch_pitch): + is_adjacent_to_staying = True + break + + if not is_adjacent_to_staying: + return False + + return True + def _find_valid_edges( self, c1: Chord, c2: Chord, symdiff_range: tuple[int, int], - ) -> list[tuple[Pitch, float, dict]]: + ) -> list[tuple[Pitch, float, dict, list[float], bool, bool]]: """ Find all valid edges between two chords. @@ -426,8 +487,11 @@ class HarmonicSpace: is connected (adjacent) to a pitch in the previous chord. Returns: - List of (transposition, weight, movements) tuples. - movements is a dict: {source_pitch: {"destination": dest_pitch, "cent_difference": cents}} + List of (transposition, weight, movements, cent_diffs, voice_crossing, is_directly_tunable) tuples. + - movements: dict {src_idx: dest_idx} + - cent_diffs: list of cent differences per voice + - voice_crossing: True if voices cross + - is_directly_tunable: True if all changing pitches adjacent to staying pitch """ edges = [] @@ -458,9 +522,37 @@ class HarmonicSpace: c1.pitches, c2_transposed.pitches ) - # Create one edge per movement map + # Create one edge per movement map with computed edge properties for movements in movement_maps: - edges.append((trans, 1.0, movements)) + # Compute cent_diffs for each voice + cent_diffs = [] + for src_idx, dest_idx in movements.items(): + src_pitch = c1.pitches[src_idx] + dst_pitch = c2_transposed.pitches[dest_idx] + cents = abs(src_pitch.to_cents() - dst_pitch.to_cents()) + cent_diffs.append(cents) + + # Check voice_crossing: True if any voice moves to different position + num_voices = len(c1.pitches) + voice_crossing = not all( + movements.get(i, i) == i for i in range(num_voices) + ) + + # Check is_directly_tunable: changing pitches are adjacent to staying pitch + is_directly_tunable = self._is_directly_tunable( + c1.pitches, c2_transposed.pitches, movements + ) + + edges.append( + ( + trans, + 1.0, + movements, + cent_diffs, + voice_crossing, + is_directly_tunable, + ) + ) return edges @@ -746,7 +838,7 @@ class PathFinder: return { "contrary_motion": True, "direct_tuning": True, - "voice_crossing": True, + "voice_crossing_allowed": False, # False = reject edges with voice crossing "melodic_threshold_min": 0, "melodic_threshold_max": 500, } @@ -769,24 +861,10 @@ class PathFinder: w = 1.0 edge_data = edge[2] - # Get movements (now index-to-index) - movements = edge_data.get("movements", {}) - - # Compute cent_diffs from movement map indices - # edge[0] = source chord c1, edge[1] = dest chord c2 - source_chord = edge[0] - dest_chord = edge[1] - trans = edge_data.get("transposition") - if trans and dest_chord: - c2_transposed = dest_chord.transpose(trans) - cent_diffs = [] - for src_idx, dest_idx in movements.items(): - src_pitch = source_chord.pitches[src_idx] - dst_pitch = c2_transposed.pitches[dest_idx] - cents = abs(src_pitch.to_cents() - dst_pitch.to_cents()) - cent_diffs.append(cents) - else: - cent_diffs = [] + # Read pre-computed edge properties from graph + cent_diffs = edge_data.get("cent_diffs", []) + voice_crossing = edge_data.get("voice_crossing", False) + is_directly_tunable = edge_data.get("is_directly_tunable", False) # Melodic threshold check: ALL movements must be within min/max range if melodic_min is not None or melodic_max is not None: @@ -817,16 +895,12 @@ class PathFinder: # Direct tuning weight if config.get("direct_tuning", False): - if edge_data.get("is_directly_tunable", False): + if is_directly_tunable: w *= 10 - # Voice crossing check - reject edges where voices cross - if config.get("voice_crossing", False): - # Check if movement map is identity (0->0, 1->1, 2->2, etc.) - # If any voice moves to a different position, that's a crossing - num_voices = len(edge[0].pitches) - is_identity = all(movements.get(i) == i for i in range(num_voices)) - if not is_identity: + # Voice crossing check - reject edges where voices cross (if not allowed) + if not config.get("voice_crossing_allowed", False): + if edge_data.get("voice_crossing", False): w = 0.0 # Reject edges with voice crossing weights.append(w)