diff --git a/.version b/.version index 834f26295..b9270b96f 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -2.8.0 +2.9.0-dev.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f1778128..d56c56211 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # CHANGELOG +## 2.9.0-dev.0 + +* Added: AudioManager audio session options with engine-driven native lifecycle and platform routing controls +* Added: Runtime audio processing controls for local audio tracks +* Fixed: Apply Android media audio attributes during WebRTC initialization +* Fixed: Use initialization audio options as the default Android session policy +* Fixed: Avoid sticky Android speaker routing when updating route preference +* Fixed: Clean up local audio tracks when recording start fails +* Fixed: Throw platformUnavailable when runtime audio processing is unsupported +* Fixed: Apply create-time audio processing when local recording is prepared + +## 2.8.1 + +* Added: Add agent deployment targeting to token source options +* Fixed: Android plugin compatibility with AGP 9 built-in Kotlin +* Fixed: Use maintain-resolution as the default video degradation preference for local video publishing + ## 2.8.0 * Added: Session API support for simpler E2EE setup diff --git a/README.md b/README.md index 9a80d3dfb..5f2068a4a 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ Include this package to your `pubspec.yaml` ```yaml --- dependencies: - livekit_client: ^2.8.0 + livekit_client: ^2.9.0-dev.0 ``` ### iOS @@ -150,30 +150,18 @@ void main() async { #### Audio Modes -By default, we use the `communication` audio mode on Android which works best for two-way voice communication. +By default LiveKit uses the `communication` audio mode on Android, which works best for two-way voice communication. -If your app is media playback oriented and does not need the use of the device's microphone, you can use the `media` -audio mode which will provide better audio quality. +If your app is media playback oriented and does not need the device's microphone, apply the `media` session yourself. This +switches `AudioManager` to manual mode, where your app owns the session. ```dart -import 'package:flutter_webrtc/flutter_webrtc.dart' as webrtc; - -Future _initializeAndroidAudioSettings() async { - await webrtc.WebRTC.initialize(options: { - 'androidAudioConfiguration': webrtc.AndroidAudioConfiguration.media.toMap() - }); - webrtc.Helper.setAndroidAudioConfiguration( - webrtc.AndroidAudioConfiguration.media); -} - -void main() async { - await _initializeAudioSettings(); - runApp(const MyApp()); -} +await AudioManager.instance.setAudioSessionOptions( + const AudioSessionOptions.media(), +); ``` -Note: the audio routing will become controlled by the system and cannot be manually changed with functions like -`Hardware.selectAudioOutput`. +See the [audio session guide](https://github.com/livekit/client-sdk-flutter/blob/main/doc/audio.md) for more. ### Desktop support @@ -322,6 +310,13 @@ 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`. A call is managed automatically with no setup. Speaker routing and, when you need it, manual session control go through the same object. See the [audio session guide](https://github.com/livekit/client-sdk-flutter/blob/main/doc/audio.md) for examples covering the automatic and manual modes, speaker routing, per platform overrides, and migration from the older `Hardware` APIs. + +```dart +// A call is managed automatically. Route to the speaker when you want. +await AudioManager.instance.setSpeakerOutputPreferred(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/android/build.gradle b/android/build.gradle index dbc0cb96b..5a671d109 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -2,7 +2,7 @@ group = "io.livekit.plugin" version = "1.0-SNAPSHOT" buildscript { - ext.kotlin_version = "1.8.22" + ext.kotlin_version = "2.1.0" repositories { google() mavenCentral() @@ -18,11 +18,23 @@ allprojects { repositories { google() mavenCentral() + maven { url 'https://jitpack.io' } } } apply plugin: "com.android.library" -apply plugin: "kotlin-android" + +// AGP 9's built-in Kotlin compiles Kotlin itself and rejects the Kotlin Gradle +// Plugin. Apply KGP only when built-in Kotlin is NOT active: that means AGP < 9, +// or AGP 9 with android.builtInKotlin=false (the configuration Flutter currently +// ships by default while the ecosystem migrates). +def agpMajor = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION.tokenize(".")[0] as int +def builtInKotlinActive = agpMajor >= 9 && + (!project.hasProperty("android.builtInKotlin") || + Boolean.parseBoolean(project.property("android.builtInKotlin").toString())) +if (!builtInKotlinActive) { + apply plugin: "kotlin-android" +} android { if (project.android.hasProperty("namespace")) { @@ -36,10 +48,6 @@ android { targetCompatibility = JavaVersion.VERSION_1_8 } - kotlinOptions { - jvmTarget = JavaVersion.VERSION_1_8 - } - sourceSets { main.java.srcDirs += "src/main/kotlin" test.java.srcDirs += "src/test/kotlin" @@ -52,8 +60,11 @@ android { dependencies { testImplementation("org.jetbrains.kotlin:kotlin-test") testImplementation("org.mockito:mockito-core:5.0.0") - implementation 'io.github.webrtc-sdk:android:144.7559.01' + 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 { @@ -68,3 +79,19 @@ android { } } } + +// Configure the Kotlin JVM target. The compilerOptions DSL requires KGP 1.9+ or +// AGP 9 built-in Kotlin; older Flutter app templates ship KGP 1.8.x, which only +// supports the legacy kotlinOptions DSL. +def kotlinExt = project.extensions.findByName("kotlin") +if (kotlinExt?.hasProperty("compilerOptions")) { + kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 + } + } +} else { + android.kotlinOptions { + jvmTarget = JavaVersion.VERSION_1_8.toString() + } +} 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..37f928df0 --- /dev/null +++ b/android/src/main/kotlin/io/livekit/plugin/LKAudioSwitchManager.kt @@ -0,0 +1,315 @@ +/* + * Copyright 2026 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.plugin + +import android.content.Context +import android.media.AudioAttributes +import android.media.AudioManager +import android.os.Build +import android.os.Handler +import android.os.HandlerThread +import com.twilio.audioswitch.AbstractAudioSwitch +import com.twilio.audioswitch.AudioDevice +import com.twilio.audioswitch.AudioSwitch +import com.twilio.audioswitch.CommDeviceAudioSwitch +import com.twilio.audioswitch.LegacyAudioSwitch + +/** + * Manages the Android platform audio session (audio mode, audio focus, and + * output routing) for the LiveKit Flutter SDK, built on top of [AudioSwitch]. + * + * This is LiveKit's own port of the audio-handling best practices from the + * LiveKit Android SDK (`AudioSwitchHandler`) and flutter_webrtc + * (`AudioSwitchManager`), so the Flutter SDK can own the platform audio session + * directly instead of delegating to flutter_webrtc's native audio management. + * + * [AudioSwitch] is not thread-safe, so every interaction with it runs on a + * single dedicated [HandlerThread]. + */ +internal class LKAudioSwitchManager(private val context: Context) { + // AudioSwitch is not threadsafe, so confine all access to a single long-lived + // thread. The AudioSwitch instance is recreated per active session, while + // queued lifecycle work stays serialized on this thread. + private val thread = HandlerThread("LKAudioSwitchThread").also { it.start() } + private val handler = Handler(thread.looper) + + private var audioSwitch: AbstractAudioSwitch? = null + private var isActive = false + + // Configuration. Defaults mirror a communication/VoIP session and match the + // AudioSwitchHandler defaults in the LiveKit Android SDK. + private var manageAudioFocus = true + private var audioMode = AudioManager.MODE_IN_COMMUNICATION + private var focusMode = AudioManager.AUDIOFOCUS_GAIN + private var audioStreamType = AudioManager.STREAM_VOICE_CALL + private var audioAttributeUsageType = AudioAttributes.USAGE_VOICE_COMMUNICATION + private var audioAttributeContentType = AudioAttributes.CONTENT_TYPE_SPEECH + private var forceHandleAudioRouting = false + + private var speakerOutputPreferred = true + private var speakerOutputForced = false + + /** + * Apply an audio session configuration. Unspecified keys keep their current + * value. When the session is already active, changes that only take effect at + * activate() time trigger a deactivate and activate cycle so they apply live. + */ + @Synchronized + fun configure(configuration: Map) { + val previous = sessionConfigSnapshot() + (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 } + val sessionConfig = sessionConfigSnapshot() + val sessionConfigChanged = sessionConfig != previous + val speakerRouting = speakerRoutingSnapshot() + + handler.post { + val switch = audioSwitch ?: return@post + applyConfiguration(switch, sessionConfig) + // AudioSwitch applies the audio mode, focus, and attributes at activate() + // time, so a live reconfiguration (e.g. communication to media) needs a + // deactivate and activate cycle to take effect on an already active + // session. Reassert speaker routing afterward. + if (isActive && sessionConfigChanged) { + switch.deactivate() + switch.activate() + applySpeakerRouting(switch, speakerRouting) + } + } + } + + // Snapshot of the AudioSwitch properties applied only at activate() time, used + // to detect when a live session needs a deactivate and activate cycle to pick + // up a configuration change. + private fun sessionConfigSnapshot() = SessionConfig( + manageAudioFocus = manageAudioFocus, + audioMode = audioMode, + focusMode = focusMode, + audioStreamType = audioStreamType, + audioAttributeUsageType = audioAttributeUsageType, + audioAttributeContentType = audioAttributeContentType, + forceHandleAudioRouting = forceHandleAudioRouting, + ) + + /** Create (if needed) and activate the audio session: acquire focus, set mode and routing. */ + @Synchronized + fun start() { + val sessionConfig = sessionConfigSnapshot() + val speakerRouting = speakerRoutingSnapshot() + handler.post { + val switch = audioSwitch ?: createSwitch(sessionConfig, speakerRouting).also { audioSwitch = it } + if (!isActive) { + switch.activate() + applySpeakerRouting(switch, speakerRouting) + isActive = true + } + } + } + + /** Deactivate and tear down the audio session: release focus and restore the previous mode. */ + @Synchronized + fun stop() { + handler.post { + audioSwitch?.stop() + audioSwitch = null + isActive = false + } + } + + /** Final cleanup when the plugin detaches. The manager must not be used after this. */ + @Synchronized + fun dispose() { + handler.post { + audioSwitch?.stop() + audioSwitch = null + isActive = false + thread.quitSafely() + } + } + + /** + * Prefer routing to/from the speaker, letting a connected headset keep priority + * unless [force] is true. + */ + @Synchronized + fun setSpeakerphoneOn(enable: Boolean, force: Boolean) { + speakerOutputPreferred = enable + speakerOutputForced = enable && force + val speakerRouting = speakerRoutingSnapshot() + handler.post { + val switch = audioSwitch ?: return@post + applySpeakerRouting(switch, speakerRouting) + } + } + + private fun createSwitch( + sessionConfig: SessionConfig, + speakerRouting: SpeakerRouting, + ): 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, false, focusListener, speakerRouting.preferredDeviceList) + + Build.VERSION.SDK_INT >= Build.VERSION_CODES.M -> + AudioSwitch(context, false, focusListener, speakerRouting.preferredDeviceList) + + else -> + LegacyAudioSwitch(context, false, focusListener, speakerRouting.preferredDeviceList) + } + applyConfiguration(switch, sessionConfig) + switch.start { _, _ -> } + return switch + } + + private fun applyConfiguration(switch: AbstractAudioSwitch, sessionConfig: SessionConfig) { + switch.manageAudioFocus = sessionConfig.manageAudioFocus + switch.audioMode = sessionConfig.audioMode + switch.focusMode = sessionConfig.focusMode + switch.audioStreamType = sessionConfig.audioStreamType + switch.audioAttributeUsageType = sessionConfig.audioAttributeUsageType + switch.audioAttributeContentType = sessionConfig.audioAttributeContentType + switch.forceHandleAudioRouting = sessionConfig.forceHandleAudioRouting + } + + private fun applySpeakerRouting(switch: AbstractAudioSwitch, speakerRouting: SpeakerRouting) { + // AudioSwitch treats selectDevice(null) as "select no device"; it does not + // recompute the best route from the preferred-device list. Keep routing + // automatic here so normal preference and forced-speaker priority both + // follow device hot-plug changes without leaving a sticky selected device. + switch.setPreferredDeviceList(speakerRouting.preferredDeviceList) + } + + private fun speakerRoutingSnapshot() = SpeakerRouting( + preferredDeviceList = preferredDeviceList( + speakerOutputPreferred = speakerOutputPreferred, + speakerOutputForced = speakerOutputForced, + ), + ) + + private fun preferredDeviceList( + speakerOutputPreferred: Boolean, + speakerOutputForced: Boolean, + ): 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( + AudioDevice.BluetoothHeadset::class.java, + AudioDevice.WiredHeadset::class.java, + AudioDevice.Earpiece::class.java, + AudioDevice.Speakerphone::class.java, + ) + } + + private data class SessionConfig( + val manageAudioFocus: Boolean, + val audioMode: Int, + val focusMode: Int, + val audioStreamType: Int, + val audioAttributeUsageType: Int, + val audioAttributeContentType: Int, + val forceHandleAudioRouting: Boolean, + ) + + private data class SpeakerRouting( + val preferredDeviceList: List>, + ) +} + +// 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 88c73c658..11a04536e 100644 --- a/android/src/main/kotlin/io/livekit/plugin/LiveKitPlugin.kt +++ b/android/src/main/kotlin/io/livekit/plugin/LiveKitPlugin.kt @@ -17,6 +17,8 @@ package io.livekit.plugin import android.annotation.SuppressLint +import android.os.Handler +import android.os.Looper import androidx.annotation.NonNull import io.flutter.embedding.engine.plugins.FlutterPlugin @@ -26,15 +28,29 @@ 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 +import org.webrtc.audio.AudioProcessingComponentOptions +import org.webrtc.audio.AudioProcessingComponentState +import org.webrtc.audio.AudioProcessingImplementation +import org.webrtc.audio.AudioProcessingMode +import org.webrtc.audio.AudioProcessingOptions +import org.webrtc.audio.AudioProcessingOptionsResult +import org.webrtc.audio.AudioProcessingState +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.RejectedExecutionException /** LiveKitPlugin */ class LiveKitPlugin : FlutterPlugin, MethodCallHandler { private var audioProcessors = mutableMapOf() private var flutterWebRTCPlugin = FlutterWebRTCPlugin.sharedSingleton private var binaryMessenger: BinaryMessenger? = null + private var audioSwitchManager: LKAudioSwitchManager? = null + private var audioDeviceModuleExecutor: ExecutorService? = null + private val mainHandler = Handler(Looper.getMainLooper()) /// The MethodChannel that will the communication between Flutter and native Android /// @@ -43,9 +59,15 @@ 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) + audioDeviceModuleExecutor?.shutdown() + audioDeviceModuleExecutor = Executors.newSingleThreadExecutor() } @SuppressLint("SuspiciousIndentation") @@ -210,6 +232,180 @@ class LiveKitPlugin : FlutterPlugin, MethodCallHandler { result.success(true) } + private fun handleSetAudioProcessingOptions(call: MethodCall, result: Result) { + val trackId = call.argument("trackId") + if (trackId == null) { + result.error("INVALID_ARGUMENT", "trackId is required", null) + return + } + + val mediaTrack = (flutterWebRTCPlugin.getLocalTrack(trackId) as? LocalAudioTrack)?.track + if (mediaTrack !is AudioTrack) { + result.error("INVALID_ARGUMENT", "track is not a local audio track", null) + return + } + + val options = audioProcessingOptions(call) + val processingResult = mediaTrack.setAudioProcessingOptions(options) + result.success( + mapOf( + "result" to processingResult.isSuccess, + "code" to audioProcessingResultCodeString(processingResult.code), + "message" to processingResult.message, + ), + ) + } + + private fun handleStartLocalRecording(call: MethodCall, result: Result) { + val audioDeviceModule = flutterWebRTCPlugin.audioDeviceModule + if (audioDeviceModule == null) { + result.error("rejectedPlatformUnavailable", "audio device module is unavailable", null) + return + } + + val executor = audioDeviceModuleExecutorOrError(result, "rejectedPlatformUnavailable") ?: return + val options = audioProcessingOptions(call) + try { + executor.execute { + try { + // prewarmRecording applies Android platform AP and prepares recording + // without setting the client-start flag. WebRTC exposes this as void, + // so only thrown failures can be surfaced here. + audioDeviceModule.prewarmRecording(options) + mainHandler.post { + result.success(null) + } + } catch (error: Throwable) { + mainHandler.post { + result.error("applyFailed", error.message, null) + } + } + } + } catch (error: RejectedExecutionException) { + result.error("rejectedPlatformUnavailable", "audio device module executor is unavailable", null) + } + } + + private fun handleStopLocalRecording(result: Result) { + val audioDeviceModule = flutterWebRTCPlugin.audioDeviceModule + if (audioDeviceModule == null) { + result.error("stopLocalRecording", "audio device module is unavailable", null) + return + } + + val executor = audioDeviceModuleExecutorOrError(result, "stopLocalRecording") ?: return + try { + executor.execute { + try { + audioDeviceModule.requestStopRecording() + mainHandler.post { + result.success(null) + } + } catch (error: Throwable) { + mainHandler.post { + result.error("stopLocalRecording", error.message, null) + } + } + } + } catch (error: RejectedExecutionException) { + result.error("stopLocalRecording", "audio device module executor is unavailable", null) + } + } + + private fun audioDeviceModuleExecutorOrError(result: Result, code: String): ExecutorService? { + val executor = audioDeviceModuleExecutor + if (executor == null || executor.isShutdown) { + result.error(code, "audio device module executor is unavailable", null) + return null + } + return executor + } + + private fun audioProcessingOptions(call: MethodCall): AudioProcessingOptions = + AudioProcessingOptions( + AudioProcessingComponentOptions( + call.argument("echoCancellation") ?: true, + audioProcessingMode(call.argument("echoCancellationMode")), + ), + AudioProcessingComponentOptions( + call.argument("noiseSuppression") ?: true, + audioProcessingMode(call.argument("noiseSuppressionMode")), + ), + AudioProcessingComponentOptions( + call.argument("autoGainControl") ?: true, + audioProcessingMode(call.argument("autoGainControlMode")), + ), + AudioProcessingComponentOptions( + call.argument("highPassFilter") ?: false, + audioProcessingMode(call.argument("highPassFilterMode")), + ), + ) + + private fun audioProcessingMode(value: String?): AudioProcessingMode = when (value) { + "platform" -> AudioProcessingMode.PLATFORM + "software" -> AudioProcessingMode.SOFTWARE + else -> AudioProcessingMode.AUTOMATIC + } + + private fun audioProcessingResultCodeString(code: AudioProcessingOptionsResult.Code): String = when (code) { + AudioProcessingOptionsResult.Code.APPLIED -> "applied" + AudioProcessingOptionsResult.Code.STORED -> "stored" + AudioProcessingOptionsResult.Code.REJECTED_REMOTE_TRACK -> "unknown" + AudioProcessingOptionsResult.Code.REJECTED_INVALID_COMBINATION -> "rejectedInvalidCombination" + AudioProcessingOptionsResult.Code.REJECTED_PLATFORM_UNAVAILABLE -> "rejectedPlatformUnavailable" + AudioProcessingOptionsResult.Code.APPLY_FAILED -> "applyFailed" + } + + private fun handleGetAudioProcessingState(result: Result) { + val factory = flutterWebRTCPlugin.getPeerConnectionFactory() + if (factory == null) { + result.success(null) + return + } + result.success(audioProcessingStateToMap(factory.audioProcessingState)) + } + + private fun audioProcessingModeString(mode: AudioProcessingMode): String = when (mode) { + AudioProcessingMode.PLATFORM -> "platform" + AudioProcessingMode.SOFTWARE -> "software" + AudioProcessingMode.AUTOMATIC -> "auto" + } + + private fun audioProcessingImplementationString(implementation: AudioProcessingImplementation): String = + when (implementation) { + AudioProcessingImplementation.UNKNOWN -> "unknown" + AudioProcessingImplementation.DISABLED -> "disabled" + AudioProcessingImplementation.SOFTWARE -> "software" + AudioProcessingImplementation.PLATFORM -> "platform" + AudioProcessingImplementation.SOFTWARE_AND_PLATFORM -> "softwareAndPlatform" + } + + private fun requestedToMap(requested: AudioProcessingComponentOptions?): Map? = + requested?.let { + mapOf( + "enabled" to it.isEnabled, + "mode" to audioProcessingModeString(it.mode), + ) + } + + private fun componentToMap(state: AudioProcessingComponentState): Map = mapOf( + "requested" to requestedToMap(state.requested), + "isSoftwareResolved" to state.isSoftwareResolved, + "isSoftwareActive" to state.isSoftwareActive, + "isPlatformAvailable" to state.isPlatformAvailable, + "isPlatformResolved" to state.isPlatformResolved, + "isPlatformActive" to state.isPlatformActive, + "effective" to audioProcessingImplementationString(state.effective), + ) + + private fun audioProcessingStateToMap(state: AudioProcessingState): Map = mapOf( + "hasAudioProcessingModule" to state.hasAudioProcessingModule, + "echoCancellation" to componentToMap(state.echoCancellation), + "noiseSuppression" to componentToMap(state.noiseSuppression), + "autoGainControl" to componentToMap(state.autoGainControl), + "highPassFilter" to componentToMap(state.highPassFilter), + ) + override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { when (call.method) { "startVisualizer" -> { @@ -228,6 +424,42 @@ class LiveKitPlugin : FlutterPlugin, MethodCallHandler { handleStopAudioRenderer(call, result) } + "setAudioProcessingOptions" -> { + handleSetAudioProcessingOptions(call, result) + } + + "startLocalRecording" -> { + handleStartLocalRecording(call, result) + } + + "stopLocalRecording" -> { + handleStopLocalRecording(result) + } + + "getAudioProcessingState" -> { + handleGetAudioProcessingState(result) + } + + "configureAndroidAudioSession" -> { + @Suppress("UNCHECKED_CAST") + val configuration = call.arguments as? Map ?: emptyMap() + audioSwitchManager?.configure(configuration) + audioSwitchManager?.start() + result.success(null) + } + + "stopAndroidAudioSession" -> { + audioSwitchManager?.stop() + result.success(null) + } + + "setAndroidSpeakerphoneOn" -> { + val enable = call.argument("enable") ?: false + val force = call.argument("force") ?: false + audioSwitchManager?.setSpeakerphoneOn(enable, force) + result.success(null) + } + else -> { result.notImplemented() } @@ -237,6 +469,12 @@ class LiveKitPlugin : FlutterPlugin, MethodCallHandler { override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { channel.setMethodCallHandler(null) + audioSwitchManager?.dispose() + audioSwitchManager = null + + audioDeviceModuleExecutor?.shutdown() + audioDeviceModuleExecutor = null + // Cleanup all processors audioProcessors.values.forEach { it.cleanup() } audioProcessors.clear() diff --git a/doc/audio.md b/doc/audio.md new file mode 100644 index 000000000..47718bfb4 --- /dev/null +++ b/doc/audio.md @@ -0,0 +1,261 @@ +# Audio session management + +LiveKit owns the platform audio session on iOS and Android through a single process-wide entry point, `AudioManager`. By default it manages a communication session for you, choosing the right native category, mode, focus, and routing. When you need more control you switch to manual mode and apply typed options yourself. On macOS, `AudioManager` reports native audio-engine state but does not configure a platform audio session. + +`AudioManager` is a singleton, reached through `AudioManager.instance`. It is also where you read back the engine-wide audio processing state, so one object covers both the audio session and the signal processing applied to your audio. + +## Defaults if you do nothing + +With no configuration, LiveKit manages the session automatically with the `communication` intent, which is meant for calls. A call needs no setup. On iOS this resolves to a `playAndRecord` session while the microphone is active and a `playback` session for listen only playout. On Android it resolves to communication mode with voice call routing and audio focus. + +LiveKit disables flutter_webrtc's own native audio management automatically when the plugin loads, so it owns the session without any setup from you. + +Speaker output is preferred by default, but a wired or Bluetooth headset still wins over the speaker. Forced speaker output is off, so the speaker is never forced over a connected headset unless you ask for it. + +The media playback preset (`AudioSessionOptions.mediaPlayback()`) is for playback-first experiences such as viewer-only live streams. On Android, pass it to `LiveKitClient.initialize` before WebRTC initializes when you need the WebRTC audio device module to use media mode and media volume. This also seeds LiveKit's automatic runtime session policy until you explicitly replace it with `AudioManager.instance.setAudioSessionOptions(...)`. Runtime session updates apply LiveKit's platform session policy, but WebRTC playout `AudioAttributes` are currently initialized when the audio device module is created. + +The default audio capture options apply standard voice processing, so echo cancellation, noise suppression, and auto gain control are on and the high pass filter is off. You can change this per track with `AudioProcessingOptions`. + +On macOS the audio engine state is reported but no `AVAudioSession` is configured. On web, Windows, and Linux the session APIs do not configure native audio. Speaker switching is available only on iOS and Android, where `AudioManager.instance.canSwitchSpeakerphone` is true. + +## Quick start + +For a call you do not need to configure anything. LiveKit manages a communication session automatically. + +To take control of the session yourself, apply options. This switches `AudioManager` to manual mode, where your app owns the session and LiveKit stops managing it from room and engine lifecycle. + +```dart +import 'package:livekit_client/livekit_client.dart'; + +// Take manual control and apply a media playback session. +await AudioManager.instance.setAudioSessionOptions( + const AudioSessionOptions.mediaPlayback(), +); +``` + +On Android, media output type has one WebRTC initialization-time piece and one LiveKit runtime-session piece. For playback-first apps, initialize WebRTC with the media intent before connecting: + +```dart +await LiveKitClient.initialize( + initialAudioSessionOptions: const AudioSessionOptions.mediaPlayback(), +); +``` + +This seeds both WebRTC's initialization-time playout attributes and LiveKit's automatic runtime session policy. See the next section for the full rule. Apply options before connecting when you can. + +## Configuration timing + +`AudioSessionOptions` are used in two places on Android: + +| API | Timing | What it can update | +| --- | --- | --- | +| `LiveKitClient.initialize(initialAudioSessionOptions: ...)` | Before WebRTC initializes. | WebRTC audio device module playout `AudioAttributes`, and LiveKit's initial automatic runtime session policy. Today WebRTC uses the Android `usageType` and `contentType` fields, for example `USAGE_MEDIA` and `CONTENT_TYPE_UNKNOWN` from `AudioSessionOptions.mediaPlayback()`. | +| `AudioManager.instance.setAudioSessionOptions(...)` | Runtime. | Explicitly replaces the stored session options, switches to manual management, and applies LiveKit's platform session policy: Android audio mode, audio focus mode, stream type, focus ownership, routing handler policy, and iOS category/options/mode. | + +Most fields in `AudioSessionOptions` are runtime session policy and can be applied again with `AudioManager.instance.setAudioSessionOptions(...)`. The exception is Android WebRTC playout attributes: changing `AndroidAudioSessionConfiguration.usageType` or `contentType` at runtime updates LiveKit's session/focus handler, but it does not change the `AudioAttributes` of an already-created WebRTC audio device module. Pass those options to `LiveKitClient.initialize(...)` before WebRTC initializes when they must affect playout volume/routing. You do not need to call `setAudioSessionOptions(...)` with the same options just to make LiveKit use them during automatic session management. + +We plan to make Android WebRTC playout attributes runtime-updatable in a future SDK/WebRTC integration if the native layer can safely update the stored attributes and recreate playout with acceptable behavior. Until then, treat WebRTC playout `AudioAttributes` as initialization-time configuration. + +## Automatic vs manual mode + +The two modes differ in who owns the session lifecycle. + +In automatic mode (the default) LiveKit manages the session from room, connect, and engine lifecycle. On iOS it derives the active category/mode from the current audio engine state. On Android it uses the current session intent, defaulting to communication and optionally seeded by `LiveKitClient.initialize(initialAudioSessionOptions: ...)`. + +In manual mode LiveKit does not touch the session on its own, and your app owns it. Enter manual mode when you need to apply a fixed platform configuration or deactivate the session yourself. + +```dart +// Apply a fixed config. This enters manual mode. +await AudioManager.instance.setAudioSessionOptions( + const AudioSessionOptions.mediaPlayback(), +); + +// Later, hand control back to LiveKit. +await AudioManager.instance.setAudioSessionManagementMode( + AudioSessionManagementMode.automatic, +); +``` + +You can also enter manual mode without applying a new config: + +```dart +await AudioManager.instance.setAudioSessionManagementMode( + AudioSessionManagementMode.manual, +); +``` + +To release the active session, call `deactivateAudioSession`. It also enters manual mode if needed, so LiveKit does not immediately reactivate the session from engine lifecycle. + +```dart +// Release the active session when your manual lifecycle no longer needs it. +await AudioManager.instance.deactivateAudioSession(); +``` + +Prefer setting the mode before connecting to a room. + +## Speaker routing + +```dart +// 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. +await AudioManager.instance.setSpeakerOutputPreferred(true, force: true); + +// Route back to the earpiece or the connected headset when supported by the +// active platform session. +await AudioManager.instance.setSpeakerOutputPreferred(false); +``` + +Speaker routing is independent of the management mode and does not switch it. In Android communication/call sessions and in iOS automatic mode, LiveKit applies the preference through its managed route policy. In iOS manual mode, the fixed Apple config you apply owns non-forced receiver vs speaker behavior. `force: true` still uses Apple's speaker override when the active category is `playAndRecord`. Read the current preference through `AudioManager.instance.isSpeakerOutputPreferred` and `AudioManager.instance.isSpeakerOutputForced`. `AudioManager.instance.canSwitchSpeakerphone` is true on iOS and Android. + +On Android, LiveKit speaker routing is a communication/call routing policy. Media sessions use Android's normal media routing. Pass `AudioSessionOptions.mediaPlayback()` to `LiveKitClient.initialize` before WebRTC initializes when the WebRTC audio device module should use media attributes. + +`Room.setSpeakerOn(...)` is deprecated and forwards to `AudioManager.instance.setSpeakerOutputPreferred`. You can also set an initial preference through `RoomOptions` (`defaultAudioOutputOptions.speakerOn`) before connecting, which LiveKit applies when the session starts. + +## 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; +``` + +## Audio processing + +Session management is one half of the audio stack. The other half is audio processing, the signal processing applied to captured audio such as echo cancellation, noise suppression, auto gain control, and the high pass filter. `AudioManager` is the home for both, and they compose cleanly. + +The session intent decides how the platform treats audio. Capture options decide what happens to local microphone audio: + +- A call usually uses the automatic `communication` session. The default `AudioCaptureOptions` enable echo cancellation, noise suppression, and auto gain control, while leaving the high pass filter off. +- Use `AudioProcessingOptions.communication()` when you want all four voice filters on for an existing local audio track. +- Use `AudioProcessingOptions.noProcessing()` for local capture where you want minimal processing, such as high quality recording or app-managed audio effects. + +Create-time processing is configured through `AudioCaptureOptions`. `LocalAudioTrack.create(...)` stores these options, and LiveKit prepares them when local recording starts, such as during publish or preconnect. If the exposed native platform API reports that capture-time setup failed, the start path throws `AudioProcessingException` before publish creates a server-side publication. + +```dart +final track = await LocalAudioTrack.create( + const AudioCaptureOptions( + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + highPassFilter: false, + ), +); +``` + +For an existing local audio track, call `setAudioProcessingOptions`. It returns when the native layer applies or stores the options. If the request is invalid, unsupported, or cannot be applied, it throws `AudioProcessingException` and the track keeps its previous processing options. + +```dart +try { + await localAudioTrack.setAudioProcessingOptions( + const AudioProcessingOptions.noProcessing(), + ); +} on AudioProcessingException catch (error) { + print('audio processing not applied: ${error.reason.name} ${error.message}'); +} +``` + +The processing module is owned by the native peer connection factory and shared across the whole engine, so the resolved state is read back through `AudioManager` rather than from a single track: + +```dart +final state = await AudioManager.instance.getAudioProcessingState(); +print('echo cancellation in effect: ${state?.echoCancellation.effective}'); +``` + +## Per platform overrides + +When the preset constructors are not enough you can pin exact platform values. Supplying options through `setAudioSessionOptions` switches to manual mode, so these configs are a manual-mode tool. `AudioSessionOptions.communication()` and `AudioSessionOptions.mediaPlayback()` pre-fill Apple and Android configs. Passing `apple` or `android` replaces that platform config rather than merging with the preset. + +```dart +await AudioManager.instance.setAudioSessionOptions( + AudioSessionOptions.communication( + apple: const AppleAudioSessionConfiguration( + category: AppleAudioCategory.playAndRecord, + categoryOptions: { + AppleAudioCategoryOption.allowBluetooth, + AppleAudioCategoryOption.mixWithOthers, + }, + mode: AppleAudioMode.voiceChat, + ), + android: AndroidAudioSessionConfiguration.communication, + ), +); +``` + +For an `apple` config, your exact category, options, and mode are applied as written. For an `android` config, any field you leave null is omitted so the native manager keeps its current value for that field. + +### Updating options with copyWith + +`AudioSessionOptions.copyWith` uses `ValueOrAbsent` to replace the Apple or Android config as a whole. A bare `copyWith()` keeps the existing config, and `ValueOrAbsent.value(x)` sets a new config. The Apple and Android config objects have their own `copyWith` methods for clearing nullable native fields with `ValueOrAbsent.value(null)`. + +```dart +const base = AudioSessionOptions.communication(); + +// Use the media Android config, keep the Apple config. +final updated = base.copyWith( + android: const ValueOrAbsent.value(AndroidAudioSessionConfiguration.media), +); + +// Clear just the Apple mode field inside the Apple config. +final clearedMode = updated.copyWith( + apple: ValueOrAbsent.value( + updated.apple.copyWith(mode: const ValueOrAbsent.value(null)), + ), +); +``` + +Create a new `AudioSessionOptions.communication()` or `AudioSessionOptions.mediaPlayback()` when you want to start from a different preset config. + +## Platform support + +| Platform | Audio session | Speaker routing | Engine state | +| --- | --- | --- | --- | +| iOS | Automatic mode follows live engine state. Manual mode applies your Apple config verbatim. | Yes. Normal preference respects wired and Bluetooth devices. Forced speaker uses Apple's speaker override while the active category is `playAndRecord`. | Yes, from native WebRTC engine events. | +| macOS | Not configured. There is no `AVAudioSession`. | No. `canSwitchSpeakerphone` is false. | Yes, the same engine events are reported. | +| Android | Automatic mode uses the current session intent, seeded by `LiveKitClient.initialize(initialAudioSessionOptions: ...)` and defaulting to communication. Pass media options before WebRTC initializes when the WebRTC audio device module should use media mode/volume. | Yes for communication/call routing. Media playback follows Android media routing. | Not reported, the Dart state stays idle. | +| Web, Windows, Linux | Not configured. | No. `canSwitchSpeakerphone` is false. | Not reported. | + +On iOS automatic mode, listen only playout uses `playback`. When recording starts, LiveKit reapplies the session as `playAndRecord`. In manual mode, non-forced receiver vs speaker behavior comes from the Apple config you applied. + +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 older counting approach. + +## 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.setSpeakerOutputPreferred(true)` | +| `room.setSpeakerOn(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, which run in manual mode. + +```dart +// Before: assigning onConfigureNativeAudio with a custom function. +// After: +await AudioManager.instance.setAudioSessionOptions( + AudioSessionOptions.communication( + apple: const AppleAudioSessionConfiguration( + category: AppleAudioCategory.playAndRecord, + mode: AppleAudioMode.videoChat, + ), + ), +); +``` + +## 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`, `AndroidAudioSessionConfiguration`, and `AudioProcessingOptions`. diff --git a/example/lib/pages/room.dart b/example/lib/pages/room.dart index 27aa394b8..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(Hardware.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 3c96b4ebc..50cf2fee7 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.isSpeakerOutputPreferred; @override void initState() { @@ -109,7 +109,7 @@ class _ControlsWidgetState extends State { void _setSpeakerphoneOn() async { _speakerphoneOn = !_speakerphoneOn; - await widget.room.setSpeakerOn(_speakerphoneOn, forceSpeakerOutput: false); + await AudioManager.instance.setSpeakerOutputPreferred(_speakerphoneOn, force: false); setState(() {}); } diff --git a/ios/livekit_client.podspec b/ios/livekit_client.podspec index e9b3032eb..ba08fb024 100644 --- a/ios/livekit_client.podspec +++ b/ios/livekit_client.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'livekit_client' - s.version = '2.8.0' + s.version = '2.9.0-dev.0' s.summary = 'Open source platform for real-time audio and video.' s.description = 'Open source platform for real-time audio and video.' s.homepage = 'https://livekit.io/' @@ -16,6 +16,6 @@ Pod::Spec.new do |s| s.static_framework = true s.dependency 'Flutter' - s.dependency 'WebRTC-SDK', '144.7559.01' + s.dependency 'WebRTC-SDK', '144.7559.09' s.dependency 'flutter_webrtc' end diff --git a/lib/livekit_client.dart b/lib/livekit_client.dart index cb0408d67..20fb5e289 100644 --- a/lib/livekit_client.dart +++ b/lib/livekit_client.dart @@ -41,12 +41,15 @@ export 'src/agent/room_agent.dart'; export 'src/participant/local.dart'; 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'; export 'src/publication/track_publication.dart'; export 'src/support/platform.dart'; +export 'src/audio/audio_processing_state.dart'; export 'src/track/audio_visualizer.dart'; export 'src/track/local/audio.dart'; export 'src/track/local/local.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..fe7434b04 --- /dev/null +++ b/lib/src/audio/android_audio_session_adapter.dart @@ -0,0 +1,38 @@ +// 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. 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 new file mode 100644 index 000000000..efb4a8d32 --- /dev/null +++ b/lib/src/audio/audio_manager.dart @@ -0,0 +1,317 @@ +// 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 'dart:async'; + +import 'package:meta/meta.dart'; + +import '../logger.dart'; +import '../support/native.dart'; +import '../support/platform.dart'; +import 'android_audio_session_adapter.dart'; +import 'audio_processing_state.dart'; +import 'audio_session.dart'; +import 'audio_session_policy.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 +/// 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 _options = const AudioSessionOptions.communication(); + AudioSessionManagementMode _managementMode = AudioSessionManagementMode.automatic; + bool _preferSpeakerOutput = true; + bool _forceSpeakerOutput = false; + bool _isPlayoutEnabled = false; + bool _isRecordingEnabled = false; + final StreamController _audioEngineStateController = StreamController.broadcast(); + + AudioSessionOptions get options => _options; + AudioSessionManagementMode get managementMode => _managementMode; + + /// 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. + bool get isSpeakerOutputForced => _forceSpeakerOutput && _preferSpeakerOutput; + + /// Whether the platform supports switching the speaker output (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; + + bool get _isAutomaticConfigurationEnabled => _managementMode == AudioSessionManagementMode.automatic; + + @visibleForTesting + void resetForTest() { + _options = const AudioSessionOptions.communication(); + _managementMode = AudioSessionManagementMode.automatic; + _preferSpeakerOutput = true; + _forceSpeakerOutput = false; + _isPlayoutEnabled = false; + _isRecordingEnabled = false; + } + + /// 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 handleAudioEngineState({ + required bool isPlayoutEnabled, + required bool isRecordingEnabled, + }) { + final nextState = AudioEngineState( + isPlayoutEnabled: isPlayoutEnabled, + isRecordingEnabled: isRecordingEnabled, + ); + if (nextState == audioEngineState) { + return; + } + + _isPlayoutEnabled = isPlayoutEnabled; + _isRecordingEnabled = isRecordingEnabled; + _audioEngineStateController.add(nextState); + } + + /// Seeds the initial session intent without taking over the session lifecycle. + /// + /// `LiveKitClient.initialize(initialAudioSessionOptions: ...)` uses this so the + /// WebRTC initialization-time Android audio attributes and LiveKit's automatic + /// runtime session policy start from the same intent. Unlike + /// [setAudioSessionOptions], this keeps automatic management enabled and does + /// not apply native session changes immediately. + @internal + void setInitialAudioSessionOptions(AudioSessionOptions options) { + if (_managementMode != AudioSessionManagementMode.automatic) { + return; + } + _options = options; + } + + /// Applies an explicit audio session configuration and switches to manual mode. + /// + /// Calling this puts [AudioManager] in [AudioSessionManagementMode.manual]: + /// LiveKit stops managing the session from room, connect, and engine + /// lifecycle, and the app owns it from here. Hand control back to LiveKit with + /// [setAudioSessionManagementMode] and [AudioSessionManagementMode.automatic]. + /// + /// The speaker preference and force flag are owned by setSpeakerOutputPreferred + /// and are preserved across this call. + Future setAudioSessionOptions(AudioSessionOptions options) async { + await _enterManualMode(); + _options = options; + await _applyCurrentAudioSessionPolicy(); + } + + /// 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] 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). + /// Switching back to automatic mode reapplies LiveKit's managed policy. + Future setAudioSessionManagementMode(AudioSessionManagementMode mode) async { + final previousMode = _managementMode; + _managementMode = mode; + await _syncAppleAudioSessionManagementMode(); + if (previousMode != AudioSessionManagementMode.automatic && mode == AudioSessionManagementMode.automatic) { + await _applyCurrentAudioSessionPolicy(); + } + } + + /// Switches to manual mode if not already, syncing the native side once. + Future _enterManualMode() async { + if (_managementMode == AudioSessionManagementMode.manual) return; + _managementMode = AudioSessionManagementMode.manual; + await _syncAppleAudioSessionManagementMode(); + } + + /// Deactivates the current platform audio session and switches to manual mode. + /// + /// Like [setAudioSessionOptions], calling this puts [AudioManager] in + /// [AudioSessionManagementMode.manual] so LiveKit does not re-activate the + /// session on its own. Re-apply a configuration with [setAudioSessionOptions], + /// or hand control back with [setAudioSessionManagementMode]. + Future deactivateAudioSession() async { + await _enterManualMode(); + 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 + /// when [preferred] is true. Set [force] to force the speaker even when a + /// 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 + /// on flutter_webrtc. + Future setSpeakerOutputPreferred(bool preferred, {bool force = false}) async { + if (!canSwitchSpeakerphone) { + logger.warning('setSpeakerOutputPreferred is only supported on iOS/Android'); + return; + } + _preferSpeakerOutput = preferred; + _forceSpeakerOutput = preferred && force; + + if (lkPlatformIs(PlatformType.iOS)) { + if (_isAutomaticConfigurationEnabled) { + 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. + await Native.configureAudio( + policy.appleConfiguration, + automatic: true, + selectCategoryByEngineState: true, + forceSpeakerOutput: policy.forceSpeakerOutput, + ); + } else { + // Manual mode: re-apply the fixed Apple config. Non-forced receiver vs + // speaker behavior comes from that config. Force is carried separately + // to native for playAndRecord sessions. + await _configureAppleAudioSession(_options); + } + } else if (lkPlatformIs(PlatformType.android)) { + await Native.setAndroidSpeakerphoneOn(preferred, force: _forceSpeakerOutput); + } + } + + Future _applyCurrentAudioSessionPolicy() async { + if (lkPlatformIs(PlatformType.iOS)) { + await _configureAppleAudioSession(_options); + } else if (lkPlatformIs(PlatformType.android)) { + await _configureAndroidAudioSession(_options); + } + } + + @internal + Future applyOptionsForConnect() async { + await _syncAppleAudioSessionManagementMode(); + if (_isAutomaticConfigurationEnabled) { + await _applyCurrentAudioSessionPolicy(); + } + } + + Future _syncAppleAudioSessionManagementMode() async { + if (lkPlatformIs(PlatformType.iOS)) { + await Native.setAppleAudioSessionAutomaticManagementEnabled(_isAutomaticConfigurationEnabled); + } + } + + Future _configureAppleAudioSession(AudioSessionOptions options) async { + 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. Automatic mode resolves the category from engine state. Manual + // mode applies the resolved config immediately and verbatim. + await Native.configureAudio( + config, + automatic: _isAutomaticConfigurationEnabled, + selectCategoryByEngineState: _isAutomaticConfigurationEnabled, + forceSpeakerOutput: policy.forceSpeakerOutput, + ); + } + + Future _configureAndroidAudioSession(AudioSessionOptions options) async { + 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, force: policy.forceSpeakerOutput); + } + + ResolvedAudioSessionPolicy _resolvedAudioSessionPolicy(AudioSessionOptions options) => ResolvedAudioSessionPolicy( + options: options, + preferSpeakerOutput: _preferSpeakerOutput, + forceSpeakerOutput: _forceSpeakerOutput && _preferSpeakerOutput, + automatic: _isAutomaticConfigurationEnabled, + ); + + /// 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 native state + /// after a `LocalAudioTrack.setAudioProcessingOptions` request. 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); + } +} diff --git a/lib/src/audio/audio_processing_state.dart b/lib/src/audio/audio_processing_state.dart new file mode 100644 index 000000000..a7d21e12c --- /dev/null +++ b/lib/src/audio/audio_processing_state.dart @@ -0,0 +1,145 @@ +// 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 '../track/options.dart'; + +/// The implementation in effect for an audio processing component. +enum AudioProcessingImplementation { + unknown('unknown'), + disabled('disabled'), + software('software'), + platform('platform'), + softwareAndPlatform('softwareAndPlatform'); + + const AudioProcessingImplementation(this.value); + + final String value; + + static AudioProcessingImplementation fromValue(String? value) => AudioProcessingImplementation.values.firstWhere( + (e) => e.value == value, + orElse: () => AudioProcessingImplementation.unknown, + ); +} + +AudioProcessingMode _modeFromValue(String? value) { + for (final mode in AudioProcessingMode.values) { + if (mode.constraintValue == value) return mode; + } + return AudioProcessingMode.automatic; +} + +/// The caller's request for one audio processing component: enabled flag plus +/// implementation mode. +class AudioProcessingComponentRequest { + const AudioProcessingComponentRequest({ + required this.enabled, + required this.mode, + }); + + factory AudioProcessingComponentRequest.fromMap(Map map) => AudioProcessingComponentRequest( + enabled: (map['enabled'] as bool?) ?? false, + mode: _modeFromValue(map['mode'] as String?), + ); + + final bool enabled; + final AudioProcessingMode mode; +} + +/// Diagnostic state of one audio processing component (echo cancellation, +/// noise suppression, auto gain control or high-pass filter), observed at +/// three stages of one pipeline: requested (caller intent) -> resolved (the +/// engine's per-path decision) -> active (live truth), with [effective] as +/// the merged verdict. +class AudioProcessingComponentState { + const AudioProcessingComponentState({ + this.requested, + required this.isSoftwareResolved, + required this.isSoftwareActive, + required this.isPlatformAvailable, + required this.isPlatformResolved, + required this.isPlatformActive, + required this.effective, + }); + + factory AudioProcessingComponentState.fromMap(Map map) => AudioProcessingComponentState( + requested: map['requested'] is Map + ? AudioProcessingComponentRequest.fromMap(Map.from(map['requested'] as Map)) + : null, + isSoftwareResolved: (map['isSoftwareResolved'] as bool?) ?? false, + isSoftwareActive: (map['isSoftwareActive'] as bool?) ?? false, + isPlatformAvailable: (map['isPlatformAvailable'] as bool?) ?? false, + isPlatformResolved: (map['isPlatformResolved'] as bool?) ?? false, + isPlatformActive: (map['isPlatformActive'] as bool?) ?? false, + effective: AudioProcessingImplementation.fromValue(map['effective'] as String?), + ); + + /// What the caller most recently requested for this component. Null when no + /// audio processing options have ever been applied — "nobody asked". + final AudioProcessingComponentRequest? requested; + + /// Whether the resolver decided the WebRTC software (APM) implementation + /// should run, after weighing the requested mode against platform + /// availability, coupling, and policy. + final bool isSoftwareResolved; + + /// Whether APM's live configuration currently has this component enabled. + final bool isSoftwareActive; + + /// Whether this device/OS offers a built-in implementation at all. + final bool isPlatformAvailable; + + /// Whether the engine asked the OS to run the platform implementation. The + /// OS owns the outcome: it can decline, defer, or couple components. + final bool isPlatformResolved; + + /// Whether the device reports the platform implementation actually running. + final bool isPlatformActive; + + /// The verdict: which implementation is in effect right now. + final AudioProcessingImplementation effective; +} + +/// Diagnostic snapshot of the resolved audio processing state for the shared +/// audio processing module. +/// +/// The module is owned by the native peer connection factory and shared +/// engine-wide, so this reflects what is actually applied (per-component +/// [AudioProcessingComponentState.effective]) versus what was requested — for +/// the whole engine, not a single track. +class AudioProcessingState { + const AudioProcessingState({ + required this.hasAudioProcessingModule, + required this.echoCancellation, + required this.noiseSuppression, + required this.autoGainControl, + required this.highPassFilter, + }); + + factory AudioProcessingState.fromMap(Map map) => AudioProcessingState( + hasAudioProcessingModule: (map['hasAudioProcessingModule'] as bool?) ?? false, + echoCancellation: + AudioProcessingComponentState.fromMap(Map.from(map['echoCancellation'] as Map)), + noiseSuppression: + AudioProcessingComponentState.fromMap(Map.from(map['noiseSuppression'] as Map)), + autoGainControl: + AudioProcessingComponentState.fromMap(Map.from(map['autoGainControl'] as Map)), + highPassFilter: AudioProcessingComponentState.fromMap(Map.from(map['highPassFilter'] as Map)), + ); + + final bool hasAudioProcessingModule; + final AudioProcessingComponentState echoCancellation; + final AudioProcessingComponentState noiseSuppression; + final AudioProcessingComponentState autoGainControl; + final AudioProcessingComponentState highPassFilter; +} diff --git a/lib/src/audio/audio_session.dart b/lib/src/audio/audio_session.dart new file mode 100644 index 000000000..942f130d0 --- /dev/null +++ b/lib/src/audio/audio_session.dart @@ -0,0 +1,276 @@ +// 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/value_or_absent.dart'; + +import 'package:meta/meta.dart'; + +import '../support/value_or_absent.dart'; + +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, +} + +@immutable +class AudioSessionOptions { + /// Exact Apple session configuration for manual mode. + final AppleAudioSessionConfiguration apple; + + /// Exact Android session configuration for manual mode. + final AndroidAudioSessionConfiguration android; + + const AudioSessionOptions._({ + required this.apple, + required this.android, + }); + + /// Two-way audio preset for calls, rooms, and microphone capture. + /// + /// This pre-fills communication-oriented platform policies. Speaker + /// routing is a runtime preference set with + /// `AudioManager.setSpeakerOutputPreferred`. Override [apple] or [android] + /// for exact platform behavior. + const AudioSessionOptions.communication({ + AppleAudioSessionConfiguration apple = AppleAudioSessionConfiguration.communication, + AndroidAudioSessionConfiguration android = AndroidAudioSessionConfiguration.communication, + }) : this._(apple: apple, android: android); + + /// One-way media playback preset. + /// + /// This pre-fills playback-oriented platform policies. Apple and Android + /// media routing are platform-owned. On Android, pass this to + /// `LiveKitClient.initialize` before WebRTC initializes when WebRTC playout + /// should use media `AudioAttributes`; the same value seeds LiveKit's initial + /// automatic runtime media session policy. + const AudioSessionOptions.mediaPlayback({ + AppleAudioSessionConfiguration apple = AppleAudioSessionConfiguration.media, + AndroidAudioSessionConfiguration android = AndroidAudioSessionConfiguration.media, + }) : this._(apple: apple, android: android); + + /// Returns a copy with selected fields replaced. + AudioSessionOptions copyWith({ + ValueOrAbsent apple = const ValueOrAbsent.absent(), + ValueOrAbsent android = const ValueOrAbsent.absent(), + }) => + AudioSessionOptions._( + apple: apple.valueOr(this.apple), + android: android.valueOr(this.android), + ); +} + +// 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, +} + +@immutable +class AppleAudioSessionConfiguration { + /// AVAudioSession category. + final AppleAudioCategory? category; + + /// AVAudioSession category options. + final Set? categoryOptions; + + /// AVAudioSession mode. + final AppleAudioMode? mode; + + const AppleAudioSessionConfiguration({ + this.category, + this.categoryOptions, + this.mode, + }); + + static const communication = AppleAudioSessionConfiguration( + category: AppleAudioCategory.playAndRecord, + categoryOptions: { + AppleAudioCategoryOption.allowBluetooth, + AppleAudioCategoryOption.allowBluetoothA2DP, + AppleAudioCategoryOption.allowAirPlay, + }, + mode: AppleAudioMode.videoChat, + ); + + static const media = AppleAudioSessionConfiguration( + category: AppleAudioCategory.playback, + categoryOptions: {AppleAudioCategoryOption.mixWithOthers}, + mode: AppleAudioMode.spokenAudio, + ); + + AppleAudioSessionConfiguration copyWith({ + ValueOrAbsent category = const ValueOrAbsent.absent(), + ValueOrAbsent?> categoryOptions = const ValueOrAbsent.absent(), + ValueOrAbsent mode = const ValueOrAbsent.absent(), + }) => + AppleAudioSessionConfiguration( + category: category.valueOr(this.category), + categoryOptions: categoryOptions.valueOr(this.categoryOptions), + mode: mode.valueOr(this.mode), + ); +} + +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, +} + +@immutable +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({ + ValueOrAbsent audioMode = const ValueOrAbsent.absent(), + ValueOrAbsent manageAudioFocus = const ValueOrAbsent.absent(), + ValueOrAbsent focusMode = const ValueOrAbsent.absent(), + ValueOrAbsent streamType = const ValueOrAbsent.absent(), + ValueOrAbsent usageType = const ValueOrAbsent.absent(), + ValueOrAbsent contentType = const ValueOrAbsent.absent(), + ValueOrAbsent forceAudioRouting = const ValueOrAbsent.absent(), + }) => + AndroidAudioSessionConfiguration( + 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/audio/audio_session_policy.dart b/lib/src/audio/audio_session_policy.dart new file mode 100644 index 000000000..4a870277d --- /dev/null +++ b/lib/src/audio/audio_session_policy.dart @@ -0,0 +1,62 @@ +// 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_audio.dart'; +import 'audio_session.dart'; + +@internal +class ResolvedAudioSessionPolicy { + const ResolvedAudioSessionPolicy({ + required this.options, + required this.preferSpeakerOutput, + required this.forceSpeakerOutput, + required this.automatic, + }); + + final AudioSessionOptions options; + final bool preferSpeakerOutput; + final bool forceSpeakerOutput; + final bool automatic; + + NativeAudioConfiguration get appleConfiguration { + if (automatic) { + return NativeAudioConfiguration( + appleAudioCategory: AppleAudioCategory.playAndRecord, + appleAudioCategoryOptions: { + AppleAudioCategoryOption.allowBluetooth, + AppleAudioCategoryOption.allowBluetoothA2DP, + AppleAudioCategoryOption.allowAirPlay, + }, + appleAudioMode: preferSpeakerOutput ? AppleAudioMode.videoChat : AppleAudioMode.voiceChat, + ); + } + + final apple = options.apple; + return NativeAudioConfiguration( + appleAudioCategory: apple.category, + appleAudioCategoryOptions: apple.categoryOptions, + appleAudioMode: apple.mode, + ); + } + + AndroidAudioSessionConfiguration get androidConfiguration { + // In automatic mode LiveKit still owns activation timing, focus/routing + // lifecycle, and speaker preference. The Android session intent itself is + // the current AudioSessionOptions value, seeded by LiveKitClient.initialize + // or replaced explicitly by AudioManager.setAudioSessionOptions. + return options.android; + } +} diff --git a/lib/src/core/room.dart b/lib/src/core/room.dart index f274a0289..9550bcbef 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'; @@ -103,6 +104,9 @@ class Room extends DisposableChangeNotifier with EventsEmittable { bool _isRecording = false; bool _audioEnabled = true; + // Whether the one-time RoomOptions speaker preference bridge has run. + bool _legacySpeakerBridged = false; + lk_models.Room? _roomInfo; /// a list of participants that are actively speaking, including local participant. @@ -288,9 +292,20 @@ class Room extends DisposableChangeNotifier with EventsEmittable { })); } + // Bridge a legacy RoomOptions speaker preference into the process-wide + // AudioManager once, on the first connect. Skipping it on a later manual + // connect of the same Room keeps a runtime speaker change from being + // reverted. New code should call setSpeakerOutputPreferred directly. + final legacySpeakerOn = roomOptions.defaultAudioOutputOptions.speakerOn; + if (legacySpeakerOn != null && !_legacySpeakerBridged && lkPlatformIsMobile()) { + _legacySpeakerBridged = true; + await AudioManager.instance.setSpeakerOutputPreferred(legacySpeakerOn); + } + // configure audio for native platform await NativeAudioManagement.start(); + var didConnect = false; try { await engine.connect( _regionUrl ?? url, @@ -300,6 +315,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 && @@ -322,12 +338,17 @@ class Room extends DisposableChangeNotifier with EventsEmittable { fastConnectOptions: fastConnectOptions, regionUrlProvider: _regionUrlProvider, ); + didConnect = true; } else { rethrow; } } else { rethrow; } + } finally { + if (!didConnect) { + await NativeAudioManagement.stop(); + } } } @@ -1121,7 +1142,8 @@ extension RoomHardwareManagementMethods on Room { roomOptions.defaultCameraCaptureOptions.deviceId ?? Hardware.instance.selectedVideoInput?.deviceId; /// Get mobile device's speaker status. - bool? get speakerOn => roomOptions.defaultAudioOutputOptions.speakerOn; + @Deprecated('Use AudioManager.instance.isSpeakerOutputPreferred instead') + bool? get speakerOn => AudioManager.instance.isSpeakerOutputPreferred; /// Set audio output device. Future setAudioOutputDevice(MediaDevice device) async { @@ -1186,27 +1208,10 @@ 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 - Future setSpeakerOn(bool speakerOn, {bool forceSpeakerOutput = false}) async { - if (lkPlatformIsMobile()) { - await Hardware.instance.setSpeakerphoneOn(speakerOn, forceSpeakerOutput: forceSpeakerOutput); - engine.roomOptions = engine.roomOptions.copyWith( - defaultAudioOutputOptions: roomOptions.defaultAudioOutputOptions.copyWith( - speakerOn: speakerOn, - ), - ); - } - } - - /// Apply audio output device settings. - @internal - Future applyAudioSpeakerSettings() async { - if (roomOptions.defaultAudioOutputOptions.speakerOn != null) { - if (lkPlatformIsMobile()) { - await Hardware.instance.setSpeakerphoneOn(roomOptions.defaultAudioOutputOptions.speakerOn!); - } - } - } + /// or bluetooth is connected. + @Deprecated('Use AudioManager.instance.setSpeakerOutputPreferred instead') + Future setSpeakerOn(bool speakerOn, {bool forceSpeakerOutput = false}) => + AudioManager.instance.setSpeakerOutputPreferred(speakerOn, force: forceSpeakerOutput); Future startAudio() async { try { diff --git a/lib/src/hardware/hardware.dart b/lib/src/hardware/hardware.dart index 065d4f361..6f75f94d1 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,33 @@ class Hardware { MediaDevice? selectedVideoInput; - bool? get speakerOn => _preferSpeakerOutput; + @Deprecated('Use AudioManager.instance.isSpeakerOutputPreferred instead') + bool? get speakerOn => AudioManager.instance.isSpeakerOutputPreferred; - bool _preferSpeakerOutput = true; - - bool get preferSpeakerOutput => _preferSpeakerOutput; - - bool _forceSpeakerOutput = false; + @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 - 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.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 + // 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.managementMode == AudioSessionManagementMode.automatic; + + @Deprecated('Use AudioManager.instance.setAudioSessionManagementMode instead') void setAutomaticConfigurationEnabled({required bool enable}) { - _isAutomaticConfigurationEnabled = enable; + unawaited( + AudioManager.instance.setAudioSessionManagementMode( + enable ? AudioSessionManagementMode.automatic : AudioSessionManagementMode.manual, + ), + ); } Future> enumerateDevices({String? type}) async { @@ -131,48 +136,19 @@ class Hardware { await rtc.Helper.selectAudioInput(device.deviceId); } - @Deprecated('use setSpeakerphoneOn') - Future setPreferSpeakerOutput(bool enable) => setSpeakerphoneOn(enable); + @Deprecated('Use AudioManager.instance.setSpeakerOutputPreferred instead') + Future setPreferSpeakerOutput(bool enable) => AudioManager.instance.setSpeakerOutputPreferred(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'); - } - } + /// or bluetooth is connected. + @Deprecated('Use AudioManager.instance.setSpeakerOutputPreferred instead') + Future setSpeakerphoneOn(bool enable, {bool forceSpeakerOutput = false}) => + AudioManager.instance.setSpeakerOutputPreferred(enable, force: forceSpeakerOutput); Future openCamera({MediaDevice? device, bool? facingMode}) async { final constraints = { diff --git a/lib/src/livekit.dart b/lib/src/livekit.dart index 6cdc2cba8..4adbdd1e5 100644 --- a/lib/src/livekit.dart +++ b/lib/src/livekit.dart @@ -14,24 +14,59 @@ import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc; +import 'audio/audio_manager.dart'; +import 'audio/audio_session.dart'; import 'support/native.dart'; -import 'support/platform.dart' show lkPlatformIsMobile; +import 'support/platform.dart' show PlatformType, lkPlatformIs, lkPlatformIsMobile; +import 'support/webrtc_initialize_options.dart'; /// Main entry point to connect to a room. /// {@category Room} class LiveKitClient { - static const version = '2.8.0'; + static const version = '2.9.0-dev.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, or to apply Android [initialAudioSessionOptions] before WebRTC + /// creates its audio device module. 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 explicit runtime audio-session behavior through [AudioManager] + /// before connecting, e.g. + /// `await AudioManager.instance.setAudioSessionManagementMode(...)` and + /// `await AudioManager.instance.setAudioSessionOptions(...)`. + /// + /// [initialAudioSessionOptions] currently affects Android's WebRTC + /// initialization-time playout attributes, such as media vs voice + /// communication usage. It also seeds [AudioManager]'s initial automatic + /// runtime session policy until the app explicitly replaces it with + /// [AudioManager.setAudioSessionOptions]. A future SDK/WebRTC integration may + /// make those Android playout attributes runtime-updatable; for now, pass them + /// here before WebRTC initializes. + static Future initialize({ + bool bypassVoiceProcessing = false, + AudioSessionOptions? initialAudioSessionOptions, + }) async { if (lkPlatformIsMobile()) { - await rtc.WebRTC.initialize(options: { - if (bypassVoiceProcessing) 'bypassVoiceProcessing': bypassVoiceProcessing, - }); - + // bypassVoiceProcessing controls only WebRTC voice processing. Android + // playout attributes are passed here because WebRTC reads them when it + // creates the audio device module. Native.bypassVoiceProcessing = bypassVoiceProcessing; + await rtc.WebRTC.initialize( + options: liveKitWebRTCInitializeOptions( + bypassVoiceProcessing: bypassVoiceProcessing, + initialAudioSessionOptions: initialAudioSessionOptions, + includeAndroidAudioConfiguration: lkPlatformIs(PlatformType.android), + ), + ); + if (lkPlatformIs(PlatformType.android) && initialAudioSessionOptions != null) { + AudioManager.instance.setInitialAudioSessionOptions(initialAudioSessionOptions); + } } } } diff --git a/lib/src/participant/local.dart b/lib/src/participant/local.dart index e785b72fa..d8e4d41fe 100644 --- a/lib/src/participant/local.dart +++ b/lib/src/participant/local.dart @@ -179,82 +179,94 @@ class LocalParticipant extends Participant { final audioEncoding = publishOptions.encoding ?? AudioEncoding.presetMusic; final List encodings = [audioEncoding.toRTCRtpEncoding()]; - final req = lk_rtc.AddTrackRequest( - cid: track.getCid(), - name: publishOptions.name ?? AudioPublishOptions.defaultMicrophoneName, - type: track.kind.toPBType(), - source: track.source.toPBType(), - muted: track.muted, - stream: buildStreamId(publishOptions, track.source), - disableDtx: !publishOptions.dtx, - disableRed: room.e2eeManager != null ? true : publishOptions.red ?? true, - encryption: room.roomOptions.lkEncryptionType, - ); + final shouldStopOnFailure = !track.isActive; + try { + // Start capture before signaling so create-time audio processing failures + // abort publish without creating a server-side publication. + await track.start(); - // Populate audio features (e.g., TF_NO_DTX, TF_PRECONNECT_BUFFER) - req.audioFeatures.addAll([ - if (!publishOptions.dtx) lk_models.AudioTrackFeature.TF_NO_DTX, - if (publishOptions.preConnect) lk_models.AudioTrackFeature.TF_PRECONNECT_BUFFER, - ]); + final req = lk_rtc.AddTrackRequest( + cid: track.getCid(), + name: publishOptions.name ?? AudioPublishOptions.defaultMicrophoneName, + type: track.kind.toPBType(), + source: track.source.toPBType(), + muted: track.muted, + stream: buildStreamId(publishOptions, track.source), + disableDtx: !publishOptions.dtx, + disableRed: room.e2eeManager != null ? true : publishOptions.red ?? true, + encryption: room.roomOptions.lkEncryptionType, + ); - Future negotiate() async { - track.transceiver = await room.engine.createTransceiverRTCRtpSender(track, publishOptions!, encodings); - await room.engine.negotiate(); - return lk_models.TrackInfo(); - } + // Populate audio features (e.g., TF_NO_DTX, TF_PRECONNECT_BUFFER) + req.audioFeatures.addAll([ + if (!publishOptions.dtx) lk_models.AudioTrackFeature.TF_NO_DTX, + if (publishOptions.preConnect) lk_models.AudioTrackFeature.TF_PRECONNECT_BUFFER, + ]); - late lk_models.TrackInfo trackInfo; - if (room.engine.enabledPublishCodecs?.isNotEmpty ?? false) { - final rets = await Future.wait([room.engine.addTrack(req), negotiate()]); - trackInfo = rets[0]; - } else { - trackInfo = await room.engine.addTrack(req); - - final transceiverInit = rtc.RTCRtpTransceiverInit( - direction: rtc.TransceiverDirection.SendOnly, - sendEncodings: encodings, - ); - // addTransceiver cannot pass in a kind parameter due to a bug in flutter-webrtc (web) - track.transceiver = await room.engine.publisher?.pc.addTransceiver( - track: track.mediaStreamTrack, - kind: rtc.RTCRtpMediaType.RTCRtpMediaTypeAudio, - init: transceiverInit, - ); + Future negotiate() async { + track.transceiver = await room.engine.createTransceiverRTCRtpSender(track, publishOptions!, encodings); + await room.engine.negotiate(); + return lk_models.TrackInfo(); + } - await room.engine.negotiate(); - } + late lk_models.TrackInfo trackInfo; + if (room.engine.enabledPublishCodecs?.isNotEmpty ?? false) { + final rets = await Future.wait([room.engine.addTrack(req), negotiate()]); + trackInfo = rets[0]; + } else { + trackInfo = await room.engine.addTrack(req); - logger.fine('publishAudioTrack engine.addTrack response: ${trackInfo}'); + final transceiverInit = rtc.RTCRtpTransceiverInit( + direction: rtc.TransceiverDirection.SendOnly, + sendEncodings: encodings, + ); + // addTransceiver cannot pass in a kind parameter due to a bug in flutter-webrtc (web) + track.transceiver = await room.engine.publisher?.pc.addTransceiver( + track: track.mediaStreamTrack, + kind: rtc.RTCRtpMediaType.RTCRtpMediaTypeAudio, + init: transceiverInit, + ); - track.lastPublishOptions = publishOptions; + await room.engine.negotiate(); + } - final pub = LocalTrackPublication( - participant: this, - info: trackInfo, - track: track, - ); - addTrackPublication(pub); + logger.fine('publishAudioTrack engine.addTrack response: ${trackInfo}'); - // did publish - await track.onPublish(); - await track.processor?.onPublish(room); + track.lastPublishOptions = publishOptions; - await room.applyAudioSpeakerSettings(); + final pub = LocalTrackPublication( + participant: this, + info: trackInfo, + track: track, + ); + addTrackPublication(pub); - final listener = track.createListener(); - listener.on((TrackEndedEvent event) async { - logger.fine('TrackEndedEvent: ${event.track}'); - await removePublishedTrack(pub.sid); - }); + // did publish + await track.onPublish(); + await track.processor?.onPublish(room); - [events, room.events].emit(LocalTrackPublishedEvent( - participant: this, - publication: pub, - )); + final listener = track.createListener(); + listener.on((TrackEndedEvent event) async { + logger.fine('TrackEndedEvent: ${event.track}'); + await removePublishedTrack(pub.sid); + }); - await track.start(); + [events, room.events].emit(LocalTrackPublishedEvent( + participant: this, + publication: pub, + )); - return pub; + return pub; + } catch (error) { + if (shouldStopOnFailure) { + try { + await track.stop(); + } catch (stopError) { + logger.warning('failed to stop audio track after publish failure: $stopError'); + } + } + rethrow; + } } /// Publish a [LocalVideoTrack] to the [Room]. @@ -386,10 +398,7 @@ class LocalParticipant extends Participant { } if ([TrackSource.camera, TrackSource.screenShareVideo].contains(track.source)) { - final degradationPreference = publishOptions.degradationPreference ?? - getDefaultDegradationPreference( - track, - ); + final degradationPreference = publishOptions.degradationPreference ?? DegradationPreference.maintainResolution; await track.setDegradationPreference(degradationPreference); } @@ -487,10 +496,7 @@ class LocalParticipant extends Participant { } if ([TrackSource.camera, TrackSource.screenShareVideo].contains(track.source)) { - final degradationPreference = publishOptions.degradationPreference ?? - getDefaultDegradationPreference( - track, - ); + final degradationPreference = publishOptions.degradationPreference ?? DegradationPreference.maintainResolution; await track.setDegradationPreference(degradationPreference); } @@ -579,8 +585,6 @@ class LocalParticipant extends Participant { await track.processor?.onUnpublish(); await track.stopProcessor(); } - - await room.applyAudioSpeakerSettings(); } if (notify) { @@ -593,17 +597,6 @@ class LocalParticipant extends Participant { await pub.dispose(); } - DegradationPreference getDefaultDegradationPreference(LocalVideoTrack track) { - // a few of reasons we have different default paths: - // 1. without this, Chrome seems to aggressively resize the SVC video stating `quality-limitation: bandwidth` even when BW isn't an issue - // 2. since we are overriding contentHint to motion (to workaround L1T3 publishing), it overrides the default degradationPreference to `balanced` - final VideoDimensions dimensions = track.currentOptions.params.dimensions; - if (track.source == TrackSource.screenShareVideo || dimensions.height >= 1080) { - return DegradationPreference.maintainResolution; - } - return DegradationPreference.balanced; - } - /// Convenience method to unpublish all tracks. Future unpublishAllTracks({bool notify = true, bool? stopOnUnpublish}) async { final trackSids = trackPublications.keys.toSet(); diff --git a/lib/src/preconnect/pre_connect_audio_buffer.dart b/lib/src/preconnect/pre_connect_audio_buffer.dart index 39cd2fb5a..c04eca8fd 100644 --- a/lib/src/preconnect/pre_connect_audio_buffer.dart +++ b/lib/src/preconnect/pre_connect_audio_buffer.dart @@ -14,9 +14,6 @@ import 'dart:async'; -import 'package:flutter/foundation.dart' show kIsWeb; - -import 'package:flutter_webrtc/flutter_webrtc.dart' as webrtc; import 'package:uuid/uuid.dart'; import '../audio/audio_frame_capture.dart'; @@ -25,6 +22,8 @@ import '../events.dart'; import '../logger.dart'; import '../participant/local.dart'; import '../support/byte_ring_buffer.dart'; +import '../support/native.dart'; +import '../support/platform.dart'; import '../support/reusable_completer.dart'; import '../track/local/audio.dart'; import '../types/data_stream.dart'; @@ -145,9 +144,16 @@ class PreConnectAudioBuffer { throw error; } - if (!kIsWeb) { - await webrtc.NativeAudioManagement.startLocalRecording(); - _nativeRecordingStarted = true; + try { + await _localTrack!.start(); + _nativeRecordingStarted = lkPlatformSupportsExplicitAudioRecordingStart(); + } catch (error) { + logger.severe('[Preconnect audio] failed to start local recording: $error'); + _onError?.call(error); + await stopRecording(withError: error); + await _localTrack?.stop(); + _localTrack = null; + rethrow; } logger.info('startAudioRenderer result: $result'); @@ -212,7 +218,7 @@ class PreConnectAudioBuffer { // Only stop native recording on error, the room's mic track still uses it. if (withError != null && _nativeRecordingStarted) { - await webrtc.NativeAudioManagement.stopLocalRecording(); + await Native.stopLocalRecording(); } _nativeRecordingStarted = false; diff --git a/lib/src/support/native.dart b/lib/src/support/native.dart index 2f3b3e24d..6817a9705 100644 --- a/lib/src/support/native.dart +++ b/lib/src/support/native.dart @@ -14,10 +14,11 @@ import 'dart:async'; -import 'package:flutter/services.dart' show MethodChannel, MethodCall; +import 'package:flutter/services.dart' show MethodChannel, MethodCall, MissingPluginException, PlatformException; 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,29 @@ 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, + bool forceSpeakerOutput = false, + }) async { try { final result = await channel.invokeMethod( 'configureNativeAudio', - configuration.toMap(), + { + ...configuration.toMap(), + 'automatic': automatic, + 'selectCategoryByEngineState': selectCategoryByEngineState, + 'forceSpeakerOutput': forceSpeakerOutput, + }, ); return result == true; } catch (error) { @@ -50,6 +68,169 @@ class Native { } } + /// Applies runtime audio processing options to a local audio track. + /// + /// Resolved natively against the underlying WebRTC audio track owned by + /// flutter_webrtc; [options] is the serialized [AudioProcessingOptions] map. + /// Returns the native result map (`result`/`code`/`message`) so the Dart + /// track API can translate native outcomes into typed exceptions. + /// This plugin is registered on platforms that do not implement runtime audio + /// processing, so missing or explicitly unimplemented hooks are normalized to + /// `rejectedPlatformUnavailable`. Other channel errors propagate because they + /// indicate unexpected native failures rather than an unsupported platform + /// capability. + @internal + static Future> setAudioProcessingOptions( + String trackId, + Map options, + ) async { + try { + final response = await channel.invokeMethod( + 'setAudioProcessingOptions', + { + 'trackId': trackId, + ...options, + }, + ); + if (response is Map) { + return response.map((key, value) => MapEntry(key.toString(), value)); + } + return {}; + } on MissingPluginException { + return _audioProcessingPlatformUnavailable(); + } on PlatformException catch (error) { + // Web registers the plugin but returns `Unimplemented` for methods it + // does not support. Treat only that narrow case like a missing native + // implementation; do not hide unrelated native/channel failures. + if (error.code == 'Unimplemented') { + return _audioProcessingPlatformUnavailable(); + } + rethrow; + } + } + + static Map _audioProcessingPlatformUnavailable() => { + 'result': false, + 'code': 'rejectedPlatformUnavailable', + 'message': 'Audio processing options are unavailable on this platform.', + }; + + static PlatformException _audioProcessingPlatformUnavailableException() => PlatformException( + code: 'rejectedPlatformUnavailable', + message: 'Audio processing options are unavailable on this platform.', + ); + + /// Starts the native WebRTC audio device module recording path with the + /// capture-time audio processing options for the local microphone track. + @internal + static Future startLocalRecording(Map audioProcessingOptions) async { + try { + await channel.invokeMethod( + 'startLocalRecording', + audioProcessingOptions, + ); + } on PlatformException catch (error) { + if (error.code == 'Unimplemented') { + throw _audioProcessingPlatformUnavailableException(); + } + rethrow; + } on MissingPluginException { + throw _audioProcessingPlatformUnavailableException(); + } + } + + /// Stops recording that was explicitly started through [startLocalRecording]. + @internal + static Future stopLocalRecording() async { + try { + await channel.invokeMethod('stopLocalRecording', {}); + } on PlatformException catch (error) { + if (error.code == 'Unimplemented') { + logger.warning('stopLocalRecording is not implemented on this platform'); + return; + } + logger.warning('stopLocalRecording did throw ${error.code}: ${error.message}'); + } on MissingPluginException { + logger.warning('stopLocalRecording is not available on this platform'); + } + } + + /// Reads the engine-wide audio processing state from the native peer + /// connection factory. Returns `null` when unavailable (e.g. the factory + /// does not exist yet, or the platform cannot provide it). + @internal + static Future?> getAudioProcessingState() async { + try { + final response = await channel.invokeMethod( + 'getAudioProcessingState', + {}, + ); + if (response is Map) { + return response.map((key, value) => MapEntry(key.toString(), value)); + } + } catch (error) { + logger.warning('getAudioProcessingState did throw $error'); + } + 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'); + } + } + + /// 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 { + try { + await channel.invokeMethod( + 'setAndroidSpeakerphoneOn', + {'enable': enable, 'force': force}, + ); + } catch (error) { + logger.warning('setAndroidSpeakerphoneOn did throw $error'); + } + } + + /// 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, { @@ -153,6 +334,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..0cce554f0 100644 --- a/lib/src/support/native_audio.dart +++ b/lib/src/support/native_audio.dart @@ -12,38 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -// 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, -} +import '../audio/audio_session.dart' show AppleAudioCategory, AppleAudioCategoryOption, AppleAudioMode; +import 'value_or_absent.dart'; extension AppleAudioCategoryExt on AppleAudioCategory { String toStringValue() => { @@ -85,47 +55,13 @@ class NativeAudioConfiguration { final AppleAudioCategory? appleAudioCategory; final Set? appleAudioCategoryOptions; 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 this.appleAudioCategory, this.appleAudioCategoryOptions, - this.appleAudioMode, - this.preferSpeakerOutput + this.appleAudioMode // Android options // ... }); @@ -135,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({ - AppleAudioCategory? appleAudioCategory, - Set? appleAudioCategoryOptions, - AppleAudioMode? appleAudioMode, - bool? preferSpeakerOutput, + ValueOrAbsent appleAudioCategory = const ValueOrAbsent.absent(), + ValueOrAbsent?> appleAudioCategoryOptions = const ValueOrAbsent.absent(), + ValueOrAbsent appleAudioMode = const ValueOrAbsent.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), ); } diff --git a/lib/src/support/platform.dart b/lib/src/support/platform.dart index 3c3bb05f5..242c0ff01 100644 --- a/lib/src/support/platform.dart +++ b/lib/src/support/platform.dart @@ -29,6 +29,9 @@ bool lkPlatformIsDesktop() => [ PlatformType.linux, ].contains(lkPlatform()); +bool lkPlatformSupportsExplicitAudioRecordingStart() => + !lkPlatformIsTest() && [PlatformType.iOS, PlatformType.macOS, PlatformType.android].contains(lkPlatform()); + bool lkPlatformSupportsE2EE() => lkE2EESupportedImplementation(); bool lkPlatformIsTest() => lkPlatformIsTestImplementation(); diff --git a/lib/src/support/value_or_absent.dart b/lib/src/support/value_or_absent.dart new file mode 100644 index 000000000..ab1abd1ef --- /dev/null +++ b/lib/src/support/value_or_absent.dart @@ -0,0 +1,63 @@ +// 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 ValueOrAbsent.absent(), +/// }) => +/// Example(name: name.valueOr(this.name)); +/// } +/// +/// example.copyWith(); // keep existing name +/// example.copyWith(name: ValueOrAbsent.value('room')); // set name +/// example.copyWith(name: ValueOrAbsent.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); +} + +final class _Value extends ValueOrAbsent { + const _Value(this.value); + + final T value; + + @override + T valueOr(T other) => value; +} + +final class _Absent extends ValueOrAbsent { + const _Absent(); + + @override + T valueOr(T other) => other; +} diff --git a/lib/src/support/webrtc_initialize_options.dart b/lib/src/support/webrtc_initialize_options.dart new file mode 100644 index 000000000..f902641b6 --- /dev/null +++ b/lib/src/support/webrtc_initialize_options.dart @@ -0,0 +1,30 @@ +// 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:meta/meta.dart'; + +import '../audio/android_audio_session_adapter.dart'; +import '../audio/audio_session.dart'; + +@internal +Map liveKitWebRTCInitializeOptions({ + required bool bypassVoiceProcessing, + required AudioSessionOptions? initialAudioSessionOptions, + required bool includeAndroidAudioConfiguration, +}) => + { + if (bypassVoiceProcessing) 'bypassVoiceProcessing': bypassVoiceProcessing, + if (includeAndroidAudioConfiguration && initialAudioSessionOptions != null) + 'androidAudioConfiguration': androidAudioSessionConfigurationToMap(initialAudioSessionOptions.android), + }; diff --git a/lib/src/token_source/room_configuration.dart b/lib/src/token_source/room_configuration.dart index 24ce8af8b..82a3898b1 100644 --- a/lib/src/token_source/room_configuration.dart +++ b/lib/src/token_source/room_configuration.dart @@ -26,9 +26,13 @@ class RoomAgentDispatch { /// Metadata for the agent. final String? metadata; + /// Optional deployment to target. Leave empty to target the production deployment. + final String? deployment; + const RoomAgentDispatch({ this.agentName, this.metadata, + this.deployment, }); factory RoomAgentDispatch.fromJson(Map json) => _$RoomAgentDispatchFromJson(json); diff --git a/lib/src/token_source/room_configuration.g.dart b/lib/src/token_source/room_configuration.g.dart index c7517a575..9bfaee4d2 100644 --- a/lib/src/token_source/room_configuration.g.dart +++ b/lib/src/token_source/room_configuration.g.dart @@ -9,11 +9,13 @@ part of 'room_configuration.dart'; RoomAgentDispatch _$RoomAgentDispatchFromJson(Map json) => RoomAgentDispatch( agentName: json['agent_name'] as String?, metadata: json['metadata'] as String?, + deployment: json['deployment'] as String?, ); Map _$RoomAgentDispatchToJson(RoomAgentDispatch instance) => { if (instance.agentName case final value?) 'agent_name': value, if (instance.metadata case final value?) 'metadata': value, + if (instance.deployment case final value?) 'deployment': value, }; RoomConfiguration _$RoomConfigurationFromJson(Map json) => RoomConfiguration( diff --git a/lib/src/token_source/token_source.dart b/lib/src/token_source/token_source.dart index f2f4e8304..392c62557 100644 --- a/lib/src/token_source/token_source.dart +++ b/lib/src/token_source/token_source.dart @@ -43,6 +43,9 @@ class TokenRequestOptions { /// Metadata passed to the agent job. final String? agentMetadata; + /// Optional deployment to target. Leave empty to target the production deployment. + final String? agentDeployment; + const TokenRequestOptions({ this.roomName, this.participantName, @@ -51,6 +54,7 @@ class TokenRequestOptions { this.participantAttributes, this.agentName, this.agentMetadata, + this.agentDeployment, }); factory TokenRequestOptions.fromJson(Map json) => _$TokenRequestOptionsFromJson(json); @@ -58,8 +62,8 @@ class TokenRequestOptions { /// Converts this options object to a wire-format request. TokenSourceRequest toRequest() { - final List? agents = (agentName != null || agentMetadata != null) - ? [RoomAgentDispatch(agentName: agentName, metadata: agentMetadata)] + final List? agents = (agentName != null || agentMetadata != null || agentDeployment != null) + ? [RoomAgentDispatch(agentName: agentName, metadata: agentMetadata, deployment: agentDeployment)] : null; return TokenSourceRequest( @@ -83,6 +87,7 @@ class TokenRequestOptions { other.participantMetadata == participantMetadata && other.agentName == agentName && other.agentMetadata == agentMetadata && + other.agentDeployment == agentDeployment && const MapEquality().equals(other.participantAttributes, participantAttributes); } @@ -95,6 +100,7 @@ class TokenRequestOptions { participantMetadata, agentName, agentMetadata, + agentDeployment, const MapEquality().hash(participantAttributes), ); } diff --git a/lib/src/token_source/token_source.g.dart b/lib/src/token_source/token_source.g.dart index a5ac7308e..ec930e18c 100644 --- a/lib/src/token_source/token_source.g.dart +++ b/lib/src/token_source/token_source.g.dart @@ -16,6 +16,7 @@ TokenRequestOptions _$TokenRequestOptionsFromJson(Map json) => ), agentName: json['agentName'] as String?, agentMetadata: json['agentMetadata'] as String?, + agentDeployment: json['agentDeployment'] as String?, ); Map _$TokenRequestOptionsToJson(TokenRequestOptions instance) => { @@ -26,6 +27,7 @@ Map _$TokenRequestOptionsToJson(TokenRequestOptions instance) = if (instance.participantAttributes case final value?) 'participantAttributes': value, if (instance.agentName case final value?) 'agentName': value, if (instance.agentMetadata case final value?) 'agentMetadata': value, + if (instance.agentDeployment case final value?) 'agentDeployment': value, }; TokenSourceRequest _$TokenSourceRequestFromJson(Map json) => TokenSourceRequest( diff --git a/lib/src/track/audio_management.dart b/lib/src/track/audio_management.dart index f287ca03b..085c63781 100644 --- a/lib/src/track/audio_management.dart +++ b/lib/src/track/audio_management.dart @@ -12,170 +12,28 @@ // 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 '../logger.dart'; +import '../audio/audio_manager.dart'; +import '../audio/audio_session.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; - } +@Deprecated('Audio session lifecycle is managed by AudioManager instead') +mixin LocalAudioManagementMixin on LocalTrack, AudioTrack {} - @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; - logger.fine('didUpdateSate: $_audioTrackState'); - - NativeAudioConfiguration? config; - if (lkPlatformIs(PlatformType.iOS)) { - // Only iOS for now... - config = await onConfigureNativeAudio.call(_audioTrackState); - - if (Hardware.instance.forceSpeakerOutput) { - config = config.copyWith( - appleAudioCategoryOptions: { - ...?config.appleAudioCategoryOptions, - AppleAudioCategoryOption.defaultToSpeaker, - }, - ); - } - } - - if (config != null) { - logger.fine('configuring for ${_audioTrackState} using ${config}...'); - try { - if (Hardware.instance.isAutomaticConfigurationEnabled) { - 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 && Hardware.instance.preferSpeakerOutput) { - return NativeAudioConfiguration.playback; - } - - return Hardware.instance.preferSpeakerOutput - ? NativeAudioConfiguration.playAndRecordSpeaker - : NativeAudioConfiguration.playAndRecordReceiver; -} +@Deprecated('Audio session lifecycle is managed by AudioManager instead') +mixin RemoteAudioManagementMixin on RemoteTrack, AudioTrack {} 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.managementMode == AudioSessionManagementMode.automatic) { + await Native.stopAndroidAudioSession(); } } } diff --git a/lib/src/track/local/audio.dart b/lib/src/track/local/audio.dart index e3675a340..77966d2b5 100644 --- a/lib/src/track/local/audio.dart +++ b/lib/src/track/local/audio.dart @@ -14,24 +14,29 @@ import 'dart:async'; +import 'package:flutter/services.dart' show PlatformException; + import 'package:collection/collection.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc; import 'package:meta/meta.dart'; import '../../events.dart'; +import '../../internal/events.dart'; import '../../logger.dart'; import '../../options.dart'; import '../../stats/audio_source_stats.dart'; import '../../stats/stats.dart'; +import '../../support/native.dart'; +import '../../support/platform.dart'; import '../../types/other.dart'; import '../audio_management.dart'; -import '../options.dart'; +import '../options.dart' as track_options; import 'local.dart'; class LocalAudioTrack extends LocalTrack with AudioTrack, LocalAudioManagementMixin { // Options used for this track @override - covariant AudioCaptureOptions currentOptions; + covariant track_options.AudioCaptureOptions currentOptions; AudioPublishOptions? lastPublishOptions; @@ -45,11 +50,50 @@ class LocalAudioTrack extends LocalTrack with AudioTrack, LocalAudioManagementMi } } + /// Applies runtime audio processing options to this local audio track. + /// + /// On success, updates [currentOptions] and emits + /// [LocalTrackOptionsUpdatedEvent]. When the native layer cannot apply the + /// options, throws [track_options.AudioProcessingException] and leaves + /// [currentOptions] unchanged. + Future setAudioProcessingOptions(track_options.AudioProcessingOptions options) async { + final nextOptions = currentOptions.copyWith(processing: options); + final response = await Native.setAudioProcessingOptions( + mediaStreamTrack.id!, + options.toMap(), + ); + + _throwIfAudioProcessingFailed(response); + + currentOptions = nextOptions; + events.emit(LocalTrackOptionsUpdatedEvent( + track: this, + options: currentOptions, + )); + } + num? _currentBitrate; num? get currentBitrate => _currentBitrate; AudioSenderStats? prevStats; + @override + Future startCapture() async { + await super.startCapture(); + if (lkPlatformSupportsExplicitAudioRecordingStart()) { + try { + // Match Swift: start the ADM before publishing so capture-time audio + // processing options are applied before WebRTC opens the microphone. + await Native.startLocalRecording(currentOptions.processing.toMap()); + } on PlatformException catch (error) { + throw track_options.AudioProcessingException( + _audioProcessingFailureReason(error.code), + error.message ?? '', + ); + } + } + } + @override Future monitorStats() async { if (events.isDisposed || !isActive) { @@ -126,9 +170,9 @@ class LocalAudioTrack extends LocalTrack with AudioTrack, LocalAudioManagementMi /// Creates a new audio track from the default audio input device. static Future create([ - AudioCaptureOptions? options, + track_options.AudioCaptureOptions? options, ]) async { - options ??= const AudioCaptureOptions(); + options ??= const track_options.AudioCaptureOptions(); final stream = await LocalTrack.createStream(options); final track = LocalAudioTrack( @@ -138,10 +182,69 @@ class LocalAudioTrack extends LocalTrack with AudioTrack, LocalAudioManagementMi options, ); - if (options.processor != null) { - await track.setProcessor(options.processor); + try { + if (options.processor != null) { + await track.setProcessor(options.processor); + } + } catch (error, stackTrace) { + try { + await track.stop(); + } catch (stopError) { + logger.warning('failed to stop audio track after processor setup failure: $stopError'); + } + Error.throwWithStackTrace(error, stackTrace); } return track; } } + +void _throwIfAudioProcessingFailed(Map response) { + final code = response['code'] as String?; + final message = (response['message'] as String?) ?? ''; + + final reason = _audioProcessingFailureReason(code); + switch (code) { + case 'applied': + case 'stored': + return; + case 'rejectedInvalidCombination': + case 'rejectedPlatformUnavailable': + case 'applyFailed': + case 'unknown': + case 'rejectedRemoteTrack': + throw track_options.AudioProcessingException( + reason, + message, + ); + default: + throw track_options.AudioProcessingException( + track_options.AudioProcessingFailureReason.unknown, + _unknownAudioProcessingMessage(code, message), + ); + } +} + +track_options.AudioProcessingFailureReason _audioProcessingFailureReason(String? code) { + switch (code) { + case 'rejectedInvalidCombination': + return track_options.AudioProcessingFailureReason.invalidCombination; + case 'rejectedPlatformUnavailable': + return track_options.AudioProcessingFailureReason.platformUnavailable; + case 'applyFailed': + return track_options.AudioProcessingFailureReason.applyFailed; + default: + return track_options.AudioProcessingFailureReason.unknown; + } +} + +String _unknownAudioProcessingMessage(String? code, String message) { + final trimmed = message.trim(); + if (trimmed.isNotEmpty) { + return trimmed; + } + if (code != null && code.isNotEmpty) { + return 'Unknown audio processing result code: $code.'; + } + return ''; +} diff --git a/lib/src/track/local/local.dart b/lib/src/track/local/local.dart index 1b197c50f..3a3cea8c6 100644 --- a/lib/src/track/local/local.dart +++ b/lib/src/track/local/local.dart @@ -96,13 +96,13 @@ mixin AudioTrack on Track { } @override - Future onStarted() async { - logger.fine('AudioTrack.onStarted()'); + Future startCapture() async { + logger.fine('AudioTrack.startCapture()'); } @override - Future onStopped() async { - logger.fine('AudioTrack.onStopped()'); + Future stopCapture() async { + logger.fine('AudioTrack.stopCapture()'); for (final group in _captureGroups.values) { await group.stop(); } diff --git a/lib/src/track/options.dart b/lib/src/track/options.dart index d5bfc5bbf..58aaa6e1a 100644 --- a/lib/src/track/options.dart +++ b/lib/src/track/options.dart @@ -242,8 +242,79 @@ abstract class VideoCaptureOptions extends LocalTrackOptions { Map toMediaConstraintsMap() => params.toMediaConstraintsMap(); } +/// Selects whether a voice-processing component uses platform or software processing. +enum AudioProcessingMode { + automatic('auto'), + platform('platform'), + software('software'); + + const AudioProcessingMode(this.constraintValue); + + final String constraintValue; +} + +/// Runtime voice-processing options for a [LocalAudioTrack]. +/// +/// These values update the native local audio source without restarting +/// capture. When the track is being sent, the native WebRTC sender reapplies +/// the updated processing config. The effective audio processing module config +/// is shared by the native voice engine/channel, so conflicting updates from +/// multiple local tracks are not isolated per track. +class AudioProcessingOptions { + const AudioProcessingOptions({ + required this.echoCancellation, + required this.noiseSuppression, + required this.autoGainControl, + required this.highPassFilter, + this.echoCancellationMode = AudioProcessingMode.automatic, + this.noiseSuppressionMode = AudioProcessingMode.automatic, + this.autoGainControlMode = AudioProcessingMode.automatic, + this.highPassFilterMode = AudioProcessingMode.automatic, + }); + + const AudioProcessingOptions.communication() + : echoCancellation = true, + noiseSuppression = true, + autoGainControl = true, + highPassFilter = true, + echoCancellationMode = AudioProcessingMode.automatic, + noiseSuppressionMode = AudioProcessingMode.automatic, + autoGainControlMode = AudioProcessingMode.automatic, + highPassFilterMode = AudioProcessingMode.automatic; + + const AudioProcessingOptions.noProcessing() + : echoCancellation = false, + noiseSuppression = false, + autoGainControl = false, + highPassFilter = false, + echoCancellationMode = AudioProcessingMode.automatic, + noiseSuppressionMode = AudioProcessingMode.automatic, + autoGainControlMode = AudioProcessingMode.automatic, + highPassFilterMode = AudioProcessingMode.automatic; + + final bool echoCancellation; + final bool noiseSuppression; + final bool autoGainControl; + final bool highPassFilter; + final AudioProcessingMode echoCancellationMode; + final AudioProcessingMode noiseSuppressionMode; + final AudioProcessingMode autoGainControlMode; + final AudioProcessingMode highPassFilterMode; + + Map toMap() => { + 'echoCancellation': echoCancellation, + 'noiseSuppression': noiseSuppression, + 'autoGainControl': autoGainControl, + 'highPassFilter': highPassFilter, + 'echoCancellationMode': echoCancellationMode.constraintValue, + 'noiseSuppressionMode': noiseSuppressionMode.constraintValue, + 'autoGainControlMode': autoGainControlMode.constraintValue, + 'highPassFilterMode': highPassFilterMode.constraintValue, + }; +} + /// Options used when creating a [LocalAudioTrack]. -class AudioCaptureOptions extends LocalTrackOptions { +class AudioCaptureOptions extends LocalTrackOptions implements AudioProcessingOptions { /// The deviceId of the capture device to use. /// Available deviceIds can be obtained through `flutter_webrtc`: /// ``` @@ -256,22 +327,38 @@ class AudioCaptureOptions extends LocalTrackOptions { /// Attempt to use noiseSuppression option (if supported by the platform) /// See https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackSettings/noiseSuppression /// Defaults to true. + @override final bool noiseSuppression; /// Attempt to use echoCancellation option (if supported by the platform) /// See https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackSettings/echoCancellation /// Defaults to true. + @override final bool echoCancellation; /// Attempt to use autoGainControl option (if supported by the platform) /// See https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints/autoGainControl /// Defaults to true. + @override final bool autoGainControl; /// Attempt to use highPassFilter options (if supported by the platform) /// Defaults to false. + @override final bool highPassFilter; + @override + final AudioProcessingMode echoCancellationMode; + + @override + final AudioProcessingMode noiseSuppressionMode; + + @override + final AudioProcessingMode autoGainControlMode; + + @override + final AudioProcessingMode highPassFilterMode; + /// Attempt to use typingNoiseDetection option (if supported by the platform) /// Defaults to true. final bool typingNoiseDetection; @@ -292,12 +379,30 @@ class AudioCaptureOptions extends LocalTrackOptions { this.echoCancellation = true, this.autoGainControl = true, this.highPassFilter = false, + this.echoCancellationMode = AudioProcessingMode.automatic, + this.noiseSuppressionMode = AudioProcessingMode.automatic, + this.autoGainControlMode = AudioProcessingMode.automatic, + this.highPassFilterMode = AudioProcessingMode.automatic, this.voiceIsolation = true, this.typingNoiseDetection = true, this.stopAudioCaptureOnMute = true, this.processor, }); + AudioProcessingOptions get processing => AudioProcessingOptions( + echoCancellation: echoCancellation, + noiseSuppression: noiseSuppression, + autoGainControl: autoGainControl, + highPassFilter: highPassFilter, + echoCancellationMode: echoCancellationMode, + noiseSuppressionMode: noiseSuppressionMode, + autoGainControlMode: autoGainControlMode, + highPassFilterMode: highPassFilterMode, + ); + + @override + Map toMap() => processing.toMap(); + @override Map toMediaConstraintsMap() { final constraints = {}; @@ -358,15 +463,30 @@ class AudioCaptureOptions extends LocalTrackOptions { bool? echoCancellation, bool? autoGainControl, bool? highPassFilter, + AudioProcessingMode? echoCancellationMode, + AudioProcessingMode? noiseSuppressionMode, + AudioProcessingMode? autoGainControlMode, + AudioProcessingMode? highPassFilterMode, + AudioProcessingOptions? processing, + bool? voiceIsolation, bool? typingNoiseDetection, + bool? stopAudioCaptureOnMute, + TrackProcessor? processor, }) { return AudioCaptureOptions( deviceId: deviceId ?? this.deviceId, - noiseSuppression: noiseSuppression ?? this.noiseSuppression, - echoCancellation: echoCancellation ?? this.echoCancellation, - autoGainControl: autoGainControl ?? this.autoGainControl, - highPassFilter: highPassFilter ?? this.highPassFilter, + noiseSuppression: processing?.noiseSuppression ?? noiseSuppression ?? this.noiseSuppression, + echoCancellation: processing?.echoCancellation ?? echoCancellation ?? this.echoCancellation, + autoGainControl: processing?.autoGainControl ?? autoGainControl ?? this.autoGainControl, + highPassFilter: processing?.highPassFilter ?? highPassFilter ?? this.highPassFilter, + echoCancellationMode: processing?.echoCancellationMode ?? echoCancellationMode ?? this.echoCancellationMode, + noiseSuppressionMode: processing?.noiseSuppressionMode ?? noiseSuppressionMode ?? this.noiseSuppressionMode, + autoGainControlMode: processing?.autoGainControlMode ?? autoGainControlMode ?? this.autoGainControlMode, + highPassFilterMode: processing?.highPassFilterMode ?? highPassFilterMode ?? this.highPassFilterMode, + voiceIsolation: voiceIsolation ?? this.voiceIsolation, typingNoiseDetection: typingNoiseDetection ?? this.typingNoiseDetection, + stopAudioCaptureOnMute: stopAudioCaptureOnMute ?? this.stopAudioCaptureOnMute, + processor: processor ?? this.processor, ); } } @@ -388,3 +508,50 @@ class AudioOutputOptions { ); } } + +/// Reason that applying [AudioProcessingOptions] failed. +enum AudioProcessingFailureReason { + /// The requested mode combination is invalid for the native audio module. + invalidCombination, + + /// The platform or device cannot provide the requested processing path. + platformUnavailable, + + /// The native layer attempted to apply the options but failed. + applyFailed, + + /// The native layer returned an unrecognized or malformed result. + unknown, +} + +String _defaultAudioProcessingMessage(AudioProcessingFailureReason reason) { + switch (reason) { + case AudioProcessingFailureReason.invalidCombination: + return 'The requested audio processing mode combination is invalid.'; + case AudioProcessingFailureReason.platformUnavailable: + return 'Audio processing options are unavailable on this platform or device.'; + case AudioProcessingFailureReason.applyFailed: + return 'The native WebRTC audio processing module could not apply the requested options.'; + case AudioProcessingFailureReason.unknown: + return 'Audio processing options failed for an unknown reason.'; + } +} + +String _audioProcessingMessageOrDefault(AudioProcessingFailureReason reason, String message) { + final trimmed = message.trim(); + return trimmed.isEmpty ? _defaultAudioProcessingMessage(reason) : trimmed; +} + +/// Thrown when [AudioProcessingOptions] cannot be applied. +/// +/// [reason] is a stable SDK category. [message] carries native details when +/// available, or an SDK-provided fallback when native does not include details. +class AudioProcessingException implements Exception { + AudioProcessingException(this.reason, String message) : message = _audioProcessingMessageOrDefault(reason, message); + + final AudioProcessingFailureReason reason; + final String message; + + @override + String toString() => 'AudioProcessingException(${reason.name}): $message'; +} diff --git a/lib/src/track/track.dart b/lib/src/track/track.dart index 520a69169..3e029b82b 100644 --- a/lib/src/track/track.dart +++ b/lib/src/track/track.dart @@ -110,11 +110,10 @@ abstract class Track extends DisposableChangeNotifier with EventsEmittable monitorStats(); + /// Called by [start] before this track is marked active. @internal - Future onStarted() async {} + Future startCapture() async {} + /// Called by [stop] while this track is still active, before the media track + /// is stopped. @internal - Future onStopped() async {} + Future stopCapture() async {} @internal void startMonitor() { diff --git a/macos/livekit_client.podspec b/macos/livekit_client.podspec index 16c318b85..44e2ead05 100644 --- a/macos/livekit_client.podspec +++ b/macos/livekit_client.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'livekit_client' - s.version = '2.8.0' + s.version = '2.9.0-dev.0' s.summary = 'Open source platform for real-time audio and video.' s.description = 'Open source platform for real-time audio and video.' s.homepage = 'https://livekit.io/' @@ -16,6 +16,6 @@ Pod::Spec.new do |s| s.static_framework = true s.dependency 'FlutterMacOS' - s.dependency 'WebRTC-SDK', '144.7559.01' + s.dependency 'WebRTC-SDK', '144.7559.09' s.dependency 'flutter_webrtc' end diff --git a/pubspec.lock b/pubspec.lock index 39350a0a9..4e81c9ecc 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -284,10 +284,10 @@ packages: dependency: "direct main" description: name: flutter_webrtc - sha256: "8b220dc006c4891266735e516f7679bd08b7caaf7c36b1a93fb9357cec555f92" + sha256: "0f89dee7f4c35dab5611f351c3897a3bfe9f7057720cc7da209b52455af4316e" url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.5.2" frontend_server_client: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 2c8783473..70703b46a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,7 +15,7 @@ name: livekit_client description: Flutter Client SDK for LiveKit. Build real-time video and audio into your apps. Supports iOS, Android, and Web. -version: 2.8.0 +version: 2.9.0-dev.0 homepage: https://github.com/livekit/client-sdk-flutter environment: @@ -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.4.0 + flutter_webrtc: 1.5.2 dart_webrtc: ^1.8.0 dev_dependencies: diff --git a/shared_swift/LiveKitPlugin.swift b/shared_swift/LiveKitPlugin.swift index a2d8d8639..2c6ae0ecf 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 @@ -68,6 +74,21 @@ 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) + + // 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 @@ -310,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 @@ -324,50 +343,58 @@ 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() - - 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 + let forceSpeakerOutput = args["forceSpeakerOutput"] as? Bool ?? false + audioEngineObserver?.updatePolicy(configuration, + automaticManagementEnabled: automatic, + selectCategoryByEngineState: selectCategoryByEngineState, + forceSpeakerOutput: forceSpeakerOutput) + + let shouldApplyNow = !automatic || (audioEngineObserver?.isSessionActive ?? false) + guard shouldApplyNow else { + result(true) + return } - // always `unlock()` when exiting scope, calling multiple times has no side-effect - defer { - unlock() + // 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 + } - 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) - } + 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 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) - } catch { - print("[LiveKit] Configure audio error: ", error) - result(FlutterError(code: "configure", message: error.localizedDescription, details: nil)) } #endif } @@ -388,6 +415,169 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { return versions.map { String($0) }.joined(separator: ".") } + public func handleSetAudioProcessingOptions(args: [String: Any?], result: @escaping FlutterResult) { + guard let trackId = args["trackId"] as? String else { + result(FlutterError(code: "setAudioProcessingOptions", message: "trackId is required", details: nil)) + return + } + + let webrtc = FlutterWebRTCPlugin.sharedSingleton() + guard let localTrack = webrtc?.localTracks?[trackId] as? LocalAudioTrack, + let audioTrack = localTrack.track() as? RTCAudioTrack + else { + result(FlutterError(code: "setAudioProcessingOptions", message: "track is not a local audio track", details: nil)) + return + } + + let options = LiveKitPlugin.audioProcessingOptions(from: args) + let processingResult = audioTrack.setAudioProcessingOptions(options) + result([ + "result": processingResult.isSuccess, + "code": LiveKitPlugin.audioProcessingResultCodeString(processingResult.code), + "message": processingResult.message, + ]) + } + + public func handleStartLocalRecording(args: [String: Any?], result: @escaping FlutterResult) { + guard let adm = FlutterWebRTCPlugin.sharedSingleton()?.peerConnectionFactory?.audioDeviceModule else { + result(FlutterError(code: "rejectedPlatformUnavailable", message: "audio device module is unavailable", details: nil)) + return + } + + let options = LiveKitPlugin.audioProcessingOptions(from: args) + DispatchQueue.global(qos: .userInitiated).async { + let admResult = adm.initAndStartRecording(audioProcessingOptions: options) + DispatchQueue.main.async { + if admResult == 0 { + result(nil) + } else { + result(FlutterError( + code: "applyFailed", + message: "Audio engine returned error code: \(admResult)", + details: nil + )) + } + } + } + } + + public func handleStopLocalRecording(result: @escaping FlutterResult) { + guard let adm = FlutterWebRTCPlugin.sharedSingleton()?.peerConnectionFactory?.audioDeviceModule else { + result(FlutterError(code: "stopLocalRecording", message: "audio device module is unavailable", details: nil)) + return + } + + DispatchQueue.global(qos: .userInitiated).async { + let admResult = adm.stopRecording() + DispatchQueue.main.async { + if admResult == 0 { + result(nil) + } else { + result(FlutterError( + code: "stopLocalRecording", + message: "Audio engine returned error code: \(admResult)", + details: nil + )) + } + } + } + } + + static func audioProcessingOptions(from args: [String: Any?]) -> RTCAudioProcessingOptions { + RTCAudioProcessingOptions( + echoCancellationOptions: RTCAudioProcessingComponentOptions( + enabled: (args["echoCancellation"] as? Bool) ?? true, + mode: LiveKitPlugin.audioProcessingMode(from: args["echoCancellationMode"] as? String) + ), + noiseSuppressionOptions: RTCAudioProcessingComponentOptions( + enabled: (args["noiseSuppression"] as? Bool) ?? true, + mode: LiveKitPlugin.audioProcessingMode(from: args["noiseSuppressionMode"] as? String) + ), + autoGainControlOptions: RTCAudioProcessingComponentOptions( + enabled: (args["autoGainControl"] as? Bool) ?? true, + mode: LiveKitPlugin.audioProcessingMode(from: args["autoGainControlMode"] as? String) + ), + highPassFilterOptions: RTCAudioProcessingComponentOptions( + enabled: (args["highPassFilter"] as? Bool) ?? false, + mode: LiveKitPlugin.audioProcessingMode(from: args["highPassFilterMode"] as? String) + ) + ) + } + + static func audioProcessingMode(from string: String?) -> RTCAudioProcessingMode { + switch string { + case "platform": return .platform + case "software": return .software + default: return .automatic + } + } + + static func audioProcessingResultCodeString(_ code: RTCAudioProcessingOptionsResultCode) -> String { + switch code { + case .applied: return "applied" + case .stored: return "stored" + case .rejectedRemoteTrack: return "unknown" + case .rejectedInvalidCombination: return "rejectedInvalidCombination" + case .rejectedPlatformUnavailable: return "rejectedPlatformUnavailable" + case .applyFailed: return "applyFailed" + @unknown default: return "unknown" + } + } + + public func handleGetAudioProcessingState(result: @escaping FlutterResult) { + guard let factory = FlutterWebRTCPlugin.sharedSingleton()?.peerConnectionFactory else { + result(nil) + return + } + result(LiveKitPlugin.toMap(state: factory.audioProcessingState)) + } + + static func audioProcessingModeString(_ mode: RTCAudioProcessingMode) -> String { + switch mode { + case .platform: return "platform" + case .software: return "software" + default: return "auto" + } + } + + static func audioProcessingImplementationString(_ implementation: RTCAudioProcessingImplementation) -> String { + switch implementation { + case .disabled: return "disabled" + case .software: return "software" + case .platform: return "platform" + case .softwareAndPlatform: return "softwareAndPlatform" + default: return "unknown" + } + } + + static func toMap(component state: RTCAudioProcessingComponentState) -> [String: Any] { + var map: [String: Any] = [ + "isSoftwareResolved": state.isSoftwareResolved, + "isSoftwareActive": state.isSoftwareActive, + "isPlatformAvailable": state.isPlatformAvailable, + "isPlatformResolved": state.isPlatformResolved, + "isPlatformActive": state.isPlatformActive, + "effective": audioProcessingImplementationString(state.effective), + ] + if let requested = state.requested { + map["requested"] = [ + "enabled": requested.isEnabled, + "mode": audioProcessingModeString(requested.mode), + ] + } + return map + } + + static func toMap(state: RTCAudioProcessingState) -> [String: Any] { + [ + "hasAudioProcessingModule": state.hasAudioProcessingModule, + "echoCancellation": toMap(component: state.echoCancellation), + "noiseSuppression": toMap(component: state.noiseSuppression), + "autoGainControl": toMap(component: state.autoGainControl), + "highPassFilter": toMap(component: state.highPassFilter), + ] + } + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { guard let args = call.arguments as? [String: Any?] else { print("[LiveKit] arguments must be a dictionary") @@ -398,6 +588,10 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { switch call.method { case "configureNativeAudio": 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": @@ -406,6 +600,14 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { handleStartAudioRenderer(args: args, result: result) case "stopAudioRenderer": handleStopAudioRenderer(args: args, result: result) + case "startLocalRecording": + handleStartLocalRecording(args: args, result: result) + case "stopLocalRecording": + handleStopLocalRecording(result: result) + case "setAudioProcessingOptions": + handleSetAudioProcessingOptions(args: args, result: result) + case "getAudioProcessingState": + handleGetAudioProcessingState(result: result) case "osVersionString": result(LiveKitPlugin.osVersionString()) #if os(iOS) @@ -422,3 +624,272 @@ 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, + forceSpeakerOutput: Bool, + active: Bool) -> Error? { + let rtcSession = RTCAudioSession.sharedInstance() + rtcSession.lockForConfiguration() + defer { rtcSession.unlockForConfiguration() } + do { + try rtcSession.setConfiguration(configuration, active: active) + // overrideOutputAudioPort hard-routes to the speaker even over a + // 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 { + try rtcSession.overrideOutputAudioPort(forceSpeakerOutput ? .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? + // 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 forceSpeakerOutput = false + private var isAutomaticManagementEnabled = true + // True when cached policy changes should apply immediately. This includes + // an engine already running under manual mode, because switching back to + // automatic should configure the live session without waiting for another + // engine lifecycle event. + private var sessionActive = false + // Last recording 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 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, where the + /// delegate callbacks apply it. Callers decide whether to apply immediately. + func updatePolicy(_ configuration: RTCAudioSessionConfiguration, + automaticManagementEnabled: 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() + } + + /// 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 forceSpeakerOutput = self.forceSpeakerOutput + lock.unlock() + guard let configuration else { return nil } + return LiveKitPlugin.applyAudioSessionConfiguration(configuration, + forceSpeakerOutput: forceSpeakerOutput, + active: true) + } + + private func applyManagedConfiguration(isRecordingEnabled: Bool) -> Error? { + lock.lock() + let shouldManageSession = isAutomaticManagementEnabled + let configuration = effectiveConfigurationLocked(isRecordingEnabled: isRecordingEnabled) + let forceSpeakerOutput = self.forceSpeakerOutput + lock.unlock() + + guard shouldManageSession, let configuration else { return nil } + return LiveKitPlugin.applyAudioSessionConfiguration(configuration, + forceSpeakerOutput: forceSpeakerOutput, + active: true) + } + + private func recordEngineState(isPlayoutEnabled: Bool, isRecordingEnabled: Bool) { + lock.lock() + lastIsRecordingEnabled = isRecordingEnabled + sessionActive = isPlayoutEnabled || isRecordingEnabled + lock.unlock() + } + + /// 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 { + if let error = applyManagedConfiguration(isRecordingEnabled: isRecordingEnabled) { + print("[LiveKit] AudioEngine willEnable: failed to configure audio session: \(error)") + resultCode = LiveKitPlugin.kAudioEngineErrorFailedToConfigureAudioSession + } + if resultCode == 0 { + recordEngineState(isPlayoutEnabled: isPlayoutEnabled, isRecordingEnabled: isRecordingEnabled) + } + } + #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 { + // A disable event can leave one side of the engine running (for + // example, mic off while remote playout continues). Re-apply so + // dynamic category selection follows the new engine state. + if let error = applyManagedConfiguration(isRecordingEnabled: isRecordingEnabled) { + print("[LiveKit] AudioEngine didDisable: failed to configure audio session: \(error)") + resultCode = LiveKitPlugin.kAudioEngineErrorFailedToConfigureAudioSession + } + } else { + lock.lock() + let shouldManageSession = isAutomaticManagementEnabled + lock.unlock() + + if shouldManageSession, let error = LiveKitPlugin.deactivateAudioSession() { + // 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 + } + } + if resultCode == 0 { + recordEngineState(isPlayoutEnabled: isPlayoutEnabled, isRecordingEnabled: isRecordingEnabled) + } + #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, + ]) + } + } +} diff --git a/test/audio/audio_session_test.dart b/test/audio/audio_session_test.dart new file mode 100644 index 000000000..583f27995 --- /dev/null +++ b/test/audio/audio_session_test.dart @@ -0,0 +1,827 @@ +// 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/services.dart'; + +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/audio/audio_session_policy.dart'; +import 'package:livekit_client/src/support/native.dart'; +import 'package:livekit_client/src/support/native_audio.dart' as native_audio; +import 'package:livekit_client/src/support/webrtc_initialize_options.dart'; +import 'package:livekit_client/src/track/options.dart' as track_options; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + AudioManager.instance.resetForTest(); + Native.bypassVoiceProcessing = false; + }); + + native_audio.NativeAudioConfiguration resolveApplePolicy( + AudioSessionOptions options, { + bool preferSpeakerOutput = true, + bool forceSpeakerOutput = false, + bool automatic = true, + }) => + ResolvedAudioSessionPolicy( + options: options, + preferSpeakerOutput: preferSpeakerOutput, + forceSpeakerOutput: forceSpeakerOutput && preferSpeakerOutput, + automatic: automatic, + ).appleConfiguration; + + AndroidAudioSessionConfiguration resolveAndroidPolicy( + AudioSessionOptions options, { + bool automatic = true, + }) => + ResolvedAudioSessionPolicy( + options: options, + preferSpeakerOutput: AudioManager.instance.isSpeakerOutputPreferred, + forceSpeakerOutput: AudioManager.instance.isSpeakerOutputForced, + automatic: automatic, + ).androidConfiguration; + + group('AudioSessionManagementMode', () { + test('supports automatic and manual management', () { + expect( + AudioSessionManagementMode.values, + [ + AudioSessionManagementMode.automatic, + AudioSessionManagementMode.manual, + ], + ); + }); + }); + + group('AudioSessionOptions', () { + test('communication constructor pre-fills platform configs', () { + const options = AudioSessionOptions.communication(); + + expect(options.apple.category, AppleAudioCategory.playAndRecord); + expect( + options.apple.categoryOptions, + { + AppleAudioCategoryOption.allowBluetooth, + AppleAudioCategoryOption.allowBluetoothA2DP, + AppleAudioCategoryOption.allowAirPlay, + }, + ); + expect(options.apple.mode, AppleAudioMode.videoChat); + expect(options.android.audioMode, AndroidAudioMode.inCommunication); + expect(options.android.streamType, AndroidAudioStreamType.voiceCall); + }); + + test('mediaPlayback constructor pre-fills platform configs', () { + const options = AudioSessionOptions.mediaPlayback(); + + expect(options.apple.category, AppleAudioCategory.playback); + expect(options.apple.categoryOptions, {AppleAudioCategoryOption.mixWithOthers}); + expect(options.apple.mode, AppleAudioMode.spokenAudio); + expect(options.android.audioMode, AndroidAudioMode.normal); + expect(options.android.streamType, AndroidAudioStreamType.music); + expect(options.android.forceAudioRouting, isNull); + }); + + test('copyWith replaces platform configs', () { + const options = AudioSessionOptions.communication(); + + final updated = options.copyWith( + apple: const ValueOrAbsent.value( + AppleAudioSessionConfiguration( + mode: AppleAudioMode.voiceChat, + ), + ), + ); + + expect(updated.apple.category, isNull); + expect(updated.apple.mode, AppleAudioMode.voiceChat); + expect(updated.android.audioMode, AndroidAudioMode.inCommunication); + + final restored = updated.copyWith( + apple: const ValueOrAbsent.value(AppleAudioSessionConfiguration.communication), + android: const ValueOrAbsent.value(AndroidAudioSessionConfiguration.communication), + ); + + expect(restored.apple.category, AppleAudioCategory.playAndRecord); + expect(restored.apple.mode, AppleAudioMode.videoChat); + expect(restored.android.audioMode, AndroidAudioMode.inCommunication); + }); + + test('Apple configuration copyWith updates and clears nullable fields', () { + const config = AppleAudioSessionConfiguration( + category: AppleAudioCategory.playAndRecord, + categoryOptions: {AppleAudioCategoryOption.allowBluetooth}, + mode: AppleAudioMode.voiceChat, + ); + + final updated = config.copyWith( + category: const ValueOrAbsent.value(AppleAudioCategory.playback), + categoryOptions: const ValueOrAbsent.value({AppleAudioCategoryOption.mixWithOthers}), + mode: const ValueOrAbsent.value(AppleAudioMode.spokenAudio), + ); + + expect(updated.category, AppleAudioCategory.playback); + expect(updated.categoryOptions, {AppleAudioCategoryOption.mixWithOthers}); + expect(updated.mode, AppleAudioMode.spokenAudio); + + final cleared = updated.copyWith( + category: const ValueOrAbsent.value(null), + categoryOptions: const ValueOrAbsent.value(null), + mode: const ValueOrAbsent.value(null), + ); + + expect(cleared.category, isNull); + expect(cleared.categoryOptions, isNull); + expect(cleared.mode, 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 ValueOrAbsent.value(AndroidAudioMode.normal), + manageAudioFocus: const ValueOrAbsent.value(false), + focusMode: const ValueOrAbsent.value(AndroidAudioFocusMode.gainTransient), + streamType: const ValueOrAbsent.value(AndroidAudioStreamType.music), + usageType: const ValueOrAbsent.value(AndroidAudioAttributesUsageType.media), + contentType: const ValueOrAbsent.value(AndroidAudioAttributesContentType.unknown), + forceAudioRouting: const ValueOrAbsent.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 ValueOrAbsent.value(null), + manageAudioFocus: const ValueOrAbsent.value(null), + focusMode: const ValueOrAbsent.value(null), + streamType: const ValueOrAbsent.value(null), + usageType: const ValueOrAbsent.value(null), + contentType: const ValueOrAbsent.value(null), + forceAudioRouting: const ValueOrAbsent.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('AudioProcessingException', () { + test('uses fallback messages when native omits details', () { + final invalid = track_options.AudioProcessingException( + track_options.AudioProcessingFailureReason.invalidCombination, + '', + ); + final platformUnavailable = track_options.AudioProcessingException( + track_options.AudioProcessingFailureReason.platformUnavailable, + ' ', + ); + final applyFailed = track_options.AudioProcessingException( + track_options.AudioProcessingFailureReason.applyFailed, + '', + ); + final unknown = track_options.AudioProcessingException( + track_options.AudioProcessingFailureReason.unknown, + '', + ); + + expect(invalid.message, 'The requested audio processing mode combination is invalid.'); + expect(platformUnavailable.message, 'Audio processing options are unavailable on this platform or device.'); + expect( + applyFailed.message, + 'The native WebRTC audio processing module could not apply the requested options.', + ); + expect(unknown.message, 'Audio processing options failed for an unknown reason.'); + }); + + test('preserves native messages when provided', () { + final error = track_options.AudioProcessingException( + track_options.AudioProcessingFailureReason.applyFailed, + ' native detail ', + ); + + expect(error.reason, track_options.AudioProcessingFailureReason.applyFailed); + expect(error.message, 'native detail'); + expect(error.toString(), 'AudioProcessingException(applyFailed): native detail'); + }); + + test('exposes unknown failure reason', () { + expect( + track_options.AudioProcessingFailureReason.values, + contains(track_options.AudioProcessingFailureReason.unknown), + ); + }); + }); + + group('AudioManager', () { + test('management mode can be set independently from options', () async { + final manager = AudioManager.instance; + + await manager.setAudioSessionManagementMode(AudioSessionManagementMode.manual); + + expect(manager.managementMode, AudioSessionManagementMode.manual); + expect(manager.options.android.audioMode, AndroidAudioMode.inCommunication); + + await manager.setAudioSessionManagementMode(AudioSessionManagementMode.automatic); + }); + + test('setAudioSessionOptions switches management to manual', () async { + final manager = AudioManager.instance; + expect(manager.managementMode, AudioSessionManagementMode.automatic); + + await manager.setAudioSessionOptions(const AudioSessionOptions.mediaPlayback()); + + expect(manager.managementMode, AudioSessionManagementMode.manual); + expect(manager.options.apple.category, AppleAudioCategory.playback); + expect(manager.options.android.streamType, AndroidAudioStreamType.music); + }); + + test('setInitialAudioSessionOptions seeds options without switching to manual', () { + final manager = AudioManager.instance; + + manager.setInitialAudioSessionOptions(const AudioSessionOptions.mediaPlayback()); + + expect(manager.managementMode, AudioSessionManagementMode.automatic); + expect(manager.options.android.streamType, AndroidAudioStreamType.music); + + final android = resolveAndroidPolicy(manager.options); + expect(android.audioMode, AndroidAudioMode.normal); + expect(android.streamType, AndroidAudioStreamType.music); + }); + + test('setInitialAudioSessionOptions does not replace manual options', () async { + final manager = AudioManager.instance; + await manager.setAudioSessionOptions(const AudioSessionOptions.communication()); + + manager.setInitialAudioSessionOptions(const AudioSessionOptions.mediaPlayback()); + + expect(manager.managementMode, AudioSessionManagementMode.manual); + expect(manager.options.android.streamType, AndroidAudioStreamType.voiceCall); + }); + + test('deactivateAudioSession switches management to manual', () async { + final manager = AudioManager.instance; + expect(manager.managementMode, AudioSessionManagementMode.automatic); + + await manager.deactivateAudioSession(); + + expect(manager.managementMode, AudioSessionManagementMode.manual); + }); + + test('setAudioSessionOptions preserves the runtime speaker preference', () async { + final manager = AudioManager.instance; + + // The speaker preference is runtime state owned by + // setSpeakerOutputPreferred, so changing the session intent must not reset + // it. + expect(manager.isSpeakerOutputPreferred, isTrue); + + await manager.setAudioSessionOptions( + const AudioSessionOptions.mediaPlayback(), + ); + expect(manager.isSpeakerOutputPreferred, isTrue); + + await manager.setAudioSessionOptions( + const AudioSessionOptions.communication(), + ); + expect(manager.isSpeakerOutputPreferred, isTrue); + }); + + test('resolves communication Apple session policy from speaker preference', () { + final speaker = resolveApplePolicy( + const AudioSessionOptions.communication(), + preferSpeakerOutput: true, + ); + + expect(speaker.appleAudioCategory, AppleAudioCategory.playAndRecord); + expect(speaker.appleAudioMode, AppleAudioMode.videoChat); + expect( + speaker.appleAudioCategoryOptions, + { + AppleAudioCategoryOption.allowBluetooth, + AppleAudioCategoryOption.allowBluetoothA2DP, + AppleAudioCategoryOption.allowAirPlay, + }, + ); + + final receiver = resolveApplePolicy( + const AudioSessionOptions.communication(), + preferSpeakerOutput: false, + ); + + expect(receiver.appleAudioCategory, AppleAudioCategory.playAndRecord); + expect(receiver.appleAudioMode, AppleAudioMode.voiceChat); + }); + + test('automatic Apple policy ignores manual media options', () { + final config = resolveApplePolicy( + const AudioSessionOptions.mediaPlayback(), + ); + + expect(config.appleAudioCategory, AppleAudioCategory.playAndRecord); + expect(config.appleAudioMode, AppleAudioMode.videoChat); + expect( + config.appleAudioCategoryOptions, + { + AppleAudioCategoryOption.allowBluetooth, + AppleAudioCategoryOption.allowBluetoothA2DP, + AppleAudioCategoryOption.allowAirPlay, + }, + ); + }); + + test('resolves manual media Apple session policy as fixed playback', () { + final config = resolveApplePolicy( + const AudioSessionOptions.mediaPlayback(), + automatic: false, + ); + + expect(config.appleAudioCategory, AppleAudioCategory.playback); + expect(config.appleAudioMode, AppleAudioMode.spokenAudio); + expect(config.appleAudioCategoryOptions, {AppleAudioCategoryOption.mixWithOthers}); + }); + + test('forced speaker does not mutate Apple category options', () { + final playback = resolveApplePolicy( + const AudioSessionOptions.mediaPlayback( + apple: AppleAudioSessionConfiguration( + category: AppleAudioCategory.playback, + categoryOptions: {AppleAudioCategoryOption.mixWithOthers}, + ), + ), + automatic: false, + forceSpeakerOutput: true, + ); + + expect(playback.appleAudioCategory, AppleAudioCategory.playback); + expect(playback.appleAudioCategoryOptions, {AppleAudioCategoryOption.mixWithOthers}); + + final playAndRecord = resolveApplePolicy( + const AudioSessionOptions.communication( + apple: AppleAudioSessionConfiguration( + category: AppleAudioCategory.playAndRecord, + categoryOptions: {AppleAudioCategoryOption.allowBluetooth}, + ), + ), + automatic: false, + forceSpeakerOutput: true, + ); + + expect(playAndRecord.appleAudioCategory, AppleAudioCategory.playAndRecord); + expect( + playAndRecord.appleAudioCategoryOptions, + {AppleAudioCategoryOption.allowBluetooth}, + ); + }); + + test('resolves Android session policy from current options', () { + final automaticMedia = resolveAndroidPolicy( + const AudioSessionOptions.mediaPlayback(), + ); + + expect(automaticMedia.audioMode, AndroidAudioMode.normal); + expect(automaticMedia.streamType, AndroidAudioStreamType.music); + + final media = resolveAndroidPolicy( + const AudioSessionOptions.mediaPlayback(), + automatic: false, + ); + + expect(media.audioMode, AndroidAudioMode.normal); + expect(media.streamType, AndroidAudioStreamType.music); + + final explicit = resolveAndroidPolicy( + const AudioSessionOptions.communication( + android: AndroidAudioSessionConfiguration( + audioMode: AndroidAudioMode.normal, + forceAudioRouting: true, + ), + ), + automatic: false, + ); + + expect(explicit.audioMode, AndroidAudioMode.normal); + expect(explicit.forceAudioRouting, isTrue); + }); + + test('automatic Apple policy ignores stored options while Android uses them', () async { + final manager = AudioManager.instance; + + await manager.setAudioSessionOptions(const AudioSessionOptions.mediaPlayback()); + await manager.setAudioSessionManagementMode(AudioSessionManagementMode.automatic); + + final isAutomatic = manager.managementMode == AudioSessionManagementMode.automatic; + final apple = resolveApplePolicy( + manager.options, + automatic: isAutomatic, + preferSpeakerOutput: false, + ); + final android = resolveAndroidPolicy( + manager.options, + automatic: isAutomatic, + ); + + expect(apple.appleAudioCategory, AppleAudioCategory.playAndRecord); + expect(apple.appleAudioMode, AppleAudioMode.voiceChat); + expect( + apple.appleAudioCategoryOptions, + { + AppleAudioCategoryOption.allowBluetooth, + AppleAudioCategoryOption.allowBluetoothA2DP, + AppleAudioCategoryOption.allowAirPlay, + }, + ); + expect(android.audioMode, AndroidAudioMode.normal); + expect(android.streamType, AndroidAudioStreamType.music); + }); + + 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(); + }); + }); + + 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); + expect(config.forceAudioRouting, isNull); + }); + }); + + group('NativeAudioConfiguration', () { + test('serializes Apple audio wire format', () { + final map = native_audio.NativeAudioConfiguration( + appleAudioCategory: AppleAudioCategory.playAndRecord, + appleAudioCategoryOptions: { + AppleAudioCategoryOption.allowBluetooth, + AppleAudioCategoryOption.defaultToSpeaker, + }, + appleAudioMode: AppleAudioMode.default_, + ).toMap(); + + expect(map['appleAudioCategory'], 'playAndRecord'); + expect( + map['appleAudioCategoryOptions'], + unorderedEquals([ + 'allowBluetooth', + 'defaultToSpeaker', + ]), + ); + expect(map['appleAudioMode'], 'default'); + expect(map.containsKey('preferSpeakerOutput'), isFalse); + }); + }); + + 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 Android platform method', () async { + await Native.setAndroidSpeakerphoneOn(true, force: true); + + expect(calls.single.method, 'setAndroidSpeakerphoneOn'); + 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( + 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), + ); + }); + + test('returns platform unavailable when audio processing channel is missing', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + Native.channel, + null, + ); + + final result = await Native.setAudioProcessingOptions( + 'track-id', + {'echoCancellation': true}, + ); + + expect( + result, + { + 'result': false, + 'code': 'rejectedPlatformUnavailable', + 'message': 'Audio processing options are unavailable on this platform.', + }, + ); + }); + + test('returns platform unavailable when audio processing method is unimplemented', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + Native.channel, + (call) async { + calls.add(call); + throw PlatformException( + code: 'Unimplemented', + details: 'livekit for web does not implement ${call.method}', + ); + }, + ); + + final result = await Native.setAudioProcessingOptions( + 'track-id', + {'echoCancellation': true}, + ); + + expect(calls.single.method, 'setAudioProcessingOptions'); + expect(calls.single.arguments, containsPair('trackId', 'track-id')); + expect( + result, + { + 'result': false, + 'code': 'rejectedPlatformUnavailable', + 'message': 'Audio processing options are unavailable on this platform.', + }, + ); + }); + + test('propagates other audio processing channel failures', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + Native.channel, + (call) async { + throw PlatformException(code: 'nativeFailure', message: 'boom'); + }, + ); + + await expectLater( + Native.setAudioProcessingOptions( + 'track-id', + {'echoCancellation': true}, + ), + throwsA(isA().having((error) => error.code, 'code', 'nativeFailure')), + ); + }); + + test('throws platform unavailable when startLocalRecording channel is missing', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + Native.channel, + null, + ); + + await expectLater( + Native.startLocalRecording({'echoCancellation': true}), + throwsA(isA().having( + (error) => error.code, + 'code', + 'rejectedPlatformUnavailable', + )), + ); + }); + + test('throws platform unavailable when startLocalRecording is unimplemented', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + Native.channel, + (call) async { + calls.add(call); + throw PlatformException(code: 'Unimplemented'); + }, + ); + + await expectLater( + Native.startLocalRecording({'echoCancellation': true}), + throwsA(isA().having( + (error) => error.code, + 'code', + 'rejectedPlatformUnavailable', + )), + ); + expect(calls.single.method, 'startLocalRecording'); + }); + + test('ignores stopLocalRecording platform failures', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + Native.channel, + (call) async { + calls.add(call); + throw PlatformException(code: 'nativeFailure', message: 'boom'); + }, + ); + + await Native.stopLocalRecording(); + + expect(calls.single.method, 'stopLocalRecording'); + }); + + test('ignores stopLocalRecording missing plugin', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + Native.channel, + null, + ); + + await Native.stopLocalRecording(); + }); + }); + + group('androidAudioSessionConfigurationToMap', () { + test('serializes communication preset for the native session manager', () { + 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, + }, + ); + }); + + test('serializes media preset without forced routing', () { + expect( + androidAudioSessionConfigurationToMap(AndroidAudioSessionConfiguration.media), + { + 'manageAudioFocus': true, + 'androidAudioMode': 'normal', + 'androidAudioFocusMode': 'gain', + 'androidAudioStreamType': 'music', + 'androidAudioAttributesUsageType': 'media', + 'androidAudioAttributesContentType': 'unknown', + }, + ); + }); + }); + + group('liveKitWebRTCInitializeOptions', () { + test('includes Android audio configuration for Android startup', () { + expect( + liveKitWebRTCInitializeOptions( + bypassVoiceProcessing: true, + initialAudioSessionOptions: const AudioSessionOptions.mediaPlayback(), + includeAndroidAudioConfiguration: true, + ), + { + 'bypassVoiceProcessing': true, + 'androidAudioConfiguration': { + 'manageAudioFocus': true, + 'androidAudioMode': 'normal', + 'androidAudioFocusMode': 'gain', + 'androidAudioStreamType': 'music', + 'androidAudioAttributesUsageType': 'media', + 'androidAudioAttributesContentType': 'unknown', + }, + }, + ); + }); + + test('omits Android audio configuration on non-Android startup', () { + expect( + liveKitWebRTCInitializeOptions( + bypassVoiceProcessing: false, + initialAudioSessionOptions: const AudioSessionOptions.mediaPlayback(), + includeAndroidAudioConfiguration: false, + ), + isEmpty, + ); + }); + }); +}