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
This commit is contained in:
Michael Winter 2026-03-12 19:14:33 +01:00
parent 2b422d55fe
commit d4cdd86e85

View file

@ -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