diff --git a/README.md b/README.md index 43bd956..6cc92d1 100644 --- a/README.md +++ b/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). diff --git a/src/graph.py b/src/graph.py index e6d5371..7c9612b 100644 --- a/src/graph.py +++ b/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 w == 0: + weights.append(0) + continue - if all_within_range: - w *= 10 - else: - w = 0.0 + # Voice crossing + voice_crossing_factor = self._factor_voice_crossing(edge_data, config) + w *= voice_crossing_factor - if w == 0.0: - weights.append(w) - continue + 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 + # 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("direct_tuning", False): - if is_directly_tunable: - w *= 10 - - if not config.get("voice_crossing_allowed", False): - if edge_data.get("voice_crossing", False): - w = 0.0 - - 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) diff --git a/src/io.py b/src/io.py index f7fda85..3b9e1dd 100644 --- a/src/io.py +++ b/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