Store edge properties at build time: cent_diffs, voice_crossing, is_directly_tunable

- Add _is_directly_tunable method to check if changing pitches are adjacent to staying pitch
- Modify _find_valid_edges to compute and return edge properties
- Store all properties in graph edges at build time
- Simplify _calculate_edge_weights to read from edge data
- Rename voice_crossing config to voice_crossing_allowed (False = reject crossing)
This commit is contained in:
Michael Winter 2026-03-12 20:06:14 +01:00
parent b7b95bb849
commit 69f08814a9

View file

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