From f4d7b62bb13813edeef75c1286ec73d839678bb0 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Sun, 5 Jul 2026 20:47:36 +0900 Subject: [PATCH 1/3] feat(audio): add microphone mute mode API Adds AudioManager.setMicrophoneMuteMode/getMicrophoneMuteMode with a LiveKit-owned MicrophoneMuteMode enum (voiceProcessing, restart, inputMixer), mirroring the Swift SDK. Controls how the AVAudioEngine audio device module mutes mic input on iOS/macOS. No-op elsewhere, including for unknown, so get/set round-trips are safe cross-platform. voiceProcessing (the default) plays the platform mute tone, inputMixer and restart mute silently. The mode applies to LiveKit's own mute path: disabling a published local audio track drives the engine-level microphone mute. Implemented on LiveKit's own plugin and method channel. The audio device module API is public in the WebRTC-SDK pod, so no flutter_webrtc change or release is needed. --- .changes/microphone-mute-mode | 1 + lib/src/audio/audio_manager.dart | 44 +++++++++++++++++ lib/src/audio/microphone_mute_mode.dart | 38 +++++++++++++++ lib/src/support/native.dart | 25 ++++++++++ lib/src/support/platform.dart | 2 + shared_swift/LiveKitPlugin.swift | 63 +++++++++++++++++++++++++ test/audio/audio_session_test.dart | 7 +++ 7 files changed, 180 insertions(+) create mode 100644 .changes/microphone-mute-mode create mode 100644 lib/src/audio/microphone_mute_mode.dart diff --git a/.changes/microphone-mute-mode b/.changes/microphone-mute-mode new file mode 100644 index 000000000..5931f4fb0 --- /dev/null +++ b/.changes/microphone-mute-mode @@ -0,0 +1 @@ +minor type="added" "AudioManager microphone mute mode control (voiceProcessing, restart, inputMixer) for iOS/macOS" diff --git a/lib/src/audio/audio_manager.dart b/lib/src/audio/audio_manager.dart index efb4a8d32..125e46a7f 100644 --- a/lib/src/audio/audio_manager.dart +++ b/lib/src/audio/audio_manager.dart @@ -23,6 +23,9 @@ import 'android_audio_session_adapter.dart'; import 'audio_processing_state.dart'; import 'audio_session.dart'; import 'audio_session_policy.dart'; +import 'microphone_mute_mode.dart'; + +export 'microphone_mute_mode.dart'; /// Snapshot of the WebRTC audio engine's playout/recording state. /// @@ -302,6 +305,47 @@ class AudioManager { automatic: _isAutomaticConfigurationEnabled, ); + /// How microphone input is muted on iOS/macOS. + /// + /// Returns [MicrophoneMuteMode.unknown] on other platforms. + Future getMicrophoneMuteMode() async { + if (!lkPlatformIsApple()) return MicrophoneMuteMode.unknown; + final mode = await Native.getMicrophoneMuteMode(); + return MicrophoneMuteMode.values.firstWhere( + (value) => value.name == mode, + orElse: () => MicrophoneMuteMode.unknown, + ); + } + + /// Sets how microphone input is muted on iOS/macOS. No-op elsewhere, + /// including for [MicrophoneMuteMode.unknown], so + /// `setMicrophoneMuteMode(await getMicrophoneMuteMode())` round-trips + /// safely on every platform. + /// + /// The default, [MicrophoneMuteMode.voiceProcessing], plays the platform's + /// mute/unmute sound effect and keeps the microphone observable for + /// muted-talker detection. Use [MicrophoneMuteMode.inputMixer] or + /// [MicrophoneMuteMode.restart] to mute silently. + /// + /// The mode applies whenever the engine mutes microphone input, which + /// includes LiveKit's own mute path: muting a published local audio track + /// (e.g. `LocalParticipant.setMicrophoneEnabled(false)`) disables the + /// track, and WebRTC mutes the engine input using this mode. With the + /// default `AudioCaptureOptions.stopAudioCaptureOnMute` (true) the capture + /// is additionally stopped after muting; for the fastest silent mute + /// toggling combine [MicrophoneMuteMode.inputMixer] with + /// `stopAudioCaptureOnMute: false`. Note that [MicrophoneMuteMode.restart] + /// restarts the audio engine on every mute toggle, which also + /// reconfigures the audio session (audible route changes on e.g. + /// Bluetooth headsets). + /// + /// This is engine-wide state; prefer setting it once before connecting. + Future setMicrophoneMuteMode(MicrophoneMuteMode mode) async { + if (mode == MicrophoneMuteMode.unknown) return; + if (!lkPlatformIsApple()) return; + await Native.setMicrophoneMuteMode(mode.name); + } + /// Diagnostic snapshot of the resolved audio processing state. /// /// The audio processing module is owned by the native peer connection factory diff --git a/lib/src/audio/microphone_mute_mode.dart b/lib/src/audio/microphone_mute_mode.dart new file mode 100644 index 000000000..f9fcae734 --- /dev/null +++ b/lib/src/audio/microphone_mute_mode.dart @@ -0,0 +1,38 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// Strategy used to mute microphone input on iOS/macOS. +/// +/// Applies to the AVAudioEngine-based audio device module, which is engine-wide +/// (process-global) state. Set via `AudioManager.setMicrophoneMuteMode`. +enum MicrophoneMuteMode { + /// Mute using Voice Processing I/O's input mute. + /// + /// Fast, and the OS keeps observing the input so muted-talker detection + /// remains possible, but the platform plays its mute/unmute sound effect. + voiceProcessing, + + /// Mute by restarting the audio engine without microphone input. + /// + /// Slower, but silent and stops microphone input entirely while muted. + restart, + + /// Mute by muting the engine's input mixer node. + /// + /// Fast and silent; the engine and audio session keep running. + inputMixer, + + /// The mode could not be determined (e.g. unsupported platform). + unknown, +} diff --git a/lib/src/support/native.dart b/lib/src/support/native.dart index 6817a9705..7858eaf7d 100644 --- a/lib/src/support/native.dart +++ b/lib/src/support/native.dart @@ -231,6 +231,31 @@ class Native { } } + /// Sets how the audio device module mutes microphone input (iOS/macOS). + /// + /// Unlike most methods in this class this deliberately does not swallow + /// platform errors: a failed change means muting behaves differently from + /// what the caller selected, so the error must reach the caller. + @internal + static Future setMicrophoneMuteMode(String mode) async { + await channel.invokeMethod( + 'setMicrophoneMuteMode', + {'mode': mode}, + ); + } + + /// Reads the audio device module's microphone mute mode (iOS/macOS). + /// Returns null when the native side cannot provide it. + @internal + static Future getMicrophoneMuteMode() async { + try { + return await channel.invokeMethod('getMicrophoneMuteMode', {}); + } catch (error) { + logger.warning('getMicrophoneMuteMode did throw $error'); + return null; + } + } + @internal static Future startVisualizer( String trackId, { diff --git a/lib/src/support/platform.dart b/lib/src/support/platform.dart index 242c0ff01..d6f0308c8 100644 --- a/lib/src/support/platform.dart +++ b/lib/src/support/platform.dart @@ -23,6 +23,8 @@ bool lkPlatformIsMobile() => [PlatformType.iOS, PlatformType.android].contains(l bool lkPlatformIsWebMobile() => lkPlatformIsWebMobileImplementation(); +bool lkPlatformIsApple() => [PlatformType.iOS, PlatformType.macOS].contains(lkPlatform()); + bool lkPlatformIsDesktop() => [ PlatformType.macOS, PlatformType.windows, diff --git a/shared_swift/LiveKitPlugin.swift b/shared_swift/LiveKitPlugin.swift index 2c6ae0ecf..58edd7a0c 100644 --- a/shared_swift/LiveKitPlugin.swift +++ b/shared_swift/LiveKitPlugin.swift @@ -438,6 +438,65 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { ]) } + // MARK: - Microphone mute mode + + static func muteModeString(_ mode: RTCAudioEngineMuteMode) -> String { + switch mode { + case .voiceProcessing: return "voiceProcessing" + case .restartEngine: return "restart" + case .inputMixer: return "inputMixer" + default: return "unknown" + } + } + + static func muteMode(from string: String) -> RTCAudioEngineMuteMode { + switch string { + case "voiceProcessing": return .voiceProcessing + case "restart": return .restartEngine + case "inputMixer": return .inputMixer + default: return .unknown + } + } + + public func handleSetMicrophoneMuteMode(args: [String: Any?], result: @escaping FlutterResult) { + let modeString = args["mode"] as? String ?? "" + let mode = LiveKitPlugin.muteMode(from: modeString) + if mode == .unknown { + result(FlutterError(code: "setMicrophoneMuteMode", message: "invalid mute mode: \(modeString)", details: nil)) + return + } + + guard let adm = FlutterWebRTCPlugin.sharedSingleton()?.peerConnectionFactory?.audioDeviceModule else { + result(FlutterError(code: "setMicrophoneMuteMode", message: "audio device module is unavailable", details: nil)) + return + } + + // Changing the mode while muted can rebuild the audio engine, so keep + // that work off the platform thread. + DispatchQueue.global(qos: .userInitiated).async { + let admResult = adm.setMuteMode(mode) + DispatchQueue.main.async { + if admResult == 0 { + result(nil) + } else { + result(FlutterError( + code: "setMicrophoneMuteMode", + message: "Audio engine returned error code: \(admResult)", + details: nil + )) + } + } + } + } + + public func handleGetMicrophoneMuteMode(result: @escaping FlutterResult) { + guard let adm = FlutterWebRTCPlugin.sharedSingleton()?.peerConnectionFactory?.audioDeviceModule else { + result(FlutterError(code: "getMicrophoneMuteMode", message: "audio device module is unavailable", details: nil)) + return + } + result(LiveKitPlugin.muteModeString(adm.muteMode)) + } + public func handleStartLocalRecording(args: [String: Any?], result: @escaping FlutterResult) { guard let adm = FlutterWebRTCPlugin.sharedSingleton()?.peerConnectionFactory?.audioDeviceModule else { result(FlutterError(code: "rejectedPlatformUnavailable", message: "audio device module is unavailable", details: nil)) @@ -600,6 +659,10 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { handleStartAudioRenderer(args: args, result: result) case "stopAudioRenderer": handleStopAudioRenderer(args: args, result: result) + case "setMicrophoneMuteMode": + handleSetMicrophoneMuteMode(args: args, result: result) + case "getMicrophoneMuteMode": + handleGetMicrophoneMuteMode(result: result) case "startLocalRecording": handleStartLocalRecording(args: args, result: result) case "stopLocalRecording": diff --git a/test/audio/audio_session_test.dart b/test/audio/audio_session_test.dart index 583f27995..32f64f807 100644 --- a/test/audio/audio_session_test.dart +++ b/test/audio/audio_session_test.dart @@ -590,6 +590,13 @@ void main() { expect(calls.single.arguments, {'enable': true, 'force': true}); }); + test('passes microphone mute mode to platform method', () async { + await Native.setMicrophoneMuteMode('inputMixer'); + + expect(calls.single.method, 'setMicrophoneMuteMode'); + expect(calls.single.arguments, {'mode': 'inputMixer'}); + }); + test('passes audio session deactivation to platform methods', () async { await Native.stopAndroidAudioSession(); await Native.deactivateAppleAudioSession(); From 4ec7888ffe83aa2863514dc3b36d8c1f4f94d245 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 7 Jul 2026 18:46:13 +0900 Subject: [PATCH 2/3] docs: document the throwing contract on setMicrophoneMuteMode The setter deliberately propagates native errors, unlike the getter which falls back to unknown. A silently failed mode change would leave muting behavior different from what the caller selected. This mirrors the Swift SDK's throwing API. --- lib/src/audio/audio_manager.dart | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/src/audio/audio_manager.dart b/lib/src/audio/audio_manager.dart index 125e46a7f..e5e28305e 100644 --- a/lib/src/audio/audio_manager.dart +++ b/lib/src/audio/audio_manager.dart @@ -332,14 +332,18 @@ class AudioManager { /// (e.g. `LocalParticipant.setMicrophoneEnabled(false)`) disables the /// track, and WebRTC mutes the engine input using this mode. With the /// default `AudioCaptureOptions.stopAudioCaptureOnMute` (true) the capture - /// is additionally stopped after muting; for the fastest silent mute + /// is additionally stopped after muting. For the fastest silent mute /// toggling combine [MicrophoneMuteMode.inputMixer] with /// `stopAudioCaptureOnMute: false`. Note that [MicrophoneMuteMode.restart] /// restarts the audio engine on every mute toggle, which also /// reconfigures the audio session (audible route changes on e.g. /// Bluetooth headsets). /// - /// This is engine-wide state; prefer setting it once before connecting. + /// Throws if the native side rejects the change (mirrors the Swift SDK's + /// throwing API), so callers never assume a muting behavior that is not + /// actually in effect. + /// + /// This is engine-wide state. Prefer setting it once before connecting. Future setMicrophoneMuteMode(MicrophoneMuteMode mode) async { if (mode == MicrophoneMuteMode.unknown) return; if (!lkPlatformIsApple()) return; From cb4f52bab2198a51001095b70d410e2f9741226d Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 7 Jul 2026 18:47:56 +0900 Subject: [PATCH 3/3] docs: drop cross-SDK reference from setMicrophoneMuteMode doc --- lib/src/audio/audio_manager.dart | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/src/audio/audio_manager.dart b/lib/src/audio/audio_manager.dart index e5e28305e..85d0ab700 100644 --- a/lib/src/audio/audio_manager.dart +++ b/lib/src/audio/audio_manager.dart @@ -339,9 +339,8 @@ class AudioManager { /// reconfigures the audio session (audible route changes on e.g. /// Bluetooth headsets). /// - /// Throws if the native side rejects the change (mirrors the Swift SDK's - /// throwing API), so callers never assume a muting behavior that is not - /// actually in effect. + /// Throws if the native side rejects the change, so callers never assume + /// a muting behavior that is not actually in effect. /// /// This is engine-wide state. Prefer setting it once before connecting. Future setMicrophoneMuteMode(MicrophoneMuteMode mode) async {