oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

1import Foundation
2import 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.
10public 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).
36public 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).
53public 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.
146public 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.
248public 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.
277public 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.
362public 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.
376public 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.
402public 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