2026-03-13 18:38:38 +01:00
|
|
|
#!/usr/bin/env python
|
|
|
|
|
"""
|
|
|
|
|
PathFinder - finds paths through voice leading graphs.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
2026-03-16 00:39:32 +01:00
|
|
|
from dataclasses import dataclass
|
2026-03-13 18:38:38 +01:00
|
|
|
import networkx as nx
|
|
|
|
|
from random import choices, seed
|
2026-03-16 14:00:10 +01:00
|
|
|
from typing import Callable, Iterator
|
2026-03-13 18:38:38 +01:00
|
|
|
|
2026-03-16 00:39:32 +01:00
|
|
|
from .path import Path
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
class Candidate:
|
|
|
|
|
"""A candidate edge with raw factor scores."""
|
|
|
|
|
|
|
|
|
|
edge: tuple
|
|
|
|
|
edge_index: int
|
|
|
|
|
graph_node: "Chord"
|
|
|
|
|
scores: dict[str, float]
|
|
|
|
|
weight: float = 0.0 # computed later by _compute_weights
|
|
|
|
|
|
2026-03-13 18:38:38 +01:00
|
|
|
|
|
|
|
|
class PathFinder:
|
|
|
|
|
"""Finds paths through voice leading graphs."""
|
|
|
|
|
|
|
|
|
|
def __init__(self, graph: nx.MultiDiGraph):
|
|
|
|
|
self.graph = graph
|
|
|
|
|
|
|
|
|
|
def find_stochastic_path(
|
|
|
|
|
self,
|
|
|
|
|
start_chord: "Chord | None" = None,
|
|
|
|
|
max_length: int = 100,
|
|
|
|
|
weights_config: dict | None = None,
|
2026-03-16 14:00:10 +01:00
|
|
|
callback: Callable[[int, Path, dict], None] | None = None,
|
|
|
|
|
interval: int = 1,
|
2026-03-16 00:39:32 +01:00
|
|
|
) -> Path:
|
2026-03-15 11:13:24 +01:00
|
|
|
"""Find a stochastic path through the graph.
|
|
|
|
|
|
|
|
|
|
Returns:
|
2026-03-16 00:39:32 +01:00
|
|
|
Path object containing output chords, graph chords, and metadata
|
2026-03-15 11:13:24 +01:00
|
|
|
"""
|
2026-03-13 18:38:38 +01:00
|
|
|
if weights_config is None:
|
|
|
|
|
weights_config = self._default_weights_config()
|
|
|
|
|
|
|
|
|
|
chord = self._initialize_chords(start_chord)
|
|
|
|
|
if not chord or chord[0] is None or len(self.graph.nodes()) == 0:
|
2026-03-16 00:39:32 +01:00
|
|
|
return Path(chord[0] if chord else None, weights_config)
|
2026-03-13 18:38:38 +01:00
|
|
|
|
|
|
|
|
original_chord = chord[0]
|
|
|
|
|
|
2026-03-16 00:39:32 +01:00
|
|
|
path_obj = Path(original_chord, weights_config)
|
2026-03-16 01:11:15 +01:00
|
|
|
path_obj.init_state(
|
|
|
|
|
set(self.graph.nodes()), len(original_chord.pitches), original_chord
|
|
|
|
|
)
|
2026-03-13 18:38:38 +01:00
|
|
|
|
2026-03-16 01:11:15 +01:00
|
|
|
graph_node = original_chord
|
2026-03-16 14:00:10 +01:00
|
|
|
step_num = 0
|
2026-03-13 18:38:38 +01:00
|
|
|
|
|
|
|
|
for _ in range(max_length):
|
|
|
|
|
out_edges = list(self.graph.out_edges(graph_node, data=True))
|
|
|
|
|
|
|
|
|
|
if not out_edges:
|
|
|
|
|
break
|
|
|
|
|
|
2026-03-16 14:00:10 +01:00
|
|
|
# Derive state from last step (or initialize fresh for step 0)
|
|
|
|
|
if path_obj.steps:
|
|
|
|
|
last_step = path_obj.steps[-1]
|
|
|
|
|
voice_stay_count = last_step.sustain_count_after
|
|
|
|
|
node_visit_counts = last_step.last_visited_count_after
|
|
|
|
|
else:
|
|
|
|
|
# First step - derive from path object's current state
|
|
|
|
|
voice_stay_count = tuple(0 for _ in range(len(path_obj._voice_map)))
|
|
|
|
|
node_visit_counts = {node: 0 for node in set(self.graph.nodes())}
|
|
|
|
|
|
2026-03-16 00:39:32 +01:00
|
|
|
# Build candidates with raw scores
|
|
|
|
|
candidates = self._build_candidates(
|
2026-03-13 18:38:38 +01:00
|
|
|
out_edges,
|
2026-03-16 00:39:32 +01:00
|
|
|
path_obj.output_chords,
|
2026-03-13 18:38:38 +01:00
|
|
|
weights_config,
|
2026-03-16 14:00:10 +01:00
|
|
|
voice_stay_count,
|
2026-03-16 01:11:15 +01:00
|
|
|
path_obj.graph_chords,
|
|
|
|
|
path_obj._cumulative_trans,
|
2026-03-16 14:00:10 +01:00
|
|
|
node_visit_counts,
|
2026-03-13 18:38:38 +01:00
|
|
|
)
|
|
|
|
|
|
2026-03-16 00:39:32 +01:00
|
|
|
# Compute weights from raw scores
|
|
|
|
|
self._compute_weights(candidates, weights_config)
|
|
|
|
|
|
|
|
|
|
# Filter out candidates with zero weight
|
|
|
|
|
valid_candidates = [c for c in candidates if c.weight > 0]
|
|
|
|
|
if not valid_candidates:
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
# Select using weighted choice
|
|
|
|
|
chosen = choices(
|
|
|
|
|
valid_candidates, weights=[c.weight for c in valid_candidates]
|
|
|
|
|
)[0]
|
|
|
|
|
|
2026-03-16 01:11:15 +01:00
|
|
|
# Use path.step() to handle all voice-leading and state updates
|
|
|
|
|
path_obj.step(
|
2026-03-16 14:00:10 +01:00
|
|
|
edge=chosen.edge,
|
2026-03-16 01:11:15 +01:00
|
|
|
candidates=candidates,
|
|
|
|
|
chosen_scores=chosen.scores,
|
2026-03-13 18:38:38 +01:00
|
|
|
)
|
|
|
|
|
|
2026-03-16 01:11:15 +01:00
|
|
|
graph_node = chosen.graph_node
|
2026-03-16 14:00:10 +01:00
|
|
|
step_num += 1
|
|
|
|
|
|
|
|
|
|
# Invoke callback if configured
|
|
|
|
|
if callback is not None and step_num % interval == 0:
|
|
|
|
|
callback(step_num, path_obj, weights_config)
|
2026-03-13 18:38:38 +01:00
|
|
|
|
2026-03-16 00:39:32 +01:00
|
|
|
return path_obj
|
2026-03-13 18:38:38 +01:00
|
|
|
|
2026-03-16 00:39:32 +01:00
|
|
|
def _build_candidates(
|
2026-03-13 18:38:38 +01:00
|
|
|
self,
|
|
|
|
|
out_edges: list,
|
|
|
|
|
path: list["Chord"],
|
|
|
|
|
config: dict,
|
2026-03-16 00:39:32 +01:00
|
|
|
voice_stay_count: tuple[int, ...] | None,
|
|
|
|
|
graph_path: list["Chord"] | None,
|
|
|
|
|
cumulative_trans: "Pitch | None",
|
|
|
|
|
node_visit_counts: dict | None,
|
|
|
|
|
) -> list["Candidate"]:
|
|
|
|
|
"""Build candidates with raw factor scores only."""
|
2026-03-15 12:04:08 +01:00
|
|
|
if not out_edges:
|
|
|
|
|
return []
|
2026-03-13 18:38:38 +01:00
|
|
|
|
2026-03-16 00:39:32 +01:00
|
|
|
candidates = []
|
|
|
|
|
for i, edge in enumerate(out_edges):
|
2026-03-13 18:38:38 +01:00
|
|
|
edge_data = edge[2]
|
|
|
|
|
|
2026-03-16 00:39:32 +01:00
|
|
|
# All factors - always compute verbatim
|
2026-03-15 12:04:08 +01:00
|
|
|
direct_tuning = self._factor_direct_tuning(edge_data, config)
|
|
|
|
|
voice_crossing = self._factor_voice_crossing(edge_data, config)
|
2026-03-16 00:39:32 +01:00
|
|
|
melodic = self._factor_melodic_threshold(edge_data, config)
|
|
|
|
|
contrary = self._factor_contrary_motion(edge_data, config)
|
|
|
|
|
hamiltonian = self._factor_dca_hamiltonian(edge, node_visit_counts, config)
|
|
|
|
|
dca_voice = self._factor_dca_voice_movement(
|
|
|
|
|
edge, path, voice_stay_count, config, cumulative_trans
|
|
|
|
|
)
|
|
|
|
|
target = self._factor_target_range(edge, path, config, cumulative_trans)
|
2026-03-15 12:04:08 +01:00
|
|
|
|
2026-03-16 00:39:32 +01:00
|
|
|
scores = {
|
|
|
|
|
"direct_tuning": direct_tuning,
|
|
|
|
|
"voice_crossing": voice_crossing,
|
|
|
|
|
"melodic_threshold": melodic,
|
|
|
|
|
"contrary_motion": contrary,
|
|
|
|
|
"dca_hamiltonian": hamiltonian,
|
|
|
|
|
"dca_voice_movement": dca_voice,
|
|
|
|
|
"target_range": target,
|
|
|
|
|
}
|
2026-03-13 18:38:38 +01:00
|
|
|
|
2026-03-16 00:39:32 +01:00
|
|
|
candidates.append(Candidate(edge, i, edge[1], scores, 0.0))
|
|
|
|
|
|
|
|
|
|
return candidates
|
|
|
|
|
|
|
|
|
|
def _compute_weights(
|
|
|
|
|
self,
|
|
|
|
|
candidates: list["Candidate"],
|
|
|
|
|
config: dict,
|
|
|
|
|
) -> list[float]:
|
|
|
|
|
"""Compute weights from raw scores for all candidates.
|
|
|
|
|
|
|
|
|
|
Returns a list of weights, and updates each candidate's weight field.
|
|
|
|
|
"""
|
|
|
|
|
if not candidates:
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
# Collect raw values for normalization
|
|
|
|
|
melodic_values = [c.scores.get("melodic_threshold", 0) for c in candidates]
|
|
|
|
|
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]
|
|
|
|
|
target_values = [c.scores.get("target_range", 0) for c in candidates]
|
2026-03-15 12:04:08 +01:00
|
|
|
|
|
|
|
|
# Helper function for sum normalization
|
|
|
|
|
def sum_normalize(values: list) -> list | None:
|
|
|
|
|
"""Normalize values to sum to 1. Returns None if no discrimination."""
|
|
|
|
|
total = sum(values)
|
|
|
|
|
if total == 0 or len(set(values)) <= 1:
|
2026-03-16 00:39:32 +01:00
|
|
|
return None
|
2026-03-15 12:04:08 +01:00
|
|
|
return [v / total for v in values]
|
|
|
|
|
|
|
|
|
|
# Sum normalize each factor
|
|
|
|
|
melodic_norm = sum_normalize(melodic_values)
|
|
|
|
|
contrary_norm = sum_normalize(contrary_values)
|
|
|
|
|
hamiltonian_norm = sum_normalize(hamiltonian_values)
|
|
|
|
|
dca_norm = sum_normalize(dca_values)
|
|
|
|
|
target_norm = sum_normalize(target_values)
|
|
|
|
|
|
2026-03-16 00:39:32 +01:00
|
|
|
# Calculate weights for each candidate
|
2026-03-15 12:04:08 +01:00
|
|
|
weights = []
|
2026-03-16 00:39:32 +01:00
|
|
|
for i, candidate in enumerate(candidates):
|
|
|
|
|
scores = candidate.scores
|
|
|
|
|
w = 1.0
|
2026-03-15 12:04:08 +01:00
|
|
|
|
2026-03-16 00:39:32 +01:00
|
|
|
# Hard factors (multiplicative - eliminates if 0)
|
|
|
|
|
w *= scores.get("direct_tuning", 0)
|
2026-03-14 02:44:30 +01:00
|
|
|
if w == 0:
|
2026-03-16 00:39:32 +01:00
|
|
|
candidate.weight = 0.0
|
|
|
|
|
weights.append(0.0)
|
2026-03-14 02:44:30 +01:00
|
|
|
continue
|
2026-03-13 18:38:38 +01:00
|
|
|
|
2026-03-16 00:39:32 +01:00
|
|
|
w *= scores.get("voice_crossing", 0)
|
2026-03-14 02:44:30 +01:00
|
|
|
if w == 0:
|
2026-03-16 00:39:32 +01:00
|
|
|
candidate.weight = 0.0
|
|
|
|
|
weights.append(0.0)
|
2026-03-14 02:44:30 +01:00
|
|
|
continue
|
2026-03-13 18:38:38 +01:00
|
|
|
|
2026-03-15 12:04:08 +01:00
|
|
|
# Soft factors (sum normalized, then weighted)
|
|
|
|
|
if melodic_norm:
|
|
|
|
|
w += melodic_norm[i] * config.get("weight_melodic", 1)
|
|
|
|
|
if contrary_norm:
|
|
|
|
|
w += contrary_norm[i] * config.get("weight_contrary_motion", 0)
|
|
|
|
|
if hamiltonian_norm:
|
2026-03-15 12:20:12 +01:00
|
|
|
w += hamiltonian_norm[i] * config.get("weight_dca_hamiltonian", 1)
|
2026-03-15 12:04:08 +01:00
|
|
|
if dca_norm:
|
2026-03-15 12:20:12 +01:00
|
|
|
w += dca_norm[i] * config.get("weight_dca_voice_movement", 1)
|
2026-03-15 12:04:08 +01:00
|
|
|
if target_norm:
|
|
|
|
|
w += target_norm[i] * config.get("weight_target_range", 1)
|
2026-03-13 22:04:48 +01:00
|
|
|
|
2026-03-16 00:39:32 +01:00
|
|
|
candidate.weight = w
|
2026-03-13 18:38:38 +01:00
|
|
|
weights.append(w)
|
|
|
|
|
|
|
|
|
|
return weights
|
|
|
|
|
|
2026-03-16 00:39:32 +01:00
|
|
|
def _initialize_chords(self, start_chord: "Chord | None") -> tuple:
|
|
|
|
|
"""Initialize chord sequence."""
|
|
|
|
|
if start_chord is not None:
|
|
|
|
|
return (start_chord,)
|
|
|
|
|
|
|
|
|
|
nodes = list(self.graph.nodes())
|
|
|
|
|
if nodes:
|
|
|
|
|
import random
|
|
|
|
|
|
|
|
|
|
random.shuffle(nodes)
|
|
|
|
|
|
|
|
|
|
weights_config = self._default_weights_config()
|
|
|
|
|
weights_config["voice_crossing_allowed"] = False
|
|
|
|
|
|
|
|
|
|
for chord in nodes[:50]:
|
|
|
|
|
out_edges = list(self.graph.out_edges(chord, data=True))
|
|
|
|
|
if len(out_edges) == 0:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
candidates = self._build_candidates(
|
2026-03-16 01:11:15 +01:00
|
|
|
out_edges, [chord], weights_config, None, None, None, None
|
2026-03-16 00:39:32 +01:00
|
|
|
)
|
|
|
|
|
nonzero = sum(1 for c in candidates if c.weight > 0)
|
|
|
|
|
|
|
|
|
|
if nonzero > 0:
|
|
|
|
|
return (chord,)
|
|
|
|
|
|
|
|
|
|
return (nodes[0],)
|
|
|
|
|
|
|
|
|
|
return (None,)
|
|
|
|
|
|
|
|
|
|
def _default_weights_config(self) -> dict:
|
|
|
|
|
"""Default weights configuration."""
|
|
|
|
|
return {
|
|
|
|
|
"contrary_motion": True,
|
|
|
|
|
"direct_tuning": True,
|
|
|
|
|
"voice_crossing_allowed": False,
|
|
|
|
|
"melodic_threshold_min": 0,
|
|
|
|
|
"melodic_threshold_max": 500,
|
|
|
|
|
"hamiltonian": True,
|
|
|
|
|
"dca": 2.0,
|
|
|
|
|
"target_range": False,
|
|
|
|
|
"target_range_octaves": 2.0,
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-14 02:44:30 +01:00
|
|
|
def _factor_melodic_threshold(self, edge_data: dict, config: dict) -> float:
|
2026-03-16 14:11:27 +01:00
|
|
|
"""Returns continuous score based on melodic threshold.
|
2026-03-14 02:44:30 +01:00
|
|
|
|
2026-03-16 14:11:27 +01:00
|
|
|
- cents == 0: score = 1.0 (no movement is always ideal)
|
|
|
|
|
- Below min (0 < cents < min): score = (cents / min)^2
|
|
|
|
|
- Within range (min <= cents <= max): score = 1.0
|
|
|
|
|
- Above max (cents > max): score = ((1200 - cents) / (1200 - max))^2
|
|
|
|
|
|
|
|
|
|
Returns product of all voice scores.
|
|
|
|
|
"""
|
2026-03-14 02:44:30 +01:00
|
|
|
melodic_min = config.get("melodic_threshold_min", 0)
|
|
|
|
|
melodic_max = config.get("melodic_threshold_max", float("inf"))
|
|
|
|
|
|
|
|
|
|
cent_diffs = edge_data.get("cent_diffs", [])
|
|
|
|
|
|
2026-03-16 14:11:27 +01:00
|
|
|
if not cent_diffs:
|
|
|
|
|
return 1.0
|
|
|
|
|
|
|
|
|
|
product = 1.0
|
|
|
|
|
for cents in cent_diffs:
|
|
|
|
|
if cents == 0:
|
|
|
|
|
score = 1.0
|
|
|
|
|
elif cents < melodic_min:
|
|
|
|
|
score = (cents / melodic_min) ** 2
|
|
|
|
|
elif cents > melodic_max:
|
|
|
|
|
score = ((1200 - cents) / (1200 - melodic_max)) ** 2
|
|
|
|
|
else:
|
|
|
|
|
score = 1.0
|
|
|
|
|
product *= score
|
|
|
|
|
|
|
|
|
|
return product
|
2026-03-14 02:44:30 +01:00
|
|
|
|
|
|
|
|
def _factor_direct_tuning(self, edge_data: dict, config: dict) -> float:
|
|
|
|
|
"""Returns 1.0 if directly tunable (or disabled), 0.0 otherwise."""
|
|
|
|
|
# Check weight - if 0, return 1.0 (neutral)
|
|
|
|
|
if config.get("weight_direct_tuning", 1) == 0:
|
|
|
|
|
return 1.0
|
|
|
|
|
|
|
|
|
|
if config.get("direct_tuning", True):
|
|
|
|
|
if edge_data.get("is_directly_tunable", False):
|
|
|
|
|
return 1.0
|
|
|
|
|
return 0.0
|
|
|
|
|
return 1.0 # not configured, neutral
|
|
|
|
|
|
|
|
|
|
def _factor_voice_crossing(self, edge_data: dict, config: dict) -> float:
|
|
|
|
|
"""Returns 1.0 if no voice crossing (or allowed), 0.0 if crossing and not allowed."""
|
|
|
|
|
if config.get("voice_crossing_allowed", False):
|
|
|
|
|
return 1.0
|
|
|
|
|
|
|
|
|
|
if edge_data.get("voice_crossing", False):
|
|
|
|
|
return 0.0
|
|
|
|
|
return 1.0
|
|
|
|
|
|
|
|
|
|
def _factor_contrary_motion(self, edge_data: dict, config: dict) -> float:
|
2026-03-15 11:31:24 +01:00
|
|
|
"""Returns factor based on contrary motion.
|
|
|
|
|
|
|
|
|
|
Contrary motion: half of moving voices go one direction, half go opposite.
|
|
|
|
|
Weighted by closeness to ideal half split.
|
|
|
|
|
factor = 1.0 - (distance_from_half / half)
|
|
|
|
|
"""
|
2026-03-14 02:44:30 +01:00
|
|
|
if config.get("weight_contrary_motion", 0) == 0:
|
|
|
|
|
return 1.0
|
|
|
|
|
|
|
|
|
|
cent_diffs = edge_data.get("cent_diffs", [])
|
2026-03-15 11:31:24 +01:00
|
|
|
|
|
|
|
|
num_up = sum(1 for d in cent_diffs if d > 0)
|
|
|
|
|
num_down = sum(1 for d in cent_diffs if d < 0)
|
|
|
|
|
num_moving = num_up + num_down
|
|
|
|
|
|
|
|
|
|
if num_moving < 2:
|
|
|
|
|
return 0.0 # Need at least 2 moving voices for contrary motion
|
|
|
|
|
|
|
|
|
|
ideal_up = num_moving / 2
|
|
|
|
|
distance = abs(num_up - ideal_up)
|
|
|
|
|
return max(0.0, 1.0 - (distance / ideal_up))
|
2026-03-14 02:44:30 +01:00
|
|
|
|
2026-03-15 12:20:12 +01:00
|
|
|
def _factor_dca_hamiltonian(
|
|
|
|
|
self, edge: tuple, node_visit_counts: dict | None, config: dict
|
2026-03-14 02:44:30 +01:00
|
|
|
) -> float:
|
2026-03-15 12:20:12 +01:00
|
|
|
"""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:
|
2026-03-14 02:44:30 +01:00
|
|
|
return 1.0
|
|
|
|
|
|
2026-03-15 12:20:12 +01:00
|
|
|
if node_visit_counts is None:
|
2026-03-14 02:44:30 +01:00
|
|
|
return 1.0
|
|
|
|
|
|
|
|
|
|
destination = edge[1]
|
2026-03-15 12:20:12 +01:00
|
|
|
if destination not in node_visit_counts:
|
|
|
|
|
return 1.0
|
|
|
|
|
|
|
|
|
|
visit_count = node_visit_counts[destination]
|
2026-03-15 13:04:00 +01:00
|
|
|
max_count = max(node_visit_counts.values()) if node_visit_counts else 0
|
2026-03-15 12:20:12 +01:00
|
|
|
|
2026-03-15 13:04:00 +01:00
|
|
|
if max_count == 0:
|
2026-03-15 12:20:12 +01:00
|
|
|
return 1.0
|
|
|
|
|
|
2026-03-15 13:04:00 +01:00
|
|
|
# Normalize by max squared - gives stronger discrimination
|
|
|
|
|
return visit_count / (max_count**2)
|
2026-03-14 02:44:30 +01:00
|
|
|
|
2026-03-15 12:20:12 +01:00
|
|
|
def _factor_dca_voice_movement(
|
2026-03-14 02:44:30 +01:00
|
|
|
self,
|
|
|
|
|
edge: tuple,
|
|
|
|
|
path: list,
|
|
|
|
|
voice_stay_count: tuple[int, ...] | None,
|
|
|
|
|
config: dict,
|
|
|
|
|
cumulative_trans: "Pitch | None",
|
|
|
|
|
) -> float:
|
|
|
|
|
"""Returns probability that voices will change.
|
|
|
|
|
|
|
|
|
|
DCA = Dissonant Counterpoint Algorithm
|
|
|
|
|
Probability = (sum of stay_counts for changing voices) / (sum of ALL stay_counts)
|
|
|
|
|
|
|
|
|
|
Higher probability = more likely to choose edge where long-staying voices change.
|
|
|
|
|
"""
|
2026-03-15 12:20:12 +01:00
|
|
|
if config.get("weight_dca_voice_movement", 1) == 0:
|
2026-03-14 02:44:30 +01:00
|
|
|
return 1.0
|
|
|
|
|
|
|
|
|
|
if voice_stay_count is None or len(path) == 0:
|
|
|
|
|
return 1.0
|
|
|
|
|
|
|
|
|
|
if cumulative_trans is None:
|
|
|
|
|
return 1.0
|
|
|
|
|
|
|
|
|
|
num_voices = len(voice_stay_count)
|
|
|
|
|
if num_voices == 0:
|
|
|
|
|
return 1.0
|
|
|
|
|
|
|
|
|
|
current_chord = path[-1]
|
|
|
|
|
edge_data = edge[2]
|
|
|
|
|
next_graph_node = edge[1]
|
|
|
|
|
trans = edge_data.get("transposition")
|
|
|
|
|
if trans is not None:
|
|
|
|
|
candidate_transposed = next_graph_node.transpose(
|
|
|
|
|
cumulative_trans.transpose(trans)
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
candidate_transposed = next_graph_node.transpose(cumulative_trans)
|
|
|
|
|
|
|
|
|
|
current_cents = [p.to_cents() for p in current_chord.pitches]
|
|
|
|
|
candidate_cents = [p.to_cents() for p in candidate_transposed.pitches]
|
|
|
|
|
|
|
|
|
|
sum_changing = 0
|
|
|
|
|
sum_all = sum(voice_stay_count)
|
|
|
|
|
|
|
|
|
|
if sum_all == 0:
|
|
|
|
|
return 1.0
|
|
|
|
|
|
|
|
|
|
for voice_idx in range(num_voices):
|
|
|
|
|
if current_cents[voice_idx] != candidate_cents[voice_idx]:
|
|
|
|
|
sum_changing += voice_stay_count[voice_idx]
|
|
|
|
|
|
|
|
|
|
return sum_changing / sum_all
|
|
|
|
|
|
|
|
|
|
def _factor_target_range(
|
|
|
|
|
self,
|
2026-03-14 03:09:46 +01:00
|
|
|
edge: tuple,
|
2026-03-14 02:44:30 +01:00
|
|
|
path: list,
|
|
|
|
|
config: dict,
|
|
|
|
|
cumulative_trans: "Pitch | None",
|
|
|
|
|
) -> float:
|
2026-03-14 03:05:23 +01:00
|
|
|
"""Returns factor based on movement toward target.
|
2026-03-14 02:44:30 +01:00
|
|
|
|
|
|
|
|
Target progresses based on position in path.
|
2026-03-14 03:09:46 +01:00
|
|
|
Uses average cents of current chord for accurate targeting.
|
2026-03-14 03:05:23 +01:00
|
|
|
Factor > 1.0 if moving toward target, < 1.0 if moving away.
|
2026-03-14 02:44:30 +01:00
|
|
|
"""
|
|
|
|
|
if config.get("weight_target_range", 1) == 0:
|
|
|
|
|
return 1.0
|
|
|
|
|
|
|
|
|
|
if not config.get("target_range", False):
|
|
|
|
|
return 1.0
|
|
|
|
|
|
|
|
|
|
if len(path) == 0 or cumulative_trans is None:
|
|
|
|
|
return 1.0
|
|
|
|
|
|
|
|
|
|
target_octaves = config.get("target_range_octaves", 2.0)
|
|
|
|
|
max_path = config.get("max_path", 50)
|
|
|
|
|
target_cents = target_octaves * 1200
|
|
|
|
|
|
2026-03-14 03:24:04 +01:00
|
|
|
start_avg_cents = sum(p.to_cents() for p in path[0].pitches) / len(
|
|
|
|
|
path[0].pitches
|
|
|
|
|
)
|
2026-03-14 02:44:30 +01:00
|
|
|
progress = len(path) / max_path
|
2026-03-14 03:24:04 +01:00
|
|
|
current_target = start_avg_cents + (progress * target_cents)
|
2026-03-14 02:44:30 +01:00
|
|
|
|
2026-03-14 03:09:46 +01:00
|
|
|
current_chord = path[-1]
|
|
|
|
|
current_avg_cents = sum(p.to_cents() for p in current_chord.pitches) / len(
|
|
|
|
|
current_chord.pitches
|
|
|
|
|
)
|
2026-03-14 03:05:23 +01:00
|
|
|
|
2026-03-14 03:09:46 +01:00
|
|
|
edge_data = edge[2]
|
|
|
|
|
next_graph_node = edge[1]
|
2026-03-14 02:44:30 +01:00
|
|
|
edge_trans = edge_data.get("transposition")
|
2026-03-14 03:09:46 +01:00
|
|
|
if edge_trans is not None:
|
|
|
|
|
candidate_transposed = next_graph_node.transpose(
|
|
|
|
|
cumulative_trans.transpose(edge_trans)
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
candidate_transposed = next_graph_node.transpose(cumulative_trans)
|
|
|
|
|
candidate_avg_cents = sum(
|
|
|
|
|
p.to_cents() for p in candidate_transposed.pitches
|
|
|
|
|
) / len(candidate_transposed.pitches)
|
2026-03-14 02:44:30 +01:00
|
|
|
|
|
|
|
|
if current_target <= 0:
|
|
|
|
|
return 1.0
|
2026-03-14 03:05:23 +01:00
|
|
|
|
2026-03-14 03:09:46 +01:00
|
|
|
dist_before = abs(current_avg_cents - current_target)
|
|
|
|
|
dist_after = abs(candidate_avg_cents - current_target)
|
2026-03-14 03:05:23 +01:00
|
|
|
|
|
|
|
|
if dist_before == 0:
|
|
|
|
|
return 1.0
|
|
|
|
|
|
|
|
|
|
if dist_after < dist_before:
|
|
|
|
|
return 1.0 + (dist_before - dist_after) / dist_before
|
|
|
|
|
elif dist_after > dist_before:
|
|
|
|
|
return max(0.1, 1.0 - (dist_after - dist_before) / dist_before)
|
|
|
|
|
else:
|
|
|
|
|
return 1.0
|
2026-03-14 02:44:30 +01:00
|
|
|
|
2026-03-13 18:38:38 +01:00
|
|
|
def is_hamiltonian(self, path: list["Chord"]) -> bool:
|
|
|
|
|
"""Check if a path is Hamiltonian (visits all nodes exactly once)."""
|
|
|
|
|
return len(path) == len(self.graph.nodes()) and len(set(path)) == len(path)
|