Skip to content

Commit

Permalink
Merge pull request #75 from EnigmaCurry/ordered_inversion
Browse files Browse the repository at this point in the history
Adds ordered inversion of chords
  • Loading branch information
yuma-m committed Jun 16, 2022
2 parents 67c7fb1 + 760f4d7 commit 4e97f8a
Show file tree
Hide file tree
Showing 7 changed files with 124 additions and 12 deletions.
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,22 @@ True
['C', 'E', 'G', 'Bb', 'D', 'F']
```

### Inversions

Chord inversions are created with a forward slash and a number
indicating the order. This can optionally be combined with an
additional forward slash to change the bass note:

```python
>>> Chord("C/1").components() # First inversion of C
['E', 'G', 'C']
>>> Chord("C/2").components() # Second inversion of C
['G', 'C', 'E']

>>> Chord("Cm7/3/F").components() # Third inversion of Cm7 with an added F bass
['F', 'Bb', 'C', 'Eb', 'G']
```

## Examples

- [pychord-midi.py](./examples/pychord-midi.py) - Create a MIDI file using PyChord and pretty_midi.
Expand Down
12 changes: 6 additions & 6 deletions pychord/chord.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ def __init__(self, chord: str):
self._appended: List[str] = appended
self._on: str = on

self._append_on_chord()

def __unicode__(self):
return self._chord

Expand Down Expand Up @@ -166,9 +168,6 @@ def components(self, visible: bool = True) -> Union[List[str], List[int]]:
:param visible: returns the name of notes if True else list of int
:return: component notes of chord
"""
if self._on:
self._quality.append_on_chord(self.on, self.root)

return self._quality.get_components(root=self._root, visible=visible)

def components_with_pitch(self, root_pitch: int) -> List[str]:
Expand All @@ -177,14 +176,15 @@ def components_with_pitch(self, root_pitch: int) -> List[str]:
:param root_pitch: the pitch of the root note
:return: component notes of chord
"""
if self._on:
self._quality.append_on_chord(self.on, self.root)

components = self._quality.get_components(root=self._root)
if components[0] < 0:
components = [c + 12 for c in components]
return [f"{val_to_note(c, scale=self._root)}{root_pitch + c // 12}" for c in components]

def _append_on_chord(self):
if self._on:
self._quality.append_on_chord(self.on, self.root)

def _reconfigure_chord(self):
# TODO: Use appended
self._chord = "{}{}{}{}".format(self._root,
Expand Down
14 changes: 12 additions & 2 deletions pychord/parser.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
from typing import Tuple, List
import re

from .quality import QualityManager, Quality
from .utils import NOTE_VAL_DICT

inversion_re = re.compile("/([0-9]+)")


def parse(chord: str) -> Tuple[str, Quality, List[str], str]:
""" Parse a string to get chord component
"""Parse a string to get chord component
:param chord: str expression of a chord
:return: (root, quality, appended, on)
Expand All @@ -24,14 +27,21 @@ def check_note(note: str):
raise ValueError(f"Invalid note {note}")

check_note(root)

inversion = 0
inversion_m = inversion_re.search(rest)
if inversion_m:
inversion = int(inversion_m.group(1))
rest = inversion_re.sub("", rest)

on_chord_idx = rest.find("/")
if on_chord_idx >= 0:
on = rest[on_chord_idx + 1:]
rest = rest[:on_chord_idx]
check_note(on)
else:
on = ""
quality = QualityManager().get_quality(rest)
quality = QualityManager().get_quality(rest, inversion)
# TODO: Implement parser for appended notes
appended: List[str] = []
return root, quality, appended, on
15 changes: 12 additions & 3 deletions pychord/quality.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,9 @@ def append_on_chord(self, on_chord, root):
on_chord_val -= 12

if on_chord_val not in components:
components.insert(0, on_chord_val)
components = [on_chord_val] + [
v for v in components if v % 12 != on_chord_val % 12
]

self.components = tuple(components)

Expand All @@ -95,11 +97,18 @@ def load_default_qualities(self):
(q, Quality(q, c)) for q, c in DEFAULT_QUALITIES
])

def get_quality(self, name: str) -> Quality:
def get_quality(self, name: str, inversion: int = 0) -> Quality:
if name not in self._qualities:
raise ValueError(f"Unknown quality: {name}")
# Create a new instance not to affect any existing instances
return copy.deepcopy(self._qualities[name])
q = copy.deepcopy(self._qualities[name])
# apply requested inversion :
for i in range(inversion):
n = q.components[0]
while n < q.components[-1]:
n += 12
q.components = q.components[1:] + (n,)
return q

def set_quality(self, name: str, components: Tuple[int, ...]):
""" Set a Quality
Expand Down
31 changes: 30 additions & 1 deletion test/test_chord.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,30 @@ def test_minor_7b5_chord(self):
def test_invalid_slash_chord(self):
self.assertRaises(ValueError, Chord, "C/H")

def test_1st_order_inversion(self):
c = Chord("C/1")
self.assertEqual(c.root, "C")
self.assertEqual(c.quality.quality, "")
self.assertEqual(c.components(), ["E", "G", "C"])

def test_2nd_order_inversion(self):
c = Chord("C/2")
self.assertEqual(c.root, "C")
self.assertEqual(c.quality.quality, "")
self.assertEqual(c.components(), ["G", "C", "E"])

def test_inversion_complicated(self):
c = Chord("Dm7b5/1")
self.assertEqual(c.root, "D")
self.assertEqual(c.quality.quality, "m7b5")
self.assertEqual(c.components(), ["F", "G#", "C", "D"])

def test_inversion_with_alternate_bass(self):
c = Chord("C/1/F")
self.assertEqual(c.root, "C")
self.assertEqual(c.quality.quality, "")
self.assertEqual(c.components(), ["F", "E", "G", "C"])

def test_eq(self):
c1 = Chord("C")
c2 = Chord("C")
Expand All @@ -71,9 +95,14 @@ def test_invalid_eq(self):
with self.assertRaises(TypeError):
print(c == 0)

def test_components(self):
c = Chord("C/E")
quality_components_before = c.quality.components
c.components()
self.assertEqual(c.quality.components, quality_components_before)

class TestChordFromNoteIndex(unittest.TestCase):

class TestChordFromNoteIndex(unittest.TestCase):
def test_note_1(self):
chord = Chord.from_note_index(note=1, quality="", scale="Cmaj")
self.assertEqual(chord, Chord("C"))
Expand Down
40 changes: 40 additions & 0 deletions test/test_component.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,46 @@ def test_add9_chord(self):
com = c.components_with_pitch(root_pitch=5)
self.assertEqual(com, ["E5", "G#5", "B5", "F#6"])

def test_first_order_inversion(self):
c = Chord("G/1")
com = c.components_with_pitch(root_pitch=4)
self.assertEqual(com, ["B4", "D5", "G5"])
c2 = Chord("G13b9/1")
com2 = c2.components_with_pitch(root_pitch=4)
self.assertEqual(com2, ['B4', 'D5', 'F5', 'G#5', 'E6', 'G6'])

def test_second_order_inversion(self):
c = Chord("G/2")
com = c.components_with_pitch(root_pitch=4)
self.assertEqual(com, ["D5", "G5", "B5"])
c2 = Chord("G13b9/2")
com2 = c2.components_with_pitch(root_pitch=4)
self.assertEqual(com2, ['D5', 'F5', 'G#5', 'E6', 'G6', 'B6'])

def test_third_order_inversion(self):
c = Chord("Cm7/3")
com = c.components_with_pitch(root_pitch=4)
self.assertEqual(com, ['Bb4', 'C5', 'Eb5', 'G5'])
c2 = Chord("F#7/3")
com2 = c2.components_with_pitch(root_pitch=4)
self.assertEqual(com2, ['E5', 'F#5', 'A#5', 'C#6'])
c3 = Chord("G13b9/3")
com3 = c3.components_with_pitch(root_pitch=4)
self.assertEqual(com3, ['F5', 'G#5', 'E6', 'G6', 'B6', 'D7'])

def test_fourth_order_inversion(self):
c = Chord("F7b9")
com = c.components_with_pitch(root_pitch=4)
self.assertEqual(com, ['F4', 'A4', 'C5', 'Eb5', 'Gb5'])
c2 = Chord("G13b9/4")
com2 = c2.components_with_pitch(root_pitch=4)
self.assertEqual(com2, ['G#5', 'E6', 'G6', 'B6', 'D7', 'F7'])

def test_fifth_order_inversion(self):
c = Chord("G13b9/5")
com = c.components_with_pitch(root_pitch=4)
self.assertEqual(com, ['E6', 'G6', 'B6', 'D7', 'F7', 'G#7'])


if __name__ == '__main__':
unittest.main()
8 changes: 8 additions & 0 deletions test/test_transpose.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,16 @@ def test_transpose_slash(self):
self.assertEqual(c.root, "C")
self.assertEqual(c.quality.quality, "m7")
self.assertEqual(c.on, "Bb")
self.assertEqual(c._chord, Chord("Cm7/Bb")._chord)
self.assertEqual(c.quality.components, Chord("Cm7/Bb").quality.components)
self.assertEqual(c, Chord("Cm7/Bb"))

def test_transpose_inversion(self):
c = Chord("Am7/3")
c.transpose(3)
self.assertEqual(c.root, "C")
self.assertEqual(c.quality.quality, "m7")

def test_invalid_transpose_type(self):
c = Chord("Am")
self.assertRaises(TypeError, c.transpose, "A")
Expand Down

0 comments on commit 4e97f8a

Please sign in to comment.