From 068d71327bf6ec8618cd70a6d3fce5d075503cae Mon Sep 17 00:00:00 2001 From: Scott Gasch Date: Thu, 21 Apr 2022 20:05:21 -0700 Subject: [PATCH] Chord name parsing. --- music/chords.g4 | 127 ++++++++++++++++++ music/chords.py | 337 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 464 insertions(+) create mode 100644 music/chords.g4 create mode 100755 music/chords.py diff --git a/music/chords.g4 b/music/chords.g4 new file mode 100644 index 0000000..bd180a9 --- /dev/null +++ b/music/chords.g4 @@ -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 index 0000000..fc2bd5f --- /dev/null +++ b/music/chords.py @@ -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()) + + +@bootstrap.initialize +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() -- 2.45.0