oficina / meet Public

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>
Paul Campbell pushed by paul@keymail.dev 5c8dc0a05645ae39c1f1db33d2424331dd8a8fcf parent 00b96abparent 763e217
4 files changed, +225 −0
  • native/README.md +10 −0
  • native/apple/Sources/CallsKit/DevicePanel.swift +28 −0
  • native/apple/Sources/CallsKit/MicrophoneMode.swift +131 −0
  • native/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)
+ }
+}