| 1 | import SwiftUI |
| 2 | |
| 3 | // MARK: - Note naming systems |
| 4 | |
| 5 | enum NoteNaming: String, CaseIterable, Identifiable { |
| 6 | case sharps = "Sharps (C♯ D♯)" |
| 7 | case flats = "Flats (D♭ E♭)" |
| 8 | case solfege = "Solfège (Do Re)" |
| 9 | case german = "German (H / B)" |
| 10 | |
| 11 | var id: String { rawValue } |
| 12 | |
| 13 | var shortLabel: String { |
| 14 | switch self { |
| 15 | case .sharps: return "Sharps" |
| 16 | case .flats: return "Flats" |
| 17 | case .solfege: return "Solfège" |
| 18 | case .german: return "German" |
| 19 | } |
| 20 | } |
| 21 | |
| 22 | /// Pitch-class names, index 0 == C. |
| 23 | var names: [String] { |
| 24 | switch self { |
| 25 | case .sharps: |
| 26 | return ["C", "C♯", "D", "D♯", "E", "F", "F♯", "G", "G♯", "A", "A♯", "B"] |
| 27 | case .flats: |
| 28 | return ["C", "D♭", "D", "E♭", "E", "F", "G♭", "G", "A♭", "A", "B♭", "B"] |
| 29 | case .solfege: |
| 30 | return ["Do", "Di", "Re", "Ri", "Mi", "Fa", "Fi", "Sol", "Si", "La", "Li", "Ti"] |
| 31 | case .german: |
| 32 | // German convention: A♯/B♭ is written "B", and B natural is "H". |
| 33 | return ["C", "C♯", "D", "D♯", "E", "F", "F♯", "G", "G♯", "A", "B", "H"] |
| 34 | } |
| 35 | } |
| 36 | } |
| 37 | |
| 38 | // MARK: - Musical helpers |
| 39 | |
| 40 | enum Music { |
| 41 | /// MIDI note number (may be fractional) for a frequency in Hz. A4 (440) == 69. |
| 42 | static func midi(fromFrequency f: Double) -> Double { |
| 43 | 69.0 + 12.0 * log2(f / 440.0) |
| 44 | } |
| 45 | |
| 46 | static func frequency(fromMidi m: Double) -> Double { |
| 47 | 440.0 * pow(2.0, (m - 69.0) / 12.0) |
| 48 | } |
| 49 | |
| 50 | /// Pitch class 0–11 for an integer MIDI number. |
| 51 | static func pitchClass(_ midi: Int) -> Int { |
| 52 | ((midi % 12) + 12) % 12 |
| 53 | } |
| 54 | |
| 55 | /// True for the "black keys" — C♯ D♯ F♯ G♯ A♯. |
| 56 | static func isAccidental(_ midi: Int) -> Bool { |
| 57 | [1, 3, 6, 8, 10].contains(pitchClass(midi)) |
| 58 | } |
| 59 | |
| 60 | /// Scientific-pitch octave (C4 == middle C == MIDI 60). |
| 61 | static func octave(_ midi: Int) -> Int { |
| 62 | midi / 12 - 1 |
| 63 | } |
| 64 | |
| 65 | /// Name without octave, e.g. "F♯". |
| 66 | static func name(_ midi: Int, naming: NoteNaming) -> String { |
| 67 | naming.names[pitchClass(midi)] |
| 68 | } |
| 69 | |
| 70 | /// Name with octave, e.g. "F♯4". |
| 71 | static func fullName(_ midi: Int, naming: NoteNaming) -> String { |
| 72 | "\(name(midi, naming: naming))\(octave(midi))" |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | // MARK: - Key estimation (Krumhansl–Schmuckler) |
| 77 | |
| 78 | struct KeyEstimate { |
| 79 | let tonic: Int // pitch class 0–11 (concert) |
| 80 | let isMajor: Bool |
| 81 | let confidence: Double // 0…1, gap between the best and next-best fit |
| 82 | } |
| 83 | |
| 84 | extension Music { |
| 85 | // Krumhansl–Kessler tonal-hierarchy profiles: how strongly each scale |
| 86 | // degree "belongs" in a major / minor key. Index 0 == the tonic. |
| 87 | static let majorProfile: [Double] = |
| 88 | [6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88] |
| 89 | static let minorProfile: [Double] = |
| 90 | [6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17] |
| 91 | |
| 92 | /// Estimate key from a 12-bin, duration-weighted pitch-class histogram. |
| 93 | /// Correlates the histogram against all 24 key profiles and returns the best |
| 94 | /// fit. Returns nil until there's enough distinct pitch material to be useful. |
| 95 | static func estimateKey(weights: [Double]) -> KeyEstimate? { |
| 96 | guard weights.count == 12 else { return nil } |
| 97 | guard weights.filter({ $0 > 0 }).count >= 3 else { return nil } |
| 98 | |
| 99 | func correlate(_ x: [Double], _ p: [Double]) -> Double { |
| 100 | let mx = x.reduce(0, +) / 12, mp = p.reduce(0, +) / 12 |
| 101 | var num = 0.0, dx = 0.0, dp = 0.0 |
| 102 | for i in 0..<12 { |
| 103 | let a = x[i] - mx, b = p[i] - mp |
| 104 | num += a * b; dx += a * a; dp += b * b |
| 105 | } |
| 106 | let den = (dx * dp).squareRoot() |
| 107 | return den == 0 ? 0 : num / den |
| 108 | } |
| 109 | |
| 110 | var best = (r: -2.0, tonic: 0, major: true) |
| 111 | var second = -2.0 |
| 112 | for tonic in 0..<12 { |
| 113 | var rot = [Double](repeating: 0, count: 12) |
| 114 | for i in 0..<12 { rot[i] = weights[(i + tonic) % 12] } |
| 115 | for (r, isMaj) in [(correlate(rot, majorProfile), true), |
| 116 | (correlate(rot, minorProfile), false)] { |
| 117 | if r > best.r { second = best.r; best = (r, tonic, isMaj) } |
| 118 | else if r > second { second = r } |
| 119 | } |
| 120 | } |
| 121 | guard best.r > 0 else { return nil } |
| 122 | return KeyEstimate(tonic: best.tonic, isMajor: best.major, |
| 123 | confidence: max(0, min(1, best.r - max(0, second)))) |
| 124 | } |
| 125 | |
| 126 | /// Display name for a key, honoring the current naming + transpose, e.g. |
| 127 | /// "G major". |
| 128 | static func keyName(_ key: KeyEstimate, naming: NoteNaming, transpose: Int) -> String { |
| 129 | let pc = (((key.tonic + transpose) % 12) + 12) % 12 |
| 130 | return "\(naming.names[pc]) \(key.isMajor ? "major" : "minor")" |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | // MARK: - Vocal / instrument ranges |
| 135 | |
| 136 | struct PitchRange: Identifiable, Hashable { |
| 137 | let name: String |
| 138 | let low: Int // MIDI (inclusive) |
| 139 | let high: Int // MIDI (inclusive) |
| 140 | var id: String { name } |
| 141 | var span: Int { high - low } |
| 142 | } |
| 143 | |
| 144 | extension PitchRange { |
| 145 | static let presets: [PitchRange] = [ |
| 146 | PitchRange(name: "Full (C1–C7)", low: 24, high: 96), |
| 147 | PitchRange(name: "Bass (E2–E4)", low: 40, high: 64), |
| 148 | PitchRange(name: "Baritone (A2–A4)", low: 45, high: 69), |
| 149 | PitchRange(name: "Tenor (C3–C5)", low: 48, high: 72), |
| 150 | PitchRange(name: "Alto (F3–F5)", low: 53, high: 77), |
| 151 | PitchRange(name: "Mezzo (A3–A5)", low: 57, high: 81), |
| 152 | PitchRange(name: "Soprano (C4–C6)", low: 60, high: 84), |
| 153 | PitchRange(name: "Wide (C2–C6)", low: 36, high: 84), |
| 154 | ] |
| 155 | |
| 156 | static let `default` = presets[7] // Wide |
| 157 | } |