-
Notifications
You must be signed in to change notification settings - Fork 234
feat(audio): AudioManager audio session management #1108
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
10b03b6
4d7ebdc
003f693
c31ebb9
3e0d3bf
23f4871
b5a83a3
3e237d2
115c43b
65e8a0b
e35b036
f7de7f7
2a5830d
93b8284
964cbbf
c0b14df
0b5656c
41d6eb6
24427e9
a9ebb17
feacfb4
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 audio session options with engine-driven native lifecycle and platform routing controls" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,265 @@ | ||
| /* | ||
| * 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. | ||
| */ | ||
|
|
||
| package io.livekit.plugin | ||
|
|
||
| import android.content.Context | ||
| import android.media.AudioAttributes | ||
| import android.media.AudioManager | ||
| import android.os.Build | ||
| import android.os.Handler | ||
| import android.os.HandlerThread | ||
| import com.twilio.audioswitch.AbstractAudioSwitch | ||
| import com.twilio.audioswitch.AudioDevice | ||
| import com.twilio.audioswitch.AudioSwitch | ||
| import com.twilio.audioswitch.CommDeviceAudioSwitch | ||
| import com.twilio.audioswitch.LegacyAudioSwitch | ||
|
|
||
| /** | ||
| * Manages the Android platform audio session (audio mode, audio focus, and | ||
| * output routing) for the LiveKit Flutter SDK, built on top of [AudioSwitch]. | ||
| * | ||
| * This is LiveKit's own port of the audio-handling best practices from the | ||
| * LiveKit Android SDK (`AudioSwitchHandler`) and flutter_webrtc | ||
| * (`AudioSwitchManager`), so the Flutter SDK can own the platform audio session | ||
| * directly instead of delegating to flutter_webrtc's native audio management. | ||
| * | ||
| * [AudioSwitch] is not thread-safe, so every interaction with it runs on a | ||
| * single dedicated [HandlerThread]. | ||
| */ | ||
| internal class LKAudioSwitchManager(private val context: Context) { | ||
| // AudioSwitch is not threadsafe, so confine all access to a single long-lived | ||
| // thread. The AudioSwitch instance is recreated per active session, while | ||
| // queued lifecycle work stays serialized on this thread. | ||
| private val thread = HandlerThread("LKAudioSwitchThread").also { it.start() } | ||
| private val handler = Handler(thread.looper) | ||
|
|
||
| private var audioSwitch: AbstractAudioSwitch? = null | ||
| private var isActive = false | ||
|
|
||
| // Configuration. Defaults mirror a communication/VoIP session and match the | ||
| // AudioSwitchHandler defaults in the LiveKit Android SDK. | ||
| private val loggingEnabled = false | ||
| private var manageAudioFocus = true | ||
| private var audioMode = AudioManager.MODE_IN_COMMUNICATION | ||
| private var focusMode = AudioManager.AUDIOFOCUS_GAIN | ||
| private var audioStreamType = AudioManager.STREAM_VOICE_CALL | ||
| private var audioAttributeUsageType = AudioAttributes.USAGE_VOICE_COMMUNICATION | ||
| private var audioAttributeContentType = AudioAttributes.CONTENT_TYPE_SPEECH | ||
| private var forceHandleAudioRouting = false | ||
|
|
||
| private var speakerOutputPreferred = true | ||
| private var speakerOutputForced = false | ||
| private var preferredDeviceList = preferredDeviceList() | ||
|
|
||
| /** | ||
| * Apply an audio session configuration. Unspecified keys keep their current | ||
| * value. Changes are applied to a running [AudioSwitch] without a restart. | ||
| */ | ||
| @Synchronized | ||
| fun configure(configuration: Map<String, Any?>) { | ||
| (configuration["manageAudioFocus"] as? Boolean)?.let { manageAudioFocus = it } | ||
| audioModeForName(configuration["androidAudioMode"] as? String)?.let { audioMode = it } | ||
| focusModeForName(configuration["androidAudioFocusMode"] as? String)?.let { focusMode = it } | ||
| streamTypeForName(configuration["androidAudioStreamType"] as? String)?.let { audioStreamType = it } | ||
| usageTypeForName(configuration["androidAudioAttributesUsageType"] as? String)?.let { audioAttributeUsageType = it } | ||
| contentTypeForName(configuration["androidAudioAttributesContentType"] as? String)?.let { audioAttributeContentType = it } | ||
| (configuration["forceHandleAudioRouting"] as? Boolean)?.let { forceHandleAudioRouting = it } | ||
|
|
||
| // Apply to a live switch so reconfiguration (e.g. communication -> media) | ||
| // does not require a restart. No-op until the switch exists. | ||
| handler.post { audioSwitch?.let { applyConfiguration(it) } } | ||
| } | ||
|
|
||
| /** Create (if needed) and activate the audio session: acquire focus, set mode and routing. */ | ||
| @Synchronized | ||
| fun start() { | ||
| handler.post { | ||
| val switch = audioSwitch ?: createSwitch().also { audioSwitch = it } | ||
| if (!isActive) { | ||
| switch.activate() | ||
| applySpeakerRouting(switch) | ||
| isActive = true | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /** Deactivate and tear down the audio session: release focus and restore the previous mode. */ | ||
| @Synchronized | ||
| fun stop() { | ||
| handler.post { | ||
| audioSwitch?.stop() | ||
| audioSwitch = null | ||
| isActive = false | ||
| } | ||
| } | ||
|
|
||
| /** Final cleanup when the plugin detaches. The manager must not be used after this. */ | ||
| @Synchronized | ||
| fun dispose() { | ||
| handler.post { | ||
| audioSwitch?.stop() | ||
| audioSwitch = null | ||
| isActive = false | ||
| thread.quitSafely() | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Prefer routing to/from the speaker, letting a connected headset keep priority | ||
| * unless [force] is true. | ||
| */ | ||
| @Synchronized | ||
| fun setSpeakerphoneOn(enable: Boolean, force: Boolean) { | ||
| speakerOutputPreferred = enable | ||
| speakerOutputForced = enable && force | ||
| preferredDeviceList = preferredDeviceList() | ||
| handler.post { | ||
| val switch = audioSwitch ?: return@post | ||
| applySpeakerRouting(switch) | ||
| } | ||
| } | ||
|
|
||
| private fun createSwitch(): AbstractAudioSwitch { | ||
| val focusListener = AudioManager.OnAudioFocusChangeListener { } | ||
| // API-aware switch selection, matching the LiveKit Android SDK's | ||
| // AudioSwitchHandler: CommDeviceAudioSwitch uses the modern | ||
| // AudioManager.setCommunicationDevice routing on API 31+. | ||
| val switch = when { | ||
| Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> | ||
| CommDeviceAudioSwitch(context, loggingEnabled, focusListener, preferredDeviceList) | ||
|
|
||
| Build.VERSION.SDK_INT >= Build.VERSION_CODES.M -> | ||
| AudioSwitch(context, loggingEnabled, focusListener, preferredDeviceList) | ||
|
|
||
| else -> | ||
| LegacyAudioSwitch(context, loggingEnabled, focusListener, preferredDeviceList) | ||
| } | ||
| applyConfiguration(switch) | ||
| switch.start { _, _ -> } | ||
| return switch | ||
| } | ||
|
|
||
| private fun applyConfiguration(switch: AbstractAudioSwitch) { | ||
| switch.manageAudioFocus = manageAudioFocus | ||
| switch.audioMode = audioMode | ||
| switch.focusMode = focusMode | ||
| switch.audioStreamType = audioStreamType | ||
| switch.audioAttributeUsageType = audioAttributeUsageType | ||
| switch.audioAttributeContentType = audioAttributeContentType | ||
| switch.forceHandleAudioRouting = forceHandleAudioRouting | ||
| } | ||
|
|
||
| private fun applySpeakerRouting(switch: AbstractAudioSwitch) { | ||
| switch.setPreferredDeviceList(preferredDeviceList) | ||
| val forcedSpeaker = if (speakerOutputForced) { | ||
| switch.availableAudioDevices.firstOrNull { it is AudioDevice.Speakerphone } | ||
| } else { | ||
| null | ||
| } | ||
| // AudioSwitch selections are sticky. Use them only for forced speaker output; | ||
| // clearing the selection lets the preferred-device list handle normal routing | ||
| // and headset hot-plug priority. | ||
| switch.selectDevice(forcedSpeaker) | ||
| } | ||
|
|
||
| private fun preferredDeviceList(): List<Class<out AudioDevice>> = | ||
| when { | ||
| speakerOutputForced -> listOf( | ||
| AudioDevice.Speakerphone::class.java, | ||
| AudioDevice.BluetoothHeadset::class.java, | ||
| AudioDevice.WiredHeadset::class.java, | ||
| AudioDevice.Earpiece::class.java, | ||
| ) | ||
|
|
||
| speakerOutputPreferred -> listOf( | ||
| AudioDevice.BluetoothHeadset::class.java, | ||
| AudioDevice.WiredHeadset::class.java, | ||
| AudioDevice.Speakerphone::class.java, | ||
| AudioDevice.Earpiece::class.java, | ||
| ) | ||
|
|
||
| else -> listOf( | ||
| AudioDevice.BluetoothHeadset::class.java, | ||
| AudioDevice.WiredHeadset::class.java, | ||
| AudioDevice.Earpiece::class.java, | ||
| AudioDevice.Speakerphone::class.java, | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| // Map the Flutter-side enum names (see android_audio_session_adapter.dart) to | ||
| // Android framework constants. Ported from flutter_webrtc's AudioUtils. | ||
|
|
||
| private fun audioModeForName(name: String?): Int? = when (name) { | ||
| null -> null | ||
| "normal" -> AudioManager.MODE_NORMAL | ||
| "callScreening" -> AudioManager.MODE_CALL_SCREENING | ||
| "inCall" -> AudioManager.MODE_IN_CALL | ||
| "inCommunication" -> AudioManager.MODE_IN_COMMUNICATION | ||
| "ringtone" -> AudioManager.MODE_RINGTONE | ||
| else -> null | ||
| } | ||
|
|
||
| private fun focusModeForName(name: String?): Int? = when (name) { | ||
| null -> null | ||
| "gain" -> AudioManager.AUDIOFOCUS_GAIN | ||
| "gainTransient" -> AudioManager.AUDIOFOCUS_GAIN_TRANSIENT | ||
| "gainTransientExclusive" -> AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE | ||
| "gainTransientMayDuck" -> AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK | ||
| else -> null | ||
| } | ||
|
|
||
| private fun streamTypeForName(name: String?): Int? = when (name) { | ||
| null -> null | ||
| "accessibility" -> AudioManager.STREAM_ACCESSIBILITY | ||
| "alarm" -> AudioManager.STREAM_ALARM | ||
| "dtmf" -> AudioManager.STREAM_DTMF | ||
| "music" -> AudioManager.STREAM_MUSIC | ||
| "notification" -> AudioManager.STREAM_NOTIFICATION | ||
| "ring" -> AudioManager.STREAM_RING | ||
| "system" -> AudioManager.STREAM_SYSTEM | ||
| "voiceCall" -> AudioManager.STREAM_VOICE_CALL | ||
| else -> null | ||
| } | ||
|
|
||
| private fun usageTypeForName(name: String?): Int? = when (name) { | ||
| null -> null | ||
| "alarm" -> AudioAttributes.USAGE_ALARM | ||
| "assistanceAccessibility" -> AudioAttributes.USAGE_ASSISTANCE_ACCESSIBILITY | ||
| "assistanceNavigationGuidance" -> AudioAttributes.USAGE_ASSISTANCE_NAVIGATION_GUIDANCE | ||
| "assistanceSonification" -> AudioAttributes.USAGE_ASSISTANCE_SONIFICATION | ||
| "assistant" -> AudioAttributes.USAGE_ASSISTANT | ||
| "game" -> AudioAttributes.USAGE_GAME | ||
| "media" -> AudioAttributes.USAGE_MEDIA | ||
| "notification" -> AudioAttributes.USAGE_NOTIFICATION | ||
| "notificationEvent" -> AudioAttributes.USAGE_NOTIFICATION_EVENT | ||
| "notificationRingtone" -> AudioAttributes.USAGE_NOTIFICATION_RINGTONE | ||
| "unknown" -> AudioAttributes.USAGE_UNKNOWN | ||
| "voiceCommunication" -> AudioAttributes.USAGE_VOICE_COMMUNICATION | ||
| "voiceCommunicationSignalling" -> AudioAttributes.USAGE_VOICE_COMMUNICATION_SIGNALLING | ||
| else -> null | ||
| } | ||
|
|
||
| private fun contentTypeForName(name: String?): Int? = when (name) { | ||
| null -> null | ||
| "movie" -> AudioAttributes.CONTENT_TYPE_MOVIE | ||
| "music" -> AudioAttributes.CONTENT_TYPE_MUSIC | ||
| "sonification" -> AudioAttributes.CONTENT_TYPE_SONIFICATION | ||
| "speech" -> AudioAttributes.CONTENT_TYPE_SPEECH | ||
| "unknown" -> AudioAttributes.CONTENT_TYPE_UNKNOWN | ||
| else -> null | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -26,6 +26,7 @@ import io.flutter.plugin.common.MethodChannel.MethodCallHandler | |
| import io.flutter.plugin.common.MethodChannel.Result | ||
|
|
||
| import com.cloudwebrtc.webrtc.FlutterWebRTCPlugin | ||
| import com.cloudwebrtc.webrtc.audio.AudioSwitchManager | ||
| import com.cloudwebrtc.webrtc.audio.LocalAudioTrack | ||
| import io.flutter.plugin.common.BinaryMessenger | ||
| import org.webrtc.AudioTrack | ||
|
|
@@ -42,6 +43,7 @@ class LiveKitPlugin : FlutterPlugin, MethodCallHandler { | |
| private var audioProcessors = mutableMapOf<String, AudioProcessors>() | ||
| private var flutterWebRTCPlugin = FlutterWebRTCPlugin.sharedSingleton | ||
| private var binaryMessenger: BinaryMessenger? = null | ||
| private var audioSwitchManager: LKAudioSwitchManager? = null | ||
|
|
||
| /// The MethodChannel that will the communication between Flutter and native Android | ||
| /// | ||
|
|
@@ -50,9 +52,13 @@ class LiveKitPlugin : FlutterPlugin, MethodCallHandler { | |
| private lateinit var channel: MethodChannel | ||
|
|
||
| override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { | ||
| // LiveKit owns the platform audio session, so disable flutter_webrtc's own | ||
| // native audio management. Set at registration, before any audio op. | ||
| AudioSwitchManager.setAudioSessionManagementEnabled(false) | ||
| channel = MethodChannel(flutterPluginBinding.binaryMessenger, "livekit_client") | ||
| channel.setMethodCallHandler(this) | ||
| binaryMessenger = flutterPluginBinding.binaryMessenger | ||
| audioSwitchManager = LKAudioSwitchManager(flutterPluginBinding.applicationContext) | ||
| } | ||
|
|
||
| @SuppressLint("SuspiciousIndentation") | ||
|
|
@@ -350,6 +356,26 @@ class LiveKitPlugin : FlutterPlugin, MethodCallHandler { | |
| handleGetAudioProcessingState(result) | ||
| } | ||
|
|
||
| "configureAndroidAudioSession" -> { | ||
| @Suppress("UNCHECKED_CAST") | ||
| val configuration = call.arguments as? Map<String, Any?> ?: emptyMap() | ||
| audioSwitchManager?.configure(configuration) | ||
| audioSwitchManager?.start() | ||
| result.success(null) | ||
| } | ||
|
Comment on lines
+359
to
+365
Member
Author
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. Good observation. The coupling here is intentional on Android. Unlike iOS, the Android side has no audio engine lifecycle observer to defer activation to, so the session is activated when LiveKit applies its options (at connect, or on an explicit apply) and released on stop or disconnect. That keeps focus and mode ownership tied to a clear start and stop, which is the standard call session pattern. Separating configure from activate, or adding an activate flag, is a reasonable future enhancement for finer grained manual mode control, and we can revisit it if a concrete need shows up. For this change the current behavior is the intended one, so we will keep it as is for now. |
||
|
|
||
| "stopAndroidAudioSession" -> { | ||
| audioSwitchManager?.stop() | ||
| result.success(null) | ||
| } | ||
|
|
||
| "setAndroidSpeakerphoneOn" -> { | ||
| val enable = call.argument<Boolean>("enable") ?: false | ||
| val force = call.argument<Boolean>("force") ?: false | ||
| audioSwitchManager?.setSpeakerphoneOn(enable, force) | ||
| result.success(null) | ||
| } | ||
|
|
||
| else -> { | ||
| result.notImplemented() | ||
| } | ||
|
|
@@ -359,6 +385,9 @@ class LiveKitPlugin : FlutterPlugin, MethodCallHandler { | |
| override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { | ||
| channel.setMethodCallHandler(null) | ||
|
|
||
| audioSwitchManager?.dispose() | ||
| audioSwitchManager = null | ||
|
|
||
| // Cleanup all processors | ||
| audioProcessors.values.forEach { it.cleanup() } | ||
| audioProcessors.clear() | ||
|
|
||
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.
Thanks. This one is a deliberate tradeoff, and it matches the rest of the LiveKit stack. The same
audioswitchcommit is pinned by bothflutter_webrtcand the LiveKit Android SDK, so dependency resolution stays consistent end to end. A JitPack artifact is immutable per commit, so the pin is reproducible in practice, and the main residual risk is JitPack availability rather than the artifact changing under us.The fork is needed for its
CommDeviceAudioSwitch, which provides the API 31setCommunicationDevicerouting path, and that class has no tagged release or MavenCentral coordinate to point at yet. If upstream publishes a release that carries it, we would happily switch over.