Add RGR voice movement factor

- Track consecutive voice change selections (change_counts)
- Favor voices that have been selected to change multiple times
- Block selection when threshold reached (default: 5)
- Add CLI args: --weight-rgr-voice-movement, --rgr-voice-movement-threshold
This commit is contained in:
Michael Winter 2026-04-04 16:53:44 +02:00
parent 1a56c35276
commit 65e3a64a0c
2 changed files with 89 additions and 1 deletions

View file

@ -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:

View file

@ -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