iOS: show Mic Mode in the device panel and open Apple's picker
Voice Isolation is the best noise suppression an iPhone has, and it reaches the call because WebRTC's iOS audio device runs on Apple's voice-processing I/O unit. Apps cannot set the mode, so the panel names the one in force (both, when the route overrides the choice) and opens the system Mic Mode picker. AVCaptureDevice's class properties sit behind a small protocol so the wording and refresh are tested with a fake. The Mac app is untouched: its WebRTC audio does not use Apple's voice processing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+225
−0
native/README.md+10 −0native/apple/Sources/CallsKit/DevicePanel.swift+28 −0native/apple/Sources/CallsKit/MicrophoneMode.swift+131 −0native/apple/Tests/CallsKitTests/MicrophoneModeTests.swift+56 −0
diff --git a/native/README.md b/native/README.md| index 5f6a204..dbf0d06 100644 |
| --- a/native/README.md |
| +++ b/native/README.md |
| @@ -58,6 +58,16 @@ silently to every call the moment any headset connected, because |
| `.allowBluetoothHFP` was set unconditionally; it is now set only while a |
| Bluetooth input is the chosen one, and the panel says what it costs. |
| +On iPhone and iPad the panel also names Apple's **Mic Mode** — Standard, |
| +Voice Isolation or Wide Spectrum — and **Change Mic Mode** opens the system |
| +picker. Voice Isolation is the best noise suppression the device has (wind, |
| +typing, room echo), and it reaches the call because WebRTC's iOS audio device |
| +runs on Apple's voice-processing I/O unit. Apps cannot set the mode, only show |
| +it and open the picker; when the route cannot honour the chosen mode, the panel |
| +names the one actually in use. The Mac app does not offer it: WebRTC's macOS |
| +audio device does not use Apple's voice processing, so the mode would not reach |
| +the call. |
| + |
| The **audio readout** separates the two ways received audio goes wrong, |
| which is the distinction a person reporting "it growled" cannot make: |
| concealed samples are loss and sound like dropouts, while inserted and |
diff --git a/native/apple/Sources/CallsKit/DevicePanel.swift b/native/apple/Sources/CallsKit/DevicePanel.swift| index ab3c609..1f0fa77 100644 |
| --- a/native/apple/Sources/CallsKit/DevicePanel.swift |
| +++ b/native/apple/Sources/CallsKit/DevicePanel.swift |
| @@ -21,6 +21,9 @@ import SwiftUI |
| @ObservedObject private var call: Call |
| @ObservedObject private var audio: AudioRoutes |
| @State private var camera: String? |
| + #if os(iOS) |
| + @StateObject private var micModes = MicrophoneModes() |
| + #endif |
| public init(call: Call) { |
| self.call = call |
| @@ -43,6 +46,7 @@ import SwiftUI |
| } else if let only = audio.inputs.first { |
| row("Microphone", only.name) |
| } |
| + micMode |
| if !audio.outputName.isEmpty { row("Speaker", audio.outputName) } |
| #else |
| macAudio |
| @@ -62,6 +66,9 @@ import SwiftUI |
| .onAppear { |
| camera = call.currentCamera |
| audio.refresh() |
| + #if os(iOS) |
| + micModes.refresh() |
| + #endif |
| } |
| // The picker follows the camera, not the intention: a switch that was |
| // refused leaves the capture where it was, and the control has to go |
| @@ -95,6 +102,27 @@ import SwiftUI |
| .fixedSize(horizontal: false, vertical: true) |
| } |
| } |
| + |
| + /// Apple's Mic Mode, named and one tap from its picker. Calls cannot |
| + /// set it, only open the place where the person can; see |
| + /// MicrophoneModes for why it is worth the row. |
| + private var micMode: some View { |
| + VStack(alignment: .leading, spacing: 6) { |
| + VStack(alignment: .leading, spacing: 2) { |
| + Text("Mic Mode").font(.headline) |
| + Text(micModes.summary).font(.callout).foregroundStyle(.secondary) |
| + .fixedSize(horizontal: false, vertical: true) |
| + .accessibilityIdentifier("micMode") |
| + }.accessibilityElement(children: .combine) |
| + Button("Change Mic Mode", systemImage: "waveform") { micModes.showPicker() } |
| + .buttonStyle(.bordered) |
| + .accessibilityIdentifier("changeMicMode") |
| + .accessibilityHint("Opens the system Mic Mode picker.") |
| + Text("Voice Isolation filters out wind, typing and room echo. The choice is kept by iOS, not Calls.") |
| + .font(.footnote).foregroundStyle(.secondary) |
| + .fixedSize(horizontal: false, vertical: true) |
| + } |
| + } |
| #endif |
| #if os(macOS) |
diff --git a/native/apple/Sources/CallsKit/MicrophoneMode.swift b/native/apple/Sources/CallsKit/MicrophoneMode.swift| new file mode 100644 |
| index 0000000..1b4cbb7 |
| --- /dev/null |
| +++ b/native/apple/Sources/CallsKit/MicrophoneMode.swift |
| @@ -0,0 +1,131 @@ |
| +import AVFoundation |
| +import Foundation |
| + |
| +#if os(iOS) |
| + import UIKit |
| +#endif |
| + |
| +/// Where the system's microphone mode is read from, and how its picker is |
| +/// opened. A protocol only so the tests can stand in for AVCaptureDevice, |
| +/// whose mode is a class property that a test cannot set: Apple lets the |
| +/// person choose the mode and lets an app do no more than look at it and |
| +/// open the picker. |
| +public protocol MicrophoneModeSource: AnyObject { |
| + /// What the person chose in Control Center. |
| + var preferred: AVCaptureDevice.MicrophoneMode { get } |
| + /// What the microphone is actually doing, which can differ when the |
| + /// route cannot honour the choice. |
| + var active: AVCaptureDevice.MicrophoneMode { get } |
| + /// Called whenever either may have changed, from any thread. |
| + var changed: (@Sendable () -> Void)? { get set } |
| + func showPicker() |
| +} |
| + |
| +/// The iPhone's microphone mode — Standard, Voice Isolation or Wide |
| +/// Spectrum — shown in words, with the way to Apple's picker beside it. |
| +/// |
| +/// Voice Isolation is the best noise suppression the phone has: wind, |
| +/// typing and a room's echo, taken out before WebRTC ever sees the audio. |
| +/// It is also not ours to switch on. There is no setter; the mode belongs |
| +/// to the person and lives in Control Center, and the most an app can do |
| +/// is say which one is in force and open the picker at the right page. |
| +/// |
| +/// It applies to Calls because the iOS WebRTC audio device runs on |
| +/// Apple's voice-processing I/O unit, under a `.videoChat` session, and |
| +/// the mic modes are voice-processing modes. The Mac app is deliberately |
| +/// left out: WebRTC's macOS audio device does not use voice processing, |
| +/// so the mode would not reach the call, and the Mac audio is the last |
| +/// place a change nobody needed should be made. |
| +@MainActor public final class MicrophoneModes: ObservableObject { |
| + @Published public private(set) var preferred: AVCaptureDevice.MicrophoneMode |
| + @Published public private(set) var active: AVCaptureDevice.MicrophoneMode |
| + private let source: MicrophoneModeSource |
| + |
| + public init(source: MicrophoneModeSource) { |
| + self.source = source |
| + preferred = source.preferred |
| + active = source.active |
| + source.changed = { [weak self] in |
| + Task { @MainActor in self?.refresh() } |
| + } |
| + } |
| + |
| + #if os(iOS) |
| + public convenience init() { self.init(source: SystemMicrophoneModes()) } |
| + #endif |
| + |
| + public func refresh() { |
| + preferred = source.preferred |
| + active = source.active |
| + } |
| + |
| + public func showPicker() { source.showPicker() } |
| + |
| + /// The mode in words. When the route has overridden the choice both are |
| + /// named, because "Voice Isolation" on its own would be a claim about |
| + /// audio the call is not getting. |
| + public var summary: String { |
| + MicrophoneModes.summary(preferred: preferred, active: active) |
| + } |
| + |
| + public nonisolated static func summary( |
| + preferred: AVCaptureDevice.MicrophoneMode, active: AVCaptureDevice.MicrophoneMode |
| + ) -> String { |
| + if preferred == active { return name(active) } |
| + return "\(name(active)) — \(name(preferred)) is chosen, but this microphone cannot use it." |
| + } |
| + |
| + /// Apple's own names for the modes, so the words here match the ones in |
| + /// Control Center. |
| + public nonisolated static func name(_ mode: AVCaptureDevice.MicrophoneMode) -> String { |
| + switch mode { |
| + case .standard: return "Standard" |
| + case .voiceIsolation: return "Voice Isolation" |
| + case .wideSpectrum: return "Wide Spectrum" |
| + @unknown default: return "Unknown" |
| + } |
| + } |
| +} |
| + |
| +#if os(iOS) |
| + /// The real thing: AVCaptureDevice's class properties, key-value observed. |
| + /// |
| + /// They are class properties, so the observation is registered on the |
| + /// class object itself, which Swift's key-path `observe` cannot express. |
| + /// Becoming active is watched as well: the picker is Control Center, so |
| + /// the change happens while Calls is in the background, and returning is |
| + /// the moment the panel most needs to be right. |
| + final class SystemMicrophoneModes: NSObject, MicrophoneModeSource { |
| + private static let keys = ["preferredMicrophoneMode", "activeMicrophoneMode"] |
| + var changed: (@Sendable () -> Void)? |
| + private var returning: NSObjectProtocol? |
| + |
| + override init() { |
| + super.init() |
| + for key in Self.keys { |
| + (AVCaptureDevice.self as AnyObject).addObserver(self, forKeyPath: key, options: [.new], context: nil) |
| + } |
| + returning = NotificationCenter.default.addObserver( |
| + forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main |
| + ) { [weak self] _ in self?.changed?() } |
| + } |
| + |
| + deinit { |
| + for key in Self.keys { |
| + (AVCaptureDevice.self as AnyObject).removeObserver(self, forKeyPath: key) |
| + } |
| + if let returning { NotificationCenter.default.removeObserver(returning) } |
| + } |
| + |
| + var preferred: AVCaptureDevice.MicrophoneMode { AVCaptureDevice.preferredMicrophoneMode } |
| + var active: AVCaptureDevice.MicrophoneMode { AVCaptureDevice.activeMicrophoneMode } |
| + func showPicker() { AVCaptureDevice.showSystemUserInterface(.microphoneModes) } |
| + |
| + override func observeValue( |
| + forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, |
| + context: UnsafeMutableRawPointer? |
| + ) { |
| + changed?() |
| + } |
| + } |
| +#endif |
diff --git a/native/apple/Tests/CallsKitTests/MicrophoneModeTests.swift b/native/apple/Tests/CallsKitTests/MicrophoneModeTests.swift| new file mode 100644 |
| index 0000000..cd3c9ae |
| --- /dev/null |
| +++ b/native/apple/Tests/CallsKitTests/MicrophoneModeTests.swift |
| @@ -0,0 +1,56 @@ |
| +import AVFoundation |
| +import XCTest |
| + |
| +@testable import CallsKit |
| + |
| +/// The Mic Mode row is only shown on iPhone, but what it says is plain |
| +/// logic over two values, and it is held here with a stand-in for |
| +/// AVCaptureDevice, whose modes a test cannot set. |
| +@MainActor final class MicrophoneModeTests: XCTestCase { |
| + final class FakeModes: MicrophoneModeSource { |
| + var preferred: AVCaptureDevice.MicrophoneMode = .standard |
| + var active: AVCaptureDevice.MicrophoneMode = .standard |
| + var changed: (@Sendable () -> Void)? |
| + var pickerShown = 0 |
| + func showPicker() { pickerShown += 1 } |
| + } |
| + |
| + func testTheModesAreCalledWhatControlCenterCallsThem() { |
| + XCTAssertEqual(MicrophoneModes.name(.standard), "Standard") |
| + XCTAssertEqual(MicrophoneModes.name(.voiceIsolation), "Voice Isolation") |
| + XCTAssertEqual(MicrophoneModes.name(.wideSpectrum), "Wide Spectrum") |
| + } |
| + |
| + func testAnOverriddenChoiceNamesBothAndLeadsWithTheTruth() { |
| + // Saying "Voice Isolation" while the route runs Standard would be a |
| + // claim about audio the call is not getting. |
| + XCTAssertEqual(MicrophoneModes.summary(preferred: .voiceIsolation, active: .voiceIsolation), "Voice Isolation") |
| + let overridden = MicrophoneModes.summary(preferred: .voiceIsolation, active: .standard) |
| + XCTAssertTrue(overridden.hasPrefix("Standard"), overridden) |
| + XCTAssertTrue(overridden.contains("Voice Isolation is chosen"), overridden) |
| + } |
| + |
| + func testTheRowFollowsTheSystemWhenItSaysSomethingChanged() async { |
| + let fake = FakeModes() |
| + let modes = MicrophoneModes(source: fake) |
| + XCTAssertEqual(modes.summary, "Standard") |
| + |
| + fake.preferred = .voiceIsolation |
| + fake.active = .voiceIsolation |
| + fake.changed?() |
| + // The change is delivered through a main-actor hop, as a KVO callback |
| + // from any thread would be. |
| + for _ in 0..<10 where modes.active != .voiceIsolation { await Task.yield() } |
| + XCTAssertEqual(modes.preferred, .voiceIsolation) |
| + XCTAssertEqual(modes.summary, "Voice Isolation") |
| + } |
| + |
| + func testChangingTheModeIsTheSystemPickersJob() { |
| + // There is no setter to call: the button only opens Apple's picker. |
| + let fake = FakeModes() |
| + let modes = MicrophoneModes(source: fake) |
| + modes.showPicker() |
| + XCTAssertEqual(fake.pickerShown, 1) |
| + XCTAssertEqual(modes.active, .standard) |
| + } |
| +} |