from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Optional, Union
from typing_extensions import TypeAlias
from neoscore.core.exceptions import InvalidPitchDescriptionError
from neoscore.western.accidental_type import AccidentalType
# TODO support very basic midi constructor, maybe like
# Pitch.from_midi(midi_code: int, prefer_flat: bool)
[docs]@dataclass(frozen=True)
class Pitch:
"""A written pitch with a letter, octave, and accidental.
This class does not define an actual concert pitch, MIDI code, pitch class, etc.
associated with it. Users building notation systems on it can decide whether this
represents a concert pitch or a written one. Neoscore's ``western`` module treats it
mostly as a written pitch, unconditionally writing provided accidentals regardless
of context and key signatures.
The class supports a helpful shorthand for standard western 12-EDO pitches inspired
by Lilypond's pitch notation; see :obj:`.Pitch.from_str`.
Extended accidentals are fully supported by passing arbitrary SMuFL glyph names to
the ``accidental`` attribute.
"""
_shorthand_regex = re.compile("^([a-g])(s|#|n|f|b|ss|x|ff|bb)?('*|,*)$")
_diatonic_degrees_in_c = {"c": 1, "d": 2, "e": 3, "f": 4, "g": 5, "a": 6, "b": 7}
_middle_c_octave = 4
_accidental_shorthands = {
"s": AccidentalType.SHARP,
"#": AccidentalType.SHARP,
"n": AccidentalType.NATURAL,
"f": AccidentalType.FLAT,
"b": AccidentalType.FLAT,
"ss": AccidentalType.DOUBLE_SHARP,
"x": AccidentalType.DOUBLE_SHARP,
"ff": AccidentalType.DOUBLE_FLAT,
"bb": AccidentalType.DOUBLE_FLAT,
}
letter: str
"""The a-g letter name of the pitch."""
accidental: Optional[AccidentalType | str]
"""An accidental associated with the pitch.
For conventional accidentals, this can be an ``AccidentalType``.
Alternatively, this can be an arbitrary SMuFL glyph name string.
"""
octave: int
"""The octave number, where 4 is the octave starting with middle-C."""
[docs] @classmethod
def from_str(cls, shorthand: str) -> Pitch:
"""Create a conventional Western ``Pitch`` from a string shorthand.
The pitch shorthand is inspired by Lilypond's. It consists of three parts:
a pitch letter, an optional accidental, and an optional octave mark.
* Pitch letters are the standard ``c`` through ``b`` letters.
* The accidental may be ``f`` or ``b`` for flat, ``s`` or ``#`` for sharp, ``n`` for
natural, ``ss`` or ``x`` for double-sharp, and ``ff`` or ``bb`` for double-flat.
* The octave indication is given by a series of apostrophes (``'``)
or commas (``,``), where each apostrophe increases the pitch by an octave,
and each comma decreases it. All octave transformations are relative to
the octave starting at middle-C. The absence of an octave indicator means a
pitch is within the octave starting at middle-C. (Note that this differs from
Lilypond's notation, which starts at the octave *below* middle-C.)
Some examples:
* Middle-C: ``c``
* The B directly below that: ``b,``
* The C one octave below middle-C: ``c,``
* The E-flat above middle-C: ``ef`` or ``eb``
* The F-sharp above middle-C: ``fs`` or ``f#``
* The G-double-sharp above middle-C: ``fx`` or ``fss``
* The A-double-flat above the treble staff: ``aff'`` or ``abb'``
"""
match = Pitch._shorthand_regex.match(shorthand)
if match is None:
raise InvalidPitchDescriptionError
letter = match.group(1)
accidental_str = match.group(2)
ticks = match.group(3)
letter = letter
if accidental_str:
accidental = Pitch._accidental_shorthands[accidental_str.lower()]
else:
accidental = None
octave = 4
if ticks:
octave += len(ticks) * (-1 if ticks[0] == "," else 1)
return Pitch(letter, accidental, octave)
[docs] @classmethod
def from_def(cls, pitch_def: PitchDef) -> Pitch:
if isinstance(pitch_def, Pitch):
return pitch_def
elif isinstance(pitch_def, tuple):
return Pitch(*pitch_def)
return Pitch.from_str(pitch_def)
@property
def diatonic_degree_in_c(self) -> int:
"""The diatonic degree of the pitch as if it were in C.
>>> Pitch.from_str("c").diatonic_degree_in_c
1
>>> Pitch.from_str("c'").diatonic_degree_in_c
1
>>> Pitch.from_str("d'''").diatonic_degree_in_c
2
>>> Pitch.from_str("bf,").diatonic_degree_in_c
7
"""
return Pitch._diatonic_degrees_in_c[self.letter]
@property
def staff_pos_from_middle_c(self) -> float:
"""The pitch's staff position relative to middle C.
Values are in numeric pseudo-staff-units where positive
values mean positions below middle C, and negative values
mean positions above it.
>>> Pitch.from_str("c").staff_pos_from_middle_c
0
>>> Pitch.from_str("cs").staff_pos_from_middle_c
0
>>> Pitch.from_str("d").staff_pos_from_middle_c
-0.5
>>> Pitch.from_str("d'").staff_pos_from_middle_c
-4
>>> Pitch.from_str("cn,,").staff_pos_from_middle_c
7
"""
middle_c = (4 * 7) + 1 # C at octave 4
note_pos = (self.octave * 7) + self.diatonic_degree_in_c
position = (note_pos - middle_c) / -2
if position % 1 == 0:
return int(position)
else:
return position
PitchDef: TypeAlias = Union[Pitch, str, tuple]
"""Shorthand for a ``Pitch``
May be either a ``Pitch``, a pitch string shorthand (see ``Pitch.from_str``), or a ``Pitch``
init arg tuple.
"""