From 6bf9a4d43833889e521cd4766b79641139c3eef3 Mon Sep 17 00:00:00 2001 From: Christian Perez Date: Wed, 22 Jul 2026 18:54:55 -0500 Subject: [PATCH] Support sus4 chords in root-finding algorithm Add sus4 chord detection to _findRoot's rootnessFunction. When no note in the chord has both a third and fifth above it (indicating a sus4 chord), apply a bonus to notes that have a fourth and fifth above them. Fixes: chord.Chord('F B- C E-').root() now correctly returns F instead of C. Resolves #1650 --- music21/chord/__init__.py | 18 ++++++++++++++++-- music21/test/test_chord.py | 25 +++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/music21/chord/__init__.py b/music21/chord/__init__.py index e024ed190..60824df06 100644 --- a/music21/chord/__init__.py +++ b/music21/chord/__init__.py @@ -1668,7 +1668,7 @@ def _findRoot(self) -> pitch.Pitch: Generally use root() instead, since if a chord doesn't know its root, root() will run ._findRoot() automatically. ''' - def rootnessFunction(rootThirdList): + def rootnessFunction(rootThirdList, is_sus4_chord=False): ''' Returns a value for how likely this pitch is to be a root given the number of thirds and fifths above it. @@ -1687,6 +1687,14 @@ def rootnessFunction(rootThirdList): for root_index, val in enumerate(rootThirdList): if val is True: score += 1 / (root_index + 6) + # sus4 bonus: for sus4 chords (no note has both a 3rd and 5th above it), + # reward a note that has a 4th (index 4) and 5th (index 1) above it. + # orderedChordSteps = (3, 5, 7, 2, 4, 6) so index 0=3rd, 1=5th, 4=4th + if is_sus4_chord: + has_fifth = rootThirdList[1] if len(rootThirdList) > 1 else False + has_fourth = rootThirdList[4] if len(rootThirdList) > 4 else False + if has_fourth and has_fifth: + score += 1 / 6 # same weight as having a third return score # FIND ROOT FAST -- for cases where one note has perfectly stacked @@ -1735,6 +1743,12 @@ def rootnessFunction(rootThirdList): rootnessFunctionScores = [] orderedChordSteps = (3, 5, 7, 2, 4, 6) + # Detect sus4 chords: no note has both a 3rd (step+2) and 5th (step+4) above it + is_sus4_chord = not any( + (sn + 2) % 7 in stepNumsToPitches and (sn + 4) % 7 in stepNumsToPitches + for sn in stepNums + ) + for p in nonDuplicatingPitches: currentListOfThirds = [] this_step_num = pitch.STEP_TO_DNN_OFFSET[p.step] @@ -1744,7 +1758,7 @@ def rootnessFunction(rootThirdList): else: currentListOfThirds.append(False) - rootnessScore = rootnessFunction(currentListOfThirds) + rootnessScore = rootnessFunction(currentListOfThirds, is_sus4_chord=is_sus4_chord) rootnessFunctionScores.append(rootnessScore) mostRootyIndex = rootnessFunctionScores.index(max(rootnessFunctionScores)) diff --git a/music21/test/test_chord.py b/music21/test/test_chord.py index 3c06f08ac..55c6c3f35 100644 --- a/music21/test/test_chord.py +++ b/music21/test/test_chord.py @@ -362,6 +362,31 @@ def testConstruction(self): self.assertEqual(unscrambledChord3.pitches[3].name, 'F') self.assertEqual(unscrambledChord3.pitches[4].name, 'A-') + def testSus4RootFinding(self): + # Regression test for https://github.com/cuthbertLab/music21/issues/1650 + # sus4 chords should correctly identify their root + + # Basic sus4 seventh chord from the issue report + sus4_seventh = chord.Chord('F B- C E-') + self.assertEqual(sus4_seventh.root().name, 'F') + + # Simple sus4 triad + sus4_triad = chord.Chord('C F G') + self.assertEqual(sus4_triad.root().name, 'C') + + # sus4 in different inversion (F in bass) + sus4_inv = chord.Chord('C F G B-') + self.assertEqual(sus4_inv.root().name, 'C') + + # Regular chords should still work correctly + self.assertEqual(chord.Chord('C E G').root().name, 'C') + self.assertEqual(chord.Chord('C E- G').root().name, 'C') + self.assertEqual(chord.Chord('C E G B-').root().name, 'C') + + # C11 chord (has both 3rd and 11th) should still return C, not F + c11 = chord.Chord('C E G B- D F') + self.assertEqual(c11.root().name, 'C') + def testEnharmonicSimplification(self): eFlat = note.Note(63) self.assertEqual(eFlat.pitch.name, 'E-')