-
Notifications
You must be signed in to change notification settings - Fork 240
feat(audio): add AudioManager microphone mute mode API #1124
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
f4d7b62
4ec7888
cb4f52b
6abecc6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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" |
| 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, | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔍 Background thread dispatch for setMuteMode may interact with concurrent calls In 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)) | ||
|
|
@@ -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": | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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.setMicrophoneMuteModeatlib/src/support/native.dart:240-245deliberately 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 theNativeclass (e.g.setAndroidSpeakerphoneOn,configureAudio, etc.), which all swallow errors withlogger.warning. TheNative.getMicrophoneMuteModeatlib/src/support/native.dart:250-257follows the conventional swallow-and-return-null pattern. This asymmetry is justified by the doc comment but reviewers should be aware that callers ofAudioManager.setMicrophoneMuteModemust handle exceptions, unlike most other AudioManager methods.Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
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.