Source code for neoscore.western.duration

from __future__ import annotations

from dataclasses import InitVar, dataclass, field
from fractions import Fraction
from typing import Optional, Tuple, Union

from typing_extensions import TypeAlias

from neoscore.core.math_helpers import is_power_of_2
from neoscore.western.duration_display import DurationDisplay


[docs]@dataclass(frozen=True) class Duration: """A metered non-tuplet duration. The duration fraction indicates duration as a fraction of a whole note. The actual written denomination of duration is deduced from the reduced fraction. For instance: * ``Duration(1, 4)`` indicates a quarter note value * ``Duration(1, 1)`` indicates a whole note value * ``Duration(3, 8)`` indicates a dotted quarter note value """ numerator: InitVar[int] denominator: InitVar[int] fraction: Fraction = field(init=False) """The reduced fraction representation of the duration""" display: Optional[DurationDisplay] = field(init=False, compare=False) """The appearance spec of the duration when written in notes or rests. This is ``None`` if the duration cannot be represented without ties. """ def __post_init__(self, numerator: int, denominator: int): if numerator <= 0: raise ValueError("Numerator must be positive") if not is_power_of_2(denominator): raise ValueError("Denominator must be a power of 2") super().__setattr__("fraction", Fraction(numerator, denominator)) super().__setattr__("display", Duration._derive_display(self.fraction)) @staticmethod def _derive_display(fraction: Fraction) -> Optional[DurationDisplay]: if fraction >= 2: if fraction >= 4: # Lengths >= 4 whole notes cannot be represented without a tie return None # The below algorithm doesn't work with double-breves, so # hack it by running it on fraction / 2, giving a # whole-note value, then plugging its dot count into the # result with double-breve (base_division 0) hardcoded sub_result = Duration._derive_display(fraction / 2) if sub_result is None: return None assert sub_result.base_duration == 1 return DurationDisplay(0, sub_result.dot_count) partial_numerator: float = fraction.numerator partial_denominator: int = fraction.denominator dot_count = 0 while partial_numerator > 1: partial_numerator = (partial_numerator - 1) / 2 partial_denominator = partial_denominator // 2 dot_count += 1 if partial_numerator != 1: # Failure to reduce means the duration requires a tie to write return None return DurationDisplay(partial_denominator, dot_count)
[docs] @classmethod def from_def(cls, duration_def: DurationDef) -> Duration: if isinstance(duration_def, Duration): return duration_def return Duration(*duration_def)
[docs] @classmethod def from_description(cls, base_division: int, dots: int) -> Duration: """Create a ``Duration`` from a base division and a number of dots. ``Duration``\ s created with this will always have a valid ``DurationDisplay``. Args: base_division: Must be 0 (double whole note) or a power of 2 dots: Must be >= 0 """ if base_division == 0: # double breve val = Fraction(2, 1) inc_division = 1 elif is_power_of_2(base_division): val = Fraction(1, base_division) inc_division = base_division * 2 else: raise ValueError("base_division must be 0 or a power of 2") for _ in range(dots): val += Fraction(1, inc_division) inc_division *= 2 return Duration(val.numerator, val.denominator)
@property def requires_tie(self) -> bool: """If this Duration requires a tie to be written.""" return self.display is None def __float__(self): """Reduce the fractional representation to a ``float`` and return it.""" return float(self.fraction) def __add__(self, other: Duration): """Durations are added by adding their fractions.""" if not isinstance(other, type(self)): raise TypeError fraction_sum = self.fraction + other.fraction return Duration(fraction_sum.numerator, fraction_sum.denominator) def __sub__(self, other: Duration): """Durations are subtracted by subtracting their fractions.""" if not isinstance(other, type(self)): raise TypeError fraction_diff = self.fraction - other.fraction return Duration(fraction_diff.numerator, fraction_diff.denominator) def __gt__(self, other: Duration): """Durations are compared by their fractions.""" if not isinstance(other, type(self)): return False return self.fraction > other.fraction def __ge__(self, other: Duration): """Durations are compared by their fractions.""" return self > other or self == other def __lt__(self, other: Duration): """Durations are ordered by their fractions.""" if not isinstance(other, type(self)): return False return self.fraction < other.fraction def __le__(self, other: Duration): """Durations are compared by their fractions.""" return self < other or self == other
DurationDef: TypeAlias = Union[Duration, Tuple[int, int]] """A Duration or a shorthand tuple for one."""