From 44e5b2307dbf0130836c4e6dc97757139c968a7c Mon Sep 17 00:00:00 2001 From: Malcolm Sailor Date: Mon, 7 Feb 2022 11:18:25 -0500 Subject: [PATCH 01/22] updated tsvConverter to parse DCML v2 --- music21/romanText/tsvConverter.py | 295 ++++++++++++++---------------- music21/romanText/tsvEg.tsv | 14 +- 2 files changed, 145 insertions(+), 164 deletions(-) diff --git a/music21/romanText/tsvConverter.py b/music21/romanText/tsvConverter.py index 9309f59ade..8f0b6dd257 100644 --- a/music21/romanText/tsvConverter.py +++ b/music21/romanText/tsvConverter.py @@ -18,7 +18,6 @@ from music21 import common from music21 import key -from music21 import metadata from music21 import meter from music21 import note from music21 import roman @@ -37,32 +36,65 @@ class TsvException(exceptions21.Music21Exception): # ------------------------------------------------------------------------------ +# Changes: +# - renamed 'combinedChord' to 'chord'; that name was not otherwise being used +# and it simplifies reading/writing headers +# - measure -> mc (however, there is also 'mn': I'm not sure what the difference +# is) TODO +# - beat -> mc_onset (agains, there is also 'mn_onset) +# - deleted 'totbeat', 'altchord', 'no', 'op', 'mov', 'length' +# (all these seem to be gone in the new version) # TODO which of these are +# important? +# - global_key -> globalkey +# - local_key -> localkey +HEADERS = ( + 'chord', + 'mc', + 'mc_onset', + 'timesig', + 'globalkey', + 'localkey', + 'pedal', + 'numeral', + 'form', + 'figbass', + 'changes', + 'relativeroot', + 'phraseend', +) class TabChord: ''' An 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 - 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 + for name in HEADERS: + setattr(self, name, None) self.representationType = None # Added (not in DCML) + + @property + def beat(self): + return float(self.mc_onset) + + @property + def measure(self): + return int(self.mc) + + @property + def local_key(self): + return self.localkey + + @local_key.setter + def local_key(self, k): + self.localkey = k + + @property + def global_key(self): + return self.globalkey + + @global_key.setter + def global_key(self, k): + self.globalkey = k def _changeRepresentation(self): ''' @@ -72,10 +104,11 @@ def _changeRepresentation(self): First, let's set up a TabChord(). >>> tabCd = romanText.tsvConverter.TabChord() + >>> tabCd.representationType = 'DCML' >>> tabCd.global_key = 'F' >>> tabCd.local_key = 'vi' >>> tabCd.numeral = '#vii' - >>> 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. @@ -155,14 +188,14 @@ def tabToM21(self): >>> tabCd = romanText.tsvConverter.TabChord() >>> tabCd.numeral = 'vii' + >>> tabCd.representationType = 'm21' >>> tabCd.global_key = 'F' >>> tabCd.local_key = 'V' - >>> tabCd.representationType = 'm21' >>> m21Ch = tabCd.tabToM21() Now we can check it's a music21 RomanNumeral(): - >>> m21Ch.figure + # >>> m21Ch.figure 'vii' ''' @@ -181,11 +214,7 @@ def tabToM21(self): 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.quarterLength = self.length # TODO? thisEntry.pedal = self.pedal @@ -193,65 +222,19 @@ def tabToM21(self): else: # handling case of '@none' thisEntry = note.Rest() - thisEntry.quarterLength = self.length + # thisEntry.quarterLength = self.length # TODO? return thisEntry # ------------------------------------------------------------------------------ - -def makeTabChord(row): - ''' - Makes a TabChord out of a list imported from TSV data - (a row of the original tabular format -- see TsvHandler.importTsv()). - - This is how to make the TabChord: - - >>> tabRowAsString1 = ['.C.I6', '', '1', '1.0', '1.0', '2/4', '1', '2', '3', '2.0', - ... 'C', 'I', '', 'I', '', '', '', '', 'false'] - >>> testTabChord1 = romanText.tsvConverter.makeTabChord(tabRowAsString1) - - And now let's check that it really is a TabChord: - - >>> testTabChord1.numeral - 'I' - ''' - - 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 - -# ------------------------------------------------------------------------------ - - class TsvHandler: ''' Conversion starting with a TSV file. First we need to get a score. (Don't worry about this bit.) - >>> name = 'tsvEg.tsv' + >>> name = 'tsvEg2.tsv' >>> path = common.getSourceFilePath() / 'romanText' / name >>> handler = romanText.tsvConverter.TsvHandler(path) >>> handler.tsvToChords() @@ -259,7 +242,7 @@ class TsvHandler: These should be TabChords now. >>> testTabChord1 = handler.chordList[0] - >>> testTabChord1.combinedChord + >>> testTabChord1.chord '.C.I6' Good. We can make them into music21 Roman-numerals. @@ -276,29 +259,59 @@ class TsvHandler: ''' + _heading_names = set(HEADERS) + def __init__(self, tsvFile): self.tsvFileName = tsvFile - self.tsvData = self.importTsv() - self.chordList = [] + self.chordList = None self.m21stream = None self.preparedStream = None + self._head_indices = None + self.tsvData = self._importTsv() + + def _get_heading_indices(self, header_row): + self._head_indices = { + i: item for i, item in enumerate(header_row) + if item in self._heading_names + } - def importTsv(self): + + def _importTsv(self): ''' Imports TSV file data for further processing. ''' + with open(self.tsvFileName, 'r', encoding='utf-8') as inf: + tsvreader = csv.reader(inf, delimiter='\t', quotechar='"') + self._get_heading_indices(next(tsvreader)) + return list(tsvreader) - fileName = self.tsvFileName + def _makeTabChord(self, row): + ''' + Makes a TabChord out of a list imported from TSV data + (a row of the original tabular format -- see TsvHandler.importTsv()). - 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]) + This is how to make the TabChord: + + # TODO there's no straightforward way to run a test like this + # now that we are getting heading names from the first row of + # TSV files; at the same time, this is now a private method, so + # we can probably just delete the doctests + # >>> tabRowAsString1 = ['.C.I6', '', '1', '1.0', '1.0', '2/4', '1', '2', '3', '2.0', + # ... 'C', 'I', '', 'I', '', '', '', '', 'false'] + # >>> testTabChord1 = romanText.tsvConverter.makeTabChord(tabRowAsString1) + + And now let's check that it really is a TabChord: + + # >>> testTabChord1.numeral + 'I' + ''' - return data + thisEntry = TabChord() + for i, name in self._head_indices.items(): + setattr(thisEntry, name, row[i]) + thisEntry.representationType = 'DCML' # Added + + return thisEntry def tsvToChords(self): ''' @@ -308,16 +321,14 @@ 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): ''' @@ -333,9 +344,11 @@ def toM21Stream(self): s = self.preparedStream p = s.parts.first() # Just to get to the part, not that there are several. + if self.chordList is None: + self.tsvToChords() for thisChord in self.chordList: - offsetInMeasure = thisChord.beat - 1 # beats always measured in quarter notes - measureNumber = thisChord.measure + offsetInMeasure = thisChord.beat # beats always measured in quarter notes + measureNumber = thisChord.mc m21Measure = p.measure(measureNumber) if thisChord.representationType == 'DCML': @@ -360,13 +373,14 @@ 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 + # 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 + # 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 startingKeySig = str(self.chordList[0].global_key) ks = key.Key(startingKeySig) @@ -394,17 +408,16 @@ def prepStream(self): previousMeasure = mNo else: # entry.measure = previousMeasure + 1 m = stream.Measure(number=entry.measure) - m.offset = entry.totbeat + currentOffset = m.offset = currentOffset + currentMeasureLength p.insert(m) if entry.timesig != currentTimeSig: newTS = meter.TimeSignature(entry.timesig) - m.insert(entry.beat - 1, newTS) + m.insert(entry.beat, newTS) currentTimeSig = entry.timesig currentMeasureLength = newTS.barDuration.quarterLength previousMeasure = entry.measure - currentOffset = entry.totbeat s.append(p) @@ -449,23 +462,16 @@ def m21ToTsv(self): if thisRN.secondaryRomanNumeral: relativeroot = thisRN.secondaryRomanNumeral.figure - altChord = None - if thisRN.secondaryRomanNumeral: - if thisRN.secondaryRomanNumeral.key == thisRN.key: - altChord = thisRN.secondaryRomanNumeral.figure - thisEntry = TabChord() - thisEntry.combinedChord = thisRN.figure # NB: slightly different from DCML: no key. - thisEntry.altchord = altChord - thisEntry.measure = thisRN.measureNumber - thisEntry.beat = thisRN.beat - thisEntry.totbeat = None + thisEntry.chord = thisRN.figure # NB: slightly different from DCML: no key. + thisEntry.mc = thisRN.measureNumber + thisEntry.mc_onset = thisRN.beat thisEntry.timesig = thisRN.getContextByClass('TimeSignature').ratioString - thisEntry.op = self.m21Stream.metadata.opusNumber - thisEntry.no = self.m21Stream.metadata.number - thisEntry.mov = self.m21Stream.metadata.movementNumber - thisEntry.length = thisRN.quarterLength + + # TODO how important is the fact that length has been removed? + # Do we need to calculate this ourselves? + # thisEntry.length = thisRN.quarterLength thisEntry.global_key = None thisEntry.local_key = thisRN.key thisEntry.pedal = None @@ -476,16 +482,11 @@ def m21ToTsv(self): thisEntry.relativeroot = relativeroot thisEntry.phraseend = None - thisInfo = [thisEntry.combinedChord, - thisEntry.altchord, - thisEntry.measure, - thisEntry.beat, - thisEntry.totbeat, + # TODO I think we need to get the order of attributes dynamically + thisInfo = [thisEntry.chord, + thisEntry.mc, + thisEntry.mc_onset, thisEntry.timesig, - thisEntry.op, - thisEntry.no, - thisEntry.mov, - thisEntry.length, thisEntry.global_key, thisEntry.local_key, thisEntry.pedal, @@ -511,29 +512,7 @@ def write(self, filePathAndName): 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(HEADERS) for thisEntry in self.tsvData: csvOut.writerow(thisEntry) @@ -679,7 +658,7 @@ def getSecondaryKey(rn, local_key): class Test(unittest.TestCase): def testTsvHandler(self): - name = 'tsvEg.tsv' + name = 'tsvEg2.tsv' # A short and improbably complicated test case complete with: # '@none' (rest entry), '/' relative root, and time signature changes. path = common.getSourceFilePath() / 'romanText' / name @@ -687,17 +666,19 @@ def testTsvHandler(self): handler = TsvHandler(path) # Raw - self.assertEqual(handler.tsvData[0][0], '.C.I6') - self.assertEqual(handler.tsvData[1][0], '#viio6/ii') + # This test can't be guaranteed to work with new approach; we can just + # remove it TODO + # 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.chord, '.C.I6') self.assertEqual(testTabChord1.numeral, 'I') - self.assertEqual(testTabChord2.combinedChord, '#viio6/ii') + self.assertEqual(testTabChord2.chord, '#viio6/ii') self.assertEqual(testTabChord2.numeral, '#vii') # Change Representation diff --git a/music21/romanText/tsvEg.tsv b/music21/romanText/tsvEg.tsv index 10a4403211..93345704ae 100644 --- a/music21/romanText/tsvEg.tsv +++ b/music21/romanText/tsvEg.tsv @@ -1,7 +1,7 @@ -"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 -"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 -"I" "" "6" "1.0" "13.0" "2/4" "1" "2" "3" 2.0 "C" "I" "" "I" "" "" "" "" false +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 0 2/4 C I .C.I6 I FALSE +2 2 0 0 2/4 C I #viio6/ii #vii o 6 ii FALSE +3 3 0 0 2/4 C I ii ii FALSE +4 4 0 0 3/4 C I V V FALSE +5 5 0 0 3/4 C I @none V FALSE +6 6 0 0 2/4 C I I I FALSE From 2c84e70b6fb2f04419fe6502a547516df4776c2e Mon Sep 17 00:00:00 2001 From: Malcolm Sailor Date: Tue, 8 Feb 2022 09:17:09 -0500 Subject: [PATCH 02/22] fixed test .tsv path, implemented m21->tsv conversion --- music21/romanText/tsvConverter.py | 131 +++++++++++++++++++++--------- 1 file changed, 94 insertions(+), 37 deletions(-) diff --git a/music21/romanText/tsvConverter.py b/music21/romanText/tsvConverter.py index 8f0b6dd257..c0d12f4595 100644 --- a/music21/romanText/tsvConverter.py +++ b/music21/romanText/tsvConverter.py @@ -14,6 +14,7 @@ ''' import csv +import re import unittest from music21 import common @@ -63,6 +64,37 @@ class TsvException(exceptions21.Music21Exception): 'phraseend', ) +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', +) + class TabChord: ''' An intermediate representation format for moving between tabular data and music21 chords. @@ -74,7 +106,14 @@ def __init__(self): @property def beat(self): - return float(self.mc_onset) + try: + return float(self.mc_onset) + except ValueError: + m = re.match( + r'(?P\d+(?:\.\d+)?)/(?P\d+(?:\.\d+)?)', + self.mc_onset, + ) + return float(m.group('numer')) / float(m.group('denom')) @property def measure(self): @@ -213,8 +252,12 @@ def tabToM21(self): localKeyNonRoman = getLocalKey(self.local_key, self.global_key) - thisEntry = roman.RomanNumeral(combined, localKeyNonRoman) - # thisEntry.quarterLength = self.length # TODO? + try: + thisEntry = roman.RomanNumeral(combined, localKeyNonRoman) + except music21.roman.RomanException: + assert combined == '@none' + # TODO what is the appropriate way of handling '@none'? + return None thisEntry.pedal = self.pedal @@ -234,7 +277,7 @@ class TsvHandler: First we need to get a score. (Don't worry about this bit.) - >>> name = 'tsvEg2.tsv' + >>> name = 'tsvEg.tsv' >>> path = common.getSourceFilePath() / 'romanText' / name >>> handler = romanText.tsvConverter.TsvHandler(path) >>> handler.tsvToChords() @@ -339,13 +382,14 @@ def toM21Stream(self): and populates that stream with the new RomanNumerals. ''' + if self.chordList is None: + self.tsvToChords() + self.prepStream() s = self.preparedStream p = s.parts.first() # Just to get to the part, not that there are several. - if self.chordList is None: - self.tsvToChords() for thisChord in self.chordList: offsetInMeasure = thisChord.beat # beats always measured in quarter notes measureNumber = thisChord.mc @@ -356,7 +400,9 @@ def toM21Stream(self): thisM21Chord = thisChord.tabToM21() # In either case. - m21Measure.insert(offsetInMeasure, thisM21Chord) + if thisM21Chord is not None: + # TODO remove this condition after handling '@none'? + m21Measure.insert(offsetInMeasure, thisM21Chord) self.m21stream = s @@ -383,7 +429,10 @@ def prepStream(self): # s.metadata.title = 'Op' + firstEntry.op + '_No' + firstEntry.no + '_Mov' + firstEntry.mov startingKeySig = str(self.chordList[0].global_key) - ks = key.Key(startingKeySig) + try: + ks = key.Key(startingKeySig) + except music21.pitch.PitchException: + ks = key.Key(self.chordList[0].local_key) p.insert(0, ks) currentTimeSig = str(self.chordList[0].timesig) @@ -399,7 +448,7 @@ def prepStream(self): 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) @@ -418,7 +467,6 @@ def prepStream(self): currentMeasureLength = newTS.barDuration.quarterLength previousMeasure = entry.measure - s.append(p) self.preparedStream = s @@ -440,7 +488,7 @@ class M21toTSV: >>> initial = romanText.tsvConverter.M21toTSV(bachHarmony) >>> tsvData = initial.tsvData - >>> tsvData[1][0] + >>> tsvData[1][14] 'I' ''' @@ -472,8 +520,12 @@ def m21ToTsv(self): # TODO how important is the fact that length has been removed? # Do we need to calculate this ourselves? # thisEntry.length = thisRN.quarterLength + + # NB "global_key" and "local_key" in DCML_V2 are pitch names + # and roman numerals respectively. If we want to reproduce that + # we will need to write appropriate logic here. thisEntry.global_key = None - thisEntry.local_key = thisRN.key + thisEntry.local_key = thisRN.key.tonicPitchNameWithCase thisEntry.pedal = None thisEntry.numeral = thisRN.romanNumeralAlone thisEntry.form = None @@ -482,21 +534,9 @@ def m21ToTsv(self): thisEntry.relativeroot = relativeroot thisEntry.phraseend = None - # TODO I think we need to get the order of attributes dynamically - thisInfo = [thisEntry.chord, - thisEntry.mc, - thisEntry.mc_onset, - thisEntry.timesig, - thisEntry.global_key, - thisEntry.local_key, - thisEntry.pedal, - thisEntry.numeral, - thisEntry.form, - thisEntry.figbass, - thisEntry.changes, - thisEntry.relativeroot, - thisEntry.phraseend - ] + thisInfo = [ + getattr(thisEntry, name, '') for name in DCML_V2_HEADERS + ] tsvData.append(thisInfo) @@ -506,13 +546,12 @@ def write(self, filePathAndName): ''' Writes a list of lists (e.g. from m21ToTsv()) to a tsv file. ''' - with open(filePathAndName, 'a', newline='', encoding='utf-8') as csvFile: + with open(filePathAndName, 'w', newline='', encoding='utf-8') as csvFile: csvOut = csv.writer(csvFile, delimiter='\t', quotechar='"', quoting=csv.QUOTE_MINIMAL) - - csvOut.writerow(HEADERS) + csvOut.writerow(DCML_V2_HEADERS) for thisEntry in self.tsvData: csvOut.writerow(thisEntry) @@ -658,7 +697,7 @@ def getSecondaryKey(rn, local_key): class Test(unittest.TestCase): def testTsvHandler(self): - name = 'tsvEg2.tsv' + 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 @@ -666,10 +705,9 @@ def testTsvHandler(self): handler = TsvHandler(path) # Raw - # This test can't be guaranteed to work with new approach; we can just - # remove it TODO - # self.assertEqual(handler.tsvData[0][0], '.C.I6') - # self.assertEqual(handler.tsvData[1][0], '#viio6/ii') + chord_i = DCML_V2_HEADERS.index('chord') + self.assertEqual(handler.tsvData[0][chord_i], '.C.I6') + self.assertEqual(handler.tsvData[1][chord_i], '#viio6/ii') # Chords handler.tsvToChords() @@ -700,6 +738,24 @@ def testTsvHandler(self): out_stream = handler.toM21Stream() self.assertEqual(out_stream.parts[0].measure(1)[0].figure, 'I') # First item in measure 1 + + # Ultimately, to verify that the conversion is working well both + # ways, it would be nice to convert forward and backwards and + # compare the results. But we won't be able to do so until writing + # "globalkey" and "localkey" is implemented + # name = 'n01op18-1_01.tsv' + # path = common.getSourceFilePath() / 'romanText' / name + # forward1 = TsvHandler(path) + # stream1 = forward1.toM21Stream() + + # envLocal = environment.Environment() + # tempF = envLocal.getTempFile() + # M21toTSV(stream1).write(tempF) + # forward2 = TsvHandler(tempF) + # stream2 = forward2.toM21Stream() + + + def testM21ToTsv(self): import os from music21 import corpus @@ -707,15 +763,16 @@ def testM21ToTsv(self): bachHarmony = corpus.parse('bach/choraleAnalyses/riemenschneider001.rntxt') initial = M21toTSV(bachHarmony) tsvData = initial.tsvData + numeral_i = DCML_V2_HEADERS.index("numeral") self.assertEqual(bachHarmony.parts[0].measure(1)[0].figure, 'I') # NB pickup measure 0. - self.assertEqual(tsvData[1][0], 'I') + 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][0], 'I') + self.assertEqual(handler.tsvData[0][numeral_i], 'I') os.remove(tempF) def testIsMinor(self): From 1398aca1f3e9c817359c279ed38dd3f38560358e Mon Sep 17 00:00:00 2001 From: Malcolm Sailor Date: Tue, 8 Feb 2022 11:20:42 -0500 Subject: [PATCH 03/22] Switched 'mc' to 'mn' --- music21/romanText/tsvConverter.py | 27 ++++++++------------------- 1 file changed, 8 insertions(+), 19 deletions(-) diff --git a/music21/romanText/tsvConverter.py b/music21/romanText/tsvConverter.py index c0d12f4595..2f4c8c7a77 100644 --- a/music21/romanText/tsvConverter.py +++ b/music21/romanText/tsvConverter.py @@ -37,21 +37,10 @@ class TsvException(exceptions21.Music21Exception): # ------------------------------------------------------------------------------ -# Changes: -# - renamed 'combinedChord' to 'chord'; that name was not otherwise being used -# and it simplifies reading/writing headers -# - measure -> mc (however, there is also 'mn': I'm not sure what the difference -# is) TODO -# - beat -> mc_onset (agains, there is also 'mn_onset) -# - deleted 'totbeat', 'altchord', 'no', 'op', 'mov', 'length' -# (all these seem to be gone in the new version) # TODO which of these are -# important? -# - global_key -> globalkey -# - local_key -> localkey HEADERS = ( 'chord', - 'mc', - 'mc_onset', + 'mn', + 'mn_onset', 'timesig', 'globalkey', 'localkey', @@ -107,17 +96,17 @@ def __init__(self): @property def beat(self): try: - return float(self.mc_onset) + return float(self.mn_onset) except ValueError: m = re.match( r'(?P\d+(?:\.\d+)?)/(?P\d+(?:\.\d+)?)', - self.mc_onset, + self.mn_onset, ) return float(m.group('numer')) / float(m.group('denom')) @property def measure(self): - return int(self.mc) + return int(self.mn) @property def local_key(self): @@ -392,7 +381,7 @@ def toM21Stream(self): for thisChord in self.chordList: offsetInMeasure = thisChord.beat # beats always measured in quarter notes - measureNumber = thisChord.mc + measureNumber = thisChord.mn m21Measure = p.measure(measureNumber) if thisChord.representationType == 'DCML': @@ -513,8 +502,8 @@ def m21ToTsv(self): thisEntry = TabChord() thisEntry.chord = thisRN.figure # NB: slightly different from DCML: no key. - thisEntry.mc = thisRN.measureNumber - thisEntry.mc_onset = thisRN.beat + thisEntry.mn = thisRN.measureNumber + thisEntry.mn_onset = thisRN.beat thisEntry.timesig = thisRN.getContextByClass('TimeSignature').ratioString # TODO how important is the fact that length has been removed? From 7981308f5a189a988d749a7fb285cae295d613ce Mon Sep 17 00:00:00 2001 From: Malcolm Sailor Date: Wed, 9 Feb 2022 09:24:48 -0500 Subject: [PATCH 04/22] storing other tsv cols in 'editorial' --- music21/romanText/tsvConverter.py | 60 ++++++++++++++++++++----------- 1 file changed, 40 insertions(+), 20 deletions(-) diff --git a/music21/romanText/tsvConverter.py b/music21/romanText/tsvConverter.py index 2f4c8c7a77..c01ace6658 100644 --- a/music21/romanText/tsvConverter.py +++ b/music21/romanText/tsvConverter.py @@ -243,7 +243,7 @@ def tabToM21(self): try: thisEntry = roman.RomanNumeral(combined, localKeyNonRoman) - except music21.roman.RomanException: + except roman.RomanException: assert combined == '@none' # TODO what is the appropriate way of handling '@none'? return None @@ -299,13 +299,20 @@ def __init__(self, tsvFile): self.m21stream = None self.preparedStream = None self._head_indices = None + self._extra_indices = None self.tsvData = self._importTsv() def _get_heading_indices(self, header_row): - self._head_indices = { - i: item for i, item in enumerate(header_row) - if item in self._heading_names - } + self._head_indices, self._extra_indices = {}, {} + for i, item in enumerate(header_row): + if item in self._heading_names: + self._head_indices[i] = item + else: + self._extra_indices[i] = item + # self._head_indices = { + # i: item for i, item in enumerate(header_row) + # if item in self._heading_names + # } def _importTsv(self): @@ -341,6 +348,9 @@ def _makeTabChord(self, row): thisEntry = TabChord() for i, name in self._head_indices.items(): setattr(thisEntry, name, row[i]) + thisEntry.extra = { + name: row[i] for i, name in self._extra_indices.items() if row[i] + } thisEntry.representationType = 'DCML' # Added return thisEntry @@ -389,8 +399,9 @@ def toM21Stream(self): thisM21Chord = thisChord.tabToM21() # In either case. + # TODO remove this condition after handling '@none'? if thisM21Chord is not None: - # TODO remove this condition after handling '@none'? + thisM21Chord.editorial.update(thisChord.extra) m21Measure.insert(offsetInMeasure, thisM21Chord) self.m21stream = s @@ -493,6 +504,9 @@ def m21ToTsv(self): tsvData = [] + # take the global_key from the first item + global_key = next(self.m21Stream.recurse().getElementsByClass( + 'RomanNumeral')).key.tonicPitchNameWithCase for thisRN in self.m21Stream.recurse().getElementsByClass('RomanNumeral'): relativeroot = None @@ -510,10 +524,8 @@ def m21ToTsv(self): # Do we need to calculate this ourselves? # thisEntry.length = thisRN.quarterLength - # NB "global_key" and "local_key" in DCML_V2 are pitch names - # and roman numerals respectively. If we want to reproduce that - # we will need to write appropriate logic here. - thisEntry.global_key = None + thisEntry.global_key = global_key + # TODO convert "local_key" to a roman numeral as in DCML? thisEntry.local_key = thisRN.key.tonicPitchNameWithCase thisEntry.pedal = None thisEntry.numeral = thisRN.romanNumeralAlone @@ -523,8 +535,10 @@ def m21ToTsv(self): thisEntry.relativeroot = relativeroot thisEntry.phraseend = None + thisInfo = [] thisInfo = [ - getattr(thisEntry, name, '') for name in DCML_V2_HEADERS + getattr(thisEntry, name, thisRN.editorial.get(name, '')) + for name in DCML_V2_HEADERS ] tsvData.append(thisInfo) @@ -684,8 +698,10 @@ def getSecondaryKey(rn, local_key): class Test(unittest.TestCase): + def testTsvHandler(self): + import os name = 'tsvEg.tsv' # A short and improbably complicated test case complete with: # '@none' (rest entry), '/' relative root, and time signature changes. @@ -732,16 +748,20 @@ def testTsvHandler(self): # ways, it would be nice to convert forward and backwards and # compare the results. But we won't be able to do so until writing # "globalkey" and "localkey" is implemented - # name = 'n01op18-1_01.tsv' - # path = common.getSourceFilePath() / 'romanText' / name - # forward1 = TsvHandler(path) - # stream1 = forward1.toM21Stream() - - # envLocal = environment.Environment() - # tempF = envLocal.getTempFile() - # M21toTSV(stream1).write(tempF) - # forward2 = TsvHandler(tempF) + name = 'n01op18-1_01.tsv' + path = common.getSourceFilePath() / 'romanText' / name + forward1 = TsvHandler(path) + stream1 = forward1.toM21Stream() + + envLocal = environment.Environment() + tempF = envLocal.getTempFile() + # tempF = common.getSourceFilePath() / 'romanText' / "temp.tsv" + M21toTSV(stream1).write(tempF) + forward2 = TsvHandler(tempF) + # TODO complete test by comparing stream1 to stream2 after implementing + # localkey as roman numerals # stream2 = forward2.toM21Stream() + os.remove(tempF) From 0ed9b94893554fa21ec8a23b4ec0aa59d6463fa3 Mon Sep 17 00:00:00 2001 From: Malcolm Sailor Date: Wed, 9 Feb 2022 13:50:21 -0500 Subject: [PATCH 05/22] handling @none etc --- music21/romanText/tsvConverter.py | 132 +++++++++++++++--------------- 1 file changed, 66 insertions(+), 66 deletions(-) diff --git a/music21/romanText/tsvConverter.py b/music21/romanText/tsvConverter.py index c01ace6658..c83231c361 100644 --- a/music21/romanText/tsvConverter.py +++ b/music21/romanText/tsvConverter.py @@ -17,7 +17,7 @@ import re import unittest -from music21 import common +from music21 import chord, common, harmony from music21 import key from music21 import meter from music21 import note @@ -227,7 +227,9 @@ def tabToM21(self): 'vii' ''' - if self.numeral: + if self.numeral == '@none': + thisEntry = harmony.NoChord() + else: if self.form: if self.figbass: combined = ''.join([self.numeral, self.form, self.figbass]) @@ -240,22 +242,10 @@ def tabToM21(self): combined = ''.join([combined, '/', self.relativeroot]) localKeyNonRoman = getLocalKey(self.local_key, self.global_key) - - try: - thisEntry = roman.RomanNumeral(combined, localKeyNonRoman) - except roman.RomanException: - assert combined == '@none' - # TODO what is the appropriate way of handling '@none'? - return None - + thisEntry = roman.RomanNumeral(combined, localKeyNonRoman) thisEntry.pedal = self.pedal - thisEntry.phraseend = None - else: # handling case of '@none' - thisEntry = note.Rest() - # thisEntry.quarterLength = self.length # TODO? - return thisEntry # ------------------------------------------------------------------------------ @@ -309,10 +299,6 @@ def _get_heading_indices(self, header_row): self._head_indices[i] = item else: self._extra_indices[i] = item - # self._head_indices = { - # i: item for i, item in enumerate(header_row) - # if item in self._heading_names - # } def _importTsv(self): @@ -328,21 +314,6 @@ def _makeTabChord(self, row): ''' Makes a TabChord out of a list imported from TSV data (a row of the original tabular format -- see TsvHandler.importTsv()). - - This is how to make the TabChord: - - # TODO there's no straightforward way to run a test like this - # now that we are getting heading names from the first row of - # TSV files; at the same time, this is now a private method, so - # we can probably just delete the doctests - # >>> tabRowAsString1 = ['.C.I6', '', '1', '1.0', '1.0', '2/4', '1', '2', '3', '2.0', - # ... 'C', 'I', '', 'I', '', '', '', '', 'false'] - # >>> testTabChord1 = romanText.tsvConverter.makeTabChord(tabRowAsString1) - - And now let's check that it really is a TabChord: - - # >>> testTabChord1.numeral - 'I' ''' thisEntry = TabChord() @@ -399,10 +370,8 @@ def toM21Stream(self): thisM21Chord = thisChord.tabToM21() # In either case. - # TODO remove this condition after handling '@none'? - if thisM21Chord is not None: - thisM21Chord.editorial.update(thisChord.extra) - m21Measure.insert(offsetInMeasure, thisM21Chord) + thisM21Chord.editorial.update(thisChord.extra) + m21Measure.insert(offsetInMeasure, thisM21Chord) self.m21stream = s @@ -505,35 +474,37 @@ def m21ToTsv(self): tsvData = [] # take the global_key from the first item + global_key_obj = next( + self.m21Stream.recurse().getElementsByClass('RomanNumeral') + ).key global_key = next(self.m21Stream.recurse().getElementsByClass( 'RomanNumeral')).key.tonicPitchNameWithCase - for thisRN in self.m21Stream.recurse().getElementsByClass('RomanNumeral'): - - relativeroot = None - if thisRN.secondaryRomanNumeral: - relativeroot = thisRN.secondaryRomanNumeral.figure - + for thisRN in self.m21Stream.recurse().getElementsByClass( + ['RomanNumeral', 'NoChord'] + ): thisEntry = TabChord() - - thisEntry.chord = thisRN.figure # NB: slightly different from DCML: no key. thisEntry.mn = thisRN.measureNumber thisEntry.mn_onset = thisRN.beat - thisEntry.timesig = thisRN.getContextByClass('TimeSignature').ratioString - - # TODO how important is the fact that length has been removed? - # Do we need to calculate this ourselves? - # thisEntry.length = thisRN.quarterLength - + thisEntry.timesig = thisRN.getContextByClass( + 'TimeSignature').ratioString thisEntry.global_key = global_key - # TODO convert "local_key" to a roman numeral as in DCML? - thisEntry.local_key = thisRN.key.tonicPitchNameWithCase - thisEntry.pedal = None - thisEntry.numeral = thisRN.romanNumeralAlone - thisEntry.form = None - thisEntry.figbass = thisRN.figuresWritten - thisEntry.changes = None # TODO - thisEntry.relativeroot = relativeroot - thisEntry.phraseend = None + if isinstance(thisRN, harmony.NoChord): + thisEntry.numeral = thisEntry.chord = "@none" + else: + relativeroot = None + if thisRN.secondaryRomanNumeral: + relativeroot = thisRN.secondaryRomanNumeral.figure + thisEntry.chord = thisRN.figure # NB: slightly different from DCML: no key. + thisEntry.pedal = None + thisEntry.numeral = thisRN.romanNumeralAlone + thisEntry.form = None + thisEntry.figbass = thisRN.figuresWritten + thisEntry.changes = None # TODO + thisEntry.relativeroot = relativeroot + thisEntry.phraseend = None + local_key = local_key_as_rn(thisRN.key, global_key_obj) + thisEntry.local_key = local_key + thisInfo = [] thisInfo = [ @@ -560,6 +531,28 @@ def write(self, filePathAndName): csvOut.writerow(thisEntry) # ------------------------------------------------------------------------------ + +def local_key_as_rn(local_key, global_key): + ''' + Takes two music21.key.Key objects and returns the roman numeral for + `local_key` in `global_key`. + + >>> k1 = key.Key('C') + >>> k2 = key.Key('e-') + >>> romanText.tsvConverter.local_key_as_rn(k1, k2) + 'VI' + + >>> romanText.tsvConverter.local_key_as_rn(k2, k1) + 'biii' + ''' + 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) + return r.romanNumeral + + def is_minor(test_key): ''' Checks whether a key is minor or not simply by upper vs lower case. @@ -748,20 +741,27 @@ def testTsvHandler(self): # ways, it would be nice to convert forward and backwards and # compare the results. But we won't be able to do so until writing # "globalkey" and "localkey" is implemented - name = 'n01op18-1_01.tsv' + # name = 'n01op18-1_01.tsv' path = common.getSourceFilePath() / 'romanText' / name forward1 = TsvHandler(path) stream1 = forward1.toM21Stream() envLocal = environment.Environment() tempF = envLocal.getTempFile() - # tempF = common.getSourceFilePath() / 'romanText' / "temp.tsv" M21toTSV(stream1).write(tempF) forward2 = TsvHandler(tempF) - # TODO complete test by comparing stream1 to stream2 after implementing - # localkey as roman numerals - # stream2 = forward2.toM21Stream() + stream2 = forward2.toM21Stream() os.remove(tempF) + assert len(stream1.recurse()) == len(stream2.recurse()) + # presently the commented-out test fails because vii seems to be notated + # differently between music21 and DCML. E.g., '#viio7/vi' in the + # DCML file becomes 'viio7/vi' when we write it out, which then + # becomes 'bvii/vi' when read anew + # for i, (item1, item2) in enumerate(zip( + # stream1.recurse().getElementsByClass('RomanNumeral'), + # stream2.recurse().getElementsByClass('RomanNumeral') + # )): + # assert item1 == item2 From 67873c51c2324b77e73b23906e258aa8af7431fc Mon Sep 17 00:00:00 2001 From: Malcolm Sailor Date: Wed, 6 Apr 2022 16:39:01 -0400 Subject: [PATCH 06/22] flag for DCML v1/v2 --- music21/romanText/tsvConverter.py | 580 ++++++++++++++++++++---------- music21/romanText/tsvEg_v1.tsv | 7 + music21/romanText/tsvEg_v2.tsv | 7 + 3 files changed, 414 insertions(+), 180 deletions(-) create mode 100644 music21/romanText/tsvEg_v1.tsv create mode 100644 music21/romanText/tsvEg_v2.tsv diff --git a/music21/romanText/tsvConverter.py b/music21/romanText/tsvConverter.py index c83231c361..d307b6780a 100644 --- a/music21/romanText/tsvConverter.py +++ b/music21/romanText/tsvConverter.py @@ -19,14 +19,17 @@ from music21 import chord, common, harmony from music21 import key +from music21 import metadata from music21 import meter from music21 import note +from music21 import pitch from music21 import roman from music21 import stream from music21 import exceptions21 from music21 import environment + environLocal = environment.Environment() # ------------------------------------------------------------------------------ @@ -35,9 +38,32 @@ class TsvException(exceptions21.Music21Exception): pass + # ------------------------------------------------------------------------------ -HEADERS = ( +V1_HEADERS = ( + 'chord', + 'altchord', + 'measure', + 'beat', + 'totbeat', + 'timesig', + 'op', + 'no', + 'mov', + 'length', + 'global_key', + 'local_key', + 'pedal', + 'numeral', + 'form', + 'figbass', + 'changes', + 'relativeroot', + 'phraseend', +) + +V2_HEADERS = ( 'chord', 'mn', 'mn_onset', @@ -53,6 +79,30 @@ class TsvException(exceptions21.Music21Exception): 'phraseend', ) +HEADERS = (V1_HEADERS, V2_HEADERS) + +DCML_V1_HEADERS = ( + 'chord', + 'altchord', + 'measure', + 'beat', + 'totbeat', + 'timesig', + 'op', + 'no', + 'mov', + 'length', + 'global_key', + 'local_key', + 'pedal', + 'numeral', + 'form', + 'figbass', + 'changes', + 'relativeroot', + 'phraseend', +) + DCML_V2_HEADERS = ( 'mc', 'mn', @@ -84,30 +134,41 @@ class TsvException(exceptions21.Music21Exception): 'bass_note', ) +DCML_HEADERS = (DCML_V1_HEADERS, DCML_V2_HEADERS) + + class TabChord: ''' An intermediate representation format for moving between tabular data and music21 chords. ''' - def __init__(self): - for name in HEADERS: + + BEAT_REGEX = re.compile( + r'(?P\d+(?:\.\d+)?)/(?P\d+(?:\.\d+)?)' + ) + + def __init__(self, dcml_version=2): + for name in HEADERS[dcml_version - 1]: + # the names 'measure' and 'beat' used in version 1 are now used + # for properties of the TabChord so we remap them here + if name == 'measure': + name = 'mn' + elif name == 'beat': + name = 'mn_onset' setattr(self, name, None) self.representationType = None # Added (not in DCML) - + @property def beat(self): try: return float(self.mn_onset) except ValueError: - m = re.match( - r'(?P\d+(?:\.\d+)?)/(?P\d+(?:\.\d+)?)', - self.mn_onset, - ) + m = re.match(self.BEAT_REGEX, self.mn_onset) return float(m.group('numer')) / float(m.group('denom')) @property def measure(self): return int(self.mn) - + @property def local_key(self): return self.localkey @@ -136,7 +197,7 @@ def _changeRepresentation(self): >>> tabCd.global_key = 'F' >>> tabCd.local_key = 'vi' >>> tabCd.numeral = '#vii' - + 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. @@ -158,54 +219,64 @@ def _changeRepresentation(self): if self.representationType == 'm21': direction = 'm21-DCML' - self.representationType = 'DCML' # Becomes the case during this function. + self.representationType = ( + 'DCML' # Becomes the case during this function. + ) elif self.representationType == 'DCML': direction = 'DCML-m21' - self.representationType = 'm21' # Becomes the case during this function. + self.representationType = ( + 'm21' # Becomes the case during this function. + ) else: - raise ValueError("Data source must specify representation type as 'm21' or 'DCML'.") + 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), - direction=direction) + self.local_key = characterSwaps( + self.local_key, minor=is_minor(self.global_key), direction=direction + ) # Local - relative and figure if is_minor(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 - self.relativeroot = characterSwaps(self.relativeroot, - minor=True, - direction=direction) - self.numeral = characterSwaps(self.numeral, - minor=True, - direction=direction) + if is_minor( + self.relativeroot + ): # ... and it's minor too, change it and the figure + self.relativeroot = characterSwaps( + self.relativeroot, minor=True, direction=direction + ) + self.numeral = characterSwaps( + self.numeral, minor=True, direction=direction + ) else: # ... rel. root but not minor - self.relativeroot = characterSwaps(self.relativeroot, - minor=False, - direction=direction) + self.relativeroot = characterSwaps( + self.relativeroot, minor=False, direction=direction + ) else: # No relative root - self.numeral = characterSwaps(self.numeral, - minor=True, - direction=direction) + self.numeral = characterSwaps( + self.numeral, minor=True, 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 - self.relativeroot = characterSwaps(self.relativeroot, - minor=False, - direction=direction) - self.numeral = characterSwaps(self.numeral, - minor=True, - direction=direction) + if is_minor( + self.relativeroot + ): # ... and it's minor, change it and the figure + self.relativeroot = characterSwaps( + self.relativeroot, minor=False, direction=direction + ) + self.numeral = characterSwaps( + self.numeral, minor=True, direction=direction + ) else: # ... rel. root but not minor - self.relativeroot = characterSwaps(self.relativeroot, - minor=False, - direction=direction) + self.relativeroot = characterSwaps( + self.relativeroot, minor=False, direction=direction + ) else: # No relative root - self.numeral = characterSwaps(self.numeral, - minor=False, - direction=direction) + self.numeral = characterSwaps( + self.numeral, minor=False, direction=direction + ) def tabToM21(self): ''' @@ -240,23 +311,27 @@ def tabToM21(self): if self.relativeroot: # special case requiring '/'. combined = ''.join([combined, '/', self.relativeroot]) - - localKeyNonRoman = getLocalKey(self.local_key, self.global_key) + if re.match(r'.*(i*v|v?i+).*', self.local_key, re.IGNORECASE): + localKeyNonRoman = getLocalKey(self.local_key, self.global_key) + else: + localKeyNonRoman = self.local_key thisEntry = roman.RomanNumeral(combined, localKeyNonRoman) thisEntry.pedal = self.pedal thisEntry.phraseend = None return thisEntry + # ------------------------------------------------------------------------------ + class TsvHandler: ''' Conversion starting with a TSV file. First we need to get a score. (Don't worry about this bit.) - >>> name = 'tsvEg.tsv' + >>> name = 'tsvEg_v2.tsv' >>> path = common.getSourceFilePath() / 'romanText' / name >>> handler = romanText.tsvConverter.TsvHandler(path) >>> handler.tsvToChords() @@ -281,26 +356,27 @@ class TsvHandler: ''' - _heading_names = set(HEADERS) + _heading_names = (set(V1_HEADERS), set(V2_HEADERS)) - def __init__(self, tsvFile): + def __init__(self, tsvFile, dcml_version=2): + self.heading_names = self._heading_names[dcml_version - 1] self.tsvFileName = tsvFile self.chordList = None self.m21stream = None self.preparedStream = None self._head_indices = None self._extra_indices = None + self.dcml_version = dcml_version self.tsvData = self._importTsv() - + def _get_heading_indices(self, header_row): self._head_indices, self._extra_indices = {}, {} for i, item in enumerate(header_row): - if item in self._heading_names: + if item in self.heading_names: self._head_indices[i] = item else: self._extra_indices[i] = item - def _importTsv(self): ''' Imports TSV file data for further processing. @@ -316,8 +392,14 @@ def _makeTabChord(self, row): (a row of the original tabular format -- see TsvHandler.importTsv()). ''' - thisEntry = TabChord() + thisEntry = TabChord(self.dcml_version) for i, name in self._head_indices.items(): + # the names 'measure' and 'beat' used in version 1 are now used + # for properties of the TabChord so we remap them here + if name == 'measure': + name = 'mn' + elif name == 'beat': + name = 'mn_onset' setattr(thisEntry, name, row[i]) thisEntry.extra = { name: row[i] for i, name in self._extra_indices.items() if row[i] @@ -358,10 +440,14 @@ def toM21Stream(self): self.prepStream() s = self.preparedStream - p = s.parts.first() # Just to get to the part, not that there are several. + p = ( + s.parts.first() + ) # Just to get to the part, not that there are several. for thisChord in self.chordList: - offsetInMeasure = thisChord.beat # beats always measured in quarter notes + offsetInMeasure = ( + thisChord.beat + ) # beats always measured in quarter notes measureNumber = thisChord.mn m21Measure = p.measure(measureNumber) @@ -388,19 +474,27 @@ def prepStream(self): s = stream.Score() p = stream.Part() - # 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 - # 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 + 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 + ) startingKeySig = str(self.chordList[0].global_key) try: ks = key.Key(startingKeySig) - except music21.pitch.PitchException: + except pitch.PitchException: ks = key.Key(self.chordList[0].local_key) p.insert(0, ks) @@ -416,7 +510,9 @@ def prepStream(self): for entry in self.chordList: if entry.measure == previousMeasure: continue - elif entry.measure != previousMeasure + 1: # Not every measure has a chord change. + elif ( + entry.measure != previousMeasure + 1 + ): # Not every measure has a chord change. for mNo in range(previousMeasure + 1, entry.measure + 1): m = stream.Measure(number=mNo) m.offset = currentOffset + currentMeasureLength @@ -461,8 +557,10 @@ class M21toTSV: 'I' ''' - def __init__(self, m21Stream): + def __init__(self, m21Stream, dcml_version=2): + self.version = dcml_version self.m21Stream = m21Stream + self.dcml_headers = DCML_HEADERS[dcml_version - 1] self.tsvData = self.m21ToTsv() def m21ToTsv(self): @@ -470,15 +568,20 @@ def m21ToTsv(self): 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_v2(self): tsvData = [] # take the global_key from the first item global_key_obj = next( self.m21Stream.recurse().getElementsByClass('RomanNumeral') ).key - global_key = next(self.m21Stream.recurse().getElementsByClass( - 'RomanNumeral')).key.tonicPitchNameWithCase + global_key = next( + self.m21Stream.recurse().getElementsByClass('RomanNumeral') + ).key.tonicPitchNameWithCase for thisRN in self.m21Stream.recurse().getElementsByClass( ['RomanNumeral', 'NoChord'] ): @@ -486,30 +589,103 @@ def m21ToTsv(self): thisEntry.mn = thisRN.measureNumber thisEntry.mn_onset = thisRN.beat thisEntry.timesig = thisRN.getContextByClass( - 'TimeSignature').ratioString + 'TimeSignature' + ).ratioString thisEntry.global_key = global_key if isinstance(thisRN, harmony.NoChord): - thisEntry.numeral = thisEntry.chord = "@none" + thisEntry.numeral = thisEntry.chord = '@none' else: relativeroot = None if thisRN.secondaryRomanNumeral: relativeroot = thisRN.secondaryRomanNumeral.figure - thisEntry.chord = thisRN.figure # NB: slightly different from DCML: no key. + thisEntry.chord = ( + thisRN.figure + ) # NB: slightly different from DCML: no key. thisEntry.pedal = None thisEntry.numeral = thisRN.romanNumeralAlone thisEntry.form = None thisEntry.figbass = thisRN.figuresWritten - thisEntry.changes = None # TODO + thisEntry.changes = None thisEntry.relativeroot = relativeroot thisEntry.phraseend = None local_key = local_key_as_rn(thisRN.key, global_key_obj) thisEntry.local_key = local_key - thisInfo = [] thisInfo = [ - getattr(thisEntry, name, thisRN.editorial.get(name, '')) - for name in DCML_V2_HEADERS + getattr(thisEntry, name, thisRN.editorial.get(name, '')) + for name in self.dcml_headers + ] + + tsvData.append(thisInfo) + + return tsvData + + def _m21ToTsv_v1(self): + tsvData = [] + + for thisRN in self.m21Stream.recurse().getElementsByClass( + 'RomanNumeral' + ): + + relativeroot = None + if thisRN.secondaryRomanNumeral: + relativeroot = thisRN.secondaryRomanNumeral.figure + + altChord = None + if thisRN.secondaryRomanNumeral: + if thisRN.secondaryRomanNumeral.key == thisRN.key: + altChord = thisRN.secondaryRomanNumeral.figure + + thisEntry = TabChord() + + thisEntry.combinedChord = ( + thisRN.figure + ) # NB: slightly different from DCML: no key. + thisEntry.altchord = altChord + thisEntry.mn = thisRN.measureNumber + thisEntry.mn_onset = thisRN.beat + thisEntry.totbeat = None + thisEntry.timesig = thisRN.getContextByClass( + 'TimeSignature' + ).ratioString + thisEntry.op = self.m21Stream.metadata.opusNumber + thisEntry.no = self.m21Stream.metadata.number + thisEntry.mov = self.m21Stream.metadata.movementNumber + thisEntry.length = thisRN.quarterLength + thisEntry.global_key = None + local_key = thisRN.key.name.split()[0] + if thisRN.key.mode == 'minor': + local_key = local_key.lower() + thisEntry.local_key = local_key + thisEntry.pedal = None + thisEntry.numeral = thisRN.romanNumeralAlone + thisEntry.form = None + thisEntry.figbass = thisRN.figuresWritten + thisEntry.changes = None + 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, ] tsvData.append(thisInfo) @@ -520,18 +696,24 @@ def write(self, filePathAndName): ''' Writes a list of lists (e.g. from m21ToTsv()) to a tsv file. ''' - with open(filePathAndName, 'w', newline='', encoding='utf-8') as csvFile: - csvOut = csv.writer(csvFile, - delimiter='\t', - quotechar='"', - quoting=csv.QUOTE_MINIMAL) - csvOut.writerow(DCML_V2_HEADERS) + with open( + filePathAndName, 'w', newline='', encoding='utf-8' + ) as csvFile: + csvOut = csv.writer( + csvFile, + delimiter='\t', + quotechar='"', + quoting=csv.QUOTE_MINIMAL, + ) + csvOut.writerow(self.dcml_headers) for thisEntry in self.tsvData: csvOut.writerow(thisEntry) + # ------------------------------------------------------------------------------ + def local_key_as_rn(local_key, global_key): ''' Takes two music21.key.Key objects and returns the roman numeral for @@ -590,15 +772,17 @@ def characterSwaps(preString, minor=True, direction='m21-DCML'): search = '' insert = '' if direction == 'm21-DCML': - characterDict = {'/o': '%', - 'ø': '%', - } + characterDict = { + '/o': '%', + 'ø': '%', + } elif direction == 'DCML-m21': - characterDict = {'%': 'ø', # Preferred over '/o' - 'M7': '7', # 7th types not specified in m21 - } + characterDict = { + '%': 'ø', # Preferred over '/o' + 'M7': '7', # 7th types not specified in m21 + } else: - raise ValueError("Direction must be 'm21-DCML' or 'DCML-m21'.") + raise ValueError('Direction must be "m21-DCML" or "DCML-m21".') for thisKey in characterDict: # Both major and minor preString = preString.replace(thisKey, characterDict[thisKey]) @@ -615,11 +799,15 @@ def characterSwaps(preString, minor=True, direction='m21-DCML'): if 'vii' in preString.lower(): position = preString.lower().index('vii') - prevChar = preString[position - 1] # the previous character, # / b. + prevChar = preString[ + position - 1 + ] # the previous character, # / b. if prevChar == search: - postString = preString[:position - 1] + preString[position:] + postString = preString[: position - 1] + preString[position:] else: - postString = preString[:position] + insert + preString[position:] + postString = ( + preString[:position] + insert + preString[position:] + ) else: postString = preString @@ -647,7 +835,9 @@ def getLocalKey(local_key, global_key, convertDCMLToM21=False): 'g' ''' if convertDCMLToM21: - local_key = characterSwaps(local_key, minor=is_minor(global_key[0]), direction='DCML-m21') + local_key = characterSwaps( + local_key, minor=is_minor(global_key[0]), direction='DCML-m21' + ) asRoman = roman.RomanNumeral(local_key, global_key) rt = asRoman.root().name @@ -682,107 +872,127 @@ def getSecondaryKey(rn, local_key): very_local_as_key = local_key else: position = rn.index('/') - very_local_as_roman = rn[position + 1:] + very_local_as_roman = rn[position + 1 :] very_local_as_key = getLocalKey(very_local_as_roman, local_key) return very_local_as_key + # ------------------------------------------------------------------------------ class Test(unittest.TestCase): - - def testTsvHandler(self): import os - 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 - chord_i = DCML_V2_HEADERS.index('chord') - self.assertEqual(handler.tsvData[0][chord_i], '.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, TabChord) - self.assertEqual(testTabChord1.chord, '.C.I6') - self.assertEqual(testTabChord1.numeral, 'I') - self.assertEqual(testTabChord2.chord, '#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 - - - # Ultimately, to verify that the conversion is working well both - # ways, it would be nice to convert forward and backwards and - # compare the results. But we won't be able to do so until writing - # "globalkey" and "localkey" is implemented - # name = 'n01op18-1_01.tsv' - path = common.getSourceFilePath() / 'romanText' / name - forward1 = TsvHandler(path) - stream1 = forward1.toM21Stream() - - envLocal = environment.Environment() - tempF = envLocal.getTempFile() - M21toTSV(stream1).write(tempF) - forward2 = TsvHandler(tempF) - stream2 = forward2.toM21Stream() - os.remove(tempF) - assert len(stream1.recurse()) == len(stream2.recurse()) - # presently the commented-out test fails because vii seems to be notated - # differently between music21 and DCML. E.g., '#viio7/vi' in the - # DCML file becomes 'viio7/vi' when we write it out, which then - # becomes 'bvii/vi' when read anew - # for i, (item1, item2) in enumerate(zip( - # stream1.recurse().getElementsByClass('RomanNumeral'), - # stream2.recurse().getElementsByClass('RomanNumeral') - # )): - # assert item1 == item2 - - + import urllib.request + + for version in (1, 2): + name = f'tsvEg_v{version}.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, dcml_version=version) + + headers = DCML_HEADERS[version - 1] + # Raw + chord_i = headers.index('chord') + self.assertEqual(handler.tsvData[0][chord_i], '.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, TabChord) + self.assertEqual(testTabChord1.chord, '.C.I6') + self.assertEqual(testTabChord1.numeral, 'I') + self.assertEqual(testTabChord2.chord, '#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 + + # Below, we download a couple real tsv files, in each version, to + # test the conversion on. + + urls = [ + 'https://raw.githubusercontent.com/DCMLab/ABC/master/data/tsv/op.%2018%20No.%201/op18_no1_mov1.tsv', + 'https://raw.githubusercontent.com/DCMLab/ABC/v2/harmonies/n01op18-1_01.tsv', + ] + url = urls[version - 1] + + envLocal = environment.Environment() + temp_tsv1 = envLocal.getTempFile() + with urllib.request.urlopen(url) as f: + tsv_contents = f.read().decode('utf-8') + with open(temp_tsv1, "w") as outf: + outf.write(tsv_contents) + + forward1 = TsvHandler(temp_tsv1, dcml_version=version) + stream1 = forward1.toM21Stream() + temp_tsv2 = envLocal.getTempFile() + M21toTSV(stream1, dcml_version=version).write(temp_tsv2) + forward2 = TsvHandler(temp_tsv2, dcml_version=version) + stream2 = forward2.toM21Stream() + os.remove(temp_tsv1) + os.remove(temp_tsv2) + assert len(stream1.recurse()) == len(stream2.recurse()) + + # presently, in version 2, the commented-out test fails because + # vii seems to be notated + # differently between music21 and DCML. E.g., '#viio7/vi' in the + # DCML file becomes 'viio7/vi' when we write it out, which then + # becomes 'bvii/vi' when read anew + # It seems to fail altogether in version 1. + # if version == 2: + # for i, (item1, item2) in enumerate(zip( + # stream1.recurse().getElementsByClass('RomanNumeral'), + # stream2.recurse().getElementsByClass('RomanNumeral') + # )): + # assert item1 == item2 def testM21ToTsv(self): import os from music21 import corpus - bachHarmony = corpus.parse('bach/choraleAnalyses/riemenschneider001.rntxt') - initial = M21toTSV(bachHarmony) - tsvData = initial.tsvData - numeral_i = DCML_V2_HEADERS.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) + bachHarmony = corpus.parse( + 'bach/choraleAnalyses/riemenschneider001.rntxt' + ) + for version in (1, 2): + initial = M21toTSV(bachHarmony, dcml_version=version) + tsvData = initial.tsvData + numeral_i = DCML_HEADERS[version - 1].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')) @@ -790,7 +1000,9 @@ def testIsMinor(self): def testOfCharacter(self): startText = 'before%after' - newText = ''.join([characterSwaps(x, direction='DCML-m21') for x in startText]) + newText = ''.join( + [characterSwaps(x, direction='DCML-m21') for x in startText] + ) self.assertIsInstance(startText, str) self.assertIsInstance(newText, str) @@ -800,19 +1012,25 @@ def testOfCharacter(self): self.assertEqual(newText, 'beforeøafter') testStr1in = 'ii%' - testStr1out = characterSwaps(testStr1in, minor=False, direction='DCML-m21') + testStr1out = characterSwaps( + testStr1in, minor=False, direction='DCML-m21' + ) self.assertEqual(testStr1in, 'ii%') self.assertEqual(testStr1out, 'iiø') testStr2in = 'vii' - testStr2out = characterSwaps(testStr2in, minor=True, direction='m21-DCML') + 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') + testStr3out = characterSwaps( + testStr3in, minor=True, direction='DCML-m21' + ) self.assertEqual(testStr3in, '#vii') self.assertEqual(testStr3out, 'vii') @@ -839,9 +1057,11 @@ def testGetSecondaryKey(self): self.assertIsInstance(veryLocalKey, str) self.assertEqual(veryLocalKey, 'b') + # ------------------------------------------------------------------------------ if __name__ == '__main__': import music21 + music21.mainTest(Test) diff --git a/music21/romanText/tsvEg_v1.tsv b/music21/romanText/tsvEg_v1.tsv new file mode 100644 index 0000000000..10a4403211 --- /dev/null +++ b/music21/romanText/tsvEg_v1.tsv @@ -0,0 +1,7 @@ +"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 +"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 +"I" "" "6" "1.0" "13.0" "2/4" "1" "2" "3" 2.0 "C" "I" "" "I" "" "" "" "" false diff --git a/music21/romanText/tsvEg_v2.tsv b/music21/romanText/tsvEg_v2.tsv new file mode 100644 index 0000000000..93345704ae --- /dev/null +++ b/music21/romanText/tsvEg_v2.tsv @@ -0,0 +1,7 @@ +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 0 2/4 C I .C.I6 I FALSE +2 2 0 0 2/4 C I #viio6/ii #vii o 6 ii FALSE +3 3 0 0 2/4 C I ii ii FALSE +4 4 0 0 3/4 C I V V FALSE +5 5 0 0 3/4 C I @none V FALSE +6 6 0 0 2/4 C I I I FALSE From 688deaacbf54f33cca245532fc163197e749d8ee Mon Sep 17 00:00:00 2001 From: Malcolm Sailor Date: Mon, 11 Apr 2022 17:12:12 -0400 Subject: [PATCH 07/22] preserve accidentals in roman numerals when writing to TSV --- music21/romanText/tsvConverter.py | 15 ++++++++++++--- music21/romanText/tsvEg.tsv | 7 ------- 2 files changed, 12 insertions(+), 10 deletions(-) delete mode 100644 music21/romanText/tsvEg.tsv diff --git a/music21/romanText/tsvConverter.py b/music21/romanText/tsvConverter.py index d307b6780a..5204af33cb 100644 --- a/music21/romanText/tsvConverter.py +++ b/music21/romanText/tsvConverter.py @@ -315,7 +315,10 @@ def tabToM21(self): localKeyNonRoman = getLocalKey(self.local_key, self.global_key) else: localKeyNonRoman = self.local_key - thisEntry = roman.RomanNumeral(combined, localKeyNonRoman) + thisEntry = roman.RomanNumeral( + combined, + localKeyNonRoman, + ) thisEntry.pedal = self.pedal thisEntry.phraseend = None @@ -557,6 +560,8 @@ class M21toTSV: 'I' ''' + NUMERAL_REGEX = re.compile(r'^(?P\D*)(?:\d+(?:/\d+)*)?$') + def __init__(self, m21Stream, dcml_version=2): self.version = dcml_version self.m21Stream = m21Stream @@ -602,7 +607,9 @@ def _m21ToTsv_v2(self): thisRN.figure ) # NB: slightly different from DCML: no key. thisEntry.pedal = None - thisEntry.numeral = thisRN.romanNumeralAlone + thisEntry.numeral = re.match( + self.NUMERAL_REGEX, thisRN.primaryFigure + ).group('numeral') thisEntry.form = None thisEntry.figbass = thisRN.figuresWritten thisEntry.changes = None @@ -659,7 +666,9 @@ def _m21ToTsv_v1(self): local_key = local_key.lower() thisEntry.local_key = local_key thisEntry.pedal = None - thisEntry.numeral = thisRN.romanNumeralAlone + thisEntry.numeral = re.match( + self.NUMERAL_REGEX, thisRN.primaryFigure + ).group('numeral') thisEntry.form = None thisEntry.figbass = thisRN.figuresWritten thisEntry.changes = None diff --git a/music21/romanText/tsvEg.tsv b/music21/romanText/tsvEg.tsv deleted file mode 100644 index 93345704ae..0000000000 --- a/music21/romanText/tsvEg.tsv +++ /dev/null @@ -1,7 +0,0 @@ -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 0 2/4 C I .C.I6 I FALSE -2 2 0 0 2/4 C I #viio6/ii #vii o 6 ii FALSE -3 3 0 0 2/4 C I ii ii FALSE -4 4 0 0 3/4 C I V V FALSE -5 5 0 0 3/4 C I @none V FALSE -6 6 0 0 2/4 C I I I FALSE From e622e195d3ae42d745417e3035bcfe833211d53e Mon Sep 17 00:00:00 2001 From: Malcolm Sailor Date: Tue, 12 Apr 2022 07:21:42 -0400 Subject: [PATCH 08/22] using .romanNumeral attr rather than regex --- music21/romanText/tsvConverter.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/music21/romanText/tsvConverter.py b/music21/romanText/tsvConverter.py index 5204af33cb..b4fcfc2ac8 100644 --- a/music21/romanText/tsvConverter.py +++ b/music21/romanText/tsvConverter.py @@ -560,8 +560,6 @@ class M21toTSV: 'I' ''' - NUMERAL_REGEX = re.compile(r'^(?P\D*)(?:\d+(?:/\d+)*)?$') - def __init__(self, m21Stream, dcml_version=2): self.version = dcml_version self.m21Stream = m21Stream @@ -607,9 +605,7 @@ def _m21ToTsv_v2(self): thisRN.figure ) # NB: slightly different from DCML: no key. thisEntry.pedal = None - thisEntry.numeral = re.match( - self.NUMERAL_REGEX, thisRN.primaryFigure - ).group('numeral') + thisEntry.numeral = thisRN.romanNumeral thisEntry.form = None thisEntry.figbass = thisRN.figuresWritten thisEntry.changes = None @@ -666,9 +662,7 @@ def _m21ToTsv_v1(self): local_key = local_key.lower() thisEntry.local_key = local_key thisEntry.pedal = None - thisEntry.numeral = re.match( - self.NUMERAL_REGEX, thisRN.primaryFigure - ).group('numeral') + thisEntry.numeral = thisRN.romanNumeral thisEntry.form = None thisEntry.figbass = thisRN.figuresWritten thisEntry.changes = None @@ -977,7 +971,7 @@ def testTsvHandler(self): # stream1.recurse().getElementsByClass('RomanNumeral'), # stream2.recurse().getElementsByClass('RomanNumeral') # )): - # assert item1 == item2 + # assert item1 == item2, f"{item1} != {item2}" def testM21ToTsv(self): import os From 8acb75197e50f5f519e5f60f74f5e86450d6da18 Mon Sep 17 00:00:00 2001 From: Malcolm Sailor Date: Tue, 12 Apr 2022 09:21:48 -0400 Subject: [PATCH 09/22] flaked and linted --- music21/romanText/tsvConverter.py | 40 ++++++++++++++++++------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/music21/romanText/tsvConverter.py b/music21/romanText/tsvConverter.py index b4fcfc2ac8..2ce121de32 100644 --- a/music21/romanText/tsvConverter.py +++ b/music21/romanText/tsvConverter.py @@ -21,7 +21,6 @@ from music21 import key from music21 import metadata from music21 import meter -from music21 import note from music21 import pitch from music21 import roman from music21 import stream @@ -141,6 +140,10 @@ class TabChord: ''' An intermediate representation format for moving between tabular data and music21 chords. ''' + # Because the attributes of this class vary depending on the dcml_version, + # we assign them dynamically with setattr in the __init__ function. Pylint + # mistakenly then thinks they are defined outside of init. + # pylint: disable=attribute-defined-outside-init BEAT_REGEX = re.compile( r'(?P\d+(?:\.\d+)?)/(?P\d+(?:\.\d+)?)' @@ -155,7 +158,7 @@ def __init__(self, dcml_version=2): elif name == 'beat': name = 'mn_onset' setattr(self, name, None) - self.representationType = None # Added (not in DCML) + self.representationType = self.extra = None # Added (not in DCML) @property def beat(self): @@ -317,7 +320,7 @@ def tabToM21(self): localKeyNonRoman = self.local_key thisEntry = roman.RomanNumeral( combined, - localKeyNonRoman, + localKeyNonRoman, ) thisEntry.pedal = self.pedal thisEntry.phraseend = None @@ -560,6 +563,11 @@ class M21toTSV: 'I' ''' + # Because the attributes of the TabChord class vary depending on the + # dcml_version, we assign them dynamically with setattr in the __init__ + # function of that class. Pylint mistakenly then thinks that, when we + # assign to them in m21toTsv, they are defined outside init. + # pylint: disable=attribute-defined-outside-init def __init__(self, m21Stream, dcml_version=2): self.version = dcml_version self.m21Stream = m21Stream @@ -665,7 +673,7 @@ def _m21ToTsv_v1(self): thisEntry.numeral = thisRN.romanNumeral thisEntry.form = None thisEntry.figbass = thisRN.figuresWritten - thisEntry.changes = None + thisEntry.changes = None thisEntry.relativeroot = relativeroot thisEntry.phraseend = None @@ -875,7 +883,7 @@ def getSecondaryKey(rn, local_key): very_local_as_key = local_key else: position = rn.index('/') - very_local_as_roman = rn[position + 1 :] + very_local_as_roman = rn[position + 1:] very_local_as_key = getLocalKey(very_local_as_roman, local_key) return very_local_as_key @@ -934,22 +942,23 @@ def testTsvHandler(self): out_stream.parts[0].measure(1)[0].figure, 'I' ) # First item in measure 1 - # Below, we download a couple real tsv files, in each version, to + # Below, we download a couple real tsv files, in each version, to # test the conversion on. urls = [ + # pylint: disable=line-too-long 'https://raw.githubusercontent.com/DCMLab/ABC/master/data/tsv/op.%2018%20No.%201/op18_no1_mov1.tsv', 'https://raw.githubusercontent.com/DCMLab/ABC/v2/harmonies/n01op18-1_01.tsv', - ] + ] url = urls[version - 1] - + envLocal = environment.Environment() temp_tsv1 = envLocal.getTempFile() with urllib.request.urlopen(url) as f: tsv_contents = f.read().decode('utf-8') - with open(temp_tsv1, "w") as outf: + with open(temp_tsv1, 'w', encoding='utf-8') as outf: outf.write(tsv_contents) - + forward1 = TsvHandler(temp_tsv1, dcml_version=version) stream1 = forward1.toM21Stream() temp_tsv2 = envLocal.getTempFile() @@ -959,13 +968,10 @@ def testTsvHandler(self): os.remove(temp_tsv1) os.remove(temp_tsv2) assert len(stream1.recurse()) == len(stream2.recurse()) - - # presently, in version 2, the commented-out test fails because - # vii seems to be notated - # differently between music21 and DCML. E.g., '#viio7/vi' in the - # DCML file becomes 'viio7/vi' when we write it out, which then - # becomes 'bvii/vi' when read anew - # It seems to fail altogether in version 1. + + # Presently, in version 2, the commented-out test fails because + # viio7 becomes vii. It also fails in version 1, possibly for a + # different reason. # if version == 2: # for i, (item1, item2) in enumerate(zip( # stream1.recurse().getElementsByClass('RomanNumeral'), From a1ad510356697f393bf6b636af8f45e81ad6ccc8 Mon Sep 17 00:00:00 2001 From: Malcolm Sailor Date: Wed, 20 Apr 2022 09:08:57 -0400 Subject: [PATCH 10/22] restored previous formatting; improved m21-to-tsv conversion; other misc tweaks --- music21/romanText/tsvConverter.py | 789 +++++++++++++++++------------- 1 file changed, 443 insertions(+), 346 deletions(-) diff --git a/music21/romanText/tsvConverter.py b/music21/romanText/tsvConverter.py index 2ce121de32..41fb2f3204 100644 --- a/music21/romanText/tsvConverter.py +++ b/music21/romanText/tsvConverter.py @@ -13,22 +13,24 @@ DCMLab's Annotated Beethoven Corpus (Neuwirth et al. 2018). ''' +import abc import csv import re +import types import unittest -from music21 import chord, common, harmony +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 pitch from music21 import roman from music21 import stream from music21 import exceptions21 from music21 import environment - environLocal = environment.Environment() # ------------------------------------------------------------------------------ @@ -40,46 +42,67 @@ class TsvException(exceptions21.Music21Exception): # ------------------------------------------------------------------------------ -V1_HEADERS = ( - 'chord', - 'altchord', - 'measure', - 'beat', - 'totbeat', - 'timesig', - 'op', - 'no', - 'mov', - 'length', - 'global_key', - 'local_key', - 'pedal', - 'numeral', - 'form', - 'figbass', - 'changes', - 'relativeroot', - 'phraseend', +# 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+)?)' ) -V2_HEADERS = ( - 'chord', - 'mn', - 'mn_onset', - 'timesig', - 'globalkey', - 'localkey', - 'pedal', - 'numeral', - 'form', - 'figbass', - 'changes', - 'relativeroot', - 'phraseend', -) +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, +}) HEADERS = (V1_HEADERS, 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', @@ -102,6 +125,9 @@ class TsvException(exceptions21.Music21Exception): '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', @@ -135,58 +161,44 @@ class TsvException(exceptions21.Music21Exception): DCML_HEADERS = (DCML_V1_HEADERS, DCML_V2_HEADERS) - -class TabChord: +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. ''' - # Because the attributes of this class vary depending on the dcml_version, - # we assign them dynamically with setattr in the __init__ function. Pylint - # mistakenly then thinks they are defined outside of init. - # pylint: disable=attribute-defined-outside-init - BEAT_REGEX = re.compile( - r'(?P\d+(?:\.\d+)?)/(?P\d+(?:\.\d+)?)' - ) - - def __init__(self, dcml_version=2): - for name in HEADERS[dcml_version - 1]: - # the names 'measure' and 'beat' used in version 1 are now used - # for properties of the TabChord so we remap them here - if name == 'measure': - name = 'mn' - elif name == 'beat': - name = 'mn_onset' - setattr(self, name, None) + def __init__(self): + super().__init__() + self.numeral = None + self.relativeroot = None self.representationType = self.extra = None # Added (not in DCML) - @property - def beat(self): - try: - return float(self.mn_onset) - except ValueError: - m = re.match(self.BEAT_REGEX, self.mn_onset) - return float(m.group('numer')) / float(m.group('denom')) @property - def measure(self): - return int(self.mn) + @abc.abstractmethod + def dcml_version(self): + pass @property - def local_key(self): - return self.localkey - - @local_key.setter - def local_key(self, k): - self.localkey = k + def combinedChord(self): + ''' + 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'. - @property - def global_key(self): - return self.globalkey + >>> tabCd = romanText.tsvConverter.TabChord() + >>> tabCd.chord = 'viio7' + >>> tabCd.combinedChord + 'viio7' + >>> tabCd.combinedChord = 'IV+' + >>> tabCd.chord + 'IV+' + ''' + return self.chord - @global_key.setter - def global_key(self, k): - self.globalkey = k + @combinedChord.setter + def combinedChord(self, value): + self.chord = value def _changeRepresentation(self): ''' @@ -196,11 +208,10 @@ def _changeRepresentation(self): First, let's set up a TabChord(). >>> tabCd = romanText.tsvConverter.TabChord() - >>> tabCd.representationType = 'DCML' >>> tabCd.global_key = 'F' >>> tabCd.local_key = 'vi' >>> tabCd.numeral = '#vii' - + >>> 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. @@ -222,64 +233,57 @@ def _changeRepresentation(self): if self.representationType == 'm21': direction = 'm21-DCML' - self.representationType = ( - 'DCML' # Becomes the case during this function. - ) + self.representationType = 'DCML' # Becomes the case during this function. elif self.representationType == 'DCML': direction = 'DCML-m21' - self.representationType = ( - 'm21' # Becomes the case during this function. - ) + self.representationType = 'm21' # Becomes the case during this function. else: - raise ValueError( - 'Data source must specify representation type as "m21" or "DCML".' - ) + 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), direction=direction - ) + # self.local_key is an ordinary attribute of TabChordV1 but a property + # of TabChordV2, so we can't define it in the __init__ of the base + # class. Thus we need to disable the pylint warning here. + self.local_key = characterSwaps(self.local_key, # pylint: disable=attribute-defined-outside-init + minor=is_minor(self.global_key), + direction=direction) # Local - relative and figure if is_minor(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 - self.relativeroot = characterSwaps( - self.relativeroot, minor=True, direction=direction - ) - self.numeral = characterSwaps( - self.numeral, minor=True, direction=direction - ) + if is_minor(self.relativeroot): # ... and it's minor too, change it and the figure + self.relativeroot = characterSwaps(self.relativeroot, + minor=True, + direction=direction) + self.numeral = characterSwaps(self.numeral, + minor=True, + direction=direction) else: # ... rel. root but not minor - self.relativeroot = characterSwaps( - self.relativeroot, minor=False, direction=direction - ) + self.relativeroot = characterSwaps(self.relativeroot, + minor=False, + direction=direction) else: # No relative root - self.numeral = characterSwaps( - self.numeral, minor=True, direction=direction - ) + self.numeral = characterSwaps(self.numeral, + minor=True, + 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 - self.relativeroot = characterSwaps( - self.relativeroot, minor=False, direction=direction - ) - self.numeral = characterSwaps( - self.numeral, minor=True, direction=direction - ) + if is_minor(self.relativeroot): # ... and it's minor, change it and the figure + self.relativeroot = characterSwaps(self.relativeroot, + minor=False, + direction=direction) + self.numeral = characterSwaps(self.numeral, + minor=True, + direction=direction) else: # ... rel. root but not minor - self.relativeroot = characterSwaps( - self.relativeroot, minor=False, direction=direction - ) + self.relativeroot = characterSwaps(self.relativeroot, + minor=False, + direction=direction) else: # No relative root - self.numeral = characterSwaps( - self.numeral, minor=False, direction=direction - ) + self.numeral = characterSwaps(self.numeral, + minor=False, + direction=direction) def tabToM21(self): ''' @@ -290,43 +294,160 @@ def tabToM21(self): >>> tabCd = romanText.tsvConverter.TabChord() >>> tabCd.numeral = 'vii' - >>> tabCd.representationType = 'm21' >>> tabCd.global_key = 'F' >>> tabCd.local_key = 'V' + >>> tabCd.representationType = 'm21' >>> m21Ch = tabCd.tabToM21() Now we can check it's a music21 RomanNumeral(): - # >>> m21Ch.figure + >>> m21Ch.figure 'vii' ''' - if self.numeral == '@none': + if self.numeral in ('@none', None): thisEntry = harmony.NoChord() else: - if self.form: - if self.figbass: - combined = ''.join([self.numeral, self.form, self.figbass]) - else: - combined = ''.join([self.numeral, self.form]) - else: - combined = self.numeral + # 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 = ''.join([combined, '/', self.relativeroot]) - if re.match(r'.*(i*v|v?i+).*', self.local_key, re.IGNORECASE): + 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, - ) + thisEntry = roman.RomanNumeral(combined, localKeyNonRoman) + + if self.dcml_version == 1: + thisEntry.quarterLength = self.length + # following metadata attributes seem to be missing from + # dcml_version 2 tsv files + thisEntry.op = self.op + thisEntry.no = self.no + thisEntry.mov = self.mov + thisEntry.pedal = self.pedal + thisEntry.phraseend = None return thisEntry +class TabChord(TabChordBase): + ''' + An intermediate representation format for moving between tabular data in + DCML v1 and music21 chords. + ''' + _dcml_version = 1 + def __init__(self): + # self.numeral and self.relativeroot defined in super().__init__() + super().__init__() + self.chord = None + self.altchord = None + self.measure = None + self.beat = None + self.totbeat = 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.form = None + self.figbass = None + self.changes = None + self.phraseend = None + + @property + def dcml_version(self): + return self._dcml_version + + + +class TabChordV2(TabChordBase): + ''' + An intermediate representation format for moving between tabular data in + DCML v2 and music21 chords. + ''' + _dcml_version = 2 + def __init__(self): + # self.numeral and self.relativeroot defined in super().__init__() + super().__init__() + self.chord = None + self.mn = None + self.mn_onset = None + self.timesig = None + self.globalkey = None + self.localkey = None + self.pedal = None + self.form = None + self.figbass = None + self.changes = None + self.phraseend = None + + @property + def dcml_version(self): + return self._dcml_version + + @property + def beat(self): + ''' + '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. This property reproduces + the former 'beat' by adding 1 to 'mn_onset'. + >>> tabCd = romanText.tsvConverter.TabChordV2() + >>> tabCd.mn_onset = 0 + >>> tabCd.beat + 1 + ''' + # beat is zero-indexed in v2 but one-indexed in v1 + return self.mn_onset + 1 + + @property + def measure(self): + ''' + '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) + + @property + def local_key(self): + ''' + '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): + self.localkey = k + + @property + def global_key(self): + ''' + '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 + + @global_key.setter + def global_key(self, k): + self.globalkey = k # ------------------------------------------------------------------------------ @@ -337,7 +458,7 @@ class TsvHandler: First we need to get a score. (Don't worry about this bit.) - >>> name = 'tsvEg_v2.tsv' + >>> name = 'tsvEg_v1.tsv' >>> path = common.getSourceFilePath() / 'romanText' / name >>> handler = romanText.tsvConverter.TsvHandler(path) >>> handler.tsvToChords() @@ -345,7 +466,7 @@ class TsvHandler: These should be TabChords now. >>> testTabChord1 = handler.chordList[0] - >>> testTabChord1.chord + >>> testTabChord1.combinedChord '.C.I6' Good. We can make them into music21 Roman-numerals. @@ -361,34 +482,36 @@ class TsvHandler: 'I' ''' - - _heading_names = (set(V1_HEADERS), set(V2_HEADERS)) - - def __init__(self, tsvFile, dcml_version=2): - self.heading_names = self._heading_names[dcml_version - 1] + def __init__(self, tsvFile, dcml_version=1): + self.heading_names = HEADERS[dcml_version - 1] self.tsvFileName = tsvFile self.chordList = None self.m21stream = None self.preparedStream = None self._head_indices = None self._extra_indices = None + self._tab_chord_cls = (TabChord, TabChordV2)[dcml_version - 1] self.dcml_version = dcml_version - self.tsvData = self._importTsv() + self.tsvData = self.importTsv() def _get_heading_indices(self, header_row): self._head_indices, self._extra_indices = {}, {} for i, item in enumerate(header_row): if item in self.heading_names: - self._head_indices[i] = item + self._head_indices[i] = item, self.heading_names[item] else: self._extra_indices[i] = item - def _importTsv(self): + def importTsv(self): ''' Imports TSV file data for further processing. ''' - with open(self.tsvFileName, 'r', encoding='utf-8') as inf: - tsvreader = csv.reader(inf, delimiter='\t', quotechar='"') + + fileName = self.tsvFileName + + with open(fileName, 'r', encoding='utf-8') as f: + tsvreader = csv.reader(f, delimiter='\t', quotechar='"') + # The first row is the header self._get_heading_indices(next(tsvreader)) return list(tsvreader) @@ -397,16 +520,10 @@ def _makeTabChord(self, row): Makes a TabChord out of a list imported from TSV data (a row of the original tabular format -- see TsvHandler.importTsv()). ''' - - thisEntry = TabChord(self.dcml_version) - for i, name in self._head_indices.items(): - # the names 'measure' and 'beat' used in version 1 are now used - # for properties of the TabChord so we remap them here - if name == 'measure': - name = 'mn' - elif name == 'beat': - name = 'mn_onset' - setattr(thisEntry, name, row[i]) + # this method replaces the previously stand-alone makeTabChord function + thisEntry = self._tab_chord_cls() + for i, (name, type_) in self._head_indices.items(): + setattr(thisEntry, name, type_(row[i])) thisEntry.extra = { name: row[i] for i, name in self._extra_indices.items() if row[i] } @@ -442,27 +559,23 @@ def toM21Stream(self): if self.chordList is None: self.tsvToChords() - self.prepStream() s = self.preparedStream - p = ( - s.parts.first() - ) # Just to get to the part, not that there are several. + p = s.parts.first() # Just to get to the part, not that there are several. for thisChord in self.chordList: - offsetInMeasure = ( - thisChord.beat - ) # beats always measured in quarter notes - measureNumber = thisChord.mn + offsetInMeasure = thisChord.beat - 1 # beats always measured in quarter notes + measureNumber = thisChord.measure m21Measure = p.measure(measureNumber) if thisChord.representationType == 'DCML': thisChord._changeRepresentation() 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 @@ -479,29 +592,21 @@ def prepStream(self): ''' s = stream.Score() p = stream.Part() - 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 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 + 'Op' + firstEntry.op + '_No' + firstEntry.no + '_Mov' + firstEntry.mov ) startingKeySig = str(self.chordList[0].global_key) - try: - ks = key.Key(startingKeySig) - except pitch.PitchException: - ks = key.Key(self.chordList[0].local_key) + ks = key.Key(startingKeySig) p.insert(0, ks) currentTimeSig = str(self.chordList[0].timesig) @@ -516,9 +621,7 @@ def prepStream(self): for entry in self.chordList: if entry.measure == previousMeasure: continue - elif ( - entry.measure != previousMeasure + 1 - ): # Not every measure has a chord change. + elif entry.measure != previousMeasure + 1: # Not every measure has a chord change. for mNo in range(previousMeasure + 1, entry.measure + 1): m = stream.Measure(number=mNo) m.offset = currentOffset + currentMeasureLength @@ -528,16 +631,20 @@ def prepStream(self): previousMeasure = mNo else: # entry.measure = previousMeasure + 1 m = stream.Measure(number=entry.measure) + # '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, newTS) + m.insert(entry.beat - 1, newTS) currentTimeSig = entry.timesig currentMeasureLength = newTS.barDuration.quarterLength previousMeasure = entry.measure + s.append(p) self.preparedStream = s @@ -557,17 +664,12 @@ class M21toTSV: The initialisation 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][14] + >>> tsvData[1][14] # 14 is index to 'chord' in v2 'I' ''' - # Because the attributes of the TabChord class vary depending on the - # dcml_version, we assign them dynamically with setattr in the __init__ - # function of that class. Pylint mistakenly then thinks that, when we - # assign to them in m21toTsv, they are defined outside init. - # pylint: disable=attribute-defined-outside-init def __init__(self, m21Stream, dcml_version=2): self.version = dcml_version self.m21Stream = m21Stream @@ -583,6 +685,58 @@ def m21ToTsv(self): return self._m21ToTsv_v1() return self._m21ToTsv_v2() + def _m21ToTsv_v1(self): + tsvData = [] + # take the global_key from the first item + global_key = next( + self.m21Stream.recurse().getElementsByClass('RomanNumeral') + ).key.tonicPitchNameWithCase + + for thisRN in self.m21Stream.recurse().getElementsByClass('RomanNumeral'): + + relativeroot = None + if thisRN.secondaryRomanNumeral: + relativeroot = thisRN.secondaryRomanNumeral.figure + + altChord = None + if thisRN.secondaryRomanNumeral: + if thisRN.secondaryRomanNumeral.key == thisRN.key: + altChord = thisRN.secondaryRomanNumeral.figure + + thisEntry = TabChord() + + thisEntry.combinedChord = thisRN.figure # NB: slightly different from DCML: no key. + thisEntry.altchord = altChord + thisEntry.measure = thisRN.measureNumber + thisEntry.beat = thisRN.beat + thisEntry.totbeat = None + thisEntry.timesig = thisRN.getContextByClass('TimeSignature').ratioString + thisEntry.op = self.m21Stream.metadata.opusNumber + thisEntry.no = self.m21Stream.metadata.number + thisEntry.mov = self.m21Stream.metadata.movementNumber + thisEntry.length = thisRN.quarterLength + thisEntry.global_key = global_key + local_key = thisRN.key.name.split()[0] + if thisRN.key.mode == 'minor': + local_key = local_key.lower() + thisEntry.local_key = local_key + thisEntry.pedal = None + thisEntry.numeral = thisRN.romanNumeral + thisEntry.form = get_form(thisRN) + # Strip any leading non-digits from figbass (e.g., M43 -> 43) + thisEntry.figbass = re.match(r"^\D*(\d.*|)", thisRN.figuresWritten).group(1) + thisEntry.changes = None # TODO + thisEntry.relativeroot = relativeroot + thisEntry.phraseend = None + + thisInfo = [ + getattr(thisEntry, name, thisRN.editorial.get(name, '')) + for name in self.dcml_headers + ] + tsvData.append(thisInfo) + + return tsvData + def _m21ToTsv_v2(self): tsvData = [] @@ -590,13 +744,11 @@ def _m21ToTsv_v2(self): global_key_obj = next( self.m21Stream.recurse().getElementsByClass('RomanNumeral') ).key - global_key = next( - self.m21Stream.recurse().getElementsByClass('RomanNumeral') - ).key.tonicPitchNameWithCase + global_key = global_key_obj.tonicPitchNameWithCase for thisRN in self.m21Stream.recurse().getElementsByClass( ['RomanNumeral', 'NoChord'] ): - thisEntry = TabChord() + thisEntry = TabChordV2() thisEntry.mn = thisRN.measureNumber thisEntry.mn_onset = thisRN.beat thisEntry.timesig = thisRN.getContextByClass( @@ -614,15 +766,15 @@ def _m21ToTsv_v2(self): ) # NB: slightly different from DCML: no key. thisEntry.pedal = None thisEntry.numeral = thisRN.romanNumeral - thisEntry.form = None - thisEntry.figbass = thisRN.figuresWritten + thisEntry.form = get_form(thisRN) + # Strip any leading non-digits from figbass (e.g., M43 -> 43) + thisEntry.figbass = re.match(r"^\D*(\d.*|)", thisRN.figuresWritten).group(1) thisEntry.changes = None thisEntry.relativeroot = relativeroot thisEntry.phraseend = None local_key = local_key_as_rn(thisRN.key, global_key_obj) thisEntry.local_key = local_key - thisInfo = [] thisInfo = [ getattr(thisEntry, name, thisRN.editorial.get(name, '')) for name in self.dcml_headers @@ -632,103 +784,59 @@ def _m21ToTsv_v2(self): return tsvData - def _m21ToTsv_v1(self): - tsvData = [] - - for thisRN in self.m21Stream.recurse().getElementsByClass( - 'RomanNumeral' - ): - - relativeroot = None - if thisRN.secondaryRomanNumeral: - relativeroot = thisRN.secondaryRomanNumeral.figure - - altChord = None - if thisRN.secondaryRomanNumeral: - if thisRN.secondaryRomanNumeral.key == thisRN.key: - altChord = thisRN.secondaryRomanNumeral.figure - - thisEntry = TabChord() - - thisEntry.combinedChord = ( - thisRN.figure - ) # NB: slightly different from DCML: no key. - thisEntry.altchord = altChord - thisEntry.mn = thisRN.measureNumber - thisEntry.mn_onset = thisRN.beat - thisEntry.totbeat = None - thisEntry.timesig = thisRN.getContextByClass( - 'TimeSignature' - ).ratioString - thisEntry.op = self.m21Stream.metadata.opusNumber - thisEntry.no = self.m21Stream.metadata.number - thisEntry.mov = self.m21Stream.metadata.movementNumber - thisEntry.length = thisRN.quarterLength - thisEntry.global_key = None - local_key = thisRN.key.name.split()[0] - if thisRN.key.mode == 'minor': - local_key = local_key.lower() - thisEntry.local_key = local_key - thisEntry.pedal = None - thisEntry.numeral = thisRN.romanNumeral - thisEntry.form = None - thisEntry.figbass = thisRN.figuresWritten - thisEntry.changes = None - 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, - ] - - tsvData.append(thisInfo) - - return tsvData - def write(self, filePathAndName): ''' Writes a list of lists (e.g. from m21ToTsv()) to a tsv file. ''' - with open( - filePathAndName, 'w', newline='', encoding='utf-8' - ) as csvFile: - csvOut = csv.writer( - csvFile, - delimiter='\t', - quotechar='"', - quoting=csv.QUOTE_MINIMAL, - ) + with open(filePathAndName, 'a', newline='', encoding='utf-8') as csvFile: + csvOut = csv.writer(csvFile, + delimiter='\t', + quotechar='"', + quoting=csv.QUOTE_MINIMAL) csvOut.writerow(self.dcml_headers) for thisEntry in self.tsvData: csvOut.writerow(thisEntry) - # ------------------------------------------------------------------------------ +def get_form(rn): + ''' + Takes a music21.roman.RomanNumeral object and returns the string indicating + "form" expected by the DCML standard. + + >>> romanText.tsvConverter.get_form(roman.RomanNumeral('V')) + '' + >>> romanText.tsvConverter.get_form(roman.RomanNumeral('viio7')) + 'o' + >>> romanText.tsvConverter.get_form(roman.RomanNumeral('IVM7')) + 'M' + >>> romanText.tsvConverter.get_form(roman.RomanNumeral('III+')) + '+' + >>> romanText.tsvConverter.get_form(roman.RomanNumeral('IV+M7')) + '+M' + >>> romanText.tsvConverter.get_form(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 local_key_as_rn(local_key, global_key): ''' Takes two music21.key.Key objects and returns the roman numeral for - `local_key` in `global_key`. + `local_key` relative to `global_key`. >>> k1 = key.Key('C') >>> k2 = key.Key('e-') @@ -745,7 +853,6 @@ def local_key_as_rn(local_key, global_key): r = roman.romanNumeralFromChord(chord.Chord(rn.pitches), keyObj=global_key) return r.romanNumeral - def is_minor(test_key): ''' Checks whether a key is minor or not simply by upper vs lower case. @@ -783,17 +890,15 @@ def characterSwaps(preString, minor=True, direction='m21-DCML'): search = '' insert = '' if direction == 'm21-DCML': - characterDict = { - '/o': '%', - 'ø': '%', - } + characterDict = {'/o': '%', + 'ø': '%', + } elif direction == 'DCML-m21': - characterDict = { - '%': 'ø', # Preferred over '/o' - 'M7': '7', # 7th types not specified in m21 - } + characterDict = {'%': 'ø', # Preferred over '/o' + 'M7': '7', # 7th types not specified in m21 + } else: - raise ValueError('Direction must be "m21-DCML" or "DCML-m21".') + raise ValueError("Direction must be 'm21-DCML' or 'DCML-m21'.") for thisKey in characterDict: # Both major and minor preString = preString.replace(thisKey, characterDict[thisKey]) @@ -810,15 +915,11 @@ def characterSwaps(preString, minor=True, direction='m21-DCML'): if 'vii' in preString.lower(): position = preString.lower().index('vii') - prevChar = preString[ - position - 1 - ] # the previous character, # / b. + prevChar = preString[position - 1] # the previous character, # / b. if prevChar == search: - postString = preString[: position - 1] + preString[position:] + postString = preString[:position - 1] + preString[position:] else: - postString = ( - preString[:position] + insert + preString[position:] - ) + postString = preString[:position] + insert + preString[position:] else: postString = preString @@ -846,9 +947,7 @@ def getLocalKey(local_key, global_key, convertDCMLToM21=False): 'g' ''' if convertDCMLToM21: - local_key = characterSwaps( - local_key, minor=is_minor(global_key[0]), direction='DCML-m21' - ) + local_key = characterSwaps(local_key, minor=is_minor(global_key[0]), direction='DCML-m21') asRoman = roman.RomanNumeral(local_key, global_key) rt = asRoman.root().name @@ -888,26 +987,24 @@ def getSecondaryKey(rn, local_key): return very_local_as_key - # ------------------------------------------------------------------------------ class Test(unittest.TestCase): + def testTsvHandler(self): import os import urllib.request - - for version in (1, 2): + for version in (1, 2): # test both versions name = f'tsvEg_v{version}.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, dcml_version=version) - headers = DCML_HEADERS[version - 1] - # Raw chord_i = headers.index('chord') + # Raw self.assertEqual(handler.tsvData[0][chord_i], '.C.I6') self.assertEqual(handler.tsvData[1][chord_i], '#viio6/ii') @@ -915,10 +1012,10 @@ def testTsvHandler(self): handler.tsvToChords() testTabChord1 = handler.chordList[0] # Also tests makeTabChord() testTabChord2 = handler.chordList[1] - self.assertIsInstance(testTabChord1, TabChord) - self.assertEqual(testTabChord1.chord, '.C.I6') + self.assertIsInstance(testTabChord1, TabChordBase) + self.assertEqual(testTabChord1.combinedChord, '.C.I6') self.assertEqual(testTabChord1.numeral, 'I') - self.assertEqual(testTabChord2.chord, '#viio6/ii') + self.assertEqual(testTabChord2.combinedChord, '#viio6/ii') self.assertEqual(testTabChord2.numeral, '#vii') # Change Representation @@ -939,19 +1036,17 @@ def testTsvHandler(self): # M21 stream out_stream = handler.toM21Stream() self.assertEqual( - out_stream.parts[0].measure(1)[0].figure, 'I' - ) # First item in measure 1 + out_stream.parts[0].measure(1)[0].figure, 'I' # First item in measure 1 + ) - # Below, we download a couple real tsv files, in each version, to - # test the conversion on. + # Download a real tsv file to test the conversion on. urls = [ # pylint: disable=line-too-long - 'https://raw.githubusercontent.com/DCMLab/ABC/master/data/tsv/op.%2018%20No.%201/op18_no1_mov1.tsv', - 'https://raw.githubusercontent.com/DCMLab/ABC/v2/harmonies/n01op18-1_01.tsv', + 'https://raw.githubusercontent.com/DCMLab/ABC/2e8a01398f8ad694d3a7af57bed8b14ac57120b7/data/tsv/op.%2018%20No.%201/op18_no1_mov1.tsv', + 'https://raw.githubusercontent.com/DCMLab/ABC/65c831a559c47180d74e2679fea49aa117fd3dbb/harmonies/n01op18-1_01.tsv', ] url = urls[version - 1] - envLocal = environment.Environment() temp_tsv1 = envLocal.getTempFile() with urllib.request.urlopen(url) as f: @@ -959,40 +1054,52 @@ def testTsvHandler(self): with open(temp_tsv1, 'w', encoding='utf-8') as outf: outf.write(tsv_contents) + # Convert to m21 forward1 = TsvHandler(temp_tsv1, 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_tsv1) os.remove(temp_tsv2) - assert len(stream1.recurse()) == len(stream2.recurse()) - - # Presently, in version 2, the commented-out test fails because - # viio7 becomes vii. It also fails in version 1, possibly for a - # different reason. - # if version == 2: - # for i, (item1, item2) in enumerate(zip( - # stream1.recurse().getElementsByClass('RomanNumeral'), - # stream2.recurse().getElementsByClass('RomanNumeral') - # )): - # assert item1 == item2, f"{item1} != {item2}" + + # Ensure that both m21 streams are the same + self.assertEqual(len(stream1.recurse()), len(stream2.recurse())) + for i, (item1, item2) in enumerate(zip( + stream1.recurse().getElementsByClass('RomanNumeral'), + stream2.recurse().getElementsByClass('RomanNumeral') + )): + try: + self.assertEqual( + item1, item2, msg=f"item {i}, version {version}: {item1} != {item2}" + ) + except AssertionError: + # Augmented sixth figures will not agree, e.g., + # - Ger6 becomes Ger65 + # - Fr6 becomes Fr43 + # This doesn't seem important, but we can at least + # assert that both items are augmented sixth chords of + # the same type. + m = re.match("Ger|Fr", item1.figure) + self.assertIsNotNone(m) + aug6_type = m.group(0) + self.assertTrue(item2.figure.startswith(aug6_type)) def testM21ToTsv(self): import os from music21 import corpus - bachHarmony = corpus.parse( - 'bach/choraleAnalyses/riemenschneider001.rntxt' - ) + bachHarmony = corpus.parse('bach/choraleAnalyses/riemenschneider001.rntxt') for version in (1, 2): initial = M21toTSV(bachHarmony, dcml_version=version) tsvData = initial.tsvData numeral_i = DCML_HEADERS[version - 1].index('numeral') - self.assertEqual( - bachHarmony.parts[0].measure(1)[0].figure, 'I' - ) # NB pickup measure 0. + self.assertEqual(bachHarmony.parts[0].measure(1)[0].figure, 'I') # NB pickup measure 0. self.assertEqual(tsvData[1][numeral_i], 'I') # Test .write @@ -1009,9 +1116,7 @@ def testIsMinor(self): def testOfCharacter(self): startText = 'before%after' - newText = ''.join( - [characterSwaps(x, direction='DCML-m21') for x in startText] - ) + newText = ''.join([characterSwaps(x, direction='DCML-m21') for x in startText]) self.assertIsInstance(startText, str) self.assertIsInstance(newText, str) @@ -1021,25 +1126,19 @@ def testOfCharacter(self): self.assertEqual(newText, 'beforeøafter') testStr1in = 'ii%' - testStr1out = characterSwaps( - testStr1in, minor=False, direction='DCML-m21' - ) + testStr1out = characterSwaps(testStr1in, minor=False, direction='DCML-m21') self.assertEqual(testStr1in, 'ii%') self.assertEqual(testStr1out, 'iiø') testStr2in = 'vii' - testStr2out = characterSwaps( - testStr2in, minor=True, direction='m21-DCML' - ) + 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' - ) + testStr3out = characterSwaps(testStr3in, minor=True, direction='DCML-m21') self.assertEqual(testStr3in, '#vii') self.assertEqual(testStr3out, 'vii') @@ -1066,11 +1165,9 @@ def testGetSecondaryKey(self): self.assertIsInstance(veryLocalKey, str) self.assertEqual(veryLocalKey, 'b') - # ------------------------------------------------------------------------------ if __name__ == '__main__': import music21 - music21.mainTest(Test) From 4364d1e33f3e785f91673e284e0ef3f33bb08eed Mon Sep 17 00:00:00 2001 From: Malcolm Sailor Date: Wed, 3 Aug 2022 16:42:14 -0400 Subject: [PATCH 11/22] type annotations and other fixes --- music21/romanText/tsvConverter.py | 412 +++++++++--------- .../{tsvEg_v2.tsv => tsvEg_v2major.tsv} | 2 + music21/romanText/tsvEg_v2minor.tsv | 9 + 3 files changed, 229 insertions(+), 194 deletions(-) rename music21/romanText/{tsvEg_v2.tsv => tsvEg_v2major.tsv} (80%) create mode 100644 music21/romanText/tsvEg_v2minor.tsv diff --git a/music21/romanText/tsvConverter.py b/music21/romanText/tsvConverter.py index 41fb2f3204..4c84f53bff 100644 --- a/music21/romanText/tsvConverter.py +++ b/music21/romanText/tsvConverter.py @@ -17,6 +17,7 @@ import csv import re import types +from typing import List import unittest from music21 import chord @@ -98,7 +99,7 @@ def _float_or_frac(value): 'phraseend': str, }) -HEADERS = (V1_HEADERS, V2_HEADERS) +HEADERS = {1: V1_HEADERS, 2: V2_HEADERS} # Headers for Digital and Cognitive Musicology Lab Standard v1 as in the ABC # corpus at @@ -159,7 +160,7 @@ def _float_or_frac(value): 'bass_note', ) -DCML_HEADERS = (DCML_V1_HEADERS, DCML_V2_HEADERS) +DCML_HEADERS = {1: DCML_V1_HEADERS, 2: DCML_V2_HEADERS} class TabChordBase(abc.ABC): ''' @@ -171,13 +172,19 @@ def __init__(self): super().__init__() self.numeral = None self.relativeroot = None - self.representationType = self.extra = None # Added (not in DCML) + self.representationType = None # Added (not in DCML) + self.extra = None + self.dcml_version = -1 + self.local_key = None # overwritten by a property in TabChordV2 - - @property - @abc.abstractmethod - def dcml_version(self): - pass + # shared between DCML v1 and v2 + self.chord = None + self.timesig = None + self.pedal = None + self.form = None + self.figbass = None + self.changes = None + self.phraseend = None @property def combinedChord(self): @@ -242,17 +249,18 @@ def _changeRepresentation(self): else: raise ValueError("Data source must specify representation type as 'm21' or 'DCML'.") - # self.local_key is an ordinary attribute of TabChordV1 but a property - # of TabChordV2, so we can't define it in the __init__ of the base - # class. Thus we need to disable the pylint warning here. - self.local_key = characterSwaps(self.local_key, # pylint: disable=attribute-defined-outside-init - minor=is_minor(self.global_key), + self.local_key = characterSwaps(self.local_key, + minor=isMinor(self.global_key), direction=direction) - + + # previously, '%' (indicating half-diminished) was not being parsed + # properly. + if self.form == '%' and direction == 'DCML-m21': + self.form = 'ø' # 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) @@ -269,7 +277,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) @@ -307,6 +315,8 @@ def tabToM21(self): if self.numeral in ('@none', None): thisEntry = harmony.NoChord() + if self.dcml_version == 1: + thisEntry.quarterLength = self.length else: # previously this code only included figbass in combined if form # was not falsy, which seems incorrect @@ -347,31 +357,19 @@ class TabChord(TabChordBase): An intermediate representation format for moving between tabular data in DCML v1 and music21 chords. ''' - _dcml_version = 1 def __init__(self): # self.numeral and self.relativeroot defined in super().__init__() super().__init__() - self.chord = None self.altchord = None self.measure = None self.beat = None self.totbeat = 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.form = None - self.figbass = None - self.changes = None - self.phraseend = None - - @property - def dcml_version(self): - return self._dcml_version + self.dcml_version = 1 @@ -380,25 +378,14 @@ class TabChordV2(TabChordBase): An intermediate representation format for moving between tabular data in DCML v2 and music21 chords. ''' - _dcml_version = 2 def __init__(self): # self.numeral and self.relativeroot defined in super().__init__() super().__init__() - self.chord = None self.mn = None self.mn_onset = None - self.timesig = None self.globalkey = None self.localkey = None - self.pedal = None - self.form = None - self.figbass = None - self.changes = None - self.phraseend = None - - @property - def dcml_version(self): - return self._dcml_version + self.dcml_version = 2 @property def beat(self): @@ -483,24 +470,38 @@ class TsvHandler: ''' def __init__(self, tsvFile, dcml_version=1): - self.heading_names = HEADERS[dcml_version - 1] + if dcml_version == 1: + self.heading_names = HEADERS[1] + self._tab_chord_cls = 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.chordList = None + self.chordList = [] self.m21stream = None self.preparedStream = None self._head_indices = None self._extra_indices = None - self._tab_chord_cls = (TabChord, TabChordV2)[dcml_version - 1] self.dcml_version = dcml_version self.tsvData = self.importTsv() - def _get_heading_indices(self, header_row): - self._head_indices, self._extra_indices = {}, {} - for i, item in enumerate(header_row): - if item in self.heading_names: - self._head_indices[i] = item, self.heading_names[item] + def _get_heading_indices(self, header_row: 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[i] = (col_name, type_to_coerce_col_to) else: - self._extra_indices[i] = item + self._extra_indices[i] = col_name def importTsv(self): ''' @@ -522,10 +523,11 @@ def _makeTabChord(self, row): ''' # this method replaces the previously stand-alone makeTabChord function thisEntry = self._tab_chord_cls() - for i, (name, type_) in self._head_indices.items(): - setattr(thisEntry, name, type_(row[i])) + for i, (col_name, 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 = { - name: row[i] for i, name in self._extra_indices.items() if row[i] + col_name: row[i] for i, col_name in self._extra_indices.items() if row[i] } thisEntry.representationType = 'DCML' # Added @@ -556,8 +558,7 @@ 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 self.chordList is None: + if not self.chordList: self.tsvToChords() self.prepStream() @@ -666,14 +667,20 @@ class M21toTSV: >>> initial = romanText.tsvConverter.M21toTSV(bachHarmony, dcml_version=2) >>> tsvData = initial.tsvData - >>> tsvData[1][14] # 14 is index to 'chord' in v2 + >>> from music21.romanText.tsvConverter import DCML_V2_HEADERS + >>> tsvData[1][DCML_V2_HEADERS.index('chord')] 'I' ''' def __init__(self, m21Stream, dcml_version=2): self.version = dcml_version self.m21Stream = m21Stream - self.dcml_headers = DCML_HEADERS[dcml_version - 1] + 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): @@ -716,15 +723,12 @@ def _m21ToTsv_v1(self): thisEntry.mov = self.m21Stream.metadata.movementNumber thisEntry.length = thisRN.quarterLength thisEntry.global_key = global_key - local_key = thisRN.key.name.split()[0] - if thisRN.key.mode == 'minor': - local_key = local_key.lower() - thisEntry.local_key = local_key + thisEntry.local_key = thisRN.key.tonicPitchNameWithCase thisEntry.pedal = None thisEntry.numeral = thisRN.romanNumeral - thisEntry.form = get_form(thisRN) + thisEntry.form = getForm(thisRN) # Strip any leading non-digits from figbass (e.g., M43 -> 43) - thisEntry.figbass = re.match(r"^\D*(\d.*|)", thisRN.figuresWritten).group(1) + thisEntry.figbass = re.match(r'^\D*(\d.*|)', thisRN.figuresWritten).group(1) thisEntry.changes = None # TODO thisEntry.relativeroot = relativeroot thisEntry.phraseend = None @@ -741,38 +745,52 @@ def _m21ToTsv_v2(self): tsvData = [] # take the global_key from the first item - global_key_obj = next( - self.m21Stream.recurse().getElementsByClass('RomanNumeral') - ).key + global_key_obj = self.m21Stream[roman.RomanNumeral].first().key global_key = global_key_obj.tonicPitchNameWithCase for thisRN in self.m21Stream.recurse().getElementsByClass( - ['RomanNumeral', 'NoChord'] + [roman.RomanNumeral, harmony.NoChord] ): thisEntry = TabChordV2() thisEntry.mn = thisRN.measureNumber thisEntry.mn_onset = thisRN.beat - thisEntry.timesig = thisRN.getContextByClass( - 'TimeSignature' - ).ratioString + 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 = thisEntry.chord = '@none' + thisEntry.numeral = '@none' + thisEntry.chord = '@none' else: + local_key = localKeyAsRn(thisRN.key, global_key_obj) relativeroot = None if thisRN.secondaryRomanNumeral: relativeroot = thisRN.secondaryRomanNumeral.figure - thisEntry.chord = ( - thisRN.figure - ) # NB: slightly different from DCML: no key. + 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 = get_form(thisRN) + thisEntry.form = getForm(thisRN) # Strip any leading non-digits from figbass (e.g., M43 -> 43) - thisEntry.figbass = re.match(r"^\D*(\d.*|)", thisRN.figuresWritten).group(1) + 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 + # 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 - local_key = local_key_as_rn(thisRN.key, global_key_obj) thisEntry.local_key = local_key thisInfo = [ @@ -781,7 +799,6 @@ def _m21ToTsv_v2(self): ] tsvData.append(thisInfo) - return tsvData def write(self, filePathAndName): @@ -800,22 +817,22 @@ def write(self, filePathAndName): # ------------------------------------------------------------------------------ -def get_form(rn): +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.get_form(roman.RomanNumeral('V')) + >>> romanText.tsvConverter.getForm(roman.RomanNumeral('V')) '' - >>> romanText.tsvConverter.get_form(roman.RomanNumeral('viio7')) + >>> romanText.tsvConverter.getForm(roman.RomanNumeral('viio7')) 'o' - >>> romanText.tsvConverter.get_form(roman.RomanNumeral('IVM7')) + >>> romanText.tsvConverter.getForm(roman.RomanNumeral('IVM7')) 'M' - >>> romanText.tsvConverter.get_form(roman.RomanNumeral('III+')) + >>> romanText.tsvConverter.getForm(roman.RomanNumeral('III+')) '+' - >>> romanText.tsvConverter.get_form(roman.RomanNumeral('IV+M7')) + >>> romanText.tsvConverter.getForm(roman.RomanNumeral('IV+M7')) '+M' - >>> romanText.tsvConverter.get_form(roman.RomanNumeral('viiø7')) + >>> romanText.tsvConverter.getForm(roman.RomanNumeral('viiø7')) '%' ''' if 'ø' in rn.figure: @@ -833,34 +850,43 @@ def get_form(rn): return '' -def local_key_as_rn(local_key, global_key): +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.local_key_as_rn(k1, k2) + >>> k2 = key.Key('e') + >>> romanText.tsvConverter.localKeyAsRn(k1, k2) 'VI' - - >>> romanText.tsvConverter.local_key_as_rn(k2, k1) - 'biii' + >>> 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 is_minor(test_key): +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() @@ -912,14 +938,17 @@ def characterSwaps(preString, minor=True, direction='m21-DCML'): elif direction == 'DCML-m21': search = '#' insert = 'b' - - if 'vii' in preString.lower(): - position = preString.lower().index('vii') + m = re.search('vii?', preString) + if m is not None: + # Previously, this function here matched VII and vii but not vi; for V2 + # (at least), we need to match vii and vi but *not* VII; this version + # also passes V1 tests. + position = m.start() prevChar = preString[position - 1] # the previous character, # / b. if prevChar == search: postString = preString[:position - 1] + preString[position:] else: - postString = preString[:position] + insert + preString[position:] + postString = preString[:position] + insert + preString[position:] else: postString = preString @@ -947,7 +976,7 @@ def getLocalKey(local_key, global_key, convertDCMLToM21=False): 'g' ''' if convertDCMLToM21: - local_key = characterSwaps(local_key, minor=is_minor(global_key[0]), direction='DCML-m21') + local_key = characterSwaps(local_key, minor=isMinor(global_key[0]), direction='DCML-m21') asRoman = roman.RomanNumeral(local_key, global_key) rt = asRoman.root().name @@ -995,100 +1024,95 @@ class Test(unittest.TestCase): def testTsvHandler(self): import os import urllib.request + test_files = { + 1:('tsvEg_v1.tsv',), + 2: ('tsvEg_v2major.tsv', 'tsvEg_v2minor.tsv'), + } for version in (1, 2): # test both versions - name = f'tsvEg_v{version}.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, dcml_version=version) - headers = DCML_HEADERS[version - 1] - chord_i = headers.index('chord') - # Raw - self.assertEqual(handler.tsvData[0][chord_i], '.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, '.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 - ) - - # Download a real tsv file to test the conversion on. - - urls = [ - # pylint: disable=line-too-long - 'https://raw.githubusercontent.com/DCMLab/ABC/2e8a01398f8ad694d3a7af57bed8b14ac57120b7/data/tsv/op.%2018%20No.%201/op18_no1_mov1.tsv', - 'https://raw.githubusercontent.com/DCMLab/ABC/65c831a559c47180d74e2679fea49aa117fd3dbb/harmonies/n01op18-1_01.tsv', - ] - url = urls[version - 1] - envLocal = environment.Environment() - temp_tsv1 = envLocal.getTempFile() - with urllib.request.urlopen(url) as f: - tsv_contents = f.read().decode('utf-8') - with open(temp_tsv1, 'w', encoding='utf-8') as outf: - outf.write(tsv_contents) - - # Convert to m21 - forward1 = TsvHandler(temp_tsv1, 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_tsv1) - 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.recurse().getElementsByClass('RomanNumeral'), - stream2.recurse().getElementsByClass('RomanNumeral') - )): - try: + 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 + self.assertEqual(handler.tsvData[0][chord_i], '.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, '.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( - item1, item2, msg=f"item {i}, version {version}: {item1} != {item2}" + out_stream.parts[0].measure(1)[0].figure, 'I' # First item in measure 1 ) - except AssertionError: - # Augmented sixth figures will not agree, e.g., - # - Ger6 becomes Ger65 - # - Fr6 becomes Fr43 - # This doesn't seem important, but we can at least - # assert that both items are augmented sixth chords of - # the same type. - m = re.match("Ger|Fr", item1.figure) - self.assertIsNotNone(m) - aug6_type = m.group(0) - self.assertTrue(item2.figure.startswith(aug6_type)) + + # test tsv -> m21 -> tsv -> m21; compare m21 streams to make sure + # they're equal + envLocal = environment.Environment() + + forward1 = TsvHandler(name, 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.recurse().getElementsByClass('RomanNumeral'), + stream2.recurse().getElementsByClass('RomanNumeral') + )): + try: + self.assertEqual( + item1, item2, msg=f'item {i}, version {version}: {item1} != {item2}' + ) + except AssertionError: + # Augmented sixth figures will not agree, e.g., + # - Ger6 becomes Ger65 + # - Fr6 becomes Fr43 + # This doesn't seem important, but we can at least + # assert that both items are augmented sixth chords of + # the same type. + m = re.match('Ger|Fr', item1.figure) + self.assertIsNotNone(m) + aug6_type = m.group(0) + self.assertTrue(item2.figure.startswith(aug6_type)) + # Checking for quarterLenght as per + # https://github.com/cuthbertLab/music21/pull/1267#discussion_r936451907 + assert hasattr(item1, 'quarterLength') and isinstance(item1.quarterLength, float) def testM21ToTsv(self): import os @@ -1098,7 +1122,7 @@ def testM21ToTsv(self): for version in (1, 2): initial = M21toTSV(bachHarmony, dcml_version=version) tsvData = initial.tsvData - numeral_i = DCML_HEADERS[version - 1].index('numeral') + 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') @@ -1111,8 +1135,8 @@ def testM21ToTsv(self): 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' diff --git a/music21/romanText/tsvEg_v2.tsv b/music21/romanText/tsvEg_v2major.tsv similarity index 80% rename from music21/romanText/tsvEg_v2.tsv rename to music21/romanText/tsvEg_v2major.tsv index 93345704ae..1bbbb62239 100644 --- a/music21/romanText/tsvEg_v2.tsv +++ b/music21/romanText/tsvEg_v2major.tsv @@ -5,3 +5,5 @@ mc mn mc_onset mn_onset timesig staff voice volta label globalkey localkey pedal 4 4 0 0 3/4 C I V V FALSE 5 5 0 0 3/4 C I @none V FALSE 6 6 0 0 2/4 C I I I FALSE +7 7 0 0 2/4 C I ii%65 ii % 65 FALSE +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..a8ac04071d --- /dev/null +++ b/music21/romanText/tsvEg_v2minor.tsv @@ -0,0 +1,9 @@ +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 +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 From add5eaaefd798208a9b02e3ebc469f35c202e381 Mon Sep 17 00:00:00 2001 From: Malcolm Sailor Date: Wed, 3 Aug 2022 16:47:39 -0400 Subject: [PATCH 12/22] linted --- music21/romanText/tsvConverter.py | 36 +++++++++++++++++-------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/music21/romanText/tsvConverter.py b/music21/romanText/tsvConverter.py index 4c84f53bff..57050083e8 100644 --- a/music21/romanText/tsvConverter.py +++ b/music21/romanText/tsvConverter.py @@ -175,7 +175,7 @@ def __init__(self): self.representationType = None # Added (not in DCML) self.extra = None self.dcml_version = -1 - self.local_key = None # overwritten by a property in TabChordV2 + self.local_key = None # overwritten by a property in TabChordV2 # shared between DCML v1 and v2 self.chord = None @@ -252,7 +252,7 @@ def _changeRepresentation(self): self.local_key = characterSwaps(self.local_key, minor=isMinor(self.global_key), direction=direction) - + # previously, '%' (indicating half-diminished) was not being parsed # properly. if self.form == '%' and direction == 'DCML-m21': @@ -490,8 +490,8 @@ def __init__(self, tsvFile, dcml_version=1): def _get_heading_indices(self, header_row: 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 + 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 = {} @@ -757,7 +757,7 @@ def _m21ToTsv_v2(self): if timesig is None: thisEntry.timesig = '' else: - thisEntry.timesig = timesig.ratioString + thisEntry.timesig = timesig.ratioString thisEntry.global_key = global_key if isinstance(thisRN, harmony.NoChord): thisEntry.numeral = '@none' @@ -777,9 +777,9 @@ def _m21ToTsv_v2(self): # 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 + # 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 + # 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 @@ -850,7 +850,7 @@ def getForm(rn: roman.RomanNumeral) -> str: return '' -def localKeyAsRn(local_key:key.Key, global_key:key.Key) -> str: +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`. @@ -873,13 +873,13 @@ def localKeyAsRn(local_key:key.Key, global_key:key.Key) -> str: # 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') + 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: +def isMinor(test_key: str) -> bool: ''' Checks whether a key is minor or not simply by upper vs lower case. @@ -948,7 +948,7 @@ def characterSwaps(preString, minor=True, direction='m21-DCML'): if prevChar == search: postString = preString[:position - 1] + preString[position:] else: - postString = preString[:position] + insert + preString[position:] + postString = preString[:position] + insert + preString[position:] else: postString = preString @@ -1023,9 +1023,8 @@ class Test(unittest.TestCase): def testTsvHandler(self): import os - import urllib.request test_files = { - 1:('tsvEg_v1.tsv',), + 1: ('tsvEg_v1.tsv',), 2: ('tsvEg_v2major.tsv', 'tsvEg_v2minor.tsv'), } for version in (1, 2): # test both versions @@ -1110,9 +1109,14 @@ def testTsvHandler(self): self.assertIsNotNone(m) aug6_type = m.group(0) self.assertTrue(item2.figure.startswith(aug6_type)) - # Checking for quarterLenght as per + # Checking for quarterLength as per # https://github.com/cuthbertLab/music21/pull/1267#discussion_r936451907 - assert hasattr(item1, 'quarterLength') and isinstance(item1.quarterLength, float) + # However I'm not sure that 'quarterLength' is meaningful + # in the case of V2 where it is not set explicitly. + assert ( + hasattr(item1, 'quarterLength') + and isinstance(item1.quarterLength, float) + ) def testM21ToTsv(self): import os From 32a01bc10693935353902c59e1a45fef8909a43e Mon Sep 17 00:00:00 2001 From: Malcolm Sailor Date: Wed, 3 Aug 2022 16:56:07 -0400 Subject: [PATCH 13/22] US spelling --- music21/romanText/tsvConverter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/music21/romanText/tsvConverter.py b/music21/romanText/tsvConverter.py index 57050083e8..4bc56f0025 100644 --- a/music21/romanText/tsvConverter.py +++ b/music21/romanText/tsvConverter.py @@ -663,7 +663,7 @@ 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, dcml_version=2) >>> tsvData = initial.tsvData From 2f7db834091c25b660a04cb8076d7a7430126ac0 Mon Sep 17 00:00:00 2001 From: Malcolm Sailor Date: Thu, 4 Aug 2022 07:15:42 -0400 Subject: [PATCH 14/22] fixed local path in test --- music21/romanText/tsvConverter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/music21/romanText/tsvConverter.py b/music21/romanText/tsvConverter.py index 4bc56f0025..5980db5c6a 100644 --- a/music21/romanText/tsvConverter.py +++ b/music21/romanText/tsvConverter.py @@ -1076,7 +1076,7 @@ def testTsvHandler(self): # they're equal envLocal = environment.Environment() - forward1 = TsvHandler(name, dcml_version=version) + forward1 = TsvHandler(path, dcml_version=version) stream1 = forward1.toM21Stream() # Write back to tsv From a3247d30bf4a7dc0d59dd7ced53b7ebbe020ef9e Mon Sep 17 00:00:00 2001 From: Malcolm Sailor Date: Fri, 5 Aug 2022 10:59:12 -0400 Subject: [PATCH 15/22] type hints; better vi/vii handling; etc --- music21/romanText/tsvConverter.py | 214 ++++++++++++---------------- music21/romanText/tsvEg_v2major.tsv | 16 ++- music21/romanText/tsvEg_v2minor.tsv | 1 + 3 files changed, 105 insertions(+), 126 deletions(-) diff --git a/music21/romanText/tsvConverter.py b/music21/romanText/tsvConverter.py index 1fa295e58b..db814e485b 100644 --- a/music21/romanText/tsvConverter.py +++ b/music21/romanText/tsvConverter.py @@ -17,7 +17,7 @@ import csv import re import types -from typing import List +import typing as t import unittest from music21 import chord @@ -97,6 +97,7 @@ def _float_or_frac(value): 'changes': str, 'relativeroot': str, 'phraseend': str, + 'label': str, }) HEADERS = {1: V1_HEADERS, 2: V2_HEADERS} @@ -187,7 +188,7 @@ def __init__(self): self.phraseend = None @property - def combinedChord(self): + 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- @@ -204,10 +205,10 @@ def combinedChord(self): return self.chord @combinedChord.setter - def combinedChord(self, value): + def combinedChord(self, value: str): self.chord = value - def _changeRepresentation(self): + def _changeRepresentation(self) -> None: ''' Converts the representationType of a TabChord between the music21 and DCML conventions, especially for the different handling of expectations in minor. @@ -227,15 +228,9 @@ def _changeRepresentation(self): >>> tabCd.representationType 'DCML' - >>> tabCd.numeral - '#vii' - >>> tabCd._changeRepresentation() >>> tabCd.representationType 'm21' - - >>> tabCd.numeral - 'vii' ''' if self.representationType == 'm21': @@ -255,8 +250,10 @@ def _changeRepresentation(self): # previously, '%' (indicating half-diminished) was not being parsed # properly. - if self.form == '%' and direction == 'DCML-m21': - self.form = 'ø' + 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('%', 'ø') # Local - relative and figure if isMinor(self.local_key): if self.relativeroot: # If there's a relative root ... @@ -293,7 +290,7 @@ def _changeRepresentation(self): minor=False, direction=direction) - def tabToM21(self): + def tabToM21(self) -> None: ''' Creates and returns a music21.roman.RomanNumeral() object from a TabChord with all shared attributes. @@ -312,20 +309,22 @@ def tabToM21(self): >>> m21Ch.figure 'vii' ''' - + if self.representationType == 'DCML': + self._changeRepresentation() if self.numeral in ('@none', None): thisEntry = harmony.NoChord() - if self.dcml_version == 1: - thisEntry.quarterLength = self.length else: - # 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 = ''.join([combined, '/', self.relativeroot]) + if self.dcml_version == 2 and self.chord: + combined = self.chord + else: + # 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 = ''.join([combined, '/', self.relativeroot]) if self.local_key is not None and re.match( r'.*(i*v|v?i+).*', self.local_key, re.IGNORECASE ): @@ -336,10 +335,14 @@ def tabToM21(self): # otherwise, we assume self.local_key is already a pitch and # pass it through unchanged localKeyNonRoman = self.local_key - thisEntry = roman.RomanNumeral(combined, localKeyNonRoman) + thisEntry = roman.RomanNumeral( + combined, + localKeyNonRoman, + sixthMinor=roman.Minor67Default.FLAT, + seventhMinor=roman.Minor67Default.FLAT + ) if self.dcml_version == 1: - thisEntry.quarterLength = self.length # following metadata attributes seem to be missing from # dcml_version 2 tsv files thisEntry.op = self.op @@ -349,7 +352,9 @@ def tabToM21(self): thisEntry.pedal = self.pedal thisEntry.phraseend = None - + # if dcml_version == 2, we need to calculate the quarterLength + # later + thisEntry.quarterLength = self.length if self.dcml_version == 1 else 0.0 return thisEntry class TabChord(TabChordBase): @@ -388,22 +393,22 @@ def __init__(self): self.dcml_version = 2 @property - def beat(self): + 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. This property reproduces the former 'beat' by adding 1 to 'mn_onset'. >>> tabCd = romanText.tsvConverter.TabChordV2() - >>> tabCd.mn_onset = 0 + >>> tabCd.mn_onset = 0.0 >>> tabCd.beat - 1 + 1.0 ''' # beat is zero-indexed in v2 but one-indexed in v1 - return self.mn_onset + 1 + return self.mn_onset + 1.0 @property - def measure(self): + 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. @@ -411,7 +416,7 @@ def measure(self): return int(self.mn) @property - def local_key(self): + 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 @@ -420,11 +425,11 @@ def local_key(self): return self.localkey @local_key.setter - def local_key(self, k): + def local_key(self, k: str): self.localkey = k @property - def global_key(self): + 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 @@ -433,7 +438,7 @@ def global_key(self): return self.globalkey @global_key.setter - def global_key(self, k): + def global_key(self, k: str): self.globalkey = k # ------------------------------------------------------------------------------ @@ -469,7 +474,7 @@ class TsvHandler: 'I' ''' - def __init__(self, tsvFile, dcml_version=1): + def __init__(self, tsvFile: str, dcml_version: int = 1): if dcml_version == 1: self.heading_names = HEADERS[1] self._tab_chord_cls = TabChord @@ -487,7 +492,7 @@ def __init__(self, tsvFile, dcml_version=1): self.dcml_version = dcml_version self.tsvData = self.importTsv() - def _get_heading_indices(self, header_row: List[str]) -> None: + 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 @@ -499,11 +504,11 @@ def _get_heading_indices(self, header_row: List[str]) -> None: 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[i] = (col_name, type_to_coerce_col_to) + 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. ''' @@ -516,14 +521,14 @@ def importTsv(self): self._get_heading_indices(next(tsvreader)) return list(tsvreader) - def _makeTabChord(self, row): + 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() - for i, (col_name, type_to_coerce_to) in self._head_indices.items(): + 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 = { @@ -533,7 +538,7 @@ def _makeTabChord(self, row): return thisEntry - def tsvToChords(self): + def tsvToChords(self) -> None: ''' Converts a list of lists (of the type imported by importTsv) into TabChords (i.e. a list of TabChords). @@ -550,7 +555,7 @@ def tsvToChords(self): else: 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 @@ -570,20 +575,21 @@ def toM21Stream(self): measureNumber = thisChord.measure m21Measure = p.measure(measureNumber) - if thisChord.representationType == 'DCML': - thisChord._changeRepresentation() - thisM21Chord = thisChord.tabToM21() # In either case. # Store any otherwise unhandled attributes of the chord thisM21Chord.editorial.update(thisChord.extra) m21Measure.insert(offsetInMeasure, thisM21Chord) + s.flatten().extendDuration(harmony.Harmony, inPlace=True) + last_harmony = s[harmony.Harmony].last() + 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, @@ -672,7 +678,7 @@ class M21toTSV: 'I' ''' - def __init__(self, m21Stream, dcml_version=2): + def __init__(self, m21Stream: stream.Score, dcml_version: int = 2): self.version = dcml_version self.m21Stream = m21Stream if dcml_version == 1: @@ -683,7 +689,7 @@ def __init__(self, m21Stream, dcml_version=2): 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. @@ -692,7 +698,7 @@ def m21ToTsv(self): return self._m21ToTsv_v1() return self._m21ToTsv_v2() - def _m21ToTsv_v1(self): + def _m21ToTsv_v1(self) -> t.List[t.List[str]]: tsvData = [] # take the global_key from the first item global_key = next( @@ -741,7 +747,7 @@ def _m21ToTsv_v1(self): return tsvData - def _m21ToTsv_v2(self): + def _m21ToTsv_v2(self) -> t.List[t.List[str]]: tsvData = [] # take the global_key from the first item @@ -801,7 +807,7 @@ def _m21ToTsv_v2(self): tsvData.append(thisInfo) return tsvData - def write(self, filePathAndName): + def write(self, filePathAndName: str): ''' Writes a list of lists (e.g. from m21ToTsv()) to a tsv file. ''' @@ -892,7 +898,7 @@ def isMinor(test_key: str) -> bool: 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). @@ -900,21 +906,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': '%', 'ø': '%', @@ -929,33 +921,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' - m = re.search('vii?', preString) - if m is not None: - # Previously, this function here matched VII and vii but not vi; for V2 - # (at least), we need to match vii and vi but *not* VII; this version - # also passes V1 tests. - position = m.start() - 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 postString + return preString -def getLocalKey(local_key, global_key, convertDCMLToM21=False): +def getLocalKey(local_key: str, global_key: str, convertDCMLToM21: bool = False): ''' Re-casts comparative local key (e.g. 'V of G major') in its own terms ('D'). @@ -967,7 +936,7 @@ def getLocalKey(local_key, global_key, convertDCMLToM21=False): 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; @@ -978,7 +947,12 @@ def getLocalKey(local_key, global_key, convertDCMLToM21=False): if convertDCMLToM21: local_key = characterSwaps(local_key, minor=isMinor(global_key[0]), direction='DCML-m21') - asRoman = roman.RomanNumeral(local_key, global_key) + 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() @@ -990,9 +964,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). @@ -1038,7 +1012,8 @@ def testTsvHandler(self): headers = DCML_HEADERS[version] chord_i = headers.index('chord') # Raw - self.assertEqual(handler.tsvData[0][chord_i], '.C.I6') + # 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 @@ -1046,7 +1021,7 @@ def testTsvHandler(self): testTabChord1 = handler.chordList[0] # Also tests makeTabChord() testTabChord2 = handler.chordList[1] self.assertIsInstance(testTabChord1, TabChordBase) - self.assertEqual(testTabChord1.combinedChord, '.C.I6') + 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') @@ -1056,20 +1031,22 @@ def testTsvHandler(self): testTabChord1._changeRepresentation() self.assertEqual(testTabChord1.numeral, 'I') testTabChord2._changeRepresentation() - self.assertEqual(testTabChord2.numeral, 'vii') + self.assertEqual(testTabChord2.numeral, '#vii') # M21 RNs m21Chord1 = testTabChord1.tabToM21() m21Chord2 = testTabChord2.tabToM21() - self.assertEqual(m21Chord1.figure, 'I') - self.assertEqual(m21Chord2.figure, 'viio6/ii') + # 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)[0].figure, 'I' # First item in measure 1 + out_stream.parts[0].measure(1)[0].figure, 'I6' if version == 2 else 'I' ) # test tsv -> m21 -> tsv -> m21; compare m21 streams to make sure @@ -1091,8 +1068,8 @@ def testTsvHandler(self): # Ensure that both m21 streams are the same self.assertEqual(len(stream1.recurse()), len(stream2.recurse())) for i, (item1, item2) in enumerate(zip( - stream1.recurse().getElementsByClass('RomanNumeral'), - stream2.recurse().getElementsByClass('RomanNumeral') + stream1.recurse().getElementsByClass(harmony.Harmony), + stream2.recurse().getElementsByClass(harmony.Harmony) )): try: self.assertEqual( @@ -1113,10 +1090,20 @@ def testTsvHandler(self): # https://github.com/cuthbertLab/music21/pull/1267#discussion_r936451907 # However I'm not sure that 'quarterLength' is meaningful # in the case of V2 where it is not set explicitly. - assert ( + self.assertTrue( hasattr(item1, 'quarterLength') and isinstance(item1.quarterLength, float) ) + # if version == 2: + 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 @@ -1159,17 +1146,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') @@ -1178,7 +1154,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) diff --git a/music21/romanText/tsvEg_v2major.tsv b/music21/romanText/tsvEg_v2major.tsv index 1bbbb62239..28033747c1 100644 --- a/music21/romanText/tsvEg_v2major.tsv +++ b/music21/romanText/tsvEg_v2major.tsv @@ -1,9 +1,11 @@ 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 0 2/4 C I .C.I6 I FALSE -2 2 0 0 2/4 C I #viio6/ii #vii o 6 ii FALSE -3 3 0 0 2/4 C I ii ii FALSE -4 4 0 0 3/4 C I V V FALSE -5 5 0 0 3/4 C I @none V FALSE -6 6 0 0 2/4 C I I I FALSE -7 7 0 0 2/4 C I ii%65 ii % 65 FALSE +1 1 0 0 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 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 index a8ac04071d..500128319c 100644 --- a/music21/romanText/tsvEg_v2minor.tsv +++ b/music21/romanText/tsvEg_v2minor.tsv @@ -1,5 +1,6 @@ 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 From cd3e063da64eca3b8686e5b780eb470dd3b5946c Mon Sep 17 00:00:00 2001 From: Malcolm Sailor Date: Sat, 6 Aug 2022 08:08:30 -0400 Subject: [PATCH 16/22] typing --- music21/romanText/tsvConverter.py | 139 ++++++++++++++++++---------- music21/romanText/tsvEg_v2major.tsv | 2 + 2 files changed, 91 insertions(+), 50 deletions(-) diff --git a/music21/romanText/tsvConverter.py b/music21/romanText/tsvConverter.py index db814e485b..2e48b73532 100644 --- a/music21/romanText/tsvConverter.py +++ b/music21/romanText/tsvConverter.py @@ -15,6 +15,7 @@ import abc import csv +import fractions import re import types import typing as t @@ -54,9 +55,9 @@ class TsvException(exceptions21.Music21Exception): 'beat': float, 'totbeat': str, 'timesig': str, - 'op': str, - 'no': str, - 'mov': str, + # 'op': str, + # 'no': str, + # 'mov': str, 'length': float, 'global_key': str, 'local_key': str, @@ -174,9 +175,8 @@ def __init__(self): self.numeral = None self.relativeroot = None self.representationType = None # Added (not in DCML) - self.extra = None + self.extra = {} self.dcml_version = -1 - self.local_key = None # overwritten by a property in TabChordV2 # shared between DCML v1 and v2 self.chord = None @@ -187,6 +187,13 @@ def __init__(self): self.changes = None self.phraseend = None + # 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 + @property def combinedChord(self) -> str: ''' @@ -290,7 +297,7 @@ def _changeRepresentation(self) -> None: minor=False, direction=direction) - def tabToM21(self) -> None: + def tabToM21(self) -> harmony.Harmony: ''' Creates and returns a music21.roman.RomanNumeral() object from a TabChord with all shared attributes. @@ -312,7 +319,7 @@ def tabToM21(self) -> None: if self.representationType == 'DCML': self._changeRepresentation() if self.numeral in ('@none', None): - thisEntry = harmony.NoChord() + thisEntry: harmony.Harmony = harmony.NoChord() else: if self.dcml_version == 2 and self.chord: combined = self.chord @@ -342,19 +349,18 @@ def tabToM21(self) -> None: seventhMinor=roman.Minor67Default.FLAT ) - if self.dcml_version == 1: + if isinstance(self, TabChord): # following metadata attributes seem to be missing from # dcml_version 2 tsv files - thisEntry.op = self.op - thisEntry.no = self.no - thisEntry.mov = self.mov - - thisEntry.pedal = self.pedal + thisEntry.editorial.op = self.extra.get("op", "") + thisEntry.editorial.no = self.extra.get("no", "") + thisEntry.editorial.mov = self.extra.get("mov", "") - thisEntry.phraseend = None + thisEntry.editorial.pedal = self.pedal + thisEntry.editorial.phraseend = None # if dcml_version == 2, we need to calculate the quarterLength # later - thisEntry.quarterLength = self.length if self.dcml_version == 1 else 0.0 + thisEntry.quarterLength = 0.0 # self.length if self.dcml_version == 1 else 0.0 TODO return thisEntry class TabChord(TabChordBase): @@ -366,14 +372,12 @@ def __init__(self): # self.numeral and self.relativeroot defined in super().__init__() super().__init__() self.altchord = None - self.measure = None - self.beat = None self.totbeat = None - self.op = None - self.no = None - self.mov = None + # self.op = None # TODO + # self.no = None + # self.mov = None self.length = None - self.global_key = None + # self.global_key = None # TODO self.dcml_version = 1 @@ -407,6 +411,10 @@ def beat(self) -> float: # beat is zero-indexed in v2 but one-indexed in v1 return self.mn_onset + 1.0 + @beat.setter + def beat(self, beat: float): + self.mn_onset = beat - 1.0 if beat is not None else None + @property def measure(self) -> int: ''' @@ -415,6 +423,10 @@ def measure(self) -> int: ''' return int(self.mn) + @measure.setter + def measure(self, measure: int): + self.mn = int(measure) if measure is not None else None + @property def local_key(self) -> str: ''' @@ -477,20 +489,19 @@ class TsvHandler: def __init__(self, tsvFile: str, dcml_version: int = 1): if dcml_version == 1: self.heading_names = HEADERS[1] - self._tab_chord_cls = TabChord + 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.chordList = [] - self.m21stream = None - self.preparedStream = None - self._head_indices = None - self._extra_indices = 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() + 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. @@ -508,7 +519,7 @@ def _get_heading_indices(self, header_row: t.List[str]) -> None: else: self._extra_indices[i] = col_name - def importTsv(self) -> t.List[t.List[str]]: + def _importTsv(self) -> t.List[t.List[str]]: ''' Imports TSV file data for further processing. ''' @@ -565,15 +576,25 @@ def toM21Stream(self) -> stream.Score: ''' 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 m21Measure is None: + # TODO: m21Measure should never be None if prepStream is + # correctly implemented. We need to handle None to satisfy + # mypy. If it *is* None, then there is a bug in the + # implementation. What is correct behavior in this instance? + # Raise a bug? + raise ValueError thisM21Chord = thisChord.tabToM21() # In either case. # Store any otherwise unhandled attributes of the chord @@ -583,9 +604,10 @@ def toM21Stream(self) -> stream.Score: s.flatten().extendDuration(harmony.Harmony, inPlace=True) last_harmony = s[harmony.Harmony].last() - last_harmony.quarterLength = ( - s.quarterLength - last_harmony.activeSite.offset - last_harmony.offset - ) + if last_harmony is not None: + last_harmony.quarterLength = ( + s.quarterLength - last_harmony.activeSite.offset - last_harmony.offset + ) self.m21stream = s return s @@ -605,11 +627,13 @@ def prepStream(self) -> stream.Score: 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.opusNumber = firstEntry.extra.get('op', '') + s.metadata.number = firstEntry.extra.get('no', '') + s.metadata.movementNumber = firstEntry.extra.get('mov', '') s.metadata.title = ( - 'Op' + firstEntry.op + '_No' + firstEntry.no + '_Mov' + firstEntry.mov + 'Op' + firstEntry.extra.get('op', '') + + '_No' + firstEntry.extra.get('no', '') + + '_Mov' + firstEntry.extra.get('mov', '') ) startingKeySig = str(self.chordList[0].global_key) @@ -622,7 +646,7 @@ def prepStream(self) -> stream.Score: 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: @@ -633,7 +657,6 @@ def prepStream(self) -> stream.Score: m = stream.Measure(number=mNo) m.offset = currentOffset + currentMeasureLength p.insert(m) - currentOffset = m.offset previousMeasure = mNo else: # entry.measure = previousMeasure + 1 @@ -646,7 +669,6 @@ def prepStream(self) -> stream.Score: if entry.timesig != currentTimeSig: newTS = meter.TimeSignature(entry.timesig) m.insert(entry.beat - 1, newTS) - currentTimeSig = entry.timesig currentMeasureLength = newTS.barDuration.quarterLength @@ -654,8 +676,6 @@ def prepStream(self) -> stream.Score: s.append(p) - self.preparedStream = s - return s @@ -706,6 +726,9 @@ def _m21ToTsv_v1(self) -> t.List[t.List[str]]: ).key.tonicPitchNameWithCase for thisRN in self.m21Stream[roman.RomanNumeral]: + if thisRN is None: + # shouldn't occur, but to satisfy mypy + continue relativeroot = None if thisRN.secondaryRomanNumeral: @@ -723,10 +746,14 @@ def _m21ToTsv_v1(self) -> t.List[t.List[str]]: 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 + thisEntry.extra["no"] = self.m21Stream.metadata.number + thisEntry.extra["mov"] = self.m21Stream.metadata.movementNumber thisEntry.length = thisRN.quarterLength thisEntry.global_key = global_key thisEntry.local_key = thisRN.key.tonicPitchNameWithCase @@ -734,7 +761,11 @@ def _m21ToTsv_v1(self) -> t.List[t.List[str]]: thisEntry.numeral = thisRN.romanNumeral thisEntry.form = getForm(thisRN) # Strip any leading non-digits from figbass (e.g., M43 -> 43) - thisEntry.figbass = re.match(r'^\D*(\d.*|)', thisRN.figuresWritten).group(1) + figbassm = re.match(r'^\D*(\d.*|)', thisRN.figuresWritten) + if figbassm is not None: + thisEntry.figbass = figbassm.group(1) + else: + thisEntry.figbass = '' thisEntry.changes = None # TODO thisEntry.relativeroot = relativeroot thisEntry.phraseend = None @@ -748,10 +779,13 @@ def _m21ToTsv_v1(self) -> t.List[t.List[str]]: return tsvData def _m21ToTsv_v2(self) -> t.List[t.List[str]]: - tsvData = [] + tsvData: t.List[t.List[str]] = [] # take the global_key from the first item - global_key_obj = self.m21Stream[roman.RomanNumeral].first().key + 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] @@ -934,6 +968,9 @@ def getLocalKey(local_key: str, global_key: str, convertDCMLToM21: bool = 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') @@ -943,6 +980,8 @@ def getLocalKey(local_key: str, global_key: str, convertDCMLToM21: bool = False) >>> romanText.tsvConverter.getLocalKey('vii', 'a', convertDCMLToM21=True) 'g' + + ''' if convertDCMLToM21: local_key = characterSwaps(local_key, minor=isMinor(global_key[0]), direction='DCML-m21') diff --git a/music21/romanText/tsvEg_v2major.tsv b/music21/romanText/tsvEg_v2major.tsv index 28033747c1..3899ff2d0c 100644 --- a/music21/romanText/tsvEg_v2major.tsv +++ b/music21/romanText/tsvEg_v2major.tsv @@ -8,4 +8,6 @@ mc mn mc_onset mn_onset timesig staff voice volta label globalkey localkey pedal 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 142 141 0 0 4/4 #VII+/vi C I #VII+/vi #VII + vi + 0 0 +348 340 0 0 6/8 4 1 IV7 C I IV7 IV 7 Mm7 0 0 -1, 3, 0, -3 -1 -1 From 61348e11e9a3d0762c92288a6e9f2676fbd6773b Mon Sep 17 00:00:00 2001 From: Malcolm Sailor Date: Sat, 6 Aug 2022 08:27:56 -0400 Subject: [PATCH 17/22] handling Mm7 chords other than V7 --- music21/romanText/tsvConverter.py | 39 +++++++++++++++-------------- music21/romanText/tsvEg_v2major.tsv | 4 +-- 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/music21/romanText/tsvConverter.py b/music21/romanText/tsvConverter.py index 2e48b73532..8db43ffb33 100644 --- a/music21/romanText/tsvConverter.py +++ b/music21/romanText/tsvConverter.py @@ -261,6 +261,11 @@ def _changeRepresentation(self) -> None: self.form = self.form.replace('%', 'ø') if self.form is not None else None if self.dcml_version == 2: self.chord = self.chord.replace('%', 'ø') + if ( + self.extra["chord_type"] == "Mm7" and self.figbass == "7" + and self.numeral != 'V' + ): + self.chord = self.chord.replace("7", "d7") # Local - relative and figure if isMinor(self.local_key): if self.relativeroot: # If there's a relative root ... @@ -319,7 +324,7 @@ def tabToM21(self) -> harmony.Harmony: if self.representationType == 'DCML': self._changeRepresentation() if self.numeral in ('@none', None): - thisEntry: harmony.Harmony = harmony.NoChord() + thisEntry: harmony.Harmony = harmony.NoChord() else: if self.dcml_version == 2 and self.chord: combined = self.chord @@ -360,7 +365,7 @@ def tabToM21(self) -> harmony.Harmony: thisEntry.editorial.phraseend = None # if dcml_version == 2, we need to calculate the quarterLength # later - thisEntry.quarterLength = 0.0 # self.length if self.dcml_version == 1 else 0.0 TODO + thisEntry.quarterLength = 0.0 return thisEntry class TabChord(TabChordBase): @@ -373,11 +378,7 @@ def __init__(self): super().__init__() self.altchord = None self.totbeat = None - # self.op = None # TODO - # self.no = None - # self.mov = None self.length = None - # self.global_key = None # TODO self.dcml_version = 1 @@ -489,19 +490,19 @@ class TsvHandler: 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 + 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.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.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 + 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. @@ -579,7 +580,7 @@ def toM21Stream(self) -> stream.Score: 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 @@ -591,7 +592,7 @@ def toM21Stream(self) -> stream.Score: if m21Measure is None: # TODO: m21Measure should never be None if prepStream is # correctly implemented. We need to handle None to satisfy - # mypy. If it *is* None, then there is a bug in the + # mypy. If it *is* None, then there is a bug in the # implementation. What is correct behavior in this instance? # Raise a bug? raise ValueError @@ -631,9 +632,9 @@ def prepStream(self) -> stream.Score: s.metadata.number = firstEntry.extra.get('no', '') s.metadata.movementNumber = firstEntry.extra.get('mov', '') s.metadata.title = ( - 'Op' + firstEntry.extra.get('op', '') + - '_No' + firstEntry.extra.get('no', '') + - '_Mov' + firstEntry.extra.get('mov', '') + 'Op' + firstEntry.extra.get('op', '') + + '_No' + firstEntry.extra.get('no', '') + + '_Mov' + firstEntry.extra.get('mov', '') ) startingKeySig = str(self.chordList[0].global_key) @@ -646,7 +647,7 @@ def prepStream(self) -> stream.Score: currentMeasureLength = ts.barDuration.quarterLength - currentOffset: t.Union[float, fractions.Fraction] = 0.0 + currentOffset: t.Union[float, fractions.Fraction] = 0.0 previousMeasure: int = self.chordList[0].measure - 1 # Covers pickups for entry in self.chordList: @@ -779,7 +780,7 @@ def _m21ToTsv_v1(self) -> t.List[t.List[str]]: return tsvData def _m21ToTsv_v2(self) -> t.List[t.List[str]]: - tsvData: 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() diff --git a/music21/romanText/tsvEg_v2major.tsv b/music21/romanText/tsvEg_v2major.tsv index 3899ff2d0c..f578b46357 100644 --- a/music21/romanText/tsvEg_v2major.tsv +++ b/music21/romanText/tsvEg_v2major.tsv @@ -6,8 +6,8 @@ mc mn mc_onset mn_onset timesig staff voice volta label globalkey localkey pedal 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 +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 +121 121 0 0 3/4 4 1 V/vi C i V/vi V vi M 0 1 -3, 1, -2 -3 -3 142 141 0 0 4/4 #VII+/vi C I #VII+/vi #VII + vi + 0 0 348 340 0 0 6/8 4 1 IV7 C I IV7 IV 7 Mm7 0 0 -1, 3, 0, -3 -1 -1 From 2200431c18453a0372e79ed1fdd004fd819836ee Mon Sep 17 00:00:00 2001 From: Malcolm Sailor Date: Mon, 8 Aug 2022 09:43:30 -0400 Subject: [PATCH 18/22] handleAddedTones; d7 etc.; populate_from_row --- music21/romanText/tsvConverter.py | 105 +++++++++++++++++++++++++--- music21/romanText/tsvEg_v2major.tsv | 1 - 2 files changed, 94 insertions(+), 12 deletions(-) diff --git a/music21/romanText/tsvConverter.py b/music21/romanText/tsvConverter.py index 8db43ffb33..b085c74e6e 100644 --- a/music21/romanText/tsvConverter.py +++ b/music21/romanText/tsvConverter.py @@ -261,11 +261,13 @@ def _changeRepresentation(self) -> None: 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["chord_type"] == "Mm7" and self.figbass == "7" + self.extra.get("chord_type", '') == "Mm7" and self.numeral != 'V' ): - self.chord = self.chord.replace("7", "d7") + self.chord = re.sub(r'(\d)', r'd\1', self.chord) + # Local - relative and figure if isMinor(self.local_key): if self.relativeroot: # If there's a relative root ... @@ -368,6 +370,28 @@ def tabToM21(self) -> harmony.Harmony: thisEntry.quarterLength = 0.0 return thisEntry + def populate_from_row( + self, + row: t.List[str], + head_indices: t.Dict[str, t.Tuple[int, t.Type]], + extra_indices: 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 head_indices.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 extra_indices.items() if row[i] + } + class TabChord(TabChordBase): ''' An intermediate representation format for moving between tabular data in @@ -381,8 +405,6 @@ def __init__(self): self.length = None self.dcml_version = 1 - - class TabChordV2(TabChordBase): ''' An intermediate representation format for moving between tabular data in @@ -540,13 +562,14 @@ def _makeTabChord(self, row: t.List[str]) -> TabChordBase: ''' # this method replaces the previously stand-alone makeTabChord function thisEntry = self._tab_chord_cls() - 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' # Added + thisEntry.populate_from_row(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 return thisEntry @@ -891,6 +914,66 @@ def getForm(rn: roman.RomanNumeral) -> str: return '' +def handleAddedTones(dcml_chord: 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)/V') + 'Viio7[no3][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/.*)?', + dcml_chord + ) + if not m: + return dcml_chord + 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, str]] = list( + # after https://github.com/johentsch/ms3/blob/main/src/ms3/utils.py + re.findall(r"((\+|-)?(\^|v)?(#+|b+)?(1\d|\d))", added_tones) + ) + additions: t.List[str] = [] + omissions: t.List[str] = [] + if figure in ('', '5', '53', '5/3', '3'): + threshold = 7 + else: + threshold = 8 + for _, added_or_removed, above_or_below, alteration, factor in added_tone_tuples: + if added_or_removed == '-': + additions.append(f'[no{factor}]') + continue + if added_or_removed != '+' and int(factor) < threshold: + if above_or_below == 'v' or alteration in ('b', ''): + increment = -1 + else: + increment = 1 + replaced_factor = str(int(factor) + increment) + omissions.append(f'[no{replaced_factor}]') + 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 diff --git a/music21/romanText/tsvEg_v2major.tsv b/music21/romanText/tsvEg_v2major.tsv index f578b46357..c2ab696a1a 100644 --- a/music21/romanText/tsvEg_v2major.tsv +++ b/music21/romanText/tsvEg_v2major.tsv @@ -10,4 +10,3 @@ mc mn mc_onset mn_onset timesig staff voice volta label globalkey localkey pedal 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 142 141 0 0 4/4 #VII+/vi C I #VII+/vi #VII + vi + 0 0 -348 340 0 0 6/8 4 1 IV7 C I IV7 IV 7 Mm7 0 0 -1, 3, 0, -3 -1 -1 From 94c7623336c951bcc92fce9ca572f57dfae9208c Mon Sep 17 00:00:00 2001 From: Malcolm Sailor Date: Mon, 8 Aug 2022 12:57:02 -0400 Subject: [PATCH 19/22] fixed beats in v2 --- music21/romanText/tsvConverter.py | 25 +++++++++++++++++++------ music21/romanText/tsvEg_v1.tsv | 2 +- music21/romanText/tsvEg_v2major.tsv | 2 +- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/music21/romanText/tsvConverter.py b/music21/romanText/tsvConverter.py index b085c74e6e..d9673f1ee8 100644 --- a/music21/romanText/tsvConverter.py +++ b/music21/romanText/tsvConverter.py @@ -424,19 +424,26 @@ 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. This property reproduces - the former 'beat' by adding 1 to '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 = 1/2 + >>> tabCd.beat + 3.0 + >>> tabCd.beat = 1.5 + >>> tabCd.beat + 1.5 ''' # beat is zero-indexed in v2 but one-indexed in v1 - return self.mn_onset + 1.0 + # moreover, beat is in fractions of a whole-note in v2 + return self.mn_onset * 4.0 + 1.0 @beat.setter def beat(self, beat: float): - self.mn_onset = beat - 1.0 if beat is not None else None + self.mn_onset = (beat - 1.0) / 4.0 if beat is not None else None @property def measure(self) -> int: @@ -632,6 +639,7 @@ def toM21Stream(self) -> stream.Score: last_harmony.quarterLength = ( s.quarterLength - last_harmony.activeSite.offset - last_harmony.offset ) + self.m21stream = s return s @@ -816,7 +824,13 @@ def _m21ToTsv_v2(self) -> t.List[t.List[str]]: ): thisEntry = TabChordV2() thisEntry.mn = thisRN.measureNumber - thisEntry.mn_onset = thisRN.beat + # 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 = '' @@ -861,7 +875,6 @@ def _m21ToTsv_v2(self) -> t.List[t.List[str]]: getattr(thisEntry, name, thisRN.editorial.get(name, '')) for name in self.dcml_headers ] - tsvData.append(thisInfo) return tsvData diff --git a/music21/romanText/tsvEg_v1.tsv b/music21/romanText/tsvEg_v1.tsv index 10a4403211..96499b6f85 100644 --- a/music21/romanText/tsvEg_v1.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 index c2ab696a1a..9c4c4f1965 100644 --- a/music21/romanText/tsvEg_v2major.tsv +++ b/music21/romanText/tsvEg_v2major.tsv @@ -1,5 +1,5 @@ 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 0 2/4 .C.I6 C I I6 I FALSE +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 From e5b6c1a0e75716213477e961aebc010e5ae4b80b Mon Sep 17 00:00:00 2001 From: Malcolm Sailor Date: Mon, 8 Aug 2022 15:37:21 -0400 Subject: [PATCH 20/22] insert first timesig into first measure --- music21/romanText/tsvConverter.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/music21/romanText/tsvConverter.py b/music21/romanText/tsvConverter.py index d9673f1ee8..30beb21243 100644 --- a/music21/romanText/tsvConverter.py +++ b/music21/romanText/tsvConverter.py @@ -512,7 +512,7 @@ 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' ''' @@ -670,11 +670,9 @@ def prepStream(self) -> stream.Score: 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 @@ -707,7 +705,10 @@ def prepStream(self) -> stream.Score: previousMeasure = entry.measure s.append(p) - + first_measure = s[stream.Measure].first() + if first_measure is not None: + first_measure.insert(0, ks) + first_measure.insert(0, ts) return s @@ -1182,7 +1183,8 @@ def testTsvHandler(self): # M21 stream out_stream = handler.toM21Stream() self.assertEqual( - out_stream.parts[0].measure(1)[0].figure, 'I6' if version == 2 else 'I' + 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 From f068d5fa7e3266019fe2e3c082fd3a46eb88a38e Mon Sep 17 00:00:00 2001 From: Malcolm Sailor Date: Tue, 9 Aug 2022 07:53:51 -0400 Subject: [PATCH 21/22] linting etc. --- music21/romanText/tsvConverter.py | 147 ++++++++++++---------------- music21/romanText/tsvEg_v2major.tsv | 1 + 2 files changed, 65 insertions(+), 83 deletions(-) diff --git a/music21/romanText/tsvConverter.py b/music21/romanText/tsvConverter.py index 30beb21243..e0b8aef040 100644 --- a/music21/romanText/tsvConverter.py +++ b/music21/romanText/tsvConverter.py @@ -175,7 +175,7 @@ def __init__(self): self.numeral = None self.relativeroot = None self.representationType = None # Added (not in DCML) - self.extra = {} + self.extra: t.Dict[str, str] = {} self.dcml_version = -1 # shared between DCML v1 and v2 @@ -217,27 +217,30 @@ def combinedChord(self, value: str): def _changeRepresentation(self) -> None: ''' - Converts the representationType of a TabChord between the music21 and DCML conventions, - especially for the different handling of expectations in minor. + Converts the representationType of a TabChordBase subclass between the + music21 and DCML conventions. - First, let's set up a TabChord(). + To demonstrate, let's set up a dummy TabChordV2(). - >>> tabCd = romanText.tsvConverter.TabChord() + >>> 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.chord + 'ii%7(6)' + >>> tabCd._changeRepresentation() >>> tabCd.representationType 'm21' + + >>> tabCd.chord + 'iiø7[no5][add6]' ''' if self.representationType == 'm21': @@ -263,7 +266,7 @@ def _changeRepresentation(self) -> None: self.chord = self.chord.replace('%', 'ø') self.chord = handleAddedTones(self.chord) if ( - self.extra.get("chord_type", '') == "Mm7" + self.extra.get('chord_type', '') == 'Mm7' and self.numeral != 'V' ): self.chord = re.sub(r'(\d)', r'd\1', self.chord) @@ -334,11 +337,11 @@ def tabToM21(self) -> harmony.Harmony: # 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] + attr for attr in (self.numeral, self.form, self.figbass) if attr ) if self.relativeroot: # special case requiring '/'. - combined = ''.join([combined, '/', self.relativeroot]) + combined += '/' + self.relativeroot if self.local_key is not None and re.match( r'.*(i*v|v?i+).*', self.local_key, re.IGNORECASE ): @@ -359,9 +362,9 @@ def tabToM21(self) -> harmony.Harmony: 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.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 @@ -370,26 +373,26 @@ def tabToM21(self) -> harmony.Harmony: thisEntry.quarterLength = 0.0 return thisEntry - def populate_from_row( + def populateFromRow( self, row: t.List[str], - head_indices: t.Dict[str, t.Tuple[int, t.Type]], - extra_indices: t.Dict[int, 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"] + # 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 head_indices.items(): + 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 extra_indices.items() if row[i] + col_name: row[i] for i, col_name in extraIndices.items() if row[i] } class TabChord(TabChordBase): @@ -426,13 +429,16 @@ def beat(self) -> float: '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 = 1/2 + + >>> tabCd.mn_onset = 0.5 >>> tabCd.beat 3.0 + >>> tabCd.beat = 1.5 >>> tabCd.beat 1.5 @@ -569,7 +575,7 @@ def _makeTabChord(self, row: t.List[str]) -> TabChordBase: ''' # this method replaces the previously stand-alone makeTabChord function thisEntry = self._tab_chord_cls() - thisEntry.populate_from_row(row, self._head_indices, self._extra_indices) + 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])) @@ -620,12 +626,7 @@ def toM21Stream(self) -> stream.Score: measureNumber = thisChord.measure m21Measure = p.measure(measureNumber) if m21Measure is None: - # TODO: m21Measure should never be None if prepStream is - # correctly implemented. We need to handle None to satisfy - # mypy. If it *is* None, then there is a bug in the - # implementation. What is correct behavior in this instance? - # Raise a bug? - raise ValueError + raise ValueError('m21Measure should not be None') thisM21Chord = thisChord.tabToM21() # In either case. # Store any otherwise unhandled attributes of the chord @@ -659,14 +660,18 @@ def prepStream(self) -> stream.Score: s.insert(0, metadata.Metadata()) firstEntry = self.chordList[0] # Any entry will do - s.metadata.opusNumber = firstEntry.extra.get('op', '') - s.metadata.number = firstEntry.extra.get('no', '') - s.metadata.movementNumber = firstEntry.extra.get('mov', '') - s.metadata.title = ( - 'Op' + firstEntry.extra.get('op', '') - + '_No' + firstEntry.extra.get('no', '') - + '_Mov' + firstEntry.extra.get('mov', '') - ) + 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) @@ -759,9 +764,6 @@ def _m21ToTsv_v1(self) -> t.List[t.List[str]]: ).key.tonicPitchNameWithCase for thisRN in self.m21Stream[roman.RomanNumeral]: - if thisRN is None: - # shouldn't occur, but to satisfy mypy - continue relativeroot = None if thisRN.secondaryRomanNumeral: @@ -784,9 +786,9 @@ def _m21ToTsv_v1(self) -> t.List[t.List[str]]: thisEntry.timesig = '' else: thisEntry.timesig = ts.ratioString - thisEntry.extra["op"] = self.m21Stream.metadata.opusNumber - thisEntry.extra["no"] = self.m21Stream.metadata.number - thisEntry.extra["mov"] = self.m21Stream.metadata.movementNumber + 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 = global_key thisEntry.local_key = thisRN.key.tonicPitchNameWithCase @@ -794,9 +796,9 @@ def _m21ToTsv_v1(self) -> t.List[t.List[str]]: 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) - if figbassm is not None: - thisEntry.figbass = figbassm.group(1) + 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 @@ -898,7 +900,7 @@ def write(self, filePathAndName: str): def getForm(rn: roman.RomanNumeral) -> str: ''' Takes a music21.roman.RomanNumeral object and returns the string indicating - "form" expected by the DCML standard. + 'form' expected by the DCML standard. >>> romanText.tsvConverter.getForm(roman.RomanNumeral('V')) '' @@ -928,7 +930,7 @@ def getForm(rn: roman.RomanNumeral) -> str: return '' -def handleAddedTones(dcml_chord: str) -> str: +def handleAddedTones(dcmlChord: str) -> str: ''' Converts DCML added-tone syntax to music21. @@ -938,8 +940,8 @@ def handleAddedTones(dcml_chord: str) -> str: >>> romanText.tsvConverter.handleAddedTones('i(4+2)') 'i[no3][add4][add2]' - >>> romanText.tsvConverter.handleAddedTones('Viio7(b4)/V') - 'Viio7[no3][addb4]/V' + >>> 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)') @@ -953,10 +955,10 @@ def handleAddedTones(dcml_chord: str) -> str: ''' m = re.match( r'(?P.*?(?P
\d*(?:/\d+)*))\((?P.*)\)(?P/.*)?', - dcml_chord + dcmlChord ) if not m: - return dcml_chord + return dcmlChord primary = m.group('primary') added_tones = m.group('added_tones') secondary = m.group('secondary') if m.group('secondary') is not None else '' @@ -965,7 +967,7 @@ def handleAddedTones(dcml_chord: str) -> str: return 'Cad64' + secondary added_tone_tuples: t.List[t.Tuple[str, str, str, str, str]] = list( # after https://github.com/johentsch/ms3/blob/main/src/ms3/utils.py - re.findall(r"((\+|-)?(\^|v)?(#+|b+)?(1\d|\d))", added_tones) + re.findall(r'((\+|-)?(\^|v)?(#+|b+)?(1\d|\d))', added_tones) ) additions: t.List[str] = [] omissions: t.List[str] = [] @@ -975,7 +977,7 @@ def handleAddedTones(dcml_chord: str) -> str: threshold = 8 for _, added_or_removed, above_or_below, alteration, factor in added_tone_tuples: if added_or_removed == '-': - additions.append(f'[no{factor}]') + omissions.append(f'[no{factor}]') continue if added_or_removed != '+' and int(factor) < threshold: if above_or_below == 'v' or alteration in ('b', ''): @@ -985,7 +987,7 @@ def handleAddedTones(dcml_chord: str) -> str: replaced_factor = str(int(factor) + increment) omissions.append(f'[no{replaced_factor}]') additions.append(f'[add{alteration}{factor}]') - return primary + "".join(omissions) + "".join(additions) + secondary + return primary + ''.join(omissions) + ''.join(additions) + secondary def localKeyAsRn(local_key: key.Key, global_key: key.Key) -> str: @@ -1056,7 +1058,7 @@ def characterSwaps(preString: str, minor: bool = True, direction: str = 'm21-DCM return preString -def getLocalKey(local_key: str, global_key: str, convertDCMLToM21: bool = 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'). @@ -1206,33 +1208,11 @@ def testTsvHandler(self): # Ensure that both m21 streams are the same self.assertEqual(len(stream1.recurse()), len(stream2.recurse())) for i, (item1, item2) in enumerate(zip( - stream1.recurse().getElementsByClass(harmony.Harmony), - stream2.recurse().getElementsByClass(harmony.Harmony) + stream1[harmony.Harmony], stream2[harmony.Harmony] )): - try: - self.assertEqual( - item1, item2, msg=f'item {i}, version {version}: {item1} != {item2}' - ) - except AssertionError: - # Augmented sixth figures will not agree, e.g., - # - Ger6 becomes Ger65 - # - Fr6 becomes Fr43 - # This doesn't seem important, but we can at least - # assert that both items are augmented sixth chords of - # the same type. - m = re.match('Ger|Fr', item1.figure) - self.assertIsNotNone(m) - aug6_type = m.group(0) - self.assertTrue(item2.figure.startswith(aug6_type)) - # Checking for quarterLength as per - # https://github.com/cuthbertLab/music21/pull/1267#discussion_r936451907 - # However I'm not sure that 'quarterLength' is meaningful - # in the case of V2 where it is not set explicitly. - self.assertTrue( - hasattr(item1, 'quarterLength') - and isinstance(item1.quarterLength, float) + self.assertEqual( + item1, item2, msg=f'item {i}, version {version}: {item1} != {item2}' ) - # if version == 2: first_harmony = stream1[harmony.Harmony].first() first_offset = first_harmony.activeSite.offset + first_harmony.offset self.assertEqual( @@ -1307,6 +1287,7 @@ def testGetSecondaryKey(self): self.assertIsInstance(veryLocalKey, str) self.assertEqual(veryLocalKey, 'b') + # ------------------------------------------------------------------------------ diff --git a/music21/romanText/tsvEg_v2major.tsv b/music21/romanText/tsvEg_v2major.tsv index 9c4c4f1965..2ab24733bd 100644 --- a/music21/romanText/tsvEg_v2major.tsv +++ b/music21/romanText/tsvEg_v2major.tsv @@ -9,4 +9,5 @@ mc mn mc_onset mn_onset timesig staff voice volta label globalkey localkey pedal 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 From b1de9e8332401af11410eee1a6f93a14306ed1b0 Mon Sep 17 00:00:00 2001 From: Malcolm Sailor Date: Thu, 11 Aug 2022 08:03:05 -0400 Subject: [PATCH 22/22] verbose regex and other refinements to handleAddedTones --- music21/romanText/tsvConverter.py | 50 +++++++++++++++++++++---------- 1 file changed, 34 insertions(+), 16 deletions(-) diff --git a/music21/romanText/tsvConverter.py b/music21/romanText/tsvConverter.py index e0b8aef040..89de6d352f 100644 --- a/music21/romanText/tsvConverter.py +++ b/music21/romanText/tsvConverter.py @@ -269,7 +269,8 @@ def _changeRepresentation(self) -> None: self.extra.get('chord_type', '') == 'Mm7' and self.numeral != 'V' ): - self.chord = re.sub(r'(\d)', r'd\1', self.chord) + # 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 isMinor(self.local_key): @@ -671,7 +672,7 @@ def prepStream(self) -> stream.Score: s.metadata.movementNumber = firstEntry.extra['mov'] title.append('Mov' + s.metadata.movementNumber) if title: - s.metadata.title = "_".join(title) + s.metadata.title = '_'.join(title) startingKeySig = str(self.chordList[0].global_key) ks = key.Key(startingKeySig) @@ -965,27 +966,44 @@ def handleAddedTones(dcmlChord: str) -> str: 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, str]] = list( - # after https://github.com/johentsch/ms3/blob/main/src/ms3/utils.py - re.findall(r'((\+|-)?(\^|v)?(#+|b+)?(1\d|\d))', added_tones) + 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'): - threshold = 7 + if figure in ('', '5', '53', '5/3', '3', '7'): + omission_threshold = 7 else: - threshold = 8 - for _, added_or_removed, above_or_below, alteration, factor in added_tone_tuples: + 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}]') + omissions.append(f'[no{factor_str}]') continue - if added_or_removed != '+' and int(factor) < threshold: - if above_or_below == 'v' or alteration in ('b', ''): - increment = -1 + 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: - increment = 1 - replaced_factor = str(int(factor) + increment) - omissions.append(f'[no{replaced_factor}]') + omissions.append(f'[no{factor - 1}]') additions.append(f'[add{alteration}{factor}]') return primary + ''.join(omissions) + ''.join(additions) + secondary