Add target range weight for rising register

- Add --target-range CLI option (in octaves)
- Implement target_range weight in PathFinder
- Add test for target range weight
- Add --max-path to README
This commit is contained in:
Michael Winter 2026-03-13 22:04:48 +01:00
parent 4ae83f857b
commit 61149597c9
4 changed files with 80 additions and 0 deletions

View file

@ -16,9 +16,11 @@ python compact_sets.py --dims 4 --chord-size 3
- `--dims 4|5|7|8` - Number of prime dimensions (default: 7)
- `--chord-size 3|4` - Size of chords (default: 3)
- `--max-path` - Maximum path length (default: 50)
- `--symdiff-min`, `--symdiff-max` - Symmetric difference range (default: 2-2)
- `--melodic-min`, `--melodic-max` - Voice movement thresholds in cents
- `--dca` - DCA multiplier (default: 2.0)
- `--target-range` - Target range in octaves for rising register (default: disabled)
- `--seed` - Random seed (default: random)
- `--cache-dir` - Graph cache directory (default: ./cache)
- `--output-dir` - Output directory (default: output)

View file

@ -142,6 +142,8 @@ class PathFinder:
"melodic_threshold_max": 500,
"hamiltonian": True,
"dca": 2.0,
"target_range": False,
"target_range_octaves": 2.0,
}
def _calculate_edge_weights(
@ -225,6 +227,50 @@ class PathFinder:
w *= move_boost
# Target range weight - boost edges that expand the path range toward target
if config.get("target_range", False) and len(path) > 0:
target_octaves = config.get("target_range_octaves", 2.0)
target_cents = target_octaves * 1200
# Get all pitches from current path
all_cents = []
for chord in path:
for pitch in chord.pitches:
all_cents.append(pitch.to_cents())
current_min = min(all_cents)
current_max = max(all_cents)
current_range = current_max - current_min
# For this edge, compute what the new range would be
# Get the destination chord after transposition (need to account for trans)
dest_chord = edge[1]
trans = edge_data.get("transposition")
# Calculate new pitches after transposition
if trans is not None:
dest_pitches = dest_chord.transpose(trans).pitches
else:
dest_pitches = dest_chord.pitches
new_cents = all_cents + [p.to_cents() for p in dest_pitches]
new_min = min(new_cents)
new_max = max(new_cents)
new_range = new_max - new_min
# Boost based on how close we are to target
# Closer to target = higher boost
current_gap = abs(target_cents - current_range)
new_gap = abs(target_cents - new_range)
if new_gap < current_gap:
# We're getting closer to target - boost
improvement = (current_gap - new_gap) / target_cents
w *= 1 + improvement * 10
else:
# We're moving away from target - small penalty
w *= 0.9
weights.append(w)
return weights

View file

@ -279,6 +279,12 @@ def main():
default=2.0,
help="DCA (Dissonant Counterpoint Algorithm) multiplier for voice momentum (0 to disable)",
)
parser.add_argument(
"--target-range",
type=float,
default=0,
help="Target range in octaves for rising register (default: disabled, 2 = two octaves)",
)
parser.add_argument(
"--allow-voice-crossing",
action="store_true",
@ -390,6 +396,9 @@ def main():
weights_config["melodic_threshold_max"] = args.melodic_max
weights_config["dca"] = args.dca
weights_config["voice_crossing_allowed"] = args.allow_voice_crossing
if args.target_range > 0:
weights_config["target_range"] = True
weights_config["target_range_octaves"] = args.target_range
path = path_finder.find_stochastic_path(
max_length=args.max_path, weights_config=weights_config

View file

@ -199,6 +199,29 @@ class TestCLI:
# For now just verify the module can be imported
assert main is not None
def test_target_range_weight(self):
"""Test that target range weight can be enabled and influences path generation."""
from src.harmonic_space import HarmonicSpace
from src.graph import PathFinder
space = HarmonicSpace(DIMS_4)
chords = space.generate_connected_sets(3, 3)
graph = space.build_voice_leading_graph(chords, 2, 2)
path_finder = PathFinder(graph)
# Test with target range disabled (default)
config_no_target = path_finder._default_weights_config()
assert config_no_target.get("target_range") is False
# Test with target range enabled
config_target = path_finder._default_weights_config()
config_target["target_range"] = True
config_target["target_range_octaves"] = 2.0
assert config_target.get("target_range") is True
assert config_target.get("target_range_octaves") == 2.0
if __name__ == "__main__":
pytest.main([__file__, "-v"])