Source code for neoscore.western.beam_group

from typing import List, NamedTuple, Optional, Union, cast

from neoscore.core.brush import Brush, BrushDef
from neoscore.core.directions import DirectionX, DirectionY
from neoscore.core.has_music_font import HasMusicFont
from neoscore.core.music_font import MusicFont
from neoscore.core.pen import Pen, PenDef
from neoscore.core.point import ORIGIN, Point
from neoscore.core.positioned_object import PositionedObject
from neoscore.core.units import ZERO, Unit
from neoscore.western.beam import Beam
from neoscore.western.chordrest import Chordrest


class _BeamState(NamedTuple):
    """The state of a beam group at a ``Chordrest`` position"""

    flag_count: int
    break_depth: Optional[int] = None
    """Indicates a subgroup break after this position.

    This indicates the number of beams to cut a subdivision to after this beam position.
    The value must be less than ``flag_count`` and greater than 0.

    For example, this encodes four 32nd notes subdivided into 2 groups of 2 connected by
    a single beam: ``[_BeamState(3), _BeamState(3, break_depth=1), _BeamState(3), _BeamState(3)]``

    Because the beam resolver is not meter-aware, it does not perform subdivision breaks
    unless explicitly requested with this field.
    """

    hook: Optional[DirectionX] = None
    """Direction for beamlet hooks.

    If provided as an override, this only has an effect if the beam
    position requires a hook and that hook direction is
    ambiguous. This only applies in places where, within a beam
    subgroup, a position has more flags than its previous and
    following position, *and* those adjacent values are equal. For
    example, a 16th note surrounded by two 8th notes.
    """


def _resolve_beam_hooks(specs: List[_BeamState]) -> List[_BeamState]:
    """Determine which states need hooks, accounting for overrides where sensible."""
    if len(specs) < 2:
        raise ValueError("Beam groups must have at least 2 members")
    states: List[_BeamState] = []
    for i, (current_count, break_depth, current_hint_hook) in enumerate(specs):
        prev_count, prev_hint_break_depth, _ = (
            specs[i - 1] if i > 0 else (None, None, None)
        )
        next_count, next_hint_break_depth, _ = (
            specs[i + 1] if i < len(specs) - 1 else (None, None, None)
        )
        # Resolve hook
        hook = None
        if prev_count is None or (prev_hint_break_depth and next_count):
            # First item in group or subgroup
            next_count = cast(int, next_count)
            if current_count > next_count:
                hook = DirectionX.RIGHT
        elif next_count is None or (break_depth and break_depth < current_count):
            # Last item in group
            prev_count = cast(int, prev_count)
            if current_count > prev_count:
                hook = DirectionX.LEFT
        else:
            # Item in middle of group
            if current_count > prev_count and current_count > next_count:
                if prev_count < next_count:
                    hook = DirectionX.RIGHT
                elif prev_count > next_count:
                    hook = DirectionX.LEFT
                else:
                    # Surrounding positions flag counts are equal, so
                    # hook direction is ambiguous. Allow override.
                    hook = current_hint_hook or DirectionX.LEFT
        states.append(_BeamState(current_count, break_depth, hook))
    return states


class _BeamPathSpec(NamedTuple):
    """An intermediate representation of beam paths."""

    depth: int
    """The beam's 1-indexed vertical position in the beam stack.

    1 represents the outermost beam, 2 represents 1 inward, and so
    on. For example, in a beam stack connecting sixteenth notes,
    ``depth=1`` would represent the 8th note beam while ``depth=2`` would
    represent the 16th note beam. In this way, ``depth`` corresponds to
    the flag counts of represented durations.
    """

    start: int
    """Index of the starting position"""

    end: Union[int, DirectionX]
    """Index of the ending position or a hook direction.

    If this is an index, it should be greater than ``start``.
    """


def _resolve_beam_layout(states: List[_BeamState]) -> List[_BeamPathSpec]:
    """Given a list of individual note beam states, work out how to render the beams."""
    # Iterate through beams from depth 0 to end, left to right.
    path_specs = []
    max_flag_count = max((state.flag_count for state in states))
    for depth in range(1, max_flag_count + 1):
        start_idx = None
        for i, state in enumerate(states):
            if start_idx is None:
                if state.flag_count >= depth:
                    start_idx = i
                else:
                    continue
            break_ends_beam = (
                state.break_depth is not None and state.break_depth < depth
            )
            next_depth_ends_beam = (
                i == len(states) - 1 or states[i + 1].flag_count < depth
            )
            if break_ends_beam or next_depth_ends_beam:
                # Beam ends after this state
                if start_idx == i:
                    # Beam only spanned one state, treat it as a hook
                    # Sanity check, hook should always be provided when required.
                    assert state.hook
                    path_specs.append(_BeamPathSpec(depth, start_idx, state.hook))
                else:
                    path_specs.append(_BeamPathSpec(depth, start_idx, i))
                start_idx = None
    return path_specs


class _BeamGroupLine(NamedTuple):
    """The line running along the outside of a group's outermost beam."""

    start_y: Unit
    """The starting y position relative to the staff.

    When the starting x position is implied to be 0, this is line's y-intercept.
    """

    slope: float


def _resolve_beam_group_line(
    chordrests: List[Chordrest], direction: DirectionY, font: MusicFont
) -> _BeamGroupLine:
    unit = chordrests[0].staff.unit
    first = chordrests[0]
    last = chordrests[-1]
    beam_group_height = _resolve_beam_group_height(chordrests, font)
    # Determine slope from the notes furthest on the side opposite of beam
    if direction == DirectionY.DOWN:
        slope_start_ref_note = first.highest_notehead
        slope_end_ref_note = last.highest_notehead
    else:
        slope_start_ref_note = first.lowest_notehead
        slope_end_ref_note = last.lowest_notehead
    if slope_start_ref_note.y > slope_end_ref_note.y:
        delta_y = unit(-1)
    elif slope_start_ref_note.y == slope_end_ref_note.y:
        delta_y = ZERO
    else:
        delta_y = unit(1)
    delta_x = last.map_to(first).x
    slope = delta_y / delta_x
    # Now find the note closest to the beam's side
    if direction == DirectionY.DOWN:
        cr_with_closest_note = max(chordrests, key=lambda c: c.lowest_notehead.y)
        cr_x = first.map_x_to(cr_with_closest_note)
        closest_y = cr_with_closest_note.lowest_notehead.y
        nearest_beam_intersect = Point(cr_x, closest_y + unit(2.5) + beam_group_height)
    else:
        cr_with_closest_note = min(chordrests, key=lambda c: c.highest_notehead.y)
        cr_x = first.map_x_to(cr_with_closest_note)
        closest_y = cr_with_closest_note.highest_notehead.y
        nearest_beam_intersect = Point(cr_x, closest_y - unit(2.5) - beam_group_height)
    # Given a beam intersect and a slope, find the beam y at ``start``
    # y = m(x - x1) + y1, where x = 0
    start_y = (slope * (-nearest_beam_intersect.x)) + nearest_beam_intersect.y
    return _BeamGroupLine(start_y, slope)


def _beam_layer_height(font: MusicFont):
    """Determine the height of a beam and its vertical padding in a given font."""
    return (
        font.engraving_defaults["beamSpacing"]
        + font.engraving_defaults["beamThickness"]
    )


def _resolve_beam_group_height(chordrests: List[Chordrest], font: MusicFont) -> Unit:
    """Find the vertical height occupied by a beam group spanning the given Chordrests.

    This determines the maximum beam depth required and uses it to
    calculate the expected maximum height in the group.
    """
    max_depth = max((c.duration.display.flag_count for c in chordrests))
    return max_depth * _beam_layer_height(font)


def _resolve_beam_direction(chordrests: List[Chordrest]) -> DirectionY:
    """Try to determine the best direction a beam group should go in.

    The algorithm works by determining the average y position of the
    outermost notes of each chord, then if that position lies above
    the middle staff line, placing the beam below (``DOWN``), and vice
    versa
    """
    middle_staff_pos = chordrests[0].staff.center_y
    s = ZERO
    for c in chordrests:
        s += c.furthest_notehead.y if c.noteheads else middle_staff_pos
    avg = s / len(chordrests)
    if avg > middle_staff_pos:
        return DirectionY.UP
    else:
        return DirectionY.DOWN


[docs]class BeamGroup(PositionedObject, HasMusicFont): """A beam group spanning a collection of Chordrests. This analyzes the given chordrests to determine a reasonable beam layout. The beaming algorithm does not take into account metric subdivisions; instead it greedily tries to beam together as many notes as possible. Subdivisions can be specified by setting :obj:`.Chordrest.beam_break_depth`, which indicates a break after the chord to the given beam count. While in most situations, beamlet "hooks" (as in a dotted 8th note followed by a 16th note) unambiguously must point right or left, there are some cases where it is ambiguous. For example, a 16th note between two 8th notes could have its beamlet point left or right. In these situations, ``BeamGroup`` will point it left by default, but users can override this by setting :obj:`.Chordrest.beam_hook_dir`. The beam direction and slant angle are determined automatically based on the given notes. The direction can be overridden in ``BeamGroup``'s constructor. Beam layout automatically modifies spanned chordrests by snapping their stems to the beam line and, if this causes a stem flip, correcting the chordrest component layout. This currently has some limitations: * It does not support beamed rests * It does not respond well to mutations. If being used in interactive or animated situations, the group likely will need to be destroyed and recreated after any changes affecting its chordrests. """
[docs] def __init__( self, chordrests: List[Chordrest], direction: Optional[DirectionY] = None, font: Optional[MusicFont] = None, brush: Optional[BrushDef] = None, pen: Optional[PenDef] = None, ): """ Args: chordrests: The notes or rests to beam across. This must have at least 2 items, all of which must be of durations requiring flags. direction: Override for the beam direction. Otherwise, the beam direction is automatically chosen based on the given chordrests. brush: The brush to fill shapes with. pen: The pen to draw outlines with. To ensure perfect overlaps with stems, this should have the same thickness of stems, derived from the ``MusicFont`` engraving default ``"stemThickness``. """ if len(chordrests) < 2: raise ValueError("BeamGroup must have at least 2 Chordrests.") # Determine top beam path chordrests.sort(key=lambda c: c.x) self._chordrests = chordrests self._beams: List[Beam] = [] super().__init__(ORIGIN, chordrests[0]) if font is None: font = HasMusicFont.find_music_font(self.parent) self._music_font = font # Load engraving defaults self._beam_thickness = self.music_font.engraving_defaults["beamThickness"] self._stem_thickness = self.music_font.engraving_defaults["stemThickness"] # Use same pen as stem to ensure perfectly aligned overlap self._brush = Brush.from_def(brush) if brush else Brush() self._pen = Pen.from_def(pen) if pen else Pen(thickness=self._stem_thickness) self._direction = direction or _resolve_beam_direction(self._chordrests) self._create_beams()
def _create_beams(self): # Work out beam direction, slope, and offset beam_group_line = _resolve_beam_group_line( self._chordrests, self.direction, self.music_font ) # Adjust stems to follow group line for c in self._chordrests: # y = m(x - x1) - y1, where x = 0 c_relative_x = c.map_x_to(self._chordrests[0]) y = (beam_group_line.slope * c_relative_x) + beam_group_line.start_y # (This direction checking approach will not work for kneed beams) if self.direction != c.stem.direction: c.stem_direction = self.direction adjusted_stem_end_y = c.stem.map_to(self).y + y c.stem.end_point.y = adjusted_stem_end_y c.flag.remove() c._flag = None # Now create the beams! layer_step = _beam_layer_height(self.music_font) * -self.direction.value specs = BeamGroup._resolve_chordrest_beam_layout(self._chordrests) base_y = -self._beam_thickness if self.direction == DirectionY.DOWN else ZERO for spec in specs: start_parent = self._chordrests[spec.start].stem.end_point start_y = base_y + ((spec.depth - 1) * layer_step) if isinstance(spec.end, int): end_parent = self._chordrests[spec.end].stem.end_point end_x = ZERO end_y = start_y else: end_parent = start_parent end_x = self._beam_thickness * 2 * spec.end.value end_y = start_y + (-end_x * beam_group_line.slope) self.beams.append( Beam( (ZERO, start_y), start_parent, (end_x, end_y), end_parent, self.music_font, self._brush, self._pen, ) ) @staticmethod def _resolve_chordrest_beam_layout( chordrests: List[Chordrest], ) -> List[_BeamPathSpec]: states = _resolve_beam_hooks( [ _BeamState( c.duration.display.flag_count, c.beam_break_depth, c.beam_hook_dir ) for c in chordrests ] ) return _resolve_beam_layout(states) @property def direction(self) -> DirectionY: return self._direction @property def chordrests(self) -> List[Chordrest]: return self._chordrests @property def beams(self) -> List[Beam]: return self._beams @property def music_font(self) -> MusicFont: return self._music_font
[docs] def remove(self): # Since the beams are actually not children of the beam group # (which is not ideal), we need to ensure they are removed # when the group is. for beam in self.beams: beam.remove() super().remove()