| 1 | import AVFoundation |
| 2 | import AudioToolbox |
| 3 | import CoreAudio |
| 4 | import QuartzCore |
| 5 | import Combine |
| 6 | |
| 7 | /// One detected pitch point on the timeline. |
| 8 | struct 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. |
| 15 | struct LivePitch { |
| 16 | let frequency: Double |
| 17 | let midi: Double |
| 18 | let clarity: Double |
| 19 | let level: Double |
| 20 | } |
| 21 | |
| 22 | enum 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. |
| 28 | final 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 | } |