Merge branch 'feat/ios-mic-mode' into integrate/native-2026-09-23
Shows Apple's Mic Mode in the iPhone device panel with a button to its picker. DevicePanel merged without a textual conflict: the iOS half (Mic Mode row) and the LiveKit branch's Mac half (microphone and speaker on the call's audio device module) sit in separate #if blocks, both kept.
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 b034d82..a1c83e5 100644 |
| --- a/native/README.md |
| +++ b/native/README.md |
| @@ -162,6 +162,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 5324ff7..6449c0a 100644 |
| --- a/native/apple/Sources/CallsKit/DevicePanel.swift |
| +++ b/native/apple/Sources/CallsKit/DevicePanel.swift |
| @@ -16,6 +16,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 |
| @@ -38,6 +41,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 |
| @@ -59,6 +63,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 |
| @@ -92,6 +99,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) |
| + } |
| +} |