"""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 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