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:
parent
61149597c9
commit
737f1e4886
58
README.md
58
README.md
|
|
@ -9,24 +9,74 @@ A rational theory of harmony based on Michael Winter's theory of conjunct connec
|
|||
source venv/bin/activate
|
||||
|
||||
# Run
|
||||
python compact_sets.py --dims 4 --chord-size 3
|
||||
python -m src.io --dims 4 --chord-size 3
|
||||
```
|
||||
|
||||
## CLI Options
|
||||
|
||||
### Graph Parameters
|
||||
- `--dims 4|5|7|8` - Number of prime dimensions (default: 7)
|
||||
- `--chord-size 3|4` - Size of chords (default: 3)
|
||||
- `--max-path` - Maximum path length (default: 50)
|
||||
- `--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)
|
||||
- `--cache-dir` - Graph cache directory (default: ./cache)
|
||||
- `--output-dir` - Output directory (default: output)
|
||||
- `--rebuild-cache` - Force rebuild graph
|
||||
- `--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
|
||||
|
||||
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).
|
||||
|
|
|
|||
321
src/graph.py
321
src/graph.py
|
|
@ -61,6 +61,7 @@ class PathFinder:
|
|||
weights_config,
|
||||
tuple(voice_stay_count),
|
||||
graph_path,
|
||||
cumulative_trans,
|
||||
)
|
||||
|
||||
edge = choices(out_edges, weights=weights)[0]
|
||||
|
|
@ -68,12 +69,6 @@ class PathFinder:
|
|||
trans = edge[2].get("transposition")
|
||||
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
|
||||
for src_idx, dest_idx in movement.items():
|
||||
new_voice_map[dest_idx] = voice_map[src_idx]
|
||||
|
|
@ -91,6 +86,14 @@ class PathFinder:
|
|||
|
||||
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_path.append(graph_node)
|
||||
|
||||
|
|
@ -154,127 +157,223 @@ class PathFinder:
|
|||
config: dict,
|
||||
voice_stay_count: tuple[int, ...] | None = None,
|
||||
graph_path: list["Chord"] | None = None,
|
||||
cumulative_trans: "Pitch | None" = None,
|
||||
) -> 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 = []
|
||||
|
||||
dca_multiplier = config.get("dca", 0)
|
||||
if dca_multiplier is None:
|
||||
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
|
||||
for edge_idx, edge in enumerate(out_edges):
|
||||
w = 1.0 # base weight
|
||||
edge_data = edge[2]
|
||||
|
||||
cent_diffs = edge_data.get("cent_diffs", [])
|
||||
voice_crossing = edge_data.get("voice_crossing", False)
|
||||
is_directly_tunable = edge_data.get("is_directly_tunable", False)
|
||||
# Hard factors (multiplication - eliminate edge if factor = 0)
|
||||
# Direct tuning
|
||||
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:
|
||||
all_within_range = True
|
||||
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)
|
||||
if w == 0:
|
||||
weights.append(0)
|
||||
continue
|
||||
|
||||
if config.get("contrary_motion", False):
|
||||
if len(cent_diffs) >= 3:
|
||||
sorted_diffs = sorted(cent_diffs)
|
||||
if sorted_diffs[0] < 0 and sorted_diffs[-1] > 0:
|
||||
w *= 100
|
||||
# Voice crossing
|
||||
voice_crossing_factor = self._factor_voice_crossing(edge_data, config)
|
||||
w *= voice_crossing_factor
|
||||
|
||||
if config.get("direct_tuning", False):
|
||||
if is_directly_tunable:
|
||||
w *= 10
|
||||
if w == 0:
|
||||
weights.append(0)
|
||||
continue
|
||||
|
||||
if not config.get("voice_crossing_allowed", False):
|
||||
if edge_data.get("voice_crossing", False):
|
||||
w = 0.0
|
||||
# Soft factors (weighted sum)
|
||||
w += self._factor_melodic_threshold(edge_data, config) * config.get(
|
||||
"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):
|
||||
destination = edge[1]
|
||||
if graph_path and destination in graph_path:
|
||||
w *= 0.1
|
||||
else:
|
||||
w *= 10
|
||||
|
||||
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
|
||||
w += self._factor_dca(
|
||||
edge, path, voice_stay_count, config, cumulative_trans
|
||||
) * config.get("weight_dca", 1)
|
||||
w += self._factor_target_range(
|
||||
edge_data, path, config, cumulative_trans
|
||||
) * config.get("weight_target_range", 1)
|
||||
|
||||
weights.append(w)
|
||||
|
||||
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:
|
||||
"""Check if a path is Hamiltonian (visits all nodes exactly once)."""
|
||||
return len(path) == len(self.graph.nodes()) and len(set(path)) == len(path)
|
||||
|
|
|
|||
61
src/io.py
61
src/io.py
|
|
@ -273,12 +273,6 @@ def main():
|
|||
default=500,
|
||||
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(
|
||||
"--target-range",
|
||||
type=float,
|
||||
|
|
@ -286,10 +280,46 @@ def main():
|
|||
help="Target range in octaves for rising register (default: disabled, 2 = two octaves)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--allow-voice-crossing",
|
||||
"--voice-crossing",
|
||||
action="store_true",
|
||||
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(
|
||||
"--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["melodic_threshold_min"] = args.melodic_min
|
||||
weights_config["melodic_threshold_max"] = args.melodic_max
|
||||
weights_config["dca"] = args.dca
|
||||
weights_config["voice_crossing_allowed"] = args.allow_voice_crossing
|
||||
weights_config["voice_crossing_allowed"] = args.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:
|
||||
weights_config["target_range"] = True
|
||||
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(
|
||||
max_length=args.max_path, weights_config=weights_config
|
||||
|
|
|
|||
Loading…
Reference in a new issue