Skip to content

Commit 166d6ae

Browse files
authored
feat(audio): add AudioManager microphone mute mode API (#1124)
## Description Adds `AudioManager.setMicrophoneMuteMode` / `getMicrophoneMuteMode` with a LiveKit-owned `MicrophoneMuteMode` enum (`voiceProcessing`, `restart`, `inputMixer`), mirroring the Swift SDK's `AudioManager.microphoneMuteMode`. ## Background On iOS/macOS the AVAudioEngine-based ADM defaults to `voiceProcessing` muting, which plays the platform's mute/unmute sound effect (see flutter-webrtc/flutter-webrtc#2098). Apps can select `inputMixer` or `restart` to mute silently. ```dart await AudioManager.instance.setMicrophoneMuteMode(MicrophoneMuteMode.inputMixer); ``` The mode applies to LiveKit's own mute path: disabling a published local audio track drives the engine-level microphone mute in webrtc (`WebRtcVoiceSendChannel::MuteStream` -> `adm->SetMicrophoneMute`). For the fastest silent mute toggling, combine `inputMixer` with `AudioCaptureOptions(stopAudioCaptureOnMute: false)` (documented on the API). ## Implementation notes - Self-contained: implemented on LiveKit's own plugin and method channel. `LiveKitPlugin.swift` calls the ADM's `muteMode`/`setMuteMode:` directly (public API in the WebRTC-SDK pod already linked), so this does not depend on any flutter_webrtc change or release. The companion flutter-webrtc PR (flutter-webrtc/flutter-webrtc#2105) exposes the same control to plain flutter-webrtc users independently. - No flutter_webrtc types on the public surface: the enum is LiveKit-owned and named to match the Swift SDK (`restart`, not `restartEngine`). - No-op on non-Apple platforms, including for `unknown`, so `set(await get())` round-trips safely in cross-platform code. - The setter runs off the platform thread natively, since a mode change while muted can rebuild the audio engine. Setter errors propagate to the caller (mirrors the Swift SDK's throwing API), the getter falls back to `unknown`. - Engine-wide state, recommended to set once before connecting. ## Testing - `dart analyze lib test` clean, all 315 tests pass. - iOS example builds against the released flutter_webrtc 1.5.2 pin, no dependency override needed.
1 parent 02d1a89 commit 166d6ae

6 files changed

Lines changed: 180 additions & 0 deletions

File tree

.changes/microphone-mute-mode

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
minor type="added" "Microphone mute mode control on iOS/macOS"

lib/src/audio/audio_manager.dart

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,10 @@ import 'audio_engine_availability.dart';
2424
import 'audio_processing_state.dart';
2525
import 'audio_session.dart';
2626
import 'audio_session_policy.dart';
27+
import 'microphone_mute_mode.dart';
2728

2829
export 'audio_engine_availability.dart';
30+
export 'microphone_mute_mode.dart';
2931

3032
/// Snapshot of the WebRTC audio engine's playout/recording state.
3133
///
@@ -359,6 +361,50 @@ class AudioManager {
359361
automatic: _isAutomaticConfigurationEnabled,
360362
);
361363

364+
/// How microphone input is muted on iOS/macOS.
365+
///
366+
/// Returns [MicrophoneMuteMode.unknown] on other platforms.
367+
Future<MicrophoneMuteMode> getMicrophoneMuteMode() async {
368+
if (!lkPlatformIsApple()) return MicrophoneMuteMode.unknown;
369+
final mode = await Native.getMicrophoneMuteMode();
370+
return MicrophoneMuteMode.values.firstWhere(
371+
(value) => value.name == mode,
372+
orElse: () => MicrophoneMuteMode.unknown,
373+
);
374+
}
375+
376+
/// Sets how microphone input is muted on iOS/macOS. No-op elsewhere,
377+
/// including for [MicrophoneMuteMode.unknown], so
378+
/// `setMicrophoneMuteMode(await getMicrophoneMuteMode())` round-trips
379+
/// safely on every platform.
380+
///
381+
/// The default, [MicrophoneMuteMode.voiceProcessing], plays the platform's
382+
/// mute/unmute sound effect and keeps the microphone observable for
383+
/// muted-talker detection. Use [MicrophoneMuteMode.inputMixer] or
384+
/// [MicrophoneMuteMode.restart] to mute silently.
385+
///
386+
/// The mode applies whenever the engine mutes microphone input, which
387+
/// includes LiveKit's own mute path: muting a published local audio track
388+
/// (e.g. `LocalParticipant.setMicrophoneEnabled(false)`) disables the
389+
/// track, and WebRTC mutes the engine input using this mode. With the
390+
/// default `AudioCaptureOptions.stopAudioCaptureOnMute` (true) the capture
391+
/// is additionally stopped after muting. For the fastest silent mute
392+
/// toggling combine [MicrophoneMuteMode.inputMixer] with
393+
/// `stopAudioCaptureOnMute: false`. Note that [MicrophoneMuteMode.restart]
394+
/// restarts the audio engine on every mute toggle, which also
395+
/// reconfigures the audio session (audible route changes on e.g.
396+
/// Bluetooth headsets).
397+
///
398+
/// Throws if the native side rejects the change, so callers never assume
399+
/// a muting behavior that is not actually in effect.
400+
///
401+
/// This is engine-wide state. Prefer setting it once before connecting.
402+
Future<void> setMicrophoneMuteMode(MicrophoneMuteMode mode) async {
403+
if (mode == MicrophoneMuteMode.unknown) return;
404+
if (!lkPlatformIsApple()) return;
405+
await Native.setMicrophoneMuteMode(mode.name);
406+
}
407+
362408
/// Diagnostic snapshot of the resolved audio processing state.
363409
///
364410
/// The audio processing module is owned by the native peer connection factory
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// Copyright 2026 LiveKit, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
/// Strategy used to mute microphone input on iOS/macOS.
16+
///
17+
/// Applies to the AVAudioEngine-based audio device module, which is engine-wide
18+
/// (process-global) state. Set via `AudioManager.setMicrophoneMuteMode`.
19+
enum MicrophoneMuteMode {
20+
/// Mute using Voice Processing I/O's input mute.
21+
///
22+
/// Fast, and the OS keeps observing the input so muted-talker detection
23+
/// remains possible, but the platform plays its mute/unmute sound effect.
24+
voiceProcessing,
25+
26+
/// Mute by restarting the audio engine without microphone input.
27+
///
28+
/// Slower, but silent and stops microphone input entirely while muted.
29+
restart,
30+
31+
/// Mute by muting the engine's input mixer node.
32+
///
33+
/// Fast and silent; the engine and audio session keep running.
34+
inputMixer,
35+
36+
/// The mode could not be determined (e.g. unsupported platform).
37+
unknown,
38+
}

lib/src/support/native.dart

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,31 @@ class Native {
241241
}
242242
}
243243

244+
/// Sets how the audio device module mutes microphone input (iOS/macOS).
245+
///
246+
/// Unlike most methods in this class this deliberately does not swallow
247+
/// platform errors: a failed change means muting behaves differently from
248+
/// what the caller selected, so the error must reach the caller.
249+
@internal
250+
static Future<void> setMicrophoneMuteMode(String mode) async {
251+
await channel.invokeMethod<void>(
252+
'setMicrophoneMuteMode',
253+
<String, dynamic>{'mode': mode},
254+
);
255+
}
256+
257+
/// Reads the audio device module's microphone mute mode (iOS/macOS).
258+
/// Returns null when the native side cannot provide it.
259+
@internal
260+
static Future<String?> getMicrophoneMuteMode() async {
261+
try {
262+
return await channel.invokeMethod<String>('getMicrophoneMuteMode', <String, dynamic>{});
263+
} catch (error) {
264+
logger.warning('getMicrophoneMuteMode did throw $error');
265+
return null;
266+
}
267+
}
268+
244269
/// Sets whether the WebRTC audio engine is allowed to run (iOS/macOS).
245270
///
246271
/// Unlike most methods in this class this deliberately does not swallow

shared_swift/LiveKitPlugin.swift

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -534,6 +534,65 @@ public class LiveKitPlugin: NSObject, FlutterPlugin {
534534
}
535535
}
536536

537+
// MARK: - Microphone mute mode
538+
539+
static func muteModeString(_ mode: RTCAudioEngineMuteMode) -> String {
540+
switch mode {
541+
case .voiceProcessing: return "voiceProcessing"
542+
case .restartEngine: return "restart"
543+
case .inputMixer: return "inputMixer"
544+
default: return "unknown"
545+
}
546+
}
547+
548+
static func muteMode(from string: String) -> RTCAudioEngineMuteMode {
549+
switch string {
550+
case "voiceProcessing": return .voiceProcessing
551+
case "restart": return .restartEngine
552+
case "inputMixer": return .inputMixer
553+
default: return .unknown
554+
}
555+
}
556+
557+
public func handleSetMicrophoneMuteMode(args: [String: Any?], result: @escaping FlutterResult) {
558+
let modeString = args["mode"] as? String ?? ""
559+
let mode = LiveKitPlugin.muteMode(from: modeString)
560+
if mode == .unknown {
561+
result(FlutterError(code: "setMicrophoneMuteMode", message: "invalid mute mode: \(modeString)", details: nil))
562+
return
563+
}
564+
565+
guard let adm = FlutterWebRTCPlugin.sharedSingleton()?.peerConnectionFactory?.audioDeviceModule else {
566+
result(FlutterError(code: "setMicrophoneMuteMode", message: "audio device module is unavailable", details: nil))
567+
return
568+
}
569+
570+
// Changing the mode while muted can rebuild the audio engine, so keep
571+
// that work off the platform thread.
572+
DispatchQueue.global(qos: .userInitiated).async {
573+
let admResult = adm.setMuteMode(mode)
574+
DispatchQueue.main.async {
575+
if admResult == 0 {
576+
result(nil)
577+
} else {
578+
result(FlutterError(
579+
code: "setMicrophoneMuteMode",
580+
message: "Audio engine returned error code: \(admResult)",
581+
details: nil
582+
))
583+
}
584+
}
585+
}
586+
}
587+
588+
public func handleGetMicrophoneMuteMode(result: @escaping FlutterResult) {
589+
guard let adm = FlutterWebRTCPlugin.sharedSingleton()?.peerConnectionFactory?.audioDeviceModule else {
590+
result(FlutterError(code: "getMicrophoneMuteMode", message: "audio device module is unavailable", details: nil))
591+
return
592+
}
593+
result(LiveKitPlugin.muteModeString(adm.muteMode))
594+
}
595+
537596
public func handleStartLocalRecording(args: [String: Any?], result: @escaping FlutterResult) {
538597
guard let adm = FlutterWebRTCPlugin.sharedSingleton()?.peerConnectionFactory?.audioDeviceModule else {
539598
result(FlutterError(code: "rejectedPlatformUnavailable", message: "audio device module is unavailable", details: nil))
@@ -696,6 +755,10 @@ public class LiveKitPlugin: NSObject, FlutterPlugin {
696755
handleStartAudioRenderer(args: args, result: result)
697756
case "stopAudioRenderer":
698757
handleStopAudioRenderer(args: args, result: result)
758+
case "setMicrophoneMuteMode":
759+
handleSetMicrophoneMuteMode(args: args, result: result)
760+
case "getMicrophoneMuteMode":
761+
handleGetMicrophoneMuteMode(result: result)
699762
case "startLocalRecording":
700763
handleStartLocalRecording(args: args, result: result)
701764
case "stopLocalRecording":

test/audio/audio_session_test.dart

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -591,6 +591,13 @@ void main() {
591591
expect(calls.single.arguments, {'enable': true, 'force': true});
592592
});
593593

594+
test('passes microphone mute mode to platform method', () async {
595+
await Native.setMicrophoneMuteMode('inputMixer');
596+
597+
expect(calls.single.method, 'setMicrophoneMuteMode');
598+
expect(calls.single.arguments, {'mode': 'inputMixer'});
599+
});
600+
594601
test('passes session activation ownership to Apple management method', () async {
595602
await Native.setAppleAudioSessionAutomaticManagementEnabled(true, sessionActivationEnabled: false);
596603

0 commit comments

Comments
 (0)