From 44e5b2307dbf0130836c4e6dc97757139c968a7c Mon Sep 17 00:00:00 2001
From: Malcolm Sailor <malcolm.sailor@gmail.com>
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 <malcolm.sailor@gmail.com>
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<numer>\d+(?:\.\d+)?)/(?P<denom>\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 <malcolm.sailor@gmail.com>
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<numer>\d+(?:\.\d+)?)/(?P<denom>\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 <malcolm.sailor@gmail.com>
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 <malcolm.sailor@gmail.com>
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 <malcolm.sailor@gmail.com>
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<numer>\d+(?:\.\d+)?)/(?P<denom>\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<numer>\d+(?:\.\d+)?)/(?P<denom>\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 <malcolm.sailor@gmail.com>
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<numeral>\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 <malcolm.sailor@gmail.com>
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<numeral>\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 <malcolm.sailor@gmail.com>
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<numer>\d+(?:\.\d+)?)/(?P<denom>\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 <malcolm.sailor@gmail.com>
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<numer>\d+(?:\.\d+)?)/(?P<denom>\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<numer>\d+(?:\.\d+)?)/(?P<denom>\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 <malcolm.sailor@gmail.com>
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 <malcolm.sailor@gmail.com>
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 <malcolm.sailor@gmail.com>
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 <malcolm.sailor@gmail.com>
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 <malcolm.sailor@gmail.com>
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 <malcolm.sailor@gmail.com>
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 <malcolm.sailor@gmail.com>
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 <malcolm.sailor@gmail.com>
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<primary>.*?(?P<figure>\d*(?:/\d+)*))\((?P<added_tones>.*)\)(?P<secondary>/.*)?',
+        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 <malcolm.sailor@gmail.com>
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 <malcolm.sailor@gmail.com>
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 <malcolm.sailor@gmail.com>
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<primary>.*?(?P<figure>\d*(?:/\d+)*))\((?P<added_tones>.*)\)(?P<secondary>/.*)?',
-        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 <malcolm.sailor@gmail.com>
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