From 236eb885289d213a88845c7c2b3992852726b2eb Mon Sep 17 00:00:00 2001 From: Titus Ong Date: Sat, 22 Aug 2020 21:26:09 +0800 Subject: [PATCH 01/52] Initial commit --- src/chordparser/music/note.py | 44 +++++++++++++++++++++++++ src/chordparser/utils/__init__.py | 0 src/chordparser/utils/converters.py | 16 +++++++++ src/chordparser/utils/regex_patterns.py | 7 ++++ src/chordparser/utils/unicode_chars.py | 4 +++ tests/test_music/test_note.py | 40 ++++++++++++++++++++++ 6 files changed, 111 insertions(+) create mode 100644 src/chordparser/music/note.py create mode 100644 src/chordparser/utils/__init__.py create mode 100644 src/chordparser/utils/converters.py create mode 100644 src/chordparser/utils/regex_patterns.py create mode 100644 src/chordparser/utils/unicode_chars.py create mode 100644 tests/test_music/test_note.py diff --git a/src/chordparser/music/note.py b/src/chordparser/music/note.py new file mode 100644 index 0000000..1e0d53a --- /dev/null +++ b/src/chordparser/music/note.py @@ -0,0 +1,44 @@ +import re + +from chordparser.utils.regex_patterns import (note_pattern, + sharp_pattern, + flat_pattern) +from chordparser.utils.converters import symbol_to_unicode + + +class NoteNotationParser: + """Parse note notation into letter and symbol groups.""" + _pattern = ( + f"^({note_pattern})({flat_pattern}|{sharp_pattern}){{0,1}}$" + ) + + def parse_notation(self, notation): + """Parse the note string.""" + regex = self._to_regex_object(notation) + if self._invalid_notation(regex): + raise SyntaxError("invalid syntax") + letter, symbol = self._split_capital_letter_and_symbol(regex) + return letter, symbol + + def _to_regex_object(self, notation): + regex = re.match( + NoteNotationParser._pattern, + notation, + re.UNICODE + ) + return regex + + def _invalid_notation(self, regex): + return not regex + + def _split_capital_letter_and_symbol(self, regex): + uppercase_letter = regex.group(1).upper() + symbol = symbol_to_unicode[regex.group(2)] + return uppercase_letter, symbol + + def get_regex_pattern(self): + return self._pattern + + def get_regex_groups_count(self): + regex = self._to_regex_object("C") + return len(regex.groups()) 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/converters.py b/src/chordparser/utils/converters.py new file mode 100644 index 0000000..8e01cdc --- /dev/null +++ b/src/chordparser/utils/converters.py @@ -0,0 +1,16 @@ +from chordparser.utils.unicode_chars import (flat, doubleflat, + sharp, doublesharp) + + +symbol_to_unicode = { + "b": flat, + flat: flat, + "bb": doubleflat, + doubleflat: doubleflat, + "#": sharp, + sharp: sharp, + "##": doublesharp, + doublesharp: doublesharp, + "": "", + None: "", +} diff --git a/src/chordparser/utils/regex_patterns.py b/src/chordparser/utils/regex_patterns.py new file mode 100644 index 0000000..c1455e1 --- /dev/null +++ b/src/chordparser/utils/regex_patterns.py @@ -0,0 +1,7 @@ +from chordparser.utils.unicode_chars import (flat, doubleflat, + sharp, doublesharp) + + +note_pattern = "[a-gA-G]" +flat_pattern = f"bb|b|{flat}|{doubleflat}" +sharp_pattern = f"##|#|{sharp}|{doublesharp}" 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_note.py b/tests/test_music/test_note.py new file mode 100644 index 0000000..54ee1ba --- /dev/null +++ b/tests/test_music/test_note.py @@ -0,0 +1,40 @@ +import pytest + +from chordparser.note import NoteNotationParser + + +class TestNNPParseNotation: + @pytest.fixture + def parser(self): + return NoteNotationParser() + + @pytest.mark.parametrize( + "notation, expected_letter, expected_symbol", [ + ("C", "C", ""), + ("d", "D", ""), + ("C#", "C", "\u266F"), + ("Db", "D", "\u266D"), + ] + ) + 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 TestNNPGetRegexGroupsCount: + def test_correct_number_of_groups(self): + parser = NoteNotationParser() + parser._pattern = "(a)(b)" + assert 2 == parser.get_regex_groups_count() From a237ef6cc43bcde3f12a2e9425b26e4bc3aaf57d Mon Sep 17 00:00:00 2001 From: Titus Ong Date: Sat, 22 Aug 2020 22:14:19 +0800 Subject: [PATCH 02/52] Initial commit --- src/chordparser/music/letter.py | 50 ++++++++++++++++++++++++ src/chordparser/music/symbol.py | 60 +++++++++++++++++++++++++++++ src/chordparser/utils/note_lists.py | 8 ++++ tests/test_music/test_letter.py | 30 +++++++++++++++ tests/test_music/test_symbol.py | 35 +++++++++++++++++ 5 files changed, 183 insertions(+) create mode 100644 src/chordparser/music/letter.py create mode 100644 src/chordparser/music/symbol.py create mode 100644 src/chordparser/utils/note_lists.py create mode 100644 tests/test_music/test_letter.py create mode 100644 tests/test_music/test_symbol.py diff --git a/src/chordparser/music/letter.py b/src/chordparser/music/letter.py new file mode 100644 index 0000000..4ddd994 --- /dev/null +++ b/src/chordparser/music/letter.py @@ -0,0 +1,50 @@ +from collections import UserString + +from chordparser.utils.note_lists import (natural_notes, + natural_semitone_intervals) + + + +class Letter(UserString): + """A class representing the letter part of a `Note`.""" + + def as_int(self): + """Return the `Letter`'s semitone value (basis: C = 0). + + The integer value is based on the number of semitones above C. + + Returns + ------- + int + The integer `Letter` value. + + Examples + -------- + >>> d = Letter("D") + >>> d.as_int() + 2 + + """ + position = natural_notes.index(self.data) + total_semitones = sum(natural_semitone_intervals[:position]) + return total_semitones + + def shift_by(self, shift): + """Shift the `Letter` along the natural note scale. + + Parameters + ---------- + shift : int + The number of letters to shift by. + + Examples + -------- + >>> d = Letter("D") + >>> d.shift_by(3) + >>> d + G + + """ + position = natural_notes.index(self.data) + position = (position + shift) % 7 + self.data = natural_notes[position] diff --git a/src/chordparser/music/symbol.py b/src/chordparser/music/symbol.py new file mode 100644 index 0000000..ae0af86 --- /dev/null +++ b/src/chordparser/music/symbol.py @@ -0,0 +1,60 @@ +from collections import UserString + +from chordparser.utils.converters import (symbol_to_unicode, + symbol_to_int, + int_to_symbol) + + + +class Symbol(UserString): + """A class representing the symbol part of a `Note`. + + The `Symbol` is automatically converted to its unicode form. Only + symbols from doubleflats to doublesharps are allowed. + + """ + def __init__(self, data): + self.data = symbol_to_unicode[data] + + def as_int(self): + """Return the `Symbol`'s semitone value (basis: natural = 0). + + The integer value is based on the number of semitones above or + below the natural note. + + Returns + ------- + int + The integer `Symbol` value. + + Examples + -------- + >>> sharp = Symbol("#") + >>> sharp.as_int() + 1 + + """ + return symbol_to_int[self.data] + + def shift_by(self, semitones): + """Shift the `Symbol` (i.e. raise or lower it). + + Parameters + ---------- + semitones : int + The semitones to shift the `Symbol` by. + + Examples + -------- + >>> sharp = Symbol("#") + >>> sharp.shift_by(-2) + >>> sharp + \u266D + + """ + int_value = self.as_int() + semitones + if int_value not in int_to_symbol.keys(): + raise ValueError( + "Symbol integer value is out of range" + ) + self.data = int_to_symbol[int_value] diff --git a/src/chordparser/utils/note_lists.py b/src/chordparser/utils/note_lists.py new file mode 100644 index 0000000..3f37a86 --- /dev/null +++ b/src/chordparser/utils/note_lists.py @@ -0,0 +1,8 @@ +natural_notes = ( + "C", "D", "E", "F", "G", "A", "B", + "C", "D", "E", "F", "G", "A", "B", +) +natural_semitone_intervals = ( + 2, 2, 1, 2, 2, 2, 1, + 2, 2, 1, 2, 2, 2, 1, +) diff --git a/tests/test_music/test_letter.py b/tests/test_music/test_letter.py new file mode 100644 index 0000000..62bb098 --- /dev/null +++ b/tests/test_music/test_letter.py @@ -0,0 +1,30 @@ +import pytest + +from chordparser.music.letter import Letter + + +class TestLetterAsInt: + @pytest.mark.parametrize( + "letter, value", [ + ("C", 0), + ("E", 4), + ("B", 11), + ] + ) + def test_correct_int(self, letter, value): + this = Letter(letter) + assert value == this.as_int() + + +class TestLetterShiftBy: + @pytest.mark.parametrize( + "shift, value", [ + (2, "E"), + (-1, "B"), + (15, "D"), + ] + ) + def test_correct_shift(self, shift, value): + this = Letter("C") + this.shift_by(shift) + assert value == this diff --git a/tests/test_music/test_symbol.py b/tests/test_music/test_symbol.py new file mode 100644 index 0000000..780517f --- /dev/null +++ b/tests/test_music/test_symbol.py @@ -0,0 +1,35 @@ +import pytest + +from chordparser.music.symbol import Symbol +from chordparser.utils.unicode_chars import flat, doublesharp + + +class TestSymbolAsInt: + @pytest.mark.parametrize( + "symbol, value", [ + ("", 0), + ("#", 1), + ("bb", -2), + ] + ) + def test_correct_int(self, symbol, value): + this = Symbol(symbol) + assert value == this.as_int() + + +class TestSymbolShiftBy: + @pytest.mark.parametrize( + "shift, value", [ + (2, doublesharp), + (-1, flat), + ] + ) + def test_correct_shift(self, shift, value): + this = Symbol("") + this.shift_by(shift) + assert value == this + + def test_symbol_out_of_range(self): + this = Symbol("") + with pytest.raises(ValueError): + this.shift_by(3) From 60526720163e4ac568bb0754e8d4c278fdb1647a Mon Sep 17 00:00:00 2001 From: Titus Ong Date: Sat, 22 Aug 2020 22:14:39 +0800 Subject: [PATCH 03/52] Added Note class and as_int method --- src/chordparser/music/note.py | 38 +++++++++++++++++++++++++++++++++++ tests/test_music/test_note.py | 23 ++++++++++++++++++++- 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/chordparser/music/note.py b/src/chordparser/music/note.py index 1e0d53a..e3b9a9a 100644 --- a/src/chordparser/music/note.py +++ b/src/chordparser/music/note.py @@ -1,5 +1,7 @@ import re +from chordparser.music.letter import Letter +from chordparser.music.symbol import Symbol from chordparser.utils.regex_patterns import (note_pattern, sharp_pattern, flat_pattern) @@ -42,3 +44,39 @@ def get_regex_pattern(self): def get_regex_groups_count(self): regex = self._to_regex_object("C") return len(regex.groups()) + + +class Note: + _NNP = NoteNotationParser() + + def __init__(self, notation): + letter, symbol = self._NNP.parse_notation(notation) + self._letter = Letter(letter) + self._symbol = Symbol(symbol) + + @property + def letter(self): + return self._letter + + @property + def symbol(self): + return self._symbol + + def as_int(self): + """Return the `Note`'s value as an integer (basis: C = 0). + + The integer value is based on the number of semitones above C. + + Returns + ------- + int + The integer `Note` value. + + Examples + -------- + >>> d = Note("D#") + >>> d.as_int() + 3 + + """ + return (self._letter.as_int() + self._symbol.as_int()) % 12 diff --git a/tests/test_music/test_note.py b/tests/test_music/test_note.py index 54ee1ba..918af33 100644 --- a/tests/test_music/test_note.py +++ b/tests/test_music/test_note.py @@ -1,6 +1,7 @@ import pytest -from chordparser.note import NoteNotationParser +from chordparser.utils.unicode_chars import sharp +from chordparser.music.note import NoteNotationParser, Note class TestNNPParseNotation: @@ -38,3 +39,23 @@ def test_correct_number_of_groups(self): parser = NoteNotationParser() parser._pattern = "(a)(b)" assert 2 == parser.get_regex_groups_count() + + +class TestNote: + def test_init(self): + note = Note("C#") + assert "C" == note.letter + assert sharp == note.symbol + + +class TestNoteAsInt: + @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_int() From 5dad0cefc004ad8afc0a7a3ab376f140311721f7 Mon Sep 17 00:00:00 2001 From: Titus Ong Date: Sat, 22 Aug 2020 22:14:49 +0800 Subject: [PATCH 04/52] Added symbol-int converters --- src/chordparser/utils/converters.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/chordparser/utils/converters.py b/src/chordparser/utils/converters.py index 8e01cdc..1513cf5 100644 --- a/src/chordparser/utils/converters.py +++ b/src/chordparser/utils/converters.py @@ -14,3 +14,19 @@ "": "", None: "", } + +symbol_to_int = { + flat: -1, + doubleflat: -2, + sharp: 1, + doublesharp: 2, + "": 0, +} + +int_to_symbol = { + -1: flat, + -2: doubleflat, + 1: sharp, + 2: doublesharp, + 0: "", +} From d8fb051458599f82613406bcf9082e48665cb641 Mon Sep 17 00:00:00 2001 From: Titus Ong Date: Sun, 23 Aug 2020 10:19:09 +0800 Subject: [PATCH 05/52] Initial commit --- src/chordparser/utils/notecomparer.py | 150 ++++++++++++++++++++++++++ tests/test_utils/test_notecomparer.py | 43 ++++++++ 2 files changed, 193 insertions(+) create mode 100644 src/chordparser/utils/notecomparer.py create mode 100644 tests/test_utils/test_notecomparer.py diff --git a/src/chordparser/utils/notecomparer.py b/src/chordparser/utils/notecomparer.py new file mode 100644 index 0000000..c9f3f91 --- /dev/null +++ b/src/chordparser/utils/notecomparer.py @@ -0,0 +1,150 @@ +from chordparser.utils.note_lists import natural_notes + + +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_int()-prev_note.as_int()) % 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 = natural_notes.index(notes[0].letter) + for note in notes: + note_idx = natural_notes.index(note.letter) + 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/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]) + From adac79001d5887ae549e701b168816c5539c55e8 Mon Sep 17 00:00:00 2001 From: Titus Ong Date: Sun, 23 Aug 2020 10:19:41 +0800 Subject: [PATCH 06/52] Made examples naming clearer --- src/chordparser/music/letter.py | 12 ++++++------ src/chordparser/music/symbol.py | 7 +++---- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/src/chordparser/music/letter.py b/src/chordparser/music/letter.py index 4ddd994..8651d1c 100644 --- a/src/chordparser/music/letter.py +++ b/src/chordparser/music/letter.py @@ -39,12 +39,12 @@ def shift_by(self, shift): Examples -------- - >>> d = Letter("D") - >>> d.shift_by(3) - >>> d + >>> letter = Letter("D") + >>> letter.shift_by(3) + >>> letter G """ - position = natural_notes.index(self.data) - position = (position + shift) % 7 - self.data = natural_notes[position] + old_position = natural_notes.index(self.data) + new_position = (old_position + shift) % 7 + self.data = natural_notes[new_position] diff --git a/src/chordparser/music/symbol.py b/src/chordparser/music/symbol.py index ae0af86..fcf42fb 100644 --- a/src/chordparser/music/symbol.py +++ b/src/chordparser/music/symbol.py @@ -5,7 +5,6 @@ int_to_symbol) - class Symbol(UserString): """A class representing the symbol part of a `Note`. @@ -46,9 +45,9 @@ def shift_by(self, semitones): Examples -------- - >>> sharp = Symbol("#") - >>> sharp.shift_by(-2) - >>> sharp + >>> symbol = Symbol("#") + >>> symbol.shift_by(-2) + >>> symbol \u266D """ From e31a5d0b86a21360fe5549c45517cbd536ab19fe Mon Sep 17 00:00:00 2001 From: Titus Ong Date: Sun, 23 Aug 2020 10:19:58 +0800 Subject: [PATCH 07/52] Included transpose methods --- src/chordparser/music/note.py | 115 +++++++++++++++++++++++++++- src/chordparser/utils/note_lists.py | 15 ++++ tests/test_music/test_note.py | 54 ++++++++++++- 3 files changed, 182 insertions(+), 2 deletions(-) diff --git a/src/chordparser/music/note.py b/src/chordparser/music/note.py index e3b9a9a..74adfc9 100644 --- a/src/chordparser/music/note.py +++ b/src/chordparser/music/note.py @@ -2,10 +2,11 @@ from chordparser.music.letter import Letter from chordparser.music.symbol import Symbol +from chordparser.utils.converters import symbol_to_unicode +from chordparser.utils.note_lists import sharp_scale, flat_scale from chordparser.utils.regex_patterns import (note_pattern, sharp_pattern, flat_pattern) -from chordparser.utils.converters import symbol_to_unicode class NoteNotationParser: @@ -50,6 +51,9 @@ class Note: _NNP = NoteNotationParser() def __init__(self, notation): + self._set(notation) + + def _set(self, notation): letter, symbol = self._NNP.parse_notation(notation) self._letter = Letter(letter) self._symbol = Symbol(symbol) @@ -80,3 +84,112 @@ def as_int(self): """ return (self._letter.as_int() + self._symbol.as_int()) % 12 + + def transpose(self, semitones, letters): + """Transpose the `Note` by some semitone and letter intervals. + + Parameters + ---------- + semitones + The difference in semitones to the transposed `Note`. + letters + 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_int = self.as_int() + self._letter.shift_by(letters) + positive_int_diff = (self.as_int() - original_int) % 12 + positive_semitones = semitones % 12 + semitone_difference = positive_semitones - positive_int_diff + self._symbol.shift_by(semitone_difference) + + 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_int() + semitones) % 12] + self._set(note) + + def __repr__(self): + return f"{str(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/utils/note_lists.py b/src/chordparser/utils/note_lists.py index 3f37a86..43a225d 100644 --- a/src/chordparser/utils/note_lists.py +++ b/src/chordparser/utils/note_lists.py @@ -1,3 +1,6 @@ +from chordparser.utils.unicode_chars import sharp, flat + + natural_notes = ( "C", "D", "E", "F", "G", "A", "B", "C", "D", "E", "F", "G", "A", "B", @@ -6,3 +9,15 @@ 2, 2, 1, 2, 2, 2, 1, 2, 2, 1, 2, 2, 2, 1, ) +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", + ) diff --git a/tests/test_music/test_note.py b/tests/test_music/test_note.py index 918af33..20464b5 100644 --- a/tests/test_music/test_note.py +++ b/tests/test_music/test_note.py @@ -1,6 +1,7 @@ import pytest -from chordparser.utils.unicode_chars import sharp +from chordparser.utils.unicode_chars import (sharp, doublesharp, + flat, doubleflat) from chordparser.music.note import NoteNotationParser, Note @@ -59,3 +60,54 @@ class TestNoteAsInt: def test_correct_int(self, note, value): n = Note(note) assert value == n.as_int() + + +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 From 4361f6febb39b619ee0750aea8db92b0fd34b5db Mon Sep 17 00:00:00 2001 From: Titus Ong Date: Sun, 23 Aug 2020 12:39:20 +0800 Subject: [PATCH 08/52] Abstracted NotationParser class out into separate file --- src/chordparser/music/notationparser.py | 36 +++++++++++++++++++++++++ src/chordparser/music/note.py | 36 ++++--------------------- tests/test_music/test_notationparser.py | 10 +++++++ tests/test_music/test_note.py | 7 ----- 4 files changed, 51 insertions(+), 38 deletions(-) create mode 100644 src/chordparser/music/notationparser.py create mode 100644 tests/test_music/test_notationparser.py diff --git a/src/chordparser/music/notationparser.py b/src/chordparser/music/notationparser.py new file mode 100644 index 0000000..7705ce4 --- /dev/null +++ b/src/chordparser/music/notationparser.py @@ -0,0 +1,36 @@ +import re + + +class NotationParser: + """Abstract class for parsing notation.""" + _pattern = "" + + def parse_notation(self, notation): + regex = self._to_regex_object(notation) + print(self._pattern) + 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=re.UNICODE | re.IGNORECASE, + ) + return regex + + def _invalid_notation(self, regex): + return not regex + + def _split_into_groups(self, regex): + # To be implemented in concrete class + raise NotImplementedError + + def get_regex_pattern(self): + return self._pattern + + def get_regex_groups_count(self): + regex = re.compile(self._pattern) + return regex.groups diff --git a/src/chordparser/music/note.py b/src/chordparser/music/note.py index 74adfc9..f836dce 100644 --- a/src/chordparser/music/note.py +++ b/src/chordparser/music/note.py @@ -1,6 +1,5 @@ -import re - from chordparser.music.letter import Letter +from chordparser.music.notationparser import NotationParser from chordparser.music.symbol import Symbol from chordparser.utils.converters import symbol_to_unicode from chordparser.utils.note_lists import sharp_scale, flat_scale @@ -9,43 +8,18 @@ flat_pattern) -class NoteNotationParser: +class NoteNotationParser(NotationParser): """Parse note notation into letter and symbol groups.""" _pattern = ( - f"^({note_pattern})({flat_pattern}|{sharp_pattern}){{0,1}}$" + f"({note_pattern})({flat_pattern}|{sharp_pattern})?" ) - def parse_notation(self, notation): - """Parse the note string.""" - regex = self._to_regex_object(notation) - if self._invalid_notation(regex): - raise SyntaxError("invalid syntax") - letter, symbol = self._split_capital_letter_and_symbol(regex) - return letter, symbol - - def _to_regex_object(self, notation): - regex = re.match( - NoteNotationParser._pattern, - notation, - re.UNICODE - ) - return regex - - def _invalid_notation(self, regex): - return not regex - - def _split_capital_letter_and_symbol(self, regex): + def _split_into_groups(self, regex): + """Split into capitalised letter and symbol.""" uppercase_letter = regex.group(1).upper() symbol = symbol_to_unicode[regex.group(2)] return uppercase_letter, symbol - def get_regex_pattern(self): - return self._pattern - - def get_regex_groups_count(self): - regex = self._to_regex_object("C") - return len(regex.groups()) - class Note: _NNP = NoteNotationParser() diff --git a/tests/test_music/test_notationparser.py b/tests/test_music/test_notationparser.py new file mode 100644 index 0000000..dab23f0 --- /dev/null +++ b/tests/test_music/test_notationparser.py @@ -0,0 +1,10 @@ +import pytest + +from chordparser.music.notationparser import NotationParser + + +class TestNNPGetRegexGroupsCount: + def test_correct_number_of_groups(self): + parser = NotationParser() + parser._pattern = "(a)(b)" + assert 2 == parser.get_regex_groups_count() diff --git a/tests/test_music/test_note.py b/tests/test_music/test_note.py index 20464b5..76f655c 100644 --- a/tests/test_music/test_note.py +++ b/tests/test_music/test_note.py @@ -35,13 +35,6 @@ def test_syntax_error(self, parser, notation): parser.parse_notation(notation) -class TestNNPGetRegexGroupsCount: - def test_correct_number_of_groups(self): - parser = NoteNotationParser() - parser._pattern = "(a)(b)" - assert 2 == parser.get_regex_groups_count() - - class TestNote: def test_init(self): note = Note("C#") From 59c68e44af1d17b21c702acea9d0a74f1a02adae Mon Sep 17 00:00:00 2001 From: Titus Ong Date: Sun, 23 Aug 2020 12:39:35 +0800 Subject: [PATCH 09/52] Included mode patterns --- src/chordparser/utils/regex_patterns.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/chordparser/utils/regex_patterns.py b/src/chordparser/utils/regex_patterns.py index c1455e1..1747609 100644 --- a/src/chordparser/utils/regex_patterns.py +++ b/src/chordparser/utils/regex_patterns.py @@ -2,6 +2,13 @@ sharp, doublesharp) -note_pattern = "[a-gA-G]" +note_pattern = "[A-G]" flat_pattern = f"bb|b|{flat}|{doubleflat}" sharp_pattern = f"##|#|{sharp}|{doublesharp}" +submode_pattern = "natural|harmonic|melodic" +mode_pattern = ( + "major|minor|ionian|dorian|phrygian|" + "lydian|mixolydian|aeolian|locrian" +) +short_minor_pattern = "m" +short_major_pattern = "" From 468a248f23caa2303112332eed4b2e50cb2d5b58 Mon Sep 17 00:00:00 2001 From: Titus Ong Date: Sun, 23 Aug 2020 12:39:41 +0800 Subject: [PATCH 10/52] Initial commit --- src/chordparser/music/key.py | 64 ++++++++++++++++++++++++++++++++++++ tests/test_music/test_key.py | 37 +++++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 src/chordparser/music/key.py create mode 100644 tests/test_music/test_key.py diff --git a/src/chordparser/music/key.py b/src/chordparser/music/key.py new file mode 100644 index 0000000..cc72c66 --- /dev/null +++ b/src/chordparser/music/key.py @@ -0,0 +1,64 @@ +from chordparser.music.notationparser import NotationParser +from chordparser.music.note import NoteNotationParser +from chordparser.utils.regex_patterns import (submode_pattern, + mode_pattern, + short_minor_pattern, + short_major_pattern) + + +class ModeError(Exception): + """Exception where a `Key`'s `mode` is invalid.""" + pass + + +class ModeNotationParser(NotationParser): + """Parse mode notation into mode and submode.""" + _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), self._is_minor(mode) + ) + return mode, submode + + def _get_mode(self, long_mode, short_minor, short_major): + if short_major is not None: + return "major" + if short_minor: + return "minor" + return long_mode.lower() + + def _is_minor(self, mode): + return mode in ("minor", "aeolian") + + def _get_submode(self, submode, is_minor): + if submode and not is_minor: + raise ModeError("Only minor can have a submode") + if not is_minor: + return "" + if is_minor and not submode: + return "natural" + return submode.lower() + + +class KeyNotationParser: + """Parse key notation into tonic and mode groups.""" + _NNP = NoteNotationParser() + _MNP = ModeNotationParser() + _pattern = ( + f"({_NNP.get_regex_pattern})" + f"({_MNP.get_regex_pattern})" + ) + + +class Key: + def __init__(self, notation): + pass diff --git a/tests/test_music/test_key.py b/tests/test_music/test_key.py new file mode 100644 index 0000000..3b59a07 --- /dev/null +++ b/tests/test_music/test_key.py @@ -0,0 +1,37 @@ +import pytest + +from chordparser.music.key import (ModeError, ModeNotationParser, + KeyNotationParser, Key) + + +class TestMNPParseNotation: + @pytest.fixture + def parser(self): + return ModeNotationParser() + + @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", ""), + ] + ) + 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") From 8a6b46fddb552d4053aed0b72d5b0f986b17f778 Mon Sep 17 00:00:00 2001 From: Titus Ong Date: Sun, 23 Aug 2020 17:21:33 +0800 Subject: [PATCH 11/52] Rename group count method and change pattern to property --- src/chordparser/music/notationparser.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/chordparser/music/notationparser.py b/src/chordparser/music/notationparser.py index 7705ce4..3b4f739 100644 --- a/src/chordparser/music/notationparser.py +++ b/src/chordparser/music/notationparser.py @@ -7,7 +7,6 @@ class NotationParser: def parse_notation(self, notation): regex = self._to_regex_object(notation) - print(self._pattern) if self._invalid_notation(regex): raise SyntaxError("invalid syntax") args = self._split_into_groups(regex) @@ -28,9 +27,10 @@ def _split_into_groups(self, regex): # To be implemented in concrete class raise NotImplementedError - def get_regex_pattern(self): + @property + def pattern(self): return self._pattern - def get_regex_groups_count(self): + def get_num_groups(self): regex = re.compile(self._pattern) return regex.groups From 1e7911b72a6b791462470a6e66c7767aab2cf900 Mon Sep 17 00:00:00 2001 From: Titus Ong Date: Sun, 23 Aug 2020 17:21:51 +0800 Subject: [PATCH 12/52] Update test with method name change --- tests/test_music/test_notationparser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_music/test_notationparser.py b/tests/test_music/test_notationparser.py index dab23f0..304e408 100644 --- a/tests/test_music/test_notationparser.py +++ b/tests/test_music/test_notationparser.py @@ -7,4 +7,4 @@ class TestNNPGetRegexGroupsCount: def test_correct_number_of_groups(self): parser = NotationParser() parser._pattern = "(a)(b)" - assert 2 == parser.get_regex_groups_count() + assert 2 == parser.get_num_groups() From 5ce4875a7e63b34b91f57cc816b8d5f46f02698a Mon Sep 17 00:00:00 2001 From: Titus Ong Date: Sun, 23 Aug 2020 17:22:05 +0800 Subject: [PATCH 13/52] Included docstring for Note --- src/chordparser/music/note.py | 41 +++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/src/chordparser/music/note.py b/src/chordparser/music/note.py index f836dce..8825c3e 100644 --- a/src/chordparser/music/note.py +++ b/src/chordparser/music/note.py @@ -22,6 +22,43 @@ def _split_into_groups(self, regex): class Note: + """A class representing a musical note. + + The `Note` class composes of a `Letter` and `Symbol` object, + 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. Upon + creation, the symbols will be converted to unicode. + + 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. If there is no + symbol, this will be a Symbol of an empty string. + + 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): @@ -64,9 +101,9 @@ def transpose(self, semitones, letters): Parameters ---------- - semitones + semitones : int The difference in semitones to the transposed `Note`. - letters + letters : int The difference in scale degrees to the transposed `Note`. Examples From 174932c9e4891cf301b3794dd975cdb8ec403694 Mon Sep 17 00:00:00 2001 From: Titus Ong Date: Sun, 23 Aug 2020 17:22:33 +0800 Subject: [PATCH 14/52] Included Mode, Key, and their notation parsers --- src/chordparser/music/key.py | 418 +++++++++++++++++++++++++++- src/chordparser/utils/note_lists.py | 19 ++ tests/test_music/test_key.py | 181 +++++++++++- 3 files changed, 612 insertions(+), 6 deletions(-) diff --git a/src/chordparser/music/key.py b/src/chordparser/music/key.py index cc72c66..a8aebee 100644 --- a/src/chordparser/music/key.py +++ b/src/chordparser/music/key.py @@ -1,5 +1,9 @@ from chordparser.music.notationparser import NotationParser -from chordparser.music.note import NoteNotationParser +from chordparser.music.note import NoteNotationParser, Note +from chordparser.utils.note_lists import (harmonic_intervals, + melodic_intervals, + natural_semitone_intervals, + mode_order) from chordparser.utils.regex_patterns import (submode_pattern, mode_pattern, short_minor_pattern, @@ -8,11 +12,13 @@ class ModeError(Exception): """Exception where a `Key`'s `mode` is invalid.""" + pass class ModeNotationParser(NotationParser): """Parse mode notation into mode and submode.""" + _pattern = ( fr"(\s?({submode_pattern})?\s?({mode_pattern}))|" f"({short_minor_pattern})|" @@ -30,6 +36,7 @@ def _split_into_groups(self, regex): 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: @@ -49,16 +56,417 @@ def _get_submode(self, submode, is_minor): return submode.lower() -class KeyNotationParser: +class Mode: + """A class representing the mode of a key. + + The `Mode` class consists of a mode {major, minor, ionian, dorian, + phrygian, lydian, mixolydian, aeolian, locrian} and an optional + submode {natural, harmonic, melodic} (only applicable for minor + or aeolian mode). + + Attributes + ---------- + mode : str + The mode. + submode : str, Optional + The submode. It is an empty string for non-minor modes, and + defaults to 'natural' for minor modes. + + """ + + _MNP = ModeNotationParser() + + def __init__(self, notation): + mode, submode = self._MNP.parse_notation(notation) + self._mode = mode + self._submode = 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 `Mode`. + + Submode accidentals are accounted for (i.e. harmonic or + melodic). + + Returns + ------- + tuple of int + The semitone step pattern of the mode. + + Examples + -------- + >>> harm_minor = Mode("harmonic minor") + >>> harm_minor.get_step_pattern() + (2, 1, 2, 2, 1, 3, 1, 2, 1, 2, 2, 1, 3, 1) + + """ + mode_pattern = self._get_mode_pattern() + submode_pattern = self._get_submode_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 _get_mode_pattern(self): + starting_idx = mode_order[self._mode] + return ( + natural_semitone_intervals[starting_idx:] + + natural_semitone_intervals[:starting_idx] + ) + + def _get_submode_pattern(self): + if self._submode == "harmonic": + return harmonic_intervals + if self._submode == "melodic": + return melodic_intervals + return [0]*len(natural_semitone_intervals) + + def __repr__(self): + return f"{str(self)} mode" + + def __str__(self): + if self._submode: + return f"{self._submode} {self._mode}" + return self._mode + + def __eq__(self, other): + """Compare with other `Modes`. + + The two `Modes` 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 = Mode("harmonic minor") + >>> m2 = Mode("harmonic minor") + >>> m3 = Mode("minor") + >>> m == m2 + True + >>> m == m3 + False + + """ + if not isinstance(other, Mode): + return NotImplemented + return ( + self._mode == other.mode and + self._submode == other._submode + ) + + +class KeyNotationParser(NotationParser): """Parse key notation into tonic and mode groups.""" + _NNP = NoteNotationParser() _MNP = ModeNotationParser() _pattern = ( - f"({_NNP.get_regex_pattern})" - f"({_MNP.get_regex_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 + `Mode` 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_args(). + + 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 : Mode + The mode 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 + minor + >>> key.mode.submode + natural + + """ + + _KNP = KeyNotationParser() + def __init__(self, notation): - pass + tonic, mode = self._KNP.parse_notation(notation) + self._tonic = Note(tonic) + self._mode = Mode(mode) + + @classmethod + def from_args(cls, tonic, mode, submode=""): + """Create a `Key` from its tonic, mode and submode arguments. + + 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 "" 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_args("C", "major") + >>> key + C major key + + >>> key = Key.from_args(Note("D"), "minor", "harmonic") + >>> key + D harmonic minor key + + """ + # str() to avoid unwanted string from repr + notation = f"{str(tonic)} {str(submode)} {str(mode)}" + return cls(notation) + + @property + def tonic(self): + return self._tonic + + @property + def mode(self): + return self._mode + + def set_mode(self, mode=None, submode=None): + """Set the `Key`'s mode. + + The mode and submode arguments are optional. The mode will + default to the current `Key`'s mode, while the submode will + default to "natural" for minor and "" 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). + + Examples + -------- + >>> key = Key("C major") + >>> key.set_mode("minor", "melodic") + >>> key.mode + melodic minor mode + + """ + if mode is None: + mode = self._mode.mode + mode_notation = self._create_mode_notation(mode, submode) + self._mode = Mode(mode_notation) + + def _create_mode_notation(self, 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 {"minor", "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("E major") + >>> key.to_relative_minor("melodic") + C\u266f melodic minor + + """ + 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 {"major", "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"{str(self)} key" + + def __str__(self): + return f"{str(self._tonic)} {str(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/utils/note_lists.py b/src/chordparser/utils/note_lists.py index 43a225d..defec96 100644 --- a/src/chordparser/utils/note_lists.py +++ b/src/chordparser/utils/note_lists.py @@ -5,10 +5,29 @@ "C", "D", "E", "F", "G", "A", "B", "C", "D", "E", "F", "G", "A", "B", ) +mode_order = { + "major": 0, + "ionian": 0, + "dorian": 1, + "phrygian": 2, + "lydian": 3, + "mixolydian": 4, + "aeolian": 5, + "minor": 5, + "locrian": 6, +} natural_semitone_intervals = ( 2, 2, 1, 2, 2, 2, 1, 2, 2, 1, 2, 2, 2, 1, ) +harmonic_intervals = ( + 0, 0, 0, 0, 0, 1, -1, + 0, 0, 0, 0, 0, 1, -1, +) +melodic_intervals = ( + 0, 0, 0, 0, 1, 0, -1, + 0, 0, 0, 0, 1, 0, -1, +) sharp_scale = ( "C", f"C{sharp}", "D", f"D{sharp}", "E", "F", f"F{sharp}", "G", f"G{sharp}", "A", f"A{sharp}", "B", diff --git a/tests/test_music/test_key.py b/tests/test_music/test_key.py index 3b59a07..261b1aa 100644 --- a/tests/test_music/test_key.py +++ b/tests/test_music/test_key.py @@ -1,7 +1,10 @@ import pytest from chordparser.music.key import (ModeError, ModeNotationParser, - KeyNotationParser, Key) + KeyNotationParser, Mode, Key) +from chordparser.music.note import Note +from chordparser.utils.unicode_chars import (sharp, doublesharp, + flat, doubleflat) class TestMNPParseNotation: @@ -35,3 +38,179 @@ def test_correct_submode(self, parser, notation, expected_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 = Mode(mode) + assert steps == m.get_step_pattern() + + +class TestModeStr: + def test_correct_str(self): + m = Mode("harmonic minor") + assert "harmonic minor" == str(m) + + def test_correct_str_2(self): + m = Mode("major") + assert "major" == str(m) + + +class TestModeEquality: + def test_equal(self): + m = Mode("harmonic minor") + m2 = Mode("harmonic minor") + assert m2 == m + + @pytest.mark.parametrize( + "other", [Mode("minor"), "harmonic minor", len] + ) + def test_inequality(self, other): + c = Mode("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", "major", ""), + ("Db dorian", f"D{flat}", "dorian", ""), + ("F# minor", f"F{sharp}", "minor", "natural"), + ("G melodic minor", "G", "minor", "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 TestKeyFromArgs: + def test_key_creation(self): + key = Key.from_args("C", "major") + assert "C" == key.tonic + assert "major" == key.mode.mode + assert "" == key.mode.submode + + def test_key_creation_2(self): + key = Key.from_args(Note("D"), "minor", "harmonic") + assert "D" == key.tonic + assert "minor" == key.mode.mode + assert "harmonic" == key.mode.submode + + +class TestKeySetMode: + def test_with_mode_and_submode(self): + key = Key("C") + key.set_mode("minor", "melodic") + assert "minor" == key.mode.mode + assert "melodic" == key.mode.submode + + def test_without_mode(self): + key = Key("D minor") + key.set_mode(submode="harmonic") + assert "minor" == key.mode.mode + assert "harmonic" == key.mode.submode + + def test_without_submode(self): + key = Key("D melodic minor") + key.set_mode(mode="dorian") + assert "dorian" == key.mode.mode + assert "" == 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() + 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 From 38247d81c79d6d929bc11f4a80e69316f7dcd96b Mon Sep 17 00:00:00 2001 From: Titus Ong Date: Sun, 23 Aug 2020 21:12:53 +0800 Subject: [PATCH 15/52] Added check for valid symbol --- src/chordparser/music/symbol.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/chordparser/music/symbol.py b/src/chordparser/music/symbol.py index fcf42fb..07189cb 100644 --- a/src/chordparser/music/symbol.py +++ b/src/chordparser/music/symbol.py @@ -13,6 +13,8 @@ class Symbol(UserString): """ def __init__(self, data): + if data not in symbol_to_unicode.keys(): + raise ValueError(f"{data} is not a valid Symbol") self.data = symbol_to_unicode[data] def as_int(self): From f6a10ea61ce031e8cffede5dc1f6538e50622002 Mon Sep 17 00:00:00 2001 From: Titus Ong Date: Sun, 23 Aug 2020 21:13:08 +0800 Subject: [PATCH 16/52] Added symbol pattern --- src/chordparser/music/note.py | 5 ++--- src/chordparser/utils/regex_patterns.py | 2 ++ 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/chordparser/music/note.py b/src/chordparser/music/note.py index 8825c3e..0174fc2 100644 --- a/src/chordparser/music/note.py +++ b/src/chordparser/music/note.py @@ -4,14 +4,13 @@ from chordparser.utils.converters import symbol_to_unicode from chordparser.utils.note_lists import sharp_scale, flat_scale from chordparser.utils.regex_patterns import (note_pattern, - sharp_pattern, - flat_pattern) + symbol_pattern) class NoteNotationParser(NotationParser): """Parse note notation into letter and symbol groups.""" _pattern = ( - f"({note_pattern})({flat_pattern}|{sharp_pattern})?" + f"({note_pattern})({symbol_pattern})?" ) def _split_into_groups(self, regex): diff --git a/src/chordparser/utils/regex_patterns.py b/src/chordparser/utils/regex_patterns.py index 1747609..8c52282 100644 --- a/src/chordparser/utils/regex_patterns.py +++ b/src/chordparser/utils/regex_patterns.py @@ -5,6 +5,7 @@ note_pattern = "[A-G]" flat_pattern = f"bb|b|{flat}|{doubleflat}" sharp_pattern = f"##|#|{sharp}|{doublesharp}" +symbol_pattern = f"{flat_pattern}|{sharp_pattern}" submode_pattern = "natural|harmonic|melodic" mode_pattern = ( "major|minor|ionian|dorian|phrygian|" @@ -12,3 +13,4 @@ ) short_minor_pattern = "m" short_major_pattern = "" +degree_pattern = "[1-7]" From 94818199eaf8bc1f6265e7f3e856d5b909dacdee Mon Sep 17 00:00:00 2001 From: Titus Ong Date: Sun, 23 Aug 2020 21:13:13 +0800 Subject: [PATCH 17/52] Test for valid symbol --- tests/test_music/test_symbol.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/test_music/test_symbol.py b/tests/test_music/test_symbol.py index 780517f..a9dff5c 100644 --- a/tests/test_music/test_symbol.py +++ b/tests/test_music/test_symbol.py @@ -4,6 +4,12 @@ from chordparser.utils.unicode_chars import flat, doublesharp +class TestSymbol: + def test_wrong_symbol(self): + with pytest.raises(ValueError): + Symbol("###") + + class TestSymbolAsInt: @pytest.mark.parametrize( "symbol, value", [ From e6a51d027f93424f20fc4dd5a02d0fb7d240d271 Mon Sep 17 00:00:00 2001 From: Titus Ong Date: Sun, 23 Aug 2020 21:13:39 +0800 Subject: [PATCH 18/52] Initial commit --- src/chordparser/music/scaledegree.py | 109 +++++++++++++++++++++++++++ tests/test_music/test_scaledegree.py | 52 +++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 src/chordparser/music/scaledegree.py create mode 100644 tests/test_music/test_scaledegree.py diff --git a/src/chordparser/music/scaledegree.py b/src/chordparser/music/scaledegree.py new file mode 100644 index 0000000..6788117 --- /dev/null +++ b/src/chordparser/music/scaledegree.py @@ -0,0 +1,109 @@ +from chordparser.music.notationparser import NotationParser +from chordparser.music.symbol import Symbol +from chordparser.utils.regex_patterns import (symbol_pattern, + degree_pattern) + + +class ScaleDegreeNotationParser(NotationParser): + """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: + """A class representing a scale degree. + + The `ScaleDegree` consists of an integer (the degree) and a + Symbol. 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. Upon + creation, the symbol will be converted to unicode. + + Attributes + ---------- + degree : int + The degree of the `ScaleDegree`. + symbol : Symbol + The accidental of the `ScaleDegree`. + + Examples + -------- + >>> sd = ScaleDegree(1, "b") + >>> sd + \u266d1 scale degree + + """ + + _SDNP = ScaleDegreeNotationParser() + + def __init__(self, notation): + degree, symbol = self._SDNP.parse_notation(notation) + self._degree = int(degree) + self._symbol = Symbol(symbol) + + @property + def degree(self): + return self._degree + + @property + def symbol(self): + return self._symbol + + def __str__(self): + return f"{self._symbol}{self._degree}" + + def __repr__(self): + return f"{str(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/tests/test_music/test_scaledegree.py b/tests/test_music/test_scaledegree.py new file mode 100644 index 0000000..5fd673c --- /dev/null +++ b/tests/test_music/test_scaledegree.py @@ -0,0 +1,52 @@ +import pytest + +from chordparser.music.scaledegree import (ScaleDegree, + ScaleDegreeNotationParser) +from chordparser.music.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("b") == sd.symbol + assert 5 == sd.degree + + +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 From d024330a343df07bc12d51daebfa6169e4f174dc Mon Sep 17 00:00:00 2001 From: Titus Ong Date: Sun, 23 Aug 2020 21:14:00 +0800 Subject: [PATCH 19/52] Create static method for getting number of groups from regex pattern --- src/chordparser/music/notationparser.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/chordparser/music/notationparser.py b/src/chordparser/music/notationparser.py index 3b4f739..7103158 100644 --- a/src/chordparser/music/notationparser.py +++ b/src/chordparser/music/notationparser.py @@ -32,5 +32,9 @@ def pattern(self): return self._pattern def get_num_groups(self): - regex = re.compile(self._pattern) + return NotationParser.get_num_groups_from(self._pattern) + + @staticmethod + def get_num_groups_from(pattern): + regex = re.compile(pattern) return regex.groups From da4ac02a1923f54ca1480dc0522bba7ff700e741 Mon Sep 17 00:00:00 2001 From: Titus Ong Date: Sun, 23 Aug 2020 21:14:08 +0800 Subject: [PATCH 20/52] Minor ordering change --- src/chordparser/utils/note_lists.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/chordparser/utils/note_lists.py b/src/chordparser/utils/note_lists.py index defec96..4c8feb7 100644 --- a/src/chordparser/utils/note_lists.py +++ b/src/chordparser/utils/note_lists.py @@ -5,6 +5,18 @@ "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", + ) mode_order = { "major": 0, "ionian": 0, @@ -28,15 +40,3 @@ 0, 0, 0, 0, 1, 0, -1, 0, 0, 0, 0, 1, 0, -1, ) -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", - ) From 7107fdd7e62cf2122c22cf2038568a795ff23e36 Mon Sep 17 00:00:00 2001 From: Titus Ong Date: Sun, 23 Aug 2020 22:06:45 +0800 Subject: [PATCH 21/52] Added from_components class method --- src/chordparser/music/scaledegree.py | 9 ++++++++- tests/test_music/test_key.py | 6 +++--- tests/test_music/test_scaledegree.py | 7 +++++++ 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/chordparser/music/scaledegree.py b/src/chordparser/music/scaledegree.py index 6788117..c6dd8f7 100644 --- a/src/chordparser/music/scaledegree.py +++ b/src/chordparser/music/scaledegree.py @@ -42,6 +42,9 @@ class ScaleDegree: 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(symbol) @@ -53,11 +56,15 @@ def degree(self): def symbol(self): return self._symbol + @classmethod + def from_components(cls, degree, symbol): + return cls(f"{symbol}{degree}") + def __str__(self): return f"{self._symbol}{self._degree}" def __repr__(self): - return f"{str(self)} scale degree" + return f"{self} scale degree" def __eq__(self, other): """Compare with other `ScaleDegrees` or strings. diff --git a/tests/test_music/test_key.py b/tests/test_music/test_key.py index 261b1aa..9c5b107 100644 --- a/tests/test_music/test_key.py +++ b/tests/test_music/test_key.py @@ -121,15 +121,15 @@ def test_key_creation(self, notation, tonic, mode, submode): assert submode == key.mode.submode -class TestKeyFromArgs: +class TestKeyFromComps: def test_key_creation(self): - key = Key.from_args("C", "major") + key = Key.from_components("C", "major") assert "C" == key.tonic assert "major" == key.mode.mode assert "" == key.mode.submode def test_key_creation_2(self): - key = Key.from_args(Note("D"), "minor", "harmonic") + key = Key.from_components(Note("D"), "minor", "harmonic") assert "D" == key.tonic assert "minor" == key.mode.mode assert "harmonic" == key.mode.submode diff --git a/tests/test_music/test_scaledegree.py b/tests/test_music/test_scaledegree.py index 5fd673c..b1071f3 100644 --- a/tests/test_music/test_scaledegree.py +++ b/tests/test_music/test_scaledegree.py @@ -32,6 +32,13 @@ def test_creation(self): assert 5 == sd.degree +class TestScaleDegreeFromComps: + def test_creation(self): + sd = ScaleDegree.from_components(5, "b") + sd2 = ScaleDegree.from_components(5, Symbol("b")) + assert sd == sd2 + + class TestScaleDegreeEquality: def test_equality_sd(self): sd = ScaleDegree("b5") From b03507842c250f48181a7fa7bb08abe6d37b3720 Mon Sep 17 00:00:00 2001 From: Titus Ong Date: Sun, 23 Aug 2020 22:07:02 +0800 Subject: [PATCH 22/52] Minor name changes --- src/chordparser/music/key.py | 16 ++++++++-------- src/chordparser/music/note.py | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/chordparser/music/key.py b/src/chordparser/music/key.py index a8aebee..42c33d6 100644 --- a/src/chordparser/music/key.py +++ b/src/chordparser/music/key.py @@ -131,7 +131,7 @@ def _get_submode_pattern(self): return [0]*len(natural_semitone_intervals) def __repr__(self): - return f"{str(self)} mode" + return f"{self} mode" def __str__(self): if self._submode: @@ -239,8 +239,8 @@ def __init__(self, notation): self._mode = Mode(mode) @classmethod - def from_args(cls, tonic, mode, submode=""): - """Create a `Key` from its tonic, mode and submode arguments. + def from_components(cls, tonic, mode, submode=""): + """Create a `Key` from its tonic, mode and submode components. Parameters ---------- @@ -262,17 +262,17 @@ def from_args(cls, tonic, mode, submode=""): Examples -------- - >>> key = Key.from_args("C", "major") + >>> key = Key.from_components("C", "major") >>> key C major key - >>> key = Key.from_args(Note("D"), "minor", "harmonic") + >>> key = Key.from_components(Note("D"), "minor", "harmonic") >>> key D harmonic minor key """ # str() to avoid unwanted string from repr - notation = f"{str(tonic)} {str(submode)} {str(mode)}" + notation = f"{tonic} {submode} {mode}" return cls(notation) @property @@ -433,10 +433,10 @@ def transpose_simple(self, semitones, use_flats=False): self._tonic.transpose_simple(semitones, use_flats) def __repr__(self): - return f"{str(self)} key" + return f"{self} key" def __str__(self): - return f"{str(self._tonic)} {str(self._mode)}" + return f"{self._tonic} {self._mode}" def __eq__(self, other): """Compare with other `Keys`. diff --git a/src/chordparser/music/note.py b/src/chordparser/music/note.py index 0174fc2..961dd89 100644 --- a/src/chordparser/music/note.py +++ b/src/chordparser/music/note.py @@ -151,7 +151,7 @@ def transpose_simple(self, semitones, use_flats=False): self._set(note) def __repr__(self): - return f"{str(self)} note" + return f"{self} note" def __str__(self): return f"{self._letter}{self._symbol}" From 1fb661e5af3cb8bd6bd115fa07b1cd8895c37fb3 Mon Sep 17 00:00:00 2001 From: Titus Ong Date: Sat, 29 Aug 2020 09:12:03 +0800 Subject: [PATCH 23/52] Added check for set_mode to have at least one argument --- src/chordparser/music/key.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/chordparser/music/key.py b/src/chordparser/music/key.py index 42c33d6..ffbebff 100644 --- a/src/chordparser/music/key.py +++ b/src/chordparser/music/key.py @@ -304,6 +304,8 @@ def set_mode(self, mode=None, submode=None): ModeError If the submode does not match the mode (e.g. harmonic major). + TypeError + If neither the mode nor submode are specified. Examples -------- @@ -313,6 +315,8 @@ def set_mode(self, mode=None, submode=None): melodic minor mode """ + if mode is None and submode is None: + raise TypeError("At least one argument must be specified") if mode is None: mode = self._mode.mode mode_notation = self._create_mode_notation(mode, submode) From 3b1729c84e3666efcbac2bcc308f08622a821f20 Mon Sep 17 00:00:00 2001 From: Titus Ong Date: Sat, 29 Aug 2020 09:20:40 +0800 Subject: [PATCH 24/52] Minor formatting changes to error messages --- src/chordparser/music/key.py | 28 +++++++++++++------------ src/chordparser/music/notationparser.py | 2 +- src/chordparser/music/note.py | 4 ++-- src/chordparser/music/scaledegree.py | 4 ++-- src/chordparser/music/symbol.py | 2 +- 5 files changed, 21 insertions(+), 19 deletions(-) diff --git a/src/chordparser/music/key.py b/src/chordparser/music/key.py index ffbebff..9a0a409 100644 --- a/src/chordparser/music/key.py +++ b/src/chordparser/music/key.py @@ -1,4 +1,4 @@ -from chordparser.music.notationparser import NotationParser +from chordparser.music.notationparser import NotationParserTemplate from chordparser.music.note import NoteNotationParser, Note from chordparser.utils.note_lists import (harmonic_intervals, melodic_intervals, @@ -16,7 +16,7 @@ class ModeError(Exception): pass -class ModeNotationParser(NotationParser): +class ModeNotationParser(NotationParserTemplate): """Parse mode notation into mode and submode.""" _pattern = ( @@ -31,7 +31,7 @@ def _split_into_groups(self, regex): regex.group(3), regex.group(4), regex.group(5) ) submode = self._get_submode( - regex.group(2), self._is_minor(mode) + regex.group(2), mode ) return mode, submode @@ -43,18 +43,18 @@ def _get_mode(self, long_mode, short_minor, short_major): return "minor" return long_mode.lower() - def _is_minor(self, mode): - return mode in ("minor", "aeolian") - - def _get_submode(self, submode, is_minor): - if submode and not is_minor: - raise ModeError("Only minor can have a submode") + def _get_submode(self, submode, mode): + if submode and not self._is_minor(mode): + raise ModeError(f"'{mode}' does not have a submode") if not is_minor: return "" if is_minor and not submode: return "natural" return submode.lower() + def _is_minor(self, mode): + return mode in ("minor", "aeolian") + class Mode: """A class representing the mode of a key. @@ -172,7 +172,7 @@ def __eq__(self, other): ) -class KeyNotationParser(NotationParser): +class KeyNotationParser(NotationParserTemplate): """Parse key notation into tonic and mode groups.""" _NNP = NoteNotationParser() @@ -316,7 +316,9 @@ def set_mode(self, mode=None, submode=None): """ if mode is None and submode is None: - raise TypeError("At least one argument must be specified") + raise TypeError( + "At least one argument must be specified (0 given)" + ) if mode is None: mode = self._mode.mode mode_notation = self._create_mode_notation(mode, submode) @@ -346,7 +348,7 @@ def to_relative_major(self): """ if not self._is_minor(): - raise ModeError(f"{self} is not minor") + raise ModeError(f"'{self}' is not minor") self.transpose(3, 2) self.set_mode("major") @@ -384,7 +386,7 @@ def to_relative_minor(self, submode="natural"): """ if not self._is_major(): - raise ModeError(f"{self} is not major") + raise ModeError(f"'{self}' is not major") self.transpose(-3, -2) self.set_mode("minor", submode) diff --git a/src/chordparser/music/notationparser.py b/src/chordparser/music/notationparser.py index 7103158..d0b3ad7 100644 --- a/src/chordparser/music/notationparser.py +++ b/src/chordparser/music/notationparser.py @@ -1,7 +1,7 @@ import re -class NotationParser: +class NotationParserTemplate: """Abstract class for parsing notation.""" _pattern = "" diff --git a/src/chordparser/music/note.py b/src/chordparser/music/note.py index 961dd89..8bc32ec 100644 --- a/src/chordparser/music/note.py +++ b/src/chordparser/music/note.py @@ -1,5 +1,5 @@ from chordparser.music.letter import Letter -from chordparser.music.notationparser import NotationParser +from chordparser.music.notationparser import NotationParserTemplate from chordparser.music.symbol import Symbol from chordparser.utils.converters import symbol_to_unicode from chordparser.utils.note_lists import sharp_scale, flat_scale @@ -7,7 +7,7 @@ symbol_pattern) -class NoteNotationParser(NotationParser): +class NoteNotationParser(NotationParserTemplate): """Parse note notation into letter and symbol groups.""" _pattern = ( f"({note_pattern})({symbol_pattern})?" diff --git a/src/chordparser/music/scaledegree.py b/src/chordparser/music/scaledegree.py index c6dd8f7..bf93d3d 100644 --- a/src/chordparser/music/scaledegree.py +++ b/src/chordparser/music/scaledegree.py @@ -1,10 +1,10 @@ -from chordparser.music.notationparser import NotationParser +from chordparser.music.notationparser import NotationParserTemplate from chordparser.music.symbol import Symbol from chordparser.utils.regex_patterns import (symbol_pattern, degree_pattern) -class ScaleDegreeNotationParser(NotationParser): +class ScaleDegreeNotationParser(NotationParserTemplate): """Parse scale degree notation into degree and symbol.""" _pattern = f"({symbol_pattern})?({degree_pattern})" diff --git a/src/chordparser/music/symbol.py b/src/chordparser/music/symbol.py index 07189cb..78b0396 100644 --- a/src/chordparser/music/symbol.py +++ b/src/chordparser/music/symbol.py @@ -14,7 +14,7 @@ class Symbol(UserString): """ def __init__(self, data): if data not in symbol_to_unicode.keys(): - raise ValueError(f"{data} is not a valid Symbol") + raise ValueError(f"'{data}' is not a valid Symbol") self.data = symbol_to_unicode[data] def as_int(self): From def367501cafe8b5bd5b69cd1b43ef4169fa1ea1 Mon Sep 17 00:00:00 2001 From: Titus Ong Date: Sat, 29 Aug 2020 11:14:01 +0800 Subject: [PATCH 25/52] Fix some references --- src/chordparser/music/notationparser.py | 2 +- tests/test_music/test_notationparser.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/chordparser/music/notationparser.py b/src/chordparser/music/notationparser.py index d0b3ad7..9e66825 100644 --- a/src/chordparser/music/notationparser.py +++ b/src/chordparser/music/notationparser.py @@ -32,7 +32,7 @@ def pattern(self): return self._pattern def get_num_groups(self): - return NotationParser.get_num_groups_from(self._pattern) + return NotationParserTemplate.get_num_groups_from(self._pattern) @staticmethod def get_num_groups_from(pattern): diff --git a/tests/test_music/test_notationparser.py b/tests/test_music/test_notationparser.py index 304e408..81cc399 100644 --- a/tests/test_music/test_notationparser.py +++ b/tests/test_music/test_notationparser.py @@ -1,10 +1,10 @@ import pytest -from chordparser.music.notationparser import NotationParser +from chordparser.music.notationparser import NotationParserTemplate class TestNNPGetRegexGroupsCount: def test_correct_number_of_groups(self): - parser = NotationParser() + parser = NotationParserTemplate() parser._pattern = "(a)(b)" assert 2 == parser.get_num_groups() From ead0be24fbbd6fc0d821737864c85ef659461030 Mon Sep 17 00:00:00 2001 From: Titus Ong Date: Sat, 29 Aug 2020 11:14:13 +0800 Subject: [PATCH 26/52] Updated docs --- src/chordparser/music/scaledegree.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/chordparser/music/scaledegree.py b/src/chordparser/music/scaledegree.py index bf93d3d..314f8ff 100644 --- a/src/chordparser/music/scaledegree.py +++ b/src/chordparser/music/scaledegree.py @@ -23,6 +23,11 @@ class ScaleDegree: characters \u266d, \u266f, \U0001D12B, or \U0001D12A. Upon creation, the symbol will be converted to unicode. + Parameters + ---------- + notation: str + The string notation of the `ScaleDegree`. + Attributes ---------- degree : int From cf926484f642e7613d080ca0a7dce406ed1a446d Mon Sep 17 00:00:00 2001 From: Titus Ong Date: Sat, 29 Aug 2020 11:14:22 +0800 Subject: [PATCH 27/52] Fix method references --- src/chordparser/music/key.py | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/chordparser/music/key.py b/src/chordparser/music/key.py index 9a0a409..46dafad 100644 --- a/src/chordparser/music/key.py +++ b/src/chordparser/music/key.py @@ -44,7 +44,8 @@ def _get_mode(self, long_mode, short_minor, short_major): return long_mode.lower() def _get_submode(self, submode, mode): - if submode and not self._is_minor(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 "" @@ -271,7 +272,6 @@ def from_components(cls, tonic, mode, submode=""): D harmonic minor key """ - # str() to avoid unwanted string from repr notation = f"{tonic} {submode} {mode}" return cls(notation) @@ -283,6 +283,26 @@ def tonic(self): 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. From 9e22023bd74820fe644733ab3d5a7f831e7e10fa Mon Sep 17 00:00:00 2001 From: Titus Ong Date: Sat, 29 Aug 2020 11:14:27 +0800 Subject: [PATCH 28/52] Initial commit for scales --- src/chordparser/music/scale.py | 251 +++++++++++++++++++++++++++++++++ tests/test_music/test_scale.py | 113 +++++++++++++++ 2 files changed, 364 insertions(+) create mode 100644 src/chordparser/music/scale.py create mode 100644 tests/test_music/test_scale.py diff --git a/src/chordparser/music/scale.py b/src/chordparser/music/scale.py new file mode 100644 index 0000000..74a9e5b --- /dev/null +++ b/src/chordparser/music/scale.py @@ -0,0 +1,251 @@ +from chordparser.music.key import Key +from chordparser.music.note import Note +from chordparser.music.scaledegree import ScaleDegree +from chordparser.music.symbol import Symbol +from chordparser.utils.note_lists import natural_semitone_intervals + + +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): + prev_degree = scale_degrees[-1] + new_degree = prev_degree.degree % 7 + 1 + step_difference = self._get_step_diff_from_major(new_degree, step) + new_symbol = Symbol(str(prev_degree.symbol)) # avoid same reference + new_symbol.shift_by(step_difference) + scale_degrees.append( + ScaleDegree.from_components(new_degree, new_symbol) + ) + + def _get_step_diff_from_major(self, new_degree, step): + step_reference = natural_semitone_intervals[new_degree-2] + return step - step_reference + + 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 + + """ + major_scale = self._get_major_scale_of_self() + note = major_scale.get_notes()[scale_degree.degree-1] + note.symbol.shift_by(scale_degree.symbol.as_int()) + return note + + 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_scale = self._get_major_scale_of_self() + degree = min([ + idx for (idx, n) in enumerate(major_scale.get_notes()) + if n.letter == note.letter + ]) + 1 + symbol = Symbol(str(note.symbol)) + new_sd = ScaleDegree.from_components(degree, symbol) + return new_sd + + def _get_major_scale_of_self(self): + new_scale = Scale(self._key) + new_scale.key.set_mode(mode="major") + return new_scale + + 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/tests/test_music/test_scale.py b/tests/test_music/test_scale.py new file mode 100644 index 0000000..6e553a1 --- /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("2"), Note("F")) + ] + ) + 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("2"), Note("F")) + ] + ) + 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")) From cd056ef0e66c1b5cb11e06edb699f216b4cc1a85 Mon Sep 17 00:00:00 2001 From: Titus Ong Date: Sun, 30 Aug 2020 17:08:01 +0800 Subject: [PATCH 29/52] Change submode and mode to enums for easier comparison --- src/chordparser/music/key.py | 204 ++++++++++++++++++++-------- src/chordparser/utils/note_lists.py | 19 --- tests/test_music/test_key.py | 62 +++++---- 3 files changed, 185 insertions(+), 100 deletions(-) diff --git a/src/chordparser/music/key.py b/src/chordparser/music/key.py index 46dafad..4e6884a 100644 --- a/src/chordparser/music/key.py +++ b/src/chordparser/music/key.py @@ -1,9 +1,7 @@ +from enum import Enum, auto + from chordparser.music.notationparser import NotationParserTemplate from chordparser.music.note import NoteNotationParser, Note -from chordparser.utils.note_lists import (harmonic_intervals, - melodic_intervals, - natural_semitone_intervals, - mode_order) from chordparser.utils.regex_patterns import (submode_pattern, mode_pattern, short_minor_pattern, @@ -12,11 +10,94 @@ class ModeError(Exception): """Exception where a `Key`'s `mode` is invalid.""" - pass -class ModeNotationParser(NotationParserTemplate): +class Mode(Enum): + # Values correspond to the step intervals + 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, + ) + + def __str__(self): + return f"{self.name.lower()}" + + def __repr__(self): + return f"Mode.{self.name}" + + +class Submode(Enum): + # The boolean corresponds to whether the submode is minor and the + # tuple corresponds to changes in step intervals + # 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, + ), + ) + + def __str__(self): + if self is Submode.NONE: + return "" + return f"{self.name.lower()}" + + def __repr__(self): + return f"Submode.{self.name}" + + +class ModeGroupNotationParser(NotationParserTemplate): """Parse mode notation into mode and submode.""" _pattern = ( @@ -48,39 +129,55 @@ def _get_submode(self, submode, mode): if submode and not is_minor: raise ModeError(f"'{mode}' does not have a submode") if not is_minor: - return "" + return "none" if is_minor and not submode: return "natural" return submode.lower() def _is_minor(self, mode): - return mode in ("minor", "aeolian") + return mode in {"minor", "aeolian"} -class Mode: +class ModeGroup: """A class representing the mode of a key. - The `Mode` class consists of a mode {major, minor, ionian, dorian, - phrygian, lydian, mixolydian, aeolian, locrian} and an optional - submode {natural, harmonic, melodic} (only applicable for minor - or aeolian mode). + The `ModeGroup` class consists of a mode enum {MAJOR, MINOR, IONIAN, + DORIAN, PHRYGIAN, LYDIAN, MIXOLYDIAN, AEOLIAN, LOCRIAN} and an + optional submode enum {NATURAL, HARMONIC, MELODIC, NONE}. Attributes ---------- - mode : str + mode : Mode The mode. - submode : str, Optional - The submode. It is an empty string for non-minor modes, and - defaults to 'natural' for minor modes. + submode : Submode + The submode. It defaults to NONE for non-minor modes, and + NATURAL for minor modes if the submode is not specified. """ - _MNP = ModeNotationParser() + _MNP = ModeGroupNotationParser() + _mode_enum_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_enum_converter = { + "natural": Submode.NATURAL, + "harmonic": Submode.HARMONIC, + "melodic": Submode.MELODIC, + "none": Submode.NONE, + } def __init__(self, notation): mode, submode = self._MNP.parse_notation(notation) - self._mode = mode - self._submode = submode + self._mode = ModeGroup._mode_enum_converter[mode] + self._submode = ModeGroup._submode_enum_converter[submode] @property def mode(self): @@ -91,7 +188,7 @@ def submode(self): return self._submode def get_step_pattern(self): - """Return the semitone step pattern of the `Mode`. + """Return the semitone step pattern of the `ModeGroup`. Submode accidentals are accounted for (i.e. harmonic or melodic). @@ -103,13 +200,13 @@ def get_step_pattern(self): Examples -------- - >>> harm_minor = Mode("harmonic minor") + >>> 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._get_mode_pattern() - submode_pattern = self._get_submode_pattern() + mode_pattern = self._mode.value + submode_pattern = self._submode.value[1] return self._combine_patterns(mode_pattern, submode_pattern) def _combine_patterns(self, mode_pattern, submode_pattern): @@ -117,32 +214,19 @@ def _combine_patterns(self, mode_pattern, submode_pattern): sum(x) for x in zip(mode_pattern, submode_pattern) ) - def _get_mode_pattern(self): - starting_idx = mode_order[self._mode] - return ( - natural_semitone_intervals[starting_idx:] - + natural_semitone_intervals[:starting_idx] - ) - - def _get_submode_pattern(self): - if self._submode == "harmonic": - return harmonic_intervals - if self._submode == "melodic": - return melodic_intervals - return [0]*len(natural_semitone_intervals) - def __repr__(self): - return f"{self} mode" + return f"{self} ModeGroup" def __str__(self): - if self._submode: + if self._submode is not Submode.NONE: return f"{self._submode} {self._mode}" - return self._mode + return str(self._mode) def __eq__(self, other): - """Compare with other `Modes`. + """Compare with other `ModeGroups`. - The two `Modes` must have the same mode and submode to be equal. + The two `ModeGroups` must have the same mode and submode to be + equal. Parameters ---------- @@ -156,16 +240,24 @@ def __eq__(self, other): Examples -------- - >>> m = Mode("harmonic minor") - >>> m2 = Mode("harmonic minor") - >>> m3 = Mode("minor") + >>> 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, Mode): + if not isinstance(other, ModeGroup): return NotImplemented return ( self._mode == other.mode and @@ -177,7 +269,7 @@ class KeyNotationParser(NotationParserTemplate): """Parse key notation into tonic and mode groups.""" _NNP = NoteNotationParser() - _MNP = ModeNotationParser() + _MNP = ModeGroupNotationParser() _pattern = ( f"({_NNP.pattern})" f"({_MNP.pattern})" @@ -194,8 +286,8 @@ class Key: """A class representing a musical key. The `Key` class composes of a `Note` object as its `tonic` and a - `Mode` object with the attributes `mode` and `submode`. It can be - created from its string notation or by specifying its tonic, mode + `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_args(). Parameters @@ -210,8 +302,8 @@ class Key: ---------- tonic : Note The tonic of the `Key`. - mode : Mode - The mode of the `Key`. + mode : ModeGroup + The mode and submode of the `Key`. Raises ------ @@ -237,7 +329,7 @@ class Key: def __init__(self, notation): tonic, mode = self._KNP.parse_notation(notation) self._tonic = Note(tonic) - self._mode = Mode(mode) + self._mode = ModeGroup(mode) @classmethod def from_components(cls, tonic, mode, submode=""): @@ -342,7 +434,7 @@ def set_mode(self, mode=None, submode=None): if mode is None: mode = self._mode.mode mode_notation = self._create_mode_notation(mode, submode) - self._mode = Mode(mode_notation) + self._mode = ModeGroup(mode_notation) def _create_mode_notation(self, mode, submode): if submode: @@ -373,7 +465,7 @@ def to_relative_major(self): self.set_mode("major") def _is_minor(self): - return self.mode.mode in {"minor", "aeolian"} + return self.mode.mode in {Mode.MINOR, Mode.AEOLIAN} def to_relative_minor(self, submode="natural"): """Change the `Key` to its relative minor. @@ -411,7 +503,7 @@ def to_relative_minor(self, submode="natural"): self.set_mode("minor", submode) def _is_major(self): - return self.mode.mode in {"major", "ionian"} + return self.mode.mode in {Mode.MAJOR, Mode.IONIAN} def transpose(self, semitones, letters): """Transpose the `Key` by some semitone and letter intervals. diff --git a/src/chordparser/utils/note_lists.py b/src/chordparser/utils/note_lists.py index 4c8feb7..e33bffc 100644 --- a/src/chordparser/utils/note_lists.py +++ b/src/chordparser/utils/note_lists.py @@ -17,26 +17,7 @@ "C", f"D{flat}", "D", f"E{flat}", "E", "F", f"G{flat}", "G", f"A{flat}", "A", f"B{flat}", "B", ) -mode_order = { - "major": 0, - "ionian": 0, - "dorian": 1, - "phrygian": 2, - "lydian": 3, - "mixolydian": 4, - "aeolian": 5, - "minor": 5, - "locrian": 6, -} natural_semitone_intervals = ( 2, 2, 1, 2, 2, 2, 1, 2, 2, 1, 2, 2, 2, 1, ) -harmonic_intervals = ( - 0, 0, 0, 0, 0, 1, -1, - 0, 0, 0, 0, 0, 1, -1, -) -melodic_intervals = ( - 0, 0, 0, 0, 1, 0, -1, - 0, 0, 0, 0, 1, 0, -1, -) diff --git a/tests/test_music/test_key.py b/tests/test_music/test_key.py index 9c5b107..94c16d9 100644 --- a/tests/test_music/test_key.py +++ b/tests/test_music/test_key.py @@ -1,7 +1,8 @@ import pytest -from chordparser.music.key import (ModeError, ModeNotationParser, - KeyNotationParser, Mode, Key) +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) @@ -10,7 +11,7 @@ class TestMNPParseNotation: @pytest.fixture def parser(self): - return ModeNotationParser() + return ModeGroupNotationParser() @pytest.mark.parametrize( "notation, expected_mode", [ @@ -28,7 +29,7 @@ def test_correct_mode(self, parser, notation, expected_mode): "notation, expected_submode", [ (" minor", "natural"), ("harmonic minor", "harmonic"), - (" major", ""), + (" major", "none"), ] ) def test_correct_submode(self, parser, notation, expected_submode): @@ -58,31 +59,41 @@ class TestModeStepPattern: ] ) def test_correct_step_pattern(self, mode, steps): - m = Mode(mode) + m = ModeGroup(mode) assert steps == m.get_step_pattern() class TestModeStr: def test_correct_str(self): - m = Mode("harmonic minor") + m = ModeGroup("harmonic minor") assert "harmonic minor" == str(m) def test_correct_str_2(self): - m = Mode("major") + m = ModeGroup("major") assert "major" == str(m) class TestModeEquality: def test_equal(self): - m = Mode("harmonic minor") - m2 = Mode("harmonic minor") + 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", [Mode("minor"), "harmonic minor", len] + "other", [ModeGroup("minor"), "harmonic minor", len] ) def test_inequality(self, other): - c = Mode("harmonic minor") + c = ModeGroup("harmonic minor") assert other != c @@ -108,10 +119,10 @@ def test_correct_groups(self, parser, notation, tonic, mode): class TestKey: @pytest.mark.parametrize( "notation, tonic, mode, submode", [ - ("B", "B", "major", ""), - ("Db dorian", f"D{flat}", "dorian", ""), - ("F# minor", f"F{sharp}", "minor", "natural"), - ("G melodic minor", "G", "minor", "melodic"), + ("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): @@ -125,34 +136,34 @@ class TestKeyFromComps: def test_key_creation(self): key = Key.from_components("C", "major") assert "C" == key.tonic - assert "major" == key.mode.mode - assert "" == key.mode.submode + 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 "minor" == key.mode.mode - assert "harmonic" == key.mode.submode + 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 "minor" == key.mode.mode - assert "melodic" == key.mode.submode + 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 "minor" == key.mode.mode - assert "harmonic" == key.mode.submode + 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 "dorian" == key.mode.mode - assert "" == key.mode.submode + assert Mode.DORIAN == key.mode.mode + assert Submode.NONE == key.mode.submode class TestKeyRelativeMajor: @@ -171,6 +182,7 @@ 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): From 1b1bb2e20da1bbf65e4bc22de0eb37d48bcd87b7 Mon Sep 17 00:00:00 2001 From: Titus Ong Date: Sun, 30 Aug 2020 17:35:19 +0800 Subject: [PATCH 30/52] Minor changes to repr to be more in line with Python's standard repr --- src/chordparser/music/key.py | 58 +++++++++++++++------------- src/chordparser/music/note.py | 12 +++--- src/chordparser/music/scale.py | 30 +++++++------- src/chordparser/music/scaledegree.py | 4 +- tests/test_music/test_scale.py | 6 +-- 5 files changed, 57 insertions(+), 53 deletions(-) diff --git a/src/chordparser/music/key.py b/src/chordparser/music/key.py index 4e6884a..8527c08 100644 --- a/src/chordparser/music/key.py +++ b/src/chordparser/music/key.py @@ -142,8 +142,8 @@ 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 an - optional submode enum {NATURAL, HARMONIC, MELODIC, NONE}. + DORIAN, PHRYGIAN, LYDIAN, MIXOLYDIAN, AEOLIAN, LOCRIAN} and a + submode enum {NATURAL, HARMONIC, MELODIC, NONE}. Attributes ---------- @@ -287,8 +287,8 @@ class 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_args(). + be created from its string notation or by specifying its tonic, + mode and submode using the class method Key.from_components(). Parameters ---------- @@ -316,11 +316,11 @@ class Key: -------- >>> key = Key("C# minor") >>> key.tonic - C\u266f note + C\u266f Note >>> key.mode.mode - minor + Mode.MINOR >>> key.mode.submode - natural + Submode.NATURAL """ @@ -332,7 +332,7 @@ def __init__(self, notation): self._mode = ModeGroup(mode) @classmethod - def from_components(cls, tonic, mode, submode=""): + def from_components(cls, tonic, mode, submode=None): """Create a `Key` from its tonic, mode and submode components. Parameters @@ -342,8 +342,8 @@ def from_components(cls, tonic, mode, submode=""): mode : str The mode of the `Key`. submode : str, Optional - The submode of the `Key`. Defaults to "" for non-minor and - 'natural' for minor modes. + The submode of the `Key`. Defaults to NONE for non-minor + and NATURAL for minor modes. Raises ------ @@ -357,14 +357,15 @@ def from_components(cls, tonic, mode, submode=""): -------- >>> key = Key.from_components("C", "major") >>> key - C major key + C major Key >>> key = Key.from_components(Note("D"), "minor", "harmonic") >>> key - D harmonic minor key + D harmonic minor Key """ - notation = f"{tonic} {submode} {mode}" + mode_notation = Key._create_mode_notation(mode, submode) + notation = f"{tonic} {mode_notation}" return cls(notation) @property @@ -398,9 +399,10 @@ def get_step_pattern(self): def set_mode(self, mode=None, submode=None): """Set the `Key`'s mode. - The mode and submode arguments are optional. The mode will - default to the current `Key`'s mode, while the submode will - default to "natural" for minor and "" for non-minor. + 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 ---------- @@ -424,7 +426,7 @@ def set_mode(self, mode=None, submode=None): >>> key = Key("C major") >>> key.set_mode("minor", "melodic") >>> key.mode - melodic minor mode + melodic minor ModeGroup """ if mode is None and submode is None: @@ -433,10 +435,11 @@ def set_mode(self, mode=None, submode=None): ) if mode is None: mode = self._mode.mode - mode_notation = self._create_mode_notation(mode, submode) + mode_notation = Key._create_mode_notation(mode, submode) self._mode = ModeGroup(mode_notation) - def _create_mode_notation(self, mode, submode): + @staticmethod + def _create_mode_notation(mode, submode): if submode: return f"{submode} {mode}" return mode @@ -456,7 +459,7 @@ def to_relative_major(self): >>> key = Key("D minor") >>> key.to_relative_major() >>> key - F major key + F major Key """ if not self._is_minor(): @@ -491,10 +494,11 @@ def to_relative_minor(self, submode="natural"): >>> key = Key("D major") >>> key.to_relative_minor() >>> key - B natural minor + B natural minor Key >>> key = Key("E major") >>> key.to_relative_minor("melodic") - C\u266f melodic minor + >>> key + C\u266f melodic minor Key """ if not self._is_major(): @@ -520,10 +524,10 @@ def transpose(self, semitones, letters): >>> key = Key("C") >>> key.transpose(6, 3) >>> key - F\u266f major key + F\u266f major Key >>> key.transpose(0, 1) >>> key - G\u266d major key + G\u266d major Key """ self._tonic.transpose(semitones, letters) @@ -543,15 +547,15 @@ def transpose_simple(self, semitones, use_flats=False): -------- >>> key = Key("C") >>> key.transpose_simple(6) - F\u266f major key + F\u266f major Key >>> key.transpose_simple(2, use_flats=True) - A\u266d major key + A\u266d major Key """ self._tonic.transpose_simple(semitones, use_flats) def __repr__(self): - return f"{self} key" + return f"{self} Key" def __str__(self): return f"{self._tonic} {self._mode}" diff --git a/src/chordparser/music/note.py b/src/chordparser/music/note.py index 8bc32ec..67590bc 100644 --- a/src/chordparser/music/note.py +++ b/src/chordparser/music/note.py @@ -52,7 +52,7 @@ class Note: -------- >>> note = Note("C#") >>> note - C\u266f note + C\u266f Note >>> str(note) "C\u266f" @@ -110,10 +110,10 @@ def transpose(self, semitones, letters): >>> note = Note("C") >>> note.transpose(6, 3) >>> note - F\u266f note + F\u266f Note >>> note.transpose(0, 1) >>> note - G\u266d note + G\u266d Note """ original_int = self.as_int() @@ -138,9 +138,9 @@ def transpose_simple(self, semitones, use_flats=False): -------- >>> note = Note("C") >>> note.transpose_simple(6) - F\u266f note + F\u266f Note >>> note.transpose_simple(2, use_flats=True) - A\u266d note + A\u266d Note """ if use_flats: @@ -151,7 +151,7 @@ def transpose_simple(self, semitones, use_flats=False): self._set(note) def __repr__(self): - return f"{self} note" + return f"{self} Note" def __str__(self): return f"{self._letter}{self._symbol}" diff --git a/src/chordparser/music/scale.py b/src/chordparser/music/scale.py index 74a9e5b..28d01ad 100644 --- a/src/chordparser/music/scale.py +++ b/src/chordparser/music/scale.py @@ -25,7 +25,7 @@ class Scale: -------- >>> scale = Scale(Key("C major")) >>> scale - C major scale + C major Scale """ @@ -51,8 +51,8 @@ def get_notes(self): -------- >>> 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] + [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() @@ -82,11 +82,11 @@ def get_scale_degrees(self): -------- >>> 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] + [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() @@ -126,10 +126,10 @@ def transpose(self, semitones, letters): >>> scale = Scale(Key("C major")) >>> scale.transpose(6, 3) >>> scale - F\u266f major scale + F\u266f major Scale >>> scale.transpose(0, 1) >>> scale - G\u266d major scale + G\u266d major Scale """ self._key.transpose(semitones, letters) @@ -149,9 +149,9 @@ def transpose_simple(self, semitones, use_flats=False): -------- >>> scale = Scale(Key("C major")) >>> scale.transpose_simple(6) - F\u266f major scale + F\u266f major Scale >>> scale.transpose_simple(2, use_flats=True) - A\u266d major scale + A\u266d major Scale """ self._key.transpose_simple(semitones, use_flats) @@ -174,7 +174,7 @@ def get_note_from_scale_degree(self, scale_degree): >>> scale = Scale(Key("C major")) >>> sd = ScaleDegree("b3") >>> scale.get_note_from_scale_degree(sd) - E\u266d note + E\u266d Note """ major_scale = self._get_major_scale_of_self() @@ -200,7 +200,7 @@ def get_scale_degree_from_note(self, note): >>> scale = Scale(Key("C major")) >>> note = Note("F#") >>> scale.get_scale_degree_from_note(note) - \u266f4 scale degree + \u266f4 Scale Degree """ major_scale = self._get_major_scale_of_self() @@ -218,7 +218,7 @@ def _get_major_scale_of_self(self): return new_scale def __repr__(self): - return f"{self._key} scale" + return f"{self._key} Scale" def __eq__(self, other): """Compare with other `Scales`. diff --git a/src/chordparser/music/scaledegree.py b/src/chordparser/music/scaledegree.py index 314f8ff..3a07bcf 100644 --- a/src/chordparser/music/scaledegree.py +++ b/src/chordparser/music/scaledegree.py @@ -39,7 +39,7 @@ class ScaleDegree: -------- >>> sd = ScaleDegree(1, "b") >>> sd - \u266d1 scale degree + \u266d1 Scale Degree """ @@ -69,7 +69,7 @@ def __str__(self): return f"{self._symbol}{self._degree}" def __repr__(self): - return f"{self} scale degree" + return f"{self} Scale Degree" def __eq__(self, other): """Compare with other `ScaleDegrees` or strings. diff --git a/tests/test_music/test_scale.py b/tests/test_music/test_scale.py index 6e553a1..a94738c 100644 --- a/tests/test_music/test_scale.py +++ b/tests/test_music/test_scale.py @@ -58,19 +58,19 @@ class TestScaleTranspose: def test_correct_transpose(self): scale = Scale(Key("C major")) scale.transpose(6, 3) - assert "F\u266f major scale" == str(scale) + 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) + 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) + assert "A\u266d major Scale" == str(scale) class TestScaleGetNoteFromScaleDegree: From bb9b2cce92683eb91164b69f460002ff23709bd2 Mon Sep 17 00:00:00 2001 From: Titus Ong Date: Sun, 30 Aug 2020 18:23:15 +0800 Subject: [PATCH 31/52] Remove unused auto function --- src/chordparser/music/key.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/chordparser/music/key.py b/src/chordparser/music/key.py index 8527c08..c18c8c4 100644 --- a/src/chordparser/music/key.py +++ b/src/chordparser/music/key.py @@ -1,4 +1,4 @@ -from enum import Enum, auto +from enum import Enum from chordparser.music.notationparser import NotationParserTemplate from chordparser.music.note import NoteNotationParser, Note From 5740c9f02fe96133a72a4ce480bcec0d0e93fcb0 Mon Sep 17 00:00:00 2001 From: Titus Ong Date: Sun, 30 Aug 2020 22:24:21 +0800 Subject: [PATCH 32/52] Included docstring for enums --- src/chordparser/music/key.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/chordparser/music/key.py b/src/chordparser/music/key.py index c18c8c4..c8672c9 100644 --- a/src/chordparser/music/key.py +++ b/src/chordparser/music/key.py @@ -14,6 +14,13 @@ class ModeError(Exception): 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. + + """ + # Values correspond to the step intervals MAJOR = ( 2, 2, 1, 2, 2, 2, 1, @@ -60,6 +67,13 @@ def __repr__(self): 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 corresponds to whether the submode is minor and the # tuple corresponds to changes in step intervals # The boolean helps to distinguish NATURAL from NONE From 5d732f66ac727672db1bbb151bbd409a9875676d Mon Sep 17 00:00:00 2001 From: Titus Ong Date: Sun, 30 Aug 2020 22:40:59 +0800 Subject: [PATCH 33/52] Updated docstrings and included step_pattern property for mode and submode enums --- src/chordparser/music/key.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/chordparser/music/key.py b/src/chordparser/music/key.py index c8672c9..09c5a70 100644 --- a/src/chordparser/music/key.py +++ b/src/chordparser/music/key.py @@ -17,11 +17,11 @@ 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. + PHRYGIAN, LYDIAN, MIXOLYDIAN, AEOLIAN and LOCRIAN. Their values + correspond to their step pattern. """ - # Values correspond to the step intervals MAJOR = ( 2, 2, 1, 2, 2, 2, 1, 2, 2, 1, 2, 2, 2, 1, @@ -59,6 +59,10 @@ class Mode(Enum): 1, 2, 2, 1, 2, 2, 2, ) + @property + def step_pattern(self): + return self.value + def __str__(self): return f"{self.name.lower()}" @@ -70,12 +74,13 @@ 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. + NONE applies to all non-minor modes. Their values are a tuple of a + boolean and a tuple. The boolean corresponds to whether the + submode is minor, while the tuple is the changes in their step + pattern relative to their mode. """ - # The boolean corresponds to whether the submode is minor and the - # tuple corresponds to changes in step intervals # The boolean helps to distinguish NATURAL from NONE NATURAL = ( True, ( @@ -102,6 +107,10 @@ class Submode(Enum): ), ) + @property + def step_pattern(self): + return self.value[1] + def __str__(self): if self is Submode.NONE: return "" @@ -219,8 +228,8 @@ def get_step_pattern(self): (2, 1, 2, 2, 1, 3, 1, 2, 1, 2, 2, 1, 3, 1) """ - mode_pattern = self._mode.value - submode_pattern = self._submode.value[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): From 0a9a5ca4d39a7e280e1a98c96232d671e4ace49e Mon Sep 17 00:00:00 2001 From: Titus Ong Date: Mon, 31 Aug 2020 17:10:07 +0800 Subject: [PATCH 34/52] minor name changes --- src/chordparser/music/key.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/chordparser/music/key.py b/src/chordparser/music/key.py index 09c5a70..0558dfd 100644 --- a/src/chordparser/music/key.py +++ b/src/chordparser/music/key.py @@ -179,7 +179,7 @@ class ModeGroup: """ _MNP = ModeGroupNotationParser() - _mode_enum_converter = { + _mode_converter = { "major": Mode.MAJOR, "ionian": Mode.IONIAN, "dorian": Mode.DORIAN, @@ -190,7 +190,7 @@ class ModeGroup: "minor": Mode.MINOR, "locrian": Mode.LOCRIAN, } - _submode_enum_converter = { + _submode_converter = { "natural": Submode.NATURAL, "harmonic": Submode.HARMONIC, "melodic": Submode.MELODIC, @@ -199,8 +199,8 @@ class ModeGroup: def __init__(self, notation): mode, submode = self._MNP.parse_notation(notation) - self._mode = ModeGroup._mode_enum_converter[mode] - self._submode = ModeGroup._submode_enum_converter[submode] + self._mode = ModeGroup._mode_converter[mode] + self._submode = ModeGroup._submode_converter[submode] @property def mode(self): From c2f3233f9891b2d82e125c2f71fb4397e4e1531b Mon Sep 17 00:00:00 2001 From: Titus Ong Date: Mon, 31 Aug 2020 17:11:36 +0800 Subject: [PATCH 35/52] Change letter and symbol to enums, and abstracted symbolful classes into abstract class --- src/chordparser/music/hassymbol.py | 134 ++++++++++++++++++++++++ src/chordparser/music/letter.py | 71 ++++++++----- src/chordparser/music/note.py | 55 +++++----- src/chordparser/music/scale.py | 62 +++++++---- src/chordparser/music/scaledegree.py | 40 +++++-- src/chordparser/music/symbol.py | 65 +++++------- src/chordparser/utils/converters.py | 32 ------ src/chordparser/utils/notecomparer.py | 9 +- src/chordparser/utils/regex_patterns.py | 23 ++++ tests/test_music/test_hassymbol.py | 98 +++++++++++++++++ tests/test_music/test_letter.py | 27 ----- tests/test_music/test_note.py | 34 ++++-- tests/test_music/test_scale.py | 4 +- tests/test_music/test_scaledegree.py | 4 +- tests/test_music/test_symbol.py | 37 ------- 15 files changed, 458 insertions(+), 237 deletions(-) create mode 100644 src/chordparser/music/hassymbol.py delete mode 100644 src/chordparser/utils/converters.py create mode 100644 tests/test_music/test_hassymbol.py diff --git a/src/chordparser/music/hassymbol.py b/src/chordparser/music/hassymbol.py new file mode 100644 index 0000000..3df28dd --- /dev/null +++ b/src/chordparser/music/hassymbol.py @@ -0,0 +1,134 @@ +from chordparser.music.symbol import Symbol + + +class HasSymbol: + @property + def symbol(self): + # To be implemented in concrete class + raise NotImplementedError + + 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/letter.py b/src/chordparser/music/letter.py index 8651d1c..d74bdb9 100644 --- a/src/chordparser/music/letter.py +++ b/src/chordparser/music/letter.py @@ -1,50 +1,65 @@ -from collections import UserString +from enum import Enum -from chordparser.utils.note_lists import (natural_notes, - natural_semitone_intervals) +class Letter(Enum): + """Enum for the letter part of a `Note`. + The enum members available are C, D, E, F, G, A and B. Their values + correspond to their index in the natural note scale and their + semitone steps away from C. -class Letter(UserString): - """A class representing the letter part of a `Note`.""" + """ - def as_int(self): - """Return the `Letter`'s semitone value (basis: C = 0). + C = (0, 0) + D = (1, 2) + E = (2, 4) + F = (3, 5) + G = (4, 7) + A = (5, 9) + B = (6, 11) - The integer value is based on the number of semitones above C. + def index(self): + """Return the index of the `Letter` in the natural note scale. Returns ------- int - The integer `Letter` value. + The index of the `Letter`. Examples -------- - >>> d = Letter("D") - >>> d.as_int() - 2 + >>> Letter.C.index() + 0 + >>> Letter.D.index() + 1 + >>> Letter.B.index() + 6 """ - position = natural_notes.index(self.data) - total_semitones = sum(natural_semitone_intervals[:position]) - return total_semitones + return self.value[0] - def shift_by(self, shift): - """Shift the `Letter` along the natural note scale. + def as_steps(self): + """Return the number of steps of the `Letter` from C. - Parameters - ---------- - shift : int - The number of letters to shift by. + Returns + ------- + int + The number of steps of the `Letter` from C. Examples -------- - >>> letter = Letter("D") - >>> letter.shift_by(3) - >>> letter - G + >>> Letter.C.as_steps() + 0 + >>> Letter.D.as_steps() + 2 + >>> Letter.B.as_steps() + 11 """ - old_position = natural_notes.index(self.data) - new_position = (old_position + shift) % 7 - self.data = natural_notes[new_position] + 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/note.py b/src/chordparser/music/note.py index 67590bc..13a9b77 100644 --- a/src/chordparser/music/note.py +++ b/src/chordparser/music/note.py @@ -1,10 +1,14 @@ +from chordparser.music.hassymbol import HasSymbol from chordparser.music.letter import Letter from chordparser.music.notationparser import NotationParserTemplate from chordparser.music.symbol import Symbol -from chordparser.utils.converters import symbol_to_unicode from chordparser.utils.note_lists import sharp_scale, flat_scale from chordparser.utils.regex_patterns import (note_pattern, - symbol_pattern) + symbol_pattern, + letter_converter, + symbol_converter) +from chordparser.utils.unicode_chars import (sharp, doublesharp, + flat, doubleflat) class NoteNotationParser(NotationParserTemplate): @@ -16,19 +20,18 @@ class NoteNotationParser(NotationParserTemplate): def _split_into_groups(self, regex): """Split into capitalised letter and symbol.""" uppercase_letter = regex.group(1).upper() - symbol = symbol_to_unicode[regex.group(2)] + symbol = regex.group(2) return uppercase_letter, symbol -class Note: +class Note(HasSymbol): """A class representing a musical note. - The `Note` class composes of a `Letter` and `Symbol` object, + 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. Upon - creation, the symbols will be converted to unicode. + characters \u266d, \u266f, \U0001D12B, or \U0001D12A. Parameters ---------- @@ -40,8 +43,7 @@ class Note: letter : Letter The letter part of the `Note`'s notation. symbol : Symbol - The symbol part of the `Note`'s notation. If there is no - symbol, this will be a Symbol of an empty string. + The symbol part of the `Note`'s notation. Raises ------ @@ -65,8 +67,8 @@ def __init__(self, notation): def _set(self, notation): letter, symbol = self._NNP.parse_notation(notation) - self._letter = Letter(letter) - self._symbol = Symbol(symbol) + self._letter = letter_converter[letter] + self._symbol = symbol_converter[symbol] @property def letter(self): @@ -76,24 +78,22 @@ def letter(self): def symbol(self): return self._symbol - def as_int(self): - """Return the `Note`'s value as an integer (basis: C = 0). - - The integer value is based on the number of semitones above C. + def as_steps(self): + """Return the number of steps of the `Note` above C. Returns ------- int - The integer `Note` value. + The number of steps above C. Examples -------- >>> d = Note("D#") - >>> d.as_int() + >>> d.as_steps() 3 """ - return (self._letter.as_int() + self._symbol.as_int()) % 12 + return (self._letter.as_steps() + self._symbol.as_steps()) % 12 def transpose(self, semitones, letters): """Transpose the `Note` by some semitone and letter intervals. @@ -116,12 +116,19 @@ def transpose(self, semitones, letters): G\u266d Note """ - original_int = self.as_int() - self._letter.shift_by(letters) - positive_int_diff = (self.as_int() - original_int) % 12 + 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_int_diff - self._symbol.shift_by(semitone_difference) + 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. @@ -147,7 +154,7 @@ def transpose_simple(self, semitones, use_flats=False): notes = flat_scale else: notes = sharp_scale - note = notes[(self.as_int() + semitones) % 12] + note = notes[(self.as_steps() + semitones) % 12] self._set(note) def __repr__(self): diff --git a/src/chordparser/music/scale.py b/src/chordparser/music/scale.py index 28d01ad..2ab9545 100644 --- a/src/chordparser/music/scale.py +++ b/src/chordparser/music/scale.py @@ -3,6 +3,7 @@ from chordparser.music.scaledegree import ScaleDegree from chordparser.music.symbol import Symbol from chordparser.utils.note_lists import natural_semitone_intervals +from chordparser.utils.notecomparer import NoteComparer class Scale: @@ -98,18 +99,20 @@ def _initialise_scale_degrees(self): return [ScaleDegree("1")] def _add_scale_degree(self, scale_degrees, step): - prev_degree = scale_degrees[-1] - new_degree = prev_degree.degree % 7 + 1 - step_difference = self._get_step_diff_from_major(new_degree, step) - new_symbol = Symbol(str(prev_degree.symbol)) # avoid same reference - new_symbol.shift_by(step_difference) - scale_degrees.append( - ScaleDegree.from_components(new_degree, new_symbol) - ) + 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, new_degree, step): - step_reference = natural_semitone_intervals[new_degree-2] - return step - step_reference + 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. @@ -177,11 +180,17 @@ def get_note_from_scale_degree(self, scale_degree): E\u266d Note """ - major_scale = self._get_major_scale_of_self() - note = major_scale.get_notes()[scale_degree.degree-1] - note.symbol.shift_by(scale_degree.symbol.as_int()) + 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`. @@ -203,20 +212,33 @@ def get_scale_degree_from_note(self, 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() - degree = min([ - idx for (idx, n) in enumerate(major_scale.get_notes()) + return next( + n for n in major_scale.get_notes() if n.letter == note.letter - ]) + 1 - symbol = Symbol(str(note.symbol)) - new_sd = ScaleDegree.from_components(degree, symbol) - return new_sd + ) 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" diff --git a/src/chordparser/music/scaledegree.py b/src/chordparser/music/scaledegree.py index 3a07bcf..49db203 100644 --- a/src/chordparser/music/scaledegree.py +++ b/src/chordparser/music/scaledegree.py @@ -1,7 +1,9 @@ +from chordparser.music.hassymbol import HasSymbol from chordparser.music.notationparser import NotationParserTemplate from chordparser.music.symbol import Symbol from chordparser.utils.regex_patterns import (symbol_pattern, - degree_pattern) + degree_pattern, + symbol_converter) class ScaleDegreeNotationParser(NotationParserTemplate): @@ -14,14 +16,14 @@ def _split_into_groups(self, regex): degree = regex.group(2) return degree, symbol -class ScaleDegree: +class ScaleDegree(HasSymbol): """A class representing a scale degree. The `ScaleDegree` consists of an integer (the degree) and a - Symbol. 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. Upon - creation, the symbol will be converted to unicode. + 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 ---------- @@ -37,7 +39,7 @@ class ScaleDegree: Examples -------- - >>> sd = ScaleDegree(1, "b") + >>> sd = ScaleDegree("b1") >>> sd \u266d1 Scale Degree @@ -51,7 +53,7 @@ def __init__(self, notation): def _set(self, degree, symbol): self._degree = int(degree) - self._symbol = Symbol(symbol) + self._symbol = symbol_converter[symbol] @property def degree(self): @@ -62,7 +64,27 @@ def symbol(self): return self._symbol @classmethod - def from_components(cls, degree, symbol): + 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): diff --git a/src/chordparser/music/symbol.py b/src/chordparser/music/symbol.py index 78b0396..30633fe 100644 --- a/src/chordparser/music/symbol.py +++ b/src/chordparser/music/symbol.py @@ -1,61 +1,44 @@ -from collections import UserString +from enum import Enum -from chordparser.utils.converters import (symbol_to_unicode, - symbol_to_int, - int_to_symbol) +from chordparser.utils.unicode_chars import (sharp, doublesharp, + flat, doubleflat) -class Symbol(UserString): - """A class representing the symbol part of a `Note`. +class Symbol(Enum): + """Enum for the symbol part of a `Note`. - The `Symbol` is automatically converted to its unicode form. Only - symbols from doubleflats to doublesharps are allowed. + The enum members available are DOUBLEFLAT, FLAT, NATURAL, SHARP + and DOUBLESHARP. Their values correspond to their unicode + characters and their number of steps away from NATURAL. """ - def __init__(self, data): - if data not in symbol_to_unicode.keys(): - raise ValueError(f"'{data}' is not a valid Symbol") - self.data = symbol_to_unicode[data] - def as_int(self): - """Return the `Symbol`'s semitone value (basis: natural = 0). + DOUBLEFLAT = (doubleflat, -2) + FLAT = (flat, -1) + NATURAL = ("", 0) + SHARP = (sharp, 1) + DOUBLESHARP = (doublesharp, 2) - The integer value is based on the number of semitones above or - below the natural note. + def as_steps(self): + """Return the number of steps of the `Symbol` from NATURAL. Returns ------- int - The integer `Symbol` value. + The number of steps from NATURAL. Examples -------- - >>> sharp = Symbol("#") - >>> sharp.as_int() + >>> Symbol.SHARP.as_steps() 1 + >>> Symbol.FLAT.as_steps() + -1 """ - return symbol_to_int[self.data] + return self.value[1] - def shift_by(self, semitones): - """Shift the `Symbol` (i.e. raise or lower it). + def __str__(self): + return f"{self.value[0]}" - Parameters - ---------- - semitones : int - The semitones to shift the `Symbol` by. - - Examples - -------- - >>> symbol = Symbol("#") - >>> symbol.shift_by(-2) - >>> symbol - \u266D - - """ - int_value = self.as_int() + semitones - if int_value not in int_to_symbol.keys(): - raise ValueError( - "Symbol integer value is out of range" - ) - self.data = int_to_symbol[int_value] + def __repr__(self): + return f"Symbol.{self.name}" diff --git a/src/chordparser/utils/converters.py b/src/chordparser/utils/converters.py deleted file mode 100644 index 1513cf5..0000000 --- a/src/chordparser/utils/converters.py +++ /dev/null @@ -1,32 +0,0 @@ -from chordparser.utils.unicode_chars import (flat, doubleflat, - sharp, doublesharp) - - -symbol_to_unicode = { - "b": flat, - flat: flat, - "bb": doubleflat, - doubleflat: doubleflat, - "#": sharp, - sharp: sharp, - "##": doublesharp, - doublesharp: doublesharp, - "": "", - None: "", -} - -symbol_to_int = { - flat: -1, - doubleflat: -2, - sharp: 1, - doublesharp: 2, - "": 0, -} - -int_to_symbol = { - -1: flat, - -2: doubleflat, - 1: sharp, - 2: doublesharp, - 0: "", -} diff --git a/src/chordparser/utils/notecomparer.py b/src/chordparser/utils/notecomparer.py index c9f3f91..f521944 100644 --- a/src/chordparser/utils/notecomparer.py +++ b/src/chordparser/utils/notecomparer.py @@ -1,6 +1,3 @@ -from chordparser.utils.note_lists import natural_notes - - class NoteComparer: """A class that contains methods for comparing `Note` values. @@ -45,7 +42,7 @@ def get_semitone_intervals(notes): intervals = [] prev_note = notes[0] for note in notes: - semitone_interval = (note.as_int()-prev_note.as_int()) % 12 + semitone_interval = (note.as_steps()-prev_note.as_steps()) % 12 intervals.append(semitone_interval) prev_note = note intervals.pop(0) @@ -93,9 +90,9 @@ def get_letter_intervals(notes): """ NoteComparer._check_array_len("get_letter_intervals", notes) intervals = [] - prev_note_idx = natural_notes.index(notes[0].letter) + prev_note_idx = notes[0].letter.index() for note in notes: - note_idx = natural_notes.index(note.letter) + note_idx = note.letter.index() interval = (note_idx-prev_note_idx) % 7 intervals.append(interval) prev_note_idx = note_idx diff --git a/src/chordparser/utils/regex_patterns.py b/src/chordparser/utils/regex_patterns.py index 8c52282..3f1808a 100644 --- a/src/chordparser/utils/regex_patterns.py +++ b/src/chordparser/utils/regex_patterns.py @@ -1,5 +1,7 @@ from chordparser.utils.unicode_chars import (flat, doubleflat, sharp, doublesharp) +from chordparser.music.letter import Letter +from chordparser.music.symbol import Symbol note_pattern = "[A-G]" @@ -14,3 +16,24 @@ short_minor_pattern = "m" short_major_pattern = "" degree_pattern = "[1-7]" + +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, +} diff --git a/tests/test_music/test_hassymbol.py b/tests/test_music/test_hassymbol.py new file mode 100644 index 0000000..a023ea5 --- /dev/null +++ b/tests/test_music/test_hassymbol.py @@ -0,0 +1,98 @@ +import pytest + +from chordparser.music.hassymbol import HasSymbol +from chordparser.music.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_letter.py b/tests/test_music/test_letter.py index 62bb098..a6f1f5b 100644 --- a/tests/test_music/test_letter.py +++ b/tests/test_music/test_letter.py @@ -1,30 +1,3 @@ import pytest from chordparser.music.letter import Letter - - -class TestLetterAsInt: - @pytest.mark.parametrize( - "letter, value", [ - ("C", 0), - ("E", 4), - ("B", 11), - ] - ) - def test_correct_int(self, letter, value): - this = Letter(letter) - assert value == this.as_int() - - -class TestLetterShiftBy: - @pytest.mark.parametrize( - "shift, value", [ - (2, "E"), - (-1, "B"), - (15, "D"), - ] - ) - def test_correct_shift(self, shift, value): - this = Letter("C") - this.shift_by(shift) - assert value == this diff --git a/tests/test_music/test_note.py b/tests/test_music/test_note.py index 76f655c..3bc47a6 100644 --- a/tests/test_music/test_note.py +++ b/tests/test_music/test_note.py @@ -1,8 +1,10 @@ import pytest +from chordparser.music.letter import Letter +from chordparser.music.symbol import Symbol +from chordparser.music.note import NoteNotationParser, Note from chordparser.utils.unicode_chars import (sharp, doublesharp, flat, doubleflat) -from chordparser.music.note import NoteNotationParser, Note class TestNNPParseNotation: @@ -12,10 +14,10 @@ def parser(self): @pytest.mark.parametrize( "notation, expected_letter, expected_symbol", [ - ("C", "C", ""), - ("d", "D", ""), - ("C#", "C", "\u266F"), - ("Db", "D", "\u266D"), + ("C", "C", None), + ("d", "D", None), + ("C#", "C", "#"), + ("Db", "D", "b"), ] ) def test_correct_notation( @@ -38,11 +40,11 @@ def test_syntax_error(self, parser, notation): class TestNote: def test_init(self): note = Note("C#") - assert "C" == note.letter - assert sharp == note.symbol + assert Letter.C == note.letter + assert Symbol.SHARP == note.symbol -class TestNoteAsInt: +class TestNoteAsSteps: @pytest.mark.parametrize( "note, value", [ ("C#", 1), @@ -52,7 +54,21 @@ class TestNoteAsInt: ) def test_correct_int(self, note, value): n = Note(note) - assert value == n.as_int() + 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: diff --git a/tests/test_music/test_scale.py b/tests/test_music/test_scale.py index a94738c..8b3604d 100644 --- a/tests/test_music/test_scale.py +++ b/tests/test_music/test_scale.py @@ -77,7 +77,7 @@ class TestScaleGetNoteFromScaleDegree: @pytest.mark.parametrize( "key, sd, note", [ (Key("C minor"), ScaleDegree("#3"), Note("E#")), - (Key("Eb major"), ScaleDegree("2"), Note("F")) + (Key("Eb major"), ScaleDegree("1"), Note("Eb")) ] ) def test_correct_note(self, key, sd, note): @@ -89,7 +89,7 @@ class TestScaleGetScaleDegreeFromNote: @pytest.mark.parametrize( "key, sd, note", [ (Key("C minor"), ScaleDegree("#3"), Note("E#")), - (Key("Eb major"), ScaleDegree("2"), Note("F")) + (Key("Eb major"), ScaleDegree("1"), Note("Eb")) ] ) def test_correct_sd(self, key, sd, note): diff --git a/tests/test_music/test_scaledegree.py b/tests/test_music/test_scaledegree.py index b1071f3..b938e6b 100644 --- a/tests/test_music/test_scaledegree.py +++ b/tests/test_music/test_scaledegree.py @@ -28,14 +28,14 @@ def test_wrong_notation(self, parser): class TestScaleDegree: def test_creation(self): sd = ScaleDegree("b5") - assert Symbol("b") == sd.symbol + 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("b")) + sd2 = ScaleDegree.from_components(5, Symbol.FLAT) assert sd == sd2 diff --git a/tests/test_music/test_symbol.py b/tests/test_music/test_symbol.py index a9dff5c..c515941 100644 --- a/tests/test_music/test_symbol.py +++ b/tests/test_music/test_symbol.py @@ -2,40 +2,3 @@ from chordparser.music.symbol import Symbol from chordparser.utils.unicode_chars import flat, doublesharp - - -class TestSymbol: - def test_wrong_symbol(self): - with pytest.raises(ValueError): - Symbol("###") - - -class TestSymbolAsInt: - @pytest.mark.parametrize( - "symbol, value", [ - ("", 0), - ("#", 1), - ("bb", -2), - ] - ) - def test_correct_int(self, symbol, value): - this = Symbol(symbol) - assert value == this.as_int() - - -class TestSymbolShiftBy: - @pytest.mark.parametrize( - "shift, value", [ - (2, doublesharp), - (-1, flat), - ] - ) - def test_correct_shift(self, shift, value): - this = Symbol("") - this.shift_by(shift) - assert value == this - - def test_symbol_out_of_range(self): - this = Symbol("") - with pytest.raises(ValueError): - this.shift_by(3) From 618330ede2fa3f387d8ef370520999e42055db9d Mon Sep 17 00:00:00 2001 From: Titus Ong Date: Mon, 31 Aug 2020 17:11:54 +0800 Subject: [PATCH 36/52] Added more template-specific tests --- src/chordparser/music/notationparser.py | 7 +----- tests/test_music/test_notationparser.py | 31 ++++++++++++++++++++++--- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/src/chordparser/music/notationparser.py b/src/chordparser/music/notationparser.py index 9e66825..3bff185 100644 --- a/src/chordparser/music/notationparser.py +++ b/src/chordparser/music/notationparser.py @@ -3,7 +3,6 @@ class NotationParserTemplate: """Abstract class for parsing notation.""" - _pattern = "" def parse_notation(self, notation): regex = self._to_regex_object(notation) @@ -32,9 +31,5 @@ def pattern(self): return self._pattern def get_num_groups(self): - return NotationParserTemplate.get_num_groups_from(self._pattern) - - @staticmethod - def get_num_groups_from(pattern): - regex = re.compile(pattern) + regex = re.compile(self._pattern) return regex.groups diff --git a/tests/test_music/test_notationparser.py b/tests/test_music/test_notationparser.py index 81cc399..a4ed19c 100644 --- a/tests/test_music/test_notationparser.py +++ b/tests/test_music/test_notationparser.py @@ -3,8 +3,33 @@ from chordparser.music.notationparser import NotationParserTemplate -class TestNNPGetRegexGroupsCount: +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 = NotationParserTemplate() - parser._pattern = "(a)(b)" + parser = NotationParser() assert 2 == parser.get_num_groups() From f0f4b5b5ea63c222f03f1972c0a3d3efd80205d5 Mon Sep 17 00:00:00 2001 From: Titus Ong Date: Mon, 31 Aug 2020 17:19:18 +0800 Subject: [PATCH 37/52] Moved mode and submode enums to separate files for other modules to access them easily --- src/chordparser/music/key.py | 136 ++---------------------- src/chordparser/music/mode.py | 58 ++++++++++ src/chordparser/music/submode.py | 51 +++++++++ src/chordparser/utils/regex_patterns.py | 19 ++++ 4 files changed, 135 insertions(+), 129 deletions(-) create mode 100644 src/chordparser/music/mode.py create mode 100644 src/chordparser/music/submode.py diff --git a/src/chordparser/music/key.py b/src/chordparser/music/key.py index 0558dfd..6bca1d2 100644 --- a/src/chordparser/music/key.py +++ b/src/chordparser/music/key.py @@ -1,11 +1,13 @@ -from enum import Enum - +from chordparser.music.mode import Mode from chordparser.music.notationparser import NotationParserTemplate from chordparser.music.note import NoteNotationParser, Note +from chordparser.music.submode import Submode from chordparser.utils.regex_patterns import (submode_pattern, mode_pattern, short_minor_pattern, - short_major_pattern) + short_major_pattern, + mode_converter, + submode_converter) class ModeError(Exception): @@ -13,113 +15,6 @@ class ModeError(Exception): pass -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. Their values - correspond to their step pattern. - - """ - - 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}" - - -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. Their values are a tuple of a - boolean and a tuple. The boolean corresponds to whether the - submode is minor, while the tuple is the changes in their step - pattern relative to their mode. - - """ - - # 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}" - - class ModeGroupNotationParser(NotationParserTemplate): """Parse mode notation into mode and submode.""" @@ -179,28 +74,11 @@ class ModeGroup: """ _MNP = ModeGroupNotationParser() - _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, - } def __init__(self, notation): mode, submode = self._MNP.parse_notation(notation) - self._mode = ModeGroup._mode_converter[mode] - self._submode = ModeGroup._submode_converter[submode] + self._mode = mode_converter[mode] + self._submode = submode_converter[submode] @property def mode(self): diff --git a/src/chordparser/music/mode.py b/src/chordparser/music/mode.py new file mode 100644 index 0000000..284ea03 --- /dev/null +++ b/src/chordparser/music/mode.py @@ -0,0 +1,58 @@ +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. Their values + correspond to their step pattern. + + """ + + 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/submode.py b/src/chordparser/music/submode.py new file mode 100644 index 0000000..fd54a5a --- /dev/null +++ b/src/chordparser/music/submode.py @@ -0,0 +1,51 @@ +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. Their values are a tuple of a + boolean and a tuple. The boolean corresponds to whether the + submode is minor, while the tuple is the changes in their step + pattern relative to their mode. + + """ + + # 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/utils/regex_patterns.py b/src/chordparser/utils/regex_patterns.py index 3f1808a..983f816 100644 --- a/src/chordparser/utils/regex_patterns.py +++ b/src/chordparser/utils/regex_patterns.py @@ -1,7 +1,9 @@ from chordparser.utils.unicode_chars import (flat, doubleflat, sharp, doublesharp) from chordparser.music.letter import Letter +from chordparser.music.mode import Mode from chordparser.music.symbol import Symbol +from chordparser.music.submode import Submode note_pattern = "[A-G]" @@ -37,3 +39,20 @@ 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, +} From 42d40280900a3fe8ce5edf5c21ef0d6f4dbff8ca Mon Sep 17 00:00:00 2001 From: Titus Ong Date: Mon, 31 Aug 2020 18:09:23 +0800 Subject: [PATCH 38/52] Made abstract classes more formalised (cannot be instantiated without overriding abstract methods) --- src/chordparser/music/hassymbol.py | 7 +++++-- src/chordparser/music/notationparser.py | 17 ++++++++++------- src/chordparser/music/note.py | 4 ---- src/chordparser/music/scaledegree.py | 4 ---- 4 files changed, 15 insertions(+), 17 deletions(-) diff --git a/src/chordparser/music/hassymbol.py b/src/chordparser/music/hassymbol.py index 3df28dd..e584df0 100644 --- a/src/chordparser/music/hassymbol.py +++ b/src/chordparser/music/hassymbol.py @@ -2,10 +2,13 @@ class HasSymbol: + """Abstract class that contains a `Symbol`.""" + + _symbol: Symbol # To be defined in concrete class + @property def symbol(self): - # To be implemented in concrete class - raise NotImplementedError + return self._symbol def raise_by(self, steps=1): """Raise the pitch by changing only its `Symbol`. diff --git a/src/chordparser/music/notationparser.py b/src/chordparser/music/notationparser.py index 3bff185..766d87e 100644 --- a/src/chordparser/music/notationparser.py +++ b/src/chordparser/music/notationparser.py @@ -1,9 +1,16 @@ +from abc import ABCMeta, abstractmethod import re -class NotationParserTemplate: +class NotationParserTemplate(metaclass=ABCMeta): """Abstract class for parsing notation.""" + _pattern: str # To be defined in concrete class + + @property + def pattern(self): + return self._pattern + def parse_notation(self, notation): regex = self._to_regex_object(notation) if self._invalid_notation(regex): @@ -22,13 +29,9 @@ def _to_regex_object(self, notation): def _invalid_notation(self, regex): return not regex + @abstractmethod def _split_into_groups(self, regex): - # To be implemented in concrete class - raise NotImplementedError - - @property - def pattern(self): - return self._pattern + pass def get_num_groups(self): regex = re.compile(self._pattern) diff --git a/src/chordparser/music/note.py b/src/chordparser/music/note.py index 13a9b77..e471091 100644 --- a/src/chordparser/music/note.py +++ b/src/chordparser/music/note.py @@ -74,10 +74,6 @@ def _set(self, notation): def letter(self): return self._letter - @property - def symbol(self): - return self._symbol - def as_steps(self): """Return the number of steps of the `Note` above C. diff --git a/src/chordparser/music/scaledegree.py b/src/chordparser/music/scaledegree.py index 49db203..460f6d4 100644 --- a/src/chordparser/music/scaledegree.py +++ b/src/chordparser/music/scaledegree.py @@ -59,10 +59,6 @@ def _set(self, degree, symbol): def degree(self): return self._degree - @property - def symbol(self): - return self._symbol - @classmethod def from_components(cls, degree, symbol=""): """Create a `ScaleDegree` from its degree and symbol components. From 010ec94bf9b3e0911afca0823c18fcd03e3325b2 Mon Sep 17 00:00:00 2001 From: Titus Ong Date: Sat, 5 Sep 2020 10:42:02 +0800 Subject: [PATCH 39/52] Changed regex flags not to ignore case by default (only key notation requires ignoring case) --- src/chordparser/music/key.py | 4 ++++ src/chordparser/music/notationparser.py | 4 +++- tests/test_music/test_note.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/chordparser/music/key.py b/src/chordparser/music/key.py index 6bca1d2..29419e1 100644 --- a/src/chordparser/music/key.py +++ b/src/chordparser/music/key.py @@ -1,3 +1,5 @@ +import re + from chordparser.music.mode import Mode from chordparser.music.notationparser import NotationParserTemplate from chordparser.music.note import NoteNotationParser, Note @@ -18,6 +20,8 @@ class ModeError(Exception): 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})|" diff --git a/src/chordparser/music/notationparser.py b/src/chordparser/music/notationparser.py index 766d87e..0417db3 100644 --- a/src/chordparser/music/notationparser.py +++ b/src/chordparser/music/notationparser.py @@ -7,6 +7,8 @@ class NotationParserTemplate(metaclass=ABCMeta): _pattern: str # To be defined in concrete class + _flags = re.UNICODE + @property def pattern(self): return self._pattern @@ -22,7 +24,7 @@ def _to_regex_object(self, notation): regex = re.match( f"{self._pattern}$", notation, - flags=re.UNICODE | re.IGNORECASE, + flags=self._flags, ) return regex diff --git a/tests/test_music/test_note.py b/tests/test_music/test_note.py index 3bc47a6..2eda0ea 100644 --- a/tests/test_music/test_note.py +++ b/tests/test_music/test_note.py @@ -15,7 +15,7 @@ def parser(self): @pytest.mark.parametrize( "notation, expected_letter, expected_symbol", [ ("C", "C", None), - ("d", "D", None), + ("D", "D", None), ("C#", "C", "#"), ("Db", "D", "b"), ] From 80b69169b2e81bcd749a29074ca6fa5658a3d1ea Mon Sep 17 00:00:00 2001 From: Titus Ong Date: Sat, 5 Sep 2020 10:42:18 +0800 Subject: [PATCH 40/52] Remove tests for enums --- tests/test_music/test_letter.py | 3 --- tests/test_music/test_symbol.py | 4 ---- 2 files changed, 7 deletions(-) delete mode 100644 tests/test_music/test_letter.py delete mode 100644 tests/test_music/test_symbol.py diff --git a/tests/test_music/test_letter.py b/tests/test_music/test_letter.py deleted file mode 100644 index a6f1f5b..0000000 --- a/tests/test_music/test_letter.py +++ /dev/null @@ -1,3 +0,0 @@ -import pytest - -from chordparser.music.letter import Letter diff --git a/tests/test_music/test_symbol.py b/tests/test_music/test_symbol.py deleted file mode 100644 index c515941..0000000 --- a/tests/test_music/test_symbol.py +++ /dev/null @@ -1,4 +0,0 @@ -import pytest - -from chordparser.music.symbol import Symbol -from chordparser.utils.unicode_chars import flat, doublesharp From f8310d9370f20dc9e03d34feb4df0335e5d043f3 Mon Sep 17 00:00:00 2001 From: Titus Ong Date: Sat, 5 Sep 2020 11:34:43 +0800 Subject: [PATCH 41/52] moved enums into components folders for cleaner folder structure --- src/chordparser/music/hassymbol.py | 2 +- src/chordparser/music/key.py | 4 ++-- src/chordparser/music/{ => keycomponents}/mode.py | 0 src/chordparser/music/{ => keycomponents}/submode.py | 0 src/chordparser/music/note.py | 4 ++-- src/chordparser/music/{ => notecomponents}/letter.py | 0 src/chordparser/music/{ => notecomponents}/symbol.py | 0 src/chordparser/music/scale.py | 2 +- src/chordparser/music/scaledegree.py | 2 +- src/chordparser/utils/regex_patterns.py | 8 ++++---- tests/test_music/test_hassymbol.py | 2 +- tests/test_music/test_note.py | 4 ++-- tests/test_music/test_scaledegree.py | 2 +- 13 files changed, 15 insertions(+), 15 deletions(-) rename src/chordparser/music/{ => keycomponents}/mode.py (100%) rename src/chordparser/music/{ => keycomponents}/submode.py (100%) rename src/chordparser/music/{ => notecomponents}/letter.py (100%) rename src/chordparser/music/{ => notecomponents}/symbol.py (100%) diff --git a/src/chordparser/music/hassymbol.py b/src/chordparser/music/hassymbol.py index e584df0..c4e848f 100644 --- a/src/chordparser/music/hassymbol.py +++ b/src/chordparser/music/hassymbol.py @@ -1,4 +1,4 @@ -from chordparser.music.symbol import Symbol +from chordparser.music.notecomponents.symbol import Symbol class HasSymbol: diff --git a/src/chordparser/music/key.py b/src/chordparser/music/key.py index 29419e1..149fe46 100644 --- a/src/chordparser/music/key.py +++ b/src/chordparser/music/key.py @@ -1,9 +1,9 @@ import re -from chordparser.music.mode import Mode +from chordparser.music.keycomponents.mode import Mode from chordparser.music.notationparser import NotationParserTemplate from chordparser.music.note import NoteNotationParser, Note -from chordparser.music.submode import Submode +from chordparser.music.keycomponents.submode import Submode from chordparser.utils.regex_patterns import (submode_pattern, mode_pattern, short_minor_pattern, diff --git a/src/chordparser/music/mode.py b/src/chordparser/music/keycomponents/mode.py similarity index 100% rename from src/chordparser/music/mode.py rename to src/chordparser/music/keycomponents/mode.py diff --git a/src/chordparser/music/submode.py b/src/chordparser/music/keycomponents/submode.py similarity index 100% rename from src/chordparser/music/submode.py rename to src/chordparser/music/keycomponents/submode.py diff --git a/src/chordparser/music/note.py b/src/chordparser/music/note.py index e471091..7dc9f9d 100644 --- a/src/chordparser/music/note.py +++ b/src/chordparser/music/note.py @@ -1,7 +1,7 @@ from chordparser.music.hassymbol import HasSymbol -from chordparser.music.letter import Letter +from chordparser.music.notecomponents.letter import Letter from chordparser.music.notationparser import NotationParserTemplate -from chordparser.music.symbol import Symbol +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, diff --git a/src/chordparser/music/letter.py b/src/chordparser/music/notecomponents/letter.py similarity index 100% rename from src/chordparser/music/letter.py rename to src/chordparser/music/notecomponents/letter.py diff --git a/src/chordparser/music/symbol.py b/src/chordparser/music/notecomponents/symbol.py similarity index 100% rename from src/chordparser/music/symbol.py rename to src/chordparser/music/notecomponents/symbol.py diff --git a/src/chordparser/music/scale.py b/src/chordparser/music/scale.py index 2ab9545..7b2bcab 100644 --- a/src/chordparser/music/scale.py +++ b/src/chordparser/music/scale.py @@ -1,7 +1,7 @@ from chordparser.music.key import Key from chordparser.music.note import Note from chordparser.music.scaledegree import ScaleDegree -from chordparser.music.symbol import Symbol +from chordparser.music.notecomponents.symbol import Symbol from chordparser.utils.note_lists import natural_semitone_intervals from chordparser.utils.notecomparer import NoteComparer diff --git a/src/chordparser/music/scaledegree.py b/src/chordparser/music/scaledegree.py index 460f6d4..09c53a1 100644 --- a/src/chordparser/music/scaledegree.py +++ b/src/chordparser/music/scaledegree.py @@ -1,6 +1,6 @@ from chordparser.music.hassymbol import HasSymbol from chordparser.music.notationparser import NotationParserTemplate -from chordparser.music.symbol import Symbol +from chordparser.music.notecomponents.symbol import Symbol from chordparser.utils.regex_patterns import (symbol_pattern, degree_pattern, symbol_converter) diff --git a/src/chordparser/utils/regex_patterns.py b/src/chordparser/utils/regex_patterns.py index 983f816..c5a82c4 100644 --- a/src/chordparser/utils/regex_patterns.py +++ b/src/chordparser/utils/regex_patterns.py @@ -1,9 +1,9 @@ from chordparser.utils.unicode_chars import (flat, doubleflat, sharp, doublesharp) -from chordparser.music.letter import Letter -from chordparser.music.mode import Mode -from chordparser.music.symbol import Symbol -from chordparser.music.submode import Submode +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 note_pattern = "[A-G]" diff --git a/tests/test_music/test_hassymbol.py b/tests/test_music/test_hassymbol.py index a023ea5..c4c2b1f 100644 --- a/tests/test_music/test_hassymbol.py +++ b/tests/test_music/test_hassymbol.py @@ -1,7 +1,7 @@ import pytest from chordparser.music.hassymbol import HasSymbol -from chordparser.music.symbol import Symbol +from chordparser.music.notecomponents.symbol import Symbol class Symbolful(HasSymbol): diff --git a/tests/test_music/test_note.py b/tests/test_music/test_note.py index 2eda0ea..313158d 100644 --- a/tests/test_music/test_note.py +++ b/tests/test_music/test_note.py @@ -1,7 +1,7 @@ import pytest -from chordparser.music.letter import Letter -from chordparser.music.symbol import Symbol +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) diff --git a/tests/test_music/test_scaledegree.py b/tests/test_music/test_scaledegree.py index b938e6b..798a9ca 100644 --- a/tests/test_music/test_scaledegree.py +++ b/tests/test_music/test_scaledegree.py @@ -2,7 +2,7 @@ from chordparser.music.scaledegree import (ScaleDegree, ScaleDegreeNotationParser) -from chordparser.music.symbol import Symbol +from chordparser.music.notecomponents.symbol import Symbol class TestSDNPParseNotation: From bf6196a4d27084a8cf481aabf34fe0482df2e66d Mon Sep 17 00:00:00 2001 From: Titus Ong Date: Sun, 13 Sep 2020 16:02:14 +0800 Subject: [PATCH 42/52] removed value description for enums - they are explained in the methods --- src/chordparser/music/keycomponents/mode.py | 3 +-- src/chordparser/music/keycomponents/submode.py | 5 +---- src/chordparser/music/notecomponents/letter.py | 4 +--- src/chordparser/music/notecomponents/symbol.py | 3 +-- 4 files changed, 4 insertions(+), 11 deletions(-) diff --git a/src/chordparser/music/keycomponents/mode.py b/src/chordparser/music/keycomponents/mode.py index 284ea03..1e10e6a 100644 --- a/src/chordparser/music/keycomponents/mode.py +++ b/src/chordparser/music/keycomponents/mode.py @@ -5,8 +5,7 @@ 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. Their values - correspond to their step pattern. + PHRYGIAN, LYDIAN, MIXOLYDIAN, AEOLIAN and LOCRIAN. """ diff --git a/src/chordparser/music/keycomponents/submode.py b/src/chordparser/music/keycomponents/submode.py index fd54a5a..3653953 100644 --- a/src/chordparser/music/keycomponents/submode.py +++ b/src/chordparser/music/keycomponents/submode.py @@ -5,10 +5,7 @@ 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. Their values are a tuple of a - boolean and a tuple. The boolean corresponds to whether the - submode is minor, while the tuple is the changes in their step - pattern relative to their mode. + NONE applies to all non-minor modes. """ diff --git a/src/chordparser/music/notecomponents/letter.py b/src/chordparser/music/notecomponents/letter.py index d74bdb9..889f182 100644 --- a/src/chordparser/music/notecomponents/letter.py +++ b/src/chordparser/music/notecomponents/letter.py @@ -4,9 +4,7 @@ class Letter(Enum): """Enum for the letter part of a `Note`. - The enum members available are C, D, E, F, G, A and B. Their values - correspond to their index in the natural note scale and their - semitone steps away from C. + The enum members available are C, D, E, F, G, A and B. """ diff --git a/src/chordparser/music/notecomponents/symbol.py b/src/chordparser/music/notecomponents/symbol.py index 30633fe..45922d4 100644 --- a/src/chordparser/music/notecomponents/symbol.py +++ b/src/chordparser/music/notecomponents/symbol.py @@ -8,8 +8,7 @@ class Symbol(Enum): """Enum for the symbol part of a `Note`. The enum members available are DOUBLEFLAT, FLAT, NATURAL, SHARP - and DOUBLESHARP. Their values correspond to their unicode - characters and their number of steps away from NATURAL. + and DOUBLESHARP. """ From 42830e2b09f1cd34b561b8e8e729cde66a80ab04 Mon Sep 17 00:00:00 2001 From: Titus Ong Date: Sun, 13 Sep 2020 16:02:26 +0800 Subject: [PATCH 43/52] minor formatting edit --- src/chordparser/music/note.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/chordparser/music/note.py b/src/chordparser/music/note.py index 7dc9f9d..4c7244f 100644 --- a/src/chordparser/music/note.py +++ b/src/chordparser/music/note.py @@ -13,6 +13,7 @@ class NoteNotationParser(NotationParserTemplate): """Parse note notation into letter and symbol groups.""" + _pattern = ( f"({note_pattern})({symbol_pattern})?" ) From d2555d5bf113bd51f2a45157cf5485b47ebc9dca Mon Sep 17 00:00:00 2001 From: Titus Ong Date: Sun, 13 Sep 2020 16:22:54 +0800 Subject: [PATCH 44/52] Initial commit for quality enums --- .../music/chordcomponents/basequality.py | 79 +++++++++++++++++++ .../music/chordcomponents/extensionquality.py | 38 +++++++++ 2 files changed, 117 insertions(+) create mode 100644 src/chordparser/music/chordcomponents/basequality.py create mode 100644 src/chordparser/music/chordcomponents/extensionquality.py diff --git a/src/chordparser/music/chordcomponents/basequality.py b/src/chordparser/music/chordcomponents/basequality.py new file mode 100644 index 0000000..75d3a77 --- /dev/null +++ b/src/chordparser/music/chordcomponents/basequality.py @@ -0,0 +1,79 @@ +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 and HALFDIMINISHED. + + """ + + 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", + ) + + def roman_letter_case_converter(self): + """Return the function to convert Roman numeral letter case.""" + return self.value[0] + + 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}" From ed55c6fbbebe5a5dfed1e758f698547e07be0677 Mon Sep 17 00:00:00 2001 From: Titus Ong Date: Sun, 13 Sep 2020 16:23:22 +0800 Subject: [PATCH 45/52] Included test for creating scale degrees for basequality enum --- .../test_chordcomponents/test_basequality.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 tests/test_music/test_chordcomponents/test_basequality.py 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 From abf8c8ff822c5db3286c54fc309622f43a3ad166 Mon Sep 17 00:00:00 2001 From: Titus Ong Date: Sun, 13 Sep 2020 16:24:18 +0800 Subject: [PATCH 46/52] Initial commit for matching power chords --- src/chordparser/music/chordtypes/power.py | 11 +++++++++++ tests/test_music/test_chordtypes/test_power.py | 17 +++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 src/chordparser/music/chordtypes/power.py create mode 100644 tests/test_music/test_chordtypes/test_power.py diff --git a/src/chordparser/music/chordtypes/power.py b/src/chordparser/music/chordtypes/power.py new file mode 100644 index 0000000..a6c7522 --- /dev/null +++ b/src/chordparser/music/chordtypes/power.py @@ -0,0 +1,11 @@ +from chordparser.music.notationparser import NotationParserTemplate +from chordparser.utils.regex_patterns import power_pattern + + +class PowerChordNotationParser(NotationParserTemplate): + """Parse power chords.""" + + _pattern = f"({power_pattern})" + + def _split_into_groups(self, regex): + return regex.group(1) 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..859af63 --- /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 "5" == result + + def test_reject_wrong_notation(self, parser): + with pytest.raises(SyntaxError): + parser.parse_notation("6") From 51d179363cd08a7fe2ab96fdd5c18641f68f6b7b Mon Sep 17 00:00:00 2001 From: Titus Ong Date: Sun, 13 Sep 2020 17:13:56 +0800 Subject: [PATCH 47/52] Added more regex patterns and tests for the more complex ones --- src/chordparser/utils/regex_patterns.py | 35 +++++++++++++--- tests/test_utils/test_regex_patterns.py | 53 +++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 5 deletions(-) create mode 100644 tests/test_utils/test_regex_patterns.py diff --git a/src/chordparser/utils/regex_patterns.py b/src/chordparser/utils/regex_patterns.py index c5a82c4..0c70a82 100644 --- a/src/chordparser/utils/regex_patterns.py +++ b/src/chordparser/utils/regex_patterns.py @@ -6,10 +6,19 @@ from chordparser.music.keycomponents.submode import Submode -note_pattern = "[A-G]" -flat_pattern = f"bb|b|{flat}|{doubleflat}" -sharp_pattern = f"##|#|{sharp}|{doublesharp}" -symbol_pattern = f"{flat_pattern}|{sharp_pattern}" +# 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|" @@ -17,8 +26,24 @@ ) short_minor_pattern = "m" short_major_pattern = "" -degree_pattern = "[1-7]" +# 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, 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) From 613155ce60e25e0f5ca4736443e202fab400c3ce Mon Sep 17 00:00:00 2001 From: Titus Ong Date: Sun, 13 Sep 2020 17:14:04 +0800 Subject: [PATCH 48/52] Initial commit for suspended chords --- src/chordparser/music/chordtypes/suspended.py | 19 +++++++++++++++++++ .../test_chordtypes/test_suspended.py | 17 +++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 src/chordparser/music/chordtypes/suspended.py create mode 100644 tests/test_music/test_chordtypes/test_suspended.py 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/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 From 5a3bb8bc319e62d2b6ff3360383b3e414be3c940 Mon Sep 17 00:00:00 2001 From: Titus Ong Date: Sun, 13 Sep 2020 17:14:46 +0800 Subject: [PATCH 49/52] Initial commit for triads --- src/chordparser/music/chordtypes/triad.py | 42 +++++++++++++++++++ .../test_music/test_chordtypes/test_triad.py | 17 ++++++++ 2 files changed, 59 insertions(+) create mode 100644 src/chordparser/music/chordtypes/triad.py create mode 100644 tests/test_music/test_chordtypes/test_triad.py 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/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 From 453918339c6b9c29aa5d66fc2ab2bd47e06ef3f6 Mon Sep 17 00:00:00 2001 From: Titus Ong Date: Sun, 13 Sep 2020 17:17:56 +0800 Subject: [PATCH 50/52] Included power into base quality --- src/chordparser/music/chordcomponents/basequality.py | 8 +++++++- src/chordparser/music/chordtypes/power.py | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/chordparser/music/chordcomponents/basequality.py b/src/chordparser/music/chordcomponents/basequality.py index 75d3a77..6c00de6 100644 --- a/src/chordparser/music/chordcomponents/basequality.py +++ b/src/chordparser/music/chordcomponents/basequality.py @@ -7,7 +7,7 @@ class BaseQuality(Enum): """Enum for the base quality of a `Chord`. The enum members available are MAJOR, MINOR, AUGMENTED, DIMINISHED, - SUS2, SUS4, DOMINANT and HALFDIMINISHED. + SUS2, SUS4, DOMINANT, HALFDIMINISHED and POWER. """ @@ -59,6 +59,12 @@ class BaseQuality(Enum): ("1", "b3", "b5"), "\u00f8", ) + POWER = ( + str.upper, + False, + ("1", "5"), + "5", + ) def roman_letter_case_converter(self): """Return the function to convert Roman numeral letter case.""" diff --git a/src/chordparser/music/chordtypes/power.py b/src/chordparser/music/chordtypes/power.py index a6c7522..c9bff3e 100644 --- a/src/chordparser/music/chordtypes/power.py +++ b/src/chordparser/music/chordtypes/power.py @@ -1,11 +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 regex.group(1) + return "power" From 0df9c846412ec39664b367e6c41ae3b497e46183 Mon Sep 17 00:00:00 2001 From: Titus Ong Date: Sun, 13 Sep 2020 22:29:42 +0800 Subject: [PATCH 51/52] Fix power regex result --- tests/test_music/test_chordtypes/test_power.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_music/test_chordtypes/test_power.py b/tests/test_music/test_chordtypes/test_power.py index 859af63..f537657 100644 --- a/tests/test_music/test_chordtypes/test_power.py +++ b/tests/test_music/test_chordtypes/test_power.py @@ -10,7 +10,7 @@ def parser(self): def test_correct_notation(self, parser): result = parser.parse_notation("5") - assert "5" == result + assert "power" == result def test_reject_wrong_notation(self, parser): with pytest.raises(SyntaxError): From 7d0f29d5f4dac4f7f6de5f109b66e6a5e7f357bc Mon Sep 17 00:00:00 2001 From: Titus Ong Date: Sun, 13 Sep 2020 22:30:00 +0800 Subject: [PATCH 52/52] Change roman converter to accept the roman notation --- src/chordparser/music/chordcomponents/basequality.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/chordparser/music/chordcomponents/basequality.py b/src/chordparser/music/chordcomponents/basequality.py index 6c00de6..ee695fc 100644 --- a/src/chordparser/music/chordcomponents/basequality.py +++ b/src/chordparser/music/chordcomponents/basequality.py @@ -66,9 +66,9 @@ class BaseQuality(Enum): "5", ) - def roman_letter_case_converter(self): - """Return the function to convert Roman numeral letter case.""" - return self.value[0] + 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."""