Skip to content

Commit 29a4c4d

Browse files
committed
Added add4 chords and fixed bug with chords having multiple roots
1 parent 227c89e commit 29a4c4d

3 files changed

Lines changed: 62 additions & 1 deletion

File tree

Sources/Tonic/Chord.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,20 @@ extension Chord {
221221
var usedNoteArrays: [[Note]] = [enharmonicNoteArray]
222222
var foundNotes: [Note] = []
223223
foundNotes.append(rootNote)
224+
225+
// The root's pitch class can appear at several octaves (guitar
226+
// voicings routinely double the root). The interval search below
227+
// never looks for a unison/octave, so consume those duplicates
228+
// up front. Otherwise they're never matched and `foundNotes`
229+
// can't reach `pitchSet.count`, which silently defeats detection
230+
// whenever every pitch class in the voicing is doubled.
231+
for noteArray in enharmonicNoteArrays where !usedNoteArrays.contains(noteArray) {
232+
if let duplicateRoot = noteArray.first(where: { $0.noteClass == rootNote.noteClass }) {
233+
foundNotes.append(duplicateRoot)
234+
usedNoteArrays.append(noteArray)
235+
}
236+
}
237+
224238
for nextIntervals in chordSearchIntervalArray {
225239
var foundNote = false
226240
for nextInterval in nextIntervals {

Sources/Tonic/ChordType.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,9 @@ public enum ChordType: String, Sendable, CaseIterable, Codable {
9696
/// Major Add Nine: Major Third, Perfect Fifth, Major Ninth, e.g. `C(add9)`
9797
case maj_add9
9898

99+
/// Major Add Four: Major Third, Perfect Fifth, Perfect Eleventh, e.g. `C(add4)`
100+
case maj_add4
101+
99102
/// Minor Add Nine: Minor Third, Perfect Fifth, Major Ninth, e.g. `Cm(add9)`
100103
case min_add9
101104

@@ -629,6 +632,8 @@ public enum ChordType: String, Sendable, CaseIterable, Codable {
629632
return [.m3, .P5, .m7, .m9]
630633
case .maj_add9:
631634
return [.M3, .P5, .M9]
635+
case .maj_add4:
636+
return [.M3, .P5, .P11]
632637
case .min_add9:
633638
return [.m3, .P5, .M9]
634639
case .dim_add9:
@@ -946,6 +951,7 @@ extension ChordType: CustomStringConvertible {
946951
case .min9: return "m9"
947952
case .min_maj9: return "mMaj9"
948953
case .maj_add9: return "(add9)"
954+
case .maj_add4: return "(add4)"
949955
case .min_add9: return "m(add9)"
950956
case .maj_6_9: return "6/9"
951957
case .maj9_flat5: return "maj9(♭5)"
@@ -1131,6 +1137,7 @@ extension ChordType: CustomStringConvertible {
11311137
case .min9: return "m9"
11321138
case .min_maj9: return "m^9"
11331139
case .maj_add9: return "@9"
1140+
case .maj_add4: return "@4"
11341141
case .min_add9: return "m@9"
11351142
case .maj_6_9: return "%"
11361143
case .maj9_flat5: return "^9b5"

Tests/TonicTests/ChordTests.swift

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -653,5 +653,45 @@ class ChordTests: XCTestCase {
653653
assertRankedChord([65, 69, 72, 74, 79], expectedDescriptions: ["F6/9", "G9sus4/F", "G11sus2/F", "Dm7(add11)/F"])
654654
assertRankedChord([65, 69, 72, 76, 79], expectedDescriptions: ["Fmaj9", "Am7(add♭13)/F"])
655655
}
656-
656+
657+
// MARK: - Octave-Doubled Voicings
658+
//
659+
// A chord must be detected the same regardless of how many times its notes
660+
// are doubled at other octaves — guitar voicings routinely repeat the root,
661+
// third, and fifth across six strings. Detection currently breaks when
662+
// *every* pitch class is doubled (e.g. a full six-string D/F♯): the chord
663+
// search never matches a unison/octave of the root, so `foundNotes` falls
664+
// one short of `pitchSet.count`. The "every note doubled" cases below
665+
// document that bug and should pass once it is fixed.
666+
667+
func testDMajorDetectedInRootPosition() {
668+
// D F♯ A
669+
assertRankedChord([50, 54, 57], expectedDescriptions: ["D"])
670+
}
671+
672+
func testDMajorOverFSharpDetectedWithDistinctNotes() {
673+
// F♯ A D
674+
assertRankedChord([42, 45, 50], expectedDescriptions: ["D/F♯"])
675+
}
676+
677+
func testDMajorOverFSharpDetectedWhenFifthAndOctaveDoubled() {
678+
// F♯ A D A D — A and D doubled; F♯ is still single so it can root the search
679+
assertRankedChord([42, 45, 50, 57, 62], expectedDescriptions: ["D/F♯"])
680+
}
681+
682+
func testDMajorOverFSharpDetectedWhenEveryNoteDoubled() {
683+
// F♯ A D A D F♯ — full six-string voicing, every pitch class doubled
684+
assertRankedChord([42, 45, 50, 57, 62, 66], expectedDescriptions: ["D/F♯"])
685+
}
686+
687+
func testCMajorDetectedWhenRootAndThirdDoubled() {
688+
// C E G C E — G is single so the search can root on G
689+
assertRankedChord([60, 64, 67, 72, 76], expectedDescriptions: ["C"])
690+
}
691+
692+
func testCMajorDetectedWhenEveryNoteDoubled() {
693+
// C E G C E G — every pitch class doubled
694+
assertRankedChord([60, 64, 67, 72, 76, 79], expectedDescriptions: ["C"])
695+
}
696+
657697
}

0 commit comments

Comments
 (0)