2026-03-13 18:38:38 +01:00
|
|
|
#!/usr/bin/env python
|
|
|
|
|
"""
|
|
|
|
|
PathFinder - finds paths through voice leading graphs.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
import networkx as nx
|
2026-03-16 18:59:13 +01:00
|
|
|
from random import choices
|
2026-03-16 16:53:22 +01:00
|
|
|
from typing import Callable
|
2026-03-13 18:38:38 +01:00
|
|
|
|
2026-03-16 16:53:22 +01:00
|
|
|
from .chord import Chord
|
|
|
|
|
from .path import Path, PathStep
|
2026-03-16 00:39:32 +01:00
|
|
|
|
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 18:59:13 +01:00
|
|
|
# Build candidates using Path's state and factor methods
|
|
|
|
|
candidates = path_obj.get_candidates(out_edges, path_obj.output_chords)
|
|
|
|
|
|
|
|
|
|
# Compute weights using Path's method
|
|
|
|
|
path_obj.compute_weights(candidates, weights_config)
|
2026-03-16 00:39:32 +01:00
|
|
|
|
|
|
|
|
# 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
|
2026-03-16 16:53:22 +01:00
|
|
|
path_obj.step(chosen)
|
2026-03-13 18:38:38 +01:00
|
|
|
|
2026-03-16 16:53:22 +01:00
|
|
|
graph_node = chosen.destination_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 _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
|
|
|
|
|
|
2026-03-16 18:59:13 +01:00
|
|
|
path = Path(chord, weights_config)
|
|
|
|
|
path.init_state(set(self.graph.nodes()), len(chord.pitches), chord)
|
|
|
|
|
candidates = path.get_candidates(out_edges, [chord])
|
|
|
|
|
path.compute_weights(candidates, weights_config)
|
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,
|
2026-03-17 14:11:21 +01:00
|
|
|
"target_register": False,
|
|
|
|
|
"target_register_octaves": 2.0,
|
2026-03-16 00:39:32 +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)
|