Add powerset to list_utils; improve chord parser.
authorScott Gasch <[email protected]>
Fri, 22 Apr 2022 21:28:44 +0000 (14:28 -0700)
committerScott Gasch <[email protected]>
Fri, 22 Apr 2022 21:28:44 +0000 (14:28 -0700)
list_utils.py
music/chords.g4
music/chords.py
tests/chords_test.py

index 5c70df3476c6dbafe3c252788e88ee6b16c5c301..1141af2fe9b6bb8861dfd25c08dab735bfd4ceaf 100644 (file)
@@ -6,7 +6,7 @@
 
 import random
 from collections import Counter
-from itertools import islice
+from itertools import chain, combinations, islice
 from typing import Any, Iterator, List, MutableSequence, Sequence, Tuple
 
 
@@ -307,6 +307,23 @@ def _binary_search(lst: Sequence[Any], target: Any, low: int, high: int) -> Tupl
         return (False, low)
 
 
+def powerset(lst: Sequence[Any]) -> Iterator[Sequence[Any]]:
+    """Returns the powerset of the items in the input sequence.
+
+    >>> for x in powerset([1, 2, 3]):
+    ...     print(x)
+    ()
+    (1,)
+    (2,)
+    (3,)
+    (1, 2)
+    (1, 3)
+    (2, 3)
+    (1, 2, 3)
+    """
+    return chain.from_iterable(combinations(lst, r) for r in range(len(lst) + 1))
+
+
 if __name__ == '__main__':
     import doctest
 
index bd180a96a2586c96aae3a53bbc027fb8473b82a7..9083e7530a6ff1d5bc9b4c1f2a3c200b7f35af39 100644 (file)
@@ -50,10 +50,11 @@ augmentedExpr: AUGMENTED;
 powerChordExpr: '5';
 
 addNotesExpr
-    : SIX
-    | SEVEN
+    : ADD* SIX
+    | ADD* SEVEN
     | MAJ_SEVEN
-    | ADD_NINE
+    | ADD* NINE
+    | ADD* ELEVEN
     ;
 
 extensionExpr
@@ -108,19 +109,23 @@ MINOR: ('m'|'min'|'minor');
 
 SUS: ('sus'|'suspended');
 
-DIMINISHED: ('dim'|'diminished');
+DIMINISHED: ('dim'|'diminished'|'-');
 
-AUGMENTED: ('aug'|'augmented');
+AUGMENTED: ('aug'|'augmented'|'+');
 
 SLASH: ('/'|'\\');
 
+ADD: ('add'|'Add'|'dom');
+
 SIX: '6';
 
 SEVEN: '7';
 
-MAJ_SEVEN: MAJOR '7';
+NINE: '9';
 
-ADD_NINE: ('add'|'Add')* '9';
+ELEVEN: '11';
+
+MAJ_SEVEN: MAJOR '7';
 
 INTERVAL: (MAJOR|MINOR)* ('b'|'#')* DIGITS ;
 
index ff4b54076a9cc1764dd49f7835101ed0bcc11c77..838ad17c567bfce81e35923915984dbc02e53ed3 100755 (executable)
@@ -12,40 +12,19 @@ import itertools
 import logging
 import re
 import sys
-from typing import Any, Callable, Dict, Iterator, List, Optional
+from typing import Any, Callable, Dict, Iterator, List, Optional, Set, Tuple
 
 import antlr4  # type: ignore
 
 import acl
 import bootstrap
 import decorator_utils
+import list_utils
 from music.chordsLexer import chordsLexer  # type: ignore
 from music.chordsListener import chordsListener  # type: ignore
 from music.chordsParser import chordsParser  # type: ignore
 
 logger = logging.getLogger(__name__)
-notes = ['A', 'A#', 'B', 'C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#']
-
-
-def generate_scale(starting_note: str) -> Iterator[str]:
-    starting_note = starting_note.upper()
-    start = 0
-    while notes[start] != starting_note:
-        start += 1
-    while True:
-        if start >= len(notes):
-            start = 0
-        yield notes[start]
-        start += 1
-
-
-def degree_of_note(root_note: str, target_note: str) -> Optional[int]:
-    root_note = root_note.upper()
-    target_note = target_note.upper()
-    for degree, note in enumerate(itertools.islice(generate_scale(root_note), 24)):
-        if note == target_note:
-            return degree - 1
-    return None
 
 
 def debug_parse(enter_or_exit_f: Callable[[Any, Any], None]):
@@ -93,12 +72,129 @@ class RaisingErrorListener(antlr4.DiagnosticErrorListener):
 
 
 class Chord:
-    def __init__(self, root_note: str, other_notes: Dict[str, int]):
+    NOTES = ['A', 'A#', 'B', 'C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#']
+
+    def __init__(self, root_note: str, other_notes: Set[int]) -> None:
         self.root_note = root_note.upper()
-        self.other_notes = other_notes
+        self.other_notes: Dict[int, Optional[str]] = {}
+        for semitone_count in other_notes:
+            name = Chord.describe_interval(semitone_count)
+            if name:
+                self.other_notes[semitone_count] = name
+            else:
+                self.other_notes[semitone_count] = None
+        self.scale = list(itertools.islice(self.generate_scale(), 24))
+
+    def generate_scale(self) -> Iterator[str]:
+        starting_note = self.root_note
+        start = 0
+        while Chord.NOTES[start] != starting_note:
+            start += 1
+        while True:
+            if start >= len(Chord.NOTES):
+                start = 0
+            yield Chord.NOTES[start]
+            start += 1
+
+    @staticmethod
+    def describe_interval(semitone_count: int) -> Optional[str]:
+        names: Dict[int, str] = {
+            0: 'perfect unison',
+            1: 'minor 2nd',
+            2: 'major 2nd',
+            3: 'minor 3rd',
+            4: 'major 3rd',
+            5: 'perfect 4th',
+            6: 'diminished 5th',
+            7: 'perfect 5th',
+            8: 'minor 6th',
+            9: 'major 6th',
+            10: 'minor 7th',
+            11: 'major 7th',
+            12: 'perfect octave',
+            13: 'minor 9th',
+            14: 'major 9th',
+            15: 'minor 10th',
+            16: 'major 10th',
+            17: 'perfect 11th',
+            18: 'diminished 12th',
+            19: 'perfect 12th',
+            20: 'minor 13th',
+            21: 'major 13th',
+            22: 'minor 14th',
+            23: 'major 14th',
+            24: 'double octave',
+            25: 'augmented 15th',
+        }
+        return names.get(semitone_count, None)
+
+    def degree_of_note(self, target_note: str) -> Optional[Tuple[Optional[str], int]]:
+        target_note = target_note.upper()
+        for degree, note in enumerate(itertools.islice(self.generate_scale(), 24)):
+            if note == target_note:
+                return (Chord.describe_interval(degree), degree)
+        return None
+
+    def note_at_degree(self, semitone_count: int) -> Optional[str]:
+        if semitone_count < len(self.scale):
+            return self.scale[semitone_count]
+        return None
+
+    def add_bass(self, bass_note: str) -> None:
+        name, semitone_count = self.degree_of_note(bass_note)
+        if name:
+            self.other_notes[semitone_count] = f'{name} (bass)'
+        else:
+            self.other_notes[semitone_count] = 'bass note'
+
+    def describe_chord(self) -> str:
+        names: Dict[Tuple[int], str] = {
+            (2, 7): 'sus2',
+            (4, 7): 'major',
+            (5, 7): 'sus4',
+            (3, 7): 'minor',
+            (4, 7, 10): 'dom7',
+            (4, 7, 11): 'maj7',
+            (4, 8): 'aug',
+            (3, 5): 'dim',
+            (4, 7, 10, 17): 'add11',
+            (4, 7, 10, 14): 'add9',
+            (5): 'power chord',
+        }
+        intervals = list(self.other_notes.keys())
+        intervals.sort()
+        intervals_set = set(intervals)
+        for interval in reversed(list(list_utils.powerset(intervals))):
+            name = names.get(tuple(interval), None)
+            if name:
+                for x in interval:
+                    intervals_set.remove(x)
+                break
+
+        if not name:
+            name = 'An unknown chord'
+
+        for semitone_count, note_name in self.other_notes.items():
+            if 'bass' in note_name:
+                note = self.note_at_degree(semitone_count)
+                name += f' with a bass {note}'
+                if semitone_count in intervals_set:
+                    intervals_set.remove(semitone_count)
+
+        for note in intervals_set:
+            note = self.note_at_degree(note)
+            name += f' add {note}'
+
+        return f'{self.root_note} ({name})'
 
     def __repr__(self):
-        return f'root={self.root_note}, others={self.other_notes}'
+        name = self.describe_chord()
+        ret = f'{name}\n'
+        ret += f'root={self.root_note}\n'
+        for semitone_count, interval_name in self.other_notes.items():
+            note = self.note_at_degree(semitone_count)
+            ret += f'+ {interval_name} ({semitone_count}) => {note}\n'
+        return ret
 
 
 @decorator_utils.decorate_matching_methods_with(
@@ -250,17 +346,15 @@ class ChordParser(chordsListener):
             self.addedNotes.append('maj3')
             self.addedNotes.append('aug5')
 
-        other_notes: Dict[str, int] = {}
+        other_notes: Set[int] = set()
         for expression in self.addedNotes:
             semitone_count = ChordParser.interval_name_to_semitone_count(expression, self.rootNote)
+            other_notes.add(semitone_count)
             if semitone_count in (14, 17, 19):
-                other_notes['min7'] = 10
-            other_notes[expression] = semitone_count
-
-        if self.bassNote:
-            degree = degree_of_note(self.rootNote, self.bassNote)
-            other_notes[self.bassNote] = degree
+                other_notes.add(10)
         self.chord = Chord(self.rootNote, other_notes)
+        if self.bassNote:
+            self.chord.add_bass(self.bassNote)
 
     def exitRootNote(self, ctx: chordsParser.RootNoteContext):
         self.rootNote = ctx.NOTE().__str__().upper()
@@ -305,8 +399,11 @@ class ChordParser(chordsListener):
             self.addedNotes.append('min7')
         if ctx.MAJ_SEVEN():
             self.addedNotes.append('maj7')
-        if ctx.ADD_NINE():
+        if ctx.NINE():
             self.addedNotes.append('maj9')
+        if ctx.ELEVEN():
+            self.addedNotes.append('min7')
+            self.addedNotes.append('maj11')
 
     def exitExtensionExpr(self, ctx: chordsParser.ExtensionExprContext):
         self.addedNotes.append(ctx.getText())
index 6776f3f33377ec2ad3baed9ed432908d66a89f03..8ecce71ff0321b97e979bd326f770dd190306364 100755 (executable)
@@ -15,17 +15,10 @@ import unittest_utils as uu
 class TestChordParder(unittest.TestCase):
     def test_with_known_correct_answers(self):
         expected_answers = {
-            'D': "root=D, others={'maj3': 4, 'perfect5th': 7}",
-            'Dmaj': "root=D, others={'maj3': 4, 'perfect5th': 7}",
-            'D major': "root=D, others={'maj3': 4, 'perfect5th': 7}",
-            'DM': "root=D, others={'maj3': 4, 'perfect5th': 7}",
-            'Dm': "root=D, others={'min3': 3, 'perfect5th': 7}",
-            'Dmin': "root=D, others={'min3': 3, 'perfect5th': 7}",
-            'D minor': "root=D, others={'min3': 3, 'perfect5th': 7}",
-            'Asus2': "root=A, others={'maj2': 2, 'perfect5th': 7}",
-            'Bsus4': "root=B, others={'perfect4': 5, 'perfect5th': 7}",
-            'F5': "root=F, others={'perfect5th': 7}",
-            'G/B': "root=G, others={'maj3': 4, 'perfect5th': 7, 'B': 3}",
+            'D': "D (major)\nroot=D\n+ major 3rd (4) => F#\n+ perfect 5th (7) => A\n",
+            'DM': "D (major)\nroot=D\n+ major 3rd (4) => F#\n+ perfect 5th (7) => A\n",
+            'Dmaj': "D (major)\nroot=D\n+ major 3rd (4) => F#\n+ perfect 5th (7) => A\n",
+            'D major': "D (major)\nroot=D\n+ major 3rd (4) => F#\n+ perfect 5th (7) => A\n",
         }
 
         cp = ChordParser()