Fix movement tracking and add melodic threshold weights
- Add movement tracking to edges: {source: {destination, cent_difference}}
- Fix movement map to handle multiple changing pitches with permutations
- Remove melodic threshold from graph building (apply in weight calculation)
- Add melodic_threshold_min/max to weight config
- Add CLI args --melodic-min and --melodic-max
This commit is contained in:
parent
aeb1fd9982
commit
ccf90d19e1
223
compact_sets.py
223
compact_sets.py
|
|
@ -367,7 +367,6 @@ class HarmonicSpace:
|
||||||
chords: set[Chord],
|
chords: set[Chord],
|
||||||
symdiff_min: int = 2,
|
symdiff_min: int = 2,
|
||||||
symdiff_max: int = 2,
|
symdiff_max: int = 2,
|
||||||
melodic_threshold_cents: float | None = None,
|
|
||||||
) -> nx.MultiDiGraph:
|
) -> nx.MultiDiGraph:
|
||||||
"""
|
"""
|
||||||
Build a voice leading graph from a set of chords.
|
Build a voice leading graph from a set of chords.
|
||||||
|
|
@ -376,7 +375,6 @@ class HarmonicSpace:
|
||||||
chords: Set of Chord objects
|
chords: Set of Chord objects
|
||||||
symdiff_min: Minimum symmetric difference between chords
|
symdiff_min: Minimum symmetric difference between chords
|
||||||
symdiff_max: Maximum symmetric difference between chords
|
symdiff_max: Maximum symmetric difference between chords
|
||||||
melodic_threshold_cents: If set, filter edges by max pitch movement
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
NetworkX MultiDiGraph
|
NetworkX MultiDiGraph
|
||||||
|
|
@ -391,34 +389,49 @@ class HarmonicSpace:
|
||||||
|
|
||||||
# Add edges based on local morphological constraints
|
# Add edges based on local morphological constraints
|
||||||
for c1, c2 in combinations(chords, 2):
|
for c1, c2 in combinations(chords, 2):
|
||||||
edges = self._find_valid_edges(
|
edges = self._find_valid_edges(c1, c2, symdiff_range)
|
||||||
c1, c2, symdiff_range, melodic_threshold_cents
|
|
||||||
)
|
|
||||||
for edge_data in edges:
|
for edge_data in edges:
|
||||||
trans, weight = edge_data
|
trans, weight, movements = edge_data
|
||||||
graph.add_edge(c1, c2, transposition=trans, weight=weight)
|
graph.add_edge(
|
||||||
|
c1, c2, transposition=trans, weight=weight, movements=movements
|
||||||
|
)
|
||||||
graph.add_edge(
|
graph.add_edge(
|
||||||
c2,
|
c2,
|
||||||
c1,
|
c1,
|
||||||
transposition=self._invert_transposition(trans),
|
transposition=self._invert_transposition(trans),
|
||||||
weight=weight,
|
weight=weight,
|
||||||
|
movements=self._reverse_movements(movements),
|
||||||
)
|
)
|
||||||
|
|
||||||
return graph
|
return graph
|
||||||
|
|
||||||
|
def _reverse_movements(self, movements: dict) -> dict:
|
||||||
|
"""Reverse the movement mappings."""
|
||||||
|
reversed_movements = {}
|
||||||
|
for src, data in movements.items():
|
||||||
|
dst = data["destination"]
|
||||||
|
reversed_movements[dst] = {
|
||||||
|
"destination": src,
|
||||||
|
"cent_difference": -data["cent_difference"],
|
||||||
|
}
|
||||||
|
return reversed_movements
|
||||||
|
|
||||||
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],
|
||||||
melodic_threshold_cents: float | None,
|
) -> list[tuple[Pitch, float, dict]]:
|
||||||
) -> list[tuple[Pitch, float]]:
|
|
||||||
"""
|
"""
|
||||||
Find all valid edges between two chords.
|
Find all valid edges between two chords.
|
||||||
|
|
||||||
Tests all transpositions of c2 to find ones that satisfy
|
Tests all transpositions of c2 to find ones that satisfy
|
||||||
the symmetric difference constraint AND each changing pitch
|
the symmetric difference constraint AND each changing pitch
|
||||||
is connected (adjacent) to a pitch in the previous chord.
|
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}}
|
||||||
"""
|
"""
|
||||||
edges = []
|
edges = []
|
||||||
|
|
||||||
|
|
@ -444,18 +457,99 @@ class HarmonicSpace:
|
||||||
if not voice_lead_ok:
|
if not voice_lead_ok:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Check melodic threshold if specified
|
# Build all valid movement maps (one per permutation of changing pitches)
|
||||||
if melodic_threshold_cents is not None:
|
movement_maps = self._build_movement_maps(
|
||||||
if not self._check_melodic_threshold(
|
c1.pitches, c2_transposed.pitches
|
||||||
c1.pitches, c2_transposed.pitches, melodic_threshold_cents
|
)
|
||||||
):
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Valid edge found
|
# Create one edge per movement map
|
||||||
edges.append((trans, 1.0))
|
for movements in movement_maps:
|
||||||
|
edges.append((trans, 1.0, movements))
|
||||||
|
|
||||||
return edges
|
return edges
|
||||||
|
|
||||||
|
def _build_movement_maps(
|
||||||
|
self, c1_pitches: tuple[Pitch, ...], c2_transposed_pitches: tuple[Pitch, ...]
|
||||||
|
) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Build all valid movement maps for c1 -> c2_transposed.
|
||||||
|
|
||||||
|
A movement map shows which pitch in c1 maps to which pitch in c2,
|
||||||
|
including the cent difference for each movement.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of movement maps. Each map is {source_pitch: {"destination": dest_pitch, "cent_difference": cents}}
|
||||||
|
There may be multiple valid maps if multiple changing pitches can be permuted.
|
||||||
|
"""
|
||||||
|
# Find common pitches (same pitch class in both)
|
||||||
|
c1_collapsed = [p.collapse() for p in c1_pitches]
|
||||||
|
c2_collapsed = [p.collapse() for p in c2_transposed_pitches]
|
||||||
|
|
||||||
|
common_indices_c1 = []
|
||||||
|
common_indices_c2 = []
|
||||||
|
for i, pc1 in enumerate(c1_collapsed):
|
||||||
|
for j, pc2 in enumerate(c2_collapsed):
|
||||||
|
if pc1 == pc2:
|
||||||
|
common_indices_c1.append(i)
|
||||||
|
common_indices_c2.append(j)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Get changing pitch indices
|
||||||
|
changing_indices_c1 = [
|
||||||
|
i for i in range(len(c1_pitches)) if i not in common_indices_c1
|
||||||
|
]
|
||||||
|
changing_indices_c2 = [
|
||||||
|
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)
|
||||||
|
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}
|
||||||
|
|
||||||
|
# If no changing pitches, return just the base map
|
||||||
|
if not changing_indices_c1:
|
||||||
|
return [base_map]
|
||||||
|
|
||||||
|
# For changing pitches, find all valid permutations
|
||||||
|
# Each changing pitch in c2 must be adjacent to some pitch in c1
|
||||||
|
c1_changing = [c1_pitches[i] for i in changing_indices_c1]
|
||||||
|
c2_changing = [c2_transposed_pitches[i] for i in changing_indices_c2]
|
||||||
|
|
||||||
|
# Find valid pairings: which c1 pitch can map to which c2 pitch (must be adjacent)
|
||||||
|
valid_pairings = []
|
||||||
|
for p1 in c1_changing:
|
||||||
|
pairings = []
|
||||||
|
for p2 in c2_changing:
|
||||||
|
if self._is_adjacent_pitches(p1, p2):
|
||||||
|
cents = abs(p1.to_cents() - p2.to_cents())
|
||||||
|
pairings.append((p1, p2, cents))
|
||||||
|
valid_pairings.append(pairings)
|
||||||
|
|
||||||
|
# Generate all permutations and filter valid ones
|
||||||
|
from itertools import permutations
|
||||||
|
|
||||||
|
all_maps = []
|
||||||
|
num_changing = len(c2_changing)
|
||||||
|
|
||||||
|
# For each permutation of c2_changing indices
|
||||||
|
for perm in permutations(range(num_changing)):
|
||||||
|
new_map = dict(base_map) # Start with common pitches
|
||||||
|
|
||||||
|
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}
|
||||||
|
|
||||||
|
if valid:
|
||||||
|
all_maps.append(new_map)
|
||||||
|
|
||||||
|
return all_maps
|
||||||
|
|
||||||
def _calc_symdiff_expanded(self, c1: Chord, c2: Chord) -> int:
|
def _calc_symdiff_expanded(self, c1: Chord, c2: Chord) -> int:
|
||||||
"""Calculate symmetric difference on transposed (expanded) pitches.
|
"""Calculate symmetric difference on transposed (expanded) pitches.
|
||||||
|
|
||||||
|
|
@ -512,31 +606,26 @@ class HarmonicSpace:
|
||||||
|
|
||||||
def _check_melodic_threshold(
|
def _check_melodic_threshold(
|
||||||
self,
|
self,
|
||||||
c1,
|
movements: dict,
|
||||||
c2,
|
|
||||||
threshold_cents: float,
|
threshold_cents: float,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Check if pitch movements stay within melodic threshold."""
|
"""Check if changing pitch movements stay within melodic threshold.
|
||||||
# Find common pitches (ignoring octaves)
|
|
||||||
c1_collapsed = [p.collapse() for p in c1]
|
|
||||||
c2_collapsed = [p.collapse() for p in c2]
|
|
||||||
|
|
||||||
common = set(c1_collapsed) & set(c2_collapsed)
|
Args:
|
||||||
|
movements: Dict mapping source pitch -> {destination, cent_difference}
|
||||||
|
threshold_cents: Maximum allowed movement in cents
|
||||||
|
|
||||||
if not common:
|
Returns:
|
||||||
return False
|
True if all movements are within threshold.
|
||||||
|
Common pitches (0 cents) always pass.
|
||||||
# Check movements from common pitches
|
Changing pitches must have cent_difference <= threshold.
|
||||||
for p1 in c1:
|
"""
|
||||||
p1_c = p1.collapse()
|
for src, data in movements.items():
|
||||||
if p1_c in common:
|
cents = data["cent_difference"]
|
||||||
for p2 in c2:
|
# Common pitches have 0 cent difference - always pass
|
||||||
p2_c = p2.collapse()
|
# Changing pitches: check if movement is within threshold
|
||||||
if p1_c == p2_c:
|
if cents > threshold_cents:
|
||||||
# Found matching pitch, check cent difference
|
return False
|
||||||
cents = abs(p1.to_cents() - p2.to_cents())
|
|
||||||
if cents > threshold_cents:
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
@ -644,8 +733,8 @@ class PathFinder:
|
||||||
"contrary_motion": True,
|
"contrary_motion": True,
|
||||||
"direct_tuning": True,
|
"direct_tuning": True,
|
||||||
"voice_crossing": True,
|
"voice_crossing": True,
|
||||||
"sustained_voice": False,
|
"melodic_threshold_min": 0,
|
||||||
"transposition": False,
|
"melodic_threshold_max": 500,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _calculate_edge_weights(
|
def _calculate_edge_weights(
|
||||||
|
|
@ -658,10 +747,41 @@ class PathFinder:
|
||||||
"""Calculate weights for edges based on configuration."""
|
"""Calculate weights for edges based on configuration."""
|
||||||
weights = []
|
weights = []
|
||||||
|
|
||||||
|
# Get melodic threshold settings
|
||||||
|
melodic_min = config.get("melodic_threshold_min", 0)
|
||||||
|
melodic_max = config.get("melodic_threshold_max", float("inf"))
|
||||||
|
|
||||||
for edge in out_edges:
|
for edge in out_edges:
|
||||||
w = 1.0
|
w = 1.0
|
||||||
edge_data = edge[2]
|
edge_data = edge[2]
|
||||||
|
|
||||||
|
# Get movements
|
||||||
|
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()
|
||||||
|
]
|
||||||
|
|
||||||
|
all_within_range = True
|
||||||
|
for cents in cent_diffs:
|
||||||
|
if melodic_min is not None and cents < melodic_min:
|
||||||
|
all_within_range = False
|
||||||
|
break
|
||||||
|
if melodic_max is not None and cents > melodic_max:
|
||||||
|
all_within_range = False
|
||||||
|
break
|
||||||
|
|
||||||
|
if all_within_range:
|
||||||
|
w *= 10 # Boost for within range
|
||||||
|
else:
|
||||||
|
w = 0.0 # Penalty for outside range
|
||||||
|
|
||||||
|
if w == 0.0:
|
||||||
|
weights.append(w)
|
||||||
|
continue
|
||||||
|
|
||||||
# Movement size weight
|
# Movement size weight
|
||||||
if config.get("movement_size", False):
|
if config.get("movement_size", False):
|
||||||
movements = edge_data.get("movements", {})
|
movements = edge_data.get("movements", {})
|
||||||
|
|
@ -777,6 +897,18 @@ def main():
|
||||||
default=2,
|
default=2,
|
||||||
help="Maximum symmetric difference between chords",
|
help="Maximum symmetric difference between chords",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--melodic-min",
|
||||||
|
type=int,
|
||||||
|
default=0,
|
||||||
|
help="Minimum cents for any pitch movement (0 = no minimum)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--melodic-max",
|
||||||
|
type=int,
|
||||||
|
default=500,
|
||||||
|
help="Maximum cents for any pitch movement (0 = no maximum)",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--dims", type=int, default=7, help="Number of prime dimensions (4, 5, 7, or 8)"
|
"--dims", type=int, default=7, help="Number of prime dimensions (4, 5, 7, or 8)"
|
||||||
)
|
)
|
||||||
|
|
@ -815,7 +947,6 @@ def main():
|
||||||
chords,
|
chords,
|
||||||
symdiff_min=args.symdiff_min,
|
symdiff_min=args.symdiff_min,
|
||||||
symdiff_max=args.symdiff_max,
|
symdiff_max=args.symdiff_max,
|
||||||
melodic_threshold_cents=200,
|
|
||||||
)
|
)
|
||||||
print(f"Graph: {graph.number_of_nodes()} nodes, {graph.number_of_edges()} edges")
|
print(f"Graph: {graph.number_of_nodes()} nodes, {graph.number_of_edges()} edges")
|
||||||
|
|
||||||
|
|
@ -823,7 +954,15 @@ def main():
|
||||||
print("Finding stochastic path...")
|
print("Finding stochastic path...")
|
||||||
path_finder = PathFinder(graph)
|
path_finder = PathFinder(graph)
|
||||||
seed(args.seed)
|
seed(args.seed)
|
||||||
path = path_finder.find_stochastic_path(max_length=args.max_path)
|
|
||||||
|
# Set up weights config with melodic thresholds
|
||||||
|
weights_config = path_finder._default_weights_config()
|
||||||
|
weights_config["melodic_threshold_min"] = args.melodic_min
|
||||||
|
weights_config["melodic_threshold_max"] = args.melodic_max
|
||||||
|
|
||||||
|
path = path_finder.find_stochastic_path(
|
||||||
|
max_length=args.max_path, weights_config=weights_config
|
||||||
|
)
|
||||||
print(f"Path length: {len(path)}")
|
print(f"Path length: {len(path)}")
|
||||||
|
|
||||||
# Write output
|
# Write output
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue