From d4cdd86e856609e6293682c8b095f907fb22b5b4 Mon Sep 17 00:00:00 2001 From: Michael Winter Date: Thu, 12 Mar 2026 19:14:33 +0100 Subject: [PATCH] Add voice-leading preservation with index-to-index movement mapping - Change movement map from {pitch: {destination, cents}} to {src_idx: dest_idx} - Track voice mapping cumulatively in pathfinder - Reorder output pitches according to voice mapping - Update weight calculation to compute cent_diffs from index mapping - Melodic threshold now correctly filters edges based on actual movements --- compact_sets.py | 90 ++++++++++++++++++++++++++++--------------------- 1 file changed, 52 insertions(+), 38 deletions(-) diff --git a/compact_sets.py b/compact_sets.py index 214b8d4..e1df171 100644 --- a/compact_sets.py +++ b/compact_sets.py @@ -406,14 +406,10 @@ class HarmonicSpace: return graph def _reverse_movements(self, movements: dict) -> dict: - """Reverse the movement mappings.""" + """Reverse the movement mappings (index to index).""" reversed_movements = {} - for src, data in movements.items(): - dst = data["destination"] - reversed_movements[dst] = { - "destination": src, - "cent_difference": -data["cent_difference"], - } + for src_idx, dest_idx in movements.items(): + reversed_movements[dest_idx] = src_idx return reversed_movements def _find_valid_edges( @@ -502,12 +498,11 @@ class HarmonicSpace: i for i in range(len(c2_transposed_pitches)) if i not in common_indices_c2 ] - # Build base map for common pitches (movement = 0 cents) + # Build base map for common pitches: index -> index base_map = {} for i in common_indices_c1: - p1 = c1_pitches[i] - p2 = c2_transposed_pitches[common_indices_c2[common_indices_c1.index(i)]] - base_map[p1] = {"destination": p2, "cent_difference": 0} + dest_idx = common_indices_c2[common_indices_c1.index(i)] + base_map[i] = dest_idx # If no changing pitches, return just the base map if not changing_indices_c1: @@ -540,10 +535,8 @@ class HarmonicSpace: valid = True for i, c1_idx in enumerate(changing_indices_c1): - p1 = c1_pitches[c1_idx] - p2 = c2_changing[perm[i]] - cents = abs(p1.to_cents() - p2.to_cents()) - new_map[p1] = {"destination": p2, "cent_difference": cents} + dest_idx = changing_indices_c2[perm[i]] + new_map[c1_idx] = dest_idx if valid: all_maps.append(new_map) @@ -680,6 +673,11 @@ class PathFinder: dims = current.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) + voice_map = list(range(num_voices)) + for _ in range(max_length): # Find edges from original graph node out_edges = list(self.graph.out_edges(current, data=True)) @@ -696,13 +694,30 @@ class PathFinder: edge = choices(out_edges, weights=weights)[0] next_node = edge[1] trans = edge[2].get("transposition") + movement = edge[2].get("movements", {}) + + # Compose voice mapping with movement map + # movement: src_idx -> dest_idx (voice at src moves to dest) + # voice_map: position i -> original voice + # new_voice_map[dest_idx] = voice_map[src_idx] + new_voice_map = [None] * num_voices + for src_idx, dest_idx in movement.items(): + new_voice_map[dest_idx] = voice_map[src_idx] + voice_map = new_voice_map # Add this edge's transposition to cumulative if trans is not None: cumulative_trans = cumulative_trans.transpose(trans) - # Output = next_node transposed by CUMULATIVE transposition - sounding_chord = next_node.transpose(cumulative_trans) + # Get transposed chord + transposed = next_node.transpose(cumulative_trans) + + # Reorder pitches according to voice mapping + # voice_map[i] = which original voice is at position i + reordered_pitches = tuple( + transposed.pitches[voice_map[i]] for i in range(num_voices) + ) + sounding_chord = Chord(reordered_pitches, dims) # Move to next graph node current = next_node @@ -755,15 +770,27 @@ class PathFinder: w = 1.0 edge_data = edge[2] - # Get movements + # Get movements (now index-to-index) movements = edge_data.get("movements", {}) - # Melodic threshold check: ALL movements must be within min/max range (using absolute values) - if melodic_min is not None or melodic_max is not None: - cent_diffs = [ - abs(v.get("cent_difference", 0)) for v in movements.values() - ] + # 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 = [] + # Melodic threshold check: ALL movements must be within min/max range + if melodic_min is not None or melodic_max is not None: all_within_range = True for cents in cent_diffs: if melodic_min is not None and cents < melodic_min: @@ -784,12 +811,6 @@ class PathFinder: # Movement size weight if config.get("movement_size", False): - movements = edge_data.get("movements", {}) - cent_diffs = [ - abs(v.get("cent_difference", 0)) - for v in movements.values() - if v.get("cent_difference") is not None - ] if cent_diffs: max_diff = max(cent_diffs) if max_diff < 100: @@ -799,16 +820,9 @@ class PathFinder: # Contrary motion weight if config.get("contrary_motion", False): - movements = edge_data.get("movements", {}) - cent_diffs = sorted( - [ - v.get("cent_difference", 0) - for v in movements.values() - if v.get("cent_difference") is not None - ] - ) if len(cent_diffs) >= 3: - if cent_diffs[0] < 0 and cent_diffs[-1] > 0: + sorted_diffs = sorted(cent_diffs) + if sorted_diffs[0] < 0 and sorted_diffs[-1] > 0: w *= 100 # Direct tuning weight