Source code for music21.romanText.rtObjects

# ------------------------------------------------------------------------------
# Name:         romanText/rtObjects.py
# Purpose:      music21 objects for processing roman numeral analysis text files
#
# Authors:      Christopher Ariza
#               Michael Scott Asato Cuthbert
#
# Copyright:    Copyright © 2011-2012, 2019 Michael Scott Asato Cuthbert
# License:      BSD, see license.txt
# ------------------------------------------------------------------------------
'''
Translation routines for roman numeral analysis text files, as defined
and demonstrated by Dmitri Tymoczko, Mark Gotham, Michael Scott Cuthbert,
and Christopher Ariza in ISMIR 2019.
'''
from __future__ import annotations

import fractions
import io
import re
import unittest

from music21 import common
from music21 import exceptions21
from music21 import environment
from music21 import key
from music21 import prebase
environLocal = environment.Environment('romanText.rtObjects')

# alternate endings might end with a, b, c for
# nonzero or more for everything after the first number
# letterToNumDict in romanText/translate.py implies that reMeasureTag should
#   match a-h (at least), not a-b
reMeasureTag = re.compile(r'm[0-9]+[a-h]*-*[0-9]*[a-h]*')
reVariant = re.compile(r'var[0-9]+')
reVariantLetter = re.compile(r'var([A-Z]+)')

reOptKeyOpenAtom = re.compile(r'\?\([A-Ga-g]+[b#]*:')
reOptKeyCloseAtom = re.compile(r'\?\)[A-Ga-g]+[b#]*:?')
# ?g:( ?
reKeyAtom = re.compile('[A-Ga-g]+[b#]*;:')
reAnalyticKeyAtom = re.compile('[A-Ga-g]+[b#]*:')
reKeySignatureAtom = re.compile(r'KS-?[0-7]')
# must distinguish b3 from bVII; there may be b1.66.5
reBeatAtom = re.compile(r'b[1-9.]+')
reRepeatStartAtom = re.compile(r'\|\|:')
reRepeatStopAtom = re.compile(r':\|\|')
reNoChordAtom = re.compile('(NC|N.C.|nc)')


# ------------------------------------------------------------------------------

class RomanTextException(exceptions21.Music21Exception):
    pass


class RTTokenException(exceptions21.Music21Exception):
    pass


class RTHandlerException(exceptions21.Music21Exception):
    pass


# ------------------------------------------------------------------------------

[docs] class RTToken(prebase.ProtoM21Object): ''' Stores each linear, logical entity of a RomanText. A multi-pass parsing procedure is likely necessary, as RomanText permits variety of groupings and markings. >>> rtt = romanText.rtObjects.RTToken('||:') >>> rtt <music21.romanText.rtObjects.RTToken '||:'> A standard RTToken returns `False` for all the following. >>> rtt.isComposer() or rtt.isTitle() or rtt.isPiece() False >>> rtt.isAnalyst() or rtt.isProofreader() False >>> rtt.isTimeSignature() or rtt.isKeySignature() or rtt.isNote() False >>> rtt.isForm() or rtt.isPedal() or rtt.isMeasure() or rtt.isWork() False >>> rtt.isMovement() or rtt.isVersion() or rtt.isAtom() False ''' def __init__(self, src=''): self.src = src # store source character sequence self.lineNumber = 0 def _reprInternal(self): return repr(self.src)
[docs] def isComposer(self): return False
[docs] def isTitle(self): return False
[docs] def isPiece(self): return False
[docs] def isAnalyst(self): return False
[docs] def isProofreader(self): return False
[docs] def isTimeSignature(self): return False
[docs] def isKeySignature(self): return False
[docs] def isNote(self): return False
[docs] def isForm(self): ''' Occasionally found in header. ''' return False
[docs] def isMeasure(self): return False
[docs] def isPedal(self): return False
[docs] def isWork(self): return False
[docs] def isMovement(self): return False
[docs] def isVersion(self): return False
[docs] def isAtom(self): ''' Atoms are any untagged data; generally only found inside a measure definition. ''' return False
[docs] class RTTagged(RTToken): ''' In romanText, some data elements are tags, that is a tag name, a colon, optional whitespace, and data. In non-RTTagged elements, there is just data. All tagged tokens are subclasses of this class. Examples are: Title: Die Jahrzeiten Composer: Fanny Mendelssohn >>> rtTag = romanText.rtObjects.RTTagged('Title: Die Jahrzeiten') >>> rtTag.tag 'Title' >>> rtTag.data 'Die Jahrzeiten' >>> rtTag.isTitle() True >>> rtTag.isComposer() False ''' def __init__(self, src=''): super().__init__(src) # try to split off tag from data self.tag = '' self.data = '' if ':' in src: iFirst = src.find(':') # first index found at self.tag = src[:iFirst].strip() # add one to skip colon self.data = src[iFirst + 1:].strip() else: # we do not have a clear tag; perhaps store all as data self.data = src
[docs] def isComposer(self): ''' True is the tag represents a composer. >>> rth = romanText.rtObjects.RTTagged('Composer: Claudio Monteverdi') >>> rth.isComposer() True >>> rth.isTitle() False >>> rth.isWork() False >>> rth.data 'Claudio Monteverdi' ''' if self.tag.lower() == 'composer': return True return False
[docs] def isTitle(self): ''' True if tag represents a title, otherwise False. >>> tag = romanText.rtObjects.RTTagged('Title: This is a title.') >>> tag.isTitle() True >>> tag = romanText.rtObjects.RTTagged('Nothing: Nothing at all.') >>> tag.isTitle() False ''' if self.tag.lower() == 'title': return True return False
[docs] def isPiece(self): ''' True if tag represents a piece, otherwise False. >>> tag = romanText.rtObjects.RTTagged('Piece: This is a piece.') >>> tag.isPiece() True >>> tag = romanText.rtObjects.RTTagged('Nothing: Nothing at all.') >>> tag.isPiece() False ''' if self.tag.lower() == 'piece': return True return False
[docs] def isAnalyst(self): ''' True if tag represents an analyst, otherwise False. >>> tag = romanText.rtObjects.RTTagged('Analyst: This is an analyst.') >>> tag.isAnalyst() True >>> tag = romanText.rtObjects.RTTagged('Nothing: Nothing at all.') >>> tag.isAnalyst() False ''' if self.tag.lower() == 'analyst': return True return False
[docs] def isProofreader(self): ''' True if tag represents a proofreader, otherwise False. >>> tag = romanText.rtObjects.RTTagged('Proofreader: This is a proofreader.') >>> tag.isProofreader() True >>> tag = romanText.rtObjects.RTTagged('Nothing: Nothing at all.') >>> tag.isProofreader() False ''' if self.tag.lower() in ('proofreader', 'proof reader'): return True return False
[docs] def isTimeSignature(self): ''' True if tag represents a time signature, otherwise False. >>> tag = romanText.rtObjects.RTTagged('TimeSignature: This is a time signature.') >>> tag.isTimeSignature() True >>> tag = romanText.rtObjects.RTTagged('Nothing: Nothing at all.') >>> tag.isTimeSignature() False TimeSignature header data can be found intermingled with measures. ''' if self.tag.lower() in ('timesignature', 'time signature'): return True return False
[docs] def isKeySignature(self): ''' True if tag represents a key signature, otherwise False. >>> tag = romanText.rtObjects.RTTagged('KeySignature: This is a key signature.') >>> tag.isKeySignature() True >>> tag.data 'This is a key signature.' KeySignatures are a type of tagged data found outside of measures, such as "Key Signature: -1" meaning one flat. Key signatures are generally numbers representing the number of sharps (or negative for flats). Non-standard key signatures are not supported. >>> tag = romanText.rtObjects.RTTagged('KeySignature: -3') >>> tag.data '-3' music21 supports one legacy key signature type: `KeySignature: Bb` which represents a one-flat signature. Important to note: no other key signatures of this type are supported. (For instance, `KeySignature: Ab` has no effect) >>> tag = romanText.rtObjects.RTTagged('KeySignature: Bb') >>> tag.data 'Bb' Testing that `.isKeySignature` returns `False` for non-key signatures: >>> tag = romanText.rtObjects.RTTagged('Nothing: Nothing at all.') >>> tag.isKeySignature() False N.B.: this is not the same as a key definition found inside a Measure. These are represented by RTKey rtObjects, defined below, and are not RTTagged rtObjects, but RTAtom subclasses. ''' if self.tag.lower() in ('keysignature', 'key signature'): return True else: return False
[docs] def isNote(self): ''' True if tag represents a note, otherwise False. >>> tag = romanText.rtObjects.RTTagged('Note: This is a note.') >>> tag.isNote() True >>> tag = romanText.rtObjects.RTTagged('Nothing: Nothing at all.') >>> tag.isNote() False ''' if self.tag.lower() == 'note': return True return False
[docs] def isForm(self): ''' True if tag represents a form, otherwise False. >>> tag = romanText.rtObjects.RTTagged('Form: This is a form.') >>> tag.isForm() True >>> tag = romanText.rtObjects.RTTagged('Nothing: Nothing at all.') >>> tag.isForm() False ''' if self.tag.lower() == 'form': return True return False
[docs] def isPedal(self): ''' True if tag represents a pedal, otherwise False. >>> tag = romanText.rtObjects.RTTagged('Pedal: This is a pedal.') >>> tag.isPedal() True >>> tag = romanText.rtObjects.RTTagged('Nothing: Nothing at all.') >>> tag.isPedal() False ''' if self.tag.lower() in ['pedal']: return True return False
[docs] def isVersion(self): ''' True if tag defines the version of RomanText standard used, otherwise False. Pieces without the tag are defined to conform to RomanText 1.0, the version described in the ISMIR publication. >>> rth = romanText.rtObjects.RTTagged('RTVersion: 1.0') >>> rth.isTitle() False >>> rth.isVersion() True >>> rth.tag 'RTVersion' >>> rth.data '1.0' ''' if self.tag.lower() == 'rtversion': return True else: return False
[docs] def isWork(self): ''' True if tag represents a work, otherwise False. The "work" is not defined as a header tag, but is used to represent all tags, often placed after Composer, for the work or pieces designation. >>> rth = romanText.rtObjects.RTTagged('Work: BWV232') >>> rth.isWork() True >>> rth.tag 'Work' >>> rth.data 'BWV232' For historical reasons, the tag 'Madrigal' also designates a work. >>> rth = romanText.rtObjects.RTTagged('Madrigal: 4.12') >>> rth.isTitle() False >>> rth.isWork() True >>> rth.tag 'Madrigal' >>> rth.data '4.12' ''' if self.tag.lower() in ('work', 'madrigal'): return True else: return False
[docs] def isMovement(self): ''' True if tag represents a movement, otherwise False. >>> tag = romanText.rtObjects.RTTagged('Movement: This is a movement.') >>> tag.isMovement() True >>> tag = romanText.rtObjects.RTTagged('Nothing: Nothing at all.') >>> tag.isMovement() False ''' if self.tag.lower() == 'movement': return True return False
[docs] def isSixthMinor(self): ''' True if tag represents a configuration setting for setting vi/vio/VI in minor >>> tag = romanText.rtObjects.RTTagged('Sixth Minor: Flat') >>> tag.isSixthMinor() True >>> tag.data 'Flat' ''' return self.tag.lower() in ('sixthminor', 'sixth minor') # e.g. 'Sixth Minor: FLAT'
[docs] def isSeventhMinor(self): ''' True if tag represents a configuration setting for setting vii/viio/VII in minor >>> tag = romanText.rtObjects.RTTagged('Seventh Minor: Courtesy') >>> tag.isSeventhMinor() True >>> tag.data 'Courtesy' ''' return self.tag.lower() in ('seventhminor', 'seventh minor')
[docs] class RTMeasure(RTToken): ''' In RomanText, measures are given one per line and always start with 'm'. For instance: m4 i b3 v b4 VI m5 b2 g: IV b4 V m6 i m7 D: V Measure ranges can be used and copied, such as: m8-m9=m4-m5 RTMeasure objects can also define variant readings for a measure: m1 ii m1var1 ii b2 ii6 b3 IV Variants are not part of the tag, but are read into an attribute. Endings are indicated by a single letter after the measure number, such as "a" for first ending. >>> rtm = romanText.rtObjects.RTMeasure('m15a V6 b1.5 V6/5 b2 I b3 viio6') >>> rtm.data 'V6 b1.5 V6/5 b2 I b3 viio6' >>> rtm.number [15] >>> rtm.repeatLetter ['a'] >>> rtm.isMeasure() True ''' def __init__(self, src=''): super().__init__(src) # try to split off tag from data self.tag = '' # the measure number or range self.data = '' # only chord, phrase, and similar definitions self.number = [] # one or more measure numbers self.repeatLetter = [] # one or more repeat letters self.variantNumber = None # a one-measure or short variant # a longer-variant that # defines a different way of reading a large section self.variantLetter = None # store boolean if this measure defines copying another range self.isCopyDefinition = False # store processed tokens associated with this measure self.atoms = [] if src: self._parseAttributes(src) def _getMeasureNumberData(self, src): ''' Return the number of numbers as a list, as well as any repeat indications. >>> rtm = romanText.rtObjects.RTMeasure() >>> rtm._getMeasureNumberData('m77') ([77], ['']) >>> rtm._getMeasureNumberData('m123b-432b') ([123, 432], ['b', 'b']) ''' # note: this is separate procedure b/c it is used to get copy # boundaries if '-' in src: # it is a range mnStart, mnEnd = src.split('-') proc = [mnStart, mnEnd] else: proc = [src] # treat as one number = [] repeatLetter = [] for mn in proc: # append in order, start, end numStr, alphaStr = common.getNumFromStr(mn) number.append(int(numStr)) # remove all 'm' in alpha alphaStr = alphaStr.replace('m', '') repeatLetter.append(alphaStr) return number, repeatLetter def _parseAttributes(self, src): # assume that we have already checked that this is a measure g = reMeasureTag.match(src) if g is None: # not measure tag found raise RTHandlerException(f'found no measure tag: {src}') iEnd = g.end() # get end index rawTag = src[:iEnd].strip() self.tag = rawTag rawData = src[iEnd:].strip() # may have variant # get the number list from the tag self.number, self.repeatLetter = self._getMeasureNumberData(rawTag) # strip a variant indication off of rawData if found g = reVariant.match(rawData) if g is not None: # there is a variant tag varStr = g.group(0) self.variantNumber = int(common.getNumFromStr(varStr)[0]) self.data = rawData[g.end():].strip() else: self.data = rawData g = reVariantLetter.match(rawData) if g is not None: # there is a variant letter tag varStr = g.group(1) self.variantLetter = varStr self.data = rawData[g.end():].strip() if self.data.startswith('='): self.isCopyDefinition = True def _reprInternal(self): if len(self.number) == 1: numberStr = str(self.number[0]) else: numberStr = f'{self.number[0]}-{self.number[1]}' return numberStr
[docs] def isMeasure(self): return True
[docs] def getCopyTarget(self): ''' If this measure defines a copy operation, return two lists defining the measures to copy; the second list has the repeat data. >>> rtm = romanText.rtObjects.RTMeasure('m35-36 = m29-30') >>> rtm.number [35, 36] >>> rtm.getCopyTarget() ([29, 30], ['', '']) >>> rtm = romanText.rtObjects.RTMeasure('m4 = m1') >>> rtm.number [4] >>> rtm.getCopyTarget() ([1], ['']) ''' # remove equal sign rawData = self.data.replace('=', '').strip() return self._getMeasureNumberData(rawData)
[docs] class RTAtom(RTToken): ''' In RomanText, definitions of chords, phrases boundaries, open/close parenthesis, beat indicators, etc. appear within measures (RTMeasure objects). These individual elements will be called Atoms, as they are data that is not tagged. Each atom store a reference to its container (normally an RTMeasure). >>> chordIV = romanText.rtObjects.RTAtom('IV') >>> beat4 = romanText.rtObjects.RTAtom('b4') >>> beat4 <music21.romanText.rtObjects.RTAtom 'b4'> >>> beat4.isAtom() True However, see RTChord, RTBeat, etc. which are subclasses of RTAtom specifically for storing chords, beats, etc. ''' def __init__(self, src='', container=None): # this stores the source super().__init__(src) self.container = container
[docs] def isAtom(self): return True
# for lower level distinctions, use isinstance(), as each type has its own subclass.
[docs] class RTChord(RTAtom): r''' An RTAtom subclass that defines a chord. Also contains a reference to the container. >>> chordIV = romanText.rtObjects.RTChord('IV') >>> chordIV <music21.romanText.rtObjects.RTChord 'IV'> ''' def __init__(self, src='', container=None): super().__init__(src, container) # store offset within measure self.offset = None # store a quarterLength duration self.quarterLength = None
[docs] class RTNoChord(RTAtom): r''' An RTAtom subclass that defines absence of a chord. Also contains a reference to the container. >>> chordNC = romanText.rtObjects.RTNoChord('NC') >>> chordNC <music21.romanText.rtObjects.RTNoChord 'NC'> >>> rth = romanText.rtObjects.RTHandler() >>> rth.tokenizeAtoms('nc NC N.C.') [<music21.romanText.rtObjects.RTNoChord 'nc'>, <music21.romanText.rtObjects.RTNoChord 'NC'>, <music21.romanText.rtObjects.RTNoChord 'N.C.'>] ''' def __init__(self, src='', container=None): super().__init__(src, container) # store offset within measure self.offset = None # store a quarterLength duration self.quarterLength = None
[docs] class RTBeat(RTAtom): r''' An RTAtom subclass that defines a beat definition. Also contains a reference to the container. >>> beatFour = romanText.rtObjects.RTBeat('b4') >>> beatFour <music21.romanText.rtObjects.RTBeat 'b4'> '''
[docs] def getBeatFloatOrFrac(self): ''' Gets the beat number as a float or fraction. Time signature independent >>> RTB = romanText.rtObjects.RTBeat Simple ones: >>> RTB('b1').getBeatFloatOrFrac() 1.0 >>> RTB('b2').getBeatFloatOrFrac() 2.0 etc. with easy float: >>> RTB('b1.5').getBeatFloatOrFrac() 1.5 >>> RTB('b1.25').getBeatFloatOrFrac() 1.25 with harder: >>> RTB('b1.33').getBeatFloatOrFrac() Fraction(4, 3) >>> RTB('b2.66').getBeatFloatOrFrac() Fraction(8, 3) >>> RTB('b1.2').getBeatFloatOrFrac() Fraction(6, 5) A third digit of 0.5 adds 1/2 of 1/DENOM of before. Here DENOM is 3 (in 5/3) so we add 1/6 to 5/3 to get 11/6: >>> RTB('b1.66').getBeatFloatOrFrac() Fraction(5, 3) >>> RTB('b1.66.5').getBeatFloatOrFrac() Fraction(11, 6) Similarly 0.25 adds 1/4 of 1/DENOM, to get 21/12 or 7/4 or 1.75: >>> RTB('b1.66.25').getBeatFloatOrFrac() 1.75 And 0.75 adds 3/4 of 1/DENOM to get 23/12: >>> RTB('b1.66.75').getBeatFloatOrFrac() Fraction(23, 12) A weird way of writing 'b1.5': >>> RTB('b1.33.5').getBeatFloatOrFrac() 1.5 ''' beatStr = self.src.replace('b', '') # there may be more than one decimal in the number, such as # 1.66.5, to show halfway through 2/3rd of a beat parts = beatStr.split('.') mainBeat = int(parts[0]) if len(parts) > 1: # 1.66 fracPart = common.addFloatPrecision('.' + parts[1]) else: fracPart = 0.0 if len(parts) > 2: # 1.66.5 fracPartDivisor = float('.' + parts[2]) # 0.5 if isinstance(fracPart, float): fracPart = fractions.Fraction.from_float(fracPart) denom = fracPart.denominator fracBeatFrac = common.opFrac(1. / (denom / fracPartDivisor)) else: fracBeatFrac = 0.0 if len(parts) > 3: environLocal.printDebug([f'got unexpected beat: {self.src}']) raise RTTokenException(f'cannot handle specification: {self.src}') beat = common.opFrac(mainBeat + fracPart + fracBeatFrac) return beat
[docs] def getOffset(self, timeSignature): ''' Given a time signature, return the offset position specified by this beat. >>> rtb = romanText.rtObjects.RTBeat('b1.5') >>> rtb.getOffset(meter.TimeSignature('3/4')) 0.5 >>> rtb.getOffset(meter.TimeSignature('6/8')) 0.75 >>> rtb.getOffset(meter.TimeSignature('2/2')) 1.0 >>> rtb = romanText.rtObjects.RTBeat('b2') >>> rtb.getOffset(meter.TimeSignature('3/4')) 1.0 >>> rtb.getOffset(meter.TimeSignature('6/8')) 1.5 >>> rtb = romanText.rtObjects.RTBeat('b1.66') >>> rtb.getOffset(meter.TimeSignature('6/8')) 1.0 >>> rtc = romanText.rtObjects.RTBeat('b1.66.5') >>> rtc.getOffset(meter.TimeSignature('6/8')) 1.25 ''' beat = self.getBeatFloatOrFrac() # environLocal.printDebug(['using beat value:', beat]) # TODO: check for exceptions/errors if this beat is bad try: post = timeSignature.getOffsetFromBeat(beat) except exceptions21.TimeSignatureException: environLocal.printDebug([ 'bad beat specification: ' f'{self.src} in a meter of {timeSignature}' ]) post = 0.0 return post
[docs] class RTKeyTypeAtom(RTAtom): ''' RTKeyTypeAtoms contain utility functions for all Key-type tokens, i.e. RTKey, RTAnalyticKey, but not KeySignature. >>> gMinor = romanText.rtObjects.RTKeyTypeAtom('g;:') >>> gMinor <music21.romanText.rtObjects.RTKeyTypeAtom 'g;:'> >>> gMinor.getKey() <music21.key.Key of g minor> ''' footerStrip = ';:'
[docs] def getKey(self): ''' This returns a Key, not a KeySignature object ''' myKey = self.src.rstrip(self.footerStrip) myKey = key.convertKeyStringToMusic21KeyString(myKey) return key.Key(myKey)
[docs] def getKeySignature(self): ''' Get a KeySignature object. ''' myKey = self.getKey() return key.KeySignature(myKey.sharps)
[docs] class RTKey(RTKeyTypeAtom): ''' An RTKey(RTAtom) defines both a change in KeySignature and a change in the analyzed Key. They are defined by ";:" after the Key. >>> gMinor = romanText.rtObjects.RTKey('g;:') >>> gMinor <music21.romanText.rtObjects.RTKey 'g;:'> >>> gMinor.getKey() <music21.key.Key of g minor> >>> bMinor = romanText.rtObjects.RTKey('bb;:') >>> bMinor <music21.romanText.rtObjects.RTKey 'bb;:'> >>> bMinor.getKey() <music21.key.Key of b- minor> >>> bMinor.getKeySignature() <music21.key.KeySignature of 5 flats> >>> eFlatMajor = romanText.rtObjects.RTKey('Eb;:') >>> eFlatMajor <music21.romanText.rtObjects.RTKey 'Eb;:'> >>> eFlatMajor.getKey() <music21.key.Key of E- major> ''' footerStrip = ';:'
[docs] class RTAnalyticKey(RTKeyTypeAtom): ''' An RTAnalyticKey(RTKeyTypeAtom) only defines a change in the key being analyzed. It does not in itself create a :class:`~music21.key.Key` object. >>> gMinor = romanText.rtObjects.RTAnalyticKey('g:') >>> gMinor <music21.romanText.rtObjects.RTAnalyticKey 'g:'> >>> gMinor.getKey() <music21.key.Key of g minor> >>> bMinor = romanText.rtObjects.RTAnalyticKey('bb:') >>> bMinor <music21.romanText.rtObjects.RTAnalyticKey 'bb:'> >>> bMinor.getKey() <music21.key.Key of b- minor> ''' footerStrip = ':'
[docs] class RTKeySignature(RTAtom): ''' An RTKeySignature(RTAtom) only defines a change in the KeySignature. It does not in itself create a :class:`~music21.key.Key` object, nor does it change the analysis taking place. The number after KS defines the number of sharps (negative for flats). >>> gMinor = romanText.rtObjects.RTKeySignature('KS-2') >>> gMinor <music21.romanText.rtObjects.RTKeySignature 'KS-2'> >>> gMinor.getKeySignature() <music21.key.KeySignature of 2 flats> >>> aMajor = romanText.rtObjects.RTKeySignature('KS3') >>> aMajor.getKeySignature() <music21.key.KeySignature of 3 sharps> '''
[docs] def getKeySignature(self): numSharps = int(self.src[2:]) return key.KeySignature(numSharps)
[docs] class RTOpenParens(RTAtom): ''' A simple open parenthesis Atom with a sensible default >>> romanText.rtObjects.RTOpenParens('(') <music21.romanText.rtObjects.RTOpenParens '('> ''' def __init__(self, src='(', container=None): super().__init__(src, container)
[docs] class RTCloseParens(RTAtom): ''' A simple close parenthesis Atom with a sensible default >>> romanText.rtObjects.RTCloseParens(')') <music21.romanText.rtObjects.RTCloseParens ')'> ''' def __init__(self, src=')', container=None): super().__init__(src, container)
[docs] class RTOptionalKeyOpen(RTAtom): ''' Marks the beginning of an optional Key area which does not affect the roman numeral analysis. (For instance, it is possible to analyze in Bb major, while remaining in g minor) >>> possibleKey = romanText.rtObjects.RTOptionalKeyOpen('?(Bb:') >>> possibleKey <music21.romanText.rtObjects.RTOptionalKeyOpen '?(Bb:'> >>> possibleKey.getKey() <music21.key.Key of B- major> '''
[docs] def getKey(self): # alter flat symbol if self.src == '?(b:': return key.Key('b') else: keyStr = self.src.replace('b', '-') keyStr = keyStr.replace(':', '') keyStr = keyStr.replace('?', '') keyStr = keyStr.replace('(', '') # environLocal.printDebug(['create a key from:', keyStr]) return key.Key(keyStr)
[docs] class RTOptionalKeyClose(RTAtom): ''' Marks the end of an optional Key area which does not affect the roman numeral analysis. For example, it is possible to analyze in Bb major, while remaining in g minor. >>> possibleKey = romanText.rtObjects.RTOptionalKeyClose('?)Bb:') >>> possibleKey <music21.romanText.rtObjects.RTOptionalKeyClose '?)Bb:'> >>> possibleKey.getKey() <music21.key.Key of B- major> '''
[docs] def getKey(self): # alter flat symbol if self.src in ('?)b:', '?)b'): return key.Key('b') else: keyStr = self.src.replace('b', '-') keyStr = keyStr.replace(':', '') keyStr = keyStr.replace('?', '') keyStr = keyStr.replace(')', '') # environLocal.printDebug(['create a key from:', keyStr]) return key.Key(keyStr)
[docs] class RTPhraseMarker(RTAtom): ''' A Phrase Marker: >>> rtPhraseMarker = romanText.rtObjects.RTPhraseMarker('') >>> rtPhraseMarker <music21.romanText.rtObjects.RTPhraseMarker ''> '''
[docs] class RTPhraseBoundary(RTPhraseMarker): ''' >>> phrase = romanText.rtObjects.RTPhraseBoundary('||') >>> phrase <music21.romanText.rtObjects.RTPhraseBoundary '||'> ''' def __init__(self, src='||', container=None): super().__init__(src, container)
[docs] class RTEllisonStart(RTPhraseMarker): ''' >>> phrase = romanText.rtObjects.RTEllisonStart('|*') >>> phrase <music21.romanText.rtObjects.RTEllisonStart '|*'> ''' def __init__(self, src='|*', container=None): super().__init__(src, container)
[docs] class RTEllisonStop(RTPhraseMarker): ''' >>> phrase = romanText.rtObjects.RTEllisonStop('*|') >>> phrase <music21.romanText.rtObjects.RTEllisonStop '*|'> ''' def __init__(self, src='*|', container=None): super().__init__(src, container)
[docs] class RTRepeat(RTAtom): ''' >>> repeat = romanText.rtObjects.RTRepeat('||:') >>> repeat <music21.romanText.rtObjects.RTRepeat '||:'> '''
[docs] class RTRepeatStart(RTRepeat): ''' >>> repeat = romanText.rtObjects.RTRepeatStart() >>> repeat <music21.romanText.rtObjects.RTRepeatStart '||:'> ''' def __init__(self, src='||:', container=None): super().__init__(src, container)
[docs] class RTRepeatStop(RTRepeat): ''' >>> repeat = romanText.rtObjects.RTRepeatStop() >>> repeat <music21.romanText.rtObjects.RTRepeatStop ':||'> ''' def __init__(self, src=':||', container=None): super().__init__(src, container)
# ------------------------------------------------------------------------------
[docs] class RTHandler: # divide elements of a character stream into rtObjects and handle # store in a list, and pass global information to components def __init__(self): # tokens are ABC rtObjects in a linear stream # tokens are strongly divided between header and body, so can # divide here self._tokens = [] self.currentLineNumber = 0
[docs] def splitAtHeader(self, lines): ''' Divide string into header and non-header; this is done before tokenization. >>> rth = romanText.rtObjects.RTHandler() >>> rth.splitAtHeader(['Title: s', 'Time Signature:', '', 'm1 g: i']) (['Title: s', 'Time Signature:', ''], ['m1 g: i']) ''' # iterate over lines and find the first measure definition iStartBody = None for i, line in enumerate(lines): if reMeasureTag.match(line.strip()) is not None: # found a measure definition iStartBody = i break if iStartBody is None: raise RomanTextException('Cannot find the first measure definition in this file. ' + 'Dumping contexts: %s', lines) return lines[:iStartBody], lines[iStartBody:]
[docs] def tokenizeHeader(self, lines): ''' In the header, we only have :class:`~music21.romanText.base.RTTagged` tokens. We can this process these all as the same class. ''' post = [] for i, line in enumerate(lines): line = line.strip() if line == '': continue # wrap each line in a header token rtt = RTTagged(line) rtt.lineNumber = i + 1 post.append(rtt) self.currentLineNumber = len(lines) + 1 return post
[docs] def tokenizeBody(self, lines): ''' In the body, we may have measure, time signature, or note declarations, as well as possible other tagged definitions. ''' post = [] startLineNumber = self.currentLineNumber for i, line in enumerate(lines): currentLineNumber = startLineNumber + i try: line = line.strip() if line == '': continue # first, see if it is a measure definition, if not, than assume it is tagged data if reMeasureTag.match(line) is not None: rtm = RTMeasure(line) rtm.lineNumber = currentLineNumber # note: could place these in-line, after post rtm.atoms = self.tokenizeAtoms(rtm.data, container=rtm) for a in rtm.atoms: a.lineNumber = currentLineNumber post.append(rtm) else: # store items in a measure tag outside the measure rtt = RTTagged(line) rtt.lineNumber = currentLineNumber post.append(rtt) except Exception as exc: import traceback tracebackMessage = traceback.format_exc() raise RTHandlerException( f'At line {currentLineNumber} ({line}) an exception was raised:\n' f'{tracebackMessage}' ) from exc return post
[docs] def tokenizeAtoms(self, line, container=None): ''' Given a line of data stored in measure consisting only of Atoms, tokenize and return a list. >>> rth = romanText.rtObjects.RTHandler() >>> rth.tokenizeAtoms('IV b3 ii7 b4 ii') [<music21.romanText.rtObjects.RTChord 'IV'>, <music21.romanText.rtObjects.RTBeat 'b3'>, <music21.romanText.rtObjects.RTChord 'ii7'>, <music21.romanText.rtObjects.RTBeat 'b4'>, <music21.romanText.rtObjects.RTChord 'ii'>] >>> rth.tokenizeAtoms('V7 b2 V13 b3 V7 iio6/5[no5]') [<music21.romanText.rtObjects.RTChord 'V7'>, <music21.romanText.rtObjects.RTBeat 'b2'>, <music21.romanText.rtObjects.RTChord 'V13'>, <music21.romanText.rtObjects.RTBeat 'b3'>, <music21.romanText.rtObjects.RTChord 'V7'>, <music21.romanText.rtObjects.RTChord 'iio6/5[no5]'>] >>> tokenList = rth.tokenizeAtoms('I b2 I b2.25 V/ii b2.5 bVII b2.75 V g: IV') >>> tokenList [<music21.romanText.rtObjects.RTChord 'I'>, <music21.romanText.rtObjects.RTBeat 'b2'>, <music21.romanText.rtObjects.RTChord 'I'>, <music21.romanText.rtObjects.RTBeat 'b2.25'>, <music21.romanText.rtObjects.RTChord 'V/ii'>, <music21.romanText.rtObjects.RTBeat 'b2.5'>, <music21.romanText.rtObjects.RTChord 'bVII'>, <music21.romanText.rtObjects.RTBeat 'b2.75'>, <music21.romanText.rtObjects.RTChord 'V'>, <music21.romanText.rtObjects.RTAnalyticKey 'g:'>, <music21.romanText.rtObjects.RTChord 'IV'>] >>> tokenList[-2].getKey() <music21.key.Key of g minor> >>> rth.tokenizeAtoms('= m3') [] >>> tokenList = rth.tokenizeAtoms('g;: ||: V b2 ?(Bb: VII7 b3 III b4 ?)Bb: i :||') >>> tokenList [<music21.romanText.rtObjects.RTKey 'g;:'>, <music21.romanText.rtObjects.RTRepeatStart '||:'>, <music21.romanText.rtObjects.RTChord 'V'>, <music21.romanText.rtObjects.RTBeat 'b2'>, <music21.romanText.rtObjects.RTOptionalKeyOpen '?(Bb:'>, <music21.romanText.rtObjects.RTChord 'VII7'>, <music21.romanText.rtObjects.RTBeat 'b3'>, <music21.romanText.rtObjects.RTChord 'III'>, <music21.romanText.rtObjects.RTBeat 'b4'>, <music21.romanText.rtObjects.RTOptionalKeyClose '?)Bb:'>, <music21.romanText.rtObjects.RTChord 'i'>, <music21.romanText.rtObjects.RTRepeatStop ':||'>] ''' post = [] # break by spaces for word in line.split(' '): word = word.strip() if word == '': continue elif word == '=': # if an = is found, this is a copy definition, and no atoms here break elif word == '||': post.append(RTPhraseBoundary(word, container)) elif word == '(': post.append(RTOpenParens(word, container)) elif word == ')': post.append(RTCloseParens(word, container)) elif reBeatAtom.match(word) is not None: post.append(RTBeat(word, container)) # from here, all that is left is keys or chords elif reOptKeyOpenAtom.match(word) is not None: post.append(RTOptionalKeyOpen(word, container)) elif reOptKeyCloseAtom.match(word) is not None: post.append(RTOptionalKeyClose(word, container)) elif reKeyAtom.match(word) is not None: post.append(RTKey(word, container)) elif reAnalyticKeyAtom.match(word) is not None: post.append(RTAnalyticKey(word, container)) elif reKeySignatureAtom.match(word) is not None: post.append(RTKeySignature(word, container)) elif reRepeatStartAtom.match(word) is not None: post.append(RTRepeatStart(word, container)) elif reRepeatStopAtom.match(word) is not None: post.append(RTRepeatStop(word, container)) elif reNoChordAtom.match(word) is not None: post.append(RTNoChord(word, container)) else: # only option is that it is a chord post.append(RTChord(word, container)) return post
[docs] def tokenize(self, src): ''' Walk the RT string, creating RT rtObjects along the way. ''' # break into lines lines = src.split('\n') linesHeader, linesBody = self.splitAtHeader(lines) # environLocal.printDebug([linesHeader]) self._tokens += self.tokenizeHeader(linesHeader) self._tokens += self.tokenizeBody(linesBody)
[docs] def process(self, src): ''' Given an entire specification as a single source string, strSrc, tokenize it. This is usually provided in a file. ''' self._tokens = [] self.tokenize(src)
[docs] def definesMovements(self, countRequired=2): ''' Return True if more than one movement is defined in a RT file. >>> rth = romanText.rtObjects.RTHandler() >>> rth.process('Movement: 1 \\n Movement: 2 \\n \\n m1') >>> rth.definesMovements() True >>> rth.process('Movement: 1 \\n m1') >>> rth.definesMovements() False ''' if not self._tokens: raise RTHandlerException('must create tokens first') count = 0 for t in self._tokens: if t.isMovement(): count += 1 if count >= countRequired: return True return False
[docs] def definesMovement(self): ''' Return True if this handler has 1 or more movement. >>> rth = romanText.rtObjects.RTHandler() >>> rth.process('Movement: 1 \\n \\n m1') >>> rth.definesMovements() False >>> rth.definesMovement() True ''' return self.definesMovements(countRequired=1)
[docs] def splitByMovement(self, duplicateHeader=True): # noinspection PyShadowingNames ''' If we have movements defined, return a list of RTHandler rtObjects, representing header information and each movement, in order. >>> rth = romanText.rtObjects.RTHandler() >>> rth.process('Title: Test \\n Movement: 1 \\n m1 \\n Movement: 2 \\n m1') >>> post = rth.splitByMovement(False) >>> len(post) 3 >>> len(post[0]) 1 >>> post[0].__class__ <class 'music21.romanText.rtObjects.RTHandler'> >>> len(post[1]), len(post[2]) (2, 2) >>> post = rth.splitByMovement(duplicateHeader=True) >>> len(post) 2 >>> len(post[0]), len(post[1]) (3, 3) ''' post = [] sub = [] for t in self._tokens: if t.isMovement(): # when finding a movement, we are ending a previous # and starting a new; this may just have metadata rth = RTHandler() rth.tokens = sub post.append(rth) sub = [] sub.append(t) if sub: rth = RTHandler() rth.tokens = sub post.append(rth) if duplicateHeader: alt = [] # if no movement in this first handler, assume it is header info if not post[0].definesMovement(): handlerHead = post[0] iStart = 1 else: handlerHead = None iStart = 0 for h in post[iStart:]: if handlerHead is not None: h = handlerHead + h # add metadata alt.append(h) # reassign post = alt return post
# -------------------------------------------------------------------------- # access tokens @property def tokens(self): ''' Get or set tokens for this Handler. ''' if not self._tokens: raise RTHandlerException('must process tokens before calling split') return self._tokens @tokens.setter def tokens(self, tokens): ''' Assign tokens to this Handler. ''' self._tokens = tokens def __len__(self): return len(self._tokens) def __add__(self, other): ''' Return a new handler adding the tokens in both ''' rth = self.__class__() # will get the same class type rth.tokens = self._tokens + other._tokens return rth
# ------------------------------------------------------------------------------
[docs] class RTFile(prebase.ProtoM21Object): ''' Roman Text File access. ''' def __init__(self): self.file = None self.filename = None
[docs] def open(self, filename): ''' Open a file for reading, trying a variety of encodings and then trying them again with an "ignore" flag if it is not possible. ''' for encoding in ('utf-8', 'macintosh', 'latin-1', 'utf-16'): try: # pylint: disable=consider-using-with self.file = io.open(filename, encoding=encoding) if self.file is not None: break except UnicodeDecodeError: pass if self.file is None: for encoding in ('utf-8', 'macintosh', 'latin-1', 'utf-16', None): try: # pylint: disable=consider-using-with self.file = io.open(filename, encoding=encoding, errors='ignore') if self.file is not None: break except UnicodeDecodeError: pass if self.file is None: raise RomanTextException( f'Cannot parse file {filename}, possibly a broken codec?') self.filename = filename
[docs] def openFileLike(self, fileLike): ''' Assign a file-like object, such as those provided by StringIO, as an open file object. ''' self.file = fileLike # already 'open'
def _reprInternal(self): return ''
[docs] def close(self): self.file.close()
[docs] def read(self): ''' Read a file. Note that this calls readstr, which processes all tokens. If `number` is given, a work number will be extracted if possible. ''' return self.readstr(self.file.read())
[docs] def readstr(self, strSrc): ''' Read a string and process all Tokens. Returns a ABCHandler instance. ''' handler = RTHandler() # return the handler instance handler.process(strSrc) return handler
# ------------------------------------------------------------------------------ class Test(unittest.TestCase): def testBasicA(self): from music21.romanText import testFiles for fileStr in testFiles.ALL: f = RTFile() unused_rth = f.readstr(fileStr) # get a handler from a string def testReA(self): # gets the index of the end of the measure indication g = reMeasureTag.match('m1 g: V b2 i') self.assertEqual(g.end(), 2) self.assertEqual(g.group(0), 'm1') self.assertEqual(reMeasureTag.match('Time Signature: 2/2'), None) g = reMeasureTag.match('m3-4=m1-2') self.assertEqual(g.end(), 4) self.assertEqual(g.start(), 0) self.assertEqual(g.group(0), 'm3-4') g = reMeasureTag.match('m123-432=m1120-24234') self.assertEqual(g.group(0), 'm123-432') g = reMeasureTag.match('m231a IV6 b4 C: V') self.assertEqual(g.group(0), 'm231a') g = reMeasureTag.match('m123b-432b=m1120a-24234a') self.assertEqual(g.group(0), 'm123b-432b') g = reMeasureTag.match('m231var1 IV6 b4 C: V') self.assertEqual(g.group(0), 'm231') # this only works if it starts the string g = reVariant.match('var1 IV6 b4 C: V') self.assertEqual(g.group(0), 'var1') g = reAnalyticKeyAtom.match('Bb:') self.assertEqual(g.group(0), 'Bb:') g = reAnalyticKeyAtom.match('F#:') self.assertEqual(g.group(0), 'F#:') g = reAnalyticKeyAtom.match('f#:') self.assertEqual(g.group(0), 'f#:') g = reAnalyticKeyAtom.match('b:') self.assertEqual(g.group(0), 'b:') g = reAnalyticKeyAtom.match('bb:') self.assertEqual(g.group(0), 'bb:') g = reAnalyticKeyAtom.match('g:') self.assertEqual(g.group(0), 'g:') # beats do not have a colon self.assertEqual(reKeyAtom.match('b2'), None) self.assertEqual(reKeyAtom.match('b2.5'), None) g = reBeatAtom.match('b2.5') self.assertEqual(g.group(0), 'b2.5') g = reBeatAtom.match('bVII') self.assertEqual(g, None) g = reBeatAtom.match('b1.66.5') self.assertEqual(g.group(0), 'b1.66.5') def testMeasureAttributeProcessing(self): rtm = RTMeasure('m17var1 vi b2 IV b2.5 viio6/4 b3.5 I') self.assertEqual(rtm.data, 'vi b2 IV b2.5 viio6/4 b3.5 I') self.assertEqual(rtm.number, [17]) self.assertEqual(rtm.tag, 'm17') self.assertEqual(rtm.variantNumber, 1) rtm = RTMeasure('m17varC vi b2 IV b2.5 viio6/4 b3.5 I') self.assertEqual(rtm.data, 'vi b2 IV b2.5 viio6/4 b3.5 I') self.assertEqual(rtm.variantLetter, 'C') rtm = RTMeasure('m20 vi b2 ii6/5 b3 V b3.5 V7') self.assertEqual(rtm.data, 'vi b2 ii6/5 b3 V b3.5 V7') self.assertEqual(rtm.number, [20]) self.assertEqual(rtm.tag, 'm20') self.assertEqual(rtm.variantNumber, None) self.assertFalse(rtm.isCopyDefinition) rtm = RTMeasure('m0 b3 G: I') self.assertEqual(rtm.data, 'b3 G: I') self.assertEqual(rtm.number, [0]) self.assertEqual(rtm.tag, 'm0') self.assertEqual(rtm.variantNumber, None) self.assertFalse(rtm.isCopyDefinition) rtm = RTMeasure('m59 = m57') self.assertEqual(rtm.data, '= m57') self.assertEqual(rtm.number, [59]) self.assertEqual(rtm.tag, 'm59') self.assertEqual(rtm.variantNumber, None) self.assertTrue(rtm.isCopyDefinition) rtm = RTMeasure('m3-4 = m1-2') self.assertEqual(rtm.data, '= m1-2') self.assertEqual(rtm.number, [3, 4]) self.assertEqual(rtm.tag, 'm3-4') self.assertEqual(rtm.variantNumber, None) self.assertTrue(rtm.isCopyDefinition) def testTokenDefinition(self): # test that we are always getting the right number of tokens from music21.romanText import testFiles rth = RTHandler() rth.process(testFiles.mozartK279) count = 0 for t in rth._tokens: if t.isMovement(): count += 1 self.assertEqual(count, 3) rth.process(testFiles.riemenschneider001) count = 0 for t in rth._tokens: if t.isMeasure(): # print(t.src) count += 1 # 21, 2 variants, and one pickup self.assertEqual(count, 21 + 2 + 1) count = 0 for t in rth._tokens: if t.isMeasure(): for a in t.atoms: if isinstance(a, RTAnalyticKey): count += 1 self.assertEqual(count, 1) # ------------------------------------------------------------------------------ # define presented order in documentation # _DOC_ORDER = [] if __name__ == '__main__': import music21 music21.mainTest(Test)