Source code for neoscore.western.chordrest

from typing import List, NamedTuple, Optional, Union

from backports.cached_property import cached_property

from neoscore.core.directions import DirectionX, DirectionY
from neoscore.core.point import ORIGIN, Point
from neoscore.core.positioned_object import PositionedObject
from neoscore.core.rect import Rect
from neoscore.core.units import ZERO, Unit
from neoscore.western import notehead_tables
from neoscore.western.accidental import Accidental
from neoscore.western.duration import Duration, DurationDef
from neoscore.western.flag import Flag
from neoscore.western.ledger_line import LedgerLine
from neoscore.western.notehead import Notehead
from neoscore.western.notehead_tables import NoteheadTable
from neoscore.western.pitch import PitchDef
from neoscore.western.rest import Rest
from neoscore.western.rhythm_dot import RhythmDot
from neoscore.western.staff import Staff
from neoscore.western.staff_object import StaffObject
from neoscore.western.stem import Stem


[docs]class PitchAndGlyph(NamedTuple): """Used to define individual notes with one-off SMuFL glyphs.""" pitch: PitchDef """The pitch for the notehead""" notehead_glyph: str """The SMuFL glyph name for the notehead"""
[docs]class Chordrest(PositionedObject, StaffObject): """A chord or a rest. This is a unified interface for conventionally notated musical notes/chords/rests. It can be given any number of pitches to be used as notes in the chord, or ``None`` for a rest. It will automatically generate and lay out: * :obj:`.Notehead`\ s if pitches are given * a :obj:`.Stem` if pitches are given and required by the given :obj:`.Duration` * a :obj:`.Flag` if pitches are given and required by the given ``Duration`` * :obj:`.LedgerLine`\ s as needed (taking into consideration the given pitches and their location on the :obj:`.Staff`) * :obj:`.Accidental`\ s as needed by any given pitches * a :obj:`.Rest` if no pitches are given * :obj:`.RhythmDot`\ s if needed by the given ``Duration`` Any accidentals given in pitches will be unconditionally drawn regardless of context and key signature. """ # All cached properties which change on rebuilds *must* be registered here. _CACHED_PROPS = [ "ledger_line_positions", "rhythm_dot_positions", "furthest_notehead", "highest_notehead", "lowest_notehead", "leftmost_notehead", "rightmost_notehead", "widest_notehead", "extra_attachment_point", "tremolo_attachment_point", "notehead_column_bounding_rect", "noteheads_outside_staff", "leftmost_notehead_outside_staff", "rightmost_notehead_outside_staff", "notehead_column_outside_staff_width", "stem_height", ]
[docs] def __init__( self, pos_x: Unit, staff: Staff, notes: Optional[List[Union[PitchDef, PitchAndGlyph]]], duration: DurationDef, rest_y: Optional[Unit] = None, stem_direction: Optional[DirectionY] = None, beam_break_depth: Optional[int] = None, beam_hook_dir: Optional[DirectionX] = None, table: NoteheadTable = notehead_tables.STANDARD, ): """ Args: pos_x: The horizontal position in the staff staff: The staff the object is attached to notes: A list of pitches and optional notehead-specific data. If ``None`` this indicates a rest. For simple notes and chords, this can typically be a list of pitch string shorthands (see :obj:`.Pitch.from_str`). Pitches with extended accidentals can be given by passing fully constructed ``Pitch`` objects. Individual notehead glyphs (by default taken from the given ``table``) can be overridden by passing a tuple of a pitch and a SMuFL glyph name string. duration: The written duration for the object. rest_y: The vertical position used by rests. This defaults to the center of the staff. stem_direction: An optional stem direction override. If omitted, the direction is automatically calculated to point away from the furthest-out notehead. beam_break_depth: Break depth used if in a :obj:`.BeamGroup`. beam_hook_dir: Beamlet hook direction used in a ``BeamGroup``. table: The set of noteheads to use according to ``duration``. """ StaffObject.__init__(self, staff) PositionedObject.__init__(self, Point(pos_x, ZERO), staff) self.duration = duration self._noteheads = [] self._accidentals = [] self._ledgers = [] self._dots = [] self._notes = [] if notes is None else notes self._stem = None self._flag = None self._rest_y = rest_y self._rest = None self._stem_direction_override = stem_direction self._beam_break_depth = beam_break_depth self._beam_hook_dir = beam_hook_dir self._table = table self._rebuild()
@property def notes(self) -> List[Union[PitchDef, PitchAndGlyph]]: return self._notes @notes.setter def notes(self, value: Optional[List[Union[PitchDef, PitchAndGlyph]]]): self._notes = [] if value is None else value self._rebuild() @property def noteheads(self) -> List[Notehead]: """The noteheads contained in this Chordrest.""" return self._noteheads @property def rest_y(self) -> Optional[Unit]: """The vertical position used by generated rests. Defaults to the staff center. """ return self._rest_y @rest_y.setter def rest_y(self, value: Optional[Unit]): self._rest_y = value self._rebuild() @property def rest(self) -> Optional[Rest]: """A rest glyph, if no noteheads exist.""" return self._rest @property def accidentals(self) -> List[Accidental]: """The accidentals contained in this Chordrest.""" return self._accidentals @property def ledgers(self) -> List[LedgerLine]: """The ledger lines contained in this Chordrest. An empty list means none are needed. """ return self._ledgers @property def dots(self) -> List[RhythmDot]: return self._dots @property def stem(self) -> Optional[Stem]: """The stem for the Chordrest.""" return self._stem @property def flag(self) -> Optional[Flag]: """The flag attached to the stem.""" return self._flag @property def beam_break_depth(self) -> Optional[int]: """Break depth used if in a :obj:`.BeamGroup`. If this Chordrest is within a beam group, this triggers a beam subdivision break at this point. The value indicates the number of beams to which the subdivision breaks. For example, in run of 16th notes a ``beam_break_depth`` of ``1`` would indicate a subdivision break to 1 beam at this point. """ return self._beam_break_depth @property def beam_hook_dir(self) -> Optional[DirectionX]: """Beamlet hook direction used in a :obj:`.BeamGroup`. If this Chordrest is within a beam group and this position is one requiring a beamlet hook whose direction is ambiguous, this controls that direction. """ return self._beam_hook_dir @property def table(self) -> NoteheadTable: return self._table @table.setter def table(self, table: NoteheadTable): self._table = table self._rebuild() @property def duration(self) -> Duration: """The written length of this event. This affects many components of the chordrest. """ return self._duration @duration.setter def duration(self, value: DurationDef): rebuild_needed = hasattr(self, "_duration") value = Duration.from_def(value) if value.display is None: raise ValueError(f"{value} cannot be represented as a single note") self._duration = value if rebuild_needed: self._rebuild() @cached_property def ledger_line_positions(self) -> List[Unit]: """A set of staff positions of needed ledger lines. Positions are in centered staff positions. An empty list means no ledger lines are needed. """ highest = self.highest_notehead.y lowest = self.lowest_notehead.y # This could be optimized by only doing both ledger lookups if # ``highest`` and ``lowest`` are on opposite sides of the staff, # in which case the check-then-append step can be done # unconditionally. ledgers = self.staff.ledgers_needed_for_y(lowest) for ledger_pos in self.staff.ledgers_needed_for_y(highest): if ledger_pos not in ledgers: ledgers.append(ledger_pos) return ledgers @cached_property def rhythm_dot_positions(self) -> List[Point]: """The positions of all rhythm dots needed.""" start_padding = self.staff.unit(0.25) if self.rest: dot_start_x = self.rest.x + self.rest.bounding_rect.width + start_padding dottables = {self.rest} else: dot_start_x = ( self.leftmost_notehead.x + self.notehead_column_bounding_rect.width + start_padding ) dottables = self.noteheads result = [] for i in range(self.duration.display.dot_count): for dottable in dottables: if dottable.y.display_value % 1 == 0: # Dottable is on a line, add dot offset to space below y_offset = self.staff.unit(-0.5) else: y_offset = self.staff.unit(0) result.append( Point( dot_start_x + (self.staff.unit(0.5) * i), dottable.y + y_offset ) ) return result @cached_property def furthest_notehead(self) -> Optional[Notehead]: """The notehead furthest from the staff center""" return max( self.noteheads, key=lambda n: abs(n.y - self.staff.center_y), default=None, ) @cached_property def highest_notehead(self) -> Optional[Notehead]: """The highest notehead in the chord.""" return min(self.noteheads, key=lambda n: n.y, default=None) @cached_property def lowest_notehead(self) -> Optional[Notehead]: """The lowest notehead in the chord.""" return max(self.noteheads, key=lambda n: n.y, default=None) @cached_property def leftmost_notehead(self) -> Optional[Notehead]: """The notehead furthest to the left in the chord""" return min(self.noteheads, key=lambda n: n.x, default=None) @cached_property def rightmost_notehead(self) -> Optional[Notehead]: """The notehead furthest to the right in the chord""" return max(self.noteheads, key=lambda n: n.x, default=None) @cached_property def extra_attachment_point(self) -> Point: """A point where common attachments like ornaments could go. For chords, this is a point centered above or below the outermost notehead opposite of the stem direction. For rests, this is a point centered above the rest. The returned point is relative to the Chordrest. """ if self.rest: bounding_rect = self.rest.bounding_rect x = self.rest.x + bounding_rect.x + (bounding_rect.width / 2) y = self.rest.y + bounding_rect.y - self.staff.unit(1) return Point(x, y) if self.stem_direction == DirectionY.UP: notehead = self.lowest_notehead bounding_rect = notehead.bounding_rect y = notehead.y + bounding_rect.y + bounding_rect.height + self.staff.unit(1) else: notehead = self.highest_notehead bounding_rect = notehead.bounding_rect y = notehead.y + bounding_rect.y - self.staff.unit(1) x = notehead.x + bounding_rect.x + (bounding_rect.width / 2) return Point(x, y) @cached_property def tremolo_attachment_point(self) -> Point: """A convenient reasonable point for tremolos to be placed. The returned point is relative to the chordrest. The precise position returned is not currently guaranteed, as there are known shortcomings that still need to be addressed, particularly with short stems and stems with flags attached. For rests, this simply returns ``ORIGIN``. """ if self.rest: # Since there's no use-case we know of for rest tremolos, there's no # reasonable implementation of this, so just return 0, 0. return ORIGIN x = ZERO noteheads_rect = self.notehead_column_bounding_rect if not self.duration.display.requires_stem: x = noteheads_rect.x + (noteheads_rect.width / 2) if self.stem_direction == DirectionY.DOWN: y = noteheads_rect.y + noteheads_rect.height + self.staff.unit(1.5) else: y = noteheads_rect.y - self.staff.unit(1) return Point(x, y) @cached_property def notehead_column_bounding_rect(self) -> Rect: """The bounding rect of the notehead column after layout.""" if self.rest: return Rect(ZERO, ZERO, ZERO, ZERO) left_x = Unit(float("inf")) top_y = Unit(float("inf")) right_x = Unit(float("-inf")) bottom_y = Unit(float("-inf")) for n in self.noteheads: n_rect = n.bounding_rect.offset(n.pos) if left_x > n_rect.x: left_x = n_rect.x if top_y > n_rect.y: top_y = n_rect.y n_rect_right_x = n_rect.x + n_rect.width if right_x < n_rect_right_x: right_x = n_rect_right_x n_rect_bottom_y = n_rect.y + n_rect.height if bottom_y < n_rect_bottom_y: bottom_y = n_rect_bottom_y return Rect(left_x, top_y, right_x - left_x, bottom_y - top_y) @cached_property def noteheads_outside_staff(self) -> List[Notehead]: """All noteheads which are above or below the staff""" return [ note for note in self.noteheads # Since ``note.parent == self`` and ``self.y == 0`` if not self.staff.y_inside_staff(note.y) ] @cached_property def leftmost_notehead_outside_staff(self) -> Optional[Notehead]: """The notehead furthest to the left outside the staff""" return min(self.noteheads_outside_staff, key=lambda n: n.x, default=None) @cached_property def rightmost_notehead_outside_staff(self) -> Optional[Notehead]: """The notehead furthest to the right outside the staff""" return max(self.noteheads_outside_staff, key=lambda n: n.x, default=None) @cached_property def notehead_column_outside_staff_width(self) -> Unit: """The total width of any noteheads outside the staff""" left_bounding_note = self.leftmost_notehead_outside_staff if not left_bounding_note: return ZERO else: extent = max((n.x + n.visual_width for n in self.noteheads_outside_staff)) return extent - left_bounding_note.x # Can't use cached_property here because a setter is needed. Could get around this # by storing this computation in an attr like self._stem_direction though. @property def stem_direction(self) -> DirectionY: """The direction of the stem Takes the notehead furthest from the center of the staff, and returns the opposite direction. If the furthest notehead is in the center of the staff, the direction defaults to ``DirectionY.DOWN``, unless the staff has only one line, in which case it defaults to ``DirectionY.UP`` as a convenience for percussion staves. This automatically calculated property may be overridden using its setter. To revert to the automatically calculated value set this property to ``None``. If there are no noteheads (meaning this Chordrest is a rest), this arbitrarily returns ``DirectionY.UP``. """ if self._stem_direction_override: return self._stem_direction_override furthest = self.furthest_notehead if furthest is None: return DirectionY.UP if furthest.y < self.staff.center_y: return DirectionY.DOWN elif furthest.y == self.staff.center_y: if self.staff.line_count == 1: return DirectionY.UP else: return DirectionY.DOWN else: return DirectionY.UP @stem_direction.setter def stem_direction(self, value: Optional[DirectionY]): self._stem_direction_override = value self._rebuild() @cached_property def stem_height(self) -> Unit: """The height of the stem""" flag_offset = self.staff.unit(Flag.vertical_offset_needed(self.duration)) min_abs_height = self.staff.unit(2.5) + flag_offset fitted_abs_height = ( abs(self.lowest_notehead.y - self.highest_notehead.y) + self.staff.unit(2.5) + flag_offset ) abs_height = max(min_abs_height, fitted_abs_height) return abs_height def _clear(self): for notehead in self.noteheads: notehead.remove() self._noteheads = [] for accidental in self.accidentals: accidental.remove() self._accidentals = [] for ledger in self.ledgers: ledger.remove() self._ledgers = [] for dot in self.dots: dot.remove() self._dots = [] if self.stem: self.stem.remove() self._stem = None if self.flag: self.flag.remove() self._flag = None if self.rest: self.rest.remove() self._rest = None for prop in Chordrest._CACHED_PROPS: self.__dict__.pop(prop, None) def _rebuild(self): """Generate or regenerate all child objects""" if self.noteheads or self.rest: # Clear existing glyphs self._clear() if self.notes: for note in self.notes: if isinstance(note, tuple) and len(note) == 2: pitch = note[0] glyph_override = note[1] else: pitch = note glyph_override = None self._noteheads.append( Notehead( ZERO, self, pitch, self.duration, table=self.table, glyph_override=glyph_override, ) ) self._rest = None self._create_stem() if self.stem: self._position_noteheads_around_stem() self._create_accidentals() self._position_accidentals_horizontally() self._create_ledgers() self._create_flag() else: rest_y = self.rest_y or self.staff.center_y self._rest = Rest(Point(self.staff.unit(0), rest_y), self, self.duration) # Both rests and chords needs dots self._create_dots() def _create_ledgers(self): """Create all required ledger lines and store them in ``self.ledgers`` This should be called after _position_noteheads_horizontally() as it relies on the position of noteheads in the chord to calculate the position and length of the ledger lines """ pos_x = self.leftmost_notehead.x length = self.notehead_column_outside_staff_width for staff_pos in self.ledger_line_positions: self.ledgers.append(LedgerLine(Point(pos_x, staff_pos), self, length)) def _create_accidentals(self): for notehead in self.noteheads: if notehead.pitch.accidental is None: continue accidental = Accidental( # X value is a placeholder to be resolved # in _position_accidentals_horizontally (ZERO, notehead.y), self, notehead.pitch.accidental, ) self.accidentals.append(accidental) def _create_dots(self): """Create all the RhythmDots needed by this Chordrest.""" for dot_pos in self.rhythm_dot_positions: self.dots.append(RhythmDot(dot_pos, self)) def _create_stem(self): """If needed, create a Stem and store it in ``self.stem``.""" if not self.duration.display.requires_stem: return if self.stem_direction == DirectionY.UP: attached_notehead = self.lowest_notehead anchor_key = "stemUpSE" else: attached_notehead = self.highest_notehead anchor_key = "stemDownNW" resolved_anchor_y = ZERO # Special case guard needed for invisible noteheads without music chars if attached_notehead.text: anchors = attached_notehead.music_chars[0].glyph_info.anchors if anchors: resolved_anchor = anchors.get(anchor_key) if resolved_anchor: resolved_anchor_y = resolved_anchor.y self._stem = Stem( Point(ZERO, attached_notehead.y + resolved_anchor_y), self, self.stem_direction, self.stem_height, ) def _create_flag(self): """Create a Flag attached to ``self.stem`` and store it in ``self.flag``""" if self.duration.display.flag_count: self._flag = Flag( (self.stem.pen.thickness / -2, ZERO), self.stem.end_point, self.duration, self.stem.direction, ) def _position_noteheads_around_stem(self): """Reposition noteheads so that they are laid out correctly around the stem. This should only be run if a stem exists. """ # Find the preferred side of the stem for noteheads, default_side = ( DirectionX.LEFT if self.stem_direction == DirectionY.UP else DirectionX.RIGHT ) # Start last staff pos at sentinel infinity position. # Rather than working with staff positions, we can work with # ``Notehead.y`` values directly because we know they all share # ``self`` as a parent. prev_y = Unit(float("inf")) # Start prev_side at wrong side so first note goes on the default side prev_side = default_side.flip() for note in sorted( self.noteheads, key=lambda n: n.y, reverse=(self.stem_direction == DirectionY.UP), ): if abs(prev_y - note.y) < self.staff.unit(1): # This note collides with previous, switch sides prev_side = prev_side.flip() else: prev_side = default_side note.x = self._resolve_notehead_x_pos(note, prev_side) prev_y = note.y def _resolve_notehead_x_pos( self, notehead: Notehead, stem_side: DirectionX ) -> Unit: if not notehead.text: return ZERO anchors = notehead.music_chars[0].glyph_info.anchors if not anchors: if stem_side == DirectionX.LEFT: return -notehead.visual_width return ZERO stem_offset = self.stem.pen.thickness / 2 if stem_side == DirectionX.LEFT: anchor_key = "stemUpSE" stem_offset *= -1 else: anchor_key = "stemDownNW" return -(anchors[anchor_key].x + stem_offset) def _position_accidentals_horizontally(self): """Reposition accidentals so that they are laid out correctly This implementation is very basic and fails in some fairly common use-cases. A proper solution would likely involve totally redoing this. See https://github.com/DigiScore/neoscore/issues/32 """ # Rather than working with staff positions, we can work with # ``Accidental.y`` values directly because we know they all share # ``self`` as a parent. padding = self.staff.music_font.engraving_defaults["staffLineThickness"] notehead_col_edge_x = self.leftmost_notehead.x prev_side = None prev_acc = None prev_rect = None for acc in sorted(self.accidentals, key=lambda a: a.y): acc_rect = acc.bounding_rect shift_for_collision = False if ( prev_acc and prev_side == DirectionX.RIGHT and (prev_acc.y + prev_rect.y + prev_rect.height > acc.y + acc_rect.y) ): # This accidental collides with previous, switch sides shift_for_collision = True prev_side = DirectionX.LEFT else: prev_side = DirectionX.RIGHT if shift_for_collision: acc.x = prev_acc.x - acc.bounding_rect.width else: acc.x = notehead_col_edge_x - acc.bounding_rect.width - padding prev_acc = acc prev_rect = acc_rect