oficina / meet Public

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

Plain git — no account needed to clone.

Download

Download this file

1import AVFoundation
2import Foundation
3
4#if os(iOS)
5 import UIKit
6#endif
7
8/// Where the system's microphone mode is read from, and how its picker is
9/// opened. A protocol only so the tests can stand in for AVCaptureDevice,
10/// whose mode is a class property that a test cannot set: Apple lets the
11/// person choose the mode and lets an app do no more than look at it and
12/// open the picker.
13public protocol MicrophoneModeSource: AnyObject {
14 /// What the person chose in Control Center.
15 var preferred: AVCaptureDevice.MicrophoneMode { get }
16 /// What the microphone is actually doing, which can differ when the
17 /// route cannot honour the choice.
18 var active: AVCaptureDevice.MicrophoneMode { get }
19 /// Called whenever either may have changed, from any thread.
20 var changed: (@Sendable () -> Void)? { get set }
21 func showPicker()
22}
23
24/// The iPhone's microphone mode — Standard, Voice Isolation or Wide
25/// Spectrum — shown in words, with the way to Apple's picker beside it.
26///
27/// Voice Isolation is the best noise suppression the phone has: wind,
28/// typing and a room's echo, taken out before WebRTC ever sees the audio.
29/// It is also not ours to switch on. There is no setter; the mode belongs
30/// to the person and lives in Control Center, and the most an app can do
31/// is say which one is in force and open the picker at the right page.
32///
33/// It applies to Calls because the iOS WebRTC audio device runs on
34/// Apple's voice-processing I/O unit, under a `.videoChat` session, and
35/// the mic modes are voice-processing modes. The Mac app is deliberately
36/// left out: WebRTC's macOS audio device does not use voice processing,
37/// so the mode would not reach the call, and the Mac audio is the last
38/// place a change nobody needed should be made.
39@MainActor public final class MicrophoneModes: ObservableObject {
40 @Published public private(set) var preferred: AVCaptureDevice.MicrophoneMode
41 @Published public private(set) var active: AVCaptureDevice.MicrophoneMode
42 private let source: MicrophoneModeSource
43
44 public init(source: MicrophoneModeSource) {
45 self.source = source
46 preferred = source.preferred
47 active = source.active
48 source.changed = { [weak self] in
49 Task { @MainActor in self?.refresh() }
50 }
51 }
52
53 #if os(iOS)
54 public convenience init() { self.init(source: SystemMicrophoneModes()) }
55 #endif
56
57 public func refresh() {
58 preferred = source.preferred
59 active = source.active
60 }
61
62 public func showPicker() { source.showPicker() }
63
64 /// The mode in words. When the route has overridden the choice both are
65 /// named, because "Voice Isolation" on its own would be a claim about
66 /// audio the call is not getting.
67 public var summary: String {
68 MicrophoneModes.summary(preferred: preferred, active: active)
69 }
70
71 public nonisolated static func summary(
72 preferred: AVCaptureDevice.MicrophoneMode, active: AVCaptureDevice.MicrophoneMode
73 ) -> String {
74 if preferred == active { return name(active) }
75 return "\(name(active)) — \(name(preferred)) is chosen, but this microphone cannot use it."
76 }
77
78 /// Apple's own names for the modes, so the words here match the ones in
79 /// Control Center.
80 public nonisolated static func name(_ mode: AVCaptureDevice.MicrophoneMode) -> String {
81 switch mode {
82 case .standard: return "Standard"
83 case .voiceIsolation: return "Voice Isolation"
84 case .wideSpectrum: return "Wide Spectrum"
85 @unknown default: return "Unknown"
86 }
87 }
88}
89
90#if os(iOS)
91 /// The real thing: AVCaptureDevice's class properties, key-value observed.
92 ///
93 /// They are class properties, so the observation is registered on the
94 /// class object itself, which Swift's key-path `observe` cannot express.
95 /// Becoming active is watched as well: the picker is Control Center, so
96 /// the change happens while Calls is in the background, and returning is
97 /// the moment the panel most needs to be right.
98 final class SystemMicrophoneModes: NSObject, MicrophoneModeSource {
99 private static let keys = ["preferredMicrophoneMode", "activeMicrophoneMode"]
100 var changed: (@Sendable () -> Void)?
101 private var returning: NSObjectProtocol?
102
103 override init() {
104 super.init()
105 for key in Self.keys {
106 (AVCaptureDevice.self as AnyObject).addObserver(self, forKeyPath: key, options: [.new], context: nil)
107 }
108 returning = NotificationCenter.default.addObserver(
109 forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main
110 ) { [weak self] _ in self?.changed?() }
111 }
112
113 deinit {
114 for key in Self.keys {
115 (AVCaptureDevice.self as AnyObject).removeObserver(self, forKeyPath: key)
116 }
117 if let returning { NotificationCenter.default.removeObserver(returning) }
118 }
119
120 var preferred: AVCaptureDevice.MicrophoneMode { AVCaptureDevice.preferredMicrophoneMode }
121 var active: AVCaptureDevice.MicrophoneMode { AVCaptureDevice.activeMicrophoneMode }
122 func showPicker() { AVCaptureDevice.showSystemUserInterface(.microphoneModes) }
123
124 override func observeValue(
125 forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?,
126 context: UnsafeMutableRawPointer?
127 ) {
128 changed?()
129 }
130 }
131#endif
132