Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Unittest implementation, __deepcopy__ implementation #5

Merged
merged 4 commits into from
Dec 23, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions harte/harte.lark
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ chord: note ":" shorthand ("(" degree_list ")")? ("/" bass)?
| note ("/" bass)?
| NA

note: NATURAL | NATURAL MODIFIER
note: NATURAL | NATURAL MODIFIER+
NATURAL: "A" | "B" | "C" | "D" | "E" | "F" | "G"
MODIFIER: "b" | "#"
NA: "N"
NA: "N" | "X"

bass: degree
degree_list: degree ("," degree)*
Expand Down
119 changes: 70 additions & 49 deletions harte/harte.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,54 +37,74 @@ def __init__(self, chord: str, **keywords):
f'The input chord {chord} is not a valid Harte chord')\
from name_error

assert parsed_chord['root']

# retrieve information from the parsed chord
self._root = parsed_chord['root']
self._shorthand = parsed_chord[
'shorthand'] if 'shorthand' in parsed_chord.keys() else None
self._degrees = parsed_chord[
'degrees'] if 'degrees' in parsed_chord.keys() else None
self._bass = parsed_chord[
'bass'] if 'bass' in parsed_chord.keys() else '1'
removed_degrees = [x for x in self._degrees if x.startswith(
'*')] if self._degrees else []

# unwrap shorthand if it exists and merge with degrees
# if no shorthand exists, just use the degrees
# if no degrees exist, assume the chord is a major triad
if self._shorthand:
assert SHORTHAND_DEGREES[self._shorthand], 'The Harte ' \
'shorthand is' \
' not valid. '
self._shorthand_degrees = SHORTHAND_DEGREES[self._shorthand]
self._all_degrees = self._shorthand_degrees + self._degrees if \
self._degrees else self._shorthand_degrees
elif self._degrees:
self._all_degrees = self._degrees
if "root" in parsed_chord:
# chord is not empty
# retrieve information from the parsed chord
self._root = parsed_chord['root']
self._shorthand = parsed_chord[
'shorthand'] if 'shorthand' in parsed_chord.keys() else None
self._degrees = parsed_chord[
'degrees'] if 'degrees' in parsed_chord.keys() else None
self._bass = parsed_chord[
'bass'] if 'bass' in parsed_chord.keys() else '1'
removed_degrees = [x for x in self._degrees if x.startswith(
'*')] if self._degrees else []

# unwrap shorthand if it exists and merge with degrees
# if no shorthand exists, just use the degrees
# if no degrees exist, assume the chord is a major triad
if self._shorthand:
assert SHORTHAND_DEGREES[self._shorthand], 'The Harte ' \
'shorthand is' \
' not valid. '
self._shorthand_degrees = SHORTHAND_DEGREES[self._shorthand]
self._all_degrees = self._shorthand_degrees + self._degrees if \
self._degrees else self._shorthand_degrees
elif self._degrees:
self._all_degrees = self._degrees
else:
self._all_degrees = ['1', '3', '5']

self._all_degrees = [x for x in self._all_degrees if
x not in removed_degrees]
# add root and bass note to the overall list of degrees
self._all_degrees.append(self._bass)
self._all_degrees.append('1')
# sort the list and remove duplicates
self._all_degrees.sort(key=lambda x: [k for k in x if k.isdigit()][0])
self._all_degrees = list(set(self._all_degrees))

# convert notes and interval to m21 primitives
# note that when multiple flats are introduced (i.e. Cbb) music21
# won't be able to parse the note.
# this is fixed by replacing each 'b' with a '-'.
m21_root = Note(self._root.replace("b", "-"))
m21_degrees = [HarteInterval(x).transposeNote(m21_root)
for x in self._all_degrees]
m21_bass = HarteInterval(self._bass).transposeNote(
m21_root)

# initialize the parent constructor
super().__init__(m21_degrees, **keywords)
super().root(m21_root)
super().bass(m21_bass)
else:
self._all_degrees = ['1', '3', '5']

self._all_degrees = [x for x in self._all_degrees if
x not in removed_degrees]
# add root and bass note to the overall list of degrees
self._all_degrees.append(self._bass)
self._all_degrees.append('1')
# sort the list and remove duplicates
self._all_degrees.sort(key=lambda x: [k for k in x if k.isdigit()][0])
self._all_degrees = list(set(self._all_degrees))

# convert notes and interval to m21 primitives
m21_root = Note(self._root)
m21_degrees = [HarteInterval(x).transposeNote(m21_root)
for x in self._all_degrees]
m21_bass = HarteInterval(self._bass).transposeNote(
m21_root)

# initialize the parent constructor
super().__init__(m21_degrees, **keywords)
super().root(m21_root)
super().bass(m21_bass)
# chord is empty
self._root = None
self._shorthand = None
self._degrees = None
self._bass = None
super().__init__(**keywords)

def __deepcopy__(self, *args, **kwargs):
"""
Perform a deepcopy of this obect by creating a new identical
object with the input chord used for this one.

:return: A copy of the current object.
:rtype: Harte
"""
return Harte(self.chord)

def get_degrees(self) -> List[str]:
"""
Expand Down Expand Up @@ -212,14 +232,15 @@ def __repr__(self):
Method to represent the HarteChord object as a string
:return: a string representing the HarteChord object
"""
return f'Harte({self._root}:{self._shorthand}({self._degrees})/{self._bass})'
return f'Harte({str(self)})'

def __str__(self):
"""
Method to represent the HarteChord object as a string
:return: a string representing the HarteChord object
"""
return f'{self._root}:{self._shorthand}({self._degrees})/{self._bass}'
return f'{self._root}:{self._shorthand}({self._degrees})/{self._bass}' \
if self._root is not None else 'N'


if __name__ == '__main__':
Expand Down
6 changes: 6 additions & 0 deletions harte/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ def convert_interval(harte_interval: str) -> str:
modifier = 'A'
elif len(matches[0]) == 1 and len(matches[1]) == 0:
modifier = 'd'
elif len(matches[0]) >= 2 and len(matches[1]) == 0:
new_sharps = matches[0].replace('##', '')
return convert_interval(f'{new_sharps}{base_degree + 1}')
elif len(matches[1]) >= 2 and len(matches[0]) == 0:
new_flats = matches[1].replace('bb', '')
return convert_interval(f'{new_flats}{base_degree - 1}')
else:
raise ValueError(f'The degree {harte_interval} cannot '
f'be parsed.')
Expand Down
3 changes: 2 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
lark~=1.1.2
music21~=8.1.0
numpy~=1.23.5
numpy~=1.23.5
pytest~=7.1.3
Empty file added test/__init__.py
Empty file.
1 change: 1 addition & 0 deletions test/chords_count.json

Large diffs are not rendered by default.

45 changes: 45 additions & 0 deletions test/test_harte.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
from typing import List
import os
import json
import pytest
from harte.harte import Harte

# load a dict of chords frequencies extracted from ChoCo [1]
# to test coverage of a big set of chords
# [1] https://github.com/smashub/choco
CUR_DIR = os.path.dirname(os.path.realpath(__file__))
with open(os.path.join(CUR_DIR, "chords_count.json"), encoding="UTF-8") as f:
CHORDS_COUNT = json.load(f)


@pytest.mark.parametrize("chord", list(CHORDS_COUNT.keys()))
def test_coverage(chord: str):
"""
Tests coverage of all chord extracted from ChoCo [1].

[1] https://github.com/smashub/choco

:param chord: Chord to be tested
:type chord: str
"""
Harte(chord)


@pytest.mark.parametrize("chord,intervals",
[("C:maj", ["P5", "M3"]),
("C:min", ["P5", "m3"])])
def test_interval_extraction(chord: str, intervals: List[str]):
"""
Test that the annotateIntervals of music21 correctly works in extracting
the intervals from a chord.

:param chord: Input chord
:type chord: str
:param intervals: Intervals that should be part of the chord
:type intervals: List[str]
"""
chord = Harte(chord)
annotated_intervals = chord.annotateIntervals(inPlace=False,
returnList=True,
stripSpecifiers=False)
assert set(intervals) == set(annotated_intervals)