Source code for melody_engine.constraints
from __future__ import annotations
"""Soft constraints used to guide melodic generation toward singable results."""
from dataclasses import dataclass
from typing import Protocol
from .structure import HarmonySpan, NoteCandidate, NoteEvent
from .theory import Key
[docs]
@dataclass(frozen=True)
class CandidateContext:
key: Key
events: tuple[NoteEvent, ...]
index: int
bar_number: int
beat_in_bar: float
total_events: int
climax_index: int
climax_step: int
phrase_end_bars: frozenset[int]
current_duration: float
harmony_span: HarmonySpan | None = None
motif_target_step: int | None = None
section_role: str = "free"
section_transform: str = "free"
@property
def previous_event(self) -> NoteEvent | None:
if self.index == 0:
return None
return self.events[self.index - 1]
@property
def previous_interval(self) -> int | None:
if self.index < 2:
return None
return self.events[self.index - 1].scale_step - self.events[self.index - 2].scale_step
@property
def previous_pitched_event(self) -> NoteEvent | None:
for event in reversed(self.events):
if not event.is_rest:
return event
return None
@property
def previous_pitched_interval(self) -> int | None:
pitched = [event for event in self.events if not event.is_rest]
if len(pitched) < 2:
return None
return pitched[-1].scale_step - pitched[-2].scale_step
@property
def notes_since_last_rest(self) -> int:
count = 0
for event in reversed(self.events):
if event.is_rest:
break
count += 1
return count
@property
def candidate_is_final(self) -> bool:
return self.index == self.total_events - 1
@property
def on_strong_beat(self) -> bool:
return self.beat_in_bar in {0.0, 2.0}
[docs]
class SoftConstraint(Protocol):
name: str
weight: float
[docs]
def evaluate(self, candidate: NoteCandidate, context: CandidateContext) -> float:
...
[docs]
@dataclass(frozen=True)
class StepwiseMotionConstraint:
weight: float = 1.0
name: str = "stepwise_motion"
[docs]
def evaluate(self, candidate: NoteCandidate, context: CandidateContext) -> float:
if candidate.is_rest:
return 0.0
previous_event = context.previous_pitched_event
candidate_step = candidate.scale_step
if previous_event is None:
return 0.6 if candidate_step in {0, 2, 4} else 0.0
distance = abs(candidate_step - previous_event.scale_step)
if distance == 0:
return 0.2
if distance == 1:
return 1.5
if distance == 2:
return 1.0
if distance == 3:
return -0.3
if distance == 4:
return -1.0
return -2.3
[docs]
@dataclass(frozen=True)
class LeapRecoveryConstraint:
weight: float = 1.0
name: str = "leap_recovery"
[docs]
def evaluate(self, candidate: NoteCandidate, context: CandidateContext) -> float:
if candidate.is_rest:
return -0.4 if context.previous_pitched_interval is not None and abs(context.previous_pitched_interval) >= 4 else 0.0
previous_interval = context.previous_pitched_interval
previous_event = context.previous_pitched_event
candidate_step = candidate.scale_step
if previous_interval is None or previous_event is None:
return 0.0
if abs(previous_interval) < 4:
return 0.0
current_interval = candidate_step - previous_event.scale_step
if current_interval == 0:
return -0.5
if abs(current_interval) <= 2 and current_interval * previous_interval < 0:
return 2.2
if current_interval * previous_interval > 0:
return -2.0
return -0.6
[docs]
@dataclass(frozen=True)
class LeadingToneResolutionConstraint:
weight: float = 1.0
name: str = "leading_tone_resolution"
[docs]
def evaluate(self, candidate: NoteCandidate, context: CandidateContext) -> float:
previous_event = context.previous_pitched_event
candidate_step = candidate.scale_step
if previous_event is None:
return 0.0
if candidate.is_rest:
return -1.2 if previous_event.scale_step % 7 == 6 else 0.0
if previous_event.scale_step % 7 != 6:
return 0.0
if candidate_step == previous_event.scale_step + 1:
return 2.5
if candidate_step == previous_event.scale_step:
return -1.2
return -2.5
[docs]
@dataclass(frozen=True)
class ChordTonePreferenceConstraint:
weight: float = 1.0
non_chord_tone_penalty: float = -0.35
name: str = "chord_tone_preference"
[docs]
def evaluate(self, candidate: NoteCandidate, context: CandidateContext) -> float:
if context.harmony_span is None:
return 0.0
if candidate.is_rest:
return -0.9 if context.on_strong_beat else -0.1
chord_pitch_classes = context.key.chord_pitch_classes(context.harmony_span.roman_symbol)
candidate_pitch_class = (
context.key.scale_pitch_class(candidate.scale_step) + candidate.chromatic_adjustment
) % 12
if candidate_pitch_class in chord_pitch_classes:
return 1.6 * context.harmony_span.weight
if context.on_strong_beat:
return self.non_chord_tone_penalty * 2 * context.harmony_span.weight
return self.non_chord_tone_penalty * context.harmony_span.weight
[docs]
@dataclass(frozen=True)
class StrongBeatStabilityConstraint:
weight: float = 1.0
name: str = "strong_beat_stability"
[docs]
def evaluate(self, candidate: NoteCandidate, context: CandidateContext) -> float:
if not context.on_strong_beat:
return 0.0
if candidate.is_rest:
return -1.2
candidate_step = candidate.scale_step
degree = candidate_step % 7
if degree in {0, 2, 4}:
return 0.9
if degree == 6:
return -0.6
return 0.1
[docs]
@dataclass(frozen=True)
class PhraseCadenceConstraint:
weight: float = 1.0
name: str = "phrase_cadence"
[docs]
def evaluate(self, candidate: NoteCandidate, context: CandidateContext) -> float:
if context.bar_number not in context.phrase_end_bars:
return 0.0
if candidate.is_rest:
if context.candidate_is_final:
return -6.0
if context.beat_in_bar >= 2.0 and context.current_duration <= 0.5:
return 0.3
return -1.0
candidate_step = candidate.scale_step
if context.candidate_is_final:
if candidate_step % 7 == 0 and candidate.chromatic_adjustment == 0:
return 5.0
if candidate_step % 7 == 4:
return 1.5
return -3.5
if context.beat_in_bar >= 2.0:
if candidate_step % 7 in {0, 4}:
return 1.0
if candidate_step % 7 == 6:
return -1.4
return 0.0
[docs]
@dataclass(frozen=True)
class SingleClimaxConstraint:
weight: float = 1.0
name: str = "single_climax"
[docs]
def evaluate(self, candidate: NoteCandidate, context: CandidateContext) -> float:
if candidate.is_rest:
return -4.0 if context.index == context.climax_index else -0.2
candidate_step = candidate.scale_step
if context.index < context.climax_index:
if candidate_step > context.climax_step:
return -4.5
if candidate_step == context.climax_step:
return -2.0
return 0.0
if context.index == context.climax_index:
if candidate_step == context.climax_step:
return 6.0
if candidate_step == context.climax_step - 1:
return 1.5
return -3.0
if candidate_step >= context.climax_step:
return -5.0
if candidate_step == context.climax_step - 1:
return -1.0
return 0.0
[docs]
@dataclass(frozen=True)
class MotifPreferenceConstraint:
weight: float = 1.0
name: str = "motif_preference"
[docs]
def evaluate(self, candidate: NoteCandidate, context: CandidateContext) -> float:
if context.motif_target_step is None:
return 0.0
if candidate.is_rest:
return -2.0
candidate_step = candidate.scale_step
if candidate_step == context.motif_target_step:
return 3.5 if candidate.chromatic_adjustment == 0 else 2.0
if abs(candidate_step - context.motif_target_step) == 1:
return 0.4
return -1.5
[docs]
@dataclass(frozen=True)
class RepeatedPitchConstraint:
weight: float = 1.0
name: str = "repeated_pitch_control"
[docs]
def evaluate(self, candidate: NoteCandidate, context: CandidateContext) -> float:
if candidate.is_rest:
return 0.0
previous_event = context.previous_pitched_event
if previous_event is None:
return 0.0
same_pitch = (
previous_event.scale_step == candidate.scale_step
and previous_event.chromatic_adjustment == candidate.chromatic_adjustment
)
if not same_pitch:
return 0.0
pitched = [event for event in context.events if not event.is_rest]
if len(pitched) >= 2:
second_previous = pitched[-2]
if (
second_previous.scale_step == candidate.scale_step
and second_previous.chromatic_adjustment == candidate.chromatic_adjustment
):
return -2.2
return -0.5
[docs]
@dataclass(frozen=True)
class DirectionChangeConstraint:
weight: float = 1.0
name: str = "direction_change"
[docs]
def evaluate(self, candidate: NoteCandidate, context: CandidateContext) -> float:
if candidate.is_rest:
return 0.0
previous_interval = context.previous_pitched_interval
previous_event = context.previous_pitched_event
if previous_interval is None or previous_event is None:
return 0.0
current_interval = candidate.scale_step - previous_event.scale_step
if current_interval == 0:
return -0.2
if abs(previous_interval) <= 1:
return 0.0
if current_interval * previous_interval < 0:
return 1.0
if current_interval * previous_interval > 0 and abs(current_interval) >= 2:
return -0.7
return 0.0
[docs]
@dataclass(frozen=True)
class LargeLeapConstraint:
weight: float = 1.0
name: str = "large_leap"
[docs]
def evaluate(self, candidate: NoteCandidate, context: CandidateContext) -> float:
if candidate.is_rest:
return 0.0
previous_event = context.previous_pitched_event
if previous_event is None:
return 0.0
distance = abs(candidate.scale_step - previous_event.scale_step)
if distance <= 3:
return 0.0
if distance == 4:
return -1.5
if distance == 5:
return -3.5
if distance == 6:
return -5.0
return -7.0
[docs]
@dataclass(frozen=True)
class RestConstraint:
weight: float = 1.0
name: str = "rest_usage"
[docs]
def evaluate(self, candidate: NoteCandidate, context: CandidateContext) -> float:
if not candidate.is_rest:
return 0.0
if context.index == 0 or context.candidate_is_final:
return -5.0
if context.current_duration > 1.0:
return -3.5
if context.on_strong_beat:
return -1.8
if context.previous_event is not None and context.previous_event.is_rest:
return -3.0
score = -0.5
if context.notes_since_last_rest >= 6:
score += 1.4
if context.bar_number in context.phrase_end_bars and context.beat_in_bar >= 2.0:
score += 0.7
return score