diff --git a/src/io.py b/src/io.py index bfe7a9d..9a15b3e 100644 --- a/src/io.py +++ b/src/io.py @@ -423,6 +423,18 @@ def main(): default=1, help="Weight for DCA voice movement factor - favors voices that stay long to change (0=disabled, default: 1)", ) + parser.add_argument( + "--weight-rgr-voice-movement", + type=float, + default=0, + help="Weight for RGR voice movement factor - favors voices that repeat selection (0=disabled, default: 0)", + ) + parser.add_argument( + "--rgr-voice-movement-threshold", + type=int, + default=5, + help="Maximum consecutive selections before blocking (default: 5)", + ) parser.add_argument( "--weight-target-register", type=float, @@ -659,6 +671,8 @@ def main(): weights_config["weight_contrary_motion"] = args.weight_contrary_motion weights_config["weight_dca_hamiltonian"] = args.weight_dca_hamiltonian 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 # Target register if args.target_register != 0: diff --git a/src/path.py b/src/path.py index 9d58e7b..98a3e70 100644 --- a/src/path.py +++ b/src/path.py @@ -29,6 +29,8 @@ class PathStep: last_visited_counts_after: dict | None = None sustain_counts_before: tuple[int, ...] | None = None sustain_counts_after: tuple[int, ...] | None = None + change_counts_before: tuple[int, ...] | None = None + change_counts_after: tuple[int, ...] | None = None new_cumulative_trans: Pitch | None = None new_voice_map: list[int] = field(default_factory=list) edge_data: dict = field(default_factory=dict) @@ -110,6 +112,7 @@ class Path: # Compute all factor scores last_visited_before = self._get_last_visited_counts() sustain_before = self._get_sustain_counts() + change_before = self._get_change_counts() scores = { "direct_tuning": self._factor_direct_tuning( @@ -133,6 +136,12 @@ class Path: sustain_before, self.weights_config, ), + "rgr_voice_movement": self._factor_rgr_voice_movement( + source_chord, + destination_chord, + change_before, + self.weights_config, + ), "target_register": self._factor_target_register( path_chords, destination_chord, @@ -155,6 +164,13 @@ class Path: else: sustain_after[voice_idx] = 0 + change_after = list(change_before) + for voice_idx in range(len(change_after)): + curr_cents = source_chord.pitches[voice_idx].to_cents() + next_cents = destination_chord.pitches[voice_idx].to_cents() + if curr_cents != next_cents: + change_after[voice_idx] += 1 + step = PathStep( source_node=source_node, destination_node=destination_node, @@ -167,6 +183,8 @@ class Path: last_visited_counts_after=last_visited_after, sustain_counts_before=sustain_before, sustain_counts_after=tuple(sustain_after), + change_counts_before=change_before, + change_counts_after=tuple(change_after), new_cumulative_trans=new_cumulative_trans, new_voice_map=new_voice_map, edge_data=edge_data, @@ -188,6 +206,7 @@ class Path: contrary_values = [c.scores.get("contrary_motion", 0) for c in candidates] 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] target_values = [c.scores.get("target_register", 0) for c in candidates] def sum_normalize(values: list) -> list | None: @@ -201,6 +220,7 @@ class Path: contrary_norm = sum_normalize(contrary_values) hamiltonian_norm = sum_normalize(hamiltonian_values) dca_norm = sum_normalize(dca_values) + rgr_norm = sum_normalize(rgr_values) target_norm = sum_normalize(target_values) weights = [] @@ -228,18 +248,20 @@ class Path: w *= hamiltonian_norm[i] * config.get("weight_dca_hamiltonian", 1) if dca_norm: 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 target_norm: w *= target_norm[i] * config.get("weight_target_register", 1) step.weight = w**16 weights.append(w) - # Store normalized scores (0-1 range) for influence calculation step.normalized_scores = { "melodic_threshold": melodic_norm[i] if melodic_norm else None, "contrary_motion": contrary_norm[i] if contrary_norm else None, "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, "target_register": target_norm[i] if target_norm else None, } @@ -353,6 +375,44 @@ class Path: return sum_changing + def _factor_rgr_voice_movement( + self, + source_chord: Chord, + destination_chord: Chord, + change_counts: tuple[int, ...], + config: dict, + ) -> float: + """Returns probability that voices will repeat selection. + + More consecutive selections = higher probability to be selected again. + Returns 0 when threshold is reached (blocks further selection). + """ + if config.get("weight_rgr_voice_movement", 1) == 0: + return 1.0 + + if change_counts is None: + return 1.0 + + num_voices = len(change_counts) + if num_voices == 0: + return 1.0 + + threshold = config.get("rgr_voice_movement_threshold", 5) + + current_cents = [p.to_cents() for p in source_chord.pitches] + candidate_cents = [p.to_cents() for p in destination_chord.pitches] + + sum_repeating = 0 + + for voice_idx in range(num_voices): + if current_cents[voice_idx] != candidate_cents[voice_idx]: + count = change_counts[voice_idx] + if count >= threshold: + return 0.0 + sum_repeating += count + + return sum_repeating + def _factor_target_register( self, path_chords: list[Chord], @@ -431,6 +491,15 @@ class Path: return tuple(0 for _ in range(self._num_voices)) + def _get_change_counts(self) -> tuple: + """Get change counts from the last step, or initialize fresh.""" + if self.steps: + last_step = self.steps[-1] + if last_step.change_counts_after is not None: + return last_step.change_counts_after + + return tuple(0 for _ in range(self._num_voices)) + def step(self, step: PathStep) -> PathStep: """Commit a chosen candidate to the path. @@ -473,6 +542,7 @@ class Path: "contrary_motion": 0.0, "dca_hamiltonian": 0.0, "dca_voice_movement": 0.0, + "rgr_voice_movement": 0.0, "target_register": 0.0, } @@ -480,6 +550,7 @@ class Path: w_contrary = weights.get("weight_contrary_motion", 0) 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_target = weights.get("weight_target_register", 1) for step in self.steps: @@ -495,6 +566,9 @@ class Path: influence["dca_voice_movement"] += ( norm.get("dca_voice_movement") or 0 ) * w_dca + influence["rgr_voice_movement"] += ( + norm.get("rgr_voice_movement") or 0 + ) * w_rgr influence["target_register"] += ( norm.get("target_register") or 0 ) * w_target