From 61149597c9e953d2b9ea0a425c258fd79e757c52 Mon Sep 17 00:00:00 2001 From: Michael Winter Date: Fri, 13 Mar 2026 22:04:48 +0100 Subject: [PATCH] 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 --- README.md | 2 ++ src/graph.py | 46 ++++++++++++++++++++++++++++++++++++++ src/io.py | 9 ++++++++ tests/test_compact_sets.py | 23 +++++++++++++++++++ 4 files changed, 80 insertions(+) diff --git a/README.md b/README.md index ccc8767..43bd956 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/src/graph.py b/src/graph.py index ad3d659..e6d5371 100644 --- a/src/graph.py +++ b/src/graph.py @@ -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 diff --git a/src/io.py b/src/io.py index 22300c6..f7fda85 100644 --- a/src/io.py +++ b/src/io.py @@ -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 diff --git a/tests/test_compact_sets.py b/tests/test_compact_sets.py index b7221b3..973810e 100644 --- a/tests/test_compact_sets.py +++ b/tests/test_compact_sets.py @@ -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"])