1import AVFoundation
2import AudioToolbox
3import CoreAudio
4import QuartzCore
5import Combine
6
7/// One detected pitch point on the timeline.
8struct PitchSample {
9 let time: Double // seconds on the CACurrentMediaTime clock
10 let midi: Double // fractional MIDI note
11 let clarity: Double
12}
13
14/// The most recent live reading, for the big note readout / tuner.
15struct LivePitch {
16 let frequency: Double
17 let midi: Double
18 let clarity: Double
19 let level: Double
20}
21
22enum MicPermission {
23 case unknown, granted, denied
24}
25
26/// Captures microphone audio, runs the YIN detector at a fixed hop, and
27/// exposes both a rolling history (for the graph) and the latest reading.
28final class AudioEngine: ObservableObject, @unchecked Sendable {
29
30 // Published UI state (mutated on the main thread only). Deliberately
31 // low-frequency — the live pitch is NOT published, so per-frame detection
32 // never invalidates the SwiftUI tree. The graph reads it via currentLive().
33 @Published var devices: [AudioInputDevice] = []
34 @Published var selectedDeviceID: AudioDeviceID?
35 @Published var isRunning = false
36 @Published var permission: MicPermission = .unknown
37 @Published var statusMessage: String?
38 /// Rolling key estimate (updated on the main thread as notes land). Low
39 /// frequency, so publishing it doesn't churn the SwiftUI tree per frame.
40 @Published var keyEstimate: KeyEstimate?
41
42 private let engine = AVAudioEngine()
43 // Sensitive settings: low RMS gate so quiet singing registers, and a
44 // slightly looser YIN threshold so less-perfectly-periodic tones are kept.
45 // Very low level gate so quiet singing registers; the clarity gate (not the
46 // level gate) is what rejects room noise, so lowering this is safe.
47 private var detector = YINDetector(sampleRate: 48000, windowSize: 2048,
48 threshold: 0.22, rmsGate: 0.0005, minClarity: 0.5)
49
50 private let windowSize = 2048
51 private let hop = 256 // ~5.3 ms between points at 48 kHz → dense, smooth trace
52
53 // Detection ring state (audio thread).
54 private var accumulator = [Float]()
55 private var framesProcessed: Int = 0
56 private var startHostTime: Double = 0
57 private var haveStart = false
58
59 // Small median window to reject single-frame octave/spike errors.
60 private var recentMidi = [Double]()
61
62 // Note segmentation ("intentional note" layer) + its guard lock. Fed on the
63 // audio thread; read by the graph on the display thread.
64 private let segmenter = NoteSegmenter()
65 private let notesLock = NSLock()
66
67 // History shared with the UI thread.
68 private let historyLock = NSLock()
69 private var history = [PitchSample]()
70 private let historyWindow: Double = 30 // seconds retained
71
72 // Latest reading, read lock-free-ish by the graph each display frame.
73 private let liveLock = NSLock()
74 private var latestLive: LivePitch?
75 private var latestLiveTime: Double = 0
76 private let liveHold: Double = 0.3 // keep showing the last note this long after silence
77
78 // MARK: - Lifecycle
79
80 func start() {
81 refreshDevices()
82 requestPermission { [weak self] granted in
83 guard let self else { return }
84 if granted {
85 self.selectedDeviceID = CoreAudioDevices.defaultInputDevice()
86 self.beginCapture()
87 }
88 }
89 }
90
91 func requestPermission(_ completion: @escaping (Bool) -> Void) {
92 switch AVCaptureDevice.authorizationStatus(for: .audio) {
93 case .authorized:
94 permission = .granted
95 completion(true)
96 case .notDetermined:
97 AVCaptureDevice.requestAccess(for: .audio) { [weak self] granted in
98 DispatchQueue.main.async {
99 self?.permission = granted ? .granted : .denied
100 completion(granted)
101 }
102 }
103 default:
104 permission = .denied
105 statusMessage = "Microphone access denied — enable it in System Settings ▸ Privacy & Security ▸ Microphone."
106 completion(false)
107 }
108 }
109
110 func refreshDevices() {
111 let list = CoreAudioDevices.inputDevices()
112 DispatchQueue.main.async {
113 self.devices = list
114 if let sel = self.selectedDeviceID, !list.contains(where: { $0.id == sel }) {
115 self.selectedDeviceID = list.first?.id
116 }
117 }
118 }
119
120 // MARK: - Device selection
121
122 func selectDevice(_ id: AudioDeviceID) {
123 selectedDeviceID = id
124 let wasRunning = isRunning
125 if wasRunning { stopCapture() }
126 applyDevice(id)
127 if wasRunning { beginCapture() }
128 }
129
130 private func applyDevice(_ id: AudioDeviceID) {
131 guard let unit = engine.inputNode.audioUnit else { return }
132 var dev = id
133 let status = AudioUnitSetProperty(
134 unit,
135 kAudioOutputUnitProperty_CurrentDevice,
136 kAudioUnitScope_Global,
137 0,
138 &dev,
139 UInt32(MemoryLayout<AudioDeviceID>.size))
140 if status != noErr {
141 DispatchQueue.main.async { self.statusMessage = "Couldn't switch input device (\(status))." }
142 }
143 }
144
145 // MARK: - Capture
146
147 private func beginCapture() {
148 if let id = selectedDeviceID { applyDevice(id) }
149
150 let input = engine.inputNode
151 let format = input.inputFormat(forBus: 0)
152 guard format.sampleRate > 0, format.channelCount > 0 else {
153 DispatchQueue.main.async { self.statusMessage = "No usable audio input." }
154 return
155 }
156
157 detector.sampleRate = format.sampleRate
158 resetDetectionState()
159
160 input.removeTap(onBus: 0)
161 input.installTap(onBus: 0, bufferSize: 1024, format: format) { [weak self] buffer, _ in
162 self?.process(buffer)
163 }
164
165 engine.prepare()
166 do {
167 try engine.start()
168 DispatchQueue.main.async {
169 self.isRunning = true
170 self.statusMessage = nil
171 }
172 } catch {
173 DispatchQueue.main.async {
174 self.statusMessage = "Audio engine failed to start: \(error.localizedDescription)"
175 }
176 }
177 }
178
179 private func stopCapture() {
180 engine.inputNode.removeTap(onBus: 0)
181 if engine.isRunning { engine.stop() }
182 DispatchQueue.main.async { self.isRunning = false }
183 }
184
185 private func resetDetectionState() {
186 accumulator.removeAll(keepingCapacity: true)
187 recentMidi.removeAll(keepingCapacity: true)
188 framesProcessed = 0
189 haveStart = false
190 notesLock.lock(); segmenter.reset(); notesLock.unlock()
191 DispatchQueue.main.async { self.keyEstimate = nil }
192 }
193
194 // MARK: - Audio-thread processing
195
196 private func process(_ buffer: AVAudioPCMBuffer) {
197 guard let channelData = buffer.floatChannelData else { return }
198 let frames = Int(buffer.frameLength)
199 if frames == 0 { return }
200 let channels = Int(buffer.format.channelCount)
201
202 if !haveStart {
203 haveStart = true
204 startHostTime = CACurrentMediaTime()
205 framesProcessed = 0
206 }
207
208 // Down-mix to mono.
209 accumulator.reserveCapacity(accumulator.count + frames)
210 if channels == 1 {
211 let p = channelData[0]
212 for i in 0..<frames { accumulator.append(p[i]) }
213 } else {
214 for i in 0..<frames {
215 var sum: Float = 0
216 for c in 0..<channels { sum += channelData[c][i] }
217 accumulator.append(sum / Float(channels))
218 }
219 }
220
221 let sr = detector.sampleRate
222 var newSamples = [PitchSample]()
223 var noteCommitted = false
224
225 while accumulator.count >= windowSize {
226 var result: YINDetector.Result?
227 accumulator.withUnsafeBufferPointer { buf in
228 result = detector.detect(buf.baseAddress!, count: windowSize)
229 }
230 let windowStartTime = startHostTime + Double(framesProcessed) / sr
231
232 if let r = result {
233 let raw = Music.midi(fromFrequency: r.frequency)
234 let midi = smoothedMidi(raw)
235 let freq = Music.frequency(fromMidi: midi)
236 newSamples.append(PitchSample(time: windowStartTime, midi: midi, clarity: r.clarity))
237 setLive(LivePitch(frequency: freq, midi: midi, clarity: r.clarity, level: r.level),
238 at: windowStartTime)
239 notesLock.lock()
240 if segmenter.feed(time: windowStartTime, midi: midi) { noteCommitted = true }
241 notesLock.unlock()
242 } else {
243 recentMidi.removeAll(keepingCapacity: true)
244 notesLock.lock()
245 if segmenter.feedSilence(now: windowStartTime) { noteCommitted = true }
246 notesLock.unlock()
247 }
248
249 accumulator.removeFirst(hop)
250 framesProcessed += hop
251 }
252
253 if !newSamples.isEmpty { appendHistory(newSamples) }
254 if noteCommitted { updateKey() }
255 }
256
257 /// Recompute the key from committed notes (duration-weighted). Called only
258 /// when a note lands, so it's cheap; the result is published on the main
259 /// thread for the top-bar readout.
260 private func updateKey() {
261 notesLock.lock()
262 var weights = [Double](repeating: 0, count: 12)
263 for n in segmenter.committed {
264 weights[(((n.midi % 12) + 12) % 12)] += n.duration
265 }
266 notesLock.unlock()
267 let est = Music.estimateKey(weights: weights)
268 DispatchQueue.main.async { self.keyEstimate = est }
269 }
270
271 /// Snapshot of committed notes ending at/after `since`, plus the note being
272 /// sung right now (if any). Read by the graph each frame.
273 func noteSnapshot(since: Double) -> (committed: [NoteEvent], pending: NoteEvent?) {
274 notesLock.lock()
275 defer { notesLock.unlock() }
276 let committed = segmenter.committed.filter { $0.offset >= since }
277 return (committed, segmenter.pending)
278 }
279
280 /// Median-of-3 over consecutive detections — removes lone octave/spike
281 /// errors while preserving vibrato and fast slides.
282 private func smoothedMidi(_ raw: Double) -> Double {
283 recentMidi.append(raw)
284 if recentMidi.count > 3 { recentMidi.removeFirst() }
285 if recentMidi.count < 3 { return raw }
286 return recentMidi.sorted()[1]
287 }
288
289 private func appendHistory(_ samples: [PitchSample]) {
290 historyLock.lock()
291 history.append(contentsOf: samples)
292 let cutoff = (samples.last?.time ?? 0) - historyWindow
293 if let first = history.first, first.time < cutoff {
294 history.removeAll { $0.time < cutoff }
295 }
296 historyLock.unlock()
297 }
298
299 private func setLive(_ value: LivePitch, at time: Double) {
300 liveLock.lock()
301 latestLive = value
302 latestLiveTime = time
303 liveLock.unlock()
304 }
305
306 /// The current reading, or nil once the note has been silent past `liveHold`.
307 /// Read every display frame by the graph — cheap and never touches @Published.
308 func currentLive() -> LivePitch? {
309 liveLock.lock()
310 defer { liveLock.unlock() }
311 guard let v = latestLive else { return nil }
312 if CACurrentMediaTime() - latestLiveTime > liveHold { return nil }
313 return v
314 }
315
316 // MARK: - Read access for the graph
317
318 /// Snapshot of samples with `time >= since`, oldest first.
319 func snapshot(since: Double) -> [PitchSample] {
320 historyLock.lock()
321 defer { historyLock.unlock() }
322 if history.isEmpty { return [] }
323 var start = history.count
324 for i in stride(from: history.count - 1, through: 0, by: -1) {
325 if history[i].time < since { break }
326 start = i
327 }
328 return Array(history[start...])
329 }
330}