3 # pylint: disable=W0201
4 # pylint: disable=R0904
6 # © Copyright 2021-2022, Scott Gasch
8 """Parse music chords; work in progress..."""
15 from typing import Any, Callable, Dict, Iterator, List, Optional, Set, Tuple
17 import antlr4 # type: ignore
21 import decorator_utils
23 from music.chordsLexer import chordsLexer # type: ignore
24 from music.chordsListener import chordsListener # type: ignore
25 from music.chordsParser import chordsParser # type: ignore
27 logger = logging.getLogger(__name__)
30 def debug_parse(enter_or_exit_f: Callable[[Any, Any], None]):
31 @functools.wraps(enter_or_exit_f)
32 def debug_parse_wrapper(*args, **kwargs):
38 + f'Entering {enter_or_exit_f.__name__} ({ctx.invokingState} / {ctx.exception})'
40 for c in ctx.getChildren():
41 logger.debug(' ' * (depth - 1) + f'{c} {type(c)}')
42 retval = enter_or_exit_f(*args, **kwargs)
45 return debug_parse_wrapper
48 class ParseException(Exception):
49 """An exception thrown during parsing because of unrecognized input."""
51 def __init__(self, message: str) -> None:
53 self.message = message
56 class RaisingErrorListener(antlr4.DiagnosticErrorListener):
57 """An error listener that raises ParseExceptions."""
59 def syntaxError(self, recognizer, offendingSymbol, line, column, msg, e):
60 raise ParseException(msg)
62 def reportAmbiguity(self, recognizer, dfa, startIndex, stopIndex, exact, ambigAlts, configs):
65 def reportAttemptingFullContext(
66 self, recognizer, dfa, startIndex, stopIndex, conflictingAlts, configs
70 def reportContextSensitivity(self, recognizer, dfa, startIndex, stopIndex, prediction, configs):
75 NOTES = ['A', 'A#', 'B', 'C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#']
77 def __init__(self, root_note: str, other_notes: Set[int]) -> None:
78 self.root_note = root_note.upper()
79 self.other_notes: Dict[int, Optional[str]] = {}
80 for semitone_count in other_notes:
81 name = Chord.describe_interval(semitone_count)
83 self.other_notes[semitone_count] = name
85 self.other_notes[semitone_count] = None
86 self.scale = list(itertools.islice(self.generate_scale(), 24))
88 def generate_scale(self) -> Iterator[str]:
89 starting_note = self.root_note
91 while Chord.NOTES[start] != starting_note:
94 if start >= len(Chord.NOTES):
96 yield Chord.NOTES[start]
100 def describe_interval(semitone_count: int) -> Optional[str]:
101 names: Dict[int, str] = {
114 12: 'perfect octave',
120 18: 'diminished 12th',
127 25: 'augmented 15th',
129 return names.get(semitone_count, None)
131 def degree_of_note(self, target_note: str) -> Optional[Tuple[Optional[str], int]]:
132 target_note = target_note.upper()
133 for degree, note in enumerate(itertools.islice(self.generate_scale(), 24)):
134 if note == target_note:
135 return (Chord.describe_interval(degree), degree)
138 def note_at_degree(self, semitone_count: int) -> Optional[str]:
139 if semitone_count < len(self.scale):
140 return self.scale[semitone_count]
143 def add_bass(self, bass_note: str) -> None:
144 name, semitone_count = self.degree_of_note(bass_note)
146 self.other_notes[semitone_count] = f'{name} (bass)'
148 self.other_notes[semitone_count] = 'bass note'
150 def describe_chord(self) -> str:
151 names: Dict[Tuple[int], str] = {
160 (4, 7, 10, 17): 'add11',
161 (4, 7, 10, 14): 'add9',
164 intervals = list(self.other_notes.keys())
166 intervals_set = set(intervals)
167 for interval in reversed(list(list_utils.powerset(intervals))):
168 name = names.get(tuple(interval), None)
171 intervals_set.remove(x)
175 name = 'An unknown chord'
177 for semitone_count, note_name in self.other_notes.items():
178 if 'bass' in note_name:
179 note = self.note_at_degree(semitone_count)
180 name += f' with a bass {note}'
181 if semitone_count in intervals_set:
182 intervals_set.remove(semitone_count)
184 for note in intervals_set:
185 note = self.note_at_degree(note)
186 name += f' add {note}'
188 return f'{self.root_note} ({name})'
191 name = self.describe_chord()
193 ret += f'root={self.root_note}\n'
194 for semitone_count, interval_name in self.other_notes.items():
195 note = self.note_at_degree(semitone_count)
196 ret += f'+ {interval_name} ({semitone_count}) => {note}\n'
200 @decorator_utils.decorate_matching_methods_with(
202 acl=acl.StringWildcardBasedACL(
207 denied_patterns=['enterEveryRule', 'exitEveryRule'],
208 order_to_check_allow_deny=acl.Order.DENY_ALLOW,
209 default_answer=False,
212 class ChordParser(chordsListener):
213 """A class to parse dates expressed in human language."""
222 def __init__(self) -> None:
226 def interval_name_to_semitone_count(
227 interval_name: str, root: Optional[str]
228 ) -> Optional[List[int]]:
229 interval_name = interval_name.lower()
230 interval_name = re.sub(r'\s+', '', interval_name)
231 interval_name = re.sub(r'th', '', interval_name)
232 interval_name = re.sub(r'add', '', interval_name)
233 interval_name = re.sub(r'perfect', '', interval_name)
234 logger.debug('Canonicalized interval name: %s', interval_name)
237 g = re.search(r'[1-9]+', interval_name)
239 number = int(g.group(0))
242 logger.debug('Number: %d', number)
244 minor = 'min' in interval_name or 'b' in interval_name
245 diminished = 'dim' in interval_name
246 augmented = 'aug' in interval_name or '#' in interval_name
260 base_interval = base_intervals.get(number, None)
261 if base_interval is None:
263 logger.debug('Starting base_interval is %d', base_interval)
266 logger.debug('Diminished...')
269 logger.debug('Minor...')
272 logger.debug('Augmented...')
274 logger.debug('Returning %d semitones.', base_interval)
277 def parse(self, chord_string: str) -> Optional[Chord]:
278 chord_string = chord_string.strip()
279 chord_string = re.sub(r'\s+', ' ', chord_string)
281 listener = RaisingErrorListener()
282 input_stream = antlr4.InputStream(chord_string)
283 lexer = chordsLexer(input_stream)
284 lexer.removeErrorListeners()
285 lexer.addErrorListener(listener)
286 stream = antlr4.CommonTokenStream(lexer)
287 parser = chordsParser(stream)
288 parser.removeErrorListeners()
289 parser.addErrorListener(listener)
290 tree = parser.parse()
291 walker = antlr4.ParseTreeWalker()
292 walker.walk(self, tree)
295 def _reset(self) -> None:
300 self.chordType = ChordParser.MAJOR
303 # -- overridden methods invoked by parse walk. Note: not part of the class'
306 def visitErrorNode(self, node: antlr4.ErrorNode) -> None:
309 def visitTerminal(self, node: antlr4.TerminalNode) -> None:
312 def exitParse(self, ctx: chordsParser.ParseContext) -> None:
313 """Populate self.chord"""
315 chord_types_with_perfect_5th = set(
319 ChordParser.SUSPENDED,
323 if self.chordType in chord_types_with_perfect_5th:
324 if self.chordType == ChordParser.MAJOR:
325 logger.debug('Major chord.')
326 self.addedNotes.append('maj3')
327 elif self.chordType == ChordParser.MINOR:
328 logger.debug('Minor chord.')
329 self.addedNotes.append('min3')
330 elif self.chordType == ChordParser.SUSPENDED:
331 if self.susNote == 2:
332 logger.debug('sus2 chord.')
333 self.addedNotes.append('maj2')
334 elif self.susNote == 4:
335 logger.debug('sus4 chord.')
336 self.addedNotes.append('perfect4')
337 elif self.chordType == ChordParser.POWER:
338 logger.debug('Power chord.')
339 self.addedNotes.append('perfect5th')
340 elif self.chordType == ChordParser.DIMINISHED:
341 logger.debug('Diminished chord.')
342 self.addedNotes.append('min3')
343 self.addedNotes.append('dim5')
344 elif self.chordType == ChordParser.AUGMENTED:
345 logger.debug('Augmented chord.')
346 self.addedNotes.append('maj3')
347 self.addedNotes.append('aug5')
349 other_notes: Set[int] = set()
350 for expression in self.addedNotes:
351 semitone_count = ChordParser.interval_name_to_semitone_count(expression, self.rootNote)
352 other_notes.add(semitone_count)
353 if semitone_count in (14, 17, 19):
355 self.chord = Chord(self.rootNote, other_notes)
357 self.chord.add_bass(self.bassNote)
359 def exitRootNote(self, ctx: chordsParser.RootNoteContext):
360 self.rootNote = ctx.NOTE().__str__().upper()
361 logger.debug('Root note is "%s"', self.rootNote)
363 def exitOverBassNoteExpr(self, ctx: chordsParser.OverBassNoteExprContext):
364 self.bassNote = ctx.NOTE().__str__().upper()
365 logger.debug('Bass note is "%s"', self.bassNote)
367 def exitPowerChordExpr(self, ctx: chordsParser.PowerChordExprContext):
368 self.chordType = ChordParser.POWER
369 logger.debug('Power chord')
371 def exitMajExpr(self, ctx: chordsParser.MajExprContext):
372 self.chordType = ChordParser.MAJOR
373 logger.debug('Major')
375 def exitMinExpr(self, ctx: chordsParser.MinExprContext):
376 self.chordType = ChordParser.MINOR
377 logger.debug('Minor')
379 def exitSusExpr(self, ctx: chordsParser.SusExprContext):
380 self.chordType = ChordParser.SUSPENDED
381 logger.debug('Suspended')
382 if '2' in ctx.getText():
384 elif '4' in ctx.getText():
387 def exitDiminishedExpr(self, ctx: chordsParser.DiminishedExprContext):
388 self.chordType = ChordParser.DIMINISHED
389 logger.debug('Diminished')
391 def exitAugmentedExpr(self, ctx: chordsParser.AugmentedExprContext):
392 self.chordType = ChordParser.AUGMENTED
393 logger.debug('Augmented')
395 def exitAddNotesExpr(self, ctx: chordsParser.AddNotesExprContext):
397 self.addedNotes.append('maj6')
399 self.addedNotes.append('min7')
401 self.addedNotes.append('maj7')
403 self.addedNotes.append('min7')
404 self.chordType = ChordParser.MINOR
406 self.addedNotes.append('maj9')
408 self.addedNotes.append('min7')
409 self.addedNotes.append('maj11')
411 def exitExtensionExpr(self, ctx: chordsParser.ExtensionExprContext):
412 self.addedNotes.append(ctx.getText())
415 @bootstrap.initialize
417 parser = ChordParser()
418 for line in sys.stdin:
420 line = re.sub(r"#.*$", "", line)
421 if re.match(r"^ *$", line) is not None:
424 chord = parser.parse(line)
426 except Exception as e:
428 print("Unrecognized.")
432 if __name__ == "__main__":