diff --git a/src/chordparser/music/chordcomponents/basequality.py b/src/chordparser/music/chordcomponents/basequality.py new file mode 100644 index 0000000..ee695fc --- /dev/null +++ b/src/chordparser/music/chordcomponents/basequality.py @@ -0,0 +1,85 @@ +from enum import Enum + +from chordparser.music.scaledegree import ScaleDegree + + +class BaseQuality(Enum): + """Enum for the base quality of a `Chord`. + + The enum members available are MAJOR, MINOR, AUGMENTED, DIMINISHED, + SUS2, SUS4, DOMINANT, HALFDIMINISHED and POWER. + + """ + + MAJOR = ( + str.upper, + True, + ("1", "3", "5"), + "", + ) + MINOR = ( + str.lower, + True, + ("1", "b3", "5"), + "m", + ) + AUGMENTED = ( + str.upper, + False, + ("1", "3", "#5"), + "aug", + ) + DIMINISHED = ( + str.lower, + False, + ("1", "b3", "b5"), + "dim", + ) + SUS2 = ( + str.upper, + False, + ("1", "2", "5"), + "sus2", + ) + SUS4 = ( + str.upper, + False, + ("1", "4", "5"), + "sus4", + ) + DOMINANT = ( + str.upper, + True, + ("1", "3", "5"), + "", + ) + HALFDIMINISHED = ( + str.lower, + False, + ("1", "b3", "b5"), + "\u00f8", + ) + POWER = ( + str.upper, + False, + ("1", "5"), + "5", + ) + + def roman_letter_case_converter(self, roman): + """Convert the Roman numeral letter case based on quality.""" + return self.value[0](roman) + + def is_derived_from_scale(self): + """Return if the quality is derived from a scale mode.""" + return self.value[1] + + @property + def scale_degrees(self): + return tuple(ScaleDegree(notation) for notation in self.value[2]) + + def __str__(self): + return self.value[3] + + def __repr__(self): + return f"BaseQuality.{self.name}" diff --git a/src/chordparser/music/chordcomponents/extensionquality.py b/src/chordparser/music/chordcomponents/extensionquality.py new file mode 100644 index 0000000..93d1bb6 --- /dev/null +++ b/src/chordparser/music/chordcomponents/extensionquality.py @@ -0,0 +1,38 @@ +from enum import Enum + + +class ExtensionQuality(Enum): + """Enum for the quality of the extension of an extended `Chord`. + + The enum members available are DIMINISHED, HALFDIMINISHED, MINOR, + DOMINANT and MAJOR. + + """ + + DIMINISHED = (-2, "") + HALFDIMINISHED = (-1, "") + MINOR = (-1, "") + DOMINANT = (-1, "") + MAJOR = (0, "maj") + + def seventh_shift(self): + """Return the step shift of the seventh. + + Returns + ------- + int + The step shift of the seventh. + + Examples + -------- + >>> ExtensionQuality.DIMINISHED.seventh_shift() + -2 + + """ + return self.value[0] + + def __str__(self): + return self.value[1] + + def __repr__(self): + return f"ExtensionQuality.{self.name}" diff --git a/src/chordparser/music/chordtypes/power.py b/src/chordparser/music/chordtypes/power.py new file mode 100644 index 0000000..c9bff3e --- /dev/null +++ b/src/chordparser/music/chordtypes/power.py @@ -0,0 +1,17 @@ +from chordparser.music.chordcomponents.basequality import BaseQuality +from chordparser.music.notationparser import NotationParserTemplate +from chordparser.utils.regex_patterns import power_pattern + + +power_enum_dict = { + "power": BaseQuality.POWER, +} + + +class PowerChordNotationParser(NotationParserTemplate): + """Parse power chords.""" + + _pattern = f"({power_pattern})" + + def _split_into_groups(self, regex): + return "power" diff --git a/src/chordparser/music/chordtypes/suspended.py b/src/chordparser/music/chordtypes/suspended.py new file mode 100644 index 0000000..c3fc9cc --- /dev/null +++ b/src/chordparser/music/chordtypes/suspended.py @@ -0,0 +1,19 @@ +from chordparser.music.chordcomponents.basequality import BaseQuality +from chordparser.music.notationparser import NotationParserTemplate +from chordparser.utils.regex_patterns import sus2_pattern, sus4_pattern + + +sus_enum_dict = { + "sus2": BaseQuality.SUS2, + "sus4": BaseQuality.SUS4, +} + + +class SusNotationParser(NotationParserTemplate): + """Parse suspended chords.""" + + # sus2 must come first since sus4 detects for "sus" + _pattern = f"({sus2_pattern})|({sus4_pattern})" + + def _split_into_groups(self, regex): + return "sus2" if regex.group(1) else "sus4" diff --git a/src/chordparser/music/chordtypes/triad.py b/src/chordparser/music/chordtypes/triad.py new file mode 100644 index 0000000..714a45d --- /dev/null +++ b/src/chordparser/music/chordtypes/triad.py @@ -0,0 +1,42 @@ +from chordparser.music.chordcomponents.basequality import BaseQuality +from chordparser.music.chordtypes.suspended import (SusNotationParser, + sus_enum_dict) +from chordparser.music.notationparser import NotationParserTemplate +from chordparser.utils.regex_patterns import (dim_triad_pattern, + aug_triad_pattern, + minor_pattern, major_pattern) + + +triad_enum_dict = { + "major": BaseQuality.MAJOR, + "minor": BaseQuality.MINOR, + "diminished": BaseQuality.DIMINISHED, + "augmented": BaseQuality.AUGMENTED, + **sus_enum_dict, +} + + +class TriadNotationParser(NotationParserTemplate): + """Parse triads.""" + + _SNP = SusNotationParser() + + _pattern = ( + f"({major_pattern})|({minor_pattern})|" + f"({dim_triad_pattern})|({aug_triad_pattern})|" + f"({_SNP.pattern})" + ) + + def _split_into_groups(self, regex): + triad_dict = { + 0: "major", + 1: "minor", + 2: "diminished", + 3: "augmented", + 4: not regex.group(5) or self._SNP.parse_notation(regex.group(5)), + } + index = next( + idx for (idx, triad) in enumerate(regex.groups()) + if triad is not None + ) + return triad_dict[index] diff --git a/src/chordparser/music/hassymbol.py b/src/chordparser/music/hassymbol.py new file mode 100644 index 0000000..c4e848f --- /dev/null +++ b/src/chordparser/music/hassymbol.py @@ -0,0 +1,137 @@ +from chordparser.music.notecomponents.symbol import Symbol + + +class HasSymbol: + """Abstract class that contains a `Symbol`.""" + + _symbol: Symbol # To be defined in concrete class + + @property + def symbol(self): + return self._symbol + + def raise_by(self, steps=1): + """Raise the pitch by changing only its `Symbol`. + + The number of steps must be positive. If you wish to change + the pitch without knowing the number of steps, use shift_by() + instead. + + Parameters + ---------- + steps: int, Optional + The number of steps to raise by. Default 1 when optional. + + Raises + ------ + ValueError + If the number of steps is not positive. + IndexError + If the new Symbol is not within the range of DOUBLEFLAT and + DOUBLESHARP. + + Examples + -------- + The examples are given using `Notes`. They will apply similarly + to other classes with `Symbols`. + + >>> note = Note("Cb") + >>> note.raise_by() + >>> note + C Note + >>> note.raise_by(2) + >>> note + C\U0001D12A Note + + """ + if steps <= 0: + raise ValueError( + f"Expected positive steps, {steps} given. Use lower_by() or " + "shift_by() instead" + ) + self.shift_by(steps) + + def lower_by(self, steps=1): + """Lower the pitch by changing only its `Symbol`. + + The number of steps must be positive. If you wish to change + the pitch without knowing the number of steps, use shift_by() + instead. + + Parameters + ---------- + steps: int, Optional + The number of steps to lower by. Default 1 when optional. + + Raises + ------ + ValueError + If the number of steps is not positive. + IndexError + If the new Symbol is not within the range of DOUBLEFLAT and + DOUBLESHARP. + + Examples + -------- + The examples are given using `Notes`. They will apply similarly + to other classes with `Symbols`. + + >>> note = Note("C#") + >>> note.lower_by() + >>> note + C Note + >>> note.lower_by(2) + >>> note + C\U0001D12B Note + + """ + if steps <= 0: + raise ValueError( + f"Expected positive steps, {steps} given. Use raise_by() or " + "shift_by() instead" + ) + self.shift_by(-steps) + + def shift_by(self, steps): + """Shift the pitch by changing only its `Symbol`. + + A positive number of steps will raise the pitch, while a + negative number of steps will lower the pitch. + + Parameters + ---------- + steps: int + The number of steps to shift by. + + Raises + ------ + IndexError + If the new Symbol is not within the range of DOUBLEFLAT and + DOUBLESHARP. + + Examples + -------- + The examples are given using `Notes`. They will apply similarly + to other classes with `Symbols`. + + >>> note = Note("C#") + >>> note.shift_by(-1) + >>> note + C Note + >>> note.shift_by(1) + >>> note + C\u266f Note + + """ + old_pitch = self._symbol.as_steps() + new_pitch = old_pitch + steps + try: + self._symbol = next( + symbol for symbol in list(Symbol) + if new_pitch == symbol.as_steps() + ) + except StopIteration: + raise IndexError( + "New symbol is not within the range of DOUBLEFLAT and " + "DOUBLESHARP" + ) diff --git a/src/chordparser/music/key.py b/src/chordparser/music/key.py new file mode 100644 index 0000000..149fe46 --- /dev/null +++ b/src/chordparser/music/key.py @@ -0,0 +1,499 @@ +import re + +from chordparser.music.keycomponents.mode import Mode +from chordparser.music.notationparser import NotationParserTemplate +from chordparser.music.note import NoteNotationParser, Note +from chordparser.music.keycomponents.submode import Submode +from chordparser.utils.regex_patterns import (submode_pattern, + mode_pattern, + short_minor_pattern, + short_major_pattern, + mode_converter, + submode_converter) + + +class ModeError(Exception): + """Exception where a `Key`'s `mode` is invalid.""" + pass + + +class ModeGroupNotationParser(NotationParserTemplate): + """Parse mode notation into mode and submode.""" + + _flags = re.UNICODE | re.IGNORECASE + + _pattern = ( + fr"(\s?({submode_pattern})?\s?({mode_pattern}))|" + f"({short_minor_pattern})|" + f"({short_major_pattern})" + ) + + def _split_into_groups(self, regex): + """Split into mode and submode.""" + mode = self._get_mode( + regex.group(3), regex.group(4), regex.group(5) + ) + submode = self._get_submode( + regex.group(2), mode + ) + return mode, submode + + def _get_mode(self, long_mode, short_minor, short_major): + # Cannot use truthy because short_major searches for "" + if short_major is not None: + return "major" + if short_minor: + return "minor" + return long_mode.lower() + + def _get_submode(self, submode, mode): + is_minor = self._is_minor(mode) + if submode and not is_minor: + raise ModeError(f"'{mode}' does not have a submode") + if not is_minor: + return "none" + if is_minor and not submode: + return "natural" + return submode.lower() + + def _is_minor(self, mode): + return mode in {"minor", "aeolian"} + + +class ModeGroup: + """A class representing the mode of a key. + + The `ModeGroup` class consists of a mode enum {MAJOR, MINOR, IONIAN, + DORIAN, PHRYGIAN, LYDIAN, MIXOLYDIAN, AEOLIAN, LOCRIAN} and a + submode enum {NATURAL, HARMONIC, MELODIC, NONE}. + + Attributes + ---------- + mode : Mode + The mode. + submode : Submode + The submode. It defaults to NONE for non-minor modes, and + NATURAL for minor modes if the submode is not specified. + + """ + + _MNP = ModeGroupNotationParser() + + def __init__(self, notation): + mode, submode = self._MNP.parse_notation(notation) + self._mode = mode_converter[mode] + self._submode = submode_converter[submode] + + @property + def mode(self): + return self._mode + + @property + def submode(self): + return self._submode + + def get_step_pattern(self): + """Return the semitone step pattern of the `ModeGroup`. + + Submode accidentals are accounted for (i.e. harmonic or + melodic). + + Returns + ------- + tuple of int + The semitone step pattern of the mode. + + Examples + -------- + >>> harm_minor = ModeGroup("harmonic minor") + >>> harm_minor.get_step_pattern() + (2, 1, 2, 2, 1, 3, 1, 2, 1, 2, 2, 1, 3, 1) + + """ + mode_pattern = self._mode.step_pattern + submode_pattern = self._submode.step_pattern + return self._combine_patterns(mode_pattern, submode_pattern) + + def _combine_patterns(self, mode_pattern, submode_pattern): + return tuple( + sum(x) for x in zip(mode_pattern, submode_pattern) + ) + + def __repr__(self): + return f"{self} ModeGroup" + + def __str__(self): + if self._submode is not Submode.NONE: + return f"{self._submode} {self._mode}" + return str(self._mode) + + def __eq__(self, other): + """Compare with other `ModeGroups`. + + The two `ModeGroups` must have the same mode and submode to be + equal. + + Parameters + ---------- + other + The object to be compared with. + + Returns + ------- + boolean + The outcome of the comparison. + + Examples + -------- + >>> m = ModeGroup("harmonic minor") + >>> m2 = ModeGroup("harmonic minor") + >>> m3 = ModeGroup("minor") + >>> m == m2 + True + >>> m == m3 + False + + Major and Ionian modes, and Minor and Aeolian modes, are + treated as the same mode. + + >>> m = ModeGroup("major") + >>> m2 = ModeGroup("ionian") + >>> m == m2 + True + + """ + if not isinstance(other, ModeGroup): + return NotImplemented + return ( + self._mode == other.mode and + self._submode == other._submode + ) + + +class KeyNotationParser(NotationParserTemplate): + """Parse key notation into tonic and mode groups.""" + + _NNP = NoteNotationParser() + _MNP = ModeGroupNotationParser() + _pattern = ( + f"({_NNP.pattern})" + f"({_MNP.pattern})" + ) + + def _split_into_groups(self, regex): + tonic = regex.group(1) + mode_group_num = self._NNP.get_num_groups() + 2 + mode = regex.group(mode_group_num) + return tonic, mode + + +class Key: + """A class representing a musical key. + + The `Key` class composes of a `Note` object as its `tonic` and a + `ModeGroup` object with the attributes `mode` and `submode`. It can + be created from its string notation or by specifying its tonic, + mode and submode using the class method Key.from_components(). + + Parameters + ---------- + notation : str + The `Key` notation. This is in the format of: + {tonic} {submode(optional)} {mode} + e.g. C# harmonic minor, C major, Dm. If only the tonic is + specified, the `Key` is assumed to be major. + + Attributes + ---------- + tonic : Note + The tonic of the `Key`. + mode : ModeGroup + The mode and submode of the `Key`. + + Raises + ------ + SyntaxError + If the tonic, mode or submode is invalid. + ModeError + If the submode does not match the mode (e.g. harmonic major). + + Examples + -------- + >>> key = Key("C# minor") + >>> key.tonic + C\u266f Note + >>> key.mode.mode + Mode.MINOR + >>> key.mode.submode + Submode.NATURAL + + """ + + _KNP = KeyNotationParser() + + def __init__(self, notation): + tonic, mode = self._KNP.parse_notation(notation) + self._tonic = Note(tonic) + self._mode = ModeGroup(mode) + + @classmethod + def from_components(cls, tonic, mode, submode=None): + """Create a `Key` from its tonic, mode and submode components. + + Parameters + ---------- + tonic : Note or str + The tonic of the `Key`. + mode : str + The mode of the `Key`. + submode : str, Optional + The submode of the `Key`. Defaults to NONE for non-minor + and NATURAL for minor modes. + + Raises + ------ + SyntaxError + If the tonic, mode or submode is invalid. + ModeError + If the submode does not match the mode + (e.g. harmonic major). + + Examples + -------- + >>> key = Key.from_components("C", "major") + >>> key + C major Key + + >>> key = Key.from_components(Note("D"), "minor", "harmonic") + >>> key + D harmonic minor Key + + """ + mode_notation = Key._create_mode_notation(mode, submode) + notation = f"{tonic} {mode_notation}" + return cls(notation) + + @property + def tonic(self): + return self._tonic + + @property + def mode(self): + return self._mode + + def get_step_pattern(self): + """Return the semitone step pattern of the `Key`. + + Submode accidentals are accounted for (i.e. harmonic or + melodic). + + Returns + ------- + tuple of int + The semitone step pattern of the key. + + Examples + -------- + >>> key = Key("C harmonic minor") + >>> key.get_step_pattern() + (2, 1, 2, 2, 1, 3, 1, 2, 1, 2, 2, 1, 3, 1) + + """ + return self._mode.get_step_pattern() + + def set_mode(self, mode=None, submode=None): + """Set the `Key`'s mode. + + Either only the mode or submode is specified, or both can be + specified. The mode will default to the current `Key`'s mode, + while the submode will default to NATURAL for minor and NONE + for non-minor. + + Parameters + ---------- + mode : str, Optional + The new mode. + submode : str, Optional + The new submode. + + Raises + ------ + SyntaxError + If the tonic, mode or submode is invalid. + ModeError + If the submode does not match the mode (e.g. harmonic + major). + TypeError + If neither the mode nor submode are specified. + + Examples + -------- + >>> key = Key("C major") + >>> key.set_mode("minor", "melodic") + >>> key.mode + melodic minor ModeGroup + + """ + if mode is None and submode is None: + raise TypeError( + "At least one argument must be specified (0 given)" + ) + if mode is None: + mode = self._mode.mode + mode_notation = Key._create_mode_notation(mode, submode) + self._mode = ModeGroup(mode_notation) + + @staticmethod + def _create_mode_notation(mode, submode): + if submode: + return f"{submode} {mode}" + return mode + + def to_relative_major(self): + """Change the `Key` to its relative major. + + The `Key`'s mode must be minor or aeolian. + + Raises + ------ + ModeError + If the `Key` is not minor or aeolian. + + Examples + -------- + >>> key = Key("D minor") + >>> key.to_relative_major() + >>> key + F major Key + + """ + if not self._is_minor(): + raise ModeError(f"'{self}' is not minor") + self.transpose(3, 2) + self.set_mode("major") + + def _is_minor(self): + return self.mode.mode in {Mode.MINOR, Mode.AEOLIAN} + + def to_relative_minor(self, submode="natural"): + """Change the `Key` to its relative minor. + + The `Key`'s `mode` must be major or ionian. + + Parameters + ---------- + key : Key + The `Key` to be changed. + submode : {"natural", "harmonic", "melodic"}, Optional + The new submode of the relative minor `Key`. + + Raises + ------ + ModeError + If the `Key` is not major or ionian. + SyntaxError + If the submode is invalid. + + Examples + -------- + >>> key = Key("D major") + >>> key.to_relative_minor() + >>> key + B natural minor Key + >>> key = Key("E major") + >>> key.to_relative_minor("melodic") + >>> key + C\u266f melodic minor Key + + """ + if not self._is_major(): + raise ModeError(f"'{self}' is not major") + self.transpose(-3, -2) + self.set_mode("minor", submode) + + def _is_major(self): + return self.mode.mode in {Mode.MAJOR, Mode.IONIAN} + + def transpose(self, semitones, letters): + """Transpose the `Key` by some semitone and letter intervals. + + Parameters + ---------- + semitones : int + The difference in semitones to the transposed `Key`. + letters : int + The difference in scale degrees to the transposed `Key`. + + Examples + -------- + >>> key = Key("C") + >>> key.transpose(6, 3) + >>> key + F\u266f major Key + >>> key.transpose(0, 1) + >>> key + G\u266d major Key + + """ + self._tonic.transpose(semitones, letters) + + def transpose_simple(self, semitones, use_flats=False): + """Transpose the `Key` by some semitone interval. + + Parameters + ---------- + semitones : int + The difference in semitones to the transposed `Key`. + use_flats : boolean, Optional + Selector to use flats or sharps for black keys. Default + False when optional. + + Examples + -------- + >>> key = Key("C") + >>> key.transpose_simple(6) + F\u266f major Key + >>> key.transpose_simple(2, use_flats=True) + A\u266d major Key + + """ + self._tonic.transpose_simple(semitones, use_flats) + + def __repr__(self): + return f"{self} Key" + + def __str__(self): + return f"{self._tonic} {self._mode}" + + def __eq__(self, other): + """Compare with other `Keys`. + + The two `Keys` must have the same tonic and mode to be equal. + + Parameters + ---------- + other + The object to be compared with. + + Returns + ------- + boolean + The outcome of the comparison. + + Examples + -------- + >>> k = Key("C major") + >>> k2 = Key("C major") + >>> k == k2 + True + >>> k3 = Key("C# major") + >>> k == k3 + False + >>> k4 = Key("C minor") + >>> k == k4 + False + + """ + if not isinstance(other, Key): + return NotImplemented + return self._tonic == other.tonic and self._mode == other.mode diff --git a/src/chordparser/music/keycomponents/mode.py b/src/chordparser/music/keycomponents/mode.py new file mode 100644 index 0000000..1e10e6a --- /dev/null +++ b/src/chordparser/music/keycomponents/mode.py @@ -0,0 +1,57 @@ +from enum import Enum + + +class Mode(Enum): + """Enum for the various modes, including major and minor. + + The enum members available are MAJOR, MINOR, IONIAN, DORIAN, + PHRYGIAN, LYDIAN, MIXOLYDIAN, AEOLIAN and LOCRIAN. + + """ + + MAJOR = ( + 2, 2, 1, 2, 2, 2, 1, + 2, 2, 1, 2, 2, 2, 1, + ) + IONIAN = ( + 2, 2, 1, 2, 2, 2, 1, + 2, 2, 1, 2, 2, 2, 1, + ) + DORIAN = ( + 2, 1, 2, 2, 2, 1, 2, + 2, 1, 2, 2, 2, 1, 2, + ) + PHRYGIAN = ( + 1, 2, 2, 2, 1, 2, 2, + 1, 2, 2, 2, 1, 2, 2, + ) + LYDIAN = ( + 2, 2, 2, 1, 2, 2, 1, + 2, 2, 2, 1, 2, 2, 1, + ) + MIXOLYDIAN = ( + 2, 2, 1, 2, 2, 1, 2, + 2, 2, 1, 2, 2, 1, 2, + ) + MINOR = ( + 2, 1, 2, 2, 1, 2, 2, + 2, 1, 2, 2, 1, 2, 2, + ) + AEOLIAN = ( + 2, 1, 2, 2, 1, 2, 2, + 2, 1, 2, 2, 1, 2, 2, + ) + LOCRIAN = ( + 1, 2, 2, 1, 2, 2, 2, + 1, 2, 2, 1, 2, 2, 2, + ) + + @property + def step_pattern(self): + return self.value + + def __str__(self): + return f"{self.name.lower()}" + + def __repr__(self): + return f"Mode.{self.name}" diff --git a/src/chordparser/music/keycomponents/submode.py b/src/chordparser/music/keycomponents/submode.py new file mode 100644 index 0000000..3653953 --- /dev/null +++ b/src/chordparser/music/keycomponents/submode.py @@ -0,0 +1,48 @@ +from enum import Enum + + +class Submode(Enum): + """Enum for the various minor submodes. + + The enum members available are NATURAL, HARMONIC, MELODIC and NONE. + NONE applies to all non-minor modes. + + """ + + # The boolean helps to distinguish NATURAL from NONE + NATURAL = ( + True, ( + 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, + ), + ) + HARMONIC = ( + True, ( + 0, 0, 0, 0, 0, 1, -1, + 0, 0, 0, 0, 0, 1, -1, + ), + ) + MELODIC = ( + True, ( + 0, 0, 0, 0, 1, 0, -1, + 0, 0, 0, 0, 1, 0, -1, + ), + ) + NONE = ( + False, ( + 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, + ), + ) + + @property + def step_pattern(self): + return self.value[1] + + def __str__(self): + if self is Submode.NONE: + return "" + return f"{self.name.lower()}" + + def __repr__(self): + return f"Submode.{self.name}" diff --git a/src/chordparser/music/notationparser.py b/src/chordparser/music/notationparser.py new file mode 100644 index 0000000..0417db3 --- /dev/null +++ b/src/chordparser/music/notationparser.py @@ -0,0 +1,40 @@ +from abc import ABCMeta, abstractmethod +import re + + +class NotationParserTemplate(metaclass=ABCMeta): + """Abstract class for parsing notation.""" + + _pattern: str # To be defined in concrete class + + _flags = re.UNICODE + + @property + def pattern(self): + return self._pattern + + def parse_notation(self, notation): + regex = self._to_regex_object(notation) + if self._invalid_notation(regex): + raise SyntaxError("invalid syntax") + args = self._split_into_groups(regex) + return args + + def _to_regex_object(self, notation): + regex = re.match( + f"{self._pattern}$", + notation, + flags=self._flags, + ) + return regex + + def _invalid_notation(self, regex): + return not regex + + @abstractmethod + def _split_into_groups(self, regex): + pass + + def get_num_groups(self): + regex = re.compile(self._pattern) + return regex.groups diff --git a/src/chordparser/music/note.py b/src/chordparser/music/note.py new file mode 100644 index 0000000..4c7244f --- /dev/null +++ b/src/chordparser/music/note.py @@ -0,0 +1,209 @@ +from chordparser.music.hassymbol import HasSymbol +from chordparser.music.notecomponents.letter import Letter +from chordparser.music.notationparser import NotationParserTemplate +from chordparser.music.notecomponents.symbol import Symbol +from chordparser.utils.note_lists import sharp_scale, flat_scale +from chordparser.utils.regex_patterns import (note_pattern, + symbol_pattern, + letter_converter, + symbol_converter) +from chordparser.utils.unicode_chars import (sharp, doublesharp, + flat, doubleflat) + + +class NoteNotationParser(NotationParserTemplate): + """Parse note notation into letter and symbol groups.""" + + _pattern = ( + f"({note_pattern})({symbol_pattern})?" + ) + + def _split_into_groups(self, regex): + """Split into capitalised letter and symbol.""" + uppercase_letter = regex.group(1).upper() + symbol = regex.group(2) + return uppercase_letter, symbol + + +class Note(HasSymbol): + """A class representing a musical note. + + The `Note` class composes of a `Letter` and `Symbol`, + representing the letter and symbol part of the `Note` respectively. + It is created from a string of notation A-G with optional + accidental symbols b, bb, #, ## or their respective unicode + characters \u266d, \u266f, \U0001D12B, or \U0001D12A. + + Parameters + ---------- + notation : str + The notation of the `Note` to be created. + + Attributes + ---------- + letter : Letter + The letter part of the `Note`'s notation. + symbol : Symbol + The symbol part of the `Note`'s notation. + + Raises + ------ + SyntaxError + If the notation is invalid. + + Examples + -------- + >>> note = Note("C#") + >>> note + C\u266f Note + >>> str(note) + "C\u266f" + + """ + + _NNP = NoteNotationParser() + + def __init__(self, notation): + self._set(notation) + + def _set(self, notation): + letter, symbol = self._NNP.parse_notation(notation) + self._letter = letter_converter[letter] + self._symbol = symbol_converter[symbol] + + @property + def letter(self): + return self._letter + + def as_steps(self): + """Return the number of steps of the `Note` above C. + + Returns + ------- + int + The number of steps above C. + + Examples + -------- + >>> d = Note("D#") + >>> d.as_steps() + 3 + + """ + return (self._letter.as_steps() + self._symbol.as_steps()) % 12 + + def transpose(self, semitones, letters): + """Transpose the `Note` by some semitone and letter intervals. + + Parameters + ---------- + semitones : int + The difference in semitones to the transposed `Note`. + letters : int + The difference in scale degrees to the transposed `Note`. + + Examples + -------- + >>> note = Note("C") + >>> note.transpose(6, 3) + >>> note + F\u266f Note + >>> note.transpose(0, 1) + >>> note + G\u266d Note + + """ + original_steps = self.as_steps() + self._shift_letter(letters) + positive_step_diff = (self.as_steps() - original_steps) % 12 + positive_semitones = semitones % 12 + semitone_difference = positive_semitones - positive_step_diff + self.shift_by(semitone_difference) + + def _shift_letter(self, letters): + """Shift the `Letter` along the natural note scale.""" + old_index = self._letter.index() + new_index = (old_index + letters) % 7 + letter_list = list(Letter) + self._letter = letter_list[new_index] + + def transpose_simple(self, semitones, use_flats=False): + """Transpose the `Note` by some semitone interval. + + Parameters + ---------- + semitones : int + The difference in semitones to the transposed `Note`. + use_flats : boolean, Optional + Selector to use flats or sharps for black keys. Default + False when optional. + + Examples + -------- + >>> note = Note("C") + >>> note.transpose_simple(6) + F\u266f Note + >>> note.transpose_simple(2, use_flats=True) + A\u266d Note + + """ + if use_flats: + notes = flat_scale + else: + notes = sharp_scale + note = notes[(self.as_steps() + semitones) % 12] + self._set(note) + + def __repr__(self): + return f"{self} Note" + + def __str__(self): + return f"{self._letter}{self._symbol}" + + def __eq__(self, other): + """Compare with other `Notes` or strings. + + If comparing with a `Note`, checks if the other `Note`'s string notation is the same as this `Note`. If comparing with a + string, checks if the string is equal to this `Note`'s string + notation.' + + Parameters + ---------- + other + The object to be compared with. + + Returns + ------- + boolean + The outcome of the comparison. + + Examples + -------- + >>> d = Note("D") + >>> d2 = Note("D") + >>> d_str = "D" + >>> d == d2 + True + >>> d == d_str + True + + Note that symbols are converted to their unicode characters + when a `Note` is created. + + >>> ds = Note("D#") + >>> ds_str = "D#" + >>> ds_str_2 = "D\u266f" + >>> ds == ds_str + False + >>> ds == ds_str_2 + True + + """ + if isinstance(other, Note): + return ( + self._letter == other.letter and + self._symbol == other.symbol + ) + if isinstance(other, str): + return str(self) == other + return NotImplemented diff --git a/src/chordparser/music/notecomponents/letter.py b/src/chordparser/music/notecomponents/letter.py new file mode 100644 index 0000000..889f182 --- /dev/null +++ b/src/chordparser/music/notecomponents/letter.py @@ -0,0 +1,63 @@ +from enum import Enum + + +class Letter(Enum): + """Enum for the letter part of a `Note`. + + The enum members available are C, D, E, F, G, A and B. + + """ + + C = (0, 0) + D = (1, 2) + E = (2, 4) + F = (3, 5) + G = (4, 7) + A = (5, 9) + B = (6, 11) + + def index(self): + """Return the index of the `Letter` in the natural note scale. + + Returns + ------- + int + The index of the `Letter`. + + Examples + -------- + >>> Letter.C.index() + 0 + >>> Letter.D.index() + 1 + >>> Letter.B.index() + 6 + + """ + return self.value[0] + + def as_steps(self): + """Return the number of steps of the `Letter` from C. + + Returns + ------- + int + The number of steps of the `Letter` from C. + + Examples + -------- + >>> Letter.C.as_steps() + 0 + >>> Letter.D.as_steps() + 2 + >>> Letter.B.as_steps() + 11 + + """ + return self.value[1] + + def __str__(self): + return f"{self.name}" + + def __repr__(self): + return f"Letter.{self.name}" diff --git a/src/chordparser/music/notecomponents/symbol.py b/src/chordparser/music/notecomponents/symbol.py new file mode 100644 index 0000000..45922d4 --- /dev/null +++ b/src/chordparser/music/notecomponents/symbol.py @@ -0,0 +1,43 @@ +from enum import Enum + +from chordparser.utils.unicode_chars import (sharp, doublesharp, + flat, doubleflat) + + +class Symbol(Enum): + """Enum for the symbol part of a `Note`. + + The enum members available are DOUBLEFLAT, FLAT, NATURAL, SHARP + and DOUBLESHARP. + + """ + + DOUBLEFLAT = (doubleflat, -2) + FLAT = (flat, -1) + NATURAL = ("", 0) + SHARP = (sharp, 1) + DOUBLESHARP = (doublesharp, 2) + + def as_steps(self): + """Return the number of steps of the `Symbol` from NATURAL. + + Returns + ------- + int + The number of steps from NATURAL. + + Examples + -------- + >>> Symbol.SHARP.as_steps() + 1 + >>> Symbol.FLAT.as_steps() + -1 + + """ + return self.value[1] + + def __str__(self): + return f"{self.value[0]}" + + def __repr__(self): + return f"Symbol.{self.name}" diff --git a/src/chordparser/music/scale.py b/src/chordparser/music/scale.py new file mode 100644 index 0000000..7b2bcab --- /dev/null +++ b/src/chordparser/music/scale.py @@ -0,0 +1,273 @@ +from chordparser.music.key import Key +from chordparser.music.note import Note +from chordparser.music.scaledegree import ScaleDegree +from chordparser.music.notecomponents.symbol import Symbol +from chordparser.utils.note_lists import natural_semitone_intervals +from chordparser.utils.notecomparer import NoteComparer + + +class Scale: + """A class representing a musical scale. + + The `Scale` composes of a `Key` on which it is based on, and + provides access to its notes and scale degrees. + + Parameters + ---------- + key : Key + The `Key` which the `Scale` is based on. + + Attributes + ---------- + key : Key + The `Key` which the `Scale` is based on. + + Examples + -------- + >>> scale = Scale(Key("C major")) + >>> scale + C major Scale + + """ + + def __init__(self, key): + # Type check to avoid surprises when using key methods later + if not isinstance(key, Key): + raise TypeError(f"object of type {type(key)} is not a Key") + self._key = key + + @property + def key(self): + return self._key + + def get_notes(self): + """Return a two octave list of `Notes` in the `Scale`. + + Returns + ------- + list of `Note` + Two octaves of the `Notes` in the `Scale`. + + Examples + -------- + >>> scale = Scale(Key("C major")) + >>> scale.get_notes() + [C Note, D Note, E Note, F Note, G Note, A Note, B Note, + C Note, D Note, E Note, F Note, G Note, A Note, B Note, C Note] + + """ + notes = self._initialise_notes() + for step in self._key.get_step_pattern(): + self._add_note(notes, step) + return notes + + def _initialise_notes(self): + tonic = Note(str(self._key.tonic)) # avoid same reference + return [tonic] + + def _add_note(self, notes, step): + prev_note = notes[-1] + new_note = Note(str(prev_note)) # avoid same reference + new_note.transpose(step, 1) + notes.append(new_note) + + def get_scale_degrees(self): + """Return a two octave list of `ScaleDegrees` in the `Scale`. + + Returns + ------- + list of `ScaleDegree` + Two octaves of the `ScaleDegrees` in the `Scale`. + + Examples + -------- + >>> scale = Scale(Key("C major")) + >>> scale.get_scale_degrees() + [1 Scale Degree, 2 Scale Degree, \u266d3 Scale Degree, + 4 Scale Degree, 5 Scale Degree, \u266d6 Scale Degree, + \u266d7 Scale Degree, 1 Scale Degree, 2 Scale Degree, + \u266d3 Scale Degree, 4 Scale Degree, 5 Scale Degree, + \u266d6 Scale Degree, \u266d7 Scale Degree, 1 Scale Degree] + + """ + scale_degrees = self._initialise_scale_degrees() + for step in self._key.get_step_pattern(): + self._add_scale_degree(scale_degrees, step) + return scale_degrees + + def _initialise_scale_degrees(self): + return [ScaleDegree("1")] + + def _add_scale_degree(self, scale_degrees, step): + new_degree = self._get_new_degree(scale_degrees) + step_difference = self._get_step_diff_from_major(scale_degrees, step) + new_sd = ScaleDegree(str(new_degree)) + new_sd.shift_by(step_difference) + scale_degrees.append(new_sd) + + def _get_new_degree(self, scale_degrees): + prev_sd = scale_degrees[-1] + return prev_sd.degree % 7 + 1 + + def _get_step_diff_from_major(self, scale_degrees, step): + prev_sd = scale_degrees[-1] + step_reference = natural_semitone_intervals[prev_sd.degree - 1] + return step - step_reference + prev_sd.symbol.as_steps() + + def transpose(self, semitones, letters): + """Transpose the `Scale` by some semitone and letter intervals. + + Parameters + ---------- + semitones : int + The difference in semitones to the transposed `Scale`. + letters : int + The difference in scale degrees to the transposed `Scale`. + + Examples + -------- + >>> scale = Scale(Key("C major")) + >>> scale.transpose(6, 3) + >>> scale + F\u266f major Scale + >>> scale.transpose(0, 1) + >>> scale + G\u266d major Scale + + """ + self._key.transpose(semitones, letters) + + def transpose_simple(self, semitones, use_flats=False): + """Transpose the `Scale` by some semitone interval. + + Parameters + ---------- + semitones : int + The difference in semitones to the transposed `Scale`. + use_flats : boolean, Optional + Selector to use flats or sharps for black keys. Default + False when optional. + + Examples + -------- + >>> scale = Scale(Key("C major")) + >>> scale.transpose_simple(6) + F\u266f major Scale + >>> scale.transpose_simple(2, use_flats=True) + A\u266d major Scale + + """ + self._key.transpose_simple(semitones, use_flats) + + def get_note_from_scale_degree(self, scale_degree): + """Get `Note` of a `Scale` by specifying its `ScaleDegree`. + + Parameters + ---------- + scale_degree: ScaleDegree + The `ScaleDegree` of the `Note` to get. + + Returns + ------- + Note + The `Note` of the `Scale` with the specified `ScaleDegree`. + + Examples + -------- + >>> scale = Scale(Key("C major")) + >>> sd = ScaleDegree("b3") + >>> scale.get_note_from_scale_degree(sd) + E\u266d Note + + """ + note = self._get_major_note_from_scale_degree(scale_degree) + self._change_accidental(note, scale_degree) + return note + + def _get_major_note_from_scale_degree(self, scale_degree): + major_scale = self._get_major_scale_of_self() + return major_scale.get_notes()[scale_degree.degree-1] + + def _change_accidental(self, note, scale_degree): + note.shift_by(scale_degree.symbol.as_steps()) + + def get_scale_degree_from_note(self, note): + """Get `ScaleDegree` of a `Scale` by specifying a `Note`. + + Parameters + ---------- + note: Note + The `Note` of the `ScaleDegree` to get. + + Returns + ------- + ScaleDegree + The `ScaleDegree` of the `Scale` of the specified `Note`. + + Examples + -------- + >>> scale = Scale(Key("C major")) + >>> note = Note("F#") + >>> scale.get_scale_degree_from_note(note) + \u266f4 Scale Degree + + """ + major_note = self._get_major_note_from_note(note) + degree = self._get_degree_of(note) + step_difference = NoteComparer.get_semitone_intervals( + [major_note, note] + )[0] + new_sd = ScaleDegree(str(degree)) + new_sd.shift_by(step_difference) + return new_sd + + def _get_major_note_from_note(self, note): + major_scale = self._get_major_scale_of_self() + return next( + n for n in major_scale.get_notes() + if n.letter == note.letter + ) + + def _get_major_scale_of_self(self): + new_scale = Scale(self._key) + new_scale.key.set_mode(mode="major") + return new_scale + + def _get_degree_of(self, note): + return next( + idx for (idx, n) in enumerate(self.get_notes()) + if n.letter == note.letter + ) + 1 + + def __repr__(self): + return f"{self._key} Scale" + + def __eq__(self, other): + """Compare with other `Scales`. + + The two `Scales` must have the same key to be equal. + + Parameters + ---------- + other + The object to be compared with. + + Returns + ------- + boolean + The outcome of the comparison. + + Examples + -------- + >>> s = Scale(Key("C major")) + >>> s2 = Scale(Key("C major")) + >>> s == s2 + True + >>> s3 = Scale(Key("C# major")) + >>> s == s3 + False + + """ + if not isinstance(other, Scale): + return NotImplemented + return self._key == other.key diff --git a/src/chordparser/music/scaledegree.py b/src/chordparser/music/scaledegree.py new file mode 100644 index 0000000..09c53a1 --- /dev/null +++ b/src/chordparser/music/scaledegree.py @@ -0,0 +1,139 @@ +from chordparser.music.hassymbol import HasSymbol +from chordparser.music.notationparser import NotationParserTemplate +from chordparser.music.notecomponents.symbol import Symbol +from chordparser.utils.regex_patterns import (symbol_pattern, + degree_pattern, + symbol_converter) + + +class ScaleDegreeNotationParser(NotationParserTemplate): + """Parse scale degree notation into degree and symbol.""" + + _pattern = f"({symbol_pattern})?({degree_pattern})" + + def _split_into_groups(self, regex): + symbol = regex.group(1) + degree = regex.group(2) + return degree, symbol + +class ScaleDegree(HasSymbol): + """A class representing a scale degree. + + The `ScaleDegree` consists of an integer (the degree) and a + Symbol. It is created from a string notation with an optional symbol + and a degree. The degree has to be between 1 and 7, and the symbols + allowed are b, bb, #, ## or their respective unicode characters + \u266d, \u266f, \U0001D12B, or \U0001D12A. + + Parameters + ---------- + notation: str + The string notation of the `ScaleDegree`. + + Attributes + ---------- + degree : int + The degree of the `ScaleDegree`. + symbol : Symbol + The accidental of the `ScaleDegree`. + + Examples + -------- + >>> sd = ScaleDegree("b1") + >>> sd + \u266d1 Scale Degree + + """ + + _SDNP = ScaleDegreeNotationParser() + + def __init__(self, notation): + degree, symbol = self._SDNP.parse_notation(notation) + self._set(degree, symbol) + + def _set(self, degree, symbol): + self._degree = int(degree) + self._symbol = symbol_converter[symbol] + + @property + def degree(self): + return self._degree + + @classmethod + def from_components(cls, degree, symbol=""): + """Create a `ScaleDegree` from its degree and symbol components. + + Parameters + ---------- + degree: int or str + The scale degree's integer value. + symbol: str, Optional + The symbol's string notation. Default "" when optional. + + Examples + -------- + >>> sd = ScaleDegree.from_components(3, "b") + >>> sd + \u266d3 Scale Degree + + >>> sd = ScaleDegree.from_components("1") + >>> sd + 1 Scale Degree + + """ + return cls(f"{symbol}{degree}") + + def __str__(self): + return f"{self._symbol}{self._degree}" + + def __repr__(self): + return f"{self} Scale Degree" + + def __eq__(self, other): + """Compare with other `ScaleDegrees` or strings. + + If the other object is a `ScaleDegree`, it must have the same + degree and symbol as this `ScaleDegree` to be equal. If it is a + string, they must have the same string representation. + + Parameters + ---------- + other + The object to be compared with. + + Returns + ------- + boolean + The outcome of the comparison. + + Examples + -------- + >>> sd = ScaleDegree("b5") + >>> sd2 = ScaleDegree("b5") + >>> sd == sd2 + True + >>> sd3 = ScaleDegree("5") + >>> sd == sd3 + False + >>> sd4 = ScaleDegree("b4") + >>> sd == sd4 + False + + Note that Symbols only use unicode characters when comparing + with other strings. + + >>> sd = ScaleDegree("#7") + >>> sd == "#7" + False + >>> sd == "\u266f7" + True + + """ + if isinstance(other, ScaleDegree): + return ( + self._degree == other.degree and + self._symbol == other.symbol + ) + if isinstance(other, str): + return str(self) == other + return NotImplemented diff --git a/src/chordparser/utils/__init__.py b/src/chordparser/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/chordparser/utils/note_lists.py b/src/chordparser/utils/note_lists.py new file mode 100644 index 0000000..e33bffc --- /dev/null +++ b/src/chordparser/utils/note_lists.py @@ -0,0 +1,23 @@ +from chordparser.utils.unicode_chars import sharp, flat + + +natural_notes = ( + "C", "D", "E", "F", "G", "A", "B", + "C", "D", "E", "F", "G", "A", "B", +) +sharp_scale = ( + "C", f"C{sharp}", "D", f"D{sharp}", "E", "F", f"F{sharp}", "G", + f"G{sharp}", "A", f"A{sharp}", "B", + "C", f"C{sharp}", "D", f"D{sharp}", "E", "F", f"F{sharp}", "G", + f"G{sharp}", "A", f"A{sharp}", "B", + ) +flat_scale = ( + "C", f"D{flat}", "D", f"E{flat}", "E", "F", f"G{flat}", "G", + f"A{flat}", "A", f"B{flat}", "B", + "C", f"D{flat}", "D", f"E{flat}", "E", "F", f"G{flat}", "G", + f"A{flat}", "A", f"B{flat}", "B", + ) +natural_semitone_intervals = ( + 2, 2, 1, 2, 2, 2, 1, + 2, 2, 1, 2, 2, 2, 1, +) diff --git a/src/chordparser/utils/notecomparer.py b/src/chordparser/utils/notecomparer.py new file mode 100644 index 0000000..f521944 --- /dev/null +++ b/src/chordparser/utils/notecomparer.py @@ -0,0 +1,147 @@ +class NoteComparer: + """A class that contains methods for comparing `Note` values. + + The NoteComparer provides methods to find intervals between + `Notes`. It does not have to be initialised for the methods to be + used. + + """ + + @staticmethod + def get_semitone_intervals(notes): + """Get the semitone intervals between an array of `Notes`. + + An array of length greater or equal to 2 is required. The + interval for each `Note` is relative to the previous `Note`. + + Parameters + ---------- + notes : array-like + An array of `Notes`. + + Returns + ------- + list of int + The list of semitone intervals between all adjacent `Notes`. + + Raises + ------ + IndexError + If the array has a length less than 2. + + Examples + -------- + >>> c = Note("C") + >>> f = Note("F") + >>> a = Note("A") + >>> NoteComparer.get_semitone_intervals([c, f, a]) + [5, 4] + + """ + NoteComparer._check_array_len("get_semitone_intervals", notes) + intervals = [] + prev_note = notes[0] + for note in notes: + semitone_interval = (note.as_steps()-prev_note.as_steps()) % 12 + intervals.append(semitone_interval) + prev_note = note + intervals.pop(0) + return intervals + + @staticmethod + def _check_array_len(method, array): + if len(array) < 2: + raise IndexError( + f"{method}() requires an array of length greater or " + f"equal to 2 but an array of length {len(array)} was " + "given" + ) + + @staticmethod + def get_letter_intervals(notes): + """Get the letter intervals between an array of `Notes`. + + An array of length greater or equal to 2 is required. The + interval for each `Note` is relative to the previous `Note`. + + Parameters + ---------- + notes : array-like + An array of `Notes`. + + Returns + ------- + list of int + The list of letter intervals between all adjacent `Notes`. + + Raises + ------ + IndexError + If the array has a length less than 2. + + Examples + -------- + >>> c = Note("C") + >>> f = Note("F") + >>> a = Note("A") + >>> NoteComparer.get_letter_intervals([c, f, a]) + [3, 2] + + """ + NoteComparer._check_array_len("get_letter_intervals", notes) + intervals = [] + prev_note_idx = notes[0].letter.index() + for note in notes: + note_idx = note.letter.index() + interval = (note_idx-prev_note_idx) % 7 + intervals.append(interval) + prev_note_idx = note_idx + intervals.pop(0) + return intervals + + @staticmethod + def get_semitone_displacements(notes): + """Get the semitone displacements between `Notes`. + + An array of length greater or equal to 2 is required. The + interval for each `Note` is relative to the previous `Note`. By + displacement, we mean the shortest semitone distance between + `Notes` that accounts for the direction of the interval, i.e. + the displacement will be negative if the shortest distance is a + downwards interval. + + Parameters + ---------- + notes : Note + An array of `Notes`. + + Returns + ------- + list of int + The list of the semitone displacements between all adjacent + `Notes`. + + Raises + ------ + IndexError + If the array has a length less than 2. + + Examples + -------- + >>> c = Note("C") + >>> b = Note("B") + >>> NoteComparer.get_semitone_intervals(c, b) + [11] + >>> NoteComparer.get_semitone_displacements(c, b) + [-1] + + """ + NoteComparer._check_array_len( + "get_semitone_displacements", notes + ) + displacements = [] + intervals = NoteComparer.get_semitone_intervals(notes) + for interval in intervals: + displacement = interval if interval <= 6 else interval-12 + displacements.append(displacement) + return displacements diff --git a/src/chordparser/utils/regex_patterns.py b/src/chordparser/utils/regex_patterns.py new file mode 100644 index 0000000..0c70a82 --- /dev/null +++ b/src/chordparser/utils/regex_patterns.py @@ -0,0 +1,83 @@ +from chordparser.utils.unicode_chars import (flat, doubleflat, + sharp, doublesharp) +from chordparser.music.notecomponents.letter import Letter +from chordparser.music.keycomponents.mode import Mode +from chordparser.music.notecomponents.symbol import Symbol +from chordparser.music.keycomponents.submode import Submode + + +# General note +note_pattern = "[A-Ga-g]" +flat_pattern = f"b|{flat}" +doubleflat_pattern = f"bb|{doubleflat}" +sharp_pattern = f"#|{sharp}" +doublesharp_pattern = f"##|{doublesharp}" +symbol_pattern = ( + f"{doubleflat_pattern}|{flat_pattern}|" + f"{doublesharp_pattern}|{sharp_pattern}" +) +degree_pattern = "[1-7]" + +# Mode +submode_pattern = "natural|harmonic|melodic" +mode_pattern = ( + "major|minor|ionian|dorian|phrygian|" + "lydian|mixolydian|aeolian|locrian" +) +short_minor_pattern = "m" +short_major_pattern = "" + +# General quality +major_pattern = "Maj|Ma|M|maj|\u0394" +minor_pattern = "min|m|-" +dim_pattern = "dim|o|\u00B0" +aug_pattern = r"aug|\+" +halfdim_pattern = "\u00f8|\u00d8" +sus2_pattern = "sus2" +sus4_pattern = "sus4|sus" +lowered_5_pattern = f"(?:{dim_pattern})5|(?:{flat_pattern})5" +raised_5_pattern = f"(?:{aug_pattern})5|(?:{sharp_pattern})5" + +# Quality +power_pattern = "5" +dim_triad_pattern = f"{dim_pattern}|(?:{minor_pattern})(?:{lowered_5_pattern})" +aug_triad_pattern = f"{aug_pattern}|(?:{major_pattern})(?:{raised_5_pattern})" + +# Convert matched regex to enum +letter_converter = { + "C": Letter.C, + "D": Letter.D, + "E": Letter.E, + "F": Letter.F, + "G": Letter.G, + "A": Letter.A, + "B": Letter.B, +} +symbol_converter = { + "b": Symbol.FLAT, + flat: Symbol.FLAT, + "bb": Symbol.DOUBLEFLAT, + doubleflat: Symbol.DOUBLEFLAT, + "#": Symbol.SHARP, + sharp: Symbol.SHARP, + "##": Symbol.DOUBLESHARP, + doublesharp: Symbol.DOUBLESHARP, + None: Symbol.NATURAL, +} +mode_converter = { + "major": Mode.MAJOR, + "ionian": Mode.IONIAN, + "dorian": Mode.DORIAN, + "phrygian": Mode.PHRYGIAN, + "lydian": Mode.LYDIAN, + "mixolydian": Mode.MIXOLYDIAN, + "aeolian": Mode.AEOLIAN, + "minor": Mode.MINOR, + "locrian": Mode.LOCRIAN, +} +submode_converter = { + "natural": Submode.NATURAL, + "harmonic": Submode.HARMONIC, + "melodic": Submode.MELODIC, + "none": Submode.NONE, +} diff --git a/src/chordparser/utils/unicode_chars.py b/src/chordparser/utils/unicode_chars.py new file mode 100644 index 0000000..2be5d2c --- /dev/null +++ b/src/chordparser/utils/unicode_chars.py @@ -0,0 +1,4 @@ +flat = "\u266D" +doubleflat = "\U0001D12B" +sharp = "\u266F" +doublesharp = "\U0001D12A" diff --git a/tests/test_music/test_chordcomponents/test_basequality.py b/tests/test_music/test_chordcomponents/test_basequality.py new file mode 100644 index 0000000..37af639 --- /dev/null +++ b/tests/test_music/test_chordcomponents/test_basequality.py @@ -0,0 +1,23 @@ +import pytest + +from chordparser.music.chordcomponents.basequality import BaseQuality +from chordparser.music.scaledegree import ScaleDegree + + +class TestBaseQualityScaleDegrees: + @pytest.mark.parametrize( + "quality, sd", [ + (BaseQuality.MAJOR, ( + ScaleDegree("1"), + ScaleDegree("3"), + ScaleDegree("5"), + )), + (BaseQuality.SUS2, ( + ScaleDegree("1"), + ScaleDegree("2"), + ScaleDegree("5"), + )), + ] + ) + def test_correct_scale_degrees_created(self, quality, sd): + assert sd == quality.scale_degrees diff --git a/tests/test_music/test_chordtypes/test_power.py b/tests/test_music/test_chordtypes/test_power.py new file mode 100644 index 0000000..f537657 --- /dev/null +++ b/tests/test_music/test_chordtypes/test_power.py @@ -0,0 +1,17 @@ +import pytest + +from chordparser.music.chordtypes.power import PowerChordNotationParser + + +class TestPCNPParseNotation: + @pytest.fixture + def parser(self): + return PowerChordNotationParser() + + def test_correct_notation(self, parser): + result = parser.parse_notation("5") + assert "power" == result + + def test_reject_wrong_notation(self, parser): + with pytest.raises(SyntaxError): + parser.parse_notation("6") diff --git a/tests/test_music/test_chordtypes/test_suspended.py b/tests/test_music/test_chordtypes/test_suspended.py new file mode 100644 index 0000000..ef7ed0b --- /dev/null +++ b/tests/test_music/test_chordtypes/test_suspended.py @@ -0,0 +1,17 @@ +import pytest + +from chordparser.music.chordtypes.suspended import SusNotationParser + + +class TestPCNPParseNotation: + @pytest.fixture + def parser(self): + return SusNotationParser() + + @pytest.mark.parametrize( + "notation, string", + [("sus", "sus4"), ("sus2", "sus2"), ("sus4", "sus4")] + ) + def test_correct_notation(self, parser, notation, string): + result = parser.parse_notation(notation) + assert string == result diff --git a/tests/test_music/test_chordtypes/test_triad.py b/tests/test_music/test_chordtypes/test_triad.py new file mode 100644 index 0000000..717e47c --- /dev/null +++ b/tests/test_music/test_chordtypes/test_triad.py @@ -0,0 +1,17 @@ +import pytest + +from chordparser.music.chordtypes.triad import TriadNotationParser + + +class TestPCNPParseNotation: + @pytest.fixture + def parser(self): + return TriadNotationParser() + + @pytest.mark.parametrize( + "notation, string", + [("M", "major"), ("dim", "diminished"), ("sus4", "sus4")] + ) + def test_correct_notation(self, parser, notation, string): + result = parser.parse_notation(notation) + assert string == result diff --git a/tests/test_music/test_hassymbol.py b/tests/test_music/test_hassymbol.py new file mode 100644 index 0000000..c4c2b1f --- /dev/null +++ b/tests/test_music/test_hassymbol.py @@ -0,0 +1,98 @@ +import pytest + +from chordparser.music.hassymbol import HasSymbol +from chordparser.music.notecomponents.symbol import Symbol + + +class Symbolful(HasSymbol): + def __init__(self, symbol): + self._symbol = symbol + + @property + def symbol(self): + return self._symbol + + +class TestSymbolfulRaiseBy: + def test_correct_shift(self): + symbolful = Symbolful(Symbol.NATURAL) + symbolful.raise_by() + assert Symbol.SHARP == symbolful.symbol + + @pytest.mark.parametrize( + "step, symbol", [ + (2, Symbol.SHARP), + (1, Symbol.NATURAL), + ] + ) + def test_correct_shift_with_step(self, step, symbol): + symbolful = Symbolful(Symbol.FLAT) + symbolful.raise_by(step) + assert symbol == symbolful.symbol + + def test_negative_shift_valueerrror(self): + symbolful = Symbolful(Symbol.NATURAL) + with pytest.raises(ValueError): + symbolful.raise_by(-1) + + def test_symbol_not_in_range(self): + symbolful = Symbolful(Symbol.NATURAL) + with pytest.raises(IndexError): + symbolful.raise_by(3) + + +class TestSymbolfulLowerBy: + def test_correct_shift(self): + symbolful = Symbolful(Symbol.NATURAL) + symbolful.lower_by() + assert Symbol.FLAT == symbolful.symbol + + @pytest.mark.parametrize( + "step, symbol", [ + (2, Symbol.FLAT), + (1, Symbol.NATURAL), + ] + ) + def test_correct_shift_with_step(self, step, symbol): + symbolful = Symbolful(Symbol.SHARP) + symbolful.lower_by(step) + assert symbol == symbolful.symbol + + def test_negative_shift_valueerrror(self): + symbolful = Symbolful(Symbol.NATURAL) + with pytest.raises(ValueError): + symbolful.lower_by(-1) + + def test_symbol_not_in_range(self): + symbolful = Symbolful(Symbol.NATURAL) + with pytest.raises(IndexError): + symbolful.lower_by(3) + + +class TestSymbolfulShiftBy: + @pytest.mark.parametrize( + "step, symbol", [ + (2, Symbol.SHARP), + (1, Symbol.NATURAL), + ] + ) + def test_correct_positive_shift(self, step, symbol): + symbolful = Symbolful(Symbol.FLAT) + symbolful.shift_by(step) + assert symbol == symbolful.symbol + + @pytest.mark.parametrize( + "step, symbol", [ + (-2, Symbol.FLAT), + (-1, Symbol.NATURAL), + ] + ) + def test_correct_negative_shift(self, step, symbol): + symbolful = Symbolful(Symbol.SHARP) + symbolful.shift_by(step) + assert symbol == symbolful.symbol + + def test_symbol_not_in_range(self): + symbolful = Symbolful(Symbol.NATURAL) + with pytest.raises(IndexError): + symbolful.shift_by(3) diff --git a/tests/test_music/test_key.py b/tests/test_music/test_key.py new file mode 100644 index 0000000..94c16d9 --- /dev/null +++ b/tests/test_music/test_key.py @@ -0,0 +1,228 @@ +import pytest + +from chordparser.music.key import (ModeError, Mode, Submode, + ModeGroupNotationParser, + KeyNotationParser, ModeGroup, Key) +from chordparser.music.note import Note +from chordparser.utils.unicode_chars import (sharp, doublesharp, + flat, doubleflat) + + +class TestMNPParseNotation: + @pytest.fixture + def parser(self): + return ModeGroupNotationParser() + + @pytest.mark.parametrize( + "notation, expected_mode", [ + ("", "major"), + ("m", "minor"), + (" major", "major"), + (" Dorian", "dorian"), + ] + ) + def test_correct_mode(self, parser, notation, expected_mode): + mode, _ = parser.parse_notation(notation) + assert expected_mode == mode + + @pytest.mark.parametrize( + "notation, expected_submode", [ + (" minor", "natural"), + ("harmonic minor", "harmonic"), + (" major", "none"), + ] + ) + def test_correct_submode(self, parser, notation, expected_submode): + _, submode = parser.parse_notation(notation) + assert expected_submode == submode + + def test_mode_error(self, parser): + with pytest.raises(ModeError): + parser.parse_notation("natural major") + + +class TestModeStepPattern: + @pytest.mark.parametrize( + "mode, steps", [ + ("major", ( + 2, 2, 1, 2, 2, 2, 1, + 2, 2, 1, 2, 2, 2, 1, + )), + ("harmonic minor", ( + 2, 1, 2, 2, 1, 3, 1, + 2, 1, 2, 2, 1, 3, 1, + )), + ("melodic minor", ( + 2, 1, 2, 2, 2, 2, 1, + 2, 1, 2, 2, 2, 2, 1, + )), + ] + ) + def test_correct_step_pattern(self, mode, steps): + m = ModeGroup(mode) + assert steps == m.get_step_pattern() + + +class TestModeStr: + def test_correct_str(self): + m = ModeGroup("harmonic minor") + assert "harmonic minor" == str(m) + + def test_correct_str_2(self): + m = ModeGroup("major") + assert "major" == str(m) + + +class TestModeEquality: + def test_equal(self): + m = ModeGroup("harmonic minor") + m2 = ModeGroup("harmonic minor") + assert m2 == m + + def test_major_equal(self): + m = ModeGroup("major") + m2 = ModeGroup("ionian") + assert m2 == m + + def test_minor_equal(self): + m = ModeGroup("minor") + m2 = ModeGroup("aeolian") + assert m2 == m + + @pytest.mark.parametrize( + "other", [ModeGroup("minor"), "harmonic minor", len] + ) + def test_inequality(self, other): + c = ModeGroup("harmonic minor") + assert other != c + + +class TestKNPParseNotation: + @pytest.fixture + def parser(self): + return KeyNotationParser() + + @pytest.mark.parametrize( + "notation, tonic, mode", [ + ("C", "C", ""), + ("D# dorian", "D#", " dorian"), + ("E melodic minor", "E", " melodic minor"), + ("F aeolian", "F", " aeolian"), + ] + ) + def test_correct_groups(self, parser, notation, tonic, mode): + t, m = parser.parse_notation(notation) + assert tonic == t + assert mode == m + + +class TestKey: + @pytest.mark.parametrize( + "notation, tonic, mode, submode", [ + ("B", "B", Mode.MAJOR, Submode.NONE), + ("Db dorian", f"D{flat}", Mode.DORIAN, Submode.NONE), + ("F# minor", f"F{sharp}", Mode.MINOR, Submode.NATURAL), + ("G melodic minor", "G", Mode.MINOR, Submode.MELODIC), + ] + ) + def test_key_creation(self, notation, tonic, mode, submode): + key = Key(notation) + assert tonic == key.tonic + assert mode == key.mode.mode + assert submode == key.mode.submode + + +class TestKeyFromComps: + def test_key_creation(self): + key = Key.from_components("C", "major") + assert "C" == key.tonic + assert Mode.MAJOR == key.mode.mode + assert Submode.NONE == key.mode.submode + + def test_key_creation_2(self): + key = Key.from_components(Note("D"), "minor", "harmonic") + assert "D" == key.tonic + assert Mode.MINOR == key.mode.mode + assert Submode.HARMONIC == key.mode.submode + + +class TestKeySetMode: + def test_with_mode_and_submode(self): + key = Key("C") + key.set_mode("minor", "melodic") + assert Mode.MINOR == key.mode.mode + assert Submode.MELODIC == key.mode.submode + + def test_without_mode(self): + key = Key("D minor") + key.set_mode(submode="harmonic") + assert Mode.MINOR == key.mode.mode + assert Submode.HARMONIC == key.mode.submode + + def test_without_submode(self): + key = Key("D melodic minor") + key.set_mode(mode="dorian") + assert Mode.DORIAN == key.mode.mode + assert Submode.NONE == key.mode.submode + + +class TestKeyRelativeMajor: + def test_correct_relative(self): + key = Key("D minor") + key.to_relative_major() + assert "F major" == str(key) + + def test_wrong_mode(self): + key = Key("D dorian") + with pytest.raises(ModeError): + key.to_relative_major() + + +class TestKeyRelativeMinor: + def test_correct_relative(self): + key = Key("D major") + key.to_relative_minor() + print(key.mode) + assert "B natural minor" == str(key) + + def test_correct_relative_2(self): + key = Key("D major") + key.to_relative_minor("harmonic") + assert "B harmonic minor" == str(key) + + def test_wrong_mode(self): + key = Key("E phrygian") + with pytest.raises(ModeError): + key.to_relative_minor() + + +class TestKeyTranspose: + def test_transpose(self): + key = Key("E") + key.transpose(6, 3) + assert f"A{sharp}" == key.tonic + +class TestKeyTransposeSimple: + def test_transpose_sharps(self): + key = Key("G") + key.transpose_simple(-1) + assert f"F{sharp}" == key.tonic + + def test_transpose_flats(self): + key = Key("G") + key.transpose_simple(1, use_flats=True) + assert f"A{flat}" == key.tonic + + +class TestKeyEquality: + def test_equality(self): + c = Key("C") + c2 = Key("C") + assert c == c2 + + @pytest.mark.parametrize( + "other", [Key("D minor"), "D minor", len] + ) + def test_inequality(self, other): + d = Key("D harmonic minor") + assert other != d diff --git a/tests/test_music/test_notationparser.py b/tests/test_music/test_notationparser.py new file mode 100644 index 0000000..a4ed19c --- /dev/null +++ b/tests/test_music/test_notationparser.py @@ -0,0 +1,35 @@ +import pytest + +from chordparser.music.notationparser import NotationParserTemplate + + +class NotationParser(NotationParserTemplate): + _pattern = "(a)(b)" + + def _split_into_groups(self, regex): + return regex.group(1), regex.group(2) + + +class TestNPParseNotation: + @pytest.fixture + def parser(self): + return NotationParser() + + def test_correct_parsing(self, parser): + groups = parser.parse_notation("ab") + assert "a" == groups[0] + assert "b" == groups[1] + + def test_wrong_notation(self, parser): + with pytest.raises(SyntaxError): + parser.parse_notation("aa") + + def test_incomplete_notation(self, parser): + with pytest.raises(SyntaxError): + parser.parse_notation("a") + + +class TestNPGetNumGroups: + def test_correct_number_of_groups(self): + parser = NotationParser() + assert 2 == parser.get_num_groups() diff --git a/tests/test_music/test_note.py b/tests/test_music/test_note.py new file mode 100644 index 0000000..313158d --- /dev/null +++ b/tests/test_music/test_note.py @@ -0,0 +1,122 @@ +import pytest + +from chordparser.music.notecomponents.letter import Letter +from chordparser.music.notecomponents.symbol import Symbol +from chordparser.music.note import NoteNotationParser, Note +from chordparser.utils.unicode_chars import (sharp, doublesharp, + flat, doubleflat) + + +class TestNNPParseNotation: + @pytest.fixture + def parser(self): + return NoteNotationParser() + + @pytest.mark.parametrize( + "notation, expected_letter, expected_symbol", [ + ("C", "C", None), + ("D", "D", None), + ("C#", "C", "#"), + ("Db", "D", "b"), + ] + ) + def test_correct_notation( + self, parser, notation, expected_letter, expected_symbol, + ): + letter, symbol = parser.parse_notation(notation) + assert expected_letter == letter + assert expected_symbol == symbol + + @pytest.mark.parametrize( + "notation", [ + "Ca", "C###", "Cbbb", "H", + ] + ) + def test_syntax_error(self, parser, notation): + with pytest.raises(SyntaxError): + parser.parse_notation(notation) + + +class TestNote: + def test_init(self): + note = Note("C#") + assert Letter.C == note.letter + assert Symbol.SHARP == note.symbol + + +class TestNoteAsSteps: + @pytest.mark.parametrize( + "note, value", [ + ("C#", 1), + ("Bbb", 9), + ("B##", 1), + ] + ) + def test_correct_int(self, note, value): + n = Note(note) + assert value == n.as_steps() + + +class TestNoteShiftLetter: + @pytest.mark.parametrize( + "shift, letter", [ + (2, Letter.E), + (-1, Letter.B), + (15, Letter.D), + ] + ) + def test_correct_shift(self, shift, letter): + this = Note("C") + this._shift_letter(shift) + assert letter == this.letter + + +class TestNoteTranspose: + @pytest.mark.parametrize( + "semitones, letters, old, new", [ + (1, 0, "C", f"C{sharp}"), + (-2, -1, "C", f"B{flat}"), + (-5, -4, "C", f"F{doublesharp}"), + (-3, -2, "D", "B"), + ] + ) + def test_correct_transpose(self, semitones, letters, old, new): + n = Note(old) + n.transpose(semitones, letters) + assert new == str(n) + + +class TestNoteTransposeSimple: + @pytest.mark.parametrize( + "note, num, new_note", [ + ("C", 2, "D"), + ("C", -13, "B"), + ("C", 3, f"D{sharp}") + ] + ) + def test_correct_transpose_simple(self, note, num, new_note): + n = Note(note) + n.transpose_simple(num) + assert new_note == str(n) + + + def test_correct_transpose_simple_flats(self): + n = Note("C") + n.transpose_simple(3, use_flats=True) + assert f"E{flat}" == str(n) + + +class TestNoteEquality: + def test_equality(self): + c = Note("C") + c2 = Note("C") + c_str = "C" + assert c == c2 + assert c == c_str + + @pytest.mark.parametrize( + "other", [Note("D"), "D", len] + ) + def test_inequality(self, other): + c = Note("C") + assert other != c diff --git a/tests/test_music/test_scale.py b/tests/test_music/test_scale.py new file mode 100644 index 0000000..8b3604d --- /dev/null +++ b/tests/test_music/test_scale.py @@ -0,0 +1,113 @@ +import pytest + +from chordparser.music.key import Key +from chordparser.music.note import Note +from chordparser.music.scale import Scale +from chordparser.music.scaledegree import ScaleDegree + + +class TestScale: + def test_scale_creation_success(self): + key = Key("C major") + scale = Scale(key) + assert key == scale.key + + def test_scale_argument_not_key(self): + with pytest.raises(TypeError): + Scale(len) + + +class TestScaleGetNotes: + @pytest.mark.parametrize( + "key, notes", [ + (Key("C major"), [ + Note("C"), Note("D"), Note("E"), Note("F"), Note("G"), + Note("A"), Note("B"), Note("C"), Note("D"), Note("E"), + Note("F"), Note("G"), Note("A"), Note("B"), Note("C"), + ]), + (Key("D dorian"), [ + Note("D"), Note("E"), Note("F"), Note("G"), Note("A"), + Note("B"), Note("C"), Note("D"), Note("E"), Note("F"), + Note("G"), Note("A"), Note("B"), Note("C"), Note("D"), + ]) + ] + ) + def test_correct_notes(self, key, notes): + scale = Scale(key) + assert notes == scale.get_notes() + + +class TestScaleGetScaleDegrees: + @pytest.mark.parametrize( + "key, sd", [ + (Key("C minor"), [ + ScaleDegree("1"), ScaleDegree("2"), ScaleDegree("b3"), + ScaleDegree("4"), ScaleDegree("5"), ScaleDegree("b6"), + ScaleDegree("b7"), ScaleDegree("1"), ScaleDegree("2"), + ScaleDegree("b3"), ScaleDegree("4"), ScaleDegree("5"), + ScaleDegree("b6"), ScaleDegree("b7"), ScaleDegree("1"), + ]) + ] + ) + def test_correct_scale_degrees(self, key, sd): + scale = Scale(key) + assert sd == scale.get_scale_degrees() + + +class TestScaleTranspose: + def test_correct_transpose(self): + scale = Scale(Key("C major")) + scale.transpose(6, 3) + assert "F\u266f major Scale" == str(scale) + + +class TestScaleTransposeSimple: + def test_correct_transpose(self): + scale = Scale(Key("C major")) + scale.transpose_simple(6) + assert "F\u266f major Scale" == str(scale) + + def test_correct_transpose_with_flats(self): + scale = Scale(Key("C major")) + scale.transpose_simple(8, use_flats=True) + assert "A\u266d major Scale" == str(scale) + + +class TestScaleGetNoteFromScaleDegree: + @pytest.mark.parametrize( + "key, sd, note", [ + (Key("C minor"), ScaleDegree("#3"), Note("E#")), + (Key("Eb major"), ScaleDegree("1"), Note("Eb")) + ] + ) + def test_correct_note(self, key, sd, note): + scale = Scale(key) + assert note == scale.get_note_from_scale_degree(sd) + + +class TestScaleGetScaleDegreeFromNote: + @pytest.mark.parametrize( + "key, sd, note", [ + (Key("C minor"), ScaleDegree("#3"), Note("E#")), + (Key("Eb major"), ScaleDegree("1"), Note("Eb")) + ] + ) + def test_correct_sd(self, key, sd, note): + scale = Scale(key) + assert sd == scale.get_scale_degree_from_note(note) + + +class TestScaleEquality: + def test_scale_equal(self): + key = Key("C major") + scale = Scale(key) + scale2 = Scale(key) + assert scale == scale2 + + def test_scale_not_equal_with_other_scales(self): + scale = Scale(Key("C major")) + scale2 = Scale(Key("C minor")) + assert scale != scale2 + + def test_scale_not_equal_with_other_types(self): + assert len != Scale(Key("C major")) diff --git a/tests/test_music/test_scaledegree.py b/tests/test_music/test_scaledegree.py new file mode 100644 index 0000000..798a9ca --- /dev/null +++ b/tests/test_music/test_scaledegree.py @@ -0,0 +1,59 @@ +import pytest + +from chordparser.music.scaledegree import (ScaleDegree, + ScaleDegreeNotationParser) +from chordparser.music.notecomponents.symbol import Symbol + + +class TestSDNPParseNotation: + @pytest.fixture + def parser(self): + return ScaleDegreeNotationParser() + + def test_correct_groups(self, parser): + degree, symbol = parser.parse_notation("5") + assert "5" == degree + assert None == symbol + + def test_correct_groups_2(self, parser): + degree, symbol = parser.parse_notation("b4") + assert "4" == degree + assert "b" == symbol + + def test_wrong_notation(self, parser): + with pytest.raises(SyntaxError): + parser.parse_notation("0") + + +class TestScaleDegree: + def test_creation(self): + sd = ScaleDegree("b5") + assert Symbol.FLAT == sd.symbol + assert 5 == sd.degree + + +class TestScaleDegreeFromComps: + def test_creation(self): + sd = ScaleDegree.from_components(5, "b") + sd2 = ScaleDegree.from_components(5, Symbol.FLAT) + assert sd == sd2 + + +class TestScaleDegreeEquality: + def test_equality_sd(self): + sd = ScaleDegree("b5") + sd2 = ScaleDegree("b5") + assert sd2 == sd + + def test_equality_str(self): + sd = ScaleDegree("b5") + assert "\u266d5" == sd + + def test_inequality(self): + sd = ScaleDegree("b5") + sd2 = ScaleDegree("b4") + assert sd2 != sd + + def test_inequality_2(self): + sd = ScaleDegree("b5") + assert len != sd diff --git a/tests/test_utils/test_notecomparer.py b/tests/test_utils/test_notecomparer.py new file mode 100644 index 0000000..c2b3ee7 --- /dev/null +++ b/tests/test_utils/test_notecomparer.py @@ -0,0 +1,43 @@ +import pytest + +from chordparser.music.note import Note +from chordparser.utils.notecomparer import NoteComparer + + +class TestGetSemitoneIntervals: + def test_correct_intervals(self): + c = Note("C") + f = Note("F") + a = Note("A") + assert [5, 4] == NoteComparer.get_semitone_intervals([c, f, a]) + + def test_invalid_array(self): + c = Note("C") + with pytest.raises(IndexError): + NoteComparer.get_semitone_intervals([c]) + + +class TestGetLetterIntervals: + def test_correct_intervals(self): + c = Note("C") + f = Note("F") + a = Note("A") + assert [3, 2] == NoteComparer.get_letter_intervals([c, f, a]) + + def test_invalid_array(self): + c = Note("C") + with pytest.raises(IndexError): + NoteComparer.get_letter_intervals([c]) + + +class TestGetSemitoneDisplacements: + def test_correct_displacements(self): + c = Note("C") + b = Note("B") + assert [-1] == NoteComparer.get_semitone_displacements([c, b]) + + def test_invalid_array(self): + c = Note("C") + with pytest.raises(IndexError): + NoteComparer.get_letter_intervals([c]) + diff --git a/tests/test_utils/test_regex_patterns.py b/tests/test_utils/test_regex_patterns.py new file mode 100644 index 0000000..1a1c9f3 --- /dev/null +++ b/tests/test_utils/test_regex_patterns.py @@ -0,0 +1,53 @@ +import pytest + +from chordparser.music.notationparser import NotationParserTemplate +import chordparser.utils.regex_patterns as RegP + + +class RegexParserTemplate(NotationParserTemplate): + def __init__(self, pattern): + self._pattern = pattern + super().__init__() + + def _split_into_groups(self, regex): + return regex.group() + + +class TestLowered5: + @pytest.fixture + def parser(self): + return RegexParserTemplate(RegP.lowered_5_pattern) + + @pytest.mark.parametrize("pattern", ["o5", "b5"]) + def test_correct_match(self, parser, pattern): + assert pattern == parser.parse_notation(pattern) + + +class TestRaised5: + @pytest.fixture + def parser(self): + return RegexParserTemplate(RegP.raised_5_pattern) + + @pytest.mark.parametrize("pattern", ["#5", "+5"]) + def test_correct_match(self, parser, pattern): + assert pattern == parser.parse_notation(pattern) + + +class TestDimTriad: + @pytest.fixture + def parser(self): + return RegexParserTemplate(RegP.dim_triad_pattern) + + @pytest.mark.parametrize("pattern", ["dim", "mb5", "mo5"]) + def test_correct_match(self, parser, pattern): + assert pattern == parser.parse_notation(pattern) + + +class TestAugTriad: + @pytest.fixture + def parser(self): + return RegexParserTemplate(RegP.aug_triad_pattern) + + @pytest.mark.parametrize("pattern", ["aug", "M#5", "M+5"]) + def test_correct_match(self, parser, pattern): + assert pattern == parser.parse_notation(pattern)