Refactor edge weights with DCA and CLI improvements

- Implement DCA (Dissonant Counterpoint Algorithm) to favor voice changes
- Track actual pitch changes in voice_stay_count (not graph indices)
- Add CLI weight arguments: --weight-melodic, --weight-contrary-motion,
  --weight-hamiltonian, --weight-dca, --weight-target-range
- DCA probability = (sum of stay_counts for changing voices) / (sum of all)
- Test with --weight-dca 100 shows frequent voice changes
This commit is contained in:
Michael Winter 2026-03-14 02:44:30 +01:00
parent 61149597c9
commit 737f1e4886
3 changed files with 317 additions and 125 deletions

View file

@ -9,24 +9,74 @@ A rational theory of harmony based on Michael Winter's theory of conjunct connec
source venv/bin/activate source venv/bin/activate
# Run # Run
python compact_sets.py --dims 4 --chord-size 3 python -m src.io --dims 4 --chord-size 3
``` ```
## CLI Options ## CLI Options
### Graph Parameters
- `--dims 4|5|7|8` - Number of prime dimensions (default: 7) - `--dims 4|5|7|8` - Number of prime dimensions (default: 7)
- `--chord-size 3|4` - Size of chords (default: 3) - `--chord-size 3|4` - Size of chords (default: 3)
- `--max-path` - Maximum path length (default: 50) - `--max-path` - Maximum path length (default: 50)
- `--symdiff-min`, `--symdiff-max` - Symmetric difference range (default: 2-2) - `--symdiff-min`, `--symdiff-max` - Symmetric difference range (default: 2-2)
- `--melodic-min`, `--melodic-max` - Voice movement thresholds in cents
- `--dca` - DCA multiplier (default: 2.0)
- `--target-range` - Target range in octaves for rising register (default: disabled)
- `--seed` - Random seed (default: random) - `--seed` - Random seed (default: random)
- `--cache-dir` - Graph cache directory (default: ./cache) - `--cache-dir` - Graph cache directory (default: ./cache)
- `--output-dir` - Output directory (default: output) - `--output-dir` - Output directory (default: output)
- `--rebuild-cache` - Force rebuild graph - `--rebuild-cache` - Force rebuild graph
- `--no-cache` - Disable caching - `--no-cache` - Disable caching
### Voice Leading Constraints (Hard Factors)
These factors eliminate edges that don't meet the criteria.
- `--voice-crossing` - Allow edges where voices cross (default: rejected)
- `--direct-tuning` - Require edges to be directly tunable (default: enabled)
### Voice Leading Weights (Soft Factors)
These factors weigh edges without eliminating them. Set weight to 0 to disable.
- `--weight-melodic` - Weight for melodic threshold (default: 1)
- `--weight-contrary-motion` - Weight for contrary motion (default: 0)
- `--weight-hamiltonian` - Weight for Hamiltonian path (default: 1)
- `--weight-dca` - Weight for DCA
- `--weight-target-range` - Weight for target register range (default: 1)
### Target Range
- `--target-range` - Target range in octaves for rising register (default: disabled)
- When set, enables target_range factor with weight 1
- Use `--weight-target-range` to adjust the weight
## Weight Factor Details
### Hard Factors (Eliminate edges)
- **Direct tuning**: If enabled, eliminates edges that are not directly tunable
- **Voice crossing**: If not allowed (default), eliminates edges with voice crossing
### Soft Factors (Weigh edges)
- **Melodic threshold**: Penalizes edges with voice movements outside the melodic range
- **Contrary motion**: Boosts edges where some voices move up and others move down
- **Hamiltonian**: Penalizes edges that revisit nodes already in the path
- **DCA**: Boosts edges where voices change/move (rather than staying)
- **Target range**: Boosts edges that move toward the target register
## Examples
```bash
# Basic usage
python -m src.io
# Rising register to 2 octaves
python -m src.io --target-range 2
# Heavy target range, no DCA
python -m src.io --target-range 2 --weight-target-range 10 --weight-dca 0
# Disable Hamiltonian (allow revisiting nodes)
python -m src.io --weight-hamiltonian 0
# Enable voice crossing
python -m src.io --voice-crossing
```
## Legacy ## Legacy
The `legacy/` folder contains earlier versions of this code, used to create the musical works published at [unboundedpress.org/works/compact_sets_1_3](https://unboundedpress.org/works/compact_sets_1_3). The `legacy/` folder contains earlier versions of this code, used to create the musical works published at [unboundedpress.org/works/compact_sets_1_3](https://unboundedpress.org/works/compact_sets_1_3).

View file

@ -61,6 +61,7 @@ class PathFinder:
weights_config, weights_config,
tuple(voice_stay_count), tuple(voice_stay_count),
graph_path, graph_path,
cumulative_trans,
) )
edge = choices(out_edges, weights=weights)[0] edge = choices(out_edges, weights=weights)[0]
@ -68,12 +69,6 @@ class PathFinder:
trans = edge[2].get("transposition") trans = edge[2].get("transposition")
movement = edge[2].get("movements", {}) movement = edge[2].get("movements", {})
for src_idx, dest_idx in movement.items():
if src_idx == dest_idx:
voice_stay_count[src_idx] += 1
else:
voice_stay_count[src_idx] = 0
new_voice_map = [None] * num_voices new_voice_map = [None] * num_voices
for src_idx, dest_idx in movement.items(): for src_idx, dest_idx in movement.items():
new_voice_map[dest_idx] = voice_map[src_idx] new_voice_map[dest_idx] = voice_map[src_idx]
@ -91,6 +86,14 @@ class PathFinder:
output_chord = Chord(reordered_pitches, dims) output_chord = Chord(reordered_pitches, dims)
for voice_idx in range(num_voices):
curr_cents = path[-1].pitches[voice_idx].to_cents()
next_cents = output_chord.pitches[voice_idx].to_cents()
if curr_cents == next_cents:
voice_stay_count[voice_idx] += 1
else:
voice_stay_count[voice_idx] = 0
graph_node = next_graph_node graph_node = next_graph_node
graph_path.append(graph_node) graph_path.append(graph_node)
@ -154,127 +157,223 @@ class PathFinder:
config: dict, config: dict,
voice_stay_count: tuple[int, ...] | None = None, voice_stay_count: tuple[int, ...] | None = None,
graph_path: list["Chord"] | None = None, graph_path: list["Chord"] | None = None,
cumulative_trans: "Pitch | None" = None,
) -> list[float]: ) -> list[float]:
"""Calculate weights for edges based on configuration.""" """Calculate weights for edges based on configuration.
Uses hybrid approach:
- Hard factors (direct tuning, voice crossing): multiplication (eliminate if factor fails)
- Soft factors (melodic, contrary, hamiltonian, dca, target range): weighted sum
"""
weights = [] weights = []
dca_multiplier = config.get("dca", 0) for edge_idx, edge in enumerate(out_edges):
if dca_multiplier is None: w = 1.0 # base weight
dca_multiplier = 0
melodic_min = config.get("melodic_threshold_min", 0)
melodic_max = config.get("melodic_threshold_max", float("inf"))
for edge in out_edges:
w = 1.0
edge_data = edge[2] edge_data = edge[2]
cent_diffs = edge_data.get("cent_diffs", []) # Hard factors (multiplication - eliminate edge if factor = 0)
voice_crossing = edge_data.get("voice_crossing", False) # Direct tuning
is_directly_tunable = edge_data.get("is_directly_tunable", False) direct_tuning_factor = self._factor_direct_tuning(edge_data, config)
w *= direct_tuning_factor
if melodic_min is not None or melodic_max is not None: if w == 0:
all_within_range = True weights.append(0)
for cents in cent_diffs:
if melodic_min is not None and cents < melodic_min:
all_within_range = False
break
if melodic_max is not None and cents > melodic_max:
all_within_range = False
break
if all_within_range:
w *= 10
else:
w = 0.0
if w == 0.0:
weights.append(w)
continue continue
if config.get("contrary_motion", False): # Voice crossing
if len(cent_diffs) >= 3: voice_crossing_factor = self._factor_voice_crossing(edge_data, config)
sorted_diffs = sorted(cent_diffs) w *= voice_crossing_factor
if sorted_diffs[0] < 0 and sorted_diffs[-1] > 0:
w *= 100
if config.get("direct_tuning", False): if w == 0:
if is_directly_tunable: weights.append(0)
w *= 10 continue
if not config.get("voice_crossing_allowed", False): # Soft factors (weighted sum)
if edge_data.get("voice_crossing", False): w += self._factor_melodic_threshold(edge_data, config) * config.get(
w = 0.0 "weight_melodic", 1
)
w += self._factor_contrary_motion(edge_data, config) * config.get(
"weight_contrary_motion", 0
)
w += self._factor_hamiltonian(edge, graph_path, config) * config.get(
"weight_hamiltonian", 1
)
if config.get("hamiltonian", False): w += self._factor_dca(
destination = edge[1] edge, path, voice_stay_count, config, cumulative_trans
if graph_path and destination in graph_path: ) * config.get("weight_dca", 1)
w *= 0.1 w += self._factor_target_range(
else: edge_data, path, config, cumulative_trans
w *= 10 ) * config.get("weight_target_range", 1)
if dca_multiplier > 0 and voice_stay_count is not None and len(path) > 0:
source_chord = path[-1]
movements = edge_data.get("movements", {})
move_boost = 1.0
for voice_idx in range(len(voice_stay_count)):
if voice_idx in movements:
dest_idx = movements[voice_idx]
if dest_idx != voice_idx:
stay_count = voice_stay_count[voice_idx]
move_boost *= dca_multiplier**stay_count
w *= move_boost
# Target range weight - boost edges that expand the path range toward target
if config.get("target_range", False) and len(path) > 0:
target_octaves = config.get("target_range_octaves", 2.0)
target_cents = target_octaves * 1200
# Get all pitches from current path
all_cents = []
for chord in path:
for pitch in chord.pitches:
all_cents.append(pitch.to_cents())
current_min = min(all_cents)
current_max = max(all_cents)
current_range = current_max - current_min
# For this edge, compute what the new range would be
# Get the destination chord after transposition (need to account for trans)
dest_chord = edge[1]
trans = edge_data.get("transposition")
# Calculate new pitches after transposition
if trans is not None:
dest_pitches = dest_chord.transpose(trans).pitches
else:
dest_pitches = dest_chord.pitches
new_cents = all_cents + [p.to_cents() for p in dest_pitches]
new_min = min(new_cents)
new_max = max(new_cents)
new_range = new_max - new_min
# Boost based on how close we are to target
# Closer to target = higher boost
current_gap = abs(target_cents - current_range)
new_gap = abs(target_cents - new_range)
if new_gap < current_gap:
# We're getting closer to target - boost
improvement = (current_gap - new_gap) / target_cents
w *= 1 + improvement * 10
else:
# We're moving away from target - small penalty
w *= 0.9
weights.append(w) weights.append(w)
return weights return weights
def _factor_melodic_threshold(self, edge_data: dict, config: dict) -> float:
"""Returns 1.0 if all voice movements are within melodic threshold, 0.0 otherwise."""
# Check weight - if 0, return 1.0 (neutral)
if config.get("weight_melodic", 1) == 0:
return 1.0
melodic_min = config.get("melodic_threshold_min", 0)
melodic_max = config.get("melodic_threshold_max", float("inf"))
cent_diffs = edge_data.get("cent_diffs", [])
if melodic_min is not None or melodic_max is not None:
for cents in cent_diffs:
if melodic_min is not None and cents < melodic_min:
return 0.0
if melodic_max is not None and cents > melodic_max:
return 0.0
return 1.0
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:
"""Returns 1.0 if voices move in contrary motion, 0.0 otherwise."""
# Check weight - if 0, return 1.0 (neutral)
if config.get("weight_contrary_motion", 0) == 0:
return 1.0
if not config.get("contrary_motion", False):
return 1.0 # neutral if not configured
cent_diffs = edge_data.get("cent_diffs", [])
if len(cent_diffs) >= 3:
sorted_diffs = sorted(cent_diffs)
if sorted_diffs[0] < 0 and sorted_diffs[-1] > 0:
return 1.0
return 0.0
def _factor_hamiltonian(
self, edge: tuple, graph_path: list | 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:
return 1.0
if not config.get("hamiltonian", False):
return 1.0
destination = edge[1]
if graph_path and destination in graph_path:
return 0.1 # penalize revisiting
return 1.0
def _factor_dca(
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.
"""
if config.get("weight_dca", 1) == 0:
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,
edge_data: dict,
path: list,
config: dict,
cumulative_trans: "Pitch | None",
) -> float:
"""Returns 1.0 if at target, 0.0 if far from target.
Target progresses based on position in path.
"""
# Check weight - if 0, return 1.0 (neutral)
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
progress = len(path) / max_path
current_target = progress * target_cents
edge_trans = edge_data.get("transposition")
new_cumulative = cumulative_trans.transpose(edge_trans)
new_cumulative_cents = new_cumulative.to_cents()
# Closeness: 1.0 if at target, 0.0 if far
if current_target <= 0:
return 1.0
distance = abs(new_cumulative_cents - current_target)
return 1.0 / (1.0 + distance)
def is_hamiltonian(self, path: list["Chord"]) -> bool: def is_hamiltonian(self, path: list["Chord"]) -> bool:
"""Check if a path is Hamiltonian (visits all nodes exactly once).""" """Check if a path is Hamiltonian (visits all nodes exactly once)."""
return len(path) == len(self.graph.nodes()) and len(set(path)) == len(path) return len(path) == len(self.graph.nodes()) and len(set(path)) == len(path)

View file

@ -273,12 +273,6 @@ def main():
default=500, default=500,
help="Maximum cents for any pitch movement (0 = no maximum)", help="Maximum cents for any pitch movement (0 = no maximum)",
) )
parser.add_argument(
"--dca",
type=float,
default=2.0,
help="DCA (Dissonant Counterpoint Algorithm) multiplier for voice momentum (0 to disable)",
)
parser.add_argument( parser.add_argument(
"--target-range", "--target-range",
type=float, type=float,
@ -286,10 +280,46 @@ def main():
help="Target range in octaves for rising register (default: disabled, 2 = two octaves)", help="Target range in octaves for rising register (default: disabled, 2 = two octaves)",
) )
parser.add_argument( parser.add_argument(
"--allow-voice-crossing", "--voice-crossing",
action="store_true", action="store_true",
help="Allow edges where voices cross (default: reject)", help="Allow edges where voices cross (default: reject)",
) )
parser.add_argument(
"--direct-tuning",
action="store_true",
default=True,
help="Require edges to be directly tunable (default: enabled)",
)
parser.add_argument(
"--weight-melodic",
type=float,
default=1,
help="Weight for melodic threshold factor (0=disabled, default: 1)",
)
parser.add_argument(
"--weight-contrary-motion",
type=float,
default=0,
help="Weight for contrary motion factor (0=disabled, default: 0)",
)
parser.add_argument(
"--weight-hamiltonian",
type=float,
default=1,
help="Weight for Hamiltonian factor (0=disabled, default: 1)",
)
parser.add_argument(
"--weight-dca",
type=float,
default=1,
help="Weight for DCA factor - favors edges where voices stay (0=disabled, default: 1)",
)
parser.add_argument(
"--weight-target-range",
type=float,
default=1,
help="Weight for target range factor (0=disabled, default: 1)",
)
parser.add_argument( parser.add_argument(
"--dims", type=int, default=7, help="Number of prime dimensions (4, 5, 7, or 8)" "--dims", type=int, default=7, help="Number of prime dimensions (4, 5, 7, or 8)"
) )
@ -394,11 +424,24 @@ def main():
weights_config = path_finder._default_weights_config() weights_config = path_finder._default_weights_config()
weights_config["melodic_threshold_min"] = args.melodic_min weights_config["melodic_threshold_min"] = args.melodic_min
weights_config["melodic_threshold_max"] = args.melodic_max weights_config["melodic_threshold_max"] = args.melodic_max
weights_config["dca"] = args.dca weights_config["voice_crossing_allowed"] = args.voice_crossing
weights_config["voice_crossing_allowed"] = args.allow_voice_crossing weights_config["direct_tuning"] = args.direct_tuning
# 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
# Target range
if args.target_range > 0: if args.target_range > 0:
weights_config["target_range"] = True weights_config["target_range"] = True
weights_config["target_range_octaves"] = args.target_range weights_config["target_range_octaves"] = args.target_range
weights_config["weight_target_range"] = args.weight_target_range
else:
weights_config["weight_target_range"] = 0 # disabled
weights_config["max_path"] = args.max_path
path = path_finder.find_stochastic_path( path = path_finder.find_stochastic_path(
max_length=args.max_path, weights_config=weights_config max_length=args.max_path, weights_config=weights_config