from __future__ import annotations
"""Weighted-search melody generator for the current procedural music engine."""
import random
from dataclasses import dataclass
from math import exp
from .constraints import CandidateContext, SoftConstraint
from .structure import FormPlan, FormSection, GenerationSettings, Melody, Motif, NoteCandidate, NoteEvent
[docs]
@dataclass(frozen=True)
class GenerationAttempt:
events: tuple[NoteEvent, ...]
score: float
[docs]
class MelodyGenerator:
def __init__(self, settings: GenerationSettings, constraints: list[SoftConstraint]):
self.settings = settings
self.constraints = constraints
self.random = random.Random(settings.random_seed)
[docs]
def generate(self) -> Melody:
rhythm = self._build_rhythm()
motif_targets = self._build_motif_targets(rhythm)
climax_index = self._select_climax_index(rhythm)
climax_step = min(self.settings.range_max, max(self.settings.range_min + 4, 7))
attempts: list[GenerationAttempt] = []
for _ in range(self.settings.attempts):
events, score = self._generate_attempt(rhythm, motif_targets, climax_index, climax_step)
attempts.append(GenerationAttempt(events=events, score=score))
best_attempt = max(attempts, key=lambda attempt: attempt.score)
metadata = {
"climax_index": climax_index,
"climax_step": climax_step,
"motif_targets": motif_targets,
"form_kind": self.settings.form_plan.kind,
"form_sections": self.settings.form_plan.sections,
}
return Melody(
key=self.settings.key,
time_signature=self.settings.time_signature,
events=best_attempt.events,
harmony_plan=self.settings.harmonic_plan,
clef=self.settings.clef,
voice_profile=self.settings.voice_profile,
metadata=metadata,
)
def _generate_attempt(
self,
rhythm: list[float],
motif_targets: dict[int, int],
climax_index: int,
climax_step: int,
) -> tuple[tuple[NoteEvent, ...], float]:
events: list[NoteEvent] = []
total_score = 0.0
beat_positions = self._beat_positions(rhythm)
phrase_end_bars = self._phrase_end_bars()
for index, duration in enumerate(rhythm):
bar_number, beat_in_bar = beat_positions[index]
harmony_span = self.settings.harmonic_plan.chord_for_position(
bar_number,
beat_in_bar,
self.settings.time_signature.bar_length,
)
motif_target_step = motif_targets.get(index)
section = self.settings.form_plan.section_for_bar(bar_number)
context = CandidateContext(
key=self.settings.key,
events=tuple(events),
index=index,
bar_number=bar_number,
beat_in_bar=beat_in_bar,
total_events=len(rhythm),
climax_index=climax_index,
climax_step=climax_step,
phrase_end_bars=phrase_end_bars,
current_duration=duration,
harmony_span=harmony_span,
motif_target_step=motif_target_step,
section_role=section.role if section is not None else "free",
section_transform=section.transform if section is not None else "free",
)
candidate_steps = self._candidate_steps(
events,
index,
climax_index,
climax_step,
motif_target_step,
context,
)
chosen_note, chosen_score = self._choose_candidate(candidate_steps, context)
events.append(
NoteEvent(
scale_step=chosen_note.scale_step,
duration=duration,
chromatic_adjustment=chosen_note.chromatic_adjustment,
is_rest=chosen_note.is_rest,
)
)
total_score += chosen_score
return tuple(events), total_score
def _build_rhythm(self) -> list[float]:
rhythm: list[float] = []
bar_length = self.settings.time_signature.bar_length
cadence_duration = min(self.settings.cadence_duration, bar_length)
phrase_end_bars = self._phrase_end_bars()
for bar_number in range(1, self.settings.bars + 1):
bar_rhythm: list[float] = []
remaining = bar_length
reserve_cadence = cadence_duration if bar_number in phrase_end_bars else 0.0
while remaining - reserve_cadence > 0:
choices = [
duration
for duration in self.settings.allowed_durations
if duration <= remaining - reserve_cadence + 1e-9
]
chosen_duration = self.random.choice(choices)
bar_rhythm.append(chosen_duration)
remaining -= chosen_duration
if reserve_cadence:
bar_rhythm.append(reserve_cadence)
remaining -= reserve_cadence
if abs(remaining) > 1e-9:
raise ValueError(f"Bar {bar_number} could not be filled exactly")
rhythm.extend(bar_rhythm)
return rhythm
def _build_motif_targets(self, rhythm: list[float]) -> dict[int, int]:
motif = self.settings.motif or self._default_motif()
motif_targets: dict[int, int] = {}
for section in self.settings.form_plan.sections:
transformed_motif = self._motif_for_section(motif, section)
start_index = self._first_index_of_bar(rhythm, section.start_bar)
if start_index is None:
continue
self._write_motif_targets(rhythm, motif_targets, start_index, transformed_motif)
if section.role == "fragmentation":
repeat_bar = section.start_bar + 1
if repeat_bar <= section.end_bar:
repeat_index = self._first_index_of_bar(rhythm, repeat_bar)
if repeat_index is not None:
repeated_fragment = self._motif_for_section(
motif,
FormSection(
label=section.label,
start_bar=repeat_bar,
end_bar=repeat_bar,
role=section.role,
source_bar=section.source_bar,
transform="sequence_fragment",
),
)
self._write_motif_targets(rhythm, motif_targets, repeat_index, repeated_fragment)
return motif_targets
def _default_motif(self) -> Motif:
return Motif.from_steps(
scale_steps=[0, 1, 2, 1],
durations=[1.0, 1.0, 1.0, 1.0],
name="default_motif",
)
def _motif_for_section(self, motif: Motif, section: FormSection) -> Motif:
transform = section.transform
if transform == "literal":
return motif
if transform == "diatonic_transpose":
return motif.transpose_diatonic(self.settings.motif_repetition_shift)
if transform == "harmonic_transpose":
span = self.settings.harmonic_plan.chord_for_bar(section.start_bar)
if span is None:
return motif
target_degree = self.settings.key.chord_tones(span.roman_symbol)[0]
anchor = self._nearest_step_with_degree(motif.events[0].scale_step, target_degree)
return motif.transpose_diatonic(anchor - motif.events[0].scale_step)
if transform in {"fragment", "sequence_fragment"}:
count = max(2, len(motif.events) // 2)
fragment = Motif(events=motif.events[:count], name=f"{motif.name}_fragment")
if transform == "sequence_fragment":
shift = self.random.choice((-2, -1, 1, 2))
return fragment.transpose_diatonic(shift)
return fragment
if transform == "period_response":
shift = self.random.choice((0, 1, -1, 2))
return motif.transpose_diatonic(shift)
return motif
def _write_motif_targets(
self,
rhythm: list[float],
motif_targets: dict[int, int],
start_index: int,
motif: Motif,
) -> None:
for offset, event in enumerate(motif.events):
event_index = start_index + offset
if event_index >= len(rhythm):
break
if abs(rhythm[event_index] - event.duration) < 1e-9:
motif_targets[event_index] = event.scale_step
def _nearest_step_with_degree(self, reference_step: int, target_degree: int) -> int:
octave_base = reference_step // 7
candidates = [target_degree + 7 * octave for octave in range(octave_base - 1, octave_base + 2)]
valid = [
candidate
for candidate in candidates
if self.settings.range_min <= candidate <= self.settings.range_max
]
if not valid:
return min(
max(target_degree + 7 * octave_base, self.settings.range_min),
self.settings.range_max,
)
return min(valid, key=lambda candidate: abs(candidate - reference_step))
def _first_index_of_bar(self, rhythm: list[float], target_bar: int) -> int | None:
bar_length = self.settings.time_signature.bar_length
running = 0.0
current_bar = 1
for index, duration in enumerate(rhythm):
if current_bar == target_bar and abs(running) < 1e-9:
return index
running += duration
if running >= bar_length - 1e-9:
running = 0.0
current_bar += 1
return None
def _select_climax_index(self, rhythm: list[float]) -> int:
lower_bound = max(2, int(len(rhythm) * 0.55))
upper_bound = max(lower_bound + 1, int(len(rhythm) * 0.75))
strong_beats = [
index
for index, (_, beat_in_bar) in enumerate(self._beat_positions(rhythm))
if index >= lower_bound and index <= upper_bound and beat_in_bar in {0.0, 1.0, 2.0}
]
if strong_beats:
return self.random.choice(strong_beats)
return min(len(rhythm) - 2, upper_bound)
def _phrase_end_bars(self) -> frozenset[int]:
phrase_end_bars = {
bar_number
for bar_number in range(self.settings.phrase_length_bars, self.settings.bars + 1, self.settings.phrase_length_bars)
}
phrase_end_bars.add(self.settings.bars)
return frozenset(phrase_end_bars)
def _beat_positions(self, rhythm: list[float]) -> list[tuple[int, float]]:
positions: list[tuple[int, float]] = []
bar_length = self.settings.time_signature.bar_length
bar_number = 1
beat_in_bar = 0.0
for duration in rhythm:
positions.append((bar_number, beat_in_bar))
beat_in_bar += duration
if beat_in_bar >= bar_length - 1e-9:
beat_in_bar = 0.0
bar_number += 1
return positions
def _candidate_steps(
self,
events: list[NoteEvent],
index: int,
climax_index: int,
climax_step: int,
motif_target_step: int | None,
context: CandidateContext,
) -> list[NoteCandidate]:
anchor_step = self._last_pitched_step(events)
if anchor_step is None:
midpoint = (self.settings.range_min + self.settings.range_max) // 2
seed_steps = [
self._clamp_to_range(step)
for step in (self.settings.range_min, midpoint, self.settings.range_max)
]
else:
seed_steps = [
anchor_step + interval
for interval in (-3, -2, -1, 0, 1, 2, 3)
]
if motif_target_step is not None:
seed_steps.extend([motif_target_step - 1, motif_target_step, motif_target_step + 1])
if index == climax_index:
seed_steps.extend([climax_step - 2, climax_step - 1, climax_step, climax_step + 1])
if context.section_role == "cadence":
seed_steps.extend([0, 4, 6])
diatonic_steps = sorted({
step
for step in seed_steps
if self.settings.range_min <= step <= self.settings.range_max
})
candidates = {NoteCandidate(scale_step=step, chromatic_adjustment=0) for step in diatonic_steps}
if self._should_offer_rest(context):
rest_anchor = anchor_step if anchor_step is not None else self.settings.range_min
candidates.add(NoteCandidate(scale_step=rest_anchor, chromatic_adjustment=0, is_rest=True))
if context.harmony_span is not None:
chord_targets = self.settings.key.chord_scale_targets(context.harmony_span.roman_symbol)
for step in diatonic_steps:
step_degree = step % 7
for chord_degree, adjustment in chord_targets:
if step_degree == chord_degree:
candidates.add(NoteCandidate(scale_step=step, chromatic_adjustment=adjustment))
return sorted(candidates, key=lambda candidate: (candidate.is_rest, candidate.scale_step, candidate.chromatic_adjustment))
def _clamp_to_range(self, step: int) -> int:
return min(max(step, self.settings.range_min), self.settings.range_max)
def _last_pitched_step(self, events: list[NoteEvent]) -> int | None:
for event in reversed(events):
if not event.is_rest:
return event.scale_step
return None
def _should_offer_rest(self, context: CandidateContext) -> bool:
if context.index == 0 or context.candidate_is_final:
return False
if context.current_duration > 1.0:
return False
if context.on_strong_beat:
return False
if context.previous_event is not None and context.previous_event.is_rest:
return False
return context.notes_since_last_rest >= 4 or (
context.bar_number in context.phrase_end_bars and context.beat_in_bar >= 2.0
)
def _choose_candidate(
self,
candidate_steps: list[NoteCandidate],
context: CandidateContext,
) -> tuple[NoteCandidate, float]:
scored_candidates = []
for candidate_step in candidate_steps:
total = 0.0
for constraint in self.constraints:
total += constraint.weight * constraint.evaluate(candidate_step, context)
scored_candidates.append((candidate_step, total))
best_score = max(score for _, score in scored_candidates)
temperature = 1.15
weights = [exp((score - best_score) / temperature) for _, score in scored_candidates]
chosen_step, chosen_score = self.random.choices(scored_candidates, weights=weights, k=1)[0]
return chosen_step, chosen_score