Spacing tweak.
[python_utils.git] / music / chords.py
index fc2bd5fa7aeb3af3b62cc6c8a6bf81740418805d..dc2220545ad5b0f0836e91010b2b697694950760 100755 (executable)
@@ -12,41 +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)):
-        print(f'"{target_note}", "{note}", {degree}')
-        if note == target_note:
-            return degree - 1
-    return None
 
 
 def debug_parse(enter_or_exit_f: Callable[[Any, Any], None]):
@@ -94,9 +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):
+        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(
@@ -213,8 +311,6 @@ class ChordParser(chordsListener):
 
     def exitParse(self, ctx: chordsParser.ParseContext) -> None:
         """Populate self.chord"""
-        print(f'Root note is a {self.rootNote}')
-        scale = list(itertools.islice(generate_scale(self.rootNote), 24))
 
         chord_types_with_perfect_5th = set(
             [
@@ -250,21 +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
-
-        for expression, semitone_count in other_notes.items():
-            note_name = scale[semitone_count]
-            print(f'Contains: {expression} ({semitone_count} semitones) => {note_name}')
-        if self.bassNote:
-            degree = degree_of_note(self.rootNote, self.bassNote)
-            print(f'Add a {self.bassNote} ({degree}) in the bass')
-            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()
@@ -309,8 +399,14 @@ class ChordParser(chordsListener):
             self.addedNotes.append('min7')
         if ctx.MAJ_SEVEN():
             self.addedNotes.append('maj7')
-        if ctx.ADD_NINE():
+        if ctx.MIN_SEVEN():
+            self.addedNotes.append('min7')
+            self.chordType = ChordParser.MINOR
+        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())