Source code for music21.humdrum.harmParser

# ------------------------------------------------------------------------------
# Name:         humdrum.harmParser.py
# Purpose:      A parser for annotations in **harm spines of Humdrum
#
# Authors:      Nestor Napoles Lopez
#
# Copyright:    Copyright © 2009-2012, 2020 Michael Scott Asato Cuthbert
# License:      BSD, see license.txt
# ------------------------------------------------------------------------------
'''
Harmonic annotations from Humdrum to `music21`.

The `**harm` representation is described here: https://www.humdrum.org/rep/harm/
'''
from __future__ import annotations

import re
import typing as t
import unittest

[docs] def convertHarmToRoman(harmStr: str) -> str|None: # noinspection PyShadowingNames ''' Converts a `**harm` string into a string that can be used to instantiate a RomanNumeral object. This is necessary because the two notations are not identical. For example, a "V7b" in `**harm` turns into "V65". Instantiate a HarmParser to process `**harm` strings >>> convertHarmToRoman = humdrum.harmParser.convertHarmToRoman Convert a few `**harm` strings to music21.roman.RomanNumeral figures >>> diatonicTriads = ['I', 'Vc', 'Ib', 'iib', 'V', 'viiob', 'vib'] >>> [convertHarmToRoman(x) for x in diatonicTriads] ['I', 'V64', 'I6', 'ii6', 'V', 'viio6', 'vi6'] A few seventh-chord inversions >>> diatonicSevenths = ['V7', 'viio7c', 'V7d', 'viio7b', 'V7c'] >>> [convertHarmToRoman(x) for x in diatonicSevenths] ['V7', 'viio43', 'V2', 'viio65', 'V43'] Inversion-wise, augmented sixth chords are a bit tricky. German and French are treated as seventh-chords (4 notes). Italians are treated as triads. >>> italianSixths = ['Lt', 'Ltb', 'Ltc'] >>> [convertHarmToRoman(x) for x in italianSixths] ['It', 'It6', 'It64'] >>> frenchSixths = ['Fr', 'Frb', 'Frc', 'Frd'] >>> [convertHarmToRoman(x) for x in frenchSixths] ['Fr7', 'Fr65', 'Fr43', 'Fr2'] >>> germanSixths = ['Gn', 'Gnb', 'Gnc', 'Gnd'] >>> [convertHarmToRoman(x) for x in germanSixths] ['Ger7', 'Ger65', 'Ger43', 'Ger2'] ''' # Parse the input string harm = HarmParser().parse(harmStr) if not harm: return None if harm['root'] == 'Gn': degree = 'Ger' harm['intervals'] = ['7'] elif harm['root'] == 'Lt': degree = 'It' elif harm['root'] == 'Fr': degree = 'Fr' harm['intervals'] = ['7'] else: degree = harm['root'] # Altered scale degrees alteration = '' if harm['accidental']: alteration = harm['accidental'] # Augmented or diminished qualities fifthsQuality = '' if harm['attribute']: fifthsQuality = harm['attribute'] # Seventh chords isSeventh = False if harm['intervals'] and '7' in harm['intervals']: isSeventh = True # Numeric inversions (although it is more the 'figured bass') if harm['inversion']: inversionNumber = ['a', 'b', 'c', 'd'].index(harm['inversion']) else: inversionNumber = 0 inversion = '' if inversionNumber == 0: inversion = '7' if isSeventh else '' elif inversionNumber == 1: inversion = '65' if isSeventh else '6' elif inversionNumber == 2: inversion = '43' if isSeventh else '64' elif inversionNumber == 3: # Assume it is a seventh chord or a special chord (e.g., Ger/Fr) inversion = '2' # Any secondary functions secondaryFunctions = [] if harm['secondary']: secondaryFunctions.append('') secondary = harm['secondary'] while secondary: secondaryFunctions.append(secondary['root']) secondary = secondary['secondary'] secondary = '' if secondaryFunctions: secondary = '/'.join(secondaryFunctions) return alteration + degree + fifthsQuality + inversion + secondary
[docs] class HarmDefs: ''' Regular expression definitions for the HarmParser class ''' # Detect lowered or raised root (-|lowered, #|raised) accidental = r''' (?P<accidental> # Named group _accidental [#-]{0,2}) ''' # The degree or special chord, i.e., I, V, Neapolitan, German augmented sixth, etc. roots = r''' (?P<root> # Named group _root_ i|ii|iii|iv|v|vi|vii| # Minor mode degrees I|II|III|IV|V|VI|VII| # Major mode degrees N|Gn|Lt|Fr|Tr) # Special chords ''' # Detect diminished or augmented triads (o|diminished, +|augmented) attribute = r''' (?P<attribute> # Named group _attribute_ [o+]?) ''' # Detect added intervals, e.g., M7, m7, DD7, A6, etc. # noinspection SpellCheckingInspection intervals = r''' ((?P<intervals> # Named group _intervals \d+|[mMPAD]\d+| # Detect minor, Major, Augmented or Diminished intervals AA\d+| # Double-augmented intervals DD\d+) # Double-diminished intervals *) # Not a limit on how many intervals can be added ''' # Detect inversions (b|First inversion, c|Second inversion, d|Third inversion) inversion = r''' (?P<inversion> # Named group _inversions_ [b-d]?) # Only third inversions possible so far ''' # Detect implied harmony between parentheses, e.g., (I), (V), (viio/ii), etc. implied = r''' ^( # Match for entire string or fail \( # Open parenthesis (?P<implied_harmony> # Group the expression ([^\(^\)])+ # At least one expression ) # /Group the expression \) # Closing parenthesis )$ # /Match for entire string or fail ''' # Detect an alternative harmony between brackets, e.g., I[V], I[V/IV], etc. alternative = r''' (\[ # Open brackets (?P<alternative> # Named group _alternative_ ([^\[^\]])+) # Match at least one time for any expression inside brackets \] # Close brackets )? # If no alternative expression, then no brackets should appear at all ''' # Detect secondary functions, e.g., V/V, V/iv/ii, viioD7/iv/v, etc. secondary = r''' (/ # Slash implies a secondary function (?P<secondary> # Named group _secondary_ ([\s\S])+) # Get all the expression after the slash symbol )? # If no secondary function, then the slash symbol should not appear ''' # The definition for a harm expr harmExpression = (r'^(' + accidental + roots + attribute + intervals + inversion + alternative + secondary + r')$')
[docs] class HarmParser: ''' Parses an expression in `**harm` syntax ''' defs = HarmDefs() def __init__(self) -> None: self.harmRegExp = re.compile(HarmParser.defs.harmExpression, re.VERBOSE) self.impliedRegExp = re.compile(HarmParser.defs.implied, re.VERBOSE)
[docs] def parse(self, harmExpression: str) -> dict[str, t.Any]: # Check for implied harmony m: dict[str, t.Any] impliedMatch = self.impliedRegExp.match(harmExpression) if impliedMatch: # This is implied harmony impliedHarmony = impliedMatch.groupdict()['implied_harmony'] # Call the function again over the inner expression m = self.parse(impliedHarmony) if m: m['implied'] = True return m else: # Normal expression matchHarmRegExp = self.harmRegExp.match(harmExpression) if matchHarmRegExp: m = matchHarmRegExp.groupdict() m['implied'] = False # Finding alternative harmony if m['alternative'] is not None: alternativeExpression = m['alternative'] a = self.parse(alternativeExpression) m['alternative'] = a # Finding secondary functions if m['secondary'] is not None: secondaryExpression = m['secondary'] s = self.parse(secondaryExpression) m['secondary'] = s return m return {}
class Test(unittest.TestCase): def testTriads(self): # noinspection SpellCheckingInspection harmTriads = [ 'I', 'Ib', 'Ic', 'ii', 'iib', 'iic', 'iii', 'iiib', 'iiic', 'IV', 'IVb', 'IVc', 'V', 'Vb', 'Vc', 'vi', 'vib', 'vic', 'viio', 'viiob', 'viioc' ] groundTruth = ( 'I', 'I6', 'I64', 'ii', 'ii6', 'ii64', 'iii', 'iii6', 'iii64', 'IV', 'IV6', 'IV64', 'V', 'V6', 'V64', 'vi', 'vi6', 'vi64', 'viio', 'viio6', 'viio64' ) romanTriads = [convertHarmToRoman(x) for x in harmTriads] self.assertEqual(tuple(romanTriads), groundTruth) def testSevenths(self): harmSevenths = [ 'I7', 'I7b', 'I7c', 'I7d', 'ii7', 'ii7b', 'ii7c', 'ii7d', 'iii7', 'iii7b', 'iii7c', 'iii7d', 'IV7', 'IV7b', 'IV7c', 'IV7d', 'V7', 'V7b', 'V7c', 'V7d', 'vi7', 'vi7b', 'vi7c', 'vi7d', 'viio7', 'viio7b', 'viio7c', 'viio7d' ] groundTruth = ( 'I7', 'I65', 'I43', 'I2', 'ii7', 'ii65', 'ii43', 'ii2', 'iii7', 'iii65', 'iii43', 'iii2', 'IV7', 'IV65', 'IV43', 'IV2', 'V7', 'V65', 'V43', 'V2', 'vi7', 'vi65', 'vi43', 'vi2', 'viio7', 'viio65', 'viio43', 'viio2' ) romanSevenths = [convertHarmToRoman(x) for x in harmSevenths] self.assertEqual(tuple(romanSevenths), groundTruth) def testSpecialChords(self): harmSpecialChords = [ 'N', 'Nb', 'Nc', 'Lt', 'Ltb', 'Ltc', 'Fr', 'Frb', 'Frc', 'Frd', 'Gn', 'Gnb', 'Gnc', 'Gnd' ] groundTruth = ( 'N', 'N6', 'N64', 'It', 'It6', 'It64', 'Fr7', 'Fr65', 'Fr43', 'Fr2', 'Ger7', 'Ger65', 'Ger43', 'Ger2' ) romanSpecialChords = [convertHarmToRoman(x) for x in harmSpecialChords] self.assertEqual(tuple(romanSpecialChords), groundTruth) def testAlterations(self): harmAlterations = [ '-IIb', '--IIc', '#IV7', '##IV7b' ] groundTruth = ( '-II6', '--II64', '#IV7', '##IV65' ) romanAlterations = [convertHarmToRoman(x) for x in harmAlterations] self.assertEqual(tuple(romanAlterations), groundTruth) if __name__ == '__main__': import music21 music21.mainTest(Test)