Skip to content

Commit ab8e2f5

Browse files
committed
Roundtrip inverted chord symbols properly
Fixes #1756 Allow ChordSymbols to specify inversion and bass and round-trip them properly. Adds several missing tests and corner case coverage
1 parent 36b00ae commit ab8e2f5

7 files changed

Lines changed: 80 additions & 23 deletions

File tree

music21/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@
5050
'''
5151
from __future__ import annotations
5252

53-
__version__ = '9.6.0b3'
53+
__version__ = '9.6.0b4'
5454

5555
def get_version_tuple(vv):
5656
v = vv.split('.')

music21/base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
<class 'music21.base.Music21Object'>
2828
2929
>>> music21.VERSION_STR
30-
'9.6.0b3'
30+
'9.6.0b4'
3131
3232
Alternatively, after doing a complete import, these classes are available
3333
under the module "base":

music21/chord/__init__.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2318,7 +2318,7 @@ def inversion(
23182318
*,
23192319
find: bool = True,
23202320
testRoot: pitch.Pitch|None = None,
2321-
transposeOnSet: bool = True
2321+
transposeOnSet: bool = True,
23222322
) -> int|None:
23232323
'''
23242324
Find the chord's inversion or (if called with a number) set the chord to
@@ -2393,7 +2393,6 @@ def inversion(
23932393
Traceback (most recent call last):
23942394
music21.chord.ChordException: Could not invert chord: inversion may not exist
23952395
2396-
23972396
If testRoot is True then that temporary root is used instead of self.root().
23982397
23992398
Get the inversion for a seventh chord showing different roots
@@ -2430,7 +2429,7 @@ def inversion(
24302429
sets the value to be returned later, which might be useful for
24312430
cases where the chords are poorly spelled, or there is an added note.
24322431
2433-
* Changed in v8: chords without pitches
2432+
* Changed in v8: deal with chords without pitches
24342433
'''
24352434
if not self.pitches:
24362435
return -1
@@ -2460,11 +2459,12 @@ def inversion(
24602459
else:
24612460
return -1
24622461

2463-
def _setInversion(self,
2464-
newInversion: int,
2465-
rootPitch: pitch.Pitch,
2466-
transposeOnSet: bool
2467-
) -> None:
2462+
def _setInversion(
2463+
self,
2464+
newInversion: int,
2465+
rootPitch: pitch.Pitch,
2466+
transposeOnSet: bool,
2467+
) -> None:
24682468
'''
24692469
Helper function for inversion(int)
24702470
'''

music21/harmony.py

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -263,38 +263,47 @@ def _reprInternal(self):
263263
summary += ' '.join([p.name for p in self.pitches])
264264
return summary
265265

266-
# PRIVATE METHODS #
266+
# PROTECTED METHODS #
267267
def _parseFigure(self):
268268
'''
269-
subclass this in extensions (SO WHY IS IT PRIVATE???)
269+
subclass this in extensions (protected in TypeScript/Java speak)
270270
'''
271271
return
272272

273273
def _updatePitches(self):
274274
'''
275-
subclass this in extensions (SO WHY IS IT PRIVATE???)
275+
subclass this in extensions (protected in TypeScript/Java speak)
276276
'''
277277
return
278278

279-
def _updateFromParameters(self, root, bass, inversion: int|None = None):
279+
def _updateFromParameters(
280+
self,
281+
root: str|pitch.Pitch|None,
282+
bass: str|pitch.Pitch|None,
283+
inversion: int|None = None
284+
) -> None:
280285
'''
281286
This method must be called twice, once before the pitches
282287
are rendered, and once after. This is because after the pitches
283-
are rendered, the root() and bass() becomes reset by the chord class,
288+
are rendered, the root() and bass() become reset by the chord class,
284289
but we want the objects to retain their initial root, bass, and inversion.
285290
'''
286291
if root and isinstance(root, str):
287292
root = common.cleanedFlatNotation(root)
288293
self.root(pitch.Pitch(root, octave=3))
289294
elif root is not None:
290295
self.root(root)
296+
297+
# set inversion first...
298+
if inversion is not None:
299+
self.inversion(inversion, transposeOnSet=True)
300+
301+
# and then bass.
291302
if bass and isinstance(bass, str):
292303
bass = common.cleanedFlatNotation(bass)
293304
self.bass(pitch.Pitch(bass, octave=3), allow_add=True)
294305
elif bass is not None:
295306
self.bass(bass, allow_add=True)
296-
if inversion is not None:
297-
self.inversion(inversion, transposeOnSet=True)
298307

299308
# PUBLIC PROPERTIES #
300309

@@ -1609,7 +1618,7 @@ class ChordSymbol(Harmony):
16091618
# INITIALIZER #
16101619

16111620
def __init__(self,
1612-
figure=None,
1621+
figure: str|None = None,
16131622
root: pitch.Pitch|str|None = None,
16141623
bass: pitch.Pitch|str|None = None,
16151624
inversion: int|None = None,
@@ -2473,7 +2482,13 @@ class NoChord(ChordSymbol):
24732482
>>> nc2.pitches
24742483
()
24752484
'''
2476-
def __init__(self, figure=None, kind='none', kindStr=None, **keywords):
2485+
def __init__(
2486+
self,
2487+
figure: str|None = None,
2488+
kind: str|None = 'none',
2489+
kindStr: str|None = None,
2490+
**keywords
2491+
):
24772492
super().__init__(figure, kind=kind, kindStr=kindStr or figure or 'N.C.', **keywords)
24782493

24792494
if self._figure is None:
@@ -2655,6 +2670,17 @@ def testChordSymbolSetsBassOctave(self):
26552670
b = d.bass()
26562671
self.assertEqual(b.nameWithOctave, 'E-3')
26572672

2673+
def testHarmonyPreservesInversionAndBass(self):
2674+
'''
2675+
Test that bass is preserved even when both bass and inversion are given
2676+
'''
2677+
explicitFm6 = ChordSymbol(root='F', bass='A-', inversion=1, kind='minor')
2678+
self.assertEqual(explicitFm6.inversion(), 1)
2679+
self.assertEqual(explicitFm6.bass(find=False).name, 'A-')
2680+
self.assertEqual(explicitFm6.root(find=False).name, 'F')
2681+
self.assertLess(explicitFm6.bass(find=False).octave,
2682+
explicitFm6.root(find=False).octave)
2683+
26582684
def testClassSortOrderHarmony(self):
26592685
'''
26602686
This tests a former bug in getContextByClass

music21/musicxml/m21ToXml.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5951,7 +5951,6 @@ def chordSymbolToXml(self,
59515951
self.setPrintStyle(mxHarmony, cs)
59525952

59535953
csRoot = cs.root()
5954-
csBass = cs.bass(find=False)
59555954
# TODO: do not look at ._attributes
59565955
if cs._roman is not None:
59575956
mxFunction = SubElement(mxHarmony, 'function')
@@ -5994,6 +5993,9 @@ def chordSymbolToXml(self,
59945993
mxInversion = SubElement(mxHarmony, 'inversion')
59955994
mxInversion.text = str(csInv)
59965995

5996+
# first -- go with the already defined bass, from overrides, etc.
5997+
# but if that is not defined, then find the bass itself.
5998+
csBass = cs.bass(find=False) or cs.bass(find=True)
59975999
if csBass is not None and (csRoot is None or csRoot.name != csBass.name):
59986000
# TODO.. reuse above from Root
59996001
mxBass = SubElement(mxHarmony, 'bass')

music21/musicxml/test_m21ToXml.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
GeneralObjectExporter, ScoreExporter,
3737
MusicXMLWarning, MusicXMLExportException
3838
)
39+
from music21.musicxml.xmlToM21 import MeasureParser
3940

4041
def stripInnerSpaces(txt: str):
4142
'''
@@ -305,6 +306,34 @@ def testCompositeLyrics(self):
305306
self.assertEqual(ly2.findall('syllabic')[1].text, 'end')
306307
self.assertEqual(ly2.findall('text')[1].text, 'e')
307308

309+
def testExportChordSymbolWithInversions(self):
310+
'''
311+
This test checks for the issue in 1756 where a chord symbol imported
312+
with both inversion and bass would not have them both set.
313+
314+
There were multiple bugs fixed, so important to check.
315+
'''
316+
explicitFm6 = harmony.ChordSymbol(root='F', bass='A-', inversion=1, kind='minor')
317+
et = self.getET(explicitFm6)
318+
harmonyEls = et.findall('part/measure/harmony')
319+
self.assertEqual(len(harmonyEls), 1)
320+
harmonyEl = harmonyEls[0]
321+
# helpers.dump(harmonyEl)
322+
root = harmonyEl.find('root')
323+
rootStep = root.find('root-step')
324+
self.assertEqual(rootStep.text, 'F')
325+
self.assertEqual(harmonyEl.find('kind').text, 'minor')
326+
self.assertEqual(harmonyEl.find('inversion').text, '1')
327+
self.assertEqual(harmonyEl.find('bass/bass-step').text, 'A')
328+
self.assertEqual(harmonyEl.find('bass/bass-alter').text, '-1')
329+
330+
# test round trip
331+
mp = MeasureParser()
332+
cs = mp.xmlToChordSymbol(harmonyEl)
333+
self.assertEqual(explicitFm6.bass(find=False), cs.bass(find=False))
334+
self.assertEqual(explicitFm6.root(find=False), cs.root(find=False))
335+
self.assertEqual(explicitFm6.inversion(), cs.inversion())
336+
308337
def testExportNC(self):
309338
s = stream.Score()
310339
p = stream.Part()

music21/musicxml/xmlToM21.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5192,8 +5192,8 @@ def xmlToChordSymbol(
51925192
<music21.pitch.Pitch D-3>
51935193
'''
51945194
# TODO: musicxml 4: attr: arrangement -- C/E or C over E etc.
5195-
# TODO: offset
5196-
# Element staff is covered by insertCoreAndReference in xmlHarmony()
5195+
# Element offset is covered by xmlHarmony(), which calls this.
5196+
# Element staff is also covered by insertCoreAndReference in xmlHarmony()
51975197
b: pitch.Pitch|None = None
51985198
r: pitch.Pitch|None = None
51995199
inversion: int|None = None
@@ -5206,7 +5206,7 @@ def xmlToChordSymbol(
52065206

52075207
mxFrame = mxHarmony.find('frame')
52085208

5209-
mxBass = mxHarmony.find('bass')
5209+
mxBass: ET.Element | None = mxHarmony.find('bass')
52105210
if mxBass is not None:
52115211
# required
52125212
bassStep = mxBass.find('bass-step')

0 commit comments

Comments
 (0)