diff --git a/music21/romanText/tsvConverter.py b/music21/romanText/tsvConverter.py index 2ce3362eb7..89de6d352f 100644 --- a/music21/romanText/tsvConverter.py +++ b/music21/romanText/tsvConverter.py @@ -13,14 +13,20 @@ DCMLab's Annotated Beethoven Corpus (Neuwirth et al. 2018). ''' +import abc import csv +import fractions +import re +import types +import typing as t import unittest +from music21 import chord from music21 import common +from music21 import harmony from music21 import key from music21 import metadata from music21 import meter -from music21 import note from music21 import roman from music21 import stream @@ -35,64 +41,206 @@ class TsvException(exceptions21.Music21Exception): pass -# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ -class TabChord: +# V1_HEADERS and V2_HEADERS specify the columns that we process from the DCML +# files, together with the type that the columns should be coerced to (usually +# str) + +V1_HEADERS = types.MappingProxyType({ + 'chord': str, + 'altchord': str, + 'measure': int, + 'beat': float, + 'totbeat': str, + 'timesig': str, + # 'op': str, + # 'no': str, + # 'mov': str, + 'length': float, + 'global_key': str, + 'local_key': str, + 'pedal': str, + 'numeral': str, + 'form': str, + 'figbass': str, + 'changes': str, + 'relativeroot': str, + 'phraseend': str, +}) + +MN_ONSET_REGEX = re.compile( + r'(?P\d+(?:\.\d+)?)/(?P\d+(?:\.\d+)?)' +) + +def _float_or_frac(value): + # mn_onset in V2 is sometimes notated as a fraction like '1/2'; we need + # to handle such cases + try: + return float(value) + except ValueError: + m = re.match(MN_ONSET_REGEX, value) + return float(m.group('numer')) / float(m.group('denom')) + + +V2_HEADERS = types.MappingProxyType({ + 'chord': str, + 'mn': int, + 'mn_onset': _float_or_frac, + 'timesig': str, + 'globalkey': str, + 'localkey': str, + 'pedal': str, + 'numeral': str, + 'form': str, + 'figbass': str, + 'changes': str, + 'relativeroot': str, + 'phraseend': str, + 'label': str, +}) + +HEADERS = {1: V1_HEADERS, 2: V2_HEADERS} + +# Headers for Digital and Cognitive Musicology Lab Standard v1 as in the ABC +# corpus at +# https://github.com/DCMLab/ABC/tree/2e8a01398f8ad694d3a7af57bed8b14ac57120b7 +DCML_V1_HEADERS = ( + 'chord', + 'altchord', + 'measure', + 'beat', + 'totbeat', + 'timesig', + 'op', + 'no', + 'mov', + 'length', + 'global_key', + 'local_key', + 'pedal', + 'numeral', + 'form', + 'figbass', + 'changes', + 'relativeroot', + 'phraseend', +) + +# Headers for Digital and Cognitive Musicology Lab Standard v2 as in the ABC +# corpus at +# https://github.com/DCMLab/ABC/tree/65c831a559c47180d74e2679fea49aa117fd3dbb +DCML_V2_HEADERS = ( + 'mc', + 'mn', + 'mc_onset', + 'mn_onset', + 'timesig', + 'staff', + 'voice', + 'volta', + 'label', + 'globalkey', + 'localkey', + 'pedal', + 'chord', + 'special', + 'numeral', + 'form', + 'figbass', + 'changes', + 'relativeroot', + 'cadence', + 'phraseend', + 'chord_type', + 'globalkey_is_minor', + 'localkey_is_minor', + 'chord_tones', + 'added_tones', + 'root', + 'bass_note', +) + +DCML_HEADERS = {1: DCML_V1_HEADERS, 2: DCML_V2_HEADERS} + +class TabChordBase(abc.ABC): ''' - An intermediate representation format for moving between tabular data and music21 chords. + Abstract base class for intermediate representation format for moving + between tabular data and music21 chords. ''' + def __init__(self): - self.combinedChord = None # 'chord' in ABC original, otherwise names the same - self.altchord = None - self.measure = None - self.beat = None - self.totbeat = None + super().__init__() + self.numeral = None + self.relativeroot = None + self.representationType = None # Added (not in DCML) + self.extra: t.Dict[str, str] = {} + self.dcml_version = -1 + + # shared between DCML v1 and v2 + self.chord = None self.timesig = None - self.op = None - self.no = None - self.mov = None - self.length = None - self.global_key = None - self.local_key = None self.pedal = None - self.numeral = None self.form = None self.figbass = None self.changes = None - self.relativeroot = None self.phraseend = None - self.representationType = None # Added (not in DCML) - def _changeRepresentation(self): - ''' - Converts the representationType of a TabChord between the music21 and DCML conventions, - especially for the different handling of expectations in minor. + # the following attributes are overwritten by properties in TabChordV2 + # because of changed column names in DCML v2 + self.local_key = None + self.global_key = None + self.beat = None + self.measure = None - First, let's set up a TabChord(). + @property + def combinedChord(self) -> str: + ''' + For easier interoperability with the DCML standards, we now use the + column name 'chord' from the DCML file. But to preserve backwards- + compatibility, we add this property, which is an alias for 'chord'. >>> tabCd = romanText.tsvConverter.TabChord() + >>> tabCd.chord = 'viio7' + >>> tabCd.combinedChord + 'viio7' + >>> tabCd.combinedChord = 'IV+' + >>> tabCd.chord + 'IV+' + ''' + return self.chord + + @combinedChord.setter + def combinedChord(self, value: str): + self.chord = value + + def _changeRepresentation(self) -> None: + ''' + Converts the representationType of a TabChordBase subclass between the + music21 and DCML conventions. + + To demonstrate, let's set up a dummy TabChordV2(). + + >>> tabCd = romanText.tsvConverter.TabChordV2() >>> tabCd.global_key = 'F' >>> tabCd.local_key = 'vi' - >>> tabCd.numeral = '#vii' + >>> tabCd.numeral = 'ii' + >>> tabCd.chord = 'ii%7(6)' >>> tabCd.representationType = 'DCML' - There's no change for a major-key context, but for a minor-key context - (given here by 'relativeroot') the 7th degree is handled differently. - - >>> tabCd.relativeroot = 'v' >>> tabCd.representationType 'DCML' - >>> tabCd.numeral - '#vii' + >>> tabCd.chord + 'ii%7(6)' >>> tabCd._changeRepresentation() >>> tabCd.representationType 'm21' - >>> tabCd.numeral - 'vii' + >>> tabCd.chord + 'iiø7[no5][add6]' ''' if self.representationType == 'm21': @@ -107,13 +255,27 @@ def _changeRepresentation(self): raise ValueError("Data source must specify representation type as 'm21' or 'DCML'.") self.local_key = characterSwaps(self.local_key, - minor=is_minor(self.global_key), + minor=isMinor(self.global_key), direction=direction) + # previously, '%' (indicating half-diminished) was not being parsed + # properly. + if direction == 'DCML-m21': + self.form = self.form.replace('%', 'ø') if self.form is not None else None + if self.dcml_version == 2: + self.chord = self.chord.replace('%', 'ø') + self.chord = handleAddedTones(self.chord) + if ( + self.extra.get('chord_type', '') == 'Mm7' + and self.numeral != 'V' + ): + # we need to make sure not to match [add4] and the like + self.chord = re.sub(r'(\d+)(?!])', r'd\1', self.chord) + # Local - relative and figure - if is_minor(self.local_key): + if isMinor(self.local_key): if self.relativeroot: # If there's a relative root ... - if is_minor(self.relativeroot): # ... and it's minor too, change it and the figure + if isMinor(self.relativeroot): # ... and it's minor too, change it and the figure self.relativeroot = characterSwaps(self.relativeroot, minor=True, direction=direction) @@ -130,7 +292,7 @@ def _changeRepresentation(self): direction=direction) else: # local key not minor if self.relativeroot: # if there's a relativeroot ... - if is_minor(self.relativeroot): # ... and it's minor, change it and the figure + if isMinor(self.relativeroot): # ... and it's minor, change it and the figure self.relativeroot = characterSwaps(self.relativeroot, minor=False, direction=direction) @@ -146,7 +308,7 @@ def _changeRepresentation(self): minor=False, direction=direction) - def tabToM21(self): + def tabToM21(self) -> harmony.Harmony: ''' Creates and returns a music21.roman.RomanNumeral() object from a TabChord with all shared attributes. @@ -165,82 +327,168 @@ def tabToM21(self): >>> m21Ch.figure 'vii' ''' - - if self.numeral: - if self.form: - if self.figbass: - combined = ''.join([self.numeral, self.form, self.figbass]) - else: - combined = ''.join([self.numeral, self.form]) + if self.representationType == 'DCML': + self._changeRepresentation() + if self.numeral in ('@none', None): + thisEntry: harmony.Harmony = harmony.NoChord() + else: + if self.dcml_version == 2 and self.chord: + combined = self.chord else: - combined = self.numeral - - if self.relativeroot: # special case requiring '/'. - combined = ''.join([combined, '/', self.relativeroot]) - - localKeyNonRoman = getLocalKey(self.local_key, self.global_key) - - thisEntry = roman.RomanNumeral(combined, localKeyNonRoman) - thisEntry.quarterLength = self.length - - thisEntry.op = self.op - thisEntry.no = self.no - thisEntry.mov = self.mov - - thisEntry.pedal = self.pedal - - thisEntry.phraseend = None - - else: # handling case of '@none' - thisEntry = note.Rest() - thisEntry.quarterLength = self.length + # previously this code only included figbass in combined if form + # was not falsy, which seems incorrect + combined = ''.join( + attr for attr in (self.numeral, self.form, self.figbass) if attr + ) + + if self.relativeroot: # special case requiring '/'. + combined += '/' + self.relativeroot + if self.local_key is not None and re.match( + r'.*(i*v|v?i+).*', self.local_key, re.IGNORECASE + ): + # if self.local_key contains a roman numeral, express it + # as a pitch, relative to the global key + localKeyNonRoman = getLocalKey(self.local_key, self.global_key) + else: + # otherwise, we assume self.local_key is already a pitch and + # pass it through unchanged + localKeyNonRoman = self.local_key + thisEntry = roman.RomanNumeral( + combined, + localKeyNonRoman, + sixthMinor=roman.Minor67Default.FLAT, + seventhMinor=roman.Minor67Default.FLAT + ) + if isinstance(self, TabChord): + # following metadata attributes seem to be missing from + # dcml_version 2 tsv files + thisEntry.editorial.op = self.extra.get('op', '') + thisEntry.editorial.no = self.extra.get('no', '') + thisEntry.editorial.mov = self.extra.get('mov', '') + + thisEntry.editorial.pedal = self.pedal + thisEntry.editorial.phraseend = None + # if dcml_version == 2, we need to calculate the quarterLength + # later + thisEntry.quarterLength = 0.0 return thisEntry -# ------------------------------------------------------------------------------ + def populateFromRow( + self, + row: t.List[str], + headIndices: t.Dict[str, t.Tuple[int, t.Type]], + extraIndices: t.Dict[int, str] + ) -> None: + # To implement without calling setattr we would need to repeat lines + # similar to the following three lines for every attribute (with + # attributes specific to subclasses in their own methods that would + # then call __super__()). + # if 'chord' in head_indices: + # i, type_to_coerce_to = head_indices['chord'] + # self.chord = type_to_coerce_to(row[i]) + for col_name, (i, type_to_coerce_to) in headIndices.items(): + if not hasattr(self, col_name): + pass # would it be appropriate to emit a warning here? + else: + setattr(self, col_name, type_to_coerce_to(row[i])) + self.extra = { + col_name: row[i] for i, col_name in extraIndices.items() if row[i] + } +class TabChord(TabChordBase): + ''' + An intermediate representation format for moving between tabular data in + DCML v1 and music21 chords. + ''' + def __init__(self): + # self.numeral and self.relativeroot defined in super().__init__() + super().__init__() + self.altchord = None + self.totbeat = None + self.length = None + self.dcml_version = 1 -def makeTabChord(row): +class TabChordV2(TabChordBase): + ''' + An intermediate representation format for moving between tabular data in + DCML v2 and music21 chords. ''' - Makes a TabChord out of a list imported from TSV data - (a row of the original tabular format -- see TsvHandler.importTsv()). + def __init__(self): + # self.numeral and self.relativeroot defined in super().__init__() + super().__init__() + self.mn = None + self.mn_onset = None + self.globalkey = None + self.localkey = None + self.dcml_version = 2 + + @property + def beat(self) -> float: + ''' + 'beat' has been removed from DCML v2 in favor of 'mn_onset' and + 'mc_onset'. 'mn_onset' is equivalent to 'beat', except that 'mn_onset' + is zero-indexed where 'beat' was 1-indexed, and 'mn_onset' is in + fractions of a whole-note rather than in quarter notes. + + >>> tabCd = romanText.tsvConverter.TabChordV2() + >>> tabCd.mn_onset = 0.0 + >>> tabCd.beat + 1.0 + + >>> tabCd.mn_onset = 0.5 + >>> tabCd.beat + 3.0 + + >>> tabCd.beat = 1.5 + >>> tabCd.beat + 1.5 + ''' + # beat is zero-indexed in v2 but one-indexed in v1 + # moreover, beat is in fractions of a whole-note in v2 + return self.mn_onset * 4.0 + 1.0 - This is how to make the TabChord: + @beat.setter + def beat(self, beat: float): + self.mn_onset = (beat - 1.0) / 4.0 if beat is not None else None - >>> tabRowAsString1 = ['.C.I6', '', '1', '1.0', '1.0', '2/4', '1', '2', '3', '2.0', - ... 'C', 'I', '', 'I', '', '', '', '', 'false'] - >>> testTabChord1 = romanText.tsvConverter.makeTabChord(tabRowAsString1) + @property + def measure(self) -> int: + ''' + 'measure' has been removed from DCML v2 in favor of 'mn' and 'mc'. 'mn' + is equivalent to 'measure', so this property is provided as an alias. + ''' + return int(self.mn) - And now let's check that it really is a TabChord: + @measure.setter + def measure(self, measure: int): + self.mn = int(measure) if measure is not None else None - >>> testTabChord1.numeral - 'I' - ''' + @property + def local_key(self) -> str: + ''' + 'local_key' has been renamed 'localkey' in DCML v2. This property is + provided as an alias for 'localkey' so that TabChord and TabChordV2 can + be used in the same way. + ''' + return self.localkey + + @local_key.setter + def local_key(self, k: str): + self.localkey = k + + @property + def global_key(self) -> str: + ''' + 'global_key' has been renamed 'globalkey' in DCML v2. This property is + provided as an alias for 'globalkey' so that TabChord and TabChordV2 can + be used in the same way. + ''' + return self.globalkey - thisEntry = TabChord() - - thisEntry.combinedChord = str(row[0]) - thisEntry.altchord = str(row[1]) - thisEntry.measure = int(row[2]) - thisEntry.beat = float(row[3]) - thisEntry.totbeat = float(row[4]) - thisEntry.timesig = row[5] - thisEntry.op = row[6] - thisEntry.no = row[7] - thisEntry.mov = row[8] - thisEntry.length = float(row[9]) - thisEntry.global_key = str(row[10]) - thisEntry.local_key = str(row[11]) - thisEntry.pedal = str(row[12]) - thisEntry.numeral = str(row[13]) - thisEntry.form = str(row[14]) - thisEntry.figbass = str(row[15]) - thisEntry.changes = str(row[16]) - thisEntry.relativeroot = str(row[17]) - thisEntry.phraseend = str(row[18]) - thisEntry.representationType = 'DCML' # Added - - return thisEntry + @global_key.setter + def global_key(self, k: str): + self.globalkey = k # ------------------------------------------------------------------------------ @@ -251,7 +499,7 @@ class TsvHandler: First we need to get a score. (Don't worry about this bit.) - >>> name = 'tsvEg.tsv' + >>> name = 'tsvEg_v1.tsv' >>> path = common.getSourceFilePath() / 'romanText' / name >>> handler = romanText.tsvConverter.TsvHandler(path) >>> handler.tsvToChords() @@ -271,19 +519,44 @@ class TsvHandler: And for our last trick, we can put the whole collection in a music21 stream. >>> out_stream = handler.toM21Stream() - >>> out_stream.parts[0].measure(1)[0].figure + >>> out_stream.parts[0].measure(1)[roman.RomanNumeral][0].figure 'I' ''' - - def __init__(self, tsvFile): + def __init__(self, tsvFile: str, dcml_version: int = 1): + if dcml_version == 1: + self.heading_names = HEADERS[1] + self._tab_chord_cls: t.Type[TabChordBase] = TabChord + elif dcml_version == 2: + self.heading_names = HEADERS[2] + self._tab_chord_cls = TabChordV2 + else: + raise ValueError(f'dcml_version {dcml_version} is not in (1, 2)') self.tsvFileName = tsvFile - self.tsvData = self.importTsv() - self.chordList = [] - self.m21stream = None - self.preparedStream = None + self.chordList: t.List[TabChordBase] = [] + self.m21stream: t.Optional[stream.Score] = None + self._head_indices: t.Dict[str, t.Tuple[int, t.Union[t.Type, t.Any]]] = {} + self._extra_indices: t.Dict[int, str] = {} + self.dcml_version = dcml_version + self.tsvData = self._importTsv() # converted to private + + def _get_heading_indices(self, header_row: t.List[str]) -> None: + '''Private method to get column name/column index correspondences. + + Expected column indices (those in HEADERS, which correspond to TabChord + attributes) are stored in self._head_indices. Others go in + self._extra_indices. + ''' + self._head_indices = {} + self._extra_indices = {} + for i, col_name in enumerate(header_row): + if col_name in self.heading_names: + type_to_coerce_col_to = self.heading_names[col_name] + self._head_indices[col_name] = (i, type_to_coerce_col_to) + else: + self._extra_indices[i] = col_name - def importTsv(self): + def _importTsv(self) -> t.List[t.List[str]]: ''' Imports TSV file data for further processing. ''' @@ -291,16 +564,30 @@ def importTsv(self): fileName = self.tsvFileName with open(fileName, 'r', encoding='utf-8') as f: - data = [] - for row_num, line in enumerate(f): - if row_num == 0: # Ignore first row (headers) - continue - values = line.strip().split('\t') - data.append([v.strip('\"') for v in values]) + tsvreader = csv.reader(f, delimiter='\t', quotechar='"') + # The first row is the header + self._get_heading_indices(next(tsvreader)) + return list(tsvreader) - return data + def _makeTabChord(self, row: t.List[str]) -> TabChordBase: + ''' + Makes a TabChord out of a list imported from TSV data + (a row of the original tabular format -- see TsvHandler.importTsv()). + ''' + # this method replaces the previously stand-alone makeTabChord function + thisEntry = self._tab_chord_cls() + thisEntry.populateFromRow(row, self._head_indices, self._extra_indices) + # for col_name, (i, type_to_coerce_to) in self._head_indices.items(): + # # set attributes of thisEntry according to values in row + # setattr(thisEntry, col_name, type_to_coerce_to(row[i])) + # thisEntry.extra = { + # col_name: row[i] for i, col_name in self._extra_indices.items() if row[i] + # } + thisEntry.representationType = 'DCML' # Addeds - def tsvToChords(self): + return thisEntry + + def tsvToChords(self) -> None: ''' Converts a list of lists (of the type imported by importTsv) into TabChords (i.e. a list of TabChords). @@ -308,18 +595,16 @@ def tsvToChords(self): data = self.tsvData - chordList = [] + self.chordList = [] for entry in data: - thisEntry = makeTabChord(entry) + thisEntry = self._makeTabChord(entry) if thisEntry is None: continue else: - chordList.append(thisEntry) - - self.chordList = chordList + self.chordList.append(thisEntry) - def toM21Stream(self): + def toM21Stream(self) -> stream.Score: ''' Takes a list of TabChords (self.chordList, prepared by .tsvToChords()), converts those TabChords in RomanNumerals @@ -327,29 +612,40 @@ def toM21Stream(self): creates a suitable music21 stream (by running .prepStream() using data from the TabChords), and populates that stream with the new RomanNumerals. ''' + if not self.chordList: + self.tsvToChords() - self.prepStream() - - s = self.preparedStream + s = self.prepStream() p = s.parts.first() # Just to get to the part, not that there are several. + if p is None: + # in case stream has no parts + return s + for thisChord in self.chordList: offsetInMeasure = thisChord.beat - 1 # beats always measured in quarter notes measureNumber = thisChord.measure m21Measure = p.measure(measureNumber) - - if thisChord.representationType == 'DCML': - thisChord._changeRepresentation() + if m21Measure is None: + raise ValueError('m21Measure should not be None') thisM21Chord = thisChord.tabToM21() # In either case. + # Store any otherwise unhandled attributes of the chord + thisM21Chord.editorial.update(thisChord.extra) m21Measure.insert(offsetInMeasure, thisM21Chord) - self.m21stream = s + s.flatten().extendDuration(harmony.Harmony, inPlace=True) + last_harmony = s[harmony.Harmony].last() + if last_harmony is not None: + last_harmony.quarterLength = ( + s.quarterLength - last_harmony.activeSite.offset - last_harmony.offset + ) + self.m21stream = s return s - def prepStream(self): + def prepStream(self) -> stream.Score: ''' Prepares a music21 stream for the harmonic analysis to go into. Specifically: creates the score, part, and measure streams, @@ -359,57 +655,66 @@ def prepStream(self): ''' s = stream.Score() p = stream.Part() - - s.insert(0, metadata.Metadata()) - - firstEntry = self.chordList[0] # Any entry will do - s.metadata.opusNumber = firstEntry.op - s.metadata.number = firstEntry.no - s.metadata.movementNumber = firstEntry.mov - s.metadata.title = 'Op' + firstEntry.op + '_No' + firstEntry.no + '_Mov' + firstEntry.mov + if self.dcml_version == 1: + # This sort of metadata seems to have been removed altogether from the + # v2 files + s.insert(0, metadata.Metadata()) + + firstEntry = self.chordList[0] # Any entry will do + title = [] + if 'op' in firstEntry.extra: + s.metadata.opusNumber = firstEntry.extra['op'] + title.append('Op' + s.metadata.opusNumber) + if 'no' in firstEntry.extra: + s.metadata.number = firstEntry.extra['no'] + title.append('No' + s.metadata.number) + if 'mov' in firstEntry.extra: + s.metadata.movementNumber = firstEntry.extra['mov'] + title.append('Mov' + s.metadata.movementNumber) + if title: + s.metadata.title = '_'.join(title) startingKeySig = str(self.chordList[0].global_key) ks = key.Key(startingKeySig) - p.insert(0, ks) currentTimeSig = str(self.chordList[0].timesig) ts = meter.TimeSignature(currentTimeSig) - p.insert(0, ts) currentMeasureLength = ts.barDuration.quarterLength - currentOffset = 0 + currentOffset: t.Union[float, fractions.Fraction] = 0.0 previousMeasure: int = self.chordList[0].measure - 1 # Covers pickups for entry in self.chordList: if entry.measure == previousMeasure: continue elif entry.measure != previousMeasure + 1: # Not every measure has a chord change. - for mNo in range(previousMeasure + 1, entry.measure): + for mNo in range(previousMeasure + 1, entry.measure + 1): m = stream.Measure(number=mNo) m.offset = currentOffset + currentMeasureLength p.insert(m) - currentOffset = m.offset previousMeasure = mNo else: # entry.measure = previousMeasure + 1 m = stream.Measure(number=entry.measure) - m.offset = entry.totbeat + # 'totbeat' column (containing the current offset) has been + # removed from v2 so instead we calculate the offset directly + # to be portable across versions + currentOffset = m.offset = currentOffset + currentMeasureLength p.insert(m) if entry.timesig != currentTimeSig: newTS = meter.TimeSignature(entry.timesig) m.insert(entry.beat - 1, newTS) - currentTimeSig = entry.timesig currentMeasureLength = newTS.barDuration.quarterLength previousMeasure = entry.measure - currentOffset = entry.totbeat s.append(p) - - self.preparedStream = s - + first_measure = s[stream.Measure].first() + if first_measure is not None: + first_measure.insert(0, ks) + first_measure.insert(0, ts) return s @@ -423,25 +728,41 @@ class M21toTSV: >>> bachHarmony.parts[0].measure(1)[0].figure 'I' - The initialisation includes the preparation of a list of lists, so + The initialization includes the preparation of a list of lists, so - >>> initial = romanText.tsvConverter.M21toTSV(bachHarmony) + >>> initial = romanText.tsvConverter.M21toTSV(bachHarmony, dcml_version=2) >>> tsvData = initial.tsvData - >>> tsvData[1][0] + >>> from music21.romanText.tsvConverter import DCML_V2_HEADERS + >>> tsvData[1][DCML_V2_HEADERS.index('chord')] 'I' ''' - def __init__(self, m21Stream): + def __init__(self, m21Stream: stream.Score, dcml_version: int = 2): + self.version = dcml_version self.m21Stream = m21Stream + if dcml_version == 1: + self.dcml_headers = DCML_HEADERS[1] + elif dcml_version == 2: + self.dcml_headers = DCML_HEADERS[2] + else: + raise ValueError(f'dcml_version {dcml_version} is not in (1, 2)') self.tsvData = self.m21ToTsv() - def m21ToTsv(self): + def m21ToTsv(self) -> t.List[t.List[str]]: ''' Converts a list of music21 chords to a list of lists which can then be written to a tsv file with toTsv(), or processed another way. ''' + if self.version == 1: + return self._m21ToTsv_v1() + return self._m21ToTsv_v2() + def _m21ToTsv_v1(self) -> t.List[t.List[str]]: tsvData = [] + # take the global_key from the first item + global_key = next( + self.m21Stream.recurse().getElementsByClass('RomanNumeral') + ).key.tonicPitchNameWithCase for thisRN in self.m21Stream[roman.RomanNumeral]: @@ -461,47 +782,107 @@ def m21ToTsv(self): thisEntry.measure = thisRN.measureNumber thisEntry.beat = thisRN.beat thisEntry.totbeat = None - thisEntry.timesig = thisRN.getContextByClass(meter.TimeSignature).ratioString - thisEntry.op = self.m21Stream.metadata.opusNumber - thisEntry.no = self.m21Stream.metadata.number - thisEntry.mov = self.m21Stream.metadata.movementNumber + ts = thisRN.getContextByClass(meter.TimeSignature) + if ts is None: + thisEntry.timesig = '' + else: + thisEntry.timesig = ts.ratioString + thisEntry.extra['op'] = self.m21Stream.metadata.opusNumber or '' + thisEntry.extra['no'] = self.m21Stream.metadata.number or '' + thisEntry.extra['mov'] = self.m21Stream.metadata.movementNumber or '' thisEntry.length = thisRN.quarterLength - thisEntry.global_key = None - thisEntry.local_key = thisRN.key + thisEntry.global_key = global_key + thisEntry.local_key = thisRN.key.tonicPitchNameWithCase thisEntry.pedal = None - thisEntry.numeral = thisRN.romanNumeralAlone - thisEntry.form = None - thisEntry.figbass = thisRN.figuresWritten + thisEntry.numeral = thisRN.romanNumeral + thisEntry.form = getForm(thisRN) + # Strip any leading non-digits from figbass (e.g., M43 -> 43) + figbassMatch = re.match(r'^\D*(\d.*|)', thisRN.figuresWritten) + if figbassMatch is not None: + thisEntry.figbass = figbassMatch.group(1) + else: + thisEntry.figbass = '' thisEntry.changes = None # TODO thisEntry.relativeroot = relativeroot thisEntry.phraseend = None - thisInfo = [thisEntry.combinedChord, - thisEntry.altchord, - thisEntry.measure, - thisEntry.beat, - thisEntry.totbeat, - thisEntry.timesig, - thisEntry.op, - thisEntry.no, - thisEntry.mov, - thisEntry.length, - thisEntry.global_key, - thisEntry.local_key, - thisEntry.pedal, - thisEntry.numeral, - thisEntry.form, - thisEntry.figbass, - thisEntry.changes, - thisEntry.relativeroot, - thisEntry.phraseend - ] - + thisInfo = [ + getattr(thisEntry, name, thisRN.editorial.get(name, '')) + for name in self.dcml_headers + ] tsvData.append(thisInfo) return tsvData - def write(self, filePathAndName): + def _m21ToTsv_v2(self) -> t.List[t.List[str]]: + tsvData: t.List[t.List[str]] = [] + + # take the global_key from the first item + first_rn = self.m21Stream[roman.RomanNumeral].first() + if first_rn is None: + return tsvData + global_key_obj = first_rn.key + global_key = global_key_obj.tonicPitchNameWithCase + for thisRN in self.m21Stream.recurse().getElementsByClass( + [roman.RomanNumeral, harmony.NoChord] + ): + thisEntry = TabChordV2() + thisEntry.mn = thisRN.measureNumber + # for a reason I do not understand, thisRN.beat in V2 seems to + # always be beat 1. In neither v1 is thisRN set explicitly; + # the offset/beat seems to be determined by + # m21Measure.insert(offsetInMeasure, thisM21Chord) above. I'm at + # a loss why there is an issue here but using thisRN.offset works + # just fine. + thisEntry.mn_onset = thisRN.offset / 4 + timesig = thisRN.getContextByClass(meter.TimeSignature) + if timesig is None: + thisEntry.timesig = '' + else: + thisEntry.timesig = timesig.ratioString + thisEntry.global_key = global_key + if isinstance(thisRN, harmony.NoChord): + thisEntry.numeral = '@none' + thisEntry.chord = '@none' + else: + local_key = localKeyAsRn(thisRN.key, global_key_obj) + relativeroot = None + if thisRN.secondaryRomanNumeral: + relativeroot = thisRN.secondaryRomanNumeral.figure + relativeroot = characterSwaps( + relativeroot, isMinor(local_key), direction='m21-DCML' + ) + thisEntry.chord = thisRN.figure # NB: slightly different from DCML: no key. + thisEntry.pedal = None + thisEntry.numeral = thisRN.romanNumeral + thisEntry.form = getForm(thisRN) + # Strip any leading non-digits from figbass (e.g., M43 -> 43) + figbassm = re.match(r'^\D*(\d.*|)', thisRN.figuresWritten) + # implementing the following check according to the review + # at https://github.com/cuthbertLab/music21/pull/1267/files/a1ad510356697f393bf6b636af8f45e81ad6ccc8#r936472302 #pylint: disable=line-too-long + # but the match should always exist because either: + # 1. there is a digit in the string, in which case it matches + # because of the left side of the alternation operator + # 2. there is no digit in the string, in which case it matches + # because of the right side of the alternation operator + # (an empty string) + if figbassm is not None: + thisEntry.figbass = figbassm.group(1) + else: + thisEntry.figbass = '' + thisEntry.changes = None + thisEntry.relativeroot = relativeroot + thisEntry.phraseend = None + thisEntry.local_key = local_key + + thisInfo = [ + getattr(thisEntry, name, thisRN.editorial.get(name, '')) + for name in self.dcml_headers + ] + tsvData.append(thisInfo) + return tsvData + + def write(self, filePathAndName: str): ''' Writes a list of lists (e.g. from m21ToTsv()) to a tsv file. ''' @@ -510,49 +891,166 @@ def write(self, filePathAndName): delimiter='\t', quotechar='"', quoting=csv.QUOTE_MINIMAL) - - headers = ( - 'chord', - 'altchord', - 'measure', - 'beat', - 'totbeat', - 'timesig', - 'op', - 'no', - 'mov', - 'length', - 'global_key', - 'local_key', - 'pedal', - 'numeral', - 'form', - 'figbass', - 'changes', - 'relativeroot', - 'phraseend', - ) - - csvOut.writerow(headers) + csvOut.writerow(self.dcml_headers) for thisEntry in self.tsvData: csvOut.writerow(thisEntry) # ------------------------------------------------------------------------------ -def is_minor(test_key): + +def getForm(rn: roman.RomanNumeral) -> str: + ''' + Takes a music21.roman.RomanNumeral object and returns the string indicating + 'form' expected by the DCML standard. + + >>> romanText.tsvConverter.getForm(roman.RomanNumeral('V')) + '' + >>> romanText.tsvConverter.getForm(roman.RomanNumeral('viio7')) + 'o' + >>> romanText.tsvConverter.getForm(roman.RomanNumeral('IVM7')) + 'M' + >>> romanText.tsvConverter.getForm(roman.RomanNumeral('III+')) + '+' + >>> romanText.tsvConverter.getForm(roman.RomanNumeral('IV+M7')) + '+M' + >>> romanText.tsvConverter.getForm(roman.RomanNumeral('viiø7')) + '%' + ''' + if 'ø' in rn.figure: + return '%' + if 'o' in rn.figure: + return 'o' + if '+M' in rn.figure: + # Not sure whether there is more than one way for an augmented major seventh to be + # indicated, in which case this condition needs to be updated. + return '+M' + if '+' in rn.figure: + return '+' + if 'M' in rn.figure: + return 'M' + return '' + + +def handleAddedTones(dcmlChord: str) -> str: + ''' + Converts DCML added-tone syntax to music21. + + >>> romanText.tsvConverter.handleAddedTones('V(64)') + 'Cad64' + + >>> romanText.tsvConverter.handleAddedTones('i(4+2)') + 'i[no3][add4][add2]' + + >>> romanText.tsvConverter.handleAddedTones('Viio7(b4-5)/V') + 'Viio7[no3][no5][addb4]/V' + + When in root position, 7 does not replace 8: + >>> romanText.tsvConverter.handleAddedTones('vi(#74)') + 'vi[no3][add#7][add4]' + + When not in root position, 7 does replace 8: + >>> romanText.tsvConverter.handleAddedTones('ii6(11#7b6)') + 'ii6[no8][no5][add11][add#7][addb6]' + + + ''' + m = re.match( + r'(?P.*?(?P
\d*(?:/\d+)*))\((?P.*)\)(?P/.*)?', + dcmlChord + ) + if not m: + return dcmlChord + primary = m.group('primary') + added_tones = m.group('added_tones') + secondary = m.group('secondary') if m.group('secondary') is not None else '' + figure = m.group('figure') + if primary == 'V' and added_tones == '64': + return 'Cad64' + secondary + added_tone_tuples: t.List[t.Tuple[str, str, str, str]] = re.findall( + r''' + (\+|-)? # indicates whether to add or remove chord factor + (\^|v)? # indicates whether tone replaces chord factor above/below + (\#+|b+)? # alteration + (1\d|\d) # figures 0-19, in practice 1-14 + ''', + added_tones, + re.VERBOSE + ) + additions: t.List[str] = [] + omissions: t.List[str] = [] + if figure in ('', '5', '53', '5/3', '3', '7'): + omission_threshold = 7 + else: + omission_threshold = 8 + for added_or_removed, above_or_below, alteration, factor_str in added_tone_tuples: + if added_or_removed == '-': + omissions.append(f'[no{factor_str}]') + continue + factor = int(factor_str) + if added_or_removed == '+' or factor >= omission_threshold: + replace_above = None + elif factor in (1, 3, 5): + replace_above = None + elif factor in (2, 4, 6): + # added scale degrees 2, 4, 6 replace lower neighbor unless + # - alteration = # + # - above_or_below = ^ + replace_above = alteration == '#' or above_or_below == '^' + else: + # Do we need to handle double sharps/flats? + replace_above = alteration != 'b' and above_or_below != 'v' + if replace_above is not None: + if replace_above: + omissions.append(f'[no{factor + 1}]') + else: + omissions.append(f'[no{factor - 1}]') + additions.append(f'[add{alteration}{factor}]') + return primary + ''.join(omissions) + ''.join(additions) + secondary + + +def localKeyAsRn(local_key: key.Key, global_key: key.Key) -> str: + ''' + Takes two music21.key.Key objects and returns the roman numeral for + `local_key` relative to `global_key`. + + >>> k1 = key.Key('C') + >>> k2 = key.Key('e') + >>> romanText.tsvConverter.localKeyAsRn(k1, k2) + 'VI' + >>> k3 = key.Key('C#') + >>> romanText.tsvConverter.localKeyAsRn(k3, k2) + '#VI' + >>> romanText.tsvConverter.localKeyAsRn(k2, k1) + 'iii' + ''' + letter = local_key.tonicPitchNameWithCase + rn = roman.RomanNumeral( + 'i' if letter.islower() else 'I', keyOrScale=local_key + ) + r = roman.romanNumeralFromChord(chord.Chord(rn.pitches), keyObj=global_key) + # Temporary hack: for some reason this gives VI and VII instead of #VI and #VII *only* + # when local_key is major and global_key is minor. + # see issue at https://github.com/cuthbertLab/music21/issues/1349#issue-1327713452 + if (local_key.mode == 'major' and global_key.mode == 'minor' + and r.romanNumeral in ('VI', 'VII') + and (r.pitchClasses[0] - global_key.pitches[0].pitchClass) % 12 in (9, 11)): + return '#' + r.romanNumeral + return r.romanNumeral + +def isMinor(test_key: str) -> bool: ''' Checks whether a key is minor or not simply by upper vs lower case. - >>> romanText.tsvConverter.is_minor('F') + >>> romanText.tsvConverter.isMinor('F') False - >>> romanText.tsvConverter.is_minor('f') + >>> romanText.tsvConverter.isMinor('f') True ''' return test_key == test_key.lower() -def characterSwaps(preString, minor=True, direction='m21-DCML'): +def characterSwaps(preString: str, minor: bool = True, direction: str = 'm21-DCML') -> str: ''' Character swap function to coordinate between the two notational versions, for instance swapping between '%' and '/o' for the notation of half diminished (for example). @@ -560,21 +1058,7 @@ def characterSwaps(preString, minor=True, direction='m21-DCML'): >>> testStr = 'ii%' >>> romanText.tsvConverter.characterSwaps(testStr, minor=False, direction='DCML-m21') 'iiø' - - In the case of minor key, additional swaps for the different default 7th degrees: - - raised in m21 (natural minor) - - not raised in DCML (melodic minor) - - >>> testStr1 = '.f.vii' - >>> romanText.tsvConverter.characterSwaps(testStr1, minor=True, direction='m21-DCML') - '.f.#vii' - - >>> testStr2 = '.f.#vii' - >>> romanText.tsvConverter.characterSwaps(testStr2, minor=True, direction='DCML-m21') - '.f.vii' ''' - search = '' - insert = '' if direction == 'm21-DCML': characterDict = {'/o': '%', 'ø': '%', @@ -589,30 +1073,10 @@ def characterSwaps(preString, minor=True, direction='m21-DCML'): for thisKey in characterDict: # Both major and minor preString = preString.replace(thisKey, characterDict[thisKey]) - if not minor: - return preString - else: - if direction == 'm21-DCML': - search = 'b' - insert = '#' - elif direction == 'DCML-m21': - search = '#' - insert = 'b' - - if 'vii' in preString.lower(): - position = preString.lower().index('vii') - prevChar = preString[position - 1] # the previous character, # / b. - if prevChar == search: - postString = preString[:position - 1] + preString[position:] - else: - postString = preString[:position] + insert + preString[position:] - else: - postString = preString + return preString - return postString - -def getLocalKey(local_key, global_key, convertDCMLToM21=False): +def getLocalKey(local_key: str, global_key: str, convertDCMLToM21: bool = False) -> str: ''' Re-casts comparative local key (e.g. 'V of G major') in its own terms ('D'). @@ -622,20 +1086,30 @@ def getLocalKey(local_key, global_key, convertDCMLToM21=False): >>> romanText.tsvConverter.getLocalKey('ii', 'C') 'd' + >>> romanText.tsvConverter.getLocalKey('i', 'C') + 'c' + By default, assumes an m21 input, and operates as such: - >>> romanText.tsvConverter.getLocalKey('vii', 'a') + >>> romanText.tsvConverter.getLocalKey('#vii', 'a') 'g#' Set convert=True to convert from DCML to m21 formats. Hence; >>> romanText.tsvConverter.getLocalKey('vii', 'a', convertDCMLToM21=True) 'g' + + ''' if convertDCMLToM21: - local_key = characterSwaps(local_key, minor=is_minor(global_key[0]), direction='DCML-m21') - - asRoman = roman.RomanNumeral(local_key, global_key) + local_key = characterSwaps(local_key, minor=isMinor(global_key[0]), direction='DCML-m21') + + asRoman = roman.RomanNumeral( + local_key, + global_key, + sixthMinor=roman.Minor67Default.FLAT, + seventhMinor=roman.Minor67Default.FLAT + ) rt = asRoman.root().name if asRoman.isMajorTriad(): newKey = rt.upper() @@ -647,9 +1121,9 @@ def getLocalKey(local_key, global_key, convertDCMLToM21=False): return newKey -def getSecondaryKey(rn, local_key): +def getSecondaryKey(rn: str, local_key: str) -> str: ''' - Separates comparative Roman-numeral for tonicisiations like 'V/vi' into the component parts of + Separates comparative Roman-numeral for tonicizations like 'V/vi' into the component parts of a Roman-numeral (V) and a (very) local key (vi) and expresses that very local key in relation to the local key also called (DCML column 11). @@ -679,67 +1153,117 @@ def getSecondaryKey(rn, local_key): class Test(unittest.TestCase): def testTsvHandler(self): - name = 'tsvEg.tsv' - # A short and improbably complicated test case complete with: - # '@none' (rest entry), '/' relative root, and time signature changes. - path = common.getSourceFilePath() / 'romanText' / name - - handler = TsvHandler(path) - - # Raw - self.assertEqual(handler.tsvData[0][0], '.C.I6') - self.assertEqual(handler.tsvData[1][0], '#viio6/ii') - - # Chords - handler.tsvToChords() - testTabChord1 = handler.chordList[0] # Also tests makeTabChord() - testTabChord2 = handler.chordList[1] - self.assertIsInstance(testTabChord1, TabChord) - self.assertEqual(testTabChord1.combinedChord, '.C.I6') - self.assertEqual(testTabChord1.numeral, 'I') - self.assertEqual(testTabChord2.combinedChord, '#viio6/ii') - self.assertEqual(testTabChord2.numeral, '#vii') - - # Change Representation - self.assertEqual(testTabChord1.representationType, 'DCML') - testTabChord1._changeRepresentation() - self.assertEqual(testTabChord1.numeral, 'I') - testTabChord2._changeRepresentation() - self.assertEqual(testTabChord2.numeral, 'vii') - - # M21 RNs - m21Chord1 = testTabChord1.tabToM21() - m21Chord2 = testTabChord2.tabToM21() - self.assertEqual(m21Chord1.figure, 'I') - self.assertEqual(m21Chord2.figure, 'viio6/ii') - self.assertEqual(m21Chord1.key.name, 'C major') - self.assertEqual(m21Chord2.key.name, 'C major') - - # M21 stream - out_stream = handler.toM21Stream() - self.assertEqual(out_stream.parts[0].measure(1)[0].figure, 'I') # First item in measure 1 + import os + test_files = { + 1: ('tsvEg_v1.tsv',), + 2: ('tsvEg_v2major.tsv', 'tsvEg_v2minor.tsv'), + } + for version in (1, 2): # test both versions + for name in test_files[version]: + # A short and improbably complicated test case complete with: + # '@none' (rest entry), '/' relative root, and time signature changes. + path = common.getSourceFilePath() / 'romanText' / name + + if 'minor' not in name: + handler = TsvHandler(path, dcml_version=version) + headers = DCML_HEADERS[version] + chord_i = headers.index('chord') + # Raw + # not sure about v1 but in v2 '.C.I6' is 'label', not 'chord' + self.assertEqual(handler.tsvData[0][chord_i], 'I6' if version == 2 else '.C.I6') + self.assertEqual(handler.tsvData[1][chord_i], '#viio6/ii') + + # Chords + handler.tsvToChords() + testTabChord1 = handler.chordList[0] # Also tests makeTabChord() + testTabChord2 = handler.chordList[1] + self.assertIsInstance(testTabChord1, TabChordBase) + self.assertEqual(testTabChord1.combinedChord, 'I6' if version == 2 else '.C.I6') + self.assertEqual(testTabChord1.numeral, 'I') + self.assertEqual(testTabChord2.combinedChord, '#viio6/ii') + self.assertEqual(testTabChord2.numeral, '#vii') + + # Change Representation + self.assertEqual(testTabChord1.representationType, 'DCML') + testTabChord1._changeRepresentation() + self.assertEqual(testTabChord1.numeral, 'I') + testTabChord2._changeRepresentation() + self.assertEqual(testTabChord2.numeral, '#vii') + + # M21 RNs + m21Chord1 = testTabChord1.tabToM21() + m21Chord2 = testTabChord2.tabToM21() + # MIEs in v1, .figure is 'I' rather than 'I6'. This seems wrong + # but leaving the implementation as-is. + self.assertEqual(m21Chord1.figure, 'I6' if version == 2 else 'I') + self.assertEqual(m21Chord2.figure, '#viio6/ii') + self.assertEqual(m21Chord1.key.name, 'C major') + self.assertEqual(m21Chord2.key.name, 'C major') + + # M21 stream + out_stream = handler.toM21Stream() + self.assertEqual( + out_stream.parts[0].measure(1)[roman.RomanNumeral][0].figure, + 'I6' if version == 2 else 'I' + ) + + # test tsv -> m21 -> tsv -> m21; compare m21 streams to make sure + # they're equal + envLocal = environment.Environment() + + forward1 = TsvHandler(path, dcml_version=version) + stream1 = forward1.toM21Stream() + + # Write back to tsv + temp_tsv2 = envLocal.getTempFile() + M21toTSV(stream1, dcml_version=version).write(temp_tsv2) + + # Convert back to m21 again + forward2 = TsvHandler(temp_tsv2, dcml_version=version) + stream2 = forward2.toM21Stream() + os.remove(temp_tsv2) + + # Ensure that both m21 streams are the same + self.assertEqual(len(stream1.recurse()), len(stream2.recurse())) + for i, (item1, item2) in enumerate(zip( + stream1[harmony.Harmony], stream2[harmony.Harmony] + )): + self.assertEqual( + item1, item2, msg=f'item {i}, version {version}: {item1} != {item2}' + ) + first_harmony = stream1[harmony.Harmony].first() + first_offset = first_harmony.activeSite.offset + first_harmony.offset + self.assertEqual( + sum( + h.quarterLength + for h in stream1.recurse().getElementsByClass(harmony.Harmony) + ), + stream1.quarterLength - first_offset + ) def testM21ToTsv(self): import os from music21 import corpus bachHarmony = corpus.parse('bach/choraleAnalyses/riemenschneider001.rntxt') - initial = M21toTSV(bachHarmony) - tsvData = initial.tsvData - self.assertEqual(bachHarmony.parts[0].measure(1)[0].figure, 'I') # NB pickup measure 0. - self.assertEqual(tsvData[1][0], 'I') - - # Test .write - envLocal = environment.Environment() - tempF = envLocal.getTempFile() - initial.write(tempF) - handler = TsvHandler(tempF) - self.assertEqual(handler.tsvData[0][0], 'I') - os.remove(tempF) + for version in (1, 2): + initial = M21toTSV(bachHarmony, dcml_version=version) + tsvData = initial.tsvData + numeral_i = DCML_HEADERS[version].index('numeral') + self.assertEqual(bachHarmony.parts[0].measure(1)[0].figure, 'I') # NB pickup measure 0. + self.assertEqual(tsvData[1][numeral_i], 'I') + + # Test .write + envLocal = environment.Environment() + tempF = envLocal.getTempFile() + initial.write(tempF) + handler = TsvHandler(tempF) + self.assertEqual(handler.tsvData[0][numeral_i], 'I') + os.remove(tempF) def testIsMinor(self): - self.assertTrue(is_minor('f')) - self.assertFalse(is_minor('F')) + self.assertTrue(isMinor('f')) + self.assertFalse(isMinor('F')) def testOfCharacter(self): startText = 'before%after' @@ -758,17 +1282,6 @@ def testOfCharacter(self): self.assertEqual(testStr1in, 'ii%') self.assertEqual(testStr1out, 'iiø') - testStr2in = 'vii' - testStr2out = characterSwaps(testStr2in, minor=True, direction='m21-DCML') - - self.assertEqual(testStr2in, 'vii') - self.assertEqual(testStr2out, '#vii') - - testStr3in = '#vii' - testStr3out = characterSwaps(testStr3in, minor=True, direction='DCML-m21') - - self.assertEqual(testStr3in, '#vii') - self.assertEqual(testStr3out, 'vii') def testGetLocalKey(self): test1 = getLocalKey('V', 'G') @@ -777,7 +1290,7 @@ def testGetLocalKey(self): test2 = getLocalKey('ii', 'C') self.assertEqual(test2, 'd') - test3 = getLocalKey('vii', 'a') + test3 = getLocalKey('#vii', 'a') self.assertEqual(test3, 'g#') test4 = getLocalKey('vii', 'a', convertDCMLToM21=True) @@ -792,6 +1305,7 @@ def testGetSecondaryKey(self): self.assertIsInstance(veryLocalKey, str) self.assertEqual(veryLocalKey, 'b') + # ------------------------------------------------------------------------------ diff --git a/music21/romanText/tsvEg.tsv b/music21/romanText/tsvEg_v1.tsv similarity index 90% rename from music21/romanText/tsvEg.tsv rename to music21/romanText/tsvEg_v1.tsv index 10a4403211..96499b6f85 100644 --- a/music21/romanText/tsvEg.tsv +++ b/music21/romanText/tsvEg_v1.tsv @@ -1,6 +1,6 @@ "chord" "altchord" "measure" "beat" "totbeat" "timesig" "op" "no" "mov" "length" "global_key" "local_key" "pedal" "numeral" "form" "figbass" "changes" "relativeroot" "phraseend" ".C.I6" "" "1" "1.0" "1.0" "2/4" "1" "2" "3" 2.0 "C" "I" "" "I" "" "" "" "" false -"#viio6/ii" "" "2" "1.0" "3.0" "2/4" "1" "2" "3" 2.0 "C" "I" "" "#vii" "o" "6" "" "ii" false +"#viio6/ii" "" "2" "2.0" "4.0" "2/4" "1" "2" "3" 2.0 "C" "I" "" "#vii" "o" "6" "" "ii" false "ii" "" "3" "1.0" "5.0" "2/4" "1" "2" "3" 2.0 "C" "I" "" "ii" "" "" "" "" false "V" "" "4" "1.0" "7.0" "3/4" "1" "2" "3" 2.0 "C" "I" "" "V" "" "" "" "" false "@none" "" "5" "1.0" "10.0" "3/4" "1" "2" "3" 2.0 "C" "I" "" "V" "" "" "" "" false diff --git a/music21/romanText/tsvEg_v2major.tsv b/music21/romanText/tsvEg_v2major.tsv new file mode 100644 index 0000000000..2ab24733bd --- /dev/null +++ b/music21/romanText/tsvEg_v2major.tsv @@ -0,0 +1,13 @@ +mc mn mc_onset mn_onset timesig staff voice volta label globalkey localkey pedal chord special numeral form figbass changes relativeroot cadence phraseend chord_type globalkey_is_minor localkey_is_minor chord_tones added_tones root bass_note +1 1 0 1/2 2/4 .C.I6 C I I6 I FALSE +2 2 0 0 2/4 #viio6/ii C I #viio6/ii #vii o 6 ii FALSE +3 3 0 0 2/4 ii C I ii ii FALSE +4 4 0 0 3/4 V C I V V FALSE +5 5 0 0 3/4 @none C I @none @none FALSE +6 6 0 0 2/4 I C I I I FALSE +7 7 0 0 2/4 ii%65 C I ii%65 ii % 65 FALSE +72 72 1/2 1/2 3/4 4 1 vii%7/V C I vii%7/V vii % 7 V %7 0 0 6, 3, 0, 4 6 6 +99 99 1/2 1/2 3/4 4 1 Ger6/vi C V Ger6/vi Ger vii o 65 b3 V/vi Ger 0 0 -1, 3, 0, 9 9 -1 +121 121 0 0 3/4 4 1 V/vi C i V/vi V vi M 0 1 -3, 1, -2 -3 -3 +125 124 1/16 1/16 2/4 4 1 Fr6 F vi Fr6 Fr V 43 b5 V Fr 0 1 -4, 0, 2, 6 2 -4 +142 141 0 0 4/4 #VII+/vi C I #VII+/vi #VII + vi + 0 0 diff --git a/music21/romanText/tsvEg_v2minor.tsv b/music21/romanText/tsvEg_v2minor.tsv new file mode 100644 index 0000000000..500128319c --- /dev/null +++ b/music21/romanText/tsvEg_v2minor.tsv @@ -0,0 +1,10 @@ +mc mn mc_onset mn_onset timesig staff voice volta label globalkey localkey pedal chord special numeral form figbass changes relativeroot cadence phraseend chord_type globalkey_is_minor localkey_is_minor chord_tones added_tones root bass_note +6 6 0 0 3/4 4 1 viio/VII e i viio/VII vii o VII o 1 1 3, 0, -3 3 3 +8 8 3/8 3/8 9/8 4 1 ii%65 d i ii%65 ii % 65 %7 1 1 -1, -4, 0, 2 2 -1 +25 25 5/8 5/8 6/8 4 1 vi.iv6 e vi iv6 iv 6 m 1 1 -4, 0, -1 -1 -4 +30 30 0 0 6/8 4 1 VI e iii VI VI M 1 1 -4, 0, -3 -4 -4 +66 66 0 0 3/4 4 1 #VI.I e #VI I I M 1 0 0 0 +103 101 0 0 6/8 4 1 VI.V65/V e VI V65/V V 65 V Mm7 1 0 6, 3, 0, 2 2 6 +139 137 3/8 3/8 6/8 4 1 vi e i vi vi m 1 1 -4, -7, -3 -4 -4 +192 190 0 0 6/8 4 1 #vio65 e i #vio65 #vi o 65 o7 1 1 0, -3, -6, 3 3 0 +218 212 3/8 3/8 6/8 4 1 V65/vi e i V65/vi V 65 vi Mm7 1 1 1, -2, -5, -3 -3 1