from __future__ import annotations
"""Core immutable data structures for melody generation, form, harmony, and voice ranges."""
from dataclasses import dataclass, field, replace
from .theory import Key
[docs]
@dataclass(frozen=True)
class TimeSignature:
beats_per_bar: int
beat_unit: int
[docs]
@classmethod
def from_string(cls, value: str) -> "TimeSignature":
beats_per_bar, beat_unit = value.split("/")
return cls(beats_per_bar=int(beats_per_bar), beat_unit=int(beat_unit))
@property
def bar_length(self) -> float:
return self.beats_per_bar * (4 / self.beat_unit)
def __str__(self) -> str:
return f"{self.beats_per_bar}/{self.beat_unit}"
[docs]
@dataclass(frozen=True)
class NoteEvent:
scale_step: int
duration: float
chromatic_adjustment: int = 0
is_rest: bool = False
[docs]
def transpose_diatonic(self, step_shift: int) -> "NoteEvent":
if self.is_rest:
return self
return replace(self, scale_step=self.scale_step + step_shift)
[docs]
@dataclass(frozen=True)
class NoteCandidate:
scale_step: int
chromatic_adjustment: int = 0
is_rest: bool = False
[docs]
@dataclass(frozen=True)
class VoiceProfile:
name: str
range_min: int
range_max: int
tessitura_min: int
tessitura_max: int
clef_hint: str | None = None
@property
def melodic_span(self) -> int:
return self.range_max - self.range_min
[docs]
@dataclass(frozen=True)
class ChoralePlan:
voice_profiles: tuple[VoiceProfile, ...] = ()
[docs]
@dataclass(frozen=True)
class Motif:
events: tuple[NoteEvent, ...]
name: str = "motif"
[docs]
@classmethod
def from_steps(cls, scale_steps: list[int], durations: list[float], name: str = "motif") -> "Motif":
if len(scale_steps) != len(durations):
raise ValueError("Motif scale steps and durations must have equal length")
return cls(
events=tuple(
NoteEvent(scale_step=scale_step, duration=duration)
for scale_step, duration in zip(scale_steps, durations)
),
name=name,
)
@property
def length(self) -> float:
return sum(event.duration for event in self.events)
[docs]
def transpose_diatonic(self, step_shift: int) -> "Motif":
return Motif(
events=tuple(event.transpose_diatonic(step_shift) for event in self.events),
name=self.name,
)
[docs]
@dataclass(frozen=True)
class HarmonySpan:
start_bar: int
end_bar: int
roman_symbol: str
weight: float = 1.0
start_beat: float = 0.0
end_beat: float | None = None
[docs]
def covers(self, bar_number: int, beat_in_bar: float, bar_length: float) -> bool:
start_position = (self.start_bar - 1) * bar_length + self.start_beat
end_position = (self.end_bar - 1) * bar_length + (
self.end_beat if self.end_beat is not None else bar_length
)
current_position = (bar_number - 1) * bar_length + beat_in_bar
return start_position <= current_position < end_position - 1e-9
[docs]
def overlaps_bar(self, bar_number: int) -> bool:
return self.start_bar <= bar_number <= self.end_bar
[docs]
@dataclass(frozen=True)
class HarmonyPlan:
spans: tuple[HarmonySpan, ...] = ()
[docs]
def chord_for_bar(self, bar_number: int) -> HarmonySpan | None:
for span in self.spans:
if span.overlaps_bar(bar_number):
return span
return None
[docs]
def chord_for_position(self, bar_number: int, beat_in_bar: float, bar_length: float) -> HarmonySpan | None:
for span in self.spans:
if span.covers(bar_number, beat_in_bar, bar_length):
return span
return None
[docs]
@dataclass(frozen=True)
class Melody:
key: Key
time_signature: TimeSignature
events: tuple[NoteEvent, ...]
harmony_plan: HarmonyPlan = HarmonyPlan()
clef: str | None = None
voice_profile: VoiceProfile = VoiceProfile(
name="melody",
range_min=0,
range_max=9,
tessitura_min=1,
tessitura_max=8,
)
metadata: dict[str, object] = field(default_factory=dict)
@property
def pitched_events(self) -> tuple[NoteEvent, ...]:
return tuple(event for event in self.events if not event.is_rest)
[docs]
def transpose_diatonic(self, step_shift: int) -> "Melody":
return Melody(
key=self.key,
time_signature=self.time_signature,
events=tuple(event.transpose_diatonic(step_shift) for event in self.events),
harmony_plan=self.harmony_plan,
clef=self.clef,
voice_profile=self.voice_profile,
metadata=dict(self.metadata),
)
[docs]
def transpose_parallel(self, new_key: Key) -> "Melody":
return Melody(
key=new_key,
time_signature=self.time_signature,
events=self.events,
harmony_plan=self.harmony_plan,
clef=self.clef,
voice_profile=self.voice_profile,
metadata=dict(self.metadata),
)
[docs]
@dataclass(frozen=True)
class ChoraleScore:
key: Key
time_signature: TimeSignature
soprano: Melody
alto: Melody
tenor: Melody
bass: Melody
harmony_plan: HarmonyPlan = field(default_factory=HarmonyPlan)
metadata: dict[str, object] = field(default_factory=dict)
@property
def voices(self) -> tuple[Melody, Melody, Melody, Melody]:
return (self.soprano, self.alto, self.tenor, self.bass)
[docs]
@dataclass(frozen=True)
class GenerationSettings:
key: Key
time_signature: TimeSignature
bars: int
allowed_durations: tuple[float, ...] = (0.5, 1.0, 2.0)
range_min: int = 0
range_max: int = 9
motif: Motif | None = None
motif_repetition_bar: int | None = None
motif_repetition_shift: int = 1
harmonic_plan: HarmonyPlan = HarmonyPlan()
phrase_length_bars: int = 4
cadence_duration: float = 2.0
attempts: int = 48
random_seed: int = 7
form_plan: FormPlan = FormPlan(kind="free")
clef: str | None = None
texture: str = "melody"
voice_profile: VoiceProfile = VoiceProfile(
name="melody",
range_min=0,
range_max=9,
tessitura_min=1,
tessitura_max=8,
)
chorale_plan: ChoralePlan = ChoralePlan()