Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions examples/types.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
"metadata": {},
"outputs": [],
"source": [
"from fractions import Fraction\n",
"\n",
"import pyabc2\n",
"from pyabc2.sources import load_example_abc"
]
Expand Down Expand Up @@ -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,
Expand All @@ -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": {
Expand Down
3 changes: 2 additions & 1 deletion pyabc2/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -13,6 +13,7 @@
"Note",
"Pitch",
"PitchClass",
"Rest",
"Tune",
)

Expand Down
204 changes: 175 additions & 29 deletions pyabc2/note.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
Note class (pitch + duration)
"""
import re
import warnings
from fractions import Fraction
from typing import Optional

Expand All @@ -18,6 +19,33 @@
)
_RE_NOTE = re.compile(_S_RE_NOTE)

# fmt: off
_S_RE_REST = (
r"(?P<type>[zx])"
r"(?P<num>[0-9]+)?"
r"(?P<slash>/+)?"
r"(?P<den>[0-9]+)?"
)
# fmt: on
_RE_REST = re.compile(_S_RE_REST)

_S_RE_MULTIMEASURE_REST = r"(?P<type>[ZX])" r"(?P<num>[0-9]+)?"
_RE_MULTIMEASURE_REST = re.compile(_S_RE_MULTIMEASURE_REST)

_S_RE_NOTE_REST = (
r"("
r"(?P<acc>\^|\^\^|=|_|__)?"
r"(?P<note>[a-gA-G])"
r"(?P<oct>[,']*)"
r")|("
r"(?P<type>[xz])"
r")"
r"(?P<num>[0-9]+)?"
r"(?P<slash>/+)?"
r"(?P<den>[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()}
Expand All @@ -33,6 +61,19 @@
Fraction("1/128"): "&#119140;",
}

_REST_FRAC_TO_HTML = {
Fraction("1"): "&#119099;",
Fraction("1/2"): "&#119100;",
Fraction("1/4"): "&#119101;",
Fraction("1/8"): "&#119102;",
Fraction("1/16"): "&#119103;",
Fraction("1/32"): "&#119104;",
Fraction("1/64"): "&#119105;",
Fraction("1/128"): "&#119106;",
}
# _MULTIMEASURE_REST_HTML = "&#119081;"
_AUG_DOT_HTML = "&#119149;"


def _octave_from_abc_parts(note: str, oct: Optional[str] = None, *, base: int = 4):
"""
Expand All @@ -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."""

Expand All @@ -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:
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 spec>`` -- 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}"
Loading