From 290e40e0bf150ab889ada06e500a5835d3935da6 Mon Sep 17 00:00:00 2001 From: Scott Gasch Date: Fri, 22 Apr 2022 14:28:44 -0700 Subject: [PATCH] Add powerset to list_utils; improve chord parser. --- list_utils.py | 19 ++++- music/chords.g4 | 19 +++-- music/chords.py | 165 ++++++++++++++++++++++++++++++++++--------- tests/chords_test.py | 15 ++-- 4 files changed, 165 insertions(+), 53 deletions(-) diff --git a/list_utils.py b/list_utils.py index 5c70df3..1141af2 100644 --- a/list_utils.py +++ b/list_utils.py @@ -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 diff --git a/music/chords.g4 b/music/chords.g4 index bd180a9..9083e75 100644 --- a/music/chords.g4 +++ b/music/chords.g4 @@ -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 ; diff --git a/music/chords.py b/music/chords.py index ff4b540..838ad17 100755 --- a/music/chords.py +++ b/music/chords.py @@ -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()) diff --git a/tests/chords_test.py b/tests/chords_test.py index 6776f3f..8ecce71 100755 --- a/tests/chords_test.py +++ b/tests/chords_test.py @@ -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() -- 2.47.1