#!/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, 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__) 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: 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: 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( 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""" 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: 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.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() 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.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()) @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()