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:
parent
b7b95bb849
commit
69f08814a9
142
compact_sets.py
142
compact_sets.py
|
|
@ -391,9 +391,23 @@ class HarmonicSpace:
|
||||||
for c1, c2 in combinations(chords, 2):
|
for c1, c2 in combinations(chords, 2):
|
||||||
edges = self._find_valid_edges(c1, c2, symdiff_range)
|
edges = self._find_valid_edges(c1, c2, symdiff_range)
|
||||||
for edge_data in edges:
|
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(
|
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(
|
graph.add_edge(
|
||||||
c2,
|
c2,
|
||||||
|
|
@ -401,6 +415,11 @@ class HarmonicSpace:
|
||||||
transposition=self._invert_transposition(trans),
|
transposition=self._invert_transposition(trans),
|
||||||
weight=weight,
|
weight=weight,
|
||||||
movements=self._reverse_movements(movements),
|
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
|
return graph
|
||||||
|
|
@ -412,12 +431,54 @@ class HarmonicSpace:
|
||||||
reversed_movements[dest_idx] = src_idx
|
reversed_movements[dest_idx] = src_idx
|
||||||
return reversed_movements
|
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(
|
def _find_valid_edges(
|
||||||
self,
|
self,
|
||||||
c1: Chord,
|
c1: Chord,
|
||||||
c2: Chord,
|
c2: Chord,
|
||||||
symdiff_range: tuple[int, int],
|
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.
|
Find all valid edges between two chords.
|
||||||
|
|
||||||
|
|
@ -426,8 +487,11 @@ class HarmonicSpace:
|
||||||
is connected (adjacent) to a pitch in the previous chord.
|
is connected (adjacent) to a pitch in the previous chord.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of (transposition, weight, movements) tuples.
|
List of (transposition, weight, movements, cent_diffs, voice_crossing, is_directly_tunable) tuples.
|
||||||
movements is a dict: {source_pitch: {"destination": dest_pitch, "cent_difference": cents}}
|
- 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 = []
|
edges = []
|
||||||
|
|
||||||
|
|
@ -458,9 +522,37 @@ class HarmonicSpace:
|
||||||
c1.pitches, c2_transposed.pitches
|
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:
|
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
|
return edges
|
||||||
|
|
||||||
|
|
@ -746,7 +838,7 @@ class PathFinder:
|
||||||
return {
|
return {
|
||||||
"contrary_motion": True,
|
"contrary_motion": True,
|
||||||
"direct_tuning": True,
|
"direct_tuning": True,
|
||||||
"voice_crossing": True,
|
"voice_crossing_allowed": False, # False = reject edges with voice crossing
|
||||||
"melodic_threshold_min": 0,
|
"melodic_threshold_min": 0,
|
||||||
"melodic_threshold_max": 500,
|
"melodic_threshold_max": 500,
|
||||||
}
|
}
|
||||||
|
|
@ -769,24 +861,10 @@ class PathFinder:
|
||||||
w = 1.0
|
w = 1.0
|
||||||
edge_data = edge[2]
|
edge_data = edge[2]
|
||||||
|
|
||||||
# Get movements (now index-to-index)
|
# Read pre-computed edge properties from graph
|
||||||
movements = edge_data.get("movements", {})
|
cent_diffs = edge_data.get("cent_diffs", [])
|
||||||
|
voice_crossing = edge_data.get("voice_crossing", False)
|
||||||
# Compute cent_diffs from movement map indices
|
is_directly_tunable = edge_data.get("is_directly_tunable", False)
|
||||||
# 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
|
# Melodic threshold check: ALL movements must be within min/max range
|
||||||
if melodic_min is not None or melodic_max is not None:
|
if melodic_min is not None or melodic_max is not None:
|
||||||
|
|
@ -817,16 +895,12 @@ class PathFinder:
|
||||||
|
|
||||||
# Direct tuning weight
|
# Direct tuning weight
|
||||||
if config.get("direct_tuning", False):
|
if config.get("direct_tuning", False):
|
||||||
if edge_data.get("is_directly_tunable", False):
|
if is_directly_tunable:
|
||||||
w *= 10
|
w *= 10
|
||||||
|
|
||||||
# Voice crossing check - reject edges where voices cross
|
# Voice crossing check - reject edges where voices cross (if not allowed)
|
||||||
if config.get("voice_crossing", False):
|
if not config.get("voice_crossing_allowed", False):
|
||||||
# Check if movement map is identity (0->0, 1->1, 2->2, etc.)
|
if edge_data.get("voice_crossing", False):
|
||||||
# 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:
|
|
||||||
w = 0.0 # Reject edges with voice crossing
|
w = 0.0 # Reject edges with voice crossing
|
||||||
|
|
||||||
weights.append(w)
|
weights.append(w)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue