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 pathlib
import re
import typing as t
import unittest

from music21 import common
from music21.common.types import OffsetQL
from music21 import exceptions21
from music21 import environment
from music21 import key
from music21 import prebase

if t.TYPE_CHECKING:
    from music21 import meter

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: str = ''): self.src: str = src # store source character sequence self.lineNumber: int = 0 def _reprInternal(self) -> str: return repr(self.src)
[docs] def isComposer(self) -> bool: return False
[docs] def isTitle(self) -> bool: return False
[docs] def isPiece(self) -> bool: return False
[docs] def isAnalyst(self) -> bool: return False
[docs] def isProofreader(self) -> bool: return False
[docs] def isTimeSignature(self) -> bool: return False
[docs] def isKeySignature(self) -> bool: return False
[docs] def isNote(self) -> bool: return False
[docs] def isForm(self) -> bool: ''' Occasionally found in header. ''' return False
[docs] def isMeasure(self) -> bool: return False
[docs] def isPedal(self) -> bool: return False
[docs] def isWork(self) -> bool: return False
[docs] def isMovement(self) -> bool: return False
[docs] def isVersion(self) -> bool: return False
[docs] def isAtom(self) -> bool: ''' 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: str = ''): super().__init__(src) # try to split off tag from data self.tag: str = '' self.data: str = '' 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) -> bool: ''' 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) -> bool: ''' 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) -> bool: ''' 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) -> bool: ''' 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) -> bool: ''' 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) -> bool: ''' 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) -> bool: ''' 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) -> bool: ''' 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) -> bool: ''' 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) -> bool: ''' 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) -> bool: ''' 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) -> bool: ''' 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) -> bool: ''' 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) -> bool: ''' 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) -> bool: ''' 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: str = ''): super().__init__(src) # try to split off tag from data self.tag: str = '' # the measure number or range self.data: str = '' # only chord, phrase, and similar definitions self.number: list[int] = [] # one or more measure numbers self.repeatLetter: list[str] = [] # one or more repeat letters self.variantNumber: int | None = None # a one-measure or short variant # a longer-variant that # defines a different way of reading a large section self.variantLetter: str | None = None # store boolean if this measure defines copying another range self.isCopyDefinition: bool = False # store processed tokens associated with this measure self.atoms: list[RTAtom] = [] if src: self._parseAttributes(src) def _getMeasureNumberData(self, src: str) -> tuple[list[int], list[str]]: ''' 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: str) -> None: # 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) -> str: 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) -> bool: return True
[docs] def getCopyTarget(self) -> tuple[list[int], list[str]]: ''' 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: str = '', container: RTMeasure | None = None): # this stores the source super().__init__(src) self.container: RTMeasure | None = container
[docs] def isAtom(self) -> bool: 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: str = '', container: RTMeasure | None = None): super().__init__(src, container) # store offset within measure self.offset: OffsetQL | None = None # store a quarterLength duration self.quarterLength: OffsetQL | None = 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: str = '', container: RTMeasure | None = None): super().__init__(src, container) # store offset within measure self.offset: OffsetQL | None = None # store a quarterLength duration self.quarterLength: OffsetQL | None = 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) -> OffsetQL: ''' 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: meter.TimeSignature) -> OffsetQL: ''' 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: str = ';:'
[docs] def getKey(self) -> key.Key: ''' 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) -> key.KeySignature: ''' 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) -> key.KeySignature: 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: str = '(', container: RTMeasure | None = 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: str = ')', container: RTMeasure | None = 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) -> key.Key: # 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) -> key.Key: # 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: str = '||', container: RTMeasure | None = None): super().__init__(src, container)
[docs] class RTEllisonStart(RTPhraseMarker): ''' >>> phrase = romanText.rtObjects.RTEllisonStart('|*') >>> phrase <music21.romanText.rtObjects.RTEllisonStart '|*'> ''' def __init__(self, src: str = '|*', container: RTMeasure | None = None): super().__init__(src, container)
[docs] class RTEllisonStop(RTPhraseMarker): ''' >>> phrase = romanText.rtObjects.RTEllisonStop('*|') >>> phrase <music21.romanText.rtObjects.RTEllisonStop '*|'> ''' def __init__(self, src: str = '*|', container: RTMeasure | None = 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: str = '||:', container: RTMeasure | None = None): super().__init__(src, container)
[docs] class RTRepeatStop(RTRepeat): ''' >>> repeat = romanText.rtObjects.RTRepeatStop() >>> repeat <music21.romanText.rtObjects.RTRepeatStop ':||'> ''' def __init__(self, src: str = ':||', container: RTMeasure | None = 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) -> None: # tokens are ABC rtObjects in a linear stream # tokens are strongly divided between header and body, so can # divide here self._tokens: list[RTToken] = [] self.currentLineNumber: int = 0
[docs] def splitAtHeader(self, lines: list[str]) -> tuple[list[str], list[str]]: ''' 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: list[str]) -> list[RTTagged]: ''' In the header, we only have :class:`~music21.romanText.base.RTTagged` tokens. We can this process these all as the same class. ''' post: list[RTTagged] = [] 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: list[str]) -> list[RTToken]: ''' In the body, we may have measure, time signature, or note declarations, as well as possible other tagged definitions. ''' post: list[RTToken] = [] 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: str, container: RTMeasure | None = None ) -> list[RTAtom]: ''' 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: list[RTAtom] = [] # 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: str) -> None: ''' 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: str) -> None: ''' 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: int = 2) -> bool: ''' 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 token in self._tokens: if token.isMovement(): count += 1 if count >= countRequired: return True return False
[docs] def definesMovement(self) -> bool: ''' 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: bool = True) -> list[RTHandler]: # 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: list[RTHandler] = [] sub: list[RTToken] = [] for token in self._tokens: if token.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(token) if sub: rth = RTHandler() rth.tokens = sub post.append(rth) if duplicateHeader: alt: list[RTHandler] = [] # 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) -> list[RTToken]: ''' 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: list[RTToken]) -> None: ''' Assign tokens to this Handler. ''' self._tokens = tokens def __len__(self) -> int: return len(self._tokens) def __add__(self, other: RTHandler) -> RTHandler: ''' 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) -> None: self.file: t.IO[str] | None = None self.filename: str | pathlib.Path | None = None
[docs] def open(self, filename: str | pathlib.Path) -> None: ''' Open a file for reading, trying a variety of encodings and then trying them again with an "ignore" flag if it is not possible. ''' encoding: str | None 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: t.IO[str]) -> None: ''' Assign a file-like object, such as those provided by StringIO, as an open file object. ''' self.file = fileLike # already 'open'
def _reprInternal(self) -> str: return ''
[docs] def close(self) -> None: openFile = t.cast('t.IO[str]', self.file) openFile.close()
[docs] def read(self) -> RTHandler: ''' Read a file. Note that this calls readstr, which processes all tokens. If `number` is given, a work number will be extracted if possible. ''' openFile = t.cast('t.IO[str]', self.file) return self.readstr(openFile.read())
[docs] def readstr(self, strSrc: str) -> RTHandler: ''' 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) -> None: 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) -> None: # gets the index of the end of the measure indication g = reMeasureTag.match('m1 g: V b2 i') assert g is not None, 'reMeasureTag did not match' 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') assert g is not None, 'reMeasureTag did not match' self.assertEqual(g.end(), 4) self.assertEqual(g.start(), 0) self.assertEqual(g.group(0), 'm3-4') g = reMeasureTag.match('m123-432=m1120-24234') assert g is not None, 'reMeasureTag did not match' self.assertEqual(g.group(0), 'm123-432') g = reMeasureTag.match('m231a IV6 b4 C: V') assert g is not None, 'reMeasureTag did not match' self.assertEqual(g.group(0), 'm231a') g = reMeasureTag.match('m123b-432b=m1120a-24234a') assert g is not None, 'reMeasureTag did not match' self.assertEqual(g.group(0), 'm123b-432b') g = reMeasureTag.match('m231var1 IV6 b4 C: V') assert g is not None, 'reMeasureTag did not match' self.assertEqual(g.group(0), 'm231') # this only works if it starts the string g = reVariant.match('var1 IV6 b4 C: V') assert g is not None, 'reVariant did not match' self.assertEqual(g.group(0), 'var1') g = reAnalyticKeyAtom.match('Bb:') assert g is not None, 'reAnalyticKeyAtom did not match' self.assertEqual(g.group(0), 'Bb:') g = reAnalyticKeyAtom.match('F#:') assert g is not None, 'reAnalyticKeyAtom did not match' self.assertEqual(g.group(0), 'F#:') g = reAnalyticKeyAtom.match('f#:') assert g is not None, 'reAnalyticKeyAtom did not match' self.assertEqual(g.group(0), 'f#:') g = reAnalyticKeyAtom.match('b:') assert g is not None, 'reAnalyticKeyAtom did not match' self.assertEqual(g.group(0), 'b:') g = reAnalyticKeyAtom.match('bb:') assert g is not None, 'reAnalyticKeyAtom did not match' self.assertEqual(g.group(0), 'bb:') g = reAnalyticKeyAtom.match('g:') assert g is not None, 'reAnalyticKeyAtom did not match' 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') assert g is not None, 'reBeatAtom did not match' self.assertEqual(g.group(0), 'b2.5') g = reBeatAtom.match('bVII') self.assertEqual(g, None) g = reBeatAtom.match('b1.66.5') assert g is not None, 'reBeatAtom did not match' self.assertEqual(g.group(0), 'b1.66.5') def testMeasureAttributeProcessing(self) -> None: 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) -> None: # 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 token in rth._tokens: if token.isMovement(): count += 1 self.assertEqual(count, 3) rth.process(testFiles.riemenschneider001) count = 0 for token in rth._tokens: if token.isMeasure(): # print(token.src) count += 1 # 21, 2 variants, and one pickup self.assertEqual(count, 21 + 2 + 1) count = 0 for token in rth._tokens: if isinstance(token, RTMeasure): for a in token.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)