Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changes/microphone-mute-mode
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
minor type="added" "AudioManager microphone mute mode control (voiceProcessing, restart, inputMixer) for iOS/macOS"
47 changes: 47 additions & 0 deletions lib/src/audio/audio_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down Expand Up @@ -302,6 +305,50 @@ class AudioManager {
automatic: _isAutomaticConfigurationEnabled,
);

/// How microphone input is muted on iOS/macOS.
///
/// Returns [MicrophoneMuteMode.unknown] on other platforms.
Future<MicrophoneMuteMode> 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).
///
/// 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<void> 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
Expand Down
38 changes: 38 additions & 0 deletions lib/src/audio/microphone_mute_mode.dart
Original file line number Diff line number Diff line change
@@ -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,
}
25 changes: 25 additions & 0 deletions lib/src/support/native.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> setMicrophoneMuteMode(String mode) async {
await channel.invokeMethod<void>(
'setMicrophoneMuteMode',
<String, dynamic>{'mode': mode},
);
}
Comment on lines +240 to +245

@devin-ai-integration devin-ai-integration Bot Jul 7, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Deliberate error-handling asymmetry between set and get native methods

The Native.setMicrophoneMuteMode at lib/src/support/native.dart:240-245 deliberately does NOT wrap the channel call in a try-catch, allowing PlatformExceptions to propagate to the caller. This is explicitly documented and intentional (a failed set means the caller's assumption about muting behavior is wrong). However, this is a departure from the pattern used by nearly every other method in the Native class (e.g. setAndroidSpeakerphoneOn, configureAudio, etc.), which all swallow errors with logger.warning. The Native.getMicrophoneMuteMode at lib/src/support/native.dart:250-257 follows the conventional swallow-and-return-null pattern. This asymmetry is justified by the doc comment but reviewers should be aware that callers of AudioManager.setMicrophoneMuteMode must handle exceptions, unlike most other AudioManager methods.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point. 4ec7888 documents the throwing contract on the public AudioManager.setMicrophoneMuteMode, matching the wording used for setEngineAvailability in #1127. The asymmetry itself stays: the setter propagates because a silently failed mode change would leave muting behavior different from what the caller selected, and the getter falls back to unknown since a failed read is harmless.


/// Reads the audio device module's microphone mute mode (iOS/macOS).
/// Returns null when the native side cannot provide it.
@internal
static Future<String?> getMicrophoneMuteMode() async {
try {
return await channel.invokeMethod<String>('getMicrophoneMuteMode', <String, dynamic>{});
} catch (error) {
logger.warning('getMicrophoneMuteMode did throw $error');
return null;
}
}

@internal
static Future<bool> startVisualizer(
String trackId, {
Expand Down
2 changes: 2 additions & 0 deletions lib/src/support/platform.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
63 changes: 63 additions & 0 deletions shared_swift/LiveKitPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
))
}
}
}
Comment on lines +476 to +489

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Background thread dispatch for setMuteMode may interact with concurrent calls

In shared_swift/LiveKitPlugin.swift:476-489, setMuteMode is dispatched to DispatchQueue.global(qos: .userInitiated) because it can rebuild the audio engine. If setMicrophoneMuteMode is called rapidly in succession (e.g. user toggling settings), multiple setMuteMode calls could execute concurrently on different global queue threads. The safety of this depends on whether RTCAudioDeviceModule.setMuteMode is internally serialized. This matches the pattern used by handleStartLocalRecording (line 448-461), so it's consistent with existing code, but worth noting if the underlying ADM method isn't thread-safe.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

}

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))
Expand Down Expand Up @@ -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":
Expand Down
7 changes: 7 additions & 0 deletions test/audio/audio_session_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading