diff --git a/.gitignore b/.gitignore index 95c4320..3b29812 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,7 @@ /Packages /*.xcodeproj xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/CHANGELOG.md b/CHANGELOG.md index 3db7b62..a0cb1c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [3.1.0] - 2023-08-05 + +### Added + +- `Hashable` conformance for `Note` +- `CustomStringConvertable` conformance +- `Identifiable` conformance for `Note` + ## [3.0.0] - 2020-07-29 ### Breaking diff --git a/Sources/Scaletor/Extensions/HarmonicMinorMode+CustomStringConvertible.swift b/Sources/Scaletor/Extensions/HarmonicMinorMode+CustomStringConvertible.swift new file mode 100644 index 0000000..9ff610d --- /dev/null +++ b/Sources/Scaletor/Extensions/HarmonicMinorMode+CustomStringConvertible.swift @@ -0,0 +1,22 @@ +import Foundation + +extension HarmonicMinorMode: CustomStringConvertible { + public var description: String { + switch self { + case .harmonicMinor: + return "Harmonic Minor" + case .locrianSharpSix: + return "Locrian ♯6" + case .ionianSharpFive: + return "Ionian ♯5" + case .dorianSharpFour: + return "Dorian ♯4" + case .phrygianDominant: + return "Phrygian Dominant" + case .lydianSharpTwo: + return "Lydian ♯2" + case .ultraLocrian: + return "Ultra Locrian" + } + } +} diff --git a/Sources/Scaletor/Extensions/MajorMode+CustomStringConvertible.swift b/Sources/Scaletor/Extensions/MajorMode+CustomStringConvertible.swift new file mode 100644 index 0000000..22f4e42 --- /dev/null +++ b/Sources/Scaletor/Extensions/MajorMode+CustomStringConvertible.swift @@ -0,0 +1,7 @@ +import Foundation + +extension MajorMode: CustomStringConvertible { + public var description: String { + return self.rawValue.capitalized + } +} diff --git a/Sources/Scaletor/Extensions/MelodicMinorMode+CustomStringConvertible.swift b/Sources/Scaletor/Extensions/MelodicMinorMode+CustomStringConvertible.swift new file mode 100644 index 0000000..21f2f3d --- /dev/null +++ b/Sources/Scaletor/Extensions/MelodicMinorMode+CustomStringConvertible.swift @@ -0,0 +1,22 @@ +import Foundation + +extension MelodicMinorMode: CustomStringConvertible { + public var description: String { + switch self { + case .melodicMinor: + return "Melodic Minor" + case .dorianFlatTwo: + return "Dorian ♭2" + case .lydianAugmented: + return "Lydian Augmented" + case .lydianDominant: + return "Lydian Dominant" + case .mixolydianFlatSix: + return "Mixolydian ♭6" + case .aeolianFlatFive: + return "Aeolian ♭5" + case .superLocrian: + return "Super Locrian" + } + } +} diff --git a/Sources/Scaletor/Models/Accidental.swift b/Sources/Scaletor/Models/Accidental.swift index 8cd7305..e0903e1 100644 --- a/Sources/Scaletor/Models/Accidental.swift +++ b/Sources/Scaletor/Models/Accidental.swift @@ -19,4 +19,19 @@ extension Accidental { self = .natural } } + + internal var id: Int { + switch self { + case .doubleFlat: + return -2 + case .flat: + return -1 + case .natural: + return 0 + case .sharp: + return 1 + case .doubleSharp: + return 2 + } + } } diff --git a/Sources/Scaletor/Models/HarmonicMinorMode.swift b/Sources/Scaletor/Models/HarmonicMinorMode.swift index e08fa03..39929be 100644 --- a/Sources/Scaletor/Models/HarmonicMinorMode.swift +++ b/Sources/Scaletor/Models/HarmonicMinorMode.swift @@ -11,6 +11,10 @@ public enum HarmonicMinorMode: String, CaseIterable { } extension HarmonicMinorMode: Mode { + public static var description: String { + "Harmonic Minor" + } + public var chords: [ChordVoicing] { [.minor, .diminished, .augmented, .minor, .major, .major, .diminished] .offset(by: index) diff --git a/Sources/Scaletor/Models/MajorMode.swift b/Sources/Scaletor/Models/MajorMode.swift index b9f3350..c7e29fd 100644 --- a/Sources/Scaletor/Models/MajorMode.swift +++ b/Sources/Scaletor/Models/MajorMode.swift @@ -5,6 +5,10 @@ public enum MajorMode: String, CaseIterable { } extension MajorMode: Mode { + public static var description: String { + "Major" + } + public var chords: [ChordVoicing] { [.major, .minor, .minor, .major, .major, .minor, .diminished] .offset(by: index) diff --git a/Sources/Scaletor/Models/MelodicMinorMode.swift b/Sources/Scaletor/Models/MelodicMinorMode.swift index 60e2486..029ee23 100644 --- a/Sources/Scaletor/Models/MelodicMinorMode.swift +++ b/Sources/Scaletor/Models/MelodicMinorMode.swift @@ -11,6 +11,10 @@ public enum MelodicMinorMode: String, CaseIterable { } extension MelodicMinorMode: Mode { + public static var description: String { + "Melodic Minor" + } + public var chords: [ChordVoicing] { [.minor, .diminished, .augmented, .major, .major, .diminished, .diminished] .offset(by: index) diff --git a/Sources/Scaletor/Models/Note.swift b/Sources/Scaletor/Models/Note.swift index ebd63bd..3a95edb 100644 --- a/Sources/Scaletor/Models/Note.swift +++ b/Sources/Scaletor/Models/Note.swift @@ -1,12 +1,14 @@ import Foundation -public struct Note: Equatable { +public struct Note: Equatable, Identifiable { + public let id: Int public let pitch: Pitch public let accidental: Accidental public init(pitch: Pitch, accidental: Accidental = .natural) { self.pitch = pitch self.accidental = accidental + self.id = Self.makeId(from: pitch, accidental: accidental) } /// Create a note from a string. @@ -27,13 +29,28 @@ public struct Note: Equatable { guard (1...2).contains(input.count) else { throw NoteError.outOfBounds } let letter = String(input.prefix(1)) - let accidental = String(input.suffix(1)) + let symbol = String(input.suffix(1)) guard let pitch = Pitch(rawValue: letter.lowercased()) else { throw NoteError.invalidNote } - self.pitch = pitch - self.accidental = input.count == 2 ? Accidental(rawValue: accidental) : .natural + let accidental = input.count == 2 ? Accidental(rawValue: symbol) : .natural + + self.init(pitch: pitch, accidental: accidental) + } +} + +extension Note: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(description) + } +} + +extension Note { + private static func makeId(from pitch: Pitch, accidental: Accidental) -> Int { + let id = pitch.id + accidental.id + let max = 12 + return ((id % max) + max) % max } } diff --git a/Sources/Scaletor/Models/Pitch.swift b/Sources/Scaletor/Models/Pitch.swift index 7b1897e..97d24ea 100644 --- a/Sources/Scaletor/Models/Pitch.swift +++ b/Sources/Scaletor/Models/Pitch.swift @@ -3,3 +3,24 @@ import Foundation public enum Pitch: String, CaseIterable { case a, b, c, d, e, f, g } + +extension Pitch { + internal var id: Int { + switch self { + case .a: + return 0 + case .b: + return 2 + case .c: + return 3 + case .d: + return 5 + case .e: + return 7 + case .f: + return 8 + case .g: + return 10 + } + } +} diff --git a/Sources/Scaletor/Protocols/Mode.swift b/Sources/Scaletor/Protocols/Mode.swift index 1ba6def..acd0c91 100644 --- a/Sources/Scaletor/Protocols/Mode.swift +++ b/Sources/Scaletor/Protocols/Mode.swift @@ -1,6 +1,7 @@ import Foundation -public protocol Mode { - var chords: [ChordVoicing] { get } +public protocol Mode: CustomStringConvertible { + static var description: String { get } var intervals: [Interval] { get } + var chords: [ChordVoicing] { get } } diff --git a/Tests/ScaletorTests/NoteTests.swift b/Tests/ScaletorTests/NoteTests.swift index 915c91c..5388cc3 100644 --- a/Tests/ScaletorTests/NoteTests.swift +++ b/Tests/ScaletorTests/NoteTests.swift @@ -1,4 +1,4 @@ -import Scaletor +@testable import Scaletor import XCTest class NoteTests: XCTestCase { @@ -48,4 +48,39 @@ class NoteTests: XCTestCase { let note = Note(pitch: .b, accidental: .sharp) XCTAssertEqual("\(note)", "B♯") } + + func test_Id_MatchesNaturalPitch() { + let note = Note(pitch: .c, accidental: .natural) + XCTAssertEqual(note.id, Pitch.c.id) + } + + func test_Id_MatchesFlattenedPitch() { + let note = Note(pitch: .c, accidental: .flat) + XCTAssertEqual(note.id, Pitch.b.id) + } + + func test_Id_MatchesDoubleFlattenedPitch() { + let note = Note(pitch: .c, accidental: .doubleFlat) + XCTAssertEqual(note.id, Pitch.b.id - 1) + } + + func test_Id_MatchesSharpenedPitch() { + let note = Note(pitch: .c, accidental: .sharp) + XCTAssertEqual(note.id, Pitch.c.id + 1) + } + + func test_Id_MatchesDoubleSharpenedPitch() { + let note = Note(pitch: .c, accidental: .doubleSharp) + XCTAssertEqual(note.id, Pitch.d.id) + } + + func test_Id_WrapsUpperBounds() { + let note = Note(pitch: .g, accidental: .doubleSharp) + XCTAssertEqual(note.id, Pitch.a.id) + } + + func test_Id_WrapsLowerBounds() { + let note = Note(pitch: .a, accidental: .doubleFlat) + XCTAssertEqual(note.id, Pitch.g.id) + } }