| 1 | import Foundation |
| 2 | import os |
| 3 | |
| 4 | // The audio test's rules, apart from any device: what the microphone's |
| 5 | // levels mean, what the recording keeps, and what the test sound is. The |
| 6 | // controller (AudioSelfTestModel.swift) wires these to a real capture; the |
| 7 | // tests drive them with a clock of their own. |
| 8 | |
| 9 | /// What the microphone half of the test says. |
| 10 | public enum MicVerdict: Equatable, Sendable { |
| 11 | /// Started, and nothing decisive yet. |
| 12 | case listening |
| 13 | /// Something is arriving before the suppressor, but nothing at speech |
| 14 | /// level has left it yet: a quiet room, or someone who has not spoken. |
| 15 | case hearing |
| 16 | /// Speech-level audio has left the capture hook — what a call sends. |
| 17 | case working |
| 18 | /// Five seconds of audio arriving with nothing above -90 dBFS before |
| 19 | /// the suppressor: the device is delivering silence. |
| 20 | case silent |
| 21 | /// Five seconds with no audio arriving at all: the microphone was asked |
| 22 | /// for and nothing came back. Up to 0.3.7 this was called `silent` too, |
| 23 | /// and the two needed telling apart (Paul's AirPods, 2026-09-26). |
| 24 | case noAudio |
| 25 | /// In a call that has not opened the microphone: WebRTC starts it only |
| 26 | /// once there is someone to send to, so silence here says nothing about |
| 27 | /// the device. |
| 28 | case notStarted |
| 29 | } |
| 30 | |
| 31 | /// Where the test's microphone is on its way to running. The test's |
| 32 | /// window opens at once; this is what it says until the microphone is |
| 33 | /// actually capturing, which with a Bluetooth headset can be a couple of |
| 34 | /// seconds (it has to move to its hands-free profile to open a |
| 35 | /// microphone at all — physics, not a fault). |
| 36 | public enum MicStartPhase: Equatable, Sendable { |
| 37 | case idle |
| 38 | /// Asking for microphone permission; a system prompt may be showing. |
| 39 | case asking |
| 40 | /// Building the audio and starting the microphone. `switching` names a |
| 41 | /// Bluetooth microphone that is moving to its hands-free profile. |
| 42 | case starting(switching: String?) |
| 43 | case running |
| 44 | /// Would not start, or no permission: the test says why elsewhere. |
| 45 | case failed |
| 46 | /// Closed before it had started. |
| 47 | case cancelled |
| 48 | } |
| 49 | |
| 50 | /// The start's rules, apart from any device, so they can be tested: every |
| 51 | /// way through open, permission, start and close, and what a start that |
| 52 | /// finishes after the window has closed must do (stop what it opened). |
| 53 | public struct MicStart: Sendable { |
| 54 | public private(set) var phase: MicStartPhase = .idle |
| 55 | |
| 56 | public init() {} |
| 57 | |
| 58 | /// What to do with a start that has just finished. |
| 59 | public enum Finish: Equatable, Sendable { |
| 60 | /// Keep it: it is the test's microphone now. |
| 61 | case keep |
| 62 | /// The test was closed while it started: stop it at once. |
| 63 | case stop |
| 64 | /// It failed; nothing to stop. |
| 65 | case failed |
| 66 | } |
| 67 | |
| 68 | /// Opening the window. False if it was already opened or closed. |
| 69 | public mutating func open() -> Bool { |
| 70 | guard phase == .idle else { return false } |
| 71 | phase = .asking |
| 72 | return true |
| 73 | } |
| 74 | |
| 75 | /// The answer to the permission question. False: do not start. |
| 76 | public mutating func permission(_ granted: Bool) -> Bool { |
| 77 | guard phase == .asking else { return false } |
| 78 | phase = granted ? .starting(switching: nil) : .failed |
| 79 | return granted |
| 80 | } |
| 81 | |
| 82 | /// Starting again after the test stopped the microphone to play |
| 83 | /// something (iPhone). False once closed. |
| 84 | public mutating func restart() -> Bool { |
| 85 | guard phase == .running || phase == .failed else { return false } |
| 86 | phase = .starting(switching: nil) |
| 87 | return true |
| 88 | } |
| 89 | |
| 90 | /// The audio is built and the microphone it will open is known. |
| 91 | public mutating func microphone(bluetoothSwitching name: String?) { |
| 92 | guard case .starting = phase else { return } |
| 93 | phase = .starting(switching: name) |
| 94 | } |
| 95 | |
| 96 | public mutating func finished(ok: Bool) -> Finish { |
| 97 | switch phase { |
| 98 | case .starting: |
| 99 | phase = ok ? .running : .failed |
| 100 | return ok ? .keep : .failed |
| 101 | case .cancelled: |
| 102 | return ok ? .stop : .failed |
| 103 | default: |
| 104 | // Not ours to judge (a start nobody asked for): stop it. |
| 105 | return ok ? .stop : .failed |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | /// The window closed. Returns whether a microphone may be open or |
| 110 | /// opening, so the caller stops it. |
| 111 | @discardableResult |
| 112 | public mutating func close() -> Bool { |
| 113 | let live: Bool |
| 114 | switch phase { |
| 115 | case .starting, .running: live = true |
| 116 | default: live = false |
| 117 | } |
| 118 | phase = .cancelled |
| 119 | return live |
| 120 | } |
| 121 | |
| 122 | public var isStarting: Bool { |
| 123 | if case .starting = phase { return true } |
| 124 | return phase == .asking |
| 125 | } |
| 126 | |
| 127 | /// What the window says while the microphone starts; nil otherwise. |
| 128 | public var sentence: String? { |
| 129 | switch phase { |
| 130 | case .asking: return "Asking to use the microphone…" |
| 131 | case .starting(let switching?): |
| 132 | return "Switching \(switching) to microphone mode…" |
| 133 | case .starting(nil): return "Starting microphone…" |
| 134 | default: return nil |
| 135 | } |
| 136 | } |
| 137 | } |
| 138 | |
| 139 | /// Judges the microphone from the capture hook's two meters. |
| 140 | /// |
| 141 | /// "Working" is asked of `CaptureDenoiser.level`, the audio after noise |
| 142 | /// reduction, because that is what the far side hears. "Can't hear |
| 143 | /// anything" is asked of `inputLevel`, before the suppressor: a quiet room |
| 144 | /// that DeepFilterNet has taken down by 30 dB can look dead afterwards |
| 145 | /// while the microphone is fine. |
| 146 | public struct MicJudge: Sendable { |
| 147 | /// About conversational speech a metre from a laptop, well below the |
| 148 | /// -26 dBFS a call aims for, and well above any room. |
| 149 | public static let speechRMS: Float = pow(10, -45 / 20) |
| 150 | /// Below this before the suppressor is digital silence, or near it: a |
| 151 | /// real microphone in a real room is 20 dB above it or more. A mono |
| 152 | /// capture of an empty input on a multichannel interface sits here. |
| 153 | public static let silenceRMS: Float = pow(10, -90 / 20) |
| 154 | public static let silenceWindow: Double = 5 |
| 155 | |
| 156 | private var started: Double? |
| 157 | private var lastSound: Double? |
| 158 | private var heardSpeech = false |
| 159 | private var everHadBuffers = false |
| 160 | public private(set) var verdict: MicVerdict = .listening |
| 161 | |
| 162 | public init() {} |
| 163 | |
| 164 | /// Forgets everything; the window starts again at `now`. |
| 165 | public mutating func start(at now: Double) { |
| 166 | started = now |
| 167 | lastSound = nil |
| 168 | heardSpeech = false |
| 169 | everHadBuffers = false |
| 170 | verdict = .listening |
| 171 | } |
| 172 | |
| 173 | /// One pair of readings (peak-held since the last), and the verdict. |
| 174 | /// |
| 175 | /// - Parameter captureExpected: false in a call that has not started its |
| 176 | /// microphone yet, where no audio is not the device's fault. |
| 177 | @discardableResult |
| 178 | public mutating func observe( |
| 179 | input: CaptureLevel.Reading, output: CaptureLevel.Reading, at now: Double, |
| 180 | captureExpected: Bool = true |
| 181 | ) -> MicVerdict { |
| 182 | if started == nil { start(at: now) } |
| 183 | if input.buffers > 0 || output.buffers > 0 { everHadBuffers = true } |
| 184 | if input.buffers > 0, input.rms >= MicJudge.silenceRMS { lastSound = now } |
| 185 | if output.buffers > 0, output.rms >= MicJudge.speechRMS { |
| 186 | heardSpeech = true |
| 187 | lastSound = now |
| 188 | } |
| 189 | let quietFor = now - (lastSound ?? started ?? now) |
| 190 | if quietFor >= MicJudge.silenceWindow { |
| 191 | heardSpeech = false |
| 192 | if everHadBuffers { |
| 193 | verdict = .silent |
| 194 | } else { |
| 195 | verdict = captureExpected ? .noAudio : .notStarted |
| 196 | } |
| 197 | } else if heardSpeech { |
| 198 | verdict = .working |
| 199 | } else if lastSound != nil { |
| 200 | verdict = .hearing |
| 201 | } else { |
| 202 | verdict = .listening |
| 203 | } |
| 204 | return verdict |
| 205 | } |
| 206 | |
| 207 | /// Carries on after a pause of `gap` seconds as though it had not |
| 208 | /// happened: the silence window does not run while the test itself is |
| 209 | /// playing. |
| 210 | public mutating func resume(after gap: Double) { |
| 211 | guard gap > 0 else { return } |
| 212 | started = started.map { $0 + gap } |
| 213 | lastSound = lastSound.map { $0 + gap } |
| 214 | } |
| 215 | |
| 216 | /// The verdict in words, naming the microphone. |
| 217 | public static func sentence(_ verdict: MicVerdict, device: String) -> String { |
| 218 | let name = device.isEmpty ? "your microphone" : device |
| 219 | switch verdict { |
| 220 | case .listening: return "Listening to \(name). Say something." |
| 221 | case .hearing: return "Calls can hear \(name), but not speech yet. Try speaking normally." |
| 222 | case .working: return "Your microphone is working." |
| 223 | case .silent: return "Sound is arriving from \(name), but it is silent." |
| 224 | case .noAudio: return "No sound is arriving from \(name). Calls asked for it and nothing came back." |
| 225 | case .notStarted: |
| 226 | return |
| 227 | "The microphone starts when someone else joins the call. Test it then, or from the home screen before a call." |
| 228 | } |
| 229 | } |
| 230 | |
| 231 | /// What to try, for the verdicts where there is something to try. |
| 232 | public static func advice(_ verdict: MicVerdict) -> String? { |
| 233 | switch verdict { |
| 234 | case .silent: |
| 235 | return |
| 236 | "Check that it is connected and switched on, that it isn’t muted on the device, and that it is the microphone chosen here. On an audio interface with several inputs, check which input it is plugged into." |
| 237 | case .noAudio: |
| 238 | return |
| 239 | "Try choosing it again, or another microphone. If it keeps happening, use Copy audio diagnostics and send what it copies." |
| 240 | default: return nil |
| 241 | } |
| 242 | } |
| 243 | } |
| 244 | |
| 245 | /// A level for a meter to show, in whole steps, so a view is only told |
| 246 | /// when what it draws would change. -60 dBFS is the bottom step and 0 the |
| 247 | /// top; each step is 5 dB. |
| 248 | public enum MeterSteps { |
| 249 | public static let count = 12 |
| 250 | |
| 251 | public static func step(_ level: Float) -> Int { |
| 252 | guard level > 0 else { return 0 } |
| 253 | let db = 20 * log10(level) |
| 254 | return min(count, max(0, Int(((db + 60) / 5).rounded(.down)) + 1)) |
| 255 | } |
| 256 | |
| 257 | /// Said to VoiceOver instead of a number that would change ten times a |
| 258 | /// second. |
| 259 | public static func words(_ step: Int) -> String { |
| 260 | switch step { |
| 261 | case 0: return "silent" |
| 262 | case 1...3: return "very quiet" |
| 263 | case 4...6: return "quiet" |
| 264 | case 7...9: return "speaking level" |
| 265 | default: return "loud" |
| 266 | } |
| 267 | } |
| 268 | } |
| 269 | |
| 270 | /// The recording for "Hear yourself as others will": a fixed block of |
| 271 | /// memory filled from the capture hook's sink on the audio thread, read |
| 272 | /// once for playback and cleared. It is never written anywhere else. |
| 273 | /// |
| 274 | /// Sized at construction for the longest recording at the highest rate a |
| 275 | /// device is likely to run at, so the audio thread never allocates; it |
| 276 | /// copies under an unfair lock and returns. |
| 277 | public final class SelfTestRecorder: @unchecked Sendable { |
| 278 | public static let maxRate = 96_000 |
| 279 | private struct State { |
| 280 | var count = 0 |
| 281 | var rate = 0 |
| 282 | var armed = false |
| 283 | } |
| 284 | private let lock = OSAllocatedUnfairLock(initialState: State()) |
| 285 | private let storage: UnsafeMutablePointer<Float> |
| 286 | private let capacity: Int |
| 287 | public let seconds: Double |
| 288 | |
| 289 | public init(seconds: Double) { |
| 290 | self.seconds = seconds |
| 291 | capacity = Int(seconds * Double(SelfTestRecorder.maxRate)) |
| 292 | storage = .allocate(capacity: capacity) |
| 293 | storage.initialize(repeating: 0, count: capacity) |
| 294 | } |
| 295 | deinit { |
| 296 | storage.update(repeating: 0, count: capacity) |
| 297 | storage.deallocate() |
| 298 | } |
| 299 | |
| 300 | /// Starts taking audio, from empty. |
| 301 | public func arm() { |
| 302 | lock.withLock { s in |
| 303 | s = State() |
| 304 | s.armed = true |
| 305 | } |
| 306 | } |
| 307 | |
| 308 | /// The sink. Samples in WebRTC's ±32768 scale, mono (the hook's channel |
| 309 | /// 0). A change of rate mid-recording starts it again: the samples |
| 310 | /// before and after cannot be played as one. |
| 311 | public func append(_ samples: UnsafePointer<Float>, frames: Int, rate: Int) { |
| 312 | guard rate > 0, frames > 0 else { return } |
| 313 | lock.withLockUnchecked { s in |
| 314 | guard s.armed else { return } |
| 315 | if s.rate != rate { |
| 316 | s.rate = rate |
| 317 | s.count = 0 |
| 318 | } |
| 319 | let wanted = Int(seconds * Double(rate)) |
| 320 | let room = min(wanted, capacity) - s.count |
| 321 | guard room > 0 else { |
| 322 | s.armed = false |
| 323 | return |
| 324 | } |
| 325 | let n = min(room, frames) |
| 326 | (storage + s.count).update(from: samples, count: n) |
| 327 | s.count += n |
| 328 | if s.count >= min(wanted, capacity) { s.armed = false } |
| 329 | } |
| 330 | } |
| 331 | |
| 332 | /// Seconds recorded so far. |
| 333 | public var recorded: Double { |
| 334 | lock.withLock { s in s.rate > 0 ? Double(s.count) / Double(s.rate) : 0 } |
| 335 | } |
| 336 | public var full: Bool { |
| 337 | lock.withLock { s in s.rate > 0 && s.count >= min(Int(seconds * Double(s.rate)), capacity) } |
| 338 | } |
| 339 | |
| 340 | /// The recording in -1...1, and its rate; the recorder is emptied and |
| 341 | /// its memory cleared, so there is one copy and it is the caller's. |
| 342 | public func take() -> (samples: [Float], rate: Int) { |
| 343 | lock.withLock { s in |
| 344 | let out = (0..<s.count).map { storage[$0] / 32768 } |
| 345 | storage.update(repeating: 0, count: s.count) |
| 346 | let rate = s.rate |
| 347 | s = State() |
| 348 | return (out, rate) |
| 349 | } |
| 350 | } |
| 351 | |
| 352 | /// Throws the recording away without reading it. |
| 353 | public func discard() { |
| 354 | lock.withLock { s in |
| 355 | storage.update(repeating: 0, count: s.count) |
| 356 | s = State() |
| 357 | } |
| 358 | } |
| 359 | } |
| 360 | |
| 361 | /// Levels of mono audio in -1...1. |
| 362 | public enum SampleLevel { |
| 363 | public static func rms(_ samples: [Float]) -> Float { |
| 364 | guard !samples.isEmpty else { return 0 } |
| 365 | var energy: Float = 0 |
| 366 | for v in samples { energy += v * v } |
| 367 | return (energy / Float(samples.count)).squareRoot() |
| 368 | } |
| 369 | |
| 370 | public static func db(_ level: Float) -> Float { 20 * log10(max(level, 1e-9)) } |
| 371 | } |
| 372 | |
| 373 | /// The speaker test's sound: a soft two-note chime, E5 then A5, each a |
| 374 | /// sine with its octave, struck and left to ring out. Made here rather |
| 375 | /// than carried as a file. |
| 376 | public enum TestChime { |
| 377 | public static let rate = 48_000.0 |
| 378 | |
| 379 | public static func samples(rate: Double = TestChime.rate) -> [Float] { |
| 380 | let notes: [(start: Double, frequency: Double)] = [(0, 659.26), (0.35, 880)] |
| 381 | let length = 1.6 |
| 382 | var out = [Float](repeating: 0, count: Int(length * rate)) |
| 383 | for note in notes { |
| 384 | let first = Int(note.start * rate) |
| 385 | for i in first..<out.count { |
| 386 | let t = Double(i - first) / rate |
| 387 | // 5 ms attack, then an exponential ring-out. |
| 388 | let envelope = min(t / 0.005, 1) * exp(-t * 3.2) |
| 389 | let tone = sin(2 * .pi * note.frequency * t) + 0.25 * sin(4 * .pi * note.frequency * t) |
| 390 | out[i] += Float(0.22 * envelope * tone) |
| 391 | } |
| 392 | } |
| 393 | // A short fade so the end never clicks. |
| 394 | let fade = Int(0.05 * rate) |
| 395 | for i in 0..<fade { out[out.count - 1 - i] *= Float(i) / Float(fade) } |
| 396 | return out |
| 397 | } |
| 398 | } |
| 399 | |
| 400 | /// A meter for audio this app is playing: the level of each buffer as it |
| 401 | /// goes to the output, peak-held for the view as CaptureLevel is. |
| 402 | public final class OutputMeter: @unchecked Sendable { |
| 403 | private let lock = OSAllocatedUnfairLock(initialState: Float(0)) |
| 404 | public init() {} |
| 405 | |
| 406 | /// Samples in -1...1. |
| 407 | public func record(_ samples: UnsafePointer<Float>, count: Int) { |
| 408 | guard count > 0 else { return } |
| 409 | var energy: Float = 0 |
| 410 | for i in 0..<count { energy += samples[i] * samples[i] } |
| 411 | let rms = min((energy / Float(count)).squareRoot(), 1) |
| 412 | lock.withLock { $0 = max($0, rms) } |
| 413 | } |
| 414 | |
| 415 | /// Loudest since the last read. |
| 416 | public func take() -> Float { |
| 417 | lock.withLock { level in |
| 418 | let out = level |
| 419 | level = 0 |
| 420 | return out |
| 421 | } |
| 422 | } |
| 423 | } |
| 424 | |