Chord name parsing.
authorScott Gasch <[email protected]>
Fri, 22 Apr 2022 03:05:21 +0000 (20:05 -0700)
committerScott Gasch <[email protected]>
Fri, 22 Apr 2022 03:05:21 +0000 (20:05 -0700)
music/chords.g4 [new file with mode: 0644]
music/chords.py [new file with mode: 0755]

diff --git a/music/chords.g4 b/music/chords.g4
new file mode 100644 (file)
index 0000000..bd180a9
--- /dev/null
@@ -0,0 +1,127 @@
+// © Copyright 2022, Scott Gasch
+//
+// antlr4 -Dlanguage=Python3 ./chords.g4
+//
+// Hi, self.  In ANTLR grammars, there are two separate types of symbols: those
+// for the lexer and those for the parser.  The former begin with a CAPITAL
+// whereas the latter begin with lowercase.  The order of the lexer symbols
+// is the order that the lexer will recognize them in.  There's a good tutorial
+// on this shit at:
+//
+//    https://tomassetti.me/antlr-mega-tutorial/
+//
+// There are also a zillion premade grammars at:
+//
+//    https://github.com/antlr/grammars-v4
+
+grammar chords;
+
+parse
+    : rootNote majMinSusPowerExpr* addNotesExpr* extensionExpr* overBassNoteExpr*
+    ;
+
+rootNote
+    : NOTE
+    ;
+
+overBassNoteExpr
+    : SLASH NOTE
+    ;
+
+majMinSusPowerExpr
+    : majExpr
+    | minExpr
+    | susExpr
+    | diminishedExpr
+    | augmentedExpr
+    | powerChordExpr
+    ;
+
+majExpr: MAJOR;
+
+minExpr: MINOR;
+
+susExpr: SUS ('2'|'4');
+
+diminishedExpr: DIMINISHED;
+
+augmentedExpr: AUGMENTED;
+
+powerChordExpr: '5';
+
+addNotesExpr
+    : SIX
+    | SEVEN
+    | MAJ_SEVEN
+    | ADD_NINE
+    ;
+
+extensionExpr
+    : INTERVAL
+    ;
+
+SPACE: [ \t\r\n] -> skip;
+
+NOTE: (AS|BS|CS|DS|ES|FS|GS) ;
+
+AS
+    : ('A'|'a')
+    | ('Ab'|'ab')
+    | ('A#'|'a#')
+    ;
+
+BS
+    : ('B'|'b')
+    | ('Bb'|'bb')
+    ;
+
+CS
+    : ('C'|'c')
+    | ('C#'|'c#')
+    ;
+
+DS
+    : ('D'|'d')
+    | ('Db'|'db')
+    | ('D#'|'d#')
+    ;
+
+ES
+    : ('E'|'e')
+    | ('Eb'|'eb')
+    ;
+
+FS
+    : ('F'|'f')
+    | ('F#'|'f#')
+    ;
+
+GS
+    : ('G'|'g')
+    | ('Gb'|'gb')
+    | ('G#'|'g#')
+    ;
+
+MAJOR: ('M'|'Maj'|'maj'|'Major'|'major');
+
+MINOR: ('m'|'min'|'minor');
+
+SUS: ('sus'|'suspended');
+
+DIMINISHED: ('dim'|'diminished');
+
+AUGMENTED: ('aug'|'augmented');
+
+SLASH: ('/'|'\\');
+
+SIX: '6';
+
+SEVEN: '7';
+
+MAJ_SEVEN: MAJOR '7';
+
+ADD_NINE: ('add'|'Add')* '9';
+
+INTERVAL: (MAJOR|MINOR)* ('b'|'#')* DIGITS ;
+
+DIGITS: [1-9]+ ;
diff --git a/music/chords.py b/music/chords.py
new file mode 100755 (executable)
index 0000000..fc2bd5f
--- /dev/null
@@ -0,0 +1,337 @@
+#!/usr/bin/env python3
+# type: ignore
+# pylint: disable=W0201
+# pylint: disable=R0904
+
+# © Copyright 2021-2022, Scott Gasch
+
+"""Parse music chords; work in progress..."""
+
+import functools
+import itertools
+import logging
+import re
+import sys
+from typing import Any, Callable, Dict, Iterator, List, Optional
+
+import antlr4  # type: ignore
+
+import acl
+import bootstrap
+import decorator_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]):
+    @functools.wraps(enter_or_exit_f)
+    def debug_parse_wrapper(*args, **kwargs):
+        # slf = args[0]
+        ctx = args[1]
+        depth = ctx.depth()
+        logger.debug(
+            '  ' * (depth - 1)
+            + f'Entering {enter_or_exit_f.__name__} ({ctx.invokingState} / {ctx.exception})'
+        )
+        for c in ctx.getChildren():
+            logger.debug('  ' * (depth - 1) + f'{c} {type(c)}')
+        retval = enter_or_exit_f(*args, **kwargs)
+        return retval
+
+    return debug_parse_wrapper
+
+
+class ParseException(Exception):
+    """An exception thrown during parsing because of unrecognized input."""
+
+    def __init__(self, message: str) -> None:
+        super().__init__()
+        self.message = message
+
+
+class RaisingErrorListener(antlr4.DiagnosticErrorListener):
+    """An error listener that raises ParseExceptions."""
+
+    def syntaxError(self, recognizer, offendingSymbol, line, column, msg, e):
+        raise ParseException(msg)
+
+    def reportAmbiguity(self, recognizer, dfa, startIndex, stopIndex, exact, ambigAlts, configs):
+        pass
+
+    def reportAttemptingFullContext(
+        self, recognizer, dfa, startIndex, stopIndex, conflictingAlts, configs
+    ):
+        pass
+
+    def reportContextSensitivity(self, recognizer, dfa, startIndex, stopIndex, prediction, configs):
+        pass
+
+
+class Chord:
+    def __init__(self, root_note: str, other_notes: Dict[str, int]):
+        self.root_note = root_note.upper()
+        self.other_notes = other_notes
+
+
+@decorator_utils.decorate_matching_methods_with(
+    debug_parse,
+    acl=acl.StringWildcardBasedACL(
+        allowed_patterns=[
+            'enter*',
+            'exit*',
+        ],
+        denied_patterns=['enterEveryRule', 'exitEveryRule'],
+        order_to_check_allow_deny=acl.Order.DENY_ALLOW,
+        default_answer=False,
+    ),
+)
+class ChordParser(chordsListener):
+    """A class to parse dates expressed in human language."""
+
+    MINOR = 0
+    MAJOR = 1
+    SUSPENDED = 2
+    DIMINISHED = 3
+    AUGMENTED = 4
+    POWER = 5
+
+    def __init__(self) -> None:
+        pass
+
+    @staticmethod
+    def interval_name_to_semitone_count(
+        interval_name: str, root: Optional[str]
+    ) -> Optional[List[int]]:
+        interval_name = interval_name.lower()
+        interval_name = re.sub(r'\s+', '', interval_name)
+        interval_name = re.sub(r'th', '', interval_name)
+        interval_name = re.sub(r'add', '', interval_name)
+        interval_name = re.sub(r'perfect', '', interval_name)
+        logger.debug('Canonicalized interval name: %s', interval_name)
+
+        number = None
+        g = re.search(r'[1-9]+', interval_name)
+        if g:
+            number = int(g.group(0))
+        else:
+            return None
+        logger.debug('Number: %d', number)
+
+        minor = 'min' in interval_name or 'b' in interval_name
+        diminished = 'dim' in interval_name
+        augmented = 'aug' in interval_name or '#' in interval_name
+        base_intervals = {
+            2: 2,
+            3: 4,
+            4: 5,
+            5: 7,
+            6: 9,
+            7: 11,
+            9: 14,
+            10: 16,
+            11: 17,
+            13: 19,
+        }
+
+        base_interval = base_intervals.get(number, None)
+        if base_interval is None:
+            return None
+        logger.debug('Starting base_interval is %d', base_interval)
+
+        if diminished:
+            logger.debug('Diminished...')
+            base_interval -= 2
+        elif minor:
+            logger.debug('Minor...')
+            base_interval -= 1
+        elif augmented:
+            logger.debug('Augmented...')
+            base_interval += 1
+        logger.debug('Returning %d semitones.', base_interval)
+        return base_interval
+
+    def parse(self, chord_string: str) -> Optional[Chord]:
+        chord_string = chord_string.strip()
+        chord_string = re.sub(r'\s+', ' ', chord_string)
+        self._reset()
+        listener = RaisingErrorListener()
+        input_stream = antlr4.InputStream(chord_string)
+        lexer = chordsLexer(input_stream)
+        lexer.removeErrorListeners()
+        lexer.addErrorListener(listener)
+        stream = antlr4.CommonTokenStream(lexer)
+        parser = chordsParser(stream)
+        parser.removeErrorListeners()
+        parser.addErrorListener(listener)
+        tree = parser.parse()
+        walker = antlr4.ParseTreeWalker()
+        walker.walk(self, tree)
+        return self.chord
+
+    def _reset(self) -> None:
+        self.chord = None
+        self.rootNote = None
+        self.susNote = None
+        self.bassNote = None
+        self.chordType = ChordParser.MAJOR
+        self.addedNotes = []
+
+    # -- overridden methods invoked by parse walk.  Note: not part of the class'
+    # public API(!!) --
+
+    def visitErrorNode(self, node: antlr4.ErrorNode) -> None:
+        pass
+
+    def visitTerminal(self, node: antlr4.TerminalNode) -> None:
+        pass
+
+    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(
+            [
+                ChordParser.MAJOR,
+                ChordParser.MINOR,
+                ChordParser.SUSPENDED,
+                ChordParser.POWER,
+            ]
+        )
+        if self.chordType in chord_types_with_perfect_5th:
+            if self.chordType == ChordParser.MAJOR:
+                logger.debug('Major chord.')
+                self.addedNotes.append('maj3')
+            elif self.chordType == ChordParser.MINOR:
+                logger.debug('Minor chord.')
+                self.addedNotes.append('min3')
+            elif self.chordType == ChordParser.SUSPENDED:
+                if self.susNote == 2:
+                    logger.debug('sus2 chord.')
+                    self.addedNotes.append('maj2')
+                elif self.susNote == 4:
+                    logger.debug('sus4 chord.')
+                    self.addedNotes.append('perfect4')
+            elif self.chordType == ChordParser.POWER:
+                logger.debug('Power chord.')
+            self.addedNotes.append('perfect5th')
+        elif self.chordType == ChordParser.DIMINISHED:
+            logger.debug('Diminished chord.')
+            self.addedNotes.append('min3')
+            self.addedNotes.append('dim5')
+        elif self.chordType == ChordParser.AUGMENTED:
+            logger.debug('Augmented chord.')
+            self.addedNotes.append('maj3')
+            self.addedNotes.append('aug5')
+
+        other_notes: Dict[str, int] = {}
+        for expression in self.addedNotes:
+            semitone_count = ChordParser.interval_name_to_semitone_count(expression, self.rootNote)
+            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
+        self.chord = Chord(self.rootNote, other_notes)
+
+    def exitRootNote(self, ctx: chordsParser.RootNoteContext):
+        self.rootNote = ctx.NOTE().__str__().upper()
+        logger.debug('Root note is "%s"', self.rootNote)
+
+    def exitOverBassNoteExpr(self, ctx: chordsParser.OverBassNoteExprContext):
+        self.bassNote = ctx.NOTE().__str__().upper()
+        logger.debug('Bass note is "%s"', self.bassNote)
+
+    def exitPowerChordExpr(self, ctx: chordsParser.PowerChordExprContext):
+        self.chordType = ChordParser.POWER
+        logger.debug('Power chord')
+
+    def exitMajExpr(self, ctx: chordsParser.MajExprContext):
+        self.chordType = ChordParser.MAJOR
+        logger.debug('Major')
+
+    def exitMinExpr(self, ctx: chordsParser.MinExprContext):
+        self.chordType = ChordParser.MINOR
+        logger.debug('Minor')
+
+    def exitSusExpr(self, ctx: chordsParser.SusExprContext):
+        self.chordType = ChordParser.SUSPENDED
+        logger.debug('Suspended')
+        if '2' in ctx.getText():
+            self.susNote = 2
+        elif '4' in ctx.getText():
+            self.susNote = 4
+
+    def exitDiminishedExpr(self, ctx: chordsParser.DiminishedExprContext):
+        self.chordType = ChordParser.DIMINISHED
+        logger.debug('Diminished')
+
+    def exitAugmentedExpr(self, ctx: chordsParser.AugmentedExprContext):
+        self.chordType = ChordParser.AUGMENTED
+        logger.debug('Augmented')
+
+    def exitAddNotesExpr(self, ctx: chordsParser.AddNotesExprContext):
+        if ctx.SIX():
+            self.addedNotes.append('maj6')
+        if ctx.SEVEN():
+            self.addedNotes.append('min7')
+        if ctx.MAJ_SEVEN():
+            self.addedNotes.append('maj7')
+        if ctx.ADD_NINE():
+            self.addedNotes.append('maj9')
+
+    def exitExtensionExpr(self, ctx: chordsParser.ExtensionExprContext):
+        self.addedNotes.append(ctx.getText())
+
+
+def main() -> None:
+    parser = ChordParser()
+    for line in sys.stdin:
+        line = line.strip()
+        line = re.sub(r"#.*$", "", line)
+        if re.match(r"^ *$", line) is not None:
+            continue
+        try:
+            chord = parser.parse(line)
+            print(chord)
+        except Exception as e:
+            logger.exception(e)
+            print("Unrecognized.")
+    sys.exit(0)
+
+
+if __name__ == "__main__":
+    main()