diff --git a/examples/types.ipynb b/examples/types.ipynb index 66a8fff..ac98ef7 100644 --- a/examples/types.ipynb +++ b/examples/types.ipynb @@ -15,6 +15,8 @@ "metadata": {}, "outputs": [], "source": [ + "from fractions import Fraction\n", + "\n", "import pyabc2\n", "from pyabc2.sources import load_example_abc" ] @@ -100,6 +102,56 @@ "pyabc2.Note.from_abc(\"D,,3/\")" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "b3218192-ec36-4bb1-833a-c3bac87d7e06", + "metadata": {}, + "outputs": [], + "source": [ + "pyabc2.Rest()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f5d79060-6327-4121-be4f-7599ca303902", + "metadata": {}, + "outputs": [], + "source": [ + "pyabc2.Rest(Fraction(\"1/4\"))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "649198f0-0d4b-4ac2-8ff3-82557b24d9ce", + "metadata": {}, + "outputs": [], + "source": [ + "pyabc2.Rest.from_abc(\"x6\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1d7851ab-6021-4445-87f3-016762498f16", + "metadata": {}, + "outputs": [], + "source": [ + "pyabc2.Rest.from_abc(\"z3/2\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "82025828-5c25-4151-9542-baa48ab95e11", + "metadata": {}, + "outputs": [], + "source": [ + "pyabc2.Rest.from_abc(\"z//\").to_abc()" + ] + }, { "cell_type": "code", "execution_count": null, @@ -121,6 +173,40 @@ "source": [ "pyabc2.Tune(abc)" ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "74dee066-80a0-4dac-bc1a-546df8ca13e2", + "metadata": {}, + "outputs": [], + "source": [ + "t = pyabc2.Tune(\"\"\"\\\n", + "K: G\n", + "BAG AG z | x |\n", + "\"\"\")\n", + "t" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a22dd283-00d8-4821-ab37-3bcf0f898c72", + "metadata": {}, + "outputs": [], + "source": [ + "t.measures" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "adf3f01e-3298-41e9-a665-5c959641651e", + "metadata": {}, + "outputs": [], + "source": [ + "t.print_measures()" + ] } ], "metadata": { diff --git a/pyabc2/__init__.py b/pyabc2/__init__.py index 3d8a9ac..65d1a38 100644 --- a/pyabc2/__init__.py +++ b/pyabc2/__init__.py @@ -4,7 +4,7 @@ __version__ = "0.1.0.dev0" -from .note import Key, Note +from .note import Key, Note, Rest from .parse import Tune, _load_abcjs_if_in_jupyter from .pitch import Pitch, PitchClass @@ -13,6 +13,7 @@ "Note", "Pitch", "PitchClass", + "Rest", "Tune", ) diff --git a/pyabc2/note.py b/pyabc2/note.py index 610c9ed..fb208b8 100644 --- a/pyabc2/note.py +++ b/pyabc2/note.py @@ -2,6 +2,7 @@ Note class (pitch + duration) """ import re +import warnings from fractions import Fraction from typing import Optional @@ -18,6 +19,33 @@ ) _RE_NOTE = re.compile(_S_RE_NOTE) +# fmt: off +_S_RE_REST = ( + r"(?P[zx])" + r"(?P[0-9]+)?" + r"(?P/+)?" + r"(?P[0-9]+)?" +) +# fmt: on +_RE_REST = re.compile(_S_RE_REST) + +_S_RE_MULTIMEASURE_REST = r"(?P[ZX])" r"(?P[0-9]+)?" +_RE_MULTIMEASURE_REST = re.compile(_S_RE_MULTIMEASURE_REST) + +_S_RE_NOTE_REST = ( + r"(" + r"(?P\^|\^\^|=|_|__)?" + r"(?P[a-gA-G])" + r"(?P[,']*)" + r")|(" + r"(?P[xz])" + r")" + r"(?P[0-9]+)?" + r"(?P/+)?" + r"(?P[0-9]+)?" +) +_RE_NOTE_REST = re.compile(_S_RE_NOTE_REST) + _ACCIDENTAL_ASCII_TO_ABC = {"#": "^", "b": "_", "=": "="} _ACCIDENTAL_ABC_TO_ASCII = {v: k for k, v in _ACCIDENTAL_ASCII_TO_ABC.items()} @@ -33,6 +61,19 @@ Fraction("1/128"): "𝅘𝅥𝅲", } +_REST_FRAC_TO_HTML = { + Fraction("1"): "𝄻", + Fraction("1/2"): "𝄼", + Fraction("1/4"): "𝄽", + Fraction("1/8"): "𝄾", + Fraction("1/16"): "𝄿", + Fraction("1/32"): "𝅀", + Fraction("1/64"): "𝅁", + Fraction("1/128"): "𝅂", +} +# _MULTIMEASURE_REST_HTML = "𝄩" +_AUG_DOT_HTML = "𝅭" + def _octave_from_abc_parts(note: str, oct: Optional[str] = None, *, base: int = 4): """ @@ -56,6 +97,41 @@ def _octave_from_abc_parts(note: str, oct: Optional[str] = None, *, base: int = _DEFAULT_UNIT_DURATION = Fraction("1/8") +def _relative_duration_from_abc_match(m: re.Match): + g = m.groupdict() + + sla = g["slash"] + num = g["num"] + den = g["den"] + if sla is not None: + # raise ValueError("only whole multiples of L supported at this time") + if num is None and den is None: + # Special case: `/` as shorthand for 1/2 and can be multiple + relative_duration = Fraction("1/2") ** sla.count("/") + elif num is not None and den is not None: + # We have both numerator and denominator + assert ( + sla == "/" + ), "there should only be one `/` when using both numerator and denominator" + relative_duration = Fraction(f"{num}/{den}") + elif den is not None: + # When only denominator, numerator 1 is assumed + assert sla == "/", "there should only be one `/` when only denominator is used" + relative_duration = Fraction(f"1/{den}") + elif num is not None: + # When only numerator, denominator 2 is assumed + assert sla == "/", "there should be only one `/` when only numerator is used" + # ^ Not 100% sure about this though + relative_duration = Fraction(f"{num}/2") + else: + raise ValueError(f"invalid relative duration spec. in {m.group(0)!r}") + # (Shouldn't ever get here.) + else: + relative_duration = Fraction(num) if num is not None else Fraction(1) + + return relative_duration + + class Note(Pitch): """A note has a pitch and a duration.""" @@ -80,6 +156,7 @@ def _repr_html_(self): if nd1 == 3 and d1 <= 0.5: # Go up one level and add dot return f"{p}{_DURATION_FRAC_TO_HTML[d1*2]}." + # ^ using `{_AUG_DOT_HTML}` instead of `.` doesn't look good in JupyterLab currently elif nd1 == 1: return f"{p}{_DURATION_FRAC_TO_HTML[d1]}" else: @@ -122,6 +199,7 @@ def _from_abc_match( if m is None: raise ValueError("invalid ABC note specification") # TODO: would be nice to have the input string in this error message + # (could move to the public method and instead assert not None here) g = m.groupdict() @@ -152,35 +230,8 @@ def _from_abc_match( dvalue_key = 0 value = pitch_class_value(nat_class_name) + 12 * octave + dvalue_acc + dvalue_key - # Determine duration - sla = g["slash"] - num = g["num"] - den = g["den"] - if sla is not None: - # raise ValueError("only whole multiples of L supported at this time") - if num is None and den is None: - # Special case: `/` as shorthand for 1/2 and can be multiple - relative_duration = Fraction("1/2") ** sla.count("/") - elif num is not None and den is not None: - # We have both numerator and denominator - assert ( - sla == "/" - ), "there should only be one `/` when using both numerator and denominator" - relative_duration = Fraction(f"{num}/{den}") - elif den is not None: - # When only denominator, numerator 1 is assumed - assert sla == "/", "there should only be one `/` when only denominator is used" - relative_duration = Fraction(f"1/{den}") - elif num is not None: - # When only numerator, denominator 2 is assumed - assert sla == "/", "there should be only one `/` when only numerator is used" - # ^ Not 100% sure about this though - relative_duration = Fraction(f"{num}/2") - else: - raise ValueError(f"invalid relative duration spec. in {m.group(0)!r}") - # (Shouldn't ever get here.) - else: - relative_duration = Fraction(num) if num is not None else Fraction(1) + # Determine relative duration + relative_duration = _relative_duration_from_abc_match(m) note = cls(value, relative_duration * unit_duration) note._class_name = nat_class_name + acc_ascii @@ -266,3 +317,98 @@ def from_class_name(cls): @classmethod def from_class_value(cls): raise NotImplementedError + + +class Rest: + """A rest has a duration but no pitch.""" + + # https://abcnotation.com/wiki/abc:standard:v2.1#rests + + __slots__ = ("duration",) + + def __init__(self, duration: Fraction = _DEFAULT_UNIT_DURATION) -> None: + pass + + self.duration = duration + """Rest duration. By default, 1/8, an eighth note.""" + + def __str__(self): + return f"{self.duration}" + + def __repr__(self): + return f"{type(self).__name__}(duration={self.duration})" + + def _repr_html_(self): + d = self.duration + + d1, nd1 = d / d.numerator, d.numerator + + if nd1 == 3 and d1 <= 0.5: + # Go up one level and add dot + return f"{_REST_FRAC_TO_HTML[d1*2]}{_AUG_DOT_HTML}" + elif nd1 == 1: + return f"{_REST_FRAC_TO_HTML[d1]}" + # TODO: use multi-measure rest HTML? + else: + # 2 or more le notes (biggest duration) + # or whole note(s) + additional + # or 5 1/8 notes + # etc. + # TODO: ties or adding multiples + return f"({nd1}{_REST_FRAC_TO_HTML[d1]}" + + @classmethod + def from_abc( + cls, + abc: str, + *, + unit_duration: Fraction = _DEFAULT_UNIT_DURATION, + ): + """Parse ABC string representing a rest. + + * ``[ZX][0-9]*`` -- multi-measure rest + * ``[zx]`` -- duration specified like Note + * ``x``/``X`` indicate invisible rests. + We check for these but ignore and warn if found. + """ + m = _RE_MULTIMEASURE_REST.match(abc) + if m is not None: + warnings.warn("multi-measure rest detected and ignored") + return None + + m = _RE_REST.match(abc) + + return cls._from_abc_match(m, unit_duration=unit_duration) + + @classmethod + def _from_abc_match( + cls, + m: Optional[re.Match], + *, + unit_duration: Fraction = _DEFAULT_UNIT_DURATION, + ): + if m is None: + raise ValueError("invalid ABC rest specification") + + g = m.groupdict() + + rest_type = g["type"] + + if rest_type == "x": + warnings.warn("invisible rest detected and ignored") + return None + + relative_duration = _relative_duration_from_abc_match(m) + + return cls(relative_duration * unit_duration) + + def to_abc(self, *, unit_duration: Fraction = _DEFAULT_UNIT_DURATION, **kwargs) -> str: + relative_duration = self.duration / unit_duration + if relative_duration == 1: + s_duration = "" # relative duration 1 is implied so not needed + elif relative_duration.numerator == 1: + s_duration = f"/{relative_duration.denominator}" # numerator 1 implied so not needed + else: + s_duration = str(relative_duration) + + return f"z{s_duration}" diff --git a/pyabc2/parse.py b/pyabc2/parse.py index c006c62..902fc7d 100644 --- a/pyabc2/parse.py +++ b/pyabc2/parse.py @@ -2,10 +2,10 @@ ABC parsing/info """ import re -from typing import Dict, Iterator, List, NamedTuple, Optional +from typing import Dict, Iterator, List, NamedTuple, Optional, Union from .key import Key -from .note import _RE_NOTE, Note +from .note import _RE_NOTE_REST, Note, Rest class InfoField(NamedTuple): @@ -196,7 +196,7 @@ def __init__(self, abc: str): self.url: Optional[str] = None """Revelant URL for this particular tune/setting.""" - self.measures: List[List[Note]] + self.measures: List[List[Union[Note, Rest]]] self._parse_abc() @@ -269,22 +269,31 @@ def _extract_measures(self, tune_lines: List[str]) -> None: measure = [] - # 2. In measure, find note groups + # 2. In measure, find note groups ("beam") # Currently not doing anything with note group, but may want to in the future - for note_group in within_measure.split(" "): + for note_group in within_measure.strip().split(" "): # TODO: deal with `>` and `<` dotted rhythm modifiers between notes # https://abcnotation.com/wiki/abc:standard:v2.1#broken_rhythm # 3. In note group, find notes - for m_note in _RE_NOTE.finditer(note_group): - - # TODO: parse/store rests, maybe have an additional iterator for "rhythmic elements" or something - - if m_note is None: - raise ValueError(f"no notes in this note group? {note_group!r}") - - measure.append(Note._from_abc_match(m_note)) + this_group = [] + for m_note in _RE_NOTE_REST.finditer(note_group): + + if m_note.groupdict()["type"] is None: + this_group.append(Note._from_abc_match(m_note)) + else: + r = Rest._from_abc_match(m_note) + if r is not None: # not invisible rest + this_group.append(r) + + # if not this_group: + # raise ValueError( + # f"no notes/rests in this note group? {note_group!r}, " + # f"from measure {within_measure!r}" + # ) + # TODO: need to deal with ending spec properly first + measure.extend(this_group) measures.append(measure) @@ -337,6 +346,6 @@ def print_measures(self, *, note_format: str = "ABC"): else: raise ValueError(f"invalid note format {note_format!r}") - def iter_notes(self) -> Iterator[Note]: + def iter_notes(self) -> Iterator[Union[Note, Rest]]: """Iterator (generator) for `Note`s of the tune.""" return (n for m in self.measures for n in m)