1import SwiftUI
2
3// MARK: - Note naming systems
4
5enum 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
40enum 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
78struct 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
84extension 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
136struct 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
144extension 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}