--- /dev/null
+#!/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()