diff --git a/src/io.py b/src/io.py index 9a15b3e..0a4ea70 100644 --- a/src/io.py +++ b/src/io.py @@ -435,6 +435,12 @@ def main(): default=5, help="Maximum consecutive selections before blocking (default: 5)", ) + parser.add_argument( + "--weight-harmonic-compactness", + type=float, + default=0, + help="Weight for harmonic compactness factor - favors compact chords (0=disabled, default: 0)", + ) parser.add_argument( "--weight-target-register", type=float, @@ -673,6 +679,7 @@ def main(): weights_config["weight_dca_voice_movement"] = args.weight_dca_voice_movement weights_config["weight_rgr_voice_movement"] = args.weight_rgr_voice_movement weights_config["rgr_voice_movement_threshold"] = args.rgr_voice_movement_threshold + weights_config["weight_harmonic_compactness"] = args.weight_harmonic_compactness # Target register if args.target_register != 0: diff --git a/src/path.py b/src/path.py index 98a3e70..9af2f71 100644 --- a/src/path.py +++ b/src/path.py @@ -142,6 +142,10 @@ class Path: change_before, self.weights_config, ), + "harmonic_compactness": self._factor_harmonic_compactness( + destination_chord, + self.weights_config, + ), "target_register": self._factor_target_register( path_chords, destination_chord, @@ -207,6 +211,7 @@ class Path: hamiltonian_values = [c.scores.get("dca_hamiltonian", 0) for c in candidates] dca_values = [c.scores.get("dca_voice_movement", 0) for c in candidates] rgr_values = [c.scores.get("rgr_voice_movement", 0) for c in candidates] + compact_values = [c.scores.get("harmonic_compactness", 0) for c in candidates] target_values = [c.scores.get("target_register", 0) for c in candidates] def sum_normalize(values: list) -> list | None: @@ -221,6 +226,7 @@ class Path: hamiltonian_norm = sum_normalize(hamiltonian_values) dca_norm = sum_normalize(dca_values) rgr_norm = sum_normalize(rgr_values) + compact_norm = sum_normalize(compact_values) target_norm = sum_normalize(target_values) weights = [] @@ -250,6 +256,8 @@ class Path: w *= dca_norm[i] * config.get("weight_dca_voice_movement", 1) if rgr_norm: w *= rgr_norm[i] * config.get("weight_rgr_voice_movement", 1) + if compact_norm: + w *= compact_norm[i] * config.get("weight_harmonic_compactness", 1) if target_norm: w *= target_norm[i] * config.get("weight_target_register", 1) @@ -262,6 +270,7 @@ class Path: "dca_hamiltonian": hamiltonian_norm[i] if hamiltonian_norm else None, "dca_voice_movement": dca_norm[i] if dca_norm else None, "rgr_voice_movement": rgr_norm[i] if rgr_norm else None, + "harmonic_compactness": compact_norm[i] if compact_norm else None, "target_register": target_norm[i] if target_norm else None, } @@ -413,6 +422,32 @@ class Path: return sum_repeating + def _factor_harmonic_compactness( + self, + destination_chord: Chord, + config: dict, + ) -> float: + """Returns probability based on sum of harmonic distances between all pairs. + + Lower sum = more compact = higher probability. + """ + if config.get("weight_harmonic_compactness", 0) == 0: + return 1.0 + + pitches = destination_chord.pitches + if len(pitches) < 2: + return 1.0 + + total_distance = 0 + for i in range(len(pitches)): + for j in range(i + 1, len(pitches)): + diff = pitches[i].pitch_difference(pitches[j]) + ratio = diff.to_fraction() + distance = math.log2(ratio.numerator * ratio.denominator) + total_distance += distance + + return 1.0 / (1.0 + total_distance) + def _factor_target_register( self, path_chords: list[Chord], @@ -543,6 +578,7 @@ class Path: "dca_hamiltonian": 0.0, "dca_voice_movement": 0.0, "rgr_voice_movement": 0.0, + "harmonic_compactness": 0.0, "target_register": 0.0, } @@ -551,6 +587,7 @@ class Path: w_hamiltonian = weights.get("weight_dca_hamiltonian", 1) w_dca = weights.get("weight_dca_voice_movement", 1) w_rgr = weights.get("weight_rgr_voice_movement", 1) + w_compact = weights.get("weight_harmonic_compactness", 1) w_target = weights.get("weight_target_register", 1) for step in self.steps: @@ -569,6 +606,9 @@ class Path: influence["rgr_voice_movement"] += ( norm.get("rgr_voice_movement") or 0 ) * w_rgr + influence["harmonic_compactness"] += ( + norm.get("harmonic_compactness") or 0 + ) * w_compact influence["target_register"] += ( norm.get("target_register") or 0 ) * w_target