Source code for paeonia.score

"""Represent a score as an ordered mapping of named staves."""

from __future__ import annotations

from collections.abc import Collection, Iterable, Mapping, MutableSequence
from copy import copy
from dataclasses import replace
from fractions import Fraction
from typing import overload

from .analysis import (
    BassMotion,
    ChordCandidate,
    DensityPoint,
    DoublingSnapshot,
    HarmonicChange,
    HarmonicContext,
    IntervalSnapshot,
    PitchClassSet,
    RegisterAnalysis,
    ScaleDegreeSnapshot,
    Verticality,
    VoiceLeadingAnalysis,
)
from .annotations import AnalysisRenderOptions, ScoreAnnotation
from .bar import Bar
from .staff import Staff, VALID_CLEFS
from .tonality import Tonality, TonalityPlan
from .voice import Voice

_DEFAULT_DISSONANT_CLASSES = frozenset({1, 2, 6})


def _normalize_plan(
        plan: TonalityPlan | Mapping[int, Tonality] | None,
        *,
        label: str,
) -> TonalityPlan:
    """Normalize and validate score or transformation plan metadata."""
    if plan is None:
        normalized = TonalityPlan()
    elif isinstance(plan, TonalityPlan):
        normalized = plan
    elif isinstance(plan, Mapping):
        changes = tuple(plan.items())
        if not all(isinstance(index, int) for index, _ in changes):
            raise TypeError(f"Every {label} index must be an integer")
        normalized = TonalityPlan(changes)
    else:
        raise TypeError(f"{label} must be a TonalityPlan, mapping, or None")
    if not all(
            isinstance(index, int)
            for index, _ in normalized.changes
    ):
        raise TypeError(f"Every {label} index must be an integer")
    if not all(
            isinstance(tonality, Tonality)
            for _, tonality in normalized.changes
    ):
        raise TypeError(f"Every {label} value must be a Tonality")
    return normalized


def _copy_voice(voice: Voice) -> Voice:
    """Return a structurally independent copy of a voice."""
    return Voice(
        bars=[copy(bar) for bar in voice.bars],
        default_tonality=voice.default_tonality,
        tonality_plan=voice.tonality_plan,
        name=voice.name,
    )


def _copy_staff(staff: Staff) -> Staff:
    """Return a staff copy whose mutable voice and bars are also copied."""
    return replace(staff, voice=_copy_voice(staff.voice))


def _slice_tonal_context(
        default: Tonality | None,
        plan: TonalityPlan,
        *,
        start: int,
        stop: int,
) -> tuple[Tonality | None, TonalityPlan]:
    """Rebase a persistent tonal context onto a forward bar slice."""
    sliced_default = plan.at(start, fallback=default)
    sliced_plan = TonalityPlan(tuple(
        (index - start, tonality)
        for index, tonality in plan.changes
        if start < index < stop
    ))
    return sliced_default, sliced_plan


class _BarWindow(MutableSequence[Bar]):
    """Fixed-size mutable window onto a voice's underlying bar sequence."""

    def __init__(
            self,
            bars: MutableSequence[Bar],
            start: int,
            stop: int,
    ) -> None:
        self._bars: MutableSequence[Bar] = bars
        self._start: int = start
        self._stop: int = stop

    def __len__(self) -> int:
        start = min(self._start, len(self._bars))
        stop = min(self._stop, len(self._bars))
        return max(0, stop - start)

    def _source_index(self, index: int) -> int:
        if index < 0:
            index += len(self)
        if not 0 <= index < len(self):
            raise IndexError("bar window index out of range")
        return self._start + index

    @overload
    def __getitem__(self, index: int) -> Bar: ...

    @overload
    def __getitem__(self, index: slice) -> list[Bar]: ...

    def __getitem__(self, index: int | slice) -> Bar | list[Bar]:
        if isinstance(index, slice):
            return [
                self[position]
                for position in range(*index.indices(len(self)))
            ]
        return self._bars[self._source_index(index)]

    @overload
    def __setitem__(self, index: int, value: Bar) -> None: ...

    @overload
    def __setitem__(
            self,
            index: slice,
            value: Iterable[Bar],
    ) -> None: ...

    def __setitem__(
            self,
            index: int | slice,
            value: Bar | Iterable[Bar],
    ) -> None:
        if isinstance(index, slice):
            if isinstance(value, Bar):
                raise TypeError("Slice assignment requires an iterable of bars")
            positions = list(range(*index.indices(len(self))))
            replacements = list(value)
            if len(replacements) != len(positions):
                raise ValueError("cannot resize a fixed-size score window")
            if not all(isinstance(bar, Bar) for bar in replacements):
                raise TypeError("Every voice element must be a Bar")
            for position, replacement in zip(positions, replacements):
                self._bars[self._source_index(position)] = replacement
            return
        if not isinstance(value, Bar):
            raise TypeError("Every voice element must be a Bar")
        self._bars[self._source_index(index)] = value

    def __delitem__(self, index: int | slice) -> None:
        raise TypeError("cannot resize a fixed-size score window")

    def insert(self, index: int, value: Bar) -> None:
        raise TypeError("cannot resize a fixed-size score window")


[docs] class Score: """An ordered collection of named staves and global musical metadata. ``Score`` retains dictionary-style voice access for compatibility while storing each voice, clef, name, and MIDI assignment together in a :class:`Staff`. Score-level tonality is inherited by voices that do not provide a more local tonal context. Parameters ---------- default_tonality : Tonality | None Score-wide fallback tonality. tonality_plan : TonalityPlan | Mapping[int, Tonality] | None Score-wide tonal changes keyed by zero-based bar index. tempo : int, default=120 Tempo in beats per minute. time_signature : tuple[int, int], default=(4, 4) Positive numerator and denominator. title : str | None Optional score title. """ def __init__( self, *, default_tonality: Tonality | None = None, tonality_plan: ( TonalityPlan | Mapping[int, Tonality] | None ) = None, tempo: int = 120, time_signature: tuple[int, int] = (4, 4), title: str | None = None, ) -> None: if default_tonality is not None and not isinstance( default_tonality, Tonality, ): raise TypeError("default_tonality must be a Tonality or None") if not isinstance(time_signature, tuple) or len(time_signature) != 2: raise TypeError("time_signature must be a two-item tuple") if not all(isinstance(value, int) for value in time_signature): raise TypeError("Time-signature values must be integers") if title is not None and not isinstance(title, str): raise TypeError("title must be a string or None") self.staves: dict[str, Staff] = {} self.default_tonality = default_tonality self.tonality_plan = _normalize_plan( tonality_plan, label="tonality_plan", ) self.tempo = int(tempo) self.time_signature = time_signature self.title = title if self.tempo <= 0: raise ValueError("Tempo must be positive") numerator, denominator = self.time_signature if numerator <= 0 or denominator <= 0: raise ValueError("Time-signature values must be positive") def __setitem__(self, name: str, value: Voice | Staff) -> None: """Insert a voice or staff under ``name`` in score order.""" if not isinstance(name, str): raise TypeError("Staff name must be a string") if isinstance(value, Voice): value = Staff(voice=value, clef="treble", name=name) elif isinstance(value, Staff): if value.name is None: value.name = name else: raise TypeError("Score values must be Voice or Staff instances") self.staves[name] = value @overload def __getitem__(self, key: str) -> Voice: ... @overload def __getitem__(self, key: slice) -> "Score": ... def __getitem__(self, key: str | slice) -> Voice | Score: """Return a named voice or an aligned mutable bar window. A slice is applied to every staff after alignment validation. Score and staff metadata are preserved, while each sliced voice retains a fixed-size window onto the original bar sequence. Replacing or mutating a bar through that window therefore updates the source score. Score- and voice-level tonal plans are rebased so the effective context at the first selected bar becomes the new default. Only ordinary forward slices are supported because tonal plans are chronological. Parameters ---------- key : str | slice Staff name, or a forward slice of bar indices. Returns ------- Voice | Score The named voice, or a score window containing the selected bars. Raises ------ ValueError If staves are not aligned or the slice has a non-unit step. TypeError If ``key`` is neither a staff name nor a slice. """ if isinstance(key, str): return self.staves[key].voice if not isinstance(key, slice): raise TypeError("Score indices must be staff names or slices") if key.step not in {None, 1}: raise ValueError("Score slicing only supports a step of 1") self.validate_alignment() bar_count = ( len(next(iter(self.staves.values())).voice) if self.staves else 0 ) start, stop, _ = key.indices(bar_count) score_default, score_plan = _slice_tonal_context( self.default_tonality, self.tonality_plan, start=start, stop=stop, ) result = Score( default_tonality=score_default, tonality_plan=score_plan, tempo=self.tempo, time_signature=self.time_signature, title=self.title, ) for name, staff in self.staves.items(): voice = staff.voice voice_default, voice_plan = _slice_tonal_context( voice.default_tonality, voice.tonality_plan, start=start, stop=stop, ) sliced_voice = Voice( default_tonality=voice_default, tonality_plan=voice_plan, name=voice.name, ) sliced_voice.bars = _BarWindow( voice.bars, start, stop, ) result.staves[name] = replace(staff, voice=sliced_voice) return result @property def voices(self) -> dict[str, Voice]: """Return a compatibility view mapping staff names to voices.""" return { name: staff.voice for name, staff in self.staves.items() } @property def clefs(self) -> dict[str, str]: """Return a compatibility view mapping staff names to clefs.""" return { name: staff.clef for name, staff in self.staves.items() }
[docs] def set_clef(self, voice: str, clef: str) -> None: """Set the clef of a named staff. Parameters ---------- voice : str Name used to insert the staff into this score. clef : str One of ``"treble"``, ``"alto"``, ``"tenor"``, or ``"bass"``. """ if clef not in VALID_CLEFS: raise ValueError(f"Invalid clef: {clef}") self.staves[voice].clef = clef
[docs] def tonality_at( self, staff_name: str, bar_index: int, ) -> Tonality | None: """Resolve a staff's effective tonality at a bar index.""" staff = self.staves[staff_name] return staff.voice.tonality_at( bar_index, inherited=self.default_tonality, inherited_plan=self.tonality_plan, )
[docs] def copy_structure_without_staves(self) -> "Score": """Copy score-level metadata into a new score with no staves.""" return Score( default_tonality=self.default_tonality, tonality_plan=self.tonality_plan, tempo=self.tempo, time_signature=self.time_signature, title=self.title, )
[docs] def copy(self) -> "Score": """Return a structurally independent score copy.""" result = self.copy_structure_without_staves() result.staves = { name: _copy_staff(staff) for name, staff in self.staves.items() } return result
def _selected_staff_names( self, voices: Collection[str] | None, ) -> set[str]: if voices is None: selected = set(self.staves) elif isinstance(voices, str): selected = {voices} else: selected = set(voices) if not all(isinstance(name, str) for name in selected): raise TypeError("Voice names must be strings") unknown = selected - set(self.staves) if unknown: raise KeyError(f"Unknown voices: {sorted(unknown)}") return selected @staticmethod def _validate_transform_options( target: Tonality | None, source: Tonality | None, chromatic: str, ) -> None: if target is not None and not isinstance(target, Tonality): raise TypeError("target must be a Tonality") if source is not None and not isinstance(source, Tonality): raise TypeError("source must be a Tonality or None") if chromatic not in { "preserve_alteration", "error", "nearest", }: raise ValueError(f"Unknown chromatic policy: {chromatic}")
[docs] def apply_tonality( self, target: Tonality, source: Tonality | None = None, *, voices: Collection[str] | None = None, chromatic: str = "preserve_alteration", ) -> "Score": """Reinterpret selected staves in one target tonality. Source tonality is resolved independently for each original bar. When all staves are selected, the target becomes score-level metadata. For a selective transformation, it becomes local metadata on each changed voice. Staff presentation and MIDI metadata are preserved. Parameters ---------- target : Tonality Tonality in which selected pitches are realized. source : Tonality | None Optional source forced across every selected bar. voices : Collection[str] | None Staff names to transform, or ``None`` for every staff. chromatic : str Chromatic analysis policy passed to ``Bar.apply_tonality``. Returns ------- Score A transformed score; the original is unchanged. """ self._validate_transform_options(target, source, chromatic) selected = self._selected_staff_names(voices) self.validate_alignment() result = self.copy_structure_without_staves() transform_all = selected == set(self.staves) if transform_all: result.default_tonality = target result.tonality_plan = TonalityPlan() for name, staff in self.staves.items(): if name not in selected: result.staves[name] = _copy_staff(staff) continue transformed_voice = staff.voice.apply_tonality( target, source=source, inherited=self.default_tonality, inherited_plan=self.tonality_plan, chromatic=chromatic, ) if transform_all: transformed_voice.default_tonality = None else: transformed_voice.default_tonality = target transformed_voice.tonality_plan = TonalityPlan() result.staves[name] = replace( staff, voice=transformed_voice, ) return result
[docs] def apply_tonality_plan( self, target_plan: TonalityPlan | Mapping[int, Tonality], source: Tonality | None = None, *, voices: Collection[str] | None = None, chromatic: str = "preserve_alteration", ) -> "Score": """Apply a synchronized target-tonality plan to selected staves. The target plan must define bar zero. Each selected voice must contain every change index. All-staff transformations store target metadata on the score; selective transformations store equivalent metadata on the transformed voices so unselected voices retain their original score context. Parameters ---------- target_plan : TonalityPlan | Mapping[int, Tonality] Target tonalities keyed by zero-based bar index, including zero. source : Tonality | None Optional source forced across every selected bar. voices : Collection[str] | None Staff names to transform, or ``None`` for every staff. chromatic : str Chromatic analysis policy passed to ``Bar.apply_tonality``. Returns ------- Score A transformed score; the original is unchanged. """ self._validate_transform_options(None, source, chromatic) normalized_target = _normalize_plan( target_plan, label="target_plan", ) target_default = normalized_target.at(0) if target_default is None: raise ValueError("target_plan must define a tonality at bar 0") selected = self._selected_staff_names(voices) self.validate_alignment() for name in selected: bar_count = len(self.staves[name].voice) for bar_index, _ in normalized_target.changes: if bar_index >= bar_count: raise ValueError( f"Voice {name!r} has no bar at plan index " f"{bar_index}" ) resolved_sources: dict[str, tuple[Tonality, ...]] = {} for name in selected: voice = self.staves[name].voice sources = [] for bar_index in range(len(voice)): resolved = ( source if source is not None else self.tonality_at(name, bar_index) ) if resolved is None: raise ValueError( "No source tonality is available for " f"voice {name!r}, bar {bar_index}" ) sources.append(resolved) resolved_sources[name] = tuple(sources) later_changes = TonalityPlan(tuple( (index, tonality) for index, tonality in normalized_target.changes if index > 0 )) result = self.copy_structure_without_staves() transform_all = selected == set(self.staves) if transform_all: result.default_tonality = target_default result.tonality_plan = later_changes for name, staff in self.staves.items(): if name not in selected: result.staves[name] = _copy_staff(staff) continue transformed_bars = [] for bar_index, bar in enumerate(staff.voice.bars): target = normalized_target.at(bar_index) if target is None: # Guarded by the required change at zero. raise RuntimeError("Target tonality resolution failed") transformed_bars.append( bar.apply_tonality( target, source=resolved_sources[name][bar_index], chromatic=chromatic, ).with_tonality(None) ) transformed_voice = Voice( bars=transformed_bars, default_tonality=( None if transform_all else target_default ), tonality_plan=( TonalityPlan() if transform_all else later_changes ), name=staff.voice.name, ) result.staves[name] = replace( staff, voice=transformed_voice, ) return result
[docs] def verticalities( self, bar_index: int, *, staves: Collection[str] | None = None, ) -> tuple[Verticality, ...]: """Return exact time frames containing all pitches sounding in a bar. Boundaries are derived from every event onset and ending across the selected aligned staves. Rests contribute time but not pitch, sustained notes remain active, and staff insertion order is preserved. """ from .analysis import verticalities return verticalities(self, bar_index, staves=staves)
[docs] def sonority_at( self, bar_index: int, offset: int | Fraction, *, staves: Collection[str] | None = None, ) -> Verticality: """Return the vertical sonority sounding at a bar-relative offset.""" from .analysis import sonority_at return sonority_at(self, bar_index, offset, staves=staves)
[docs] def pitch_class_set( self, bar_index: int, *, offset: int | Fraction | None = None, staves: Collection[str] | None = None, ) -> PitchClassSet: """Return both written and MIDI pitch-class sets. With no ``offset``, the result is the union of the complete bar. Pass an offset for the set sounding at one exact point. """ from .analysis import score_pitch_class_set return score_pitch_class_set( self, bar_index, offset=offset, staves=staves, )
[docs] def interval_class_vector( self, bar_index: int, *, offset: int | Fraction | None = None, staves: Collection[str] | None = None, ) -> tuple[int, int, int, int, int, int]: """Return the interval-class vector of a pointwise or whole-bar set.""" from .analysis import interval_class_vector return interval_class_vector( self.pitch_class_set( bar_index, offset=offset, staves=staves, ).midi )
[docs] def prime_form( self, bar_index: int, *, offset: int | Fraction | None = None, staves: Collection[str] | None = None, ) -> tuple[int, ...]: """Return a transposition/inversion-normalized pitch-class form.""" from .analysis import prime_form return prime_form( self.pitch_class_set( bar_index, offset=offset, staves=staves, ).midi )
[docs] def scale_degrees( self, bar_index: int, *, tonality: Tonality | None = None, staves: Collection[str] | None = None, ) -> tuple[ScaleDegreeSnapshot, ...]: """Analyze every pitch as a time-aware scale position. When ``tonality`` is omitted, all selected staves must resolve to one effective tonality at this bar. """ from .analysis import score_scale_degrees return score_scale_degrees( self, bar_index, tonality=tonality, staves=staves, )
[docs] def chord_candidates( self, bar_index: int, *, offset: int | Fraction = 0, tonality: Tonality | None = None, staves: Collection[str] | None = None, limit: int = 5, ) -> tuple[ChordCandidate, ...]: """Rank common chord interpretations at an exact offset.""" from .analysis import score_chord_candidates return score_chord_candidates( self, bar_index, offset=offset, tonality=tonality, staves=staves, limit=limit, )
[docs] def roman_numerals( self, bar_index: int, *, tonality: Tonality | None = None, staves: Collection[str] | None = None, ) -> tuple[HarmonicChange, ...]: """Return contiguous Roman-numeral assignments within a bar.""" from .analysis import score_roman_numerals return score_roman_numerals( self, bar_index, tonality=tonality, staves=staves, )
[docs] def harmonic_context( self, bar_index: int, *, tonality: Tonality | None = None, staves: Collection[str] | None = None, ) -> HarmonicContext: """Return duration-weighted harmony, changes, and non-chord tones.""" from .analysis import harmonic_context return harmonic_context( self, bar_index, tonality=tonality, staves=staves, )
[docs] def harmonic_rhythm( self, bar_index: int, *, tonality: Tonality | None = None, staves: Collection[str] | None = None, ) -> tuple[HarmonicChange, ...]: """Return each contiguous chord assignment and exact duration.""" from .analysis import score_harmonic_rhythm return score_harmonic_rhythm( self, bar_index, tonality=tonality, staves=staves, )
[docs] def interval_matrix( self, bar_index: int, *, staves: Collection[str] | None = None, dissonant_classes: Collection[int] = _DEFAULT_DISSONANT_CLASSES, ) -> tuple[IntervalSnapshot, ...]: """Return all cross-staff pitch intervals at every vertical change.""" from .analysis import score_interval_matrix return score_interval_matrix( self, bar_index, staves=staves, dissonant_classes=dissonant_classes, )
[docs] def dissonances( self, bar_index: int, *, staves: Collection[str] | None = None, dissonant_classes: Collection[int] = _DEFAULT_DISSONANT_CLASSES, ) -> tuple[IntervalSnapshot, ...]: """Return cross-staff intervals in configurable dissonant classes.""" from .analysis import score_dissonances return score_dissonances( self, bar_index, staves=staves, dissonant_classes=dissonant_classes, )
[docs] def doublings( self, bar_index: int, *, staves: Collection[str] | None = None, ) -> tuple[DoublingSnapshot, ...]: """Return duplicated MIDI pitch classes at each vertical change.""" from .analysis import score_doublings return score_doublings(self, bar_index, staves=staves)
[docs] def density( self, bar_index: int, *, staves: Collection[str] | None = None, ) -> tuple[DensityPoint, ...]: """Return pitch and active-staff density throughout a bar.""" from .analysis import score_density return score_density(self, bar_index, staves=staves)
[docs] def register_analysis( self, bar_index: int, *, staves: Collection[str] | None = None, ) -> RegisterAnalysis: """Return bass, soprano, ambitus, spacing, crossings, and overlaps.""" from .analysis import score_register_analysis return score_register_analysis(self, bar_index, staves=staves)
[docs] def bass_motion( self, bar_index: int, *, staves: Collection[str] | None = None, ) -> tuple[BassMotion, ...]: """Return motion between successive sounding bass pitches.""" from .analysis import score_bass_motion return score_bass_motion(self, bar_index, staves=staves)
[docs] def voice_leading_to( self, bar_index: int, next_bar_index: int | None = None, *, staves: Collection[str] | None = None, leap_threshold: int = 5, ) -> VoiceLeadingAnalysis: """Analyze voice leading from a bar to another bar. ``next_bar_index`` defaults to the following bar. Pitches are matched by minimum motion within each staff; results include common tones, total motion, contrary motion, parallel perfects, and leaps. """ from .analysis import voice_leading_to target = bar_index + 1 if next_bar_index is None else next_bar_index return voice_leading_to( self, bar_index, target, staves=staves, leap_threshold=leap_threshold, )
[docs] def validate_alignment(self) -> None: """Require all voices to have identical bar counts and spans. Raises ------ ValueError If a voice has a different bar count or if corresponding bars have different total durations. Error messages identify the voice and, for duration mismatches, the bar index. """ if not self.staves: return reference_name, reference_staff = next(iter(self.staves.items())) reference_spans = reference_staff.voice.bar_spans() for name, staff in self.staves.items(): spans = staff.voice.bar_spans() if len(spans) != len(reference_spans): raise ValueError( f"Voice {name!r} has {len(spans)} bars; " f"{reference_name!r} has {len(reference_spans)}" ) for index, (actual, expected) in enumerate( zip(spans, reference_spans), ): if actual != expected: raise ValueError( f"Bar {index} span mismatch: {name!r}={actual}, " f"{reference_name!r}={expected}" )
[docs] def to_midi( self, path, tpb: int = 480, *, allow_unaligned: bool = False, ): """Delegate MIDI-file export to the score renderer.""" from .midi import score_to_midi_file return score_to_midi_file( self, path=path, tpb=tpb, allow_unaligned=allow_unaligned, )
[docs] def analysis_annotations( self, options: AnalysisRenderOptions | None = None, ) -> tuple[ScoreAnnotation, ...]: """Compute fresh renderer-independent annotations for this score. The analysis is never cached on the score, so editing a voice or a mutable score window cannot leave stale labels behind. With no options, the readable composition-oriented preset is used. """ from .annotations import score_annotations return score_annotations(self, options)
[docs] def to_lilypond( self, *, bar_numbers: bool = True, analysis: bool | AnalysisRenderOptions = False, ) -> str: """Render the score with optional bar numbers and analysis labels.""" from .lilypond import score_to_lilypond return score_to_lilypond( self, bar_numbers=bar_numbers, analysis=analysis, )
[docs] def show( self, *, bar_numbers: bool = True, analysis: bool | AnalysisRenderOptions = False, ) -> "Score": """Display the score with optional analysis, then return it.""" from .playback import show_score show_score( self, bar_numbers=bar_numbers, analysis=analysis, ) return self
[docs] def play( self, tpb: int = 480, autoplay: bool = False, *, allow_unaligned: bool = False, ) -> "Score": """Render and play this score, then return it.""" from .playback import play_score play_score( self, tpb=tpb, autoplay=autoplay, allow_unaligned=allow_unaligned, ) return self