From 218d7a55ffec3729d43dbfd7ce6749c6e88963f6 Mon Sep 17 00:00:00 2001 From: Michael Winter Date: Sun, 15 Mar 2026 12:20:12 +0100 Subject: [PATCH] Refactor DCA factor: rename to DCA Voice Movement and DCA Hamiltonian - Rename _factor_dca to _factor_dca_voice_movement (tracks voice pitch changes) - Rename _factor_hamiltonian to _factor_dca_hamiltonian (tracks node visit counts) - Update CLI: --weight-dca -> --weight-dca-voice-movement - Update CLI: --weight-hamiltonian -> --weight-dca-hamiltonian - Remove hamiltonian coverage from analysis (no longer tracking) --- src/analyze.py | 23 ++++++-------------- src/graph.py | 57 +++++++++++++++++++++++++++++++++++++------------- src/io.py | 12 +++++------ 3 files changed, 54 insertions(+), 38 deletions(-) diff --git a/src/analyze.py b/src/analyze.py index daa0ab3..91d0c04 100644 --- a/src/analyze.py +++ b/src/analyze.py @@ -195,28 +195,17 @@ def format_analysis(metrics: dict) -> str: f"Percentage: {metrics['contrary_motion_percent']:.1f}%", f"Avg score: {metrics['contrary_motion_score']:.2f}", "", - "--- DCA (Voice Stay) ---", + "--- DCA Voice Movement ---", f"Avg stay count: {metrics['dca_avg_voice_stay']:.2f} steps", f"Max stay count: {metrics['dca_max_voice_stay']} steps", "", - "--- Hamiltonian ---", - f"Unique nodes: {metrics['hamiltonian_unique_nodes']}", + "--- Target Range ---", + f"Target: {metrics['target_octaves']} octaves ({metrics['target_cents']:.0f} cents)", + f"Start: {metrics['target_start_cents']:.0f} cents", + f"End: {metrics['target_end_cents']:.0f} cents", + f"Achieved: {metrics['target_actual_cents']:.0f} cents ({metrics['target_percent']:.1f}%)", ] - if metrics["hamiltonian_coverage"] is not None: - lines.append(f"Coverage: {metrics['hamiltonian_coverage']:.1f}%") - - lines.extend( - [ - "", - "--- Target Range ---", - f"Target: {metrics['target_octaves']} octaves ({metrics['target_cents']:.0f} cents)", - f"Start: {metrics['target_start_cents']:.0f} cents", - f"End: {metrics['target_end_cents']:.0f} cents", - f"Achieved: {metrics['target_actual_cents']:.0f} cents ({metrics['target_percent']:.1f}%)", - ] - ) - return "\n".join(lines) diff --git a/src/graph.py b/src/graph.py index 414c1a1..8eaa492 100644 --- a/src/graph.py +++ b/src/graph.py @@ -44,6 +44,11 @@ class PathFinder: graph_path = [graph_node] + # Track how long since each node was last visited (for DCA Hamiltonian) + node_visit_counts = {node: 0 for node in self.graph.nodes()} + # Mark start node as just visited + node_visit_counts[graph_node] = 0 + from .pitch import Pitch dims = output_chord.dims @@ -55,6 +60,10 @@ class PathFinder: voice_stay_count = [0] * num_voices for _ in range(max_length): + # Increment all node visit counts + for node in node_visit_counts: + node_visit_counts[node] += 1 + out_edges = list(self.graph.out_edges(graph_node, data=True)) if not out_edges: @@ -68,6 +77,7 @@ class PathFinder: tuple(voice_stay_count), graph_path, cumulative_trans, + node_visit_counts, ) edge = choices(out_edges, weights=weights)[0] @@ -103,6 +113,10 @@ class PathFinder: graph_node = next_graph_node graph_path.append(graph_node) + # Reset visit count for visited node + if next_graph_node in node_visit_counts: + node_visit_counts[next_graph_node] = 0 + path.append(output_chord) last_graph_nodes = last_graph_nodes + (graph_node,) if len(last_graph_nodes) > 2: @@ -164,6 +178,7 @@ class PathFinder: voice_stay_count: tuple[int, ...] | None = None, graph_path: list["Chord"] | None = None, cumulative_trans: "Pitch | None" = None, + node_visit_counts: dict | None = None, ) -> list[float]: """Calculate weights for edges based on configuration. @@ -201,10 +216,12 @@ class PathFinder: melodic_values.append(self._factor_melodic_threshold(edge_data, config)) contrary_values.append(self._factor_contrary_motion(edge_data, config)) hamiltonian_values.append( - self._factor_hamiltonian(edge, graph_path, config) + self._factor_dca_hamiltonian(edge, node_visit_counts, config) ) dca_values.append( - self._factor_dca(edge, path, voice_stay_count, config, cumulative_trans) + self._factor_dca_voice_movement( + edge, path, voice_stay_count, config, cumulative_trans + ) ) target_values.append( self._factor_target_range(edge, path, config, cumulative_trans) @@ -248,9 +265,9 @@ class PathFinder: if contrary_norm: w += contrary_norm[i] * config.get("weight_contrary_motion", 0) if hamiltonian_norm: - w += hamiltonian_norm[i] * config.get("weight_hamiltonian", 1) + w += hamiltonian_norm[i] * config.get("weight_dca_hamiltonian", 1) if dca_norm: - w += dca_norm[i] * config.get("weight_dca", 1) + w += dca_norm[i] * config.get("weight_dca_voice_movement", 1) if target_norm: w += target_norm[i] * config.get("weight_target_range", 1) @@ -321,23 +338,33 @@ class PathFinder: distance = abs(num_up - ideal_up) return max(0.0, 1.0 - (distance / ideal_up)) - def _factor_hamiltonian( - self, edge: tuple, graph_path: list | None, config: dict + def _factor_dca_hamiltonian( + self, edge: tuple, node_visit_counts: dict | None, config: dict ) -> float: - """Returns 1.0 if destination not visited, lower if already visited.""" - # Check weight - if 0, return 1.0 (neutral) - if config.get("weight_hamiltonian", 1) == 0: + """Returns probability based on how long since node was last visited. + + DCA Hamiltonian: longer since visited = higher probability. + Similar to DCA voice movement but for graph nodes. + """ + if config.get("weight_dca_hamiltonian", 1) == 0: return 1.0 - if not config.get("hamiltonian", False): + if node_visit_counts is None: return 1.0 destination = edge[1] - if graph_path and destination in graph_path: - return 0.1 # penalize revisiting - return 1.0 + if destination not in node_visit_counts: + return 1.0 - def _factor_dca( + visit_count = node_visit_counts[destination] + total_counts = sum(node_visit_counts.values()) + + if total_counts == 0: + return 1.0 + + return visit_count / total_counts + + def _factor_dca_voice_movement( self, edge: tuple, path: list, @@ -352,7 +379,7 @@ class PathFinder: Higher probability = more likely to choose edge where long-staying voices change. """ - if config.get("weight_dca", 1) == 0: + if config.get("weight_dca_voice_movement", 1) == 0: return 1.0 if voice_stay_count is None or len(path) == 0: diff --git a/src/io.py b/src/io.py index 6d6ab46..44f6763 100644 --- a/src/io.py +++ b/src/io.py @@ -303,16 +303,16 @@ def main(): help="Weight for contrary motion factor (0=disabled, default: 0)", ) parser.add_argument( - "--weight-hamiltonian", + "--weight-dca-hamiltonian", type=float, default=1, - help="Weight for Hamiltonian factor (0=disabled, default: 1)", + help="Weight for DCA Hamiltonian factor - favors long-unvisited nodes (0=disabled, default: 1)", ) parser.add_argument( - "--weight-dca", + "--weight-dca-voice-movement", type=float, default=1, - help="Weight for DCA factor - favors edges where voices stay (0=disabled, default: 1)", + help="Weight for DCA voice movement factor - favors voices that stay long to change (0=disabled, default: 1)", ) parser.add_argument( "--weight-target-range", @@ -435,8 +435,8 @@ def main(): # Soft factor weights weights_config["weight_melodic"] = args.weight_melodic weights_config["weight_contrary_motion"] = args.weight_contrary_motion - weights_config["weight_hamiltonian"] = args.weight_hamiltonian - weights_config["weight_dca"] = args.weight_dca + weights_config["weight_dca_hamiltonian"] = args.weight_dca_hamiltonian + weights_config["weight_dca_voice_movement"] = args.weight_dca_voice_movement # Target range if args.target_range > 0: