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 01/21] 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 02/21] 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" From 003f693687f083ba7e39bd7108e94c846af0e9d1 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Mon, 15 Jun 2026 00:49:16 +0800 Subject: [PATCH 03/21] chore(deps): bump flutter_webrtc to 1.5.1 Picks up the aligned audioswitch revision (flutter-webrtc#2084), which resolves the Android build conflict on the shared classpath. --- pubspec.lock | 4 ++-- pubspec.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 3aefe0abc..c78edefdc 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -284,10 +284,10 @@ packages: dependency: "direct main" description: name: flutter_webrtc - sha256: d8c89028d29e5693742190285b2e3c8a117531b0960ae0693d84273a53968d28 + sha256: c4e8db6ed337b8c30d76cd7cd8c91693f5495e1aeb556cbcb73f1e5d5bdcf020 url: "https://pub.dev" source: hosted - version: "1.5.0" + version: "1.5.1" frontend_server_client: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 2c768472e..0665dc206 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -46,7 +46,7 @@ dependencies: json_annotation: ^4.9.0 # Fix version to avoid version conflicts between WebRTC-SDK pods, which both this package and flutter_webrtc depend on. - flutter_webrtc: 1.5.0 + flutter_webrtc: 1.5.1 dart_webrtc: ^1.8.0 dev_dependencies: From c31ebb958a4a6719d40ab9e703aa52800d89b6e8 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 16 Jun 2026 03:57:13 +0800 Subject: [PATCH 04/21] feat(audio): add session configuration API --- lib/src/audio/audio_manager.dart | 329 ++++++++++++++++++++------- lib/src/audio/audio_session.dart | 82 ++++--- lib/src/core/room.dart | 7 + lib/src/hardware/hardware.dart | 6 +- lib/src/livekit.dart | 4 +- lib/src/support/native.dart | 43 +++- lib/src/support/native_audio.dart | 50 +--- lib/src/support/value_or_absent.dart | 65 ++++++ 8 files changed, 435 insertions(+), 151 deletions(-) create mode 100644 lib/src/support/value_or_absent.dart diff --git a/lib/src/audio/audio_manager.dart b/lib/src/audio/audio_manager.dart index 54e9b316f..849abd255 100644 --- a/lib/src/audio/audio_manager.dart +++ b/lib/src/audio/audio_manager.dart @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +import 'dart:async'; + import 'package:meta/meta.dart'; import '../logger.dart'; @@ -22,6 +24,39 @@ import 'android_audio_session_adapter.dart'; import 'audio_processing_state.dart'; import 'audio_session.dart'; +/// Snapshot of the WebRTC audio engine's playout/recording state. +/// +/// Surfaced by [AudioManager] from real audio-engine lifecycle events on the +/// native side (iOS and macOS). This is the source of truth for audio activity, +/// replacing the legacy track-counting state. +class AudioEngineState { + /// Whether the engine has playout (output / remote audio) enabled. + final bool isPlayoutEnabled; + + /// Whether the engine has recording (input / local mic) enabled. + final bool isRecordingEnabled; + + const AudioEngineState({ + required this.isPlayoutEnabled, + required this.isRecordingEnabled, + }); + + /// Whether the engine is neither playing out nor recording. + bool get isIdle => !isPlayoutEnabled && !isRecordingEnabled; + + @override + bool operator ==(Object other) => + other is AudioEngineState && + other.isPlayoutEnabled == isPlayoutEnabled && + other.isRecordingEnabled == isRecordingEnabled; + + @override + int get hashCode => Object.hash(isPlayoutEnabled, isRecordingEnabled); + + @override + String toString() => 'AudioEngineState(isPlayoutEnabled: $isPlayoutEnabled, isRecordingEnabled: $isRecordingEnabled)'; +} + /// Controls LiveKit's process-wide platform audio behavior. /// /// Platform audio sessions and the audio processing module are global to the @@ -35,11 +70,12 @@ class 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; + bool _isPlayoutEnabled = false; + bool _isRecordingEnabled = false; + final StreamController _audioEngineStateController = StreamController.broadcast(); AudioSessionOptions get defaultOptions => _defaultOptions; AudioSessionOptions get options => _options; @@ -56,28 +92,68 @@ class AudioManager { /// Whether the platform supports switching the speakerphone (iOS/Android). bool get canSwitchSpeakerphone => lkPlatformIsMobile(); + /// The current audio engine state, derived from native engine lifecycle + /// events (iOS/macOS). On platforms without engine events this stays idle. + AudioEngineState get audioEngineState => + AudioEngineState(isPlayoutEnabled: _isPlayoutEnabled, isRecordingEnabled: _isRecordingEnabled); + + /// A broadcast stream of audio engine state changes (native engine lifecycle). + Stream get audioEngineStateStream => _audioEngineStateController.stream; + // Derived from [managementMode]; kept internal so the public surface exposes // a single way to read the mode. @internal bool get isAutomaticConfigurationEnabled => _managementMode == AudioSessionManagementMode.automatic; + @visibleForTesting + void resetForTest() { + _defaultOptions = const AudioSessionOptions.communication(); + _options = const AudioSessionOptions.communication(); + _managementMode = AudioSessionManagementMode.automatic; + _hasExplicitRuntimeOptions = false; + _preferSpeakerOutput = true; + _forceSpeakerOutput = false; + _isPlayoutEnabled = false; + _isRecordingEnabled = false; + } + @internal void configureDefaults({ required bool bypassVoiceProcessing, }) { _defaultOptions = bypassVoiceProcessing ? const AudioSessionOptions.media() : const AudioSessionOptions.communication(); - _options = _defaultOptions; - _hasExplicitRuntimeOptions = false; + if (!_hasExplicitRuntimeOptions) { + _options = _defaultOptions; + } } + /// Invoked from native when the WebRTC audio engine's playout/recording state + /// changes. Audio-engine lifecycle events are the single source of truth for + /// audio activity; this replaces the legacy track-counting path, which had + /// timing races and could miss session deactivation. + /// + /// On iOS the native engine delegate also owns audio-session activation + /// timing (configure + activate on enable, deactivate on disable); this Dart + /// hop is non-blocking and only keeps the observable state in sync. macOS + /// emits the same events (no `AVAudioSession` to configure) so engine state + /// stays authoritative there too. @internal - void updateAudioTrackState({ - required bool hasLocalAudio, - required bool hasRemoteAudio, + void handleAudioEngineState({ + required bool isPlayoutEnabled, + required bool isRecordingEnabled, }) { - _hasLocalAudio = hasLocalAudio; - _hasRemoteAudio = hasRemoteAudio; + final nextState = AudioEngineState( + isPlayoutEnabled: isPlayoutEnabled, + isRecordingEnabled: isRecordingEnabled, + ); + if (nextState == audioEngineState) { + return; + } + + _isPlayoutEnabled = isPlayoutEnabled; + _isRecordingEnabled = isRecordingEnabled; + _audioEngineStateController.add(nextState); } /// Applies a new audio session configuration immediately. @@ -88,6 +164,8 @@ class AudioManager { /// automatic and manual management modes. Future setAudioSessionOptions(AudioSessionOptions options) async { _hasExplicitRuntimeOptions = true; + _syncSpeakerPreferenceFromOptions(options); + _forceSpeakerOutput = false; _options = options; await applyCurrentAudioSessionOptions(); } @@ -98,12 +176,13 @@ class AudioManager { /// 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); + /// Prefer setting this before connecting to a room. 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) { + Future setAudioSessionManagementMode(AudioSessionManagementMode mode) async { _managementMode = mode; + await _syncAppleAudioSessionManagementMode(); } /// Routes audio output to/from the speakerphone. @@ -121,20 +200,21 @@ class AudioManager { return; } _preferSpeakerOutput = enable; - _forceSpeakerOutput = forceSpeakerOutput; + _forceSpeakerOutput = enable && forceSpeakerOutput; + _options = _optionsWithSpeakerPreference(_options, enable); 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); + final policy = _resolvedAudioSessionPolicy(_options); + // Automatic mode: the native audio-engine delegate owns activation + // timing, so this caches the policy and applies now only if the engine + // is already running. Category is resolved natively from engine state + // unless the app supplied an explicit Apple override. + await Native.configureAudio( + policy.appleConfiguration, + automatic: true, + selectCategoryByEngineState: policy.usesDynamicAppleCategory, + ); } else { // Manual mode: route without re-applying category/mode the app owns. await Native.setAppleSpeakerphoneOn(enable); @@ -157,8 +237,10 @@ class AudioManager { } @internal - Map? androidAudioConfigurationForInitialize() { - if (!lkPlatformIs(PlatformType.android)) { + Map? androidAudioConfigurationForInitialize({ + @visibleForTesting bool assumeAndroid = false, + }) { + if (!assumeAndroid && !lkPlatformIs(PlatformType.android)) { return null; } @@ -168,80 +250,174 @@ class AudioManager { return null; } - return androidAudioSessionConfigurationToMap(_resolveAndroidConfiguration(_defaultOptions)); + return androidAudioSessionConfigurationToMap(_resolvedAudioSessionPolicy(_options).androidConfiguration); } @internal Future applyOptionsForConnect() async { + await _syncAppleAudioSessionManagementMode(); if (isAutomaticConfigurationEnabled) { await applyCurrentAudioSessionOptions(); } } - @internal - bool get shouldUseLegacyAutomaticAppleConfiguration => - isAutomaticConfigurationEnabled && - !_hasExplicitRuntimeOptions && - _options.isCommunication && - _options.preferSpeakerOutput && - _options.apple == null; - - @internal - NativeAudioConfiguration automaticAppleAudioConfiguration() => _resolveAppleConfiguration(_options); + Future _syncAppleAudioSessionManagementMode() async { + if (lkPlatformIs(PlatformType.iOS)) { + await Native.setAppleAudioSessionAutomaticManagementEnabled(isAutomaticConfigurationEnabled); + } + } Future _configureAppleAudioSession(AudioSessionOptions options) async { - final config = _resolveAppleConfiguration(options); + final policy = _resolvedAudioSessionPolicy(options); + final config = policy.appleConfiguration; logger.fine('configuring Apple audio session using $config...'); - await Native.configureAudio(config); + // In automatic mode the native audio-engine delegate owns activation + // timing, so this caches the policy (and applies now only if the engine is + // already running). In manual mode it applies immediately. The category is + // chosen natively from engine state unless the app gave an explicit Apple + // override (then the config is applied verbatim). + await Native.configureAudio( + config, + automatic: isAutomaticConfigurationEnabled, + selectCategoryByEngineState: isAutomaticConfigurationEnabled && policy.usesDynamicAppleCategory, + ); } Future _configureAndroidAudioSession(AudioSessionOptions options) async { - final config = _resolveAndroidConfiguration(options); - logger.fine('configuring Android audio session using ${androidAudioSessionConfigurationToMap(config)}...'); + final policy = _resolvedAudioSessionPolicy(options); + final config = policy.androidConfiguration; + logger.fine( + 'configuring Android audio session using ${androidAudioSessionConfigurationToMap(config)}...', + ); await setAndroidAudioSessionConfiguration(config); + await Native.setAndroidSpeakerphoneOn(policy.preferSpeakerOutput); + } + + _ResolvedAudioSessionPolicy _resolvedAudioSessionPolicy(AudioSessionOptions options) { + final preferSpeakerOutput = _speakerPreferenceForOptions(options); + return _ResolvedAudioSessionPolicy( + options: options, + preferSpeakerOutput: preferSpeakerOutput, + forceSpeakerOutput: _forceSpeakerOutput && preferSpeakerOutput, + ); + } + + @visibleForTesting + NativeAudioConfiguration resolveAppleAudioConfigurationForTest( + AudioSessionOptions options, { + bool forceSpeakerOutput = false, + }) { + final preferSpeakerOutput = _speakerPreferenceForOptions(options); + return _ResolvedAudioSessionPolicy( + options: options, + preferSpeakerOutput: preferSpeakerOutput, + forceSpeakerOutput: forceSpeakerOutput && preferSpeakerOutput, + ).appleConfiguration; + } + + @visibleForTesting + AndroidAudioSessionConfiguration resolveAndroidAudioConfigurationForTest(AudioSessionOptions options) => + _resolvedAudioSessionPolicy(options).androidConfiguration; + + bool _speakerPreferenceForOptions(AudioSessionOptions options) => + options.apple?.preferSpeakerOutput ?? options.preferSpeakerOutput; + + void _syncSpeakerPreferenceFromOptions(AudioSessionOptions options) { + _preferSpeakerOutput = _speakerPreferenceForOptions(options); + if (!_preferSpeakerOutput) { + _forceSpeakerOutput = false; + } } - NativeAudioConfiguration _resolveAppleConfiguration(AudioSessionOptions options) { + AudioSessionOptions _optionsWithSpeakerPreference(AudioSessionOptions options, bool preferSpeakerOutput) { + final apple = options.apple; + return options.copyWith( + preferSpeakerOutput: Value(preferSpeakerOutput), + apple: apple == null + ? const Absent() + : Value( + apple.copyWith( + preferSpeakerOutput: Value(preferSpeakerOutput), + ), + ), + ); + } + + /// Diagnostic snapshot of the resolved audio processing state. + /// + /// The audio processing module is owned by the native peer connection factory + /// and shared engine-wide, so this reflects what is actually applied across + /// the engine rather than any single track — use it to verify what a + /// `LocalAudioTrack.setAudioProcessingOptions` request resolved to. Returns + /// `null` when the native side cannot provide it. + Future getAudioProcessingState() async { + final response = await Native.getAudioProcessingState(); + if (response == null) return null; + return AudioProcessingState.fromMap(response); + } +} + +class _ResolvedAudioSessionPolicy { + const _ResolvedAudioSessionPolicy({ + required this.options, + required this.preferSpeakerOutput, + required this.forceSpeakerOutput, + }); + + final AudioSessionOptions options; + final bool preferSpeakerOutput; + final bool forceSpeakerOutput; + + bool get usesDynamicAppleCategory => options.apple == null; + + NativeAudioConfiguration get appleConfiguration { final apple = options.apple; if (apple != null) { - return NativeAudioConfiguration( - appleAudioCategory: apple.category, - appleAudioCategoryOptions: apple.categoryOptions, - appleAudioMode: apple.mode, - preferSpeakerOutput: apple.preferSpeakerOutput, + return _withForcedSpeakerOutput( + NativeAudioConfiguration( + appleAudioCategory: apple.category, + appleAudioCategoryOptions: apple.categoryOptions, + appleAudioMode: apple.mode, + preferSpeakerOutput: preferSpeakerOutput, + ), ); } if (options.isCommunication) { - return NativeAudioConfiguration( + return _withForcedSpeakerOutput( + NativeAudioConfiguration( + appleAudioCategory: AppleAudioCategory.playAndRecord, + appleAudioCategoryOptions: { + AppleAudioCategoryOption.allowBluetooth, + AppleAudioCategoryOption.allowBluetoothA2DP, + AppleAudioCategoryOption.allowAirPlay, + }, + appleAudioMode: preferSpeakerOutput ? AppleAudioMode.videoChat : AppleAudioMode.voiceChat, + preferSpeakerOutput: preferSpeakerOutput, + ), + ); + } + + // Media (non-communication) base policy. The category here is a base; in + // automatic mode the native engine delegate overrides it from the live + // engine state (playAndRecord while recording, playback for playout-only), + // so it no longer depends on stale track/engine flags resolved at connect. + return _withForcedSpeakerOutput( + NativeAudioConfiguration( appleAudioCategory: AppleAudioCategory.playAndRecord, appleAudioCategoryOptions: { + AppleAudioCategoryOption.mixWithOthers, 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, + appleAudioMode: AppleAudioMode.default_, + preferSpeakerOutput: preferSpeakerOutput, + ), ); } - AndroidAudioSessionConfiguration _resolveAndroidConfiguration(AudioSessionOptions options) { + AndroidAudioSessionConfiguration get androidConfiguration { final android = options.android; if (android != null) { return android; @@ -253,16 +429,15 @@ class AudioManager { return AndroidAudioSessionConfiguration.media; } - /// Diagnostic snapshot of the resolved audio processing state. - /// - /// The audio processing module is owned by the native peer connection factory - /// and shared engine-wide, so this reflects what is actually applied across - /// the engine rather than any single track — use it to verify what a - /// `LocalAudioTrack.setAudioProcessingOptions` request resolved to. Returns - /// `null` when the native side cannot provide it. - Future getAudioProcessingState() async { - final response = await Native.getAudioProcessingState(); - if (response == null) return null; - return AudioProcessingState.fromMap(response); + NativeAudioConfiguration _withForcedSpeakerOutput(NativeAudioConfiguration configuration) { + if (!forceSpeakerOutput || configuration.appleAudioCategory != AppleAudioCategory.playAndRecord) { + return configuration; + } + return configuration.copyWith( + appleAudioCategoryOptions: Value({ + ...?configuration.appleAudioCategoryOptions, + AppleAudioCategoryOption.defaultToSpeaker, + }), + ); } } diff --git a/lib/src/audio/audio_session.dart b/lib/src/audio/audio_session.dart index b94253c86..f4e191391 100644 --- a/lib/src/audio/audio_session.dart +++ b/lib/src/audio/audio_session.dart @@ -13,10 +13,12 @@ // limitations under the License. export '../support/native_audio.dart' show AppleAudioCategory, AppleAudioCategoryOption, AppleAudioMode; +export '../support/value_or_absent.dart'; import 'package:meta/meta.dart'; import '../support/native_audio.dart' show AppleAudioCategory, AppleAudioCategoryOption, AppleAudioMode; +import '../support/value_or_absent.dart'; enum AudioSessionManagementMode { /// LiveKit updates the platform audio session based on room/track lifecycle. @@ -35,6 +37,10 @@ class AudioSessionOptions { final _AudioSessionPreset _preset; /// Whether communication sessions should prefer speaker output. + /// + /// This is used by the communication preset and by Apple `playAndRecord` + /// policies. Media/playback policies leave routing to the platform unless an + /// exact platform override says otherwise. final bool preferSpeakerOutput; /// Optional exact iOS session override. @@ -50,6 +56,15 @@ class AudioSessionOptions { this.android, }) : _preset = preset; + /// Two-way audio preset for calls, rooms, and microphone capture. + /// + /// LiveKit resolves this to communication-oriented platform policies. Use + /// [preferSpeakerOutput] for the default speaker preference unless [apple] or + /// [android] provides a more exact platform policy. + /// + /// On Apple platforms in automatic mode, listen-only playout uses playback + /// until recording starts; receiver routing from [preferSpeakerOutput] only + /// applies while the effective category is `playAndRecord`. const AudioSessionOptions.communication({ bool preferSpeakerOutput = true, AppleAudioSessionConfiguration? apple, @@ -61,6 +76,12 @@ class AudioSessionOptions { android: android, ); + /// One-way media playback preset. + /// + /// This intentionally does not expose [preferSpeakerOutput] because speaker + /// preference only affects communication/playAndRecord policies. Use [apple] + /// or [android] for exact platform behavior, or switch at runtime with + /// `AudioManager.setSpeakerphoneOn`. const AudioSessionOptions.media({ AppleAudioSessionConfiguration? apple, AndroidAudioSessionConfiguration? android, @@ -71,16 +92,21 @@ class AudioSessionOptions { android: android, ); + /// Returns a copy with selected fields replaced. + /// + /// The preset chosen by [AudioSessionOptions.communication] or + /// [AudioSessionOptions.media] is intentionally retained. Create a new + /// options object with the other constructor to switch presets. AudioSessionOptions copyWith({ - bool? preferSpeakerOutput, - AppleAudioSessionConfiguration? apple, - AndroidAudioSessionConfiguration? android, + ValueOrAbsent preferSpeakerOutput = const Absent(), + ValueOrAbsent apple = const Absent(), + ValueOrAbsent android = const Absent(), }) => AudioSessionOptions._( preset: _preset, - preferSpeakerOutput: preferSpeakerOutput ?? this.preferSpeakerOutput, - apple: apple ?? this.apple, - android: android ?? this.android, + preferSpeakerOutput: preferSpeakerOutput.valueOr(this.preferSpeakerOutput), + apple: apple.valueOr(this.apple), + android: android.valueOr(this.android), ); @internal @@ -111,16 +137,16 @@ class AppleAudioSessionConfiguration { }); AppleAudioSessionConfiguration copyWith({ - AppleAudioCategory? category, - Set? categoryOptions, - AppleAudioMode? mode, - bool? preferSpeakerOutput, + ValueOrAbsent category = const Absent(), + ValueOrAbsent?> categoryOptions = const Absent(), + ValueOrAbsent mode = const Absent(), + ValueOrAbsent preferSpeakerOutput = const Absent(), }) => AppleAudioSessionConfiguration( - category: category ?? this.category, - categoryOptions: categoryOptions ?? this.categoryOptions, - mode: mode ?? this.mode, - preferSpeakerOutput: preferSpeakerOutput ?? this.preferSpeakerOutput, + category: category.valueOr(this.category), + categoryOptions: categoryOptions.valueOr(this.categoryOptions), + mode: mode.valueOr(this.mode), + preferSpeakerOutput: preferSpeakerOutput.valueOr(this.preferSpeakerOutput), ); } @@ -225,21 +251,21 @@ class AndroidAudioSessionConfiguration { ); AndroidAudioSessionConfiguration copyWith({ - AndroidAudioMode? audioMode, - bool? manageAudioFocus, - AndroidAudioFocusMode? focusMode, - AndroidAudioStreamType? streamType, - AndroidAudioAttributesUsageType? usageType, - AndroidAudioAttributesContentType? contentType, - bool? forceAudioRouting, + ValueOrAbsent audioMode = const Absent(), + ValueOrAbsent manageAudioFocus = const Absent(), + ValueOrAbsent focusMode = const Absent(), + ValueOrAbsent streamType = const Absent(), + ValueOrAbsent usageType = const Absent(), + ValueOrAbsent contentType = const Absent(), + ValueOrAbsent forceAudioRouting = const Absent(), }) => 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, + audioMode: audioMode.valueOr(this.audioMode), + manageAudioFocus: manageAudioFocus.valueOr(this.manageAudioFocus), + focusMode: focusMode.valueOr(this.focusMode), + streamType: streamType.valueOr(this.streamType), + usageType: usageType.valueOr(this.usageType), + contentType: contentType.valueOr(this.contentType), + forceAudioRouting: forceAudioRouting.valueOr(this.forceAudioRouting), ); } diff --git a/lib/src/core/room.dart b/lib/src/core/room.dart index cb109791e..6af061dfa 100644 --- a/lib/src/core/room.dart +++ b/lib/src/core/room.dart @@ -292,6 +292,7 @@ class Room extends DisposableChangeNotifier with EventsEmittable { // configure audio for native platform await NativeAudioManagement.start(); + var didConnect = false; try { await engine.connect( _regionUrl ?? url, @@ -301,6 +302,7 @@ class Room extends DisposableChangeNotifier with EventsEmittable { fastConnectOptions: fastConnectOptions, regionUrlProvider: _regionUrlProvider, ); + didConnect = true; } catch (e) { logger.warning('could not connect to $url $e'); if (_regionUrlProvider != null && @@ -323,12 +325,17 @@ class Room extends DisposableChangeNotifier with EventsEmittable { fastConnectOptions: fastConnectOptions, regionUrlProvider: _regionUrlProvider, ); + didConnect = true; } else { rethrow; } } else { rethrow; } + } finally { + if (!didConnect) { + await NativeAudioManagement.stop(); + } } } diff --git a/lib/src/hardware/hardware.dart b/lib/src/hardware/hardware.dart index 6f05cf623..e781571bc 100644 --- a/lib/src/hardware/hardware.dart +++ b/lib/src/hardware/hardware.dart @@ -90,8 +90,10 @@ class Hardware { @Deprecated('Use AudioManager.instance.setAudioSessionManagementMode instead') void setAutomaticConfigurationEnabled({required bool enable}) { - AudioManager.instance.setAudioSessionManagementMode( - enable ? AudioSessionManagementMode.automatic : AudioSessionManagementMode.manual, + unawaited( + AudioManager.instance.setAudioSessionManagementMode( + enable ? AudioSessionManagementMode.automatic : AudioSessionManagementMode.manual, + ), ); } diff --git a/lib/src/livekit.dart b/lib/src/livekit.dart index 945e391bc..ed70268e6 100644 --- a/lib/src/livekit.dart +++ b/lib/src/livekit.dart @@ -33,8 +33,8 @@ class LiveKitClient { /// (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(...)`. + /// e.g. `await AudioManager.instance.setAudioSessionManagementMode(...)` and + /// `await AudioManager.instance.setAudioSessionOptions(...)`. static Future initialize({ bool bypassVoiceProcessing = false, }) async { diff --git a/lib/src/support/native.dart b/lib/src/support/native.dart index 3e322bc89..60e0ab767 100644 --- a/lib/src/support/native.dart +++ b/lib/src/support/native.dart @@ -18,6 +18,7 @@ import 'package:flutter/services.dart' show MethodChannel, MethodCall; import 'package:meta/meta.dart'; +import '../audio/audio_manager.dart'; import '../logger.dart'; import '../managers/broadcast_manager.dart'; import 'native_audio.dart'; @@ -36,12 +37,27 @@ class Native { @internal static bool bypassVoiceProcessing = false; + /// Configures (and caches) the Apple audio session. + /// + /// When [automatic] is true, the native audio-engine delegate owns activation + /// timing: the configuration is cached and (re)applied on engine lifecycle + /// events, and only applied immediately here if the engine is already + /// running. When false (manual mode / explicit apply) it is applied + /// immediately. @internal - static Future configureAudio(NativeAudioConfiguration configuration) async { + static Future configureAudio( + NativeAudioConfiguration configuration, { + bool automatic = false, + bool selectCategoryByEngineState = false, + }) async { try { final result = await channel.invokeMethod( 'configureNativeAudio', - configuration.toMap(), + { + ...configuration.toMap(), + 'automatic': automatic, + 'selectCategoryByEngineState': selectCategoryByEngineState, + }, ); return result == true; } catch (error) { @@ -134,6 +150,20 @@ class Native { } } + /// Enable or disable LiveKit's automatic iOS audio-session management from + /// native WebRTC audio-engine lifecycle callbacks. + @internal + static Future setAppleAudioSessionAutomaticManagementEnabled(bool enabled) async { + try { + await channel.invokeMethod( + 'setAppleAudioSessionAutomaticManagementEnabled', + {'enabled': enabled}, + ); + } catch (error) { + logger.warning('setAppleAudioSessionAutomaticManagementEnabled did throw $error'); + } + } + @internal static Future startVisualizer( String trackId, { @@ -237,6 +267,15 @@ class Native { } _broadcastStateChanged(call.arguments as bool); return null; + case 'onAudioEngineState': + final args = call.arguments; + if (args is Map) { + AudioManager.instance.handleAudioEngineState( + isPlayoutEnabled: args['isPlayoutEnabled'] == true, + isRecordingEnabled: args['isRecordingEnabled'] == true, + ); + } + return null; default: logger.warning('Method ${call.method} is not implemented.'); return null; diff --git a/lib/src/support/native_audio.dart b/lib/src/support/native_audio.dart index 70bc59dce..afc7df47d 100644 --- a/lib/src/support/native_audio.dart +++ b/lib/src/support/native_audio.dart @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +import 'value_or_absent.dart'; + // https://developer.apple.com/documentation/avfaudio/avaudiosession/category enum AppleAudioCategory { soloAmbient, @@ -87,38 +89,6 @@ class NativeAudioConfiguration { final AppleAudioMode? appleAudioMode; final bool? preferSpeakerOutput; - static final soloAmbient = NativeAudioConfiguration( - appleAudioCategory: AppleAudioCategory.soloAmbient, - appleAudioCategoryOptions: {}, - appleAudioMode: AppleAudioMode.default_, - ); - - static final playback = NativeAudioConfiguration( - appleAudioCategory: AppleAudioCategory.playback, - appleAudioCategoryOptions: {AppleAudioCategoryOption.mixWithOthers}, - appleAudioMode: AppleAudioMode.spokenAudio, - ); - - static final playAndRecordSpeaker = NativeAudioConfiguration( - appleAudioCategory: AppleAudioCategory.playAndRecord, - appleAudioCategoryOptions: { - AppleAudioCategoryOption.allowBluetooth, - AppleAudioCategoryOption.allowBluetoothA2DP, - AppleAudioCategoryOption.allowAirPlay, - }, - appleAudioMode: AppleAudioMode.videoChat, - ); - - static final playAndRecordReceiver = NativeAudioConfiguration( - appleAudioCategory: AppleAudioCategory.playAndRecord, - appleAudioCategoryOptions: { - AppleAudioCategoryOption.allowBluetooth, - AppleAudioCategoryOption.allowBluetoothA2DP, - AppleAudioCategoryOption.allowAirPlay, - }, - appleAudioMode: AppleAudioMode.voiceChat, - ); - NativeAudioConfiguration( { // for iOS / Mac @@ -139,15 +109,15 @@ class NativeAudioConfiguration { }; NativeAudioConfiguration copyWith({ - AppleAudioCategory? appleAudioCategory, - Set? appleAudioCategoryOptions, - AppleAudioMode? appleAudioMode, - bool? preferSpeakerOutput, + ValueOrAbsent appleAudioCategory = const Absent(), + ValueOrAbsent?> appleAudioCategoryOptions = const Absent(), + ValueOrAbsent appleAudioMode = const Absent(), + ValueOrAbsent preferSpeakerOutput = const Absent(), }) => NativeAudioConfiguration( - appleAudioCategory: appleAudioCategory ?? this.appleAudioCategory, - appleAudioCategoryOptions: appleAudioCategoryOptions ?? this.appleAudioCategoryOptions, - appleAudioMode: appleAudioMode ?? this.appleAudioMode, - preferSpeakerOutput: preferSpeakerOutput ?? this.preferSpeakerOutput, + appleAudioCategory: appleAudioCategory.valueOr(this.appleAudioCategory), + appleAudioCategoryOptions: appleAudioCategoryOptions.valueOr(this.appleAudioCategoryOptions), + appleAudioMode: appleAudioMode.valueOr(this.appleAudioMode), + preferSpeakerOutput: preferSpeakerOutput.valueOr(this.preferSpeakerOutput), ); } diff --git a/lib/src/support/value_or_absent.dart b/lib/src/support/value_or_absent.dart new file mode 100644 index 000000000..1b5b03283 --- /dev/null +++ b/lib/src/support/value_or_absent.dart @@ -0,0 +1,65 @@ +// 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. + +/// Distinguishes an omitted copy value from an explicit replacement value. +/// +/// This is useful for `copyWith` methods where a nullable field must be able to +/// keep its current value, change to a non-null value, or change to `null`. +/// +/// ```dart +/// class Example { +/// const Example({this.name}); +/// +/// final String? name; +/// +/// Example copyWith({ +/// ValueOrAbsent name = const Absent(), +/// }) => +/// Example(name: name.valueOr(this.name)); +/// } +/// +/// example.copyWith(); // keep existing name +/// example.copyWith(name: Value('room')); // set name +/// example.copyWith(name: Value(null)); // clear name +/// ``` +sealed class ValueOrAbsent { + const ValueOrAbsent(); + + /// Creates an explicit replacement value. + const factory ValueOrAbsent.value(T value) = Value; + + /// Creates an omitted value that preserves the current field. + const factory ValueOrAbsent.absent() = Absent; + + /// Returns the explicit value, or [other] when this value is absent. + T valueOr(T other); +} + +/// An explicit replacement value, including `null`. +final class Value extends ValueOrAbsent { + const Value(this.value); + + final T value; + + @override + T valueOr(T other) => value; +} + +/// An omitted value that keeps the current field. +final class Absent extends ValueOrAbsent { + const Absent(); + + @override + T valueOr(T other) => other; +} From 3e0d3bffe1d2d7757e150309b08a129939bfd5e3 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 16 Jun 2026 03:57:20 +0800 Subject: [PATCH 05/21] feat(apple): manage audio session from engine lifecycle --- shared_swift/LiveKitPlugin.swift | 351 +++++++++++++++++++++++++++---- 1 file changed, 312 insertions(+), 39 deletions(-) diff --git a/shared_swift/LiveKitPlugin.swift b/shared_swift/LiveKitPlugin.swift index ed9376b9e..2fe2a7298 100644 --- a/shared_swift/LiveKitPlugin.swift +++ b/shared_swift/LiveKitPlugin.swift @@ -14,6 +14,7 @@ * limitations under the License. */ +import AVFoundation import flutter_webrtc import WebRTC @@ -52,6 +53,11 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { var binaryMessenger: FlutterBinaryMessenger? + // Retained strong reference to the audio-engine delegate. flutter_webrtc and + // the audio device module both hold it weakly, so LiveKit must keep it alive. + var channel: FlutterMethodChannel? + var audioEngineObserver: LKAudioEngineObserver? + #if os(iOS) var cancellable = Set() #endif @@ -72,6 +78,17 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { // own native audio management. Set at registration, before any audio op. FlutterWebRTCPlugin.setAudioSessionManagementEnabled(false) + // Own the audio device module's engine-lifecycle delegate so LiveKit + // drives the audio session from real engine events (configure + activate + // on enable, deactivate on disable) instead of track counting. The + // engine emits these events on both iOS and macOS; macOS has no + // AVAudioSession to configure, so there it only surfaces engine state. + // Set before the peer connection factory is created. + instance.channel = channel + let audioEngineObserver = LKAudioEngineObserver(channel: channel) + instance.audioEngineObserver = audioEngineObserver + FlutterWebRTCPlugin.setAudioDeviceModuleObserver(audioEngineObserver) + #if os(iOS) BroadcastManager.shared.isBroadcastingPublisher .sink { isBroadcasting in @@ -314,13 +331,11 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { let category = categoryMap[string] { configuration.category = category.rawValue - print("[LiveKit] Configuring category: ", configuration.category) } // CategoryOptions if let strings = args["appleAudioCategoryOptions"] as? [String] { configuration.categoryOptions = categoryOptions(fromFlutter: strings) - print("[LiveKit] Configuring categoryOptions: ", strings) } // Mode @@ -328,54 +343,50 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { let mode = modeMap[string] { configuration.mode = mode.rawValue - print("[LiveKit] Configuring mode: ", configuration.mode) } - // get `RTCAudioSession` and lock - let rtcSession = RTCAudioSession.sharedInstance() - rtcSession.lockForConfiguration() + let preferSpeakerOutput = args["preferSpeakerOutput"] as? Bool - var isLocked = true - let unlock = { - guard isLocked else { - print("[LiveKit] not locked, ignoring unlock") - return - } - rtcSession.unlockForConfiguration() - isLocked = false - } + // Cache the policy so the audio-engine delegate can (re)apply it on + // engine lifecycle events. In automatic mode the delegate owns + // activation timing (configure + activate on engine enable), so here we + // only apply immediately if the engine is already running. Manual mode + // (or no `automatic` flag) applies immediately. + let automatic = args["automatic"] as? Bool ?? false + let selectCategoryByEngineState = args["selectCategoryByEngineState"] as? Bool ?? false + audioEngineObserver?.updatePolicy(configuration, + preferSpeakerOutput: preferSpeakerOutput, + automaticManagementEnabled: automatic, + selectCategoryByEngineState: selectCategoryByEngineState) - // always `unlock()` when exiting scope, calling multiple times has no side-effect - defer { - unlock() + let shouldApplyNow = !automatic || (audioEngineObserver?.isSessionActive ?? false) + guard shouldApplyNow else { + result(true) + return } - do { - try rtcSession.setConfiguration(configuration, active: true) - // unlock here before configuring `AVAudioSession` - // unlock() - print("[LiveKit] RTCAudioSession Configure success") - - // also configure longFormAudio - // let avSession = AVAudioSession.sharedInstance() - // try avSession.setCategory(AVAudioSession.Category(rawValue: configuration.category), - // mode: AVAudioSession.Mode(rawValue: configuration.mode), - // policy: .default, - // options: configuration.categoryOptions) - // print("[LiveKit] AVAudioSession Configure success") - - // preferSpeakerOutput - if let preferSpeakerOutput = args["preferSpeakerOutput"] as? Bool { - try rtcSession.overrideOutputAudioPort(preferSpeakerOutput ? .speaker : .none) - } - result(true) - } catch { + // Apply through the observer so the category is resolved from the live + // engine state (when category selection is enabled), matching what the + // engine-lifecycle callbacks would apply. + if let error = audioEngineObserver?.applyCachedConfiguration() { print("[LiveKit] Configure audio error: ", error) result(FlutterError(code: "configure", message: error.localizedDescription, details: nil)) + } else { + result(true) } #endif } + public func handleSetAppleAudioSessionAutomaticManagementEnabled(args: [String: Any?], result: @escaping FlutterResult) { + #if os(macOS) + result(FlutterMethodNotImplemented) + #else + let enabled = (args["enabled"] as? Bool) ?? true + audioEngineObserver?.setAutomaticManagementEnabled(enabled) + result(true) + #endif + } + public func handleSetAppleSpeakerphoneOn(args: [String: Any?], result: @escaping FlutterResult) { #if os(macOS) result(FlutterMethodNotImplemented) @@ -387,7 +398,9 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { defer { rtcSession.unlockForConfiguration() } do { - try rtcSession.overrideOutputAudioPort(enable ? .speaker : .none) + if rtcSession.category == AVAudioSession.Category.playAndRecord.rawValue { + try rtcSession.overrideOutputAudioPort(enable ? .speaker : .none) + } result(true) } catch { print("[LiveKit] setAppleSpeakerphoneOn error: ", error) @@ -537,6 +550,8 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { switch call.method { case "configureNativeAudio": handleConfigureNativeAudio(args: args, result: result) + case "setAppleAudioSessionAutomaticManagementEnabled": + handleSetAppleAudioSessionAutomaticManagementEnabled(args: args, result: result) case "setAppleSpeakerphoneOn": handleSetAppleSpeakerphoneOn(args: args, result: result) case "startVisualizer": @@ -567,3 +582,261 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { } } } + +#if !os(macOS) +@available(iOS 13.0, *) +extension LiveKitPlugin { + /// SDK-side audio engine error code (mirrors client-sdk-swift): returned + /// from a delegate callback to make WebRTC abort / roll back the engine + /// operation when the audio session cannot be configured. + static let kAudioEngineErrorFailedToConfigureAudioSession = -4100 + + /// Applies an `RTCAudioSessionConfiguration` to the shared `RTCAudioSession`. + /// Returns `nil` on success or the thrown error. Safe to call on any thread. + static func applyAudioSessionConfiguration(_ configuration: RTCAudioSessionConfiguration, + preferSpeakerOutput: Bool?, + active: Bool) -> Error? { + let rtcSession = RTCAudioSession.sharedInstance() + rtcSession.lockForConfiguration() + defer { rtcSession.unlockForConfiguration() } + do { + try rtcSession.setConfiguration(configuration, active: active) + // overrideOutputAudioPort is only valid for the playAndRecord + // category; calling it for a playback session throws. + if active, + let preferSpeakerOutput = preferSpeakerOutput, + configuration.category == AVAudioSession.Category.playAndRecord.rawValue { + try rtcSession.overrideOutputAudioPort(preferSpeakerOutput ? .speaker : .none) + } + return nil + } catch { + return error + } + } + + /// Deactivates the shared `RTCAudioSession`. Returns `nil` on success. + static func deactivateAudioSession() -> Error? { + let rtcSession = RTCAudioSession.sharedInstance() + rtcSession.lockForConfiguration() + defer { rtcSession.unlockForConfiguration() } + do { + try rtcSession.setActive(false) + return nil + } catch { + return error + } + } +} +#endif + +/// Receives the WebRTC audio device module's engine-lifecycle callbacks and, +/// on iOS, drives the audio session: configure + activate when the engine +/// enables, deactivate when it disables. Replaces the old track-counting +/// trigger. On macOS there is no `AVAudioSession`, so it only surfaces engine +/// state to Dart (keeping engine state the single source of truth there too). +/// +/// The engine-lifecycle methods are invoked synchronously on WebRTC's worker +/// thread — the engine blocks on the return value (`0` = proceed, non-zero = +/// abort / roll back), so the session work here is synchronous and never calls +/// back into the audio device module. The Dart notification is dispatched +/// asynchronously and is purely informational; it never blocks the engine. +@available(iOS 13.0, macOS 10.15, *) +class LKAudioEngineObserver: NSObject, RTCAudioDeviceModuleDelegate { + private let lock = NSLock() + private weak var channel: FlutterMethodChannel? + + #if !os(macOS) + private var cachedConfiguration: RTCAudioSessionConfiguration? + private var preferSpeakerOutput: Bool? + // When true, the category is chosen from the live engine state at apply time + // (playAndRecord while recording, playback for playout-only) rather than + // taken from the pushed config. This is what keeps the category correct as + // recording/playout come and go; the pushed config still supplies the mode, + // options and speaker preference. False for an explicit per-platform + // override or manual mode, where the config is applied verbatim. + private var selectCategoryByEngineState = false + private var isAutomaticManagementEnabled = true + private var sessionActive = false + // Last engine state seen, so an immediate re-apply (e.g. speaker toggle + // while the engine is running) can resolve the category from current state. + private var lastIsPlayoutEnabled = false + private var lastIsRecordingEnabled = false + #endif + + init(channel: FlutterMethodChannel) { + self.channel = channel + super.init() + } + + #if !os(macOS) + var isSessionActive: Bool { + lock.lock(); defer { lock.unlock() } + return sessionActive + } + + func setAutomaticManagementEnabled(_ enabled: Bool) { + lock.lock() + isAutomaticManagementEnabled = enabled + lock.unlock() + } + + /// Stores the audio session policy pushed from Dart. Pure cache — the + /// delegate callbacks apply it; callers decide whether to apply immediately. + func updatePolicy(_ configuration: RTCAudioSessionConfiguration, + preferSpeakerOutput: Bool?, + automaticManagementEnabled: Bool, + selectCategoryByEngineState: Bool) { + let cachedConfiguration = copyConfiguration(configuration) + lock.lock() + self.cachedConfiguration = cachedConfiguration + self.preferSpeakerOutput = preferSpeakerOutput + self.isAutomaticManagementEnabled = automaticManagementEnabled + self.selectCategoryByEngineState = selectCategoryByEngineState + lock.unlock() + } + + /// Applies the cached configuration immediately, resolving the category from + /// the last known engine state when category selection is enabled. Used for + /// manual mode and for re-applying while the engine is already running. + func applyCachedConfiguration() -> Error? { + lock.lock() + let configuration = effectiveConfigurationLocked(isRecordingEnabled: lastIsRecordingEnabled) + let preferSpeaker = preferSpeakerOutput + lock.unlock() + guard let configuration else { return nil } + return LiveKitPlugin.applyAudioSessionConfiguration(configuration, + preferSpeakerOutput: preferSpeaker, + active: true) + } + + /// Resolves the configuration to apply for a given engine state. Must be + /// called with `lock` held. + /// + /// When category selection is disabled (explicit Apple override or manual + /// mode) the pushed config is applied verbatim. When enabled, recording + /// uses the pushed config (resolved as a playAndRecord policy by Dart), + /// while playout-only uses a coherent playback policy: flipping only the + /// category would leave playAndRecord-only mode/options (e.g. videoChat, + /// allowBluetooth) that are invalid for the playback category. Mirrors the + /// Swift SDK's `.playback` preset (playback + spokenAudio + mixWithOthers). + private func effectiveConfigurationLocked(isRecordingEnabled: Bool) -> RTCAudioSessionConfiguration? { + guard let configuration = cachedConfiguration else { return nil } + guard selectCategoryByEngineState, !isRecordingEnabled else { return configuration } + + let playback = copyConfiguration(configuration) + playback.category = AVAudioSession.Category.playback.rawValue + playback.categoryOptions = [.mixWithOthers] + playback.mode = AVAudioSession.Mode.spokenAudio.rawValue + return playback + } + + private func copyConfiguration(_ configuration: RTCAudioSessionConfiguration) -> RTCAudioSessionConfiguration { + let copy = RTCAudioSessionConfiguration() + copy.category = configuration.category + copy.categoryOptions = configuration.categoryOptions + copy.mode = configuration.mode + copy.sampleRate = configuration.sampleRate + copy.ioBufferDuration = configuration.ioBufferDuration + copy.inputNumberOfChannels = configuration.inputNumberOfChannels + copy.outputNumberOfChannels = configuration.outputNumberOfChannels + return copy + } + #endif + + // MARK: RTCAudioDeviceModuleDelegate — engine lifecycle + + func audioDeviceModule(_: RTCAudioDeviceModule, + willEnableEngine _: AVAudioEngine, + isPlayoutEnabled: Bool, + isRecordingEnabled: Bool) -> Int { + var resultCode = 0 + #if !os(macOS) + if isPlayoutEnabled || isRecordingEnabled { + lock.lock() + let shouldManageSession = isAutomaticManagementEnabled + let configuration = effectiveConfigurationLocked(isRecordingEnabled: isRecordingEnabled) + let preferSpeaker = preferSpeakerOutput + lock.unlock() + + if shouldManageSession, + let configuration = configuration, + let error = LiveKitPlugin.applyAudioSessionConfiguration(configuration, + preferSpeakerOutput: preferSpeaker, + active: true) { + print("[LiveKit] AudioEngine willEnable: failed to configure audio session: \(error)") + resultCode = LiveKitPlugin.kAudioEngineErrorFailedToConfigureAudioSession + } + if resultCode == 0 { + lock.lock() + lastIsPlayoutEnabled = isPlayoutEnabled + lastIsRecordingEnabled = isRecordingEnabled + if shouldManageSession { + sessionActive = true + } + lock.unlock() + } + } + #endif + if resultCode == 0 { + notifyEngineState(isPlayoutEnabled: isPlayoutEnabled, isRecordingEnabled: isRecordingEnabled) + } + return resultCode + } + + func audioDeviceModule(_: RTCAudioDeviceModule, + didDisableEngine _: AVAudioEngine, + isPlayoutEnabled: Bool, + isRecordingEnabled: Bool) -> Int { + var resultCode = 0 + #if !os(macOS) + if !isPlayoutEnabled, !isRecordingEnabled { + lock.lock() + let shouldManageSession = isAutomaticManagementEnabled + lock.unlock() + + if shouldManageSession, let error = LiveKitPlugin.deactivateAudioSession() { + // Leave sessionActive untrue so cached state still reflects the + // live session — flipping it to false here would make a later + // configureNativeAudio(automatic:) cache-only while the session + // is in fact still active. + print("[LiveKit] AudioEngine didDisable: failed to deactivate audio session: \(error)") + resultCode = LiveKitPlugin.kAudioEngineErrorFailedToConfigureAudioSession + } else if shouldManageSession { + lock.lock(); sessionActive = false; lock.unlock() + } + } + if resultCode == 0 { + lock.lock() + lastIsPlayoutEnabled = isPlayoutEnabled + lastIsRecordingEnabled = isRecordingEnabled + lock.unlock() + } + #endif + if resultCode == 0 { + notifyEngineState(isPlayoutEnabled: isPlayoutEnabled, isRecordingEnabled: isRecordingEnabled) + } + return resultCode + } + + // Remaining callbacks are not needed for session management (proceed / no-op). + func audioDeviceModule(_: RTCAudioDeviceModule, didCreateEngine _: AVAudioEngine) -> Int { 0 } + func audioDeviceModule(_: RTCAudioDeviceModule, willStartEngine _: AVAudioEngine, isPlayoutEnabled _: Bool, isRecordingEnabled _: Bool) -> Int { 0 } + func audioDeviceModule(_: RTCAudioDeviceModule, didStopEngine _: AVAudioEngine, isPlayoutEnabled _: Bool, isRecordingEnabled _: Bool) -> Int { 0 } + func audioDeviceModule(_: RTCAudioDeviceModule, willReleaseEngine _: AVAudioEngine) -> Int { 0 } + func audioDeviceModule(_: RTCAudioDeviceModule, engine _: AVAudioEngine, configureInputFromSource _: AVAudioNode?, toDestination _: AVAudioNode, format _: AVAudioFormat, context _: [AnyHashable: Any]) -> Int { 0 } + func audioDeviceModule(_: RTCAudioDeviceModule, engine _: AVAudioEngine, configureOutputFromSource _: AVAudioNode, toDestination _: AVAudioNode?, format _: AVAudioFormat, context _: [AnyHashable: Any]) -> Int { 0 } + func audioDeviceModule(_: RTCAudioDeviceModule, didReceiveSpeechActivityEvent _: RTCSpeechActivityEvent) {} + func audioDeviceModuleDidUpdateDevices(_ audioDeviceModule: RTCAudioDeviceModule) { + FlutterWebRTCPlugin.sharedSingleton()?.audioDeviceModuleDidUpdateDevices(audioDeviceModule) + } + + private func notifyEngineState(isPlayoutEnabled: Bool, isRecordingEnabled: Bool) { + guard let channel = channel else { return } + DispatchQueue.main.async { + channel.invokeMethod("onAudioEngineState", arguments: [ + "isPlayoutEnabled": isPlayoutEnabled, + "isRecordingEnabled": isRecordingEnabled, + ]) + } + } +} From 23f48718382cdf69b2fff6b33df0759b315a7e0d Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 16 Jun 2026 03:57:27 +0800 Subject: [PATCH 06/21] fix(android): serialize audio switch lifecycle --- .../io/livekit/plugin/LKAudioSwitchManager.kt | 55 +++++++------------ .../kotlin/io/livekit/plugin/LiveKitPlugin.kt | 3 +- 2 files changed, 22 insertions(+), 36 deletions(-) diff --git a/android/src/main/kotlin/io/livekit/plugin/LKAudioSwitchManager.kt b/android/src/main/kotlin/io/livekit/plugin/LKAudioSwitchManager.kt index adf32346c..85426b4ea 100644 --- a/android/src/main/kotlin/io/livekit/plugin/LKAudioSwitchManager.kt +++ b/android/src/main/kotlin/io/livekit/plugin/LKAudioSwitchManager.kt @@ -41,12 +41,11 @@ import com.twilio.audioswitch.LegacyAudioSwitch * 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 + // AudioSwitch is not threadsafe; confine all access to a single long-lived + // thread. Do not recreate it on stop/start; queued lifecycle work must stay + // serialized. + private val thread = HandlerThread("LKAudioSwitchThread").also { it.start() } + private val handler = Handler(thread.looper) private var audioSwitch: AbstractAudioSwitch? = null private var isActive = false @@ -80,14 +79,13 @@ internal class LKAudioSwitchManager(private val context: Context) { // 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) } } + 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 { + handler.post { val switch = audioSwitch ?: createSwitch().also { audioSwitch = it } if (!isActive) { switch.activate() @@ -99,25 +97,30 @@ internal class LKAudioSwitchManager(private val context: Context) { /** 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 { + 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() } - 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 } + handler.post { + val switch = audioSwitch ?: return@post switch.setPreferredDeviceList(preferredDeviceList) val device = if (enable) { switch.availableAudioDevices.firstOrNull { it is AudioDevice.Speakerphone } @@ -130,13 +133,6 @@ internal class LKAudioSwitchManager(private val context: Context) { } } - /** 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 @@ -167,15 +163,6 @@ internal class LKAudioSwitchManager(private val context: Context) { 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( diff --git a/android/src/main/kotlin/io/livekit/plugin/LiveKitPlugin.kt b/android/src/main/kotlin/io/livekit/plugin/LiveKitPlugin.kt index 3301b47b2..3b1266fab 100644 --- a/android/src/main/kotlin/io/livekit/plugin/LiveKitPlugin.kt +++ b/android/src/main/kotlin/io/livekit/plugin/LiveKitPlugin.kt @@ -366,7 +366,6 @@ class LiveKitPlugin : FlutterPlugin, MethodCallHandler { "stopAndroidAudioSession" -> { audioSwitchManager?.stop() - audioSwitchManager?.clearCommunicationDevice() result.success(null) } @@ -385,7 +384,7 @@ class LiveKitPlugin : FlutterPlugin, MethodCallHandler { override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { channel.setMethodCallHandler(null) - audioSwitchManager?.stop() + audioSwitchManager?.dispose() audioSwitchManager = null // Cleanup all processors From b5a83a3af529ea2f0e63db647fa02fe4f3a04d67 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 16 Jun 2026 03:57:37 +0800 Subject: [PATCH 07/21] refactor(audio): remove stale track counting --- lib/src/track/audio_management.dart | 153 ---------------------------- lib/src/track/local/audio.dart | 3 +- lib/src/track/remote/audio.dart | 3 +- 3 files changed, 2 insertions(+), 157 deletions(-) diff --git a/lib/src/track/audio_management.dart b/lib/src/track/audio_management.dart index 0fb0cbb29..3c462d1a9 100644 --- a/lib/src/track/audio_management.dart +++ b/lib/src/track/audio_management.dart @@ -12,162 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. -import 'package:synchronized/synchronized.dart' as sync; - import '../audio/audio_manager.dart'; -import '../logger.dart'; import '../support/native.dart'; -import '../support/native_audio.dart'; import '../support/platform.dart'; -import 'local/local.dart'; -import 'remote/remote.dart'; - -enum AudioTrackState { - none, - remoteOnly, - localOnly, - localAndRemote, -} - -typedef ConfigureNativeAudioFunc = Future Function(AudioTrackState state); - -// it's possible to set custom function here to customize audio session configuration -ConfigureNativeAudioFunc onConfigureNativeAudio = defaultNativeAudioConfigurationFunc; - -final _trackCounterLock = sync.Lock(); -AudioTrackState _audioTrackState = AudioTrackState.none; - -AudioTrackState get audioTrackState => _audioTrackState; - -int _localTrackCount = 0; -int _remoteTrackCount = 0; - -mixin LocalAudioManagementMixin on LocalTrack, AudioTrack { - @override - Future onPublish() async { - final didUpdate = await super.onPublish(); - if (didUpdate) { - // update counter - await _trackCounterLock.synchronized(() async { - _localTrackCount++; - await _onAudioTrackCountDidChange(); - }); - } - return didUpdate; - } - - @override - Future onUnpublish() async { - final didUpdate = await super.onUnpublish(); - if (didUpdate) { - // update counter - await _trackCounterLock.synchronized(() async { - _localTrackCount--; - await _onAudioTrackCountDidChange(); - }); - } - return didUpdate; - } -} -mixin RemoteAudioManagementMixin on RemoteTrack, AudioTrack { - /// Start playing audio track. On web platform, create an audio element and - /// start playback - @override - Future start() async { - final didStart = await super.start(); - if (didStart) { - await _trackCounterLock.synchronized(() async { - _remoteTrackCount++; - await _onAudioTrackCountDidChange(); - }); - } - return didStart; - } - - @override - Future stop() async { - final didStop = await super.stop(); - if (didStop) { - await _trackCounterLock.synchronized(() async { - _remoteTrackCount--; - await _onAudioTrackCountDidChange(); - }); - } - return didStop; - } -} - -Future _onAudioTrackCountDidChange() async { - logger.fine('onAudioTrackCountDidChange: ' - 'local: $_localTrackCount, remote: $_remoteTrackCount'); - - final newState = _computeAudioTrackState(); - - 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 = AudioManager.instance.shouldUseLegacyAutomaticAppleConfiguration - ? await onConfigureNativeAudio.call(_audioTrackState) - : AudioManager.instance.automaticAppleAudioConfiguration(); - - if (AudioManager.instance.forceSpeakerOutput) { - config = config.copyWith( - appleAudioCategoryOptions: { - ...?config.appleAudioCategoryOptions, - AppleAudioCategoryOption.defaultToSpeaker, - }, - ); - } - } - - if (config != null) { - logger.fine('configuring for ${_audioTrackState} using ${config}...'); - try { - logger.fine('configuring native audio...'); - await Native.configureAudio(config); - } catch (error) { - logger.warning('failed to configure ${error}'); - } - } - } -} - -AudioTrackState _computeAudioTrackState() { - if (_localTrackCount > 0 && _remoteTrackCount == 0) { - return AudioTrackState.localOnly; - } else if (_localTrackCount == 0 && _remoteTrackCount > 0) { - return AudioTrackState.remoteOnly; - } else if (_localTrackCount > 0 && _remoteTrackCount > 0) { - return AudioTrackState.localAndRemote; - } - // Default - return AudioTrackState.none; -} - -Future defaultNativeAudioConfigurationFunc(AudioTrackState state) async { - if (state == AudioTrackState.none) { - return NativeAudioConfiguration.soloAmbient; - } else if (state == AudioTrackState.remoteOnly && AudioManager.instance.preferSpeakerOutput) { - return NativeAudioConfiguration.playback; - } - - return AudioManager.instance.preferSpeakerOutput - ? NativeAudioConfiguration.playAndRecordSpeaker - : NativeAudioConfiguration.playAndRecordReceiver; -} class NativeAudioManagement { static Future start() async { diff --git a/lib/src/track/local/audio.dart b/lib/src/track/local/audio.dart index 48468b8cc..1edc688c8 100644 --- a/lib/src/track/local/audio.dart +++ b/lib/src/track/local/audio.dart @@ -26,11 +26,10 @@ import '../../stats/audio_source_stats.dart'; import '../../stats/stats.dart'; import '../../support/native.dart'; import '../../types/other.dart'; -import '../audio_management.dart'; import '../options.dart' as track_options; import 'local.dart'; -class LocalAudioTrack extends LocalTrack with AudioTrack, LocalAudioManagementMixin { +class LocalAudioTrack extends LocalTrack with AudioTrack { // Options used for this track @override covariant track_options.AudioCaptureOptions currentOptions; diff --git a/lib/src/track/remote/audio.dart b/lib/src/track/remote/audio.dart index 5038df572..c7eb4bf4e 100644 --- a/lib/src/track/remote/audio.dart +++ b/lib/src/track/remote/audio.dart @@ -21,12 +21,11 @@ import '../../logger.dart'; import '../../stats/audio_source_stats.dart'; import '../../stats/stats.dart'; import '../../types/other.dart'; -import '../audio_management.dart'; import '../local/local.dart'; import '../web/_audio_api.dart' if (dart.library.js_interop) '../web/_audio_html.dart' as audio; import 'remote.dart'; -class RemoteAudioTrack extends RemoteTrack with AudioTrack, RemoteAudioManagementMixin { +class RemoteAudioTrack extends RemoteTrack with AudioTrack { String? _deviceId; RemoteAudioTrack(TrackSource source, rtc.MediaStream stream, rtc.MediaStreamTrack track, {rtc.RTCRtpReceiver? receiver}) From 3e237d258d7d54608f939ddfd2d11b4225e66250 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 16 Jun 2026 03:57:45 +0800 Subject: [PATCH 08/21] test(audio): cover session configuration behavior --- test/audio/audio_session_test.dart | 368 ++++++++++++++++++++++++++++- 1 file changed, 365 insertions(+), 3 deletions(-) diff --git a/test/audio/audio_session_test.dart b/test/audio/audio_session_test.dart index f2fa77409..8132b1caa 100644 --- a/test/audio/audio_session_test.dart +++ b/test/audio/audio_session_test.dart @@ -17,8 +17,15 @@ 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'; +import 'package:livekit_client/src/support/native.dart'; +import 'package:livekit_client/src/support/native_audio.dart' as native_audio; void main() { + setUp(() { + AudioManager.instance.resetForTest(); + Native.bypassVoiceProcessing = false; + }); + group('AudioSessionManagementMode', () { test('supports automatic and manual management', () { expect( @@ -48,19 +55,349 @@ void main() { expect(media.isCommunication, isFalse); expect(media.isMedia, isTrue); }); + + test('copyWith updates and clears platform overrides', () { + const options = AudioSessionOptions.communication( + preferSpeakerOutput: true, + apple: AppleAudioSessionConfiguration( + category: AppleAudioCategory.playAndRecord, + ), + android: AndroidAudioSessionConfiguration( + audioMode: AndroidAudioMode.inCommunication, + ), + ); + + final updated = options.copyWith( + preferSpeakerOutput: const Value(false), + apple: const Value( + AppleAudioSessionConfiguration( + mode: AppleAudioMode.voiceChat, + ), + ), + ); + + expect(updated.preferSpeakerOutput, isFalse); + expect(updated.apple?.category, isNull); + expect(updated.apple?.mode, AppleAudioMode.voiceChat); + expect(updated.android?.audioMode, AndroidAudioMode.inCommunication); + + final cleared = updated.copyWith( + apple: const Value(null), + android: const Value(null), + ); + + expect(cleared.apple, isNull); + expect(cleared.android, isNull); + }); + + test('Apple configuration copyWith updates and clears nullable fields', () { + const config = AppleAudioSessionConfiguration( + category: AppleAudioCategory.playAndRecord, + categoryOptions: {AppleAudioCategoryOption.allowBluetooth}, + mode: AppleAudioMode.voiceChat, + preferSpeakerOutput: false, + ); + + final updated = config.copyWith( + category: const Value(AppleAudioCategory.playback), + categoryOptions: const Value({AppleAudioCategoryOption.mixWithOthers}), + mode: const Value(AppleAudioMode.spokenAudio), + preferSpeakerOutput: const Value(true), + ); + + expect(updated.category, AppleAudioCategory.playback); + expect(updated.categoryOptions, {AppleAudioCategoryOption.mixWithOthers}); + expect(updated.mode, AppleAudioMode.spokenAudio); + expect(updated.preferSpeakerOutput, isTrue); + + final cleared = updated.copyWith( + category: const Value(null), + categoryOptions: const Value(null), + mode: const Value(null), + preferSpeakerOutput: const Value(null), + ); + + expect(cleared.category, isNull); + expect(cleared.categoryOptions, isNull); + expect(cleared.mode, isNull); + expect(cleared.preferSpeakerOutput, isNull); + }); + + test('Android configuration copyWith updates and clears nullable fields', () { + const config = AndroidAudioSessionConfiguration( + audioMode: AndroidAudioMode.inCommunication, + manageAudioFocus: true, + focusMode: AndroidAudioFocusMode.gain, + streamType: AndroidAudioStreamType.voiceCall, + usageType: AndroidAudioAttributesUsageType.voiceCommunication, + contentType: AndroidAudioAttributesContentType.speech, + forceAudioRouting: true, + ); + + final updated = config.copyWith( + audioMode: const Value(AndroidAudioMode.normal), + manageAudioFocus: const Value(false), + focusMode: const Value(AndroidAudioFocusMode.gainTransient), + streamType: const Value(AndroidAudioStreamType.music), + usageType: const Value(AndroidAudioAttributesUsageType.media), + contentType: const Value(AndroidAudioAttributesContentType.unknown), + forceAudioRouting: const Value(false), + ); + + expect(updated.audioMode, AndroidAudioMode.normal); + expect(updated.manageAudioFocus, isFalse); + expect(updated.focusMode, AndroidAudioFocusMode.gainTransient); + expect(updated.streamType, AndroidAudioStreamType.music); + expect(updated.usageType, AndroidAudioAttributesUsageType.media); + expect(updated.contentType, AndroidAudioAttributesContentType.unknown); + expect(updated.forceAudioRouting, isFalse); + + final cleared = updated.copyWith( + audioMode: const Value(null), + manageAudioFocus: const Value(null), + focusMode: const Value(null), + streamType: const Value(null), + usageType: const Value(null), + contentType: const Value(null), + forceAudioRouting: const Value(null), + ); + + expect(cleared.audioMode, isNull); + expect(cleared.manageAudioFocus, isNull); + expect(cleared.focusMode, isNull); + expect(cleared.streamType, isNull); + expect(cleared.usageType, isNull); + expect(cleared.contentType, isNull); + expect(cleared.forceAudioRouting, isNull); + }); }); group('AudioManager', () { - test('management mode can be set independently from options', () { + test('management mode can be set independently from options', () async { final manager = AudioManager.instance; - manager.setAudioSessionManagementMode(AudioSessionManagementMode.manual); + await manager.setAudioSessionManagementMode(AudioSessionManagementMode.manual); expect(manager.managementMode, AudioSessionManagementMode.manual); expect(manager.isAutomaticConfigurationEnabled, isFalse); expect(manager.options.isCommunication, isTrue); - manager.setAudioSessionManagementMode(AudioSessionManagementMode.automatic); + await manager.setAudioSessionManagementMode(AudioSessionManagementMode.automatic); + }); + + test('setAudioSessionOptions syncs communication speaker preference', () async { + final manager = AudioManager.instance; + + await manager.setAudioSessionOptions( + const AudioSessionOptions.communication(preferSpeakerOutput: false), + ); + + expect(manager.speakerphoneOn, isFalse); + expect(manager.preferSpeakerOutput, isFalse); + expect(manager.options.preferSpeakerOutput, isFalse); + + await manager.setAudioSessionOptions( + const AudioSessionOptions.communication(preferSpeakerOutput: true), + ); + + expect(manager.speakerphoneOn, isTrue); + expect(manager.preferSpeakerOutput, isTrue); + expect(manager.options.preferSpeakerOutput, isTrue); + }); + + test('setAudioSessionOptions syncs explicit Apple speaker preference', () async { + final manager = AudioManager.instance; + + await manager.setAudioSessionOptions( + const AudioSessionOptions.communication( + apple: AppleAudioSessionConfiguration( + preferSpeakerOutput: false, + ), + ), + ); + + expect(manager.speakerphoneOn, isFalse); + expect(manager.preferSpeakerOutput, isFalse); + + await manager.setAudioSessionOptions( + const AudioSessionOptions.communication( + apple: AppleAudioSessionConfiguration( + preferSpeakerOutput: true, + ), + ), + ); + + expect(manager.speakerphoneOn, isTrue); + expect(manager.preferSpeakerOutput, isTrue); + }); + + test('resolves communication Apple session policy from speaker preference', () { + final manager = AudioManager.instance; + + final speaker = manager.resolveAppleAudioConfigurationForTest( + const AudioSessionOptions.communication(preferSpeakerOutput: true), + ); + + expect(speaker.appleAudioCategory, AppleAudioCategory.playAndRecord); + expect(speaker.appleAudioMode, AppleAudioMode.videoChat); + expect(speaker.preferSpeakerOutput, isTrue); + expect( + speaker.appleAudioCategoryOptions, + { + AppleAudioCategoryOption.allowBluetooth, + AppleAudioCategoryOption.allowBluetoothA2DP, + AppleAudioCategoryOption.allowAirPlay, + }, + ); + + final receiver = manager.resolveAppleAudioConfigurationForTest( + const AudioSessionOptions.communication(preferSpeakerOutput: false), + ); + + expect(receiver.appleAudioCategory, AppleAudioCategory.playAndRecord); + expect(receiver.appleAudioMode, AppleAudioMode.voiceChat); + expect(receiver.preferSpeakerOutput, isFalse); + }); + + test('resolves media Apple session policy as dynamic playAndRecord base', () { + final config = AudioManager.instance.resolveAppleAudioConfigurationForTest( + const AudioSessionOptions.media(), + ); + + expect(config.appleAudioCategory, AppleAudioCategory.playAndRecord); + expect(config.appleAudioMode, AppleAudioMode.default_); + expect(config.preferSpeakerOutput, isTrue); + expect( + config.appleAudioCategoryOptions, + { + AppleAudioCategoryOption.mixWithOthers, + AppleAudioCategoryOption.allowBluetooth, + AppleAudioCategoryOption.allowBluetoothA2DP, + AppleAudioCategoryOption.allowAirPlay, + }, + ); + }); + + test('forced speaker only adds defaultToSpeaker to playAndRecord Apple sessions', () { + final manager = AudioManager.instance; + + final playback = manager.resolveAppleAudioConfigurationForTest( + const AudioSessionOptions.media( + apple: AppleAudioSessionConfiguration( + category: AppleAudioCategory.playback, + categoryOptions: {AppleAudioCategoryOption.mixWithOthers}, + ), + ), + forceSpeakerOutput: true, + ); + + expect(playback.appleAudioCategory, AppleAudioCategory.playback); + expect(playback.appleAudioCategoryOptions, {AppleAudioCategoryOption.mixWithOthers}); + + final playAndRecord = manager.resolveAppleAudioConfigurationForTest( + const AudioSessionOptions.communication( + apple: AppleAudioSessionConfiguration( + category: AppleAudioCategory.playAndRecord, + categoryOptions: {AppleAudioCategoryOption.allowBluetooth}, + ), + ), + forceSpeakerOutput: true, + ); + + expect(playAndRecord.appleAudioCategory, AppleAudioCategory.playAndRecord); + expect( + playAndRecord.appleAudioCategoryOptions, + { + AppleAudioCategoryOption.allowBluetooth, + AppleAudioCategoryOption.defaultToSpeaker, + }, + ); + }); + + test('resolves Android session policy from preset or explicit override', () { + final manager = AudioManager.instance; + + final communication = manager.resolveAndroidAudioConfigurationForTest( + const AudioSessionOptions.communication(), + ); + + expect(communication.audioMode, AndroidAudioMode.inCommunication); + expect(communication.streamType, AndroidAudioStreamType.voiceCall); + + final media = manager.resolveAndroidAudioConfigurationForTest( + const AudioSessionOptions.media(), + ); + + expect(media.audioMode, AndroidAudioMode.normal); + expect(media.streamType, AndroidAudioStreamType.music); + + final explicit = manager.resolveAndroidAudioConfigurationForTest( + const AudioSessionOptions.communication( + android: AndroidAudioSessionConfiguration( + audioMode: AndroidAudioMode.normal, + forceAudioRouting: true, + ), + ), + ); + + expect(explicit.audioMode, AndroidAudioMode.normal); + expect(explicit.forceAudioRouting, isTrue); + }); + + test('Android initialize configuration uses active runtime options', () async { + final manager = AudioManager.instance; + + await manager.setAudioSessionOptions(const AudioSessionOptions.communication()); + manager.configureDefaults(bypassVoiceProcessing: true); + Native.bypassVoiceProcessing = true; + + expect( + manager.androidAudioConfigurationForInitialize(assumeAndroid: true), + containsPair('androidAudioMode', 'inCommunication'), + ); + }); + + test('handleAudioEngineState updates snapshot and stream', () async { + final manager = AudioManager.instance; + final states = []; + final subscription = manager.audioEngineStateStream.listen(states.add); + + manager.handleAudioEngineState( + isPlayoutEnabled: true, + isRecordingEnabled: false, + ); + await pumpEventQueue(); + + expect( + manager.audioEngineState, + const AudioEngineState(isPlayoutEnabled: true, isRecordingEnabled: false), + ); + expect(states, [const AudioEngineState(isPlayoutEnabled: true, isRecordingEnabled: false)]); + + manager.handleAudioEngineState( + isPlayoutEnabled: true, + isRecordingEnabled: false, + ); + await pumpEventQueue(); + + expect(states, [const AudioEngineState(isPlayoutEnabled: true, isRecordingEnabled: false)]); + + manager.handleAudioEngineState( + isPlayoutEnabled: false, + isRecordingEnabled: false, + ); + await pumpEventQueue(); + + expect(manager.audioEngineState.isIdle, isTrue); + expect( + states, + [ + const AudioEngineState(isPlayoutEnabled: true, isRecordingEnabled: false), + const AudioEngineState(isPlayoutEnabled: false, isRecordingEnabled: false), + ], + ); + + await subscription.cancel(); }); }); @@ -88,6 +425,31 @@ void main() { }); }); + group('NativeAudioConfiguration', () { + test('serializes Apple audio wire format', () { + final map = native_audio.NativeAudioConfiguration( + appleAudioCategory: AppleAudioCategory.playAndRecord, + appleAudioCategoryOptions: { + AppleAudioCategoryOption.allowBluetooth, + AppleAudioCategoryOption.defaultToSpeaker, + }, + appleAudioMode: AppleAudioMode.default_, + preferSpeakerOutput: false, + ).toMap(); + + expect(map['appleAudioCategory'], 'playAndRecord'); + expect( + map['appleAudioCategoryOptions'], + unorderedEquals([ + 'allowBluetooth', + 'defaultToSpeaker', + ]), + ); + expect(map['appleAudioMode'], 'default'); + expect(map['preferSpeakerOutput'], isFalse); + }); + }); + group('androidAudioSessionConfigurationToMap', () { test('serializes communication preset for WebRTC initialization', () { expect( From 115c43b11f444485c707a762ad92f52349f2707c Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 16 Jun 2026 03:57:52 +0800 Subject: [PATCH 09/21] docs(audio): note session management migration --- .changes/audio-session-management-migration | 1 + 1 file changed, 1 insertion(+) create mode 100644 .changes/audio-session-management-migration diff --git a/.changes/audio-session-management-migration b/.changes/audio-session-management-migration new file mode 100644 index 000000000..c3ff67b92 --- /dev/null +++ b/.changes/audio-session-management-migration @@ -0,0 +1 @@ +minor type="changed" "LiveKit now owns native audio session lifecycle; migrate custom track-count audio hooks to AudioManager.setAudioSessionOptions." From 65e8a0b8c6fdb346c79b96afb9799382e7d3447b Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 16 Jun 2026 04:19:08 +0800 Subject: [PATCH 10/21] adjustments --- .changes/audio-manager-api | 2 +- .changes/audio-session-management-migration | 1 - .../io/livekit/plugin/LKAudioSwitchManager.kt | 8 ++++---- lib/src/audio/audio_manager.dart | 18 +++++++++--------- lib/src/audio/audio_session.dart | 2 +- lib/src/hardware/hardware.dart | 2 +- lib/src/livekit.dart | 2 +- shared_swift/LiveKitPlugin.swift | 18 +++++++++--------- 8 files changed, 26 insertions(+), 27 deletions(-) delete mode 100644 .changes/audio-session-management-migration diff --git a/.changes/audio-manager-api b/.changes/audio-manager-api index 23b09ab5e..2fd918ba1 100644 --- a/.changes/audio-manager-api +++ b/.changes/audio-manager-api @@ -1 +1 @@ -minor type="added" "AudioManager audio session management: session options, Android audio session configuration and routing, Apple speakerphone control" +minor type="added" "AudioManager audio session options with engine-driven native lifecycle and platform routing controls" diff --git a/.changes/audio-session-management-migration b/.changes/audio-session-management-migration deleted file mode 100644 index c3ff67b92..000000000 --- a/.changes/audio-session-management-migration +++ /dev/null @@ -1 +0,0 @@ -minor type="changed" "LiveKit now owns native audio session lifecycle; migrate custom track-count audio hooks to AudioManager.setAudioSessionOptions." diff --git a/android/src/main/kotlin/io/livekit/plugin/LKAudioSwitchManager.kt b/android/src/main/kotlin/io/livekit/plugin/LKAudioSwitchManager.kt index 85426b4ea..05aafe94c 100644 --- a/android/src/main/kotlin/io/livekit/plugin/LKAudioSwitchManager.kt +++ b/android/src/main/kotlin/io/livekit/plugin/LKAudioSwitchManager.kt @@ -29,8 +29,8 @@ 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]. + * 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 @@ -41,8 +41,8 @@ import com.twilio.audioswitch.LegacyAudioSwitch * single dedicated [HandlerThread]. */ internal class LKAudioSwitchManager(private val context: Context) { - // AudioSwitch is not threadsafe; confine all access to a single long-lived - // thread. Do not recreate it on stop/start; queued lifecycle work must stay + // AudioSwitch is not threadsafe, so confine all access to a single long-lived + // thread. Do not recreate it on stop/start. Queued lifecycle work must stay // serialized. private val thread = HandlerThread("LKAudioSwitchThread").also { it.start() } private val handler = Handler(thread.looper) diff --git a/lib/src/audio/audio_manager.dart b/lib/src/audio/audio_manager.dart index 849abd255..b58c2d337 100644 --- a/lib/src/audio/audio_manager.dart +++ b/lib/src/audio/audio_manager.dart @@ -100,7 +100,7 @@ class AudioManager { /// A broadcast stream of audio engine state changes (native engine lifecycle). Stream get audioEngineStateStream => _audioEngineStateController.stream; - // Derived from [managementMode]; kept internal so the public surface exposes + // Derived from [managementMode]. Kept internal so the public surface exposes // a single way to read the mode. @internal bool get isAutomaticConfigurationEnabled => _managementMode == AudioSessionManagementMode.automatic; @@ -130,11 +130,11 @@ class AudioManager { /// Invoked from native when the WebRTC audio engine's playout/recording state /// changes. Audio-engine lifecycle events are the single source of truth for - /// audio activity; this replaces the legacy track-counting path, which had + /// audio activity. This replaces the legacy track-counting path, which had /// timing races and could miss session deactivation. /// /// On iOS the native engine delegate also owns audio-session activation - /// timing (configure + activate on enable, deactivate on disable); this Dart + /// timing (configure + activate on enable, deactivate on disable). This Dart /// hop is non-blocking and only keeps the observable state in sync. macOS /// emits the same events (no `AVAudioSession` to configure) so engine state /// stays authoritative there too. @@ -177,8 +177,8 @@ class AudioManager { /// configuration explicitly with [setAudioSessionOptions]. /// /// Prefer setting this before connecting to a room. 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 + /// native audio management is always disabled (LiveKit owns the session). + /// Changing the mode at runtime only affects LiveKit's own automatic /// configuration. Future setAudioSessionManagementMode(AudioSessionManagementMode mode) async { _managementMode = mode; @@ -191,8 +191,8 @@ class AudioManager { /// 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 + /// 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) { @@ -244,7 +244,7 @@ class AudioManager { return null; } - // Preserve today's implicit initialize behavior; only send Android audio + // Preserve today's implicit initialize behavior. Only send Android audio // attributes when the bypassVoiceProcessing path needs media attributes. if (!isAutomaticConfigurationEnabled || !Native.bypassVoiceProcessing) { return null; @@ -398,7 +398,7 @@ class _ResolvedAudioSessionPolicy { ); } - // Media (non-communication) base policy. The category here is a base; in + // Media (non-communication) base policy. The category here is a base. In // automatic mode the native engine delegate overrides it from the live // engine state (playAndRecord while recording, playback for playout-only), // so it no longer depends on stale track/engine flags resolved at connect. diff --git a/lib/src/audio/audio_session.dart b/lib/src/audio/audio_session.dart index f4e191391..ed4dc7d37 100644 --- a/lib/src/audio/audio_session.dart +++ b/lib/src/audio/audio_session.dart @@ -63,7 +63,7 @@ class AudioSessionOptions { /// [android] provides a more exact platform policy. /// /// On Apple platforms in automatic mode, listen-only playout uses playback - /// until recording starts; receiver routing from [preferSpeakerOutput] only + /// until recording starts. Receiver routing from [preferSpeakerOutput] only /// applies while the effective category is `playAndRecord`. const AudioSessionOptions.communication({ bool preferSpeakerOutput = true, diff --git a/lib/src/hardware/hardware.dart b/lib/src/hardware/hardware.dart index e781571bc..4db4b87bd 100644 --- a/lib/src/hardware/hardware.dart +++ b/lib/src/hardware/hardware.dart @@ -84,7 +84,7 @@ class Hardware { // configuring the native audio session manually. // // Backed by [AudioManager] so there is a single source of truth for the - // management mode; see [AudioManager.setAudioSessionManagementMode]. + // management mode. See [AudioManager.setAudioSessionManagementMode]. @Deprecated('Use AudioManager.instance.managementMode instead') bool get isAutomaticConfigurationEnabled => AudioManager.instance.isAutomaticConfigurationEnabled; diff --git a/lib/src/livekit.dart b/lib/src/livekit.dart index ed70268e6..802c66475 100644 --- a/lib/src/livekit.dart +++ b/lib/src/livekit.dart @@ -26,7 +26,7 @@ class LiveKitClient { /// Initialize the WebRTC plugin. /// /// Optional: call once at startup to enable [bypassVoiceProcessing] before - /// connecting; otherwise WebRTC initializes lazily with defaults. + /// 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 diff --git a/shared_swift/LiveKitPlugin.swift b/shared_swift/LiveKitPlugin.swift index 2fe2a7298..a994685a0 100644 --- a/shared_swift/LiveKitPlugin.swift +++ b/shared_swift/LiveKitPlugin.swift @@ -81,7 +81,7 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { // Own the audio device module's engine-lifecycle delegate so LiveKit // drives the audio session from real engine events (configure + activate // on enable, deactivate on disable) instead of track counting. The - // engine emits these events on both iOS and macOS; macOS has no + // engine emits these events on both iOS and macOS. macOS has no // AVAudioSession to configure, so there it only surfaces engine state. // Set before the peer connection factory is created. instance.channel = channel @@ -602,7 +602,7 @@ extension LiveKitPlugin { do { try rtcSession.setConfiguration(configuration, active: active) // overrideOutputAudioPort is only valid for the playAndRecord - // category; calling it for a playback session throws. + // category. Calling it for a playback session throws. if active, let preferSpeakerOutput = preferSpeakerOutput, configuration.category == AVAudioSession.Category.playAndRecord.rawValue { @@ -636,10 +636,10 @@ extension LiveKitPlugin { /// state to Dart (keeping engine state the single source of truth there too). /// /// The engine-lifecycle methods are invoked synchronously on WebRTC's worker -/// thread — the engine blocks on the return value (`0` = proceed, non-zero = +/// thread. The engine blocks on the return value (`0` = proceed, non-zero = /// abort / roll back), so the session work here is synchronous and never calls /// back into the audio device module. The Dart notification is dispatched -/// asynchronously and is purely informational; it never blocks the engine. +/// asynchronously and is purely informational. It never blocks the engine. @available(iOS 13.0, macOS 10.15, *) class LKAudioEngineObserver: NSObject, RTCAudioDeviceModuleDelegate { private let lock = NSLock() @@ -651,7 +651,7 @@ class LKAudioEngineObserver: NSObject, RTCAudioDeviceModuleDelegate { // When true, the category is chosen from the live engine state at apply time // (playAndRecord while recording, playback for playout-only) rather than // taken from the pushed config. This is what keeps the category correct as - // recording/playout come and go; the pushed config still supplies the mode, + // recording/playout come and go. The pushed config still supplies the mode, // options and speaker preference. False for an explicit per-platform // override or manual mode, where the config is applied verbatim. private var selectCategoryByEngineState = false @@ -680,8 +680,8 @@ class LKAudioEngineObserver: NSObject, RTCAudioDeviceModuleDelegate { lock.unlock() } - /// Stores the audio session policy pushed from Dart. Pure cache — the - /// delegate callbacks apply it; callers decide whether to apply immediately. + /// Stores the audio session policy pushed from Dart. Pure cache, where the + /// delegate callbacks apply it. Callers decide whether to apply immediately. func updatePolicy(_ configuration: RTCAudioSessionConfiguration, preferSpeakerOutput: Bool?, automaticManagementEnabled: Bool, @@ -743,7 +743,7 @@ class LKAudioEngineObserver: NSObject, RTCAudioDeviceModuleDelegate { } #endif - // MARK: RTCAudioDeviceModuleDelegate — engine lifecycle + // MARK: RTCAudioDeviceModuleDelegate, engine lifecycle func audioDeviceModule(_: RTCAudioDeviceModule, willEnableEngine _: AVAudioEngine, @@ -796,7 +796,7 @@ class LKAudioEngineObserver: NSObject, RTCAudioDeviceModuleDelegate { if shouldManageSession, let error = LiveKitPlugin.deactivateAudioSession() { // Leave sessionActive untrue so cached state still reflects the - // live session — flipping it to false here would make a later + // live session. Flipping it to false here would make a later // configureNativeAudio(automatic:) cache-only while the session // is in fact still active. print("[LiveKit] AudioEngine didDisable: failed to deactivate audio session: \(error)") From e35b0363d6f808104e5f37985ca13a0a1e6cff62 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:23:21 +0800 Subject: [PATCH 11/21] docs(apple): clarify sessionActive comment on deactivate failure --- shared_swift/LiveKitPlugin.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/shared_swift/LiveKitPlugin.swift b/shared_swift/LiveKitPlugin.swift index a994685a0..d0fcc264e 100644 --- a/shared_swift/LiveKitPlugin.swift +++ b/shared_swift/LiveKitPlugin.swift @@ -795,10 +795,10 @@ class LKAudioEngineObserver: NSObject, RTCAudioDeviceModuleDelegate { lock.unlock() if shouldManageSession, let error = LiveKitPlugin.deactivateAudioSession() { - // Leave sessionActive untrue so cached state still reflects the - // live session. Flipping it to false here would make a later - // configureNativeAudio(automatic:) cache-only while the session - // is in fact still active. + // Leave sessionActive unchanged (still true) so cached state + // keeps reflecting the live session. Flipping it to false here + // would make a later configureNativeAudio(automatic:) cache-only + // while the session is in fact still active. print("[LiveKit] AudioEngine didDisable: failed to deactivate audio session: \(error)") resultCode = LiveKitPlugin.kAudioEngineErrorFailedToConfigureAudioSession } else if shouldManageSession { From f7de7f7e780c84df9d36d40cc9641839240d62ab Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 16 Jun 2026 15:07:21 +0800 Subject: [PATCH 12/21] refactor(audio): move Apple audio enums to public audio types Relocate AppleAudioCategory / AppleAudioCategoryOption / AppleAudioMode from the internal wire file (support/native_audio.dart) into audio_session.dart, where the public AppleAudioSessionConfiguration already lives. native_audio.dart keeps only the internal NativeAudioConfiguration wire type and its toStringValue serialization, importing the enums from the audio layer. Removes the import-plus-export of native_audio from audio_session.dart. Public API and behavior are unchanged. --- lib/src/audio/audio_session.dart | 35 +++++++++++++++++++++++++++++-- lib/src/support/native_audio.dart | 34 +----------------------------- 2 files changed, 34 insertions(+), 35 deletions(-) diff --git a/lib/src/audio/audio_session.dart b/lib/src/audio/audio_session.dart index ed4dc7d37..2e5b382f7 100644 --- a/lib/src/audio/audio_session.dart +++ b/lib/src/audio/audio_session.dart @@ -12,12 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -export '../support/native_audio.dart' show AppleAudioCategory, AppleAudioCategoryOption, AppleAudioMode; export '../support/value_or_absent.dart'; import 'package:meta/meta.dart'; -import '../support/native_audio.dart' show AppleAudioCategory, AppleAudioCategoryOption, AppleAudioMode; import '../support/value_or_absent.dart'; enum AudioSessionManagementMode { @@ -116,6 +114,39 @@ class AudioSessionOptions { bool get isMedia => _preset == _AudioSessionPreset.media; } +// https://developer.apple.com/documentation/avfaudio/avaudiosession/category +enum AppleAudioCategory { + soloAmbient, + playback, + record, + playAndRecord, + multiRoute, +} + +// https://developer.apple.com/documentation/avfaudio/avaudiosession/categoryoptions +enum AppleAudioCategoryOption { + mixWithOthers, // Only playAndRecord, playback, or multiRoute. + duckOthers, // Only playAndRecord, playback, or multiRoute. + interruptSpokenAudioAndMixWithOthers, + allowBluetooth, // Only playAndRecord or record. + allowBluetoothA2DP, + allowAirPlay, + defaultToSpeaker, +} + +// https://developer.apple.com/documentation/avfaudio/avaudiosession/mode +enum AppleAudioMode { + default_, + gameChat, + measurement, + moviePlayback, + spokenAudio, + videoChat, + videoRecording, + voiceChat, + voicePrompt, +} + class AppleAudioSessionConfiguration { /// AVAudioSession category. final AppleAudioCategory? category; diff --git a/lib/src/support/native_audio.dart b/lib/src/support/native_audio.dart index afc7df47d..d4aae4f2d 100644 --- a/lib/src/support/native_audio.dart +++ b/lib/src/support/native_audio.dart @@ -12,41 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. +import '../audio/audio_session.dart' show AppleAudioCategory, AppleAudioCategoryOption, AppleAudioMode; import 'value_or_absent.dart'; -// https://developer.apple.com/documentation/avfaudio/avaudiosession/category -enum AppleAudioCategory { - soloAmbient, - playback, - record, - playAndRecord, - multiRoute, -} - -// https://developer.apple.com/documentation/avfaudio/avaudiosession/categoryoptions -enum AppleAudioCategoryOption { - mixWithOthers, // Only playAndRecord, playback, or multiRoute. - duckOthers, // Only playAndRecord, playback, or multiRoute. - interruptSpokenAudioAndMixWithOthers, - allowBluetooth, // Only playAndRecord or record. - allowBluetoothA2DP, - allowAirPlay, - defaultToSpeaker, -} - -// https://developer.apple.com/documentation/avfaudio/avaudiosession/mode -enum AppleAudioMode { - default_, - gameChat, - measurement, - moviePlayback, - spokenAudio, - videoChat, - videoRecording, - voiceChat, - voicePrompt, -} - extension AppleAudioCategoryExt on AppleAudioCategory { String toStringValue() => { AppleAudioCategory.soloAmbient: 'soloAmbient', From 2a5830d3e461cd4a7f694187b7b64b419cad7cd7 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 16 Jun 2026 15:25:09 +0800 Subject: [PATCH 13/21] docs(audio): add audio session guide and link from README --- README.md | 10 +++ docs/audio.md | 174 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 docs/audio.md diff --git a/README.md b/README.md index 9a80d3dfb..43695182d 100644 --- a/README.md +++ b/README.md @@ -322,6 +322,16 @@ Widget build(BuildContext context) { Audio tracks are played automatically as long as you are subscribed to them. +LiveKit owns the platform audio session through `AudioManager`, which lets you choose a session intent, route to the speaker, and pin per platform values. See the [audio session guide](https://github.com/livekit/client-sdk-flutter/blob/main/docs/audio.md) for examples covering communication and media modes, speaker routing, manual mode, per platform overrides, and migration from the older `Hardware` APIs. + +```dart +// Enter call mode and prefer the speaker +await AudioManager.instance.setAudioSessionOptions( + const AudioSessionOptions.communication(), +); +await AudioManager.instance.setSpeakerphoneOn(true); +``` + ### Handling changes LiveKit client makes it simple to build declarative UI that reacts to state changes. It notifies changes in two ways diff --git a/docs/audio.md b/docs/audio.md new file mode 100644 index 000000000..cc5665e70 --- /dev/null +++ b/docs/audio.md @@ -0,0 +1,174 @@ +# Audio session management + +LiveKit owns the platform audio session on iOS, macOS, and Android through a single process-wide entry point, `AudioManager`. You configure session intent once with typed options and LiveKit applies the right native category, mode, focus, and routing for you. This replaces the older approach that relied on flutter_webrtc defaults, `Hardware` speaker calls, and Dart track counting. + +`AudioManager` is a singleton, reached through `AudioManager.instance`. + +## Quick start + +Pick a session intent. `communication` is the default and is meant for calls and rooms where you both send and receive audio. `media` is for one way playback or media capture where communication mode is not wanted. + +```dart +import 'package:livekit_client/livekit_client.dart'; + +// VoIP style call (this is the default, so you only need it to be explicit) +await AudioManager.instance.setAudioSessionOptions( + const AudioSessionOptions.communication(), +); + +// Media playback or live streaming capture +await AudioManager.instance.setAudioSessionOptions( + const AudioSessionOptions.media(), +); +``` + +Set this before connecting when you can. The explicit apply path above works in both automatic and manual management modes. + +## Speaker routing + +```dart +// Prefer the speaker. A wired or Bluetooth headset still takes priority. +await AudioManager.instance.setSpeakerphoneOn(true); + +// Force the speaker even when a headset is connected (iOS only). +await AudioManager.instance.setSpeakerphoneOn(true, forceSpeakerOutput: true); + +// Route back to the earpiece or the connected headset. +await AudioManager.instance.setSpeakerphoneOn(false); +``` + +Read the current preference through `AudioManager.instance.preferSpeakerOutput` (or the alias `speakerphoneOn`) and `AudioManager.instance.forceSpeakerOutput`. `AudioManager.instance.canSwitchSpeakerphone` is true on iOS and Android. + +`Room.setSpeakerOn(...)` still works and forwards to the same path, so existing call sites do not need to change. + +## Automatic vs manual mode + +In automatic mode (the default) LiveKit updates the audio session from room, connect, and engine lifecycle. In manual mode LiveKit does not touch the session on its own, and your app drives it explicitly with `setAudioSessionOptions`. + +```dart +// Hand session control to the app. +await AudioManager.instance.setAudioSessionManagementMode( + AudioSessionManagementMode.manual, +); + +// Apply a configuration yourself whenever you need it. +await AudioManager.instance.setAudioSessionOptions( + const AudioSessionOptions.communication(), +); + +// Re-apply the current options, for example after an interruption. +await AudioManager.instance.applyCurrentAudioSessionOptions(); +``` + +Prefer setting the mode before connecting to a room. + +## Per platform overrides + +When the presets are not enough you can pin exact platform values. Anything you do not set falls back to the preset behavior. + +```dart +await AudioManager.instance.setAudioSessionOptions( + AudioSessionOptions.communication( + apple: const AppleAudioSessionConfiguration( + category: AppleAudioCategory.playAndRecord, + categoryOptions: { + AppleAudioCategoryOption.allowBluetooth, + AppleAudioCategoryOption.mixWithOthers, + }, + mode: AppleAudioMode.voiceChat, + ), + android: AndroidAudioSessionConfiguration.communication, + ), +); +``` + +Supplying an explicit `apple` override turns off dynamic category selection, so your configuration is applied as written. + +### Updating options with copyWith + +`copyWith` uses `Value` and `Absent` so it can tell apart leaving a field alone from setting it to null. A bare `copyWith()` keeps the existing value, `Value(x)` sets a new value, and `Value(null)` clears it. + +```dart +final base = AudioSessionOptions.communication( + apple: const AppleAudioSessionConfiguration(mode: AppleAudioMode.voiceChat), +); + +// Change one field, keep the rest. +final updated = base.copyWith(preferSpeakerOutput: const Value(false)); + +// Clear the Apple override entirely. +final cleared = base.copyWith(apple: const Value(null)); +``` + +If `Value` collides with another package in your imports, alias the import. + +```dart +import 'package:livekit_client/livekit_client.dart' as lk; + +final cleared = base.copyWith(apple: const lk.Value(null)); +``` + +## Observing audio engine state + +On iOS and macOS the native audio engine reports when playout and recording turn on and off. This is the source of truth for audio activity. + +```dart +final sub = AudioManager.instance.audioEngineStateStream.listen((state) { + print('playout ${state.isPlayoutEnabled} recording ${state.isRecordingEnabled}'); + if (state.isIdle) { + print('engine is idle'); + } +}); + +// Current snapshot without listening. +final now = AudioManager.instance.audioEngineState; +``` + +## Initialization + +You can optionally call `LiveKitClient.initialize` once at startup. Passing `bypassVoiceProcessing: true` makes the default options media oriented for playback or capture without voice processing. Explicit runtime options that you set with `setAudioSessionOptions` are always preserved. + +```dart +await LiveKitClient.initialize(bypassVoiceProcessing: true); +``` + +flutter_webrtc's own native audio management is disabled automatically when the LiveKit plugin loads, so audio session ownership does not depend on this call. + +## Migrating from the old APIs + +The legacy `Hardware` audio members still work but are deprecated and forward to `AudioManager`. + +| Old | New | +| --- | --- | +| `Hardware.instance.setSpeakerphoneOn(true)` | `AudioManager.instance.setSpeakerphoneOn(true)` | +| `Hardware.instance.speakerOn` | `AudioManager.instance.speakerphoneOn` | +| `Hardware.instance.preferSpeakerOutput` | `AudioManager.instance.preferSpeakerOutput` | +| `Hardware.instance.forceSpeakerOutput` | `AudioManager.instance.forceSpeakerOutput` | +| `Hardware.instance.setAutomaticConfigurationEnabled(enable: false)` | `AudioManager.instance.setAudioSessionManagementMode(AudioSessionManagementMode.manual)` | + +The old `onConfigureNativeAudio` hook (a deep src import) is removed. Replace a custom configuration function with explicit options. + +```dart +// Before: assigning onConfigureNativeAudio with a custom function. +// After: +await AudioManager.instance.setAudioSessionOptions( + AudioSessionOptions.communication( + apple: const AppleAudioSessionConfiguration( + category: AppleAudioCategory.playAndRecord, + mode: AppleAudioMode.videoChat, + ), + ), +); +``` + +Dart track counting no longer drives the session. The native audio engine delegate drives it from real lifecycle events, which removes the timing races and missed deactivations of the counting approach. + +## Platform notes + +- **iOS** uses a WebRTC audio engine observer that configures and activates the session when the engine enables, and deactivates when it disables. Category is chosen from live engine state. A listen only or mic muted session uses the playback category until recording starts, and receiver routing from `preferSpeakerOutput` only applies while the effective category is `playAndRecord`. +- **macOS** emits the same engine events but has no `AVAudioSession`, so engine state is reported while no session category is applied. +- **Android** uses an AudioSwitch based manager for audio mode, focus, and routing. The session is activated when LiveKit applies its options (at connect, or on an explicit apply) and released on disconnect. + +## API reference + +The full generated API reference for these types lives at [pub.dev](https://pub.dev/documentation/livekit_client/latest/). Start from `AudioManager`, `AudioSessionOptions`, `AppleAudioSessionConfiguration`, and `AndroidAudioSessionConfiguration`. From 93b8284b4e074565ed433420ca9152641455055c Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 16 Jun 2026 18:50:10 +0800 Subject: [PATCH 14/21] fix(apple): honor headset priority for non-forced speaker preference The automatic-mode session expressed the speaker preference twice: through the audio mode (videoChat for speaker, voiceChat for receiver) and again through overrideOutputAudioPort. The override hard-routes to the speaker even over a connected headset, so a plain setSpeakerphoneOn(true) ignored the documented headset priority and behaved like a forced speaker. Gate the speaker override on the forced case only (carried as the defaultToSpeaker category option) and otherwise clear it, letting the audio mode and connected devices decide. This matches the Swift SDK, which selects playAndRecordSpeaker/playAndRecordReceiver by mode and never overrides the output port. Removes the now-unused preferSpeakerOutput plumbing from the native observer and apply path. Manual mode keeps its direct route override. --- shared_swift/LiveKitPlugin.swift | 29 ++++++++++------------------- 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/shared_swift/LiveKitPlugin.swift b/shared_swift/LiveKitPlugin.swift index d0fcc264e..7cfbeb058 100644 --- a/shared_swift/LiveKitPlugin.swift +++ b/shared_swift/LiveKitPlugin.swift @@ -345,8 +345,6 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { configuration.mode = mode.rawValue } - let preferSpeakerOutput = args["preferSpeakerOutput"] as? Bool - // Cache the policy so the audio-engine delegate can (re)apply it on // engine lifecycle events. In automatic mode the delegate owns // activation timing (configure + activate on engine enable), so here we @@ -355,7 +353,6 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { let automatic = args["automatic"] as? Bool ?? false let selectCategoryByEngineState = args["selectCategoryByEngineState"] as? Bool ?? false audioEngineObserver?.updatePolicy(configuration, - preferSpeakerOutput: preferSpeakerOutput, automaticManagementEnabled: automatic, selectCategoryByEngineState: selectCategoryByEngineState) @@ -594,19 +591,21 @@ extension LiveKitPlugin { /// Applies an `RTCAudioSessionConfiguration` to the shared `RTCAudioSession`. /// Returns `nil` on success or the thrown error. Safe to call on any thread. static func applyAudioSessionConfiguration(_ configuration: RTCAudioSessionConfiguration, - preferSpeakerOutput: Bool?, active: Bool) -> Error? { let rtcSession = RTCAudioSession.sharedInstance() rtcSession.lockForConfiguration() defer { rtcSession.unlockForConfiguration() } do { try rtcSession.setConfiguration(configuration, active: active) - // overrideOutputAudioPort is only valid for the playAndRecord - // category. Calling it for a playback session throws. - if active, - let preferSpeakerOutput = preferSpeakerOutput, - configuration.category == AVAudioSession.Category.playAndRecord.rawValue { - try rtcSession.overrideOutputAudioPort(preferSpeakerOutput ? .speaker : .none) + // overrideOutputAudioPort hard-routes to the speaker even over a + // connected headset, so only use it when the speaker is forced + // (carried as the defaultToSpeaker option). For a plain speaker + // preference the audio mode already defaults to the speaker while + // letting a wired or Bluetooth headset keep priority, so clear any + // override instead. Only valid for the playAndRecord category. + if active, configuration.category == AVAudioSession.Category.playAndRecord.rawValue { + let forcesSpeaker = configuration.categoryOptions.contains(.defaultToSpeaker) + try rtcSession.overrideOutputAudioPort(forcesSpeaker ? .speaker : .none) } return nil } catch { @@ -647,7 +646,6 @@ class LKAudioEngineObserver: NSObject, RTCAudioDeviceModuleDelegate { #if !os(macOS) private var cachedConfiguration: RTCAudioSessionConfiguration? - private var preferSpeakerOutput: Bool? // When true, the category is chosen from the live engine state at apply time // (playAndRecord while recording, playback for playout-only) rather than // taken from the pushed config. This is what keeps the category correct as @@ -683,13 +681,11 @@ class LKAudioEngineObserver: NSObject, RTCAudioDeviceModuleDelegate { /// Stores the audio session policy pushed from Dart. Pure cache, where the /// delegate callbacks apply it. Callers decide whether to apply immediately. func updatePolicy(_ configuration: RTCAudioSessionConfiguration, - preferSpeakerOutput: Bool?, automaticManagementEnabled: Bool, selectCategoryByEngineState: Bool) { let cachedConfiguration = copyConfiguration(configuration) lock.lock() self.cachedConfiguration = cachedConfiguration - self.preferSpeakerOutput = preferSpeakerOutput self.isAutomaticManagementEnabled = automaticManagementEnabled self.selectCategoryByEngineState = selectCategoryByEngineState lock.unlock() @@ -701,12 +697,9 @@ class LKAudioEngineObserver: NSObject, RTCAudioDeviceModuleDelegate { func applyCachedConfiguration() -> Error? { lock.lock() let configuration = effectiveConfigurationLocked(isRecordingEnabled: lastIsRecordingEnabled) - let preferSpeaker = preferSpeakerOutput lock.unlock() guard let configuration else { return nil } - return LiveKitPlugin.applyAudioSessionConfiguration(configuration, - preferSpeakerOutput: preferSpeaker, - active: true) + return LiveKitPlugin.applyAudioSessionConfiguration(configuration, active: true) } /// Resolves the configuration to apply for a given engine state. Must be @@ -755,13 +748,11 @@ class LKAudioEngineObserver: NSObject, RTCAudioDeviceModuleDelegate { lock.lock() let shouldManageSession = isAutomaticManagementEnabled let configuration = effectiveConfigurationLocked(isRecordingEnabled: isRecordingEnabled) - let preferSpeaker = preferSpeakerOutput lock.unlock() if shouldManageSession, let configuration = configuration, let error = LiveKitPlugin.applyAudioSessionConfiguration(configuration, - preferSpeakerOutput: preferSpeaker, active: true) { print("[LiveKit] AudioEngine willEnable: failed to configure audio session: \(error)") resultCode = LiveKitPlugin.kAudioEngineErrorFailedToConfigureAudioSession From 964cbbfb84e5b1df83d757a4fa6a2332d65e8fb1 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 16 Jun 2026 18:55:55 +0800 Subject: [PATCH 15/21] refactor(audio): rename speaker API to setSpeakerOutputPreferred Rename the new (unreleased) AudioManager speaker surface for clarity and to match the Swift SDK vocabulary: setSpeakerphoneOn(enable, {forceSpeakerOutput}) -> setSpeakerOutputPreferred(preferred, {force}) get preferSpeakerOutput -> get isSpeakerOutputPreferred get forceSpeakerOutput -> get isSpeakerOutputForced Drop the duplicate speakerphoneOn getter (was identical to preferSpeakerOutput). Update the deprecated Hardware forwards, Room.setSpeakerOn, the example app, the audio guide, and the tests. AudioSessionOptions.preferSpeakerOutput and the per-platform config fields keep their names. No behavior change. --- README.md | 2 +- docs/audio.md | 16 ++++++++-------- example/lib/pages/room.dart | 2 +- example/lib/widgets/controls.dart | 2 +- lib/src/audio/audio_manager.dart | 29 ++++++++++++++--------------- lib/src/audio/audio_session.dart | 2 +- lib/src/core/room.dart | 4 ++-- lib/src/hardware/hardware.dart | 20 ++++++++++---------- test/audio/audio_session_test.dart | 12 ++++-------- 9 files changed, 42 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 43695182d..e2357d02e 100644 --- a/README.md +++ b/README.md @@ -329,7 +329,7 @@ LiveKit owns the platform audio session through `AudioManager`, which lets you c await AudioManager.instance.setAudioSessionOptions( const AudioSessionOptions.communication(), ); -await AudioManager.instance.setSpeakerphoneOn(true); +await AudioManager.instance.setSpeakerOutputPreferred(true); ``` ### Handling changes diff --git a/docs/audio.md b/docs/audio.md index cc5665e70..7d1e3ccd2 100644 --- a/docs/audio.md +++ b/docs/audio.md @@ -28,16 +28,16 @@ Set this before connecting when you can. The explicit apply path above works in ```dart // Prefer the speaker. A wired or Bluetooth headset still takes priority. -await AudioManager.instance.setSpeakerphoneOn(true); +await AudioManager.instance.setSpeakerOutputPreferred(true); // Force the speaker even when a headset is connected (iOS only). -await AudioManager.instance.setSpeakerphoneOn(true, forceSpeakerOutput: true); +await AudioManager.instance.setSpeakerOutputPreferred(true, force: true); // Route back to the earpiece or the connected headset. -await AudioManager.instance.setSpeakerphoneOn(false); +await AudioManager.instance.setSpeakerOutputPreferred(false); ``` -Read the current preference through `AudioManager.instance.preferSpeakerOutput` (or the alias `speakerphoneOn`) and `AudioManager.instance.forceSpeakerOutput`. `AudioManager.instance.canSwitchSpeakerphone` is true on iOS and Android. +Read the current preference through `AudioManager.instance.isSpeakerOutputPreferred` and `AudioManager.instance.isSpeakerOutputForced`. `AudioManager.instance.canSwitchSpeakerphone` is true on iOS and Android. `Room.setSpeakerOn(...)` still works and forwards to the same path, so existing call sites do not need to change. @@ -140,10 +140,10 @@ The legacy `Hardware` audio members still work but are deprecated and forward to | Old | New | | --- | --- | -| `Hardware.instance.setSpeakerphoneOn(true)` | `AudioManager.instance.setSpeakerphoneOn(true)` | -| `Hardware.instance.speakerOn` | `AudioManager.instance.speakerphoneOn` | -| `Hardware.instance.preferSpeakerOutput` | `AudioManager.instance.preferSpeakerOutput` | -| `Hardware.instance.forceSpeakerOutput` | `AudioManager.instance.forceSpeakerOutput` | +| `Hardware.instance.setSpeakerphoneOn(true)` | `AudioManager.instance.setSpeakerOutputPreferred(true)` | +| `Hardware.instance.speakerOn` | `AudioManager.instance.isSpeakerOutputPreferred` | +| `Hardware.instance.preferSpeakerOutput` | `AudioManager.instance.isSpeakerOutputPreferred` | +| `Hardware.instance.forceSpeakerOutput` | `AudioManager.instance.isSpeakerOutputForced` | | `Hardware.instance.setAutomaticConfigurationEnabled(enable: false)` | `AudioManager.instance.setAudioSessionManagementMode(AudioSessionManagementMode.manual)` | The old `onConfigureNativeAudio` hook (a deep src import) is removed. Replace a custom configuration function with explicit options. diff --git a/example/lib/pages/room.dart b/example/lib/pages/room.dart index 1e6d10491..42295242e 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(AudioManager.instance.setSpeakerphoneOn(true)); + unawaited(AudioManager.instance.setSpeakerOutputPreferred(true)); } if (lkPlatformIsDesktop()) { diff --git a/example/lib/widgets/controls.dart b/example/lib/widgets/controls.dart index 39bf0795d..b1102c32a 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 = AudioManager.instance.speakerphoneOn; + bool _speakerphoneOn = AudioManager.instance.isSpeakerOutputPreferred; @override void initState() { diff --git a/lib/src/audio/audio_manager.dart b/lib/src/audio/audio_manager.dart index b58c2d337..f57d40e68 100644 --- a/lib/src/audio/audio_manager.dart +++ b/lib/src/audio/audio_manager.dart @@ -81,15 +81,14 @@ class AudioManager { 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 the speaker is the preferred audio output. + bool get isSpeakerOutputPreferred => _preferSpeakerOutput; /// Whether speaker output is forced even when a headset/Bluetooth device is /// connected (iOS only). - bool get forceSpeakerOutput => _forceSpeakerOutput && _preferSpeakerOutput; + bool get isSpeakerOutputForced => _forceSpeakerOutput && _preferSpeakerOutput; - /// Whether the platform supports switching the speakerphone (iOS/Android). + /// Whether the platform supports switching the speaker output (iOS/Android). bool get canSwitchSpeakerphone => lkPlatformIsMobile(); /// The current audio engine state, derived from native engine lifecycle @@ -185,23 +184,23 @@ class AudioManager { await _syncAppleAudioSessionManagementMode(); } - /// Routes audio output to/from the speakerphone. + /// Prefers routing audio output to/from the speaker. /// /// 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). + /// when [preferred] is true. Set [force] 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 { + Future setSpeakerOutputPreferred(bool preferred, {bool force = false}) async { if (!canSwitchSpeakerphone) { - logger.warning('setSpeakerphoneOn is only supported on iOS/Android'); + logger.warning('setSpeakerOutputPreferred is only supported on iOS/Android'); return; } - _preferSpeakerOutput = enable; - _forceSpeakerOutput = enable && forceSpeakerOutput; - _options = _optionsWithSpeakerPreference(_options, enable); + _preferSpeakerOutput = preferred; + _forceSpeakerOutput = preferred && force; + _options = _optionsWithSpeakerPreference(_options, preferred); if (lkPlatformIs(PlatformType.iOS)) { if (isAutomaticConfigurationEnabled) { @@ -217,10 +216,10 @@ class AudioManager { ); } else { // Manual mode: route without re-applying category/mode the app owns. - await Native.setAppleSpeakerphoneOn(enable); + await Native.setAppleSpeakerphoneOn(preferred); } } else if (lkPlatformIs(PlatformType.android)) { - await Native.setAndroidSpeakerphoneOn(enable); + await Native.setAndroidSpeakerphoneOn(preferred); } } diff --git a/lib/src/audio/audio_session.dart b/lib/src/audio/audio_session.dart index 2e5b382f7..3af98f722 100644 --- a/lib/src/audio/audio_session.dart +++ b/lib/src/audio/audio_session.dart @@ -79,7 +79,7 @@ class AudioSessionOptions { /// This intentionally does not expose [preferSpeakerOutput] because speaker /// preference only affects communication/playAndRecord policies. Use [apple] /// or [android] for exact platform behavior, or switch at runtime with - /// `AudioManager.setSpeakerphoneOn`. + /// `AudioManager.setSpeakerOutputPreferred`. const AudioSessionOptions.media({ AppleAudioSessionConfiguration? apple, AndroidAudioSessionConfiguration? android, diff --git a/lib/src/core/room.dart b/lib/src/core/room.dart index 6af061dfa..1755f7daa 100644 --- a/lib/src/core/room.dart +++ b/lib/src/core/room.dart @@ -1197,7 +1197,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 AudioManager.instance.setSpeakerphoneOn(speakerOn, forceSpeakerOutput: forceSpeakerOutput); + await AudioManager.instance.setSpeakerOutputPreferred(speakerOn, force: forceSpeakerOutput); engine.roomOptions = engine.roomOptions.copyWith( defaultAudioOutputOptions: roomOptions.defaultAudioOutputOptions.copyWith( speakerOn: speakerOn, @@ -1211,7 +1211,7 @@ extension RoomHardwareManagementMethods on Room { Future applyAudioSpeakerSettings() async { if (roomOptions.defaultAudioOutputOptions.speakerOn != null) { if (lkPlatformIsMobile()) { - await AudioManager.instance.setSpeakerphoneOn(roomOptions.defaultAudioOutputOptions.speakerOn!); + await AudioManager.instance.setSpeakerOutputPreferred(roomOptions.defaultAudioOutputOptions.speakerOn!); } } } diff --git a/lib/src/hardware/hardware.dart b/lib/src/hardware/hardware.dart index 4db4b87bd..c4401a9d3 100644 --- a/lib/src/hardware/hardware.dart +++ b/lib/src/hardware/hardware.dart @@ -68,16 +68,16 @@ class Hardware { MediaDevice? selectedVideoInput; - @Deprecated('Use AudioManager.instance.speakerphoneOn instead') - bool? get speakerOn => AudioManager.instance.speakerphoneOn; + @Deprecated('Use AudioManager.instance.isSpeakerOutputPreferred instead') + bool? get speakerOn => AudioManager.instance.isSpeakerOutputPreferred; - @Deprecated('Use AudioManager.instance.preferSpeakerOutput instead') - bool get preferSpeakerOutput => AudioManager.instance.preferSpeakerOutput; + @Deprecated('Use AudioManager.instance.isSpeakerOutputPreferred instead') + bool get preferSpeakerOutput => AudioManager.instance.isSpeakerOutputPreferred; /// if true, will force speaker output even if headphones or bluetooth is connected /// only supported on iOS for now - @Deprecated('Use AudioManager.instance.forceSpeakerOutput instead') - bool get forceSpeakerOutput => AudioManager.instance.forceSpeakerOutput; + @Deprecated('Use AudioManager.instance.isSpeakerOutputForced instead') + bool get forceSpeakerOutput => AudioManager.instance.isSpeakerOutputForced; // Whether automatic native audio configuration is enabled. If disabled, // Native.configureAudio is not called and the app is responsible for @@ -136,8 +136,8 @@ class Hardware { await rtc.Helper.selectAudioInput(device.deviceId); } - @Deprecated('Use AudioManager.instance.setSpeakerphoneOn instead') - Future setPreferSpeakerOutput(bool enable) => AudioManager.instance.setSpeakerphoneOn(enable); + @Deprecated('Use AudioManager.instance.setSpeakerOutputPreferred instead') + Future setPreferSpeakerOutput(bool enable) => AudioManager.instance.setSpeakerOutputPreferred(enable); @Deprecated('Use AudioManager.instance.canSwitchSpeakerphone instead') bool get canSwitchSpeakerphone => AudioManager.instance.canSwitchSpeakerphone; @@ -146,9 +146,9 @@ class Hardware { /// 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 - @Deprecated('Use AudioManager.instance.setSpeakerphoneOn instead') + @Deprecated('Use AudioManager.instance.setSpeakerOutputPreferred instead') Future setSpeakerphoneOn(bool enable, {bool forceSpeakerOutput = false}) => - AudioManager.instance.setSpeakerphoneOn(enable, forceSpeakerOutput: forceSpeakerOutput); + AudioManager.instance.setSpeakerOutputPreferred(enable, force: forceSpeakerOutput); Future openCamera({MediaDevice? device, bool? facingMode}) async { final constraints = { diff --git a/test/audio/audio_session_test.dart b/test/audio/audio_session_test.dart index 8132b1caa..63525424d 100644 --- a/test/audio/audio_session_test.dart +++ b/test/audio/audio_session_test.dart @@ -192,16 +192,14 @@ void main() { const AudioSessionOptions.communication(preferSpeakerOutput: false), ); - expect(manager.speakerphoneOn, isFalse); - expect(manager.preferSpeakerOutput, isFalse); + expect(manager.isSpeakerOutputPreferred, isFalse); expect(manager.options.preferSpeakerOutput, isFalse); await manager.setAudioSessionOptions( const AudioSessionOptions.communication(preferSpeakerOutput: true), ); - expect(manager.speakerphoneOn, isTrue); - expect(manager.preferSpeakerOutput, isTrue); + expect(manager.isSpeakerOutputPreferred, isTrue); expect(manager.options.preferSpeakerOutput, isTrue); }); @@ -216,8 +214,7 @@ void main() { ), ); - expect(manager.speakerphoneOn, isFalse); - expect(manager.preferSpeakerOutput, isFalse); + expect(manager.isSpeakerOutputPreferred, isFalse); await manager.setAudioSessionOptions( const AudioSessionOptions.communication( @@ -227,8 +224,7 @@ void main() { ), ); - expect(manager.speakerphoneOn, isTrue); - expect(manager.preferSpeakerOutput, isTrue); + expect(manager.isSpeakerOutputPreferred, isTrue); }); test('resolves communication Apple session policy from speaker preference', () { From c0b14dff750551bd3349b7ce39fe1fc6cab9b23c Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 16 Jun 2026 18:58:57 +0800 Subject: [PATCH 16/21] refactor(audio): drop unused preferSpeakerOutput from native wire config The headset-priority fix made the native side ignore the preferSpeakerOutput wire field (the speaker preference now lives entirely in the audio mode). Remove the now-dead field from NativeAudioConfiguration (field, toMap, copyWith) and stop sending it from the resolved Apple policy. The wire-format test now pins its absence. --- lib/src/audio/audio_manager.dart | 3 --- lib/src/support/native_audio.dart | 7 +------ test/audio/audio_session_test.dart | 6 +----- 3 files changed, 2 insertions(+), 14 deletions(-) diff --git a/lib/src/audio/audio_manager.dart b/lib/src/audio/audio_manager.dart index f57d40e68..bb9152aa9 100644 --- a/lib/src/audio/audio_manager.dart +++ b/lib/src/audio/audio_manager.dart @@ -377,7 +377,6 @@ class _ResolvedAudioSessionPolicy { appleAudioCategory: apple.category, appleAudioCategoryOptions: apple.categoryOptions, appleAudioMode: apple.mode, - preferSpeakerOutput: preferSpeakerOutput, ), ); } @@ -392,7 +391,6 @@ class _ResolvedAudioSessionPolicy { AppleAudioCategoryOption.allowAirPlay, }, appleAudioMode: preferSpeakerOutput ? AppleAudioMode.videoChat : AppleAudioMode.voiceChat, - preferSpeakerOutput: preferSpeakerOutput, ), ); } @@ -411,7 +409,6 @@ class _ResolvedAudioSessionPolicy { AppleAudioCategoryOption.allowAirPlay, }, appleAudioMode: AppleAudioMode.default_, - preferSpeakerOutput: preferSpeakerOutput, ), ); } diff --git a/lib/src/support/native_audio.dart b/lib/src/support/native_audio.dart index d4aae4f2d..474f543ed 100644 --- a/lib/src/support/native_audio.dart +++ b/lib/src/support/native_audio.dart @@ -55,15 +55,13 @@ class NativeAudioConfiguration { final AppleAudioCategory? appleAudioCategory; final Set? appleAudioCategoryOptions; final AppleAudioMode? appleAudioMode; - final bool? preferSpeakerOutput; NativeAudioConfiguration( { // for iOS / Mac this.appleAudioCategory, this.appleAudioCategoryOptions, - this.appleAudioMode, - this.preferSpeakerOutput + this.appleAudioMode // Android options // ... }); @@ -73,19 +71,16 @@ class NativeAudioConfiguration { if (appleAudioCategoryOptions != null) 'appleAudioCategoryOptions': appleAudioCategoryOptions!.map((e) => e.toStringValue()).toList(), if (appleAudioMode != null) 'appleAudioMode': appleAudioMode!.toStringValue(), - if (preferSpeakerOutput != null) 'preferSpeakerOutput': preferSpeakerOutput, }; NativeAudioConfiguration copyWith({ ValueOrAbsent appleAudioCategory = const Absent(), ValueOrAbsent?> appleAudioCategoryOptions = const Absent(), ValueOrAbsent appleAudioMode = const Absent(), - ValueOrAbsent preferSpeakerOutput = const Absent(), }) => NativeAudioConfiguration( appleAudioCategory: appleAudioCategory.valueOr(this.appleAudioCategory), appleAudioCategoryOptions: appleAudioCategoryOptions.valueOr(this.appleAudioCategoryOptions), appleAudioMode: appleAudioMode.valueOr(this.appleAudioMode), - preferSpeakerOutput: preferSpeakerOutput.valueOr(this.preferSpeakerOutput), ); } diff --git a/test/audio/audio_session_test.dart b/test/audio/audio_session_test.dart index 63525424d..d02c4fac2 100644 --- a/test/audio/audio_session_test.dart +++ b/test/audio/audio_session_test.dart @@ -236,7 +236,6 @@ void main() { expect(speaker.appleAudioCategory, AppleAudioCategory.playAndRecord); expect(speaker.appleAudioMode, AppleAudioMode.videoChat); - expect(speaker.preferSpeakerOutput, isTrue); expect( speaker.appleAudioCategoryOptions, { @@ -252,7 +251,6 @@ void main() { expect(receiver.appleAudioCategory, AppleAudioCategory.playAndRecord); expect(receiver.appleAudioMode, AppleAudioMode.voiceChat); - expect(receiver.preferSpeakerOutput, isFalse); }); test('resolves media Apple session policy as dynamic playAndRecord base', () { @@ -262,7 +260,6 @@ void main() { expect(config.appleAudioCategory, AppleAudioCategory.playAndRecord); expect(config.appleAudioMode, AppleAudioMode.default_); - expect(config.preferSpeakerOutput, isTrue); expect( config.appleAudioCategoryOptions, { @@ -430,7 +427,6 @@ void main() { AppleAudioCategoryOption.defaultToSpeaker, }, appleAudioMode: AppleAudioMode.default_, - preferSpeakerOutput: false, ).toMap(); expect(map['appleAudioCategory'], 'playAndRecord'); @@ -442,7 +438,7 @@ void main() { ]), ); expect(map['appleAudioMode'], 'default'); - expect(map['preferSpeakerOutput'], isFalse); + expect(map.containsKey('preferSpeakerOutput'), isFalse); }); }); From 0b5656c89afb6f56cdcc602171ea024885930ef6 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 16 Jun 2026 20:39:22 +0800 Subject: [PATCH 17/21] fix(android): honor headset priority for non-forced speaker preference setSpeakerphoneOn(true) explicitly selected the speaker device even when a wired/Bluetooth headset was connected, forcing the speaker and ignoring the documented headset priority. Select a connected headset first and fall back to the speaker only when none is present (earpiece when the speaker is not preferred). This mirrors the iOS fix and removes the forced-speaker routing the media preset would otherwise apply on every options change. --- .../io/livekit/plugin/LKAudioSwitchManager.kt | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/android/src/main/kotlin/io/livekit/plugin/LKAudioSwitchManager.kt b/android/src/main/kotlin/io/livekit/plugin/LKAudioSwitchManager.kt index 05aafe94c..45b276ecf 100644 --- a/android/src/main/kotlin/io/livekit/plugin/LKAudioSwitchManager.kt +++ b/android/src/main/kotlin/io/livekit/plugin/LKAudioSwitchManager.kt @@ -115,19 +115,23 @@ internal class LKAudioSwitchManager(private val context: Context) { } } - /** Route audio to/from the speakerphone, falling back to the next preferred device. */ + /** Prefer routing to/from the speaker, letting a connected headset keep priority. */ @Synchronized fun setSpeakerphoneOn(enable: Boolean) { preferredDeviceList = preferredDeviceList(speakerFirst = enable) handler.post { val switch = audioSwitch ?: return@post switch.setPreferredDeviceList(preferredDeviceList) - val device = if (enable) { + // A connected wired/Bluetooth headset always takes priority. When the + // speaker is preferred fall back to it only if no headset is present, and + // when it is not preferred fall back to the earpiece instead. + val headset = switch.availableAudioDevices.firstOrNull { + it is AudioDevice.BluetoothHeadset || it is AudioDevice.WiredHeadset + } + val device = headset ?: 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.availableAudioDevices.firstOrNull { it is AudioDevice.Earpiece } } switch.selectDevice(device) } From 41d6eb66fc77e003137cbc3800ba34d31a91a3b1 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 16 Jun 2026 21:02:19 +0800 Subject: [PATCH 18/21] fix(audio): honor forced speaker routing on mobile --- .../io/livekit/plugin/LKAudioSwitchManager.kt | 60 ++++++++++------ .../kotlin/io/livekit/plugin/LiveKitPlugin.kt | 3 +- docs/audio.md | 2 +- lib/src/audio/audio_manager.dart | 72 ++++++++----------- lib/src/core/room.dart | 2 +- lib/src/hardware/hardware.dart | 3 +- lib/src/support/native.dart | 16 +++-- shared_swift/LiveKitPlugin.swift | 32 ++++++--- test/audio/audio_session_test.dart | 61 ++++++++++++++-- 9 files changed, 160 insertions(+), 91 deletions(-) diff --git a/android/src/main/kotlin/io/livekit/plugin/LKAudioSwitchManager.kt b/android/src/main/kotlin/io/livekit/plugin/LKAudioSwitchManager.kt index 45b276ecf..644cd9062 100644 --- a/android/src/main/kotlin/io/livekit/plugin/LKAudioSwitchManager.kt +++ b/android/src/main/kotlin/io/livekit/plugin/LKAudioSwitchManager.kt @@ -61,7 +61,9 @@ internal class LKAudioSwitchManager(private val context: Context) { private var audioAttributeContentType = AudioAttributes.CONTENT_TYPE_SPEECH private var forceHandleAudioRouting = false - private var preferredDeviceList = preferredDeviceList(speakerFirst = true) + private var speakerOutputPreferred = true + private var speakerOutputForced = false + private var preferredDeviceList = preferredDeviceList() /** * Apply an audio session configuration. Unspecified keys keep their current @@ -89,6 +91,7 @@ internal class LKAudioSwitchManager(private val context: Context) { val switch = audioSwitch ?: createSwitch().also { audioSwitch = it } if (!isActive) { switch.activate() + applySpeakerRouting(switch) isActive = true } } @@ -115,25 +118,18 @@ internal class LKAudioSwitchManager(private val context: Context) { } } - /** Prefer routing to/from the speaker, letting a connected headset keep priority. */ + /** + * Prefer routing to/from the speaker, letting a connected headset keep priority + * unless [force] is true. + */ @Synchronized - fun setSpeakerphoneOn(enable: Boolean) { - preferredDeviceList = preferredDeviceList(speakerFirst = enable) + fun setSpeakerphoneOn(enable: Boolean, force: Boolean) { + speakerOutputPreferred = enable + speakerOutputForced = enable && force + preferredDeviceList = preferredDeviceList() handler.post { val switch = audioSwitch ?: return@post - switch.setPreferredDeviceList(preferredDeviceList) - // A connected wired/Bluetooth headset always takes priority. When the - // speaker is preferred fall back to it only if no headset is present, and - // when it is not preferred fall back to the earpiece instead. - val headset = switch.availableAudioDevices.firstOrNull { - it is AudioDevice.BluetoothHeadset || it is AudioDevice.WiredHeadset - } - val device = headset ?: if (enable) { - switch.availableAudioDevices.firstOrNull { it is AudioDevice.Speakerphone } - } else { - switch.availableAudioDevices.firstOrNull { it is AudioDevice.Earpiece } - } - switch.selectDevice(device) + applySpeakerRouting(switch) } } @@ -167,16 +163,36 @@ internal class LKAudioSwitchManager(private val context: Context) { switch.forceHandleAudioRouting = forceHandleAudioRouting } - private fun preferredDeviceList(speakerFirst: Boolean): List> = - if (speakerFirst) { - listOf( + 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> = + 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( + + else -> listOf( AudioDevice.BluetoothHeadset::class.java, AudioDevice.WiredHeadset::class.java, AudioDevice.Earpiece::class.java, diff --git a/android/src/main/kotlin/io/livekit/plugin/LiveKitPlugin.kt b/android/src/main/kotlin/io/livekit/plugin/LiveKitPlugin.kt index 3b1266fab..925890628 100644 --- a/android/src/main/kotlin/io/livekit/plugin/LiveKitPlugin.kt +++ b/android/src/main/kotlin/io/livekit/plugin/LiveKitPlugin.kt @@ -371,7 +371,8 @@ class LiveKitPlugin : FlutterPlugin, MethodCallHandler { "setAndroidSpeakerphoneOn" -> { val enable = call.argument("enable") ?: false - audioSwitchManager?.setSpeakerphoneOn(enable) + val force = call.argument("force") ?: false + audioSwitchManager?.setSpeakerphoneOn(enable, force) result.success(null) } diff --git a/docs/audio.md b/docs/audio.md index 7d1e3ccd2..daba1a491 100644 --- a/docs/audio.md +++ b/docs/audio.md @@ -30,7 +30,7 @@ Set this before connecting when you can. The explicit apply path above works in // Prefer the speaker. A wired or Bluetooth headset still takes priority. await AudioManager.instance.setSpeakerOutputPreferred(true); -// Force the speaker even when a headset is connected (iOS only). +// Force the speaker even when a headset is connected. await AudioManager.instance.setSpeakerOutputPreferred(true, force: true); // Route back to the earpiece or the connected headset. diff --git a/lib/src/audio/audio_manager.dart b/lib/src/audio/audio_manager.dart index bb9152aa9..a341ba1ce 100644 --- a/lib/src/audio/audio_manager.dart +++ b/lib/src/audio/audio_manager.dart @@ -85,7 +85,7 @@ class AudioManager { bool get isSpeakerOutputPreferred => _preferSpeakerOutput; /// Whether speaker output is forced even when a headset/Bluetooth device is - /// connected (iOS only). + /// connected. bool get isSpeakerOutputForced => _forceSpeakerOutput && _preferSpeakerOutput; /// Whether the platform supports switching the speaker output (iOS/Android). @@ -188,7 +188,7 @@ class AudioManager { /// /// By default a connected wired/Bluetooth headset still takes priority even /// when [preferred] is true. Set [force] to force the speaker even when a - /// headset is connected (iOS only). + /// headset is connected. /// /// LiveKit owns this routing on both platforms (Android via its own /// audioswitch handler and iOS via its audio session), so it does not depend @@ -213,13 +213,14 @@ class AudioManager { policy.appleConfiguration, automatic: true, selectCategoryByEngineState: policy.usesDynamicAppleCategory, + forceSpeakerOutput: policy.forceSpeakerOutput, ); } else { // Manual mode: route without re-applying category/mode the app owns. - await Native.setAppleSpeakerphoneOn(preferred); + await Native.setAppleSpeakerphoneOn(preferred, force: _forceSpeakerOutput); } } else if (lkPlatformIs(PlatformType.android)) { - await Native.setAndroidSpeakerphoneOn(preferred); + await Native.setAndroidSpeakerphoneOn(preferred, force: _forceSpeakerOutput); } } @@ -279,6 +280,7 @@ class AudioManager { config, automatic: isAutomaticConfigurationEnabled, selectCategoryByEngineState: isAutomaticConfigurationEnabled && policy.usesDynamicAppleCategory, + forceSpeakerOutput: policy.forceSpeakerOutput, ); } @@ -289,7 +291,7 @@ class AudioManager { 'configuring Android audio session using ${androidAudioSessionConfigurationToMap(config)}...', ); await setAndroidAudioSessionConfiguration(config); - await Native.setAndroidSpeakerphoneOn(policy.preferSpeakerOutput); + await Native.setAndroidSpeakerphoneOn(policy.preferSpeakerOutput, force: policy.forceSpeakerOutput); } _ResolvedAudioSessionPolicy _resolvedAudioSessionPolicy(AudioSessionOptions options) { @@ -372,26 +374,22 @@ class _ResolvedAudioSessionPolicy { NativeAudioConfiguration get appleConfiguration { final apple = options.apple; if (apple != null) { - return _withForcedSpeakerOutput( - NativeAudioConfiguration( - appleAudioCategory: apple.category, - appleAudioCategoryOptions: apple.categoryOptions, - appleAudioMode: apple.mode, - ), + return NativeAudioConfiguration( + appleAudioCategory: apple.category, + appleAudioCategoryOptions: apple.categoryOptions, + appleAudioMode: apple.mode, ); } if (options.isCommunication) { - return _withForcedSpeakerOutput( - NativeAudioConfiguration( - appleAudioCategory: AppleAudioCategory.playAndRecord, - appleAudioCategoryOptions: { - AppleAudioCategoryOption.allowBluetooth, - AppleAudioCategoryOption.allowBluetoothA2DP, - AppleAudioCategoryOption.allowAirPlay, - }, - appleAudioMode: preferSpeakerOutput ? AppleAudioMode.videoChat : AppleAudioMode.voiceChat, - ), + return NativeAudioConfiguration( + appleAudioCategory: AppleAudioCategory.playAndRecord, + appleAudioCategoryOptions: { + AppleAudioCategoryOption.allowBluetooth, + AppleAudioCategoryOption.allowBluetoothA2DP, + AppleAudioCategoryOption.allowAirPlay, + }, + appleAudioMode: preferSpeakerOutput ? AppleAudioMode.videoChat : AppleAudioMode.voiceChat, ); } @@ -399,17 +397,15 @@ class _ResolvedAudioSessionPolicy { // automatic mode the native engine delegate overrides it from the live // engine state (playAndRecord while recording, playback for playout-only), // so it no longer depends on stale track/engine flags resolved at connect. - return _withForcedSpeakerOutput( - NativeAudioConfiguration( - appleAudioCategory: AppleAudioCategory.playAndRecord, - appleAudioCategoryOptions: { - AppleAudioCategoryOption.mixWithOthers, - AppleAudioCategoryOption.allowBluetooth, - AppleAudioCategoryOption.allowBluetoothA2DP, - AppleAudioCategoryOption.allowAirPlay, - }, - appleAudioMode: AppleAudioMode.default_, - ), + return NativeAudioConfiguration( + appleAudioCategory: AppleAudioCategory.playAndRecord, + appleAudioCategoryOptions: { + AppleAudioCategoryOption.mixWithOthers, + AppleAudioCategoryOption.allowBluetooth, + AppleAudioCategoryOption.allowBluetoothA2DP, + AppleAudioCategoryOption.allowAirPlay, + }, + appleAudioMode: AppleAudioMode.default_, ); } @@ -424,16 +420,4 @@ class _ResolvedAudioSessionPolicy { } return AndroidAudioSessionConfiguration.media; } - - NativeAudioConfiguration _withForcedSpeakerOutput(NativeAudioConfiguration configuration) { - if (!forceSpeakerOutput || configuration.appleAudioCategory != AppleAudioCategory.playAndRecord) { - return configuration; - } - return configuration.copyWith( - appleAudioCategoryOptions: Value({ - ...?configuration.appleAudioCategoryOptions, - AppleAudioCategoryOption.defaultToSpeaker, - }), - ); - } } diff --git a/lib/src/core/room.dart b/lib/src/core/room.dart index 1755f7daa..97f89440a 100644 --- a/lib/src/core/room.dart +++ b/lib/src/core/room.dart @@ -1194,7 +1194,7 @@ extension RoomHardwareManagementMethods on Room { /// [speakerOn] 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 + /// or bluetooth is connected. Future setSpeakerOn(bool speakerOn, {bool forceSpeakerOutput = false}) async { if (lkPlatformIsMobile()) { await AudioManager.instance.setSpeakerOutputPreferred(speakerOn, force: forceSpeakerOutput); diff --git a/lib/src/hardware/hardware.dart b/lib/src/hardware/hardware.dart index c4401a9d3..9182015e6 100644 --- a/lib/src/hardware/hardware.dart +++ b/lib/src/hardware/hardware.dart @@ -75,7 +75,6 @@ class Hardware { bool get preferSpeakerOutput => AudioManager.instance.isSpeakerOutputPreferred; /// if true, will force speaker output even if headphones or bluetooth is connected - /// only supported on iOS for now @Deprecated('Use AudioManager.instance.isSpeakerOutputForced instead') bool get forceSpeakerOutput => AudioManager.instance.isSpeakerOutputForced; @@ -145,7 +144,7 @@ class Hardware { /// [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 + /// or bluetooth is connected. @Deprecated('Use AudioManager.instance.setSpeakerOutputPreferred instead') Future setSpeakerphoneOn(bool enable, {bool forceSpeakerOutput = false}) => AudioManager.instance.setSpeakerOutputPreferred(enable, force: forceSpeakerOutput); diff --git a/lib/src/support/native.dart b/lib/src/support/native.dart index 60e0ab767..b600c867c 100644 --- a/lib/src/support/native.dart +++ b/lib/src/support/native.dart @@ -49,6 +49,7 @@ class Native { NativeAudioConfiguration configuration, { bool automatic = false, bool selectCategoryByEngineState = false, + bool forceSpeakerOutput = false, }) async { try { final result = await channel.invokeMethod( @@ -57,6 +58,7 @@ class Native { ...configuration.toMap(), 'automatic': automatic, 'selectCategoryByEngineState': selectCategoryByEngineState, + 'forceSpeakerOutput': forceSpeakerOutput, }, ); return result == true; @@ -131,9 +133,12 @@ class Native { /// Route Android audio output to/from the speakerphone. @internal - static Future setAndroidSpeakerphoneOn(bool enable) async { + static Future setAndroidSpeakerphoneOn(bool enable, {bool force = false}) async { try { - await channel.invokeMethod('setAndroidSpeakerphoneOn', {'enable': enable}); + await channel.invokeMethod( + 'setAndroidSpeakerphoneOn', + {'enable': enable, 'force': force}, + ); } catch (error) { logger.warning('setAndroidSpeakerphoneOn did throw $error'); } @@ -142,9 +147,12 @@ class Native { /// Route Apple (iOS) audio output to/from the speakerphone without otherwise /// changing the audio session category/mode. @internal - static Future setAppleSpeakerphoneOn(bool enable) async { + static Future setAppleSpeakerphoneOn(bool enable, {bool force = false}) async { try { - await channel.invokeMethod('setAppleSpeakerphoneOn', {'enable': enable}); + await channel.invokeMethod( + 'setAppleSpeakerphoneOn', + {'enable': enable, 'force': force}, + ); } catch (error) { logger.warning('setAppleSpeakerphoneOn did throw $error'); } diff --git a/shared_swift/LiveKitPlugin.swift b/shared_swift/LiveKitPlugin.swift index 7cfbeb058..5584d9cce 100644 --- a/shared_swift/LiveKitPlugin.swift +++ b/shared_swift/LiveKitPlugin.swift @@ -352,9 +352,11 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { // (or no `automatic` flag) applies immediately. let automatic = args["automatic"] as? Bool ?? false let selectCategoryByEngineState = args["selectCategoryByEngineState"] as? Bool ?? false + let forceSpeakerOutput = args["forceSpeakerOutput"] as? Bool ?? false audioEngineObserver?.updatePolicy(configuration, automaticManagementEnabled: automatic, - selectCategoryByEngineState: selectCategoryByEngineState) + selectCategoryByEngineState: selectCategoryByEngineState, + forceSpeakerOutput: forceSpeakerOutput) let shouldApplyNow = !automatic || (audioEngineObserver?.isSessionActive ?? false) guard shouldApplyNow else { @@ -389,6 +391,7 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { result(FlutterMethodNotImplemented) #else let enable = (args["enable"] as? Bool) ?? false + let force = (args["force"] as? Bool) ?? false let rtcSession = RTCAudioSession.sharedInstance() rtcSession.lockForConfiguration() @@ -396,7 +399,7 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { do { if rtcSession.category == AVAudioSession.Category.playAndRecord.rawValue { - try rtcSession.overrideOutputAudioPort(enable ? .speaker : .none) + try rtcSession.overrideOutputAudioPort((enable && force) ? .speaker : .none) } result(true) } catch { @@ -591,6 +594,7 @@ extension LiveKitPlugin { /// Applies an `RTCAudioSessionConfiguration` to the shared `RTCAudioSession`. /// Returns `nil` on success or the thrown error. Safe to call on any thread. static func applyAudioSessionConfiguration(_ configuration: RTCAudioSessionConfiguration, + forceSpeakerOutput: Bool, active: Bool) -> Error? { let rtcSession = RTCAudioSession.sharedInstance() rtcSession.lockForConfiguration() @@ -598,14 +602,12 @@ extension LiveKitPlugin { do { try rtcSession.setConfiguration(configuration, active: active) // overrideOutputAudioPort hard-routes to the speaker even over a - // connected headset, so only use it when the speaker is forced - // (carried as the defaultToSpeaker option). For a plain speaker - // preference the audio mode already defaults to the speaker while - // letting a wired or Bluetooth headset keep priority, so clear any - // override instead. Only valid for the playAndRecord category. + // connected headset. Plain speaker preference is expressed by the + // selected audio mode/category options, so clear any stale hard + // override unless the app explicitly forced speaker output. + // Only valid for the playAndRecord category. if active, configuration.category == AVAudioSession.Category.playAndRecord.rawValue { - let forcesSpeaker = configuration.categoryOptions.contains(.defaultToSpeaker) - try rtcSession.overrideOutputAudioPort(forcesSpeaker ? .speaker : .none) + try rtcSession.overrideOutputAudioPort(forceSpeakerOutput ? .speaker : .none) } return nil } catch { @@ -653,6 +655,7 @@ class LKAudioEngineObserver: NSObject, RTCAudioDeviceModuleDelegate { // options and speaker preference. False for an explicit per-platform // override or manual mode, where the config is applied verbatim. private var selectCategoryByEngineState = false + private var forceSpeakerOutput = false private var isAutomaticManagementEnabled = true private var sessionActive = false // Last engine state seen, so an immediate re-apply (e.g. speaker toggle @@ -682,12 +685,14 @@ class LKAudioEngineObserver: NSObject, RTCAudioDeviceModuleDelegate { /// delegate callbacks apply it. Callers decide whether to apply immediately. func updatePolicy(_ configuration: RTCAudioSessionConfiguration, automaticManagementEnabled: Bool, - selectCategoryByEngineState: Bool) { + selectCategoryByEngineState: Bool, + forceSpeakerOutput: Bool) { let cachedConfiguration = copyConfiguration(configuration) lock.lock() self.cachedConfiguration = cachedConfiguration self.isAutomaticManagementEnabled = automaticManagementEnabled self.selectCategoryByEngineState = selectCategoryByEngineState + self.forceSpeakerOutput = forceSpeakerOutput lock.unlock() } @@ -697,9 +702,12 @@ class LKAudioEngineObserver: NSObject, RTCAudioDeviceModuleDelegate { func applyCachedConfiguration() -> Error? { lock.lock() let configuration = effectiveConfigurationLocked(isRecordingEnabled: lastIsRecordingEnabled) + let forceSpeakerOutput = self.forceSpeakerOutput lock.unlock() guard let configuration else { return nil } - return LiveKitPlugin.applyAudioSessionConfiguration(configuration, active: true) + return LiveKitPlugin.applyAudioSessionConfiguration(configuration, + forceSpeakerOutput: forceSpeakerOutput, + active: true) } /// Resolves the configuration to apply for a given engine state. Must be @@ -748,11 +756,13 @@ class LKAudioEngineObserver: NSObject, RTCAudioDeviceModuleDelegate { lock.lock() let shouldManageSession = isAutomaticManagementEnabled let configuration = effectiveConfigurationLocked(isRecordingEnabled: isRecordingEnabled) + let forceSpeakerOutput = self.forceSpeakerOutput lock.unlock() if shouldManageSession, let configuration = configuration, let error = LiveKitPlugin.applyAudioSessionConfiguration(configuration, + forceSpeakerOutput: forceSpeakerOutput, active: true) { print("[LiveKit] AudioEngine willEnable: failed to configure audio session: \(error)") resultCode = LiveKitPlugin.kAudioEngineErrorFailedToConfigureAudioSession diff --git a/test/audio/audio_session_test.dart b/test/audio/audio_session_test.dart index d02c4fac2..d367554db 100644 --- a/test/audio/audio_session_test.dart +++ b/test/audio/audio_session_test.dart @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:livekit_client/src/audio/android_audio_session_adapter.dart'; @@ -21,6 +22,8 @@ import 'package:livekit_client/src/support/native.dart'; import 'package:livekit_client/src/support/native_audio.dart' as native_audio; void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() { AudioManager.instance.resetForTest(); Native.bypassVoiceProcessing = false; @@ -271,7 +274,7 @@ void main() { ); }); - test('forced speaker only adds defaultToSpeaker to playAndRecord Apple sessions', () { + test('forced speaker does not mutate Apple category options', () { final manager = AudioManager.instance; final playback = manager.resolveAppleAudioConfigurationForTest( @@ -300,10 +303,7 @@ void main() { expect(playAndRecord.appleAudioCategory, AppleAudioCategory.playAndRecord); expect( playAndRecord.appleAudioCategoryOptions, - { - AppleAudioCategoryOption.allowBluetooth, - AppleAudioCategoryOption.defaultToSpeaker, - }, + {AppleAudioCategoryOption.allowBluetooth}, ); }); @@ -442,6 +442,57 @@ void main() { }); }); + group('Native audio channel', () { + late List calls; + + setUp(() { + calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + Native.channel, + (call) async { + calls.add(call); + return call.method == 'configureNativeAudio' ? true : null; + }, + ); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + Native.channel, + null, + ); + }); + + test('passes forced speaker routing to platform methods', () async { + await Native.setAndroidSpeakerphoneOn(true, force: true); + await Native.setAppleSpeakerphoneOn(true, force: false); + + expect(calls[0].method, 'setAndroidSpeakerphoneOn'); + expect(calls[0].arguments, {'enable': true, 'force': true}); + expect(calls[1].method, 'setAppleSpeakerphoneOn'); + expect(calls[1].arguments, {'enable': true, 'force': false}); + }); + + test('passes forced speaker routing to automatic Apple configuration', () async { + final result = await Native.configureAudio( + native_audio.NativeAudioConfiguration( + appleAudioCategory: AppleAudioCategory.playAndRecord, + appleAudioMode: AppleAudioMode.videoChat, + ), + automatic: true, + selectCategoryByEngineState: true, + forceSpeakerOutput: true, + ); + + expect(result, isTrue); + expect(calls.single.method, 'configureNativeAudio'); + expect( + calls.single.arguments, + containsPair('forceSpeakerOutput', true), + ); + }); + }); + group('androidAudioSessionConfigurationToMap', () { test('serializes communication preset for WebRTC initialization', () { expect( From 24427e934e9a3203d1a87f5a2e82d3c8d9337e34 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 16 Jun 2026 21:07:51 +0800 Subject: [PATCH 19/21] style(audio): sort audio session test imports --- test/audio/audio_session_test.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/test/audio/audio_session_test.dart b/test/audio/audio_session_test.dart index d367554db..010bb8521 100644 --- a/test/audio/audio_session_test.dart +++ b/test/audio/audio_session_test.dart @@ -13,6 +13,7 @@ // limitations under the License. import 'package:flutter/services.dart'; + import 'package:flutter_test/flutter_test.dart'; import 'package:livekit_client/src/audio/android_audio_session_adapter.dart'; From a9ebb174d92787d2210203a558a3b6e18f53ae91 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 16 Jun 2026 21:21:45 +0800 Subject: [PATCH 20/21] fix(audio): address audio manager review comments --- .../io/livekit/plugin/LKAudioSwitchManager.kt | 4 +-- docs/audio.md | 2 +- lib/src/audio/audio_manager.dart | 16 ++++++------ lib/src/support/native.dart | 14 ----------- shared_swift/LiveKitPlugin.swift | 25 ------------------- test/audio/audio_session_test.dart | 9 +++---- 6 files changed, 15 insertions(+), 55 deletions(-) diff --git a/android/src/main/kotlin/io/livekit/plugin/LKAudioSwitchManager.kt b/android/src/main/kotlin/io/livekit/plugin/LKAudioSwitchManager.kt index 644cd9062..18e48d4fb 100644 --- a/android/src/main/kotlin/io/livekit/plugin/LKAudioSwitchManager.kt +++ b/android/src/main/kotlin/io/livekit/plugin/LKAudioSwitchManager.kt @@ -42,8 +42,8 @@ import com.twilio.audioswitch.LegacyAudioSwitch */ internal class LKAudioSwitchManager(private val context: Context) { // AudioSwitch is not threadsafe, so confine all access to a single long-lived - // thread. Do not recreate it on stop/start. Queued lifecycle work must stay - // serialized. + // 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) diff --git a/docs/audio.md b/docs/audio.md index daba1a491..27ef9e94a 100644 --- a/docs/audio.md +++ b/docs/audio.md @@ -1,6 +1,6 @@ # Audio session management -LiveKit owns the platform audio session on iOS, macOS, and Android through a single process-wide entry point, `AudioManager`. You configure session intent once with typed options and LiveKit applies the right native category, mode, focus, and routing for you. This replaces the older approach that relied on flutter_webrtc defaults, `Hardware` speaker calls, and Dart track counting. +LiveKit owns the platform audio session on iOS and Android through a single process-wide entry point, `AudioManager`. You configure session intent once with typed options and LiveKit applies the right native category, mode, focus, and routing for you. On macOS, `AudioManager` reports native audio-engine state but does not configure a platform audio session. `AudioManager` is a singleton, reached through `AudioManager.instance`. diff --git a/lib/src/audio/audio_manager.dart b/lib/src/audio/audio_manager.dart index a341ba1ce..9e7603731 100644 --- a/lib/src/audio/audio_manager.dart +++ b/lib/src/audio/audio_manager.dart @@ -216,8 +216,10 @@ class AudioManager { forceSpeakerOutput: policy.forceSpeakerOutput, ); } else { - // Manual mode: route without re-applying category/mode the app owns. - await Native.setAppleSpeakerphoneOn(preferred, force: _forceSpeakerOutput); + // Manual mode: this is an explicit routing request, so re-apply the + // resolved session policy immediately. Plain speaker preference is + // expressed by category/mode; force is carried separately to native. + await _configureAppleAudioSession(_options); } } else if (lkPlatformIs(PlatformType.android)) { await Native.setAndroidSpeakerphoneOn(preferred, force: _forceSpeakerOutput); @@ -271,11 +273,11 @@ class AudioManager { final policy = _resolvedAudioSessionPolicy(options); final config = policy.appleConfiguration; logger.fine('configuring Apple audio session using $config...'); - // In automatic mode the native audio-engine delegate owns activation - // timing, so this caches the policy (and applies now only if the engine is - // already running). In manual mode it applies immediately. The category is - // chosen natively from engine state unless the app gave an explicit Apple - // override (then the config is applied verbatim). + // In automatic mode the native audio-engine delegate owns activation timing, + // so this caches the policy and applies now only if the engine is already + // running. Automatic mode can resolve the category from engine state unless + // the app gave an explicit Apple override. Manual mode applies the resolved + // config immediately and verbatim. await Native.configureAudio( config, automatic: isAutomaticConfigurationEnabled, diff --git a/lib/src/support/native.dart b/lib/src/support/native.dart index b600c867c..b4b92d039 100644 --- a/lib/src/support/native.dart +++ b/lib/src/support/native.dart @@ -144,20 +144,6 @@ class Native { } } - /// Route Apple (iOS) audio output to/from the speakerphone without otherwise - /// changing the audio session category/mode. - @internal - static Future setAppleSpeakerphoneOn(bool enable, {bool force = false}) async { - try { - await channel.invokeMethod( - 'setAppleSpeakerphoneOn', - {'enable': enable, 'force': force}, - ); - } catch (error) { - logger.warning('setAppleSpeakerphoneOn did throw $error'); - } - } - /// Enable or disable LiveKit's automatic iOS audio-session management from /// native WebRTC audio-engine lifecycle callbacks. @internal diff --git a/shared_swift/LiveKitPlugin.swift b/shared_swift/LiveKitPlugin.swift index 5584d9cce..0d562f935 100644 --- a/shared_swift/LiveKitPlugin.swift +++ b/shared_swift/LiveKitPlugin.swift @@ -386,29 +386,6 @@ 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 force = (args["force"] as? Bool) ?? false - - let rtcSession = RTCAudioSession.sharedInstance() - rtcSession.lockForConfiguration() - defer { rtcSession.unlockForConfiguration() } - - do { - if rtcSession.category == AVAudioSession.Category.playAndRecord.rawValue { - try rtcSession.overrideOutputAudioPort((enable && force) ? .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. @@ -552,8 +529,6 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { handleConfigureNativeAudio(args: args, result: result) case "setAppleAudioSessionAutomaticManagementEnabled": handleSetAppleAudioSessionAutomaticManagementEnabled(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 index 010bb8521..bb957c4cf 100644 --- a/test/audio/audio_session_test.dart +++ b/test/audio/audio_session_test.dart @@ -464,14 +464,11 @@ void main() { ); }); - test('passes forced speaker routing to platform methods', () async { + test('passes forced speaker routing to Android platform method', () async { await Native.setAndroidSpeakerphoneOn(true, force: true); - await Native.setAppleSpeakerphoneOn(true, force: false); - expect(calls[0].method, 'setAndroidSpeakerphoneOn'); - expect(calls[0].arguments, {'enable': true, 'force': true}); - expect(calls[1].method, 'setAppleSpeakerphoneOn'); - expect(calls[1].arguments, {'enable': true, 'force': false}); + expect(calls.single.method, 'setAndroidSpeakerphoneOn'); + expect(calls.single.arguments, {'enable': true, 'force': true}); }); test('passes forced speaker routing to automatic Apple configuration', () async { From feacfb4939a5ee43eb0a44bf65629d5974739e51 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:03:33 +0800 Subject: [PATCH 21/21] fix(audio): add explicit session deactivation API --- docs/audio.md | 5 ++++- lib/src/audio/audio_manager.dart | 17 ++++++++++++++++- lib/src/audio/audio_session.dart | 7 ++++--- lib/src/support/native.dart | 10 ++++++++++ shared_swift/LiveKitPlugin.swift | 15 +++++++++++++++ test/audio/audio_session_test.dart | 10 ++++++++++ 6 files changed, 59 insertions(+), 5 deletions(-) diff --git a/docs/audio.md b/docs/audio.md index 27ef9e94a..bb7a15cc5 100644 --- a/docs/audio.md +++ b/docs/audio.md @@ -43,7 +43,7 @@ Read the current preference through `AudioManager.instance.isSpeakerOutputPrefer ## Automatic vs manual mode -In automatic mode (the default) LiveKit updates the audio session from room, connect, and engine lifecycle. In manual mode LiveKit does not touch the session on its own, and your app drives it explicitly with `setAudioSessionOptions`. +In automatic mode (the default) LiveKit updates the audio session from room, connect, and engine lifecycle. In manual mode LiveKit does not touch the session on its own, and your app drives it explicitly with `setAudioSessionOptions` and `deactivateAudioSession`. ```dart // Hand session control to the app. @@ -58,6 +58,9 @@ await AudioManager.instance.setAudioSessionOptions( // Re-apply the current options, for example after an interruption. await AudioManager.instance.applyCurrentAudioSessionOptions(); + +// Release the session when your manual lifecycle no longer needs it. +await AudioManager.instance.deactivateAudioSession(); ``` Prefer setting the mode before connecting to a room. diff --git a/lib/src/audio/audio_manager.dart b/lib/src/audio/audio_manager.dart index 9e7603731..a0e2611c0 100644 --- a/lib/src/audio/audio_manager.dart +++ b/lib/src/audio/audio_manager.dart @@ -173,7 +173,8 @@ class AudioManager { /// /// 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]. + /// configuration explicitly with [setAudioSessionOptions] and release it with + /// [deactivateAudioSession]. /// /// Prefer setting this before connecting to a room. flutter_webrtc's own /// native audio management is always disabled (LiveKit owns the session). @@ -184,6 +185,20 @@ class AudioManager { await _syncAppleAudioSessionManagementMode(); } + /// Deactivates the current platform audio session. + /// + /// In manual mode this is the explicit release counterpart to + /// [setAudioSessionOptions] or [applyCurrentAudioSessionOptions]. In automatic + /// mode, LiveKit normally releases the session from room/engine lifecycle, so + /// apps rarely need to call this directly. + Future deactivateAudioSession() async { + if (lkPlatformIs(PlatformType.iOS)) { + await Native.deactivateAppleAudioSession(); + } else if (lkPlatformIs(PlatformType.android)) { + await Native.stopAndroidAudioSession(); + } + } + /// Prefers routing audio output to/from the speaker. /// /// By default a connected wired/Bluetooth headset still takes priority even diff --git a/lib/src/audio/audio_session.dart b/lib/src/audio/audio_session.dart index 3af98f722..79e030783 100644 --- a/lib/src/audio/audio_session.dart +++ b/lib/src/audio/audio_session.dart @@ -76,9 +76,10 @@ class AudioSessionOptions { /// One-way media playback preset. /// - /// This intentionally does not expose [preferSpeakerOutput] because speaker - /// preference only affects communication/playAndRecord policies. Use [apple] - /// or [android] for exact platform behavior, or switch at runtime with + /// This intentionally does not expose an initial [preferSpeakerOutput] value: + /// Apple playback policy leaves routing to the platform, while Android + /// speaker routing remains a runtime preference. Use [apple] or [android] for + /// exact platform behavior, or switch at runtime with /// `AudioManager.setSpeakerOutputPreferred`. const AudioSessionOptions.media({ AppleAudioSessionConfiguration? apple, diff --git a/lib/src/support/native.dart b/lib/src/support/native.dart index b4b92d039..b71dd18ab 100644 --- a/lib/src/support/native.dart +++ b/lib/src/support/native.dart @@ -131,6 +131,16 @@ class Native { } } + /// Deactivate LiveKit's Apple audio session. + @internal + static Future deactivateAppleAudioSession() async { + try { + await channel.invokeMethod('deactivateAppleAudioSession', {}); + } catch (error) { + logger.warning('deactivateAppleAudioSession did throw $error'); + } + } + /// Route Android audio output to/from the speakerphone. @internal static Future setAndroidSpeakerphoneOn(bool enable, {bool force = false}) async { diff --git a/shared_swift/LiveKitPlugin.swift b/shared_swift/LiveKitPlugin.swift index 0d562f935..f06f3ece3 100644 --- a/shared_swift/LiveKitPlugin.swift +++ b/shared_swift/LiveKitPlugin.swift @@ -386,6 +386,19 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { #endif } + public func handleDeactivateAppleAudioSession(result: @escaping FlutterResult) { + #if os(macOS) + result(FlutterMethodNotImplemented) + #else + if let error = LiveKitPlugin.deactivateAudioSession() { + print("[LiveKit] Deactivate audio session error: ", error) + result(FlutterError(code: "deactivateAudioSession", message: error.localizedDescription, details: nil)) + } else { + result(true) + } + #endif + } + private static let processInfo = ProcessInfo() /// Returns os version as a string. @@ -529,6 +542,8 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { handleConfigureNativeAudio(args: args, result: result) case "setAppleAudioSessionAutomaticManagementEnabled": handleSetAppleAudioSessionAutomaticManagementEnabled(args: args, result: result) + case "deactivateAppleAudioSession": + handleDeactivateAppleAudioSession(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 index bb957c4cf..abe60400c 100644 --- a/test/audio/audio_session_test.dart +++ b/test/audio/audio_session_test.dart @@ -471,6 +471,16 @@ void main() { expect(calls.single.arguments, {'enable': true, 'force': true}); }); + test('passes audio session deactivation to platform methods', () async { + await Native.stopAndroidAudioSession(); + await Native.deactivateAppleAudioSession(); + + expect(calls[0].method, 'stopAndroidAudioSession'); + expect(calls[0].arguments, isNull); + expect(calls[1].method, 'deactivateAppleAudioSession'); + expect(calls[1].arguments, {}); + }); + test('passes forced speaker routing to automatic Apple configuration', () async { final result = await Native.configureAudio( native_audio.NativeAudioConfiguration(