Skip to content

Commit 41b6b6f

Browse files
authored
Merge pull request #1775 from cuthbertLab/chord_symbol_rt
Roundtrip inverted chord symbols properly
2 parents ed245aa + ab8e2f5 commit 41b6b6f

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
@@ -6022,7 +6022,6 @@ def chordSymbolToXml(self,
60226022
self.setPrintStyle(mxHarmony, cs)
60236023

60246024
csRoot = cs.root()
6025-
csBass = cs.bass(find=False)
60266025
# TODO: do not look at ._attributes
60276026
if cs._roman is not None:
60286027
mxFunction = SubElement(mxHarmony, 'function')
@@ -6065,6 +6064,9 @@ def chordSymbolToXml(self,
60656064
mxInversion = SubElement(mxHarmony, 'inversion')
60666065
mxInversion.text = str(csInv)
60676066

6067+
# first -- go with the already defined bass, from overrides, etc.
6068+
# but if that is not defined, then find the bass itself.
6069+
csBass = cs.bass(find=False) or cs.bass(find=True)
60686070
if csBass is not None and (csRoot is None or csRoot.name != csBass.name):
60696071
# TODO.. reuse above from Root
60706072
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
@@ -5252,8 +5252,8 @@ def xmlToChordSymbol(
52525252
<music21.pitch.Pitch D-3>
52535253
'''
52545254
# TODO: musicxml 4: attr: arrangement -- C/E or C over E etc.
5255-
# TODO: offset
5256-
# Element staff is covered by insertCoreAndReference in xmlHarmony()
5255+
# Element offset is covered by xmlHarmony(), which calls this.
5256+
# Element staff is also covered by insertCoreAndReference in xmlHarmony()
52575257
b: pitch.Pitch|None = None
52585258
r: pitch.Pitch|None = None
52595259
inversion: int|None = None
@@ -5266,7 +5266,7 @@ def xmlToChordSymbol(
52665266

52675267
mxFrame = mxHarmony.find('frame')
52685268

5269-
mxBass = mxHarmony.find('bass')
5269+
mxBass: ET.Element | None = mxHarmony.find('bass')
52705270
if mxBass is not None:
52715271
# required
52725272
bassStep = mxBass.find('bass-step')

0 commit comments

Comments
 (0)