From 10b03b6384a666566e03579340f0602c4207b647 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:39:00 +0900 Subject: [PATCH 1/2] feat(audio): AudioManager audio session management Process-wide audio session control on AudioManager: session options and management modes (automatic/manual), Android audio session configuration (mode/focus/routing) backed by an AudioSwitch-based manager in the native plugin, Apple speakerphone routing, and speaker output preferences. Platform audio sessions are global to the app process, so this lives on AudioManager rather than Room. --- android/build.gradle | 4 + .../io/livekit/plugin/LKAudioSwitchManager.kt | 258 ++++++++++++++++++ .../kotlin/io/livekit/plugin/LiveKitPlugin.kt | 29 ++ example/lib/pages/room.dart | 2 +- example/lib/widgets/controls.dart | 2 +- lib/livekit_client.dart | 1 + .../audio/android_audio_session_adapter.dart | 39 +++ lib/src/audio/audio_manager.dart | 234 +++++++++++++++- lib/src/audio/audio_session.dart | 245 +++++++++++++++++ lib/src/core/room.dart | 5 +- lib/src/hardware/hardware.dart | 84 ++---- lib/src/livekit.dart | 29 +- lib/src/support/native.dart | 41 +++ lib/src/track/audio_management.dart | 41 +-- shared_swift/LiveKitPlugin.swift | 26 ++ test/audio/audio_session_test.dart | 121 ++++++++ 16 files changed, 1073 insertions(+), 88 deletions(-) create mode 100644 android/src/main/kotlin/io/livekit/plugin/LKAudioSwitchManager.kt create mode 100644 lib/src/audio/android_audio_session_adapter.dart create mode 100644 lib/src/audio/audio_session.dart create mode 100644 test/audio/audio_session_test.dart diff --git a/android/build.gradle b/android/build.gradle index 82e5dd8d7..5a671d109 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -18,6 +18,7 @@ allprojects { repositories { google() mavenCentral() + maven { url 'https://jitpack.io' } } } @@ -61,6 +62,9 @@ android { testImplementation("org.mockito:mockito-core:5.0.0") implementation 'io.github.webrtc-sdk:android:144.7559.09' implementation 'io.livekit:noise:2.0.0' + // Audio device/focus/mode routing. Pinned to the same revision used by + // the LiveKit Android SDK (AudioSwitchHandler). + implementation 'com.github.davidliu:audioswitch:039a35aefab7747c557242fa216c9ea11743b604' } testOptions { diff --git a/android/src/main/kotlin/io/livekit/plugin/LKAudioSwitchManager.kt b/android/src/main/kotlin/io/livekit/plugin/LKAudioSwitchManager.kt new file mode 100644 index 000000000..adf32346c --- /dev/null +++ b/android/src/main/kotlin/io/livekit/plugin/LKAudioSwitchManager.kt @@ -0,0 +1,258 @@ +/* + * 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) { + + private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + + // AudioSwitch is not threadsafe; confine all access to a single thread. + private var thread: HandlerThread? = null + private var handler: Handler? = null + + 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 preferredDeviceList = preferredDeviceList(speakerFirst = true) + + /** + * 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) { + (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() { + ensureThread() + handler?.post { + val switch = audioSwitch ?: createSwitch().also { audioSwitch = it } + if (!isActive) { + switch.activate() + isActive = true + } + } + } + + /** Deactivate and tear down the audio session: release focus and restore the previous mode. */ + @Synchronized + fun stop() { + val h = handler ?: return + h.removeCallbacksAndMessages(null) + h.postAtFrontOfQueue { + audioSwitch?.stop() + audioSwitch = null + isActive = false + } + thread?.quitSafely() + handler = null + thread = null + } + + /** Route audio to/from the speakerphone, falling back to the next preferred device. */ + @Synchronized + fun setSpeakerphoneOn(enable: Boolean) { + preferredDeviceList = preferredDeviceList(speakerFirst = enable) + ensureThread() + handler?.post { + val switch = audioSwitch ?: createSwitch().also { audioSwitch = it } + switch.setPreferredDeviceList(preferredDeviceList) + val device = if (enable) { + switch.availableAudioDevices.firstOrNull { it is AudioDevice.Speakerphone } + } else { + switch.availableAudioDevices.firstOrNull { + it is AudioDevice.BluetoothHeadset || it is AudioDevice.WiredHeadset || it is AudioDevice.Earpiece + } + } + switch.selectDevice(device) + } + } + + /** Clear any forced communication device selection (API 31+). */ + fun clearCommunicationDevice() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + audioManager.clearCommunicationDevice() + } + } + + 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 ensureThread() { + if (thread == null) { + thread = HandlerThread("LKAudioSwitchThread").also { it.start() } + } + if (handler == null) { + handler = Handler(thread!!.looper) + } + } + + private fun preferredDeviceList(speakerFirst: Boolean): List> = + if (speakerFirst) { + 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 +} diff --git a/android/src/main/kotlin/io/livekit/plugin/LiveKitPlugin.kt b/android/src/main/kotlin/io/livekit/plugin/LiveKitPlugin.kt index 95a242497..3301b47b2 100644 --- a/android/src/main/kotlin/io/livekit/plugin/LiveKitPlugin.kt +++ b/android/src/main/kotlin/io/livekit/plugin/LiveKitPlugin.kt @@ -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() 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 ?: emptyMap() + audioSwitchManager?.configure(configuration) + audioSwitchManager?.start() + result.success(null) + } + + "stopAndroidAudioSession" -> { + audioSwitchManager?.stop() + audioSwitchManager?.clearCommunicationDevice() + result.success(null) + } + + "setAndroidSpeakerphoneOn" -> { + val enable = call.argument("enable") ?: false + audioSwitchManager?.setSpeakerphoneOn(enable) + 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?.stop() + audioSwitchManager = null + // Cleanup all processors audioProcessors.values.forEach { it.cleanup() } audioProcessors.clear() diff --git a/example/lib/pages/room.dart b/example/lib/pages/room.dart index 27aa394b8..1e6d10491 100644 --- a/example/lib/pages/room.dart +++ b/example/lib/pages/room.dart @@ -52,7 +52,7 @@ class _RoomPageState extends State { }); if (lkPlatformIs(PlatformType.android)) { - unawaited(Hardware.instance.setSpeakerphoneOn(true)); + unawaited(AudioManager.instance.setSpeakerphoneOn(true)); } if (lkPlatformIsDesktop()) { diff --git a/example/lib/widgets/controls.dart b/example/lib/widgets/controls.dart index 3c96b4ebc..39bf0795d 100644 --- a/example/lib/widgets/controls.dart +++ b/example/lib/widgets/controls.dart @@ -36,7 +36,7 @@ class _ControlsWidgetState extends State { StreamSubscription? _subscription; - bool _speakerphoneOn = Hardware.instance.speakerOn ?? false; + bool _speakerphoneOn = AudioManager.instance.speakerphoneOn; @override void initState() { diff --git a/lib/livekit_client.dart b/lib/livekit_client.dart index 9756d5a07..20fb5e289 100644 --- a/lib/livekit_client.dart +++ b/lib/livekit_client.dart @@ -43,6 +43,7 @@ export 'src/participant/participant.dart'; export 'src/participant/remote.dart' hide ParticipantCreationResult; export 'src/audio/audio_manager.dart'; export 'src/audio/audio_frame_capture.dart' show AudioFormat, AudioFrame, AudioFrameCallback, AudioRendererOptions; +export 'src/audio/audio_session.dart'; export 'src/preconnect/pre_connect_audio_buffer.dart'; export 'src/publication/local.dart'; export 'src/publication/remote.dart'; diff --git a/lib/src/audio/android_audio_session_adapter.dart b/lib/src/audio/android_audio_session_adapter.dart new file mode 100644 index 000000000..adaaa7131 --- /dev/null +++ b/lib/src/audio/android_audio_session_adapter.dart @@ -0,0 +1,39 @@ +// Copyright 2024 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. + +import 'package:meta/meta.dart'; + +import '../support/native.dart'; +import 'audio_session.dart'; + +/// Serializes an [AndroidAudioSessionConfiguration] into the map consumed by +/// LiveKit's native Android audio session manager (and by flutter_webrtc's +/// audio device module at initialization). Unset fields are omitted so the +/// native side keeps its current value. +@internal +Map androidAudioSessionConfigurationToMap(AndroidAudioSessionConfiguration config) => + { + if (config.manageAudioFocus != null) 'manageAudioFocus': config.manageAudioFocus!, + if (config.audioMode != null) 'androidAudioMode': config.audioMode!.name, + if (config.focusMode != null) 'androidAudioFocusMode': config.focusMode!.name, + if (config.streamType != null) 'androidAudioStreamType': config.streamType!.name, + if (config.usageType != null) 'androidAudioAttributesUsageType': config.usageType!.name, + if (config.contentType != null) 'androidAudioAttributesContentType': config.contentType!.name, + if (config.forceAudioRouting != null) 'forceHandleAudioRouting': config.forceAudioRouting!, + }; + +@internal +Future setAndroidAudioSessionConfiguration(AndroidAudioSessionConfiguration config) async { + await Native.configureAndroidAudioSession(androidAudioSessionConfigurationToMap(config)); +} diff --git a/lib/src/audio/audio_manager.dart b/lib/src/audio/audio_manager.dart index 3034a3706..54e9b316f 100644 --- a/lib/src/audio/audio_manager.dart +++ b/lib/src/audio/audio_manager.dart @@ -12,19 +12,247 @@ // See the License for the specific language governing permissions and // limitations under the License. +import 'package:meta/meta.dart'; + +import '../logger.dart'; import '../support/native.dart'; +import '../support/native_audio.dart'; +import '../support/platform.dart'; +import 'android_audio_session_adapter.dart'; import 'audio_processing_state.dart'; +import 'audio_session.dart'; /// Controls LiveKit's process-wide platform audio behavior. /// -/// The platform audio engine and its audio processing module are global to the -/// app process, so engine-scoped audio state lives here rather than on a `Room` -/// or an individual track. +/// Platform audio sessions and the audio processing module are global to the +/// app process, so session options and engine-scoped audio state live here +/// rather than on a `Room` or an individual track. class AudioManager { AudioManager._(); static final AudioManager instance = AudioManager._(); + AudioSessionOptions _defaultOptions = const AudioSessionOptions.communication(); + AudioSessionOptions _options = const AudioSessionOptions.communication(); + AudioSessionManagementMode _managementMode = AudioSessionManagementMode.automatic; + bool _hasLocalAudio = false; + bool _hasRemoteAudio = false; + bool _hasExplicitRuntimeOptions = false; + bool _preferSpeakerOutput = true; + bool _forceSpeakerOutput = false; + + AudioSessionOptions get defaultOptions => _defaultOptions; + AudioSessionOptions get options => _options; + AudioSessionManagementMode get managementMode => _managementMode; + + /// Whether the speakerphone is the preferred audio output. + bool get speakerphoneOn => _preferSpeakerOutput; + bool get preferSpeakerOutput => _preferSpeakerOutput; + + /// Whether speaker output is forced even when a headset/Bluetooth device is + /// connected (iOS only). + bool get forceSpeakerOutput => _forceSpeakerOutput && _preferSpeakerOutput; + + /// Whether the platform supports switching the speakerphone (iOS/Android). + bool get canSwitchSpeakerphone => lkPlatformIsMobile(); + + // Derived from [managementMode]; kept internal so the public surface exposes + // a single way to read the mode. + @internal + bool get isAutomaticConfigurationEnabled => _managementMode == AudioSessionManagementMode.automatic; + + @internal + void configureDefaults({ + required bool bypassVoiceProcessing, + }) { + _defaultOptions = + bypassVoiceProcessing ? const AudioSessionOptions.media() : const AudioSessionOptions.communication(); + _options = _defaultOptions; + _hasExplicitRuntimeOptions = false; + } + + @internal + void updateAudioTrackState({ + required bool hasLocalAudio, + required bool hasRemoteAudio, + }) { + _hasLocalAudio = hasLocalAudio; + _hasRemoteAudio = hasRemoteAudio; + } + + /// Applies a new audio session configuration immediately. + /// + /// Use [AudioSessionOptions.communication] to enter VoIP/call mode and + /// [AudioSessionOptions.media] to leave communication mode for + /// media/live-streaming capture. This explicit apply path works in both + /// automatic and manual management modes. + Future setAudioSessionOptions(AudioSessionOptions options) async { + _hasExplicitRuntimeOptions = true; + _options = options; + await applyCurrentAudioSessionOptions(); + } + + /// Selects whether LiveKit manages the platform audio session automatically. + /// + /// In [AudioSessionManagementMode.manual], LiveKit does not update the audio + /// session from room, connect, or track lifecycle. The app can still apply a + /// configuration explicitly with [setAudioSessionOptions]. + /// + /// Prefer choosing the mode via `LiveKitClient.initialize`. flutter_webrtc's + /// own native audio management is always disabled (LiveKit owns the session); + /// changing the mode at runtime only affects LiveKit's own automatic + /// configuration. + void setAudioSessionManagementMode(AudioSessionManagementMode mode) { + _managementMode = mode; + } + + /// Routes audio output to/from the speakerphone. + /// + /// By default a connected wired/Bluetooth headset still takes priority even + /// when [enable] is true. Set [forceSpeakerOutput] to force the speaker even + /// when a headset is connected (iOS only). + /// + /// LiveKit owns this routing on both platforms — Android via its own + /// audioswitch handler and iOS via its audio session — so it does not depend + /// on flutter_webrtc. + Future setSpeakerphoneOn(bool enable, {bool forceSpeakerOutput = false}) async { + if (!canSwitchSpeakerphone) { + logger.warning('setSpeakerphoneOn is only supported on iOS/Android'); + return; + } + _preferSpeakerOutput = enable; + _forceSpeakerOutput = forceSpeakerOutput; + + if (lkPlatformIs(PlatformType.iOS)) { + if (isAutomaticConfigurationEnabled) { + var config = _resolveAppleConfiguration(_options); + if (_preferSpeakerOutput && _forceSpeakerOutput) { + config = config.copyWith( + appleAudioCategoryOptions: { + ...?config.appleAudioCategoryOptions, + AppleAudioCategoryOption.defaultToSpeaker, + }, + ); + } + await Native.configureAudio(config); + } else { + // Manual mode: route without re-applying category/mode the app owns. + await Native.setAppleSpeakerphoneOn(enable); + } + } else if (lkPlatformIs(PlatformType.android)) { + await Native.setAndroidSpeakerphoneOn(enable); + } + } + + /// Re-applies the current audio session options. + /// + /// This is useful after platform interruptions or app lifecycle changes when + /// the app wants LiveKit to restore its currently selected session mode. + Future applyCurrentAudioSessionOptions() async { + if (lkPlatformIs(PlatformType.iOS)) { + await _configureAppleAudioSession(_options); + } else if (lkPlatformIs(PlatformType.android)) { + await _configureAndroidAudioSession(_options); + } + } + + @internal + Map? androidAudioConfigurationForInitialize() { + if (!lkPlatformIs(PlatformType.android)) { + return null; + } + + // Preserve today's implicit initialize behavior; only send Android audio + // attributes when the bypassVoiceProcessing path needs media attributes. + if (!isAutomaticConfigurationEnabled || !Native.bypassVoiceProcessing) { + return null; + } + + return androidAudioSessionConfigurationToMap(_resolveAndroidConfiguration(_defaultOptions)); + } + + @internal + Future applyOptionsForConnect() async { + if (isAutomaticConfigurationEnabled) { + await applyCurrentAudioSessionOptions(); + } + } + + @internal + bool get shouldUseLegacyAutomaticAppleConfiguration => + isAutomaticConfigurationEnabled && + !_hasExplicitRuntimeOptions && + _options.isCommunication && + _options.preferSpeakerOutput && + _options.apple == null; + + @internal + NativeAudioConfiguration automaticAppleAudioConfiguration() => _resolveAppleConfiguration(_options); + + Future _configureAppleAudioSession(AudioSessionOptions options) async { + final config = _resolveAppleConfiguration(options); + logger.fine('configuring Apple audio session using $config...'); + await Native.configureAudio(config); + } + + Future _configureAndroidAudioSession(AudioSessionOptions options) async { + final config = _resolveAndroidConfiguration(options); + logger.fine('configuring Android audio session using ${androidAudioSessionConfigurationToMap(config)}...'); + await setAndroidAudioSessionConfiguration(config); + } + + NativeAudioConfiguration _resolveAppleConfiguration(AudioSessionOptions options) { + final apple = options.apple; + if (apple != null) { + return NativeAudioConfiguration( + appleAudioCategory: apple.category, + appleAudioCategoryOptions: apple.categoryOptions, + appleAudioMode: apple.mode, + preferSpeakerOutput: apple.preferSpeakerOutput, + ); + } + + if (options.isCommunication) { + return NativeAudioConfiguration( + appleAudioCategory: AppleAudioCategory.playAndRecord, + appleAudioCategoryOptions: { + AppleAudioCategoryOption.allowBluetooth, + AppleAudioCategoryOption.allowBluetoothA2DP, + AppleAudioCategoryOption.allowAirPlay, + }, + appleAudioMode: options.preferSpeakerOutput ? AppleAudioMode.videoChat : AppleAudioMode.voiceChat, + preferSpeakerOutput: options.preferSpeakerOutput, + ); + } + + if (isAutomaticConfigurationEnabled && !_hasLocalAudio) { + return _hasRemoteAudio ? NativeAudioConfiguration.playback : NativeAudioConfiguration.soloAmbient; + } + return NativeAudioConfiguration( + appleAudioCategory: AppleAudioCategory.playAndRecord, + appleAudioCategoryOptions: { + AppleAudioCategoryOption.mixWithOthers, + AppleAudioCategoryOption.allowBluetooth, + AppleAudioCategoryOption.allowBluetoothA2DP, + AppleAudioCategoryOption.allowAirPlay, + }, + appleAudioMode: AppleAudioMode.default_, + preferSpeakerOutput: true, + ); + } + + AndroidAudioSessionConfiguration _resolveAndroidConfiguration(AudioSessionOptions options) { + final android = options.android; + if (android != null) { + return android; + } + + if (options.isCommunication) { + return AndroidAudioSessionConfiguration.communication; + } + return AndroidAudioSessionConfiguration.media; + } + /// 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/audio_session.dart b/lib/src/audio/audio_session.dart new file mode 100644 index 000000000..b94253c86 --- /dev/null +++ b/lib/src/audio/audio_session.dart @@ -0,0 +1,245 @@ +// Copyright 2024 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. + +export '../support/native_audio.dart' show AppleAudioCategory, AppleAudioCategoryOption, AppleAudioMode; + +import 'package:meta/meta.dart'; + +import '../support/native_audio.dart' show AppleAudioCategory, AppleAudioCategoryOption, AppleAudioMode; + +enum AudioSessionManagementMode { + /// LiveKit updates the platform audio session based on room/track lifecycle. + automatic, + + /// LiveKit does not update the platform audio session automatically. + /// + /// The app must call AudioManager APIs when it wants to apply a session + /// configuration. + manual, +} + +enum _AudioSessionPreset { communication, media } + +class AudioSessionOptions { + final _AudioSessionPreset _preset; + + /// Whether communication sessions should prefer speaker output. + final bool preferSpeakerOutput; + + /// Optional exact iOS session override. + final AppleAudioSessionConfiguration? apple; + + /// Optional exact Android session override. + final AndroidAudioSessionConfiguration? android; + + const AudioSessionOptions._({ + required _AudioSessionPreset preset, + this.preferSpeakerOutput = true, + this.apple, + this.android, + }) : _preset = preset; + + const AudioSessionOptions.communication({ + bool preferSpeakerOutput = true, + AppleAudioSessionConfiguration? apple, + AndroidAudioSessionConfiguration? android, + }) : this._( + preset: _AudioSessionPreset.communication, + preferSpeakerOutput: preferSpeakerOutput, + apple: apple, + android: android, + ); + + const AudioSessionOptions.media({ + AppleAudioSessionConfiguration? apple, + AndroidAudioSessionConfiguration? android, + }) : this._( + preset: _AudioSessionPreset.media, + preferSpeakerOutput: true, + apple: apple, + android: android, + ); + + AudioSessionOptions copyWith({ + bool? preferSpeakerOutput, + AppleAudioSessionConfiguration? apple, + AndroidAudioSessionConfiguration? android, + }) => + AudioSessionOptions._( + preset: _preset, + preferSpeakerOutput: preferSpeakerOutput ?? this.preferSpeakerOutput, + apple: apple ?? this.apple, + android: android ?? this.android, + ); + + @internal + bool get isCommunication => _preset == _AudioSessionPreset.communication; + + @internal + bool get isMedia => _preset == _AudioSessionPreset.media; +} + +class AppleAudioSessionConfiguration { + /// AVAudioSession category. + final AppleAudioCategory? category; + + /// AVAudioSession category options. + final Set? categoryOptions; + + /// AVAudioSession mode. + final AppleAudioMode? mode; + + /// Whether AVAudioSession should prefer speaker output when supported. + final bool? preferSpeakerOutput; + + const AppleAudioSessionConfiguration({ + this.category, + this.categoryOptions, + this.mode, + this.preferSpeakerOutput, + }); + + AppleAudioSessionConfiguration copyWith({ + AppleAudioCategory? category, + Set? categoryOptions, + AppleAudioMode? mode, + bool? preferSpeakerOutput, + }) => + AppleAudioSessionConfiguration( + category: category ?? this.category, + categoryOptions: categoryOptions ?? this.categoryOptions, + mode: mode ?? this.mode, + preferSpeakerOutput: preferSpeakerOutput ?? this.preferSpeakerOutput, + ); +} + +enum AndroidAudioMode { + normal, + callScreening, + inCall, + inCommunication, + ringtone, +} + +enum AndroidAudioFocusMode { + gain, + gainTransient, + gainTransientExclusive, + gainTransientMayDuck, +} + +enum AndroidAudioStreamType { + accessibility, + alarm, + dtmf, + music, + notification, + ring, + system, + voiceCall, +} + +enum AndroidAudioAttributesUsageType { + alarm, + assistanceAccessibility, + assistanceNavigationGuidance, + assistanceSonification, + assistant, + game, + media, + notification, + notificationEvent, + notificationRingtone, + unknown, + voiceCommunication, + voiceCommunicationSignalling, +} + +enum AndroidAudioAttributesContentType { + movie, + music, + sonification, + speech, + unknown, +} + +class AndroidAudioSessionConfiguration { + /// Android AudioManager mode. + final AndroidAudioMode? audioMode; + + /// Whether LiveKit should manage Android audio focus. + final bool? manageAudioFocus; + + /// Requested Android audio focus gain type. + final AndroidAudioFocusMode? focusMode; + + /// Legacy Android stream type. + final AndroidAudioStreamType? streamType; + + /// Android AudioAttributes usage. + final AndroidAudioAttributesUsageType? usageType; + + /// Android AudioAttributes content type. + final AndroidAudioAttributesContentType? contentType; + + /// Forces LiveKit audio routing even outside communication/call modes. + final bool? forceAudioRouting; + + const AndroidAudioSessionConfiguration({ + this.audioMode, + this.manageAudioFocus, + this.focusMode, + this.streamType, + this.usageType, + this.contentType, + this.forceAudioRouting, + }); + + static const communication = AndroidAudioSessionConfiguration( + audioMode: AndroidAudioMode.inCommunication, + manageAudioFocus: true, + focusMode: AndroidAudioFocusMode.gain, + streamType: AndroidAudioStreamType.voiceCall, + usageType: AndroidAudioAttributesUsageType.voiceCommunication, + contentType: AndroidAudioAttributesContentType.speech, + ); + + static const media = AndroidAudioSessionConfiguration( + audioMode: AndroidAudioMode.normal, + manageAudioFocus: true, + focusMode: AndroidAudioFocusMode.gain, + streamType: AndroidAudioStreamType.music, + usageType: AndroidAudioAttributesUsageType.media, + contentType: AndroidAudioAttributesContentType.unknown, + ); + + AndroidAudioSessionConfiguration copyWith({ + AndroidAudioMode? audioMode, + bool? manageAudioFocus, + AndroidAudioFocusMode? focusMode, + AndroidAudioStreamType? streamType, + AndroidAudioAttributesUsageType? usageType, + AndroidAudioAttributesContentType? contentType, + bool? forceAudioRouting, + }) => + AndroidAudioSessionConfiguration( + audioMode: audioMode ?? this.audioMode, + manageAudioFocus: manageAudioFocus ?? this.manageAudioFocus, + focusMode: focusMode ?? this.focusMode, + streamType: streamType ?? this.streamType, + usageType: usageType ?? this.usageType, + contentType: contentType ?? this.contentType, + forceAudioRouting: forceAudioRouting ?? this.forceAudioRouting, + ); +} diff --git a/lib/src/core/room.dart b/lib/src/core/room.dart index f274a0289..cb109791e 100644 --- a/lib/src/core/room.dart +++ b/lib/src/core/room.dart @@ -19,6 +19,7 @@ import 'package:collection/collection.dart'; import 'package:http/http.dart' as http; import 'package:meta/meta.dart'; +import '../audio/audio_manager.dart'; import '../core/signal_client.dart'; import '../data_stream/errors.dart'; import '../data_stream/stream_reader.dart'; @@ -1189,7 +1190,7 @@ extension RoomHardwareManagementMethods on Room { /// or bluetooth is connected, only supported on iOS for now Future setSpeakerOn(bool speakerOn, {bool forceSpeakerOutput = false}) async { if (lkPlatformIsMobile()) { - await Hardware.instance.setSpeakerphoneOn(speakerOn, forceSpeakerOutput: forceSpeakerOutput); + await AudioManager.instance.setSpeakerphoneOn(speakerOn, forceSpeakerOutput: forceSpeakerOutput); engine.roomOptions = engine.roomOptions.copyWith( defaultAudioOutputOptions: roomOptions.defaultAudioOutputOptions.copyWith( speakerOn: speakerOn, @@ -1203,7 +1204,7 @@ extension RoomHardwareManagementMethods on Room { Future applyAudioSpeakerSettings() async { if (roomOptions.defaultAudioOutputOptions.speakerOn != null) { if (lkPlatformIsMobile()) { - await Hardware.instance.setSpeakerphoneOn(roomOptions.defaultAudioOutputOptions.speakerOn!); + await AudioManager.instance.setSpeakerphoneOn(roomOptions.defaultAudioOutputOptions.speakerOn!); } } } diff --git a/lib/src/hardware/hardware.dart b/lib/src/hardware/hardware.dart index 065d4f361..6f05cf623 100644 --- a/lib/src/hardware/hardware.dart +++ b/lib/src/hardware/hardware.dart @@ -17,11 +17,10 @@ import 'dart:async'; import 'package:collection/collection.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc; +import '../audio/audio_manager.dart'; +import '../audio/audio_session.dart'; import '../logger.dart'; -import '../support/native.dart'; -import '../support/native_audio.dart'; import '../support/platform.dart'; -import '../track/audio_management.dart'; class MediaDevice { const MediaDevice(this.deviceId, this.label, this.kind, this.groupId); @@ -69,27 +68,31 @@ class Hardware { MediaDevice? selectedVideoInput; - bool? get speakerOn => _preferSpeakerOutput; + @Deprecated('Use AudioManager.instance.speakerphoneOn instead') + bool? get speakerOn => AudioManager.instance.speakerphoneOn; - bool _preferSpeakerOutput = true; - - bool get preferSpeakerOutput => _preferSpeakerOutput; - - bool _forceSpeakerOutput = false; + @Deprecated('Use AudioManager.instance.preferSpeakerOutput instead') + bool get preferSpeakerOutput => AudioManager.instance.preferSpeakerOutput; /// if true, will force speaker output even if headphones or bluetooth is connected /// only supported on iOS for now - bool get forceSpeakerOutput => _forceSpeakerOutput && _preferSpeakerOutput; - - // This flag is used to determine if automatic native configuration - // of audio is enabled. If set to false Natvive.configureAudio - // will not be called, and the user is responsible for configuring - // the native audio configuration manually. - bool _isAutomaticConfigurationEnabled = true; - bool get isAutomaticConfigurationEnabled => _isAutomaticConfigurationEnabled; - + @Deprecated('Use AudioManager.instance.forceSpeakerOutput instead') + bool get forceSpeakerOutput => AudioManager.instance.forceSpeakerOutput; + + // Whether automatic native audio configuration is enabled. If disabled, + // Native.configureAudio is not called and the app is responsible for + // configuring the native audio session manually. + // + // Backed by [AudioManager] so there is a single source of truth for the + // management mode; see [AudioManager.setAudioSessionManagementMode]. + @Deprecated('Use AudioManager.instance.managementMode instead') + bool get isAutomaticConfigurationEnabled => AudioManager.instance.isAutomaticConfigurationEnabled; + + @Deprecated('Use AudioManager.instance.setAudioSessionManagementMode instead') void setAutomaticConfigurationEnabled({required bool enable}) { - _isAutomaticConfigurationEnabled = enable; + AudioManager.instance.setAudioSessionManagementMode( + enable ? AudioSessionManagementMode.automatic : AudioSessionManagementMode.manual, + ); } Future> enumerateDevices({String? type}) async { @@ -131,48 +134,19 @@ class Hardware { await rtc.Helper.selectAudioInput(device.deviceId); } - @Deprecated('use setSpeakerphoneOn') - Future setPreferSpeakerOutput(bool enable) => setSpeakerphoneOn(enable); + @Deprecated('Use AudioManager.instance.setSpeakerphoneOn instead') + Future setPreferSpeakerOutput(bool enable) => AudioManager.instance.setSpeakerphoneOn(enable); - bool get canSwitchSpeakerphone => lkPlatformIsMobile(); + @Deprecated('Use AudioManager.instance.canSwitchSpeakerphone instead') + bool get canSwitchSpeakerphone => AudioManager.instance.canSwitchSpeakerphone; /// [enable] set speakerphone on or off, by default wired/bluetooth headsets will still /// be prioritized even if set to true. /// [forceSpeakerOutput] if true, will force speaker output even if headphones /// or bluetooth is connected, only supported on iOS for now - Future setSpeakerphoneOn(bool enable, {bool forceSpeakerOutput = false}) async { - if (canSwitchSpeakerphone) { - _preferSpeakerOutput = enable; - _forceSpeakerOutput = forceSpeakerOutput; - if (lkPlatformIs(PlatformType.iOS)) { - NativeAudioConfiguration? config; - if (lkPlatformIs(PlatformType.iOS)) { - // Only iOS for now... - config = await onConfigureNativeAudio.call(audioTrackState); - if (_preferSpeakerOutput && _forceSpeakerOutput) { - config = config.copyWith( - appleAudioCategoryOptions: { - ...?config.appleAudioCategoryOptions, - AppleAudioCategoryOption.defaultToSpeaker, - }, - ); - } - logger.fine('configuring for ${audioTrackState} using ${config}...'); - try { - if (_isAutomaticConfigurationEnabled) { - await Native.configureAudio(config); - } - } catch (error) { - logger.warning('failed to configure ${error}'); - } - } - } else { - await rtc.Helper.setSpeakerphoneOn(enable); - } - } else { - logger.warning('setSpeakerphoneOn only support on iOS/Android'); - } - } + @Deprecated('Use AudioManager.instance.setSpeakerphoneOn instead') + Future setSpeakerphoneOn(bool enable, {bool forceSpeakerOutput = false}) => + AudioManager.instance.setSpeakerphoneOn(enable, forceSpeakerOutput: forceSpeakerOutput); Future openCamera({MediaDevice? device, bool? facingMode}) async { final constraints = { diff --git a/lib/src/livekit.dart b/lib/src/livekit.dart index 6cdc2cba8..945e391bc 100644 --- a/lib/src/livekit.dart +++ b/lib/src/livekit.dart @@ -14,6 +14,7 @@ import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc; +import 'audio/audio_manager.dart'; import 'support/native.dart'; import 'support/platform.dart' show lkPlatformIsMobile; @@ -22,16 +23,32 @@ import 'support/platform.dart' show lkPlatformIsMobile; class LiveKitClient { static const version = '2.8.0'; - /// Initialize the WebRTC plugin. If this is not manually called, will be - /// initialized with default settings. - /// This method must be called before calling any LiveKit SDK API. - static Future initialize({bool bypassVoiceProcessing = false}) async { + /// Initialize the WebRTC plugin. + /// + /// Optional: call once at startup to enable [bypassVoiceProcessing] before + /// connecting; otherwise WebRTC initializes lazily with defaults. + /// + /// LiveKit owns the platform audio session, and flutter_webrtc's own native + /// audio management is disabled automatically when the LiveKit plugin loads + /// (done natively at registration), so that does not depend on this call. + /// + /// Configure audio-session behavior through [AudioManager] before connecting, + /// e.g. `AudioManager.instance.setAudioSessionManagementMode(...)` and + /// `AudioManager.instance.setAudioSessionOptions(...)`. + static Future initialize({ + bool bypassVoiceProcessing = false, + }) async { if (lkPlatformIsMobile()) { + Native.bypassVoiceProcessing = bypassVoiceProcessing; + AudioManager.instance.configureDefaults( + bypassVoiceProcessing: bypassVoiceProcessing, + ); + final androidAudioConfiguration = AudioManager.instance.androidAudioConfigurationForInitialize(); + await rtc.WebRTC.initialize(options: { if (bypassVoiceProcessing) 'bypassVoiceProcessing': bypassVoiceProcessing, + if (androidAudioConfiguration != null) 'androidAudioConfiguration': androidAudioConfiguration, }); - - Native.bypassVoiceProcessing = bypassVoiceProcessing; } } } diff --git a/lib/src/support/native.dart b/lib/src/support/native.dart index 9cdfe928a..3e322bc89 100644 --- a/lib/src/support/native.dart +++ b/lib/src/support/native.dart @@ -93,6 +93,47 @@ class Native { return null; } + /// Configure and activate LiveKit's Android audio session (mode/focus/routing). + @internal + static Future configureAndroidAudioSession(Map configuration) async { + try { + await channel.invokeMethod('configureAndroidAudioSession', configuration); + } catch (error) { + logger.warning('configureAndroidAudioSession did throw $error'); + } + } + + /// Deactivate LiveKit's Android audio session (release focus, restore mode). + @internal + static Future stopAndroidAudioSession() async { + try { + await channel.invokeMethod('stopAndroidAudioSession'); + } catch (error) { + logger.warning('stopAndroidAudioSession did throw $error'); + } + } + + /// Route Android audio output to/from the speakerphone. + @internal + static Future setAndroidSpeakerphoneOn(bool enable) async { + try { + await channel.invokeMethod('setAndroidSpeakerphoneOn', {'enable': enable}); + } catch (error) { + logger.warning('setAndroidSpeakerphoneOn did throw $error'); + } + } + + /// Route Apple (iOS) audio output to/from the speakerphone without otherwise + /// changing the audio session category/mode. + @internal + static Future setAppleSpeakerphoneOn(bool enable) async { + try { + await channel.invokeMethod('setAppleSpeakerphoneOn', {'enable': enable}); + } catch (error) { + logger.warning('setAppleSpeakerphoneOn did throw $error'); + } + } + @internal static Future startVisualizer( String trackId, { diff --git a/lib/src/track/audio_management.dart b/lib/src/track/audio_management.dart index f287ca03b..0fb0cbb29 100644 --- a/lib/src/track/audio_management.dart +++ b/lib/src/track/audio_management.dart @@ -12,10 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. -import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc; import 'package:synchronized/synchronized.dart' as sync; -import '../hardware/hardware.dart'; +import '../audio/audio_manager.dart'; import '../logger.dart'; import '../support/native.dart'; import '../support/native_audio.dart'; @@ -106,14 +105,25 @@ Future _onAudioTrackCountDidChange() async { if (_audioTrackState != newState) { _audioTrackState = newState; + AudioManager.instance.updateAudioTrackState( + hasLocalAudio: _localTrackCount > 0, + hasRemoteAudio: _remoteTrackCount > 0, + ); logger.fine('didUpdateSate: $_audioTrackState'); + if (!AudioManager.instance.isAutomaticConfigurationEnabled) { + logger.fine('automatic audio session configuration is disabled because AudioManager is in manual mode'); + return; + } + NativeAudioConfiguration? config; if (lkPlatformIs(PlatformType.iOS)) { // Only iOS for now... - config = await onConfigureNativeAudio.call(_audioTrackState); + config = AudioManager.instance.shouldUseLegacyAutomaticAppleConfiguration + ? await onConfigureNativeAudio.call(_audioTrackState) + : AudioManager.instance.automaticAppleAudioConfiguration(); - if (Hardware.instance.forceSpeakerOutput) { + if (AudioManager.instance.forceSpeakerOutput) { config = config.copyWith( appleAudioCategoryOptions: { ...?config.appleAudioCategoryOptions, @@ -126,10 +136,8 @@ Future _onAudioTrackCountDidChange() async { if (config != null) { logger.fine('configuring for ${_audioTrackState} using ${config}...'); try { - if (Hardware.instance.isAutomaticConfigurationEnabled) { - logger.fine('configuring native audio...'); - await Native.configureAudio(config); - } + logger.fine('configuring native audio...'); + await Native.configureAudio(config); } catch (error) { logger.warning('failed to configure ${error}'); } @@ -152,30 +160,23 @@ AudioTrackState _computeAudioTrackState() { Future defaultNativeAudioConfigurationFunc(AudioTrackState state) async { if (state == AudioTrackState.none) { return NativeAudioConfiguration.soloAmbient; - } else if (state == AudioTrackState.remoteOnly && Hardware.instance.preferSpeakerOutput) { + } else if (state == AudioTrackState.remoteOnly && AudioManager.instance.preferSpeakerOutput) { return NativeAudioConfiguration.playback; } - return Hardware.instance.preferSpeakerOutput + return AudioManager.instance.preferSpeakerOutput ? NativeAudioConfiguration.playAndRecordSpeaker : NativeAudioConfiguration.playAndRecordReceiver; } class NativeAudioManagement { static Future start() async { - // Audio configuration for Android. - if (lkPlatformIs(PlatformType.android)) { - if (Native.bypassVoiceProcessing) { - await rtc.Helper.setAndroidAudioConfiguration(rtc.AndroidAudioConfiguration.media); - } else { - await rtc.Helper.setAndroidAudioConfiguration(rtc.AndroidAudioConfiguration.communication); - } - } + await AudioManager.instance.applyOptionsForConnect(); } static Future stop() async { - if (lkPlatformIs(PlatformType.android)) { - await rtc.Helper.clearAndroidCommunicationDevice(); + if (lkPlatformIs(PlatformType.android) && AudioManager.instance.isAutomaticConfigurationEnabled) { + await Native.stopAndroidAudioSession(); } } } diff --git a/shared_swift/LiveKitPlugin.swift b/shared_swift/LiveKitPlugin.swift index fa2367bbe..ed9376b9e 100644 --- a/shared_swift/LiveKitPlugin.swift +++ b/shared_swift/LiveKitPlugin.swift @@ -68,6 +68,10 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { instance.binaryMessenger = messenger registrar.addMethodCallDelegate(instance, channel: channel) + // LiveKit owns the platform audio session, so disable flutter_webrtc's + // own native audio management. Set at registration, before any audio op. + FlutterWebRTCPlugin.setAudioSessionManagementEnabled(false) + #if os(iOS) BroadcastManager.shared.isBroadcastingPublisher .sink { isBroadcasting in @@ -372,6 +376,26 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { #endif } + public func handleSetAppleSpeakerphoneOn(args: [String: Any?], result: @escaping FlutterResult) { + #if os(macOS) + result(FlutterMethodNotImplemented) + #else + let enable = (args["enable"] as? Bool) ?? false + + let rtcSession = RTCAudioSession.sharedInstance() + rtcSession.lockForConfiguration() + defer { rtcSession.unlockForConfiguration() } + + do { + try rtcSession.overrideOutputAudioPort(enable ? .speaker : .none) + result(true) + } catch { + print("[LiveKit] setAppleSpeakerphoneOn error: ", error) + result(FlutterError(code: "setAppleSpeakerphoneOn", message: error.localizedDescription, details: nil)) + } + #endif + } + private static let processInfo = ProcessInfo() /// Returns os version as a string. @@ -513,6 +537,8 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { switch call.method { case "configureNativeAudio": handleConfigureNativeAudio(args: args, result: result) + case "setAppleSpeakerphoneOn": + handleSetAppleSpeakerphoneOn(args: args, result: result) case "startVisualizer": handleStartAudioVisualizer(args: args, result: result) case "stopVisualizer": diff --git a/test/audio/audio_session_test.dart b/test/audio/audio_session_test.dart new file mode 100644 index 000000000..f2fa77409 --- /dev/null +++ b/test/audio/audio_session_test.dart @@ -0,0 +1,121 @@ +// 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. + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:livekit_client/src/audio/android_audio_session_adapter.dart'; +import 'package:livekit_client/src/audio/audio_manager.dart'; +import 'package:livekit_client/src/audio/audio_session.dart'; + +void main() { + group('AudioSessionManagementMode', () { + test('supports automatic and manual management', () { + expect( + AudioSessionManagementMode.values, + [ + AudioSessionManagementMode.automatic, + AudioSessionManagementMode.manual, + ], + ); + }); + }); + + group('AudioSessionOptions', () { + test('defaults to communication', () { + final options = AudioSessionOptions.communication(); + + expect(options.isCommunication, isTrue); + expect(options.isMedia, isFalse); + }); + + test('communication and media constructors describe session intent', () { + final communication = AudioSessionOptions.communication(); + final media = AudioSessionOptions.media(); + + expect(communication.isCommunication, isTrue); + expect(communication.isMedia, isFalse); + expect(media.isCommunication, isFalse); + expect(media.isMedia, isTrue); + }); + }); + + group('AudioManager', () { + test('management mode can be set independently from options', () { + final manager = AudioManager.instance; + + manager.setAudioSessionManagementMode(AudioSessionManagementMode.manual); + + expect(manager.managementMode, AudioSessionManagementMode.manual); + expect(manager.isAutomaticConfigurationEnabled, isFalse); + expect(manager.options.isCommunication, isTrue); + + manager.setAudioSessionManagementMode(AudioSessionManagementMode.automatic); + }); + }); + + group('AndroidAudioSessionConfiguration', () { + test('communication preset uses voice communication values', () { + final config = AndroidAudioSessionConfiguration.communication; + + expect(config.manageAudioFocus, isTrue); + expect(config.audioMode, AndroidAudioMode.inCommunication); + expect(config.focusMode, AndroidAudioFocusMode.gain); + expect(config.streamType, AndroidAudioStreamType.voiceCall); + expect(config.usageType, AndroidAudioAttributesUsageType.voiceCommunication); + expect(config.contentType, AndroidAudioAttributesContentType.speech); + }); + + test('media preset uses non-communication media values', () { + final config = AndroidAudioSessionConfiguration.media; + + expect(config.manageAudioFocus, isTrue); + expect(config.audioMode, AndroidAudioMode.normal); + expect(config.focusMode, AndroidAudioFocusMode.gain); + expect(config.streamType, AndroidAudioStreamType.music); + expect(config.usageType, AndroidAudioAttributesUsageType.media); + expect(config.contentType, AndroidAudioAttributesContentType.unknown); + }); + }); + + group('androidAudioSessionConfigurationToMap', () { + test('serializes communication preset for WebRTC initialization', () { + expect( + androidAudioSessionConfigurationToMap(AndroidAudioSessionConfiguration.communication), + { + 'manageAudioFocus': true, + 'androidAudioMode': 'inCommunication', + 'androidAudioFocusMode': 'gain', + 'androidAudioStreamType': 'voiceCall', + 'androidAudioAttributesUsageType': 'voiceCommunication', + 'androidAudioAttributesContentType': 'speech', + }, + ); + }); + + test('omits unset Android fields', () { + final config = AndroidAudioSessionConfiguration( + audioMode: AndroidAudioMode.normal, + forceAudioRouting: true, + ); + + expect( + androidAudioSessionConfigurationToMap(config), + { + 'androidAudioMode': 'normal', + 'forceHandleAudioRouting': true, + }, + ); + }); + }); +} From 4d7ebdcbe629af351a00d03e91a4cca2991cef1f Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Fri, 12 Jun 2026 18:08:26 +0800 Subject: [PATCH 2/2] chore: add changeset for AudioManager audio session management --- .changes/audio-manager-api | 1 + 1 file changed, 1 insertion(+) create mode 100644 .changes/audio-manager-api diff --git a/.changes/audio-manager-api b/.changes/audio-manager-api new file mode 100644 index 000000000..23b09ab5e --- /dev/null +++ b/.changes/audio-manager-api @@ -0,0 +1 @@ +minor type="added" "AudioManager audio session management: session options, Android audio session configuration and routing, Apple speakerphone control"