Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
10b03b6
feat(audio): AudioManager audio session management
hiroshihorie Jun 2, 2026
4d7ebdc
chore: add changeset for AudioManager audio session management
hiroshihorie Jun 12, 2026
003f693
chore(deps): bump flutter_webrtc to 1.5.1
hiroshihorie Jun 14, 2026
c31ebb9
feat(audio): add session configuration API
hiroshihorie Jun 15, 2026
3e0d3bf
feat(apple): manage audio session from engine lifecycle
hiroshihorie Jun 15, 2026
23f4871
fix(android): serialize audio switch lifecycle
hiroshihorie Jun 15, 2026
b5a83a3
refactor(audio): remove stale track counting
hiroshihorie Jun 15, 2026
3e237d2
test(audio): cover session configuration behavior
hiroshihorie Jun 15, 2026
115c43b
docs(audio): note session management migration
hiroshihorie Jun 15, 2026
65e8a0b
adjustments
hiroshihorie Jun 15, 2026
e35b036
docs(apple): clarify sessionActive comment on deactivate failure
hiroshihorie Jun 16, 2026
f7de7f7
refactor(audio): move Apple audio enums to public audio types
hiroshihorie Jun 16, 2026
2a5830d
docs(audio): add audio session guide and link from README
hiroshihorie Jun 16, 2026
93b8284
fix(apple): honor headset priority for non-forced speaker preference
hiroshihorie Jun 16, 2026
964cbbf
refactor(audio): rename speaker API to setSpeakerOutputPreferred
hiroshihorie Jun 16, 2026
c0b14df
refactor(audio): drop unused preferSpeakerOutput from native wire config
hiroshihorie Jun 16, 2026
0b5656c
fix(android): honor headset priority for non-forced speaker preference
hiroshihorie Jun 16, 2026
41d6eb6
fix(audio): honor forced speaker routing on mobile
hiroshihorie Jun 16, 2026
24427e9
style(audio): sort audio session test imports
hiroshihorie Jun 16, 2026
a9ebb17
fix(audio): address audio manager review comments
hiroshihorie Jun 16, 2026
feacfb4
fix(audio): add explicit session deactivation API
hiroshihorie Jun 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changes/audio-manager-api
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
minor type="added" "AudioManager audio session options with engine-driven native lifecycle and platform routing controls"
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,16 @@ Widget build(BuildContext context) {

Audio tracks are played automatically as long as you are subscribed to them.

LiveKit owns the platform audio session through `AudioManager`, which lets you choose a session intent, route to the speaker, and pin per platform values. See the [audio session guide](https://github.com/livekit/client-sdk-flutter/blob/main/docs/audio.md) for examples covering communication and media modes, speaker routing, manual mode, per platform overrides, and migration from the older `Hardware` APIs.

```dart
// Enter call mode and prefer the speaker
await AudioManager.instance.setAudioSessionOptions(
const AudioSessionOptions.communication(),
);
await AudioManager.instance.setSpeakerOutputPreferred(true);
```

### Handling changes

LiveKit client makes it simple to build declarative UI that reacts to state changes. It notifies changes in two ways
Expand Down
4 changes: 4 additions & 0 deletions android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ allprojects {
repositories {
google()
mavenCentral()
maven { url 'https://jitpack.io' }
}
}

Expand Down Expand Up @@ -61,6 +62,9 @@ android {
testImplementation("org.mockito:mockito-core:5.0.0")
implementation 'io.github.webrtc-sdk:android:144.7559.09'
implementation 'io.livekit:noise:2.0.0'
// Audio device/focus/mode routing. Pinned to the same revision used by
// the LiveKit Android SDK (AudioSwitchHandler).
implementation 'com.github.davidliu:audioswitch:039a35aefab7747c557242fa216c9ea11743b604'
Comment on lines +65 to +67

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. This one is a deliberate tradeoff, and it matches the rest of the LiveKit stack. The same audioswitch commit is pinned by both flutter_webrtc and the LiveKit Android SDK, so dependency resolution stays consistent end to end. A JitPack artifact is immutable per commit, so the pin is reproducible in practice, and the main residual risk is JitPack availability rather than the artifact changing under us.

The fork is needed for its CommDeviceAudioSwitch, which provides the API 31 setCommunicationDevice routing path, and that class has no tagged release or MavenCentral coordinate to point at yet. If upstream publishes a release that carries it, we would happily switch over.

}

testOptions {
Expand Down
265 changes: 265 additions & 0 deletions android/src/main/kotlin/io/livekit/plugin/LKAudioSwitchManager.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
/*
* Copyright 2026 LiveKit, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package io.livekit.plugin

import android.content.Context
import android.media.AudioAttributes
import android.media.AudioManager
import android.os.Build
import android.os.Handler
import android.os.HandlerThread
import com.twilio.audioswitch.AbstractAudioSwitch
import com.twilio.audioswitch.AudioDevice
import com.twilio.audioswitch.AudioSwitch
import com.twilio.audioswitch.CommDeviceAudioSwitch
import com.twilio.audioswitch.LegacyAudioSwitch

/**
* Manages the Android platform audio session (audio mode, audio focus, and
* output routing) for the LiveKit Flutter SDK, built on top of [AudioSwitch].
*
* This is LiveKit's own port of the audio-handling best practices from the
* LiveKit Android SDK (`AudioSwitchHandler`) and flutter_webrtc
* (`AudioSwitchManager`), so the Flutter SDK can own the platform audio session
* directly instead of delegating to flutter_webrtc's native audio management.
*
* [AudioSwitch] is not thread-safe, so every interaction with it runs on a
* single dedicated [HandlerThread].
*/
internal class LKAudioSwitchManager(private val context: Context) {
// AudioSwitch is not threadsafe, so confine all access to a single long-lived
// thread. The AudioSwitch instance is recreated per active session, while
// queued lifecycle work stays serialized on this thread.
private val thread = HandlerThread("LKAudioSwitchThread").also { it.start() }
private val handler = Handler(thread.looper)

private var audioSwitch: AbstractAudioSwitch? = null
private var isActive = false

// Configuration. Defaults mirror a communication/VoIP session and match the
// AudioSwitchHandler defaults in the LiveKit Android SDK.
private val loggingEnabled = false
private var manageAudioFocus = true
private var audioMode = AudioManager.MODE_IN_COMMUNICATION
private var focusMode = AudioManager.AUDIOFOCUS_GAIN
private var audioStreamType = AudioManager.STREAM_VOICE_CALL
private var audioAttributeUsageType = AudioAttributes.USAGE_VOICE_COMMUNICATION
private var audioAttributeContentType = AudioAttributes.CONTENT_TYPE_SPEECH
private var forceHandleAudioRouting = false

private var speakerOutputPreferred = true
private var speakerOutputForced = false
private var preferredDeviceList = preferredDeviceList()

/**
* Apply an audio session configuration. Unspecified keys keep their current
* value. Changes are applied to a running [AudioSwitch] without a restart.
*/
@Synchronized
fun configure(configuration: Map<String, Any?>) {
(configuration["manageAudioFocus"] as? Boolean)?.let { manageAudioFocus = it }
audioModeForName(configuration["androidAudioMode"] as? String)?.let { audioMode = it }
focusModeForName(configuration["androidAudioFocusMode"] as? String)?.let { focusMode = it }
streamTypeForName(configuration["androidAudioStreamType"] as? String)?.let { audioStreamType = it }
usageTypeForName(configuration["androidAudioAttributesUsageType"] as? String)?.let { audioAttributeUsageType = it }
contentTypeForName(configuration["androidAudioAttributesContentType"] as? String)?.let { audioAttributeContentType = it }
(configuration["forceHandleAudioRouting"] as? Boolean)?.let { forceHandleAudioRouting = it }

// Apply to a live switch so reconfiguration (e.g. communication -> media)
// does not require a restart. No-op until the switch exists.
handler.post { audioSwitch?.let { applyConfiguration(it) } }
}

/** Create (if needed) and activate the audio session: acquire focus, set mode and routing. */
@Synchronized
fun start() {
handler.post {
val switch = audioSwitch ?: createSwitch().also { audioSwitch = it }
if (!isActive) {
switch.activate()
applySpeakerRouting(switch)
isActive = true
}
}
}

/** Deactivate and tear down the audio session: release focus and restore the previous mode. */
@Synchronized
fun stop() {
handler.post {
audioSwitch?.stop()
audioSwitch = null
isActive = false
}
}

/** Final cleanup when the plugin detaches. The manager must not be used after this. */
@Synchronized
fun dispose() {
handler.post {
audioSwitch?.stop()
audioSwitch = null
isActive = false
thread.quitSafely()
}
}

/**
* Prefer routing to/from the speaker, letting a connected headset keep priority
* unless [force] is true.
*/
@Synchronized
fun setSpeakerphoneOn(enable: Boolean, force: Boolean) {
speakerOutputPreferred = enable
speakerOutputForced = enable && force
preferredDeviceList = preferredDeviceList()
handler.post {
val switch = audioSwitch ?: return@post
applySpeakerRouting(switch)
}
}

private fun createSwitch(): AbstractAudioSwitch {
val focusListener = AudioManager.OnAudioFocusChangeListener { }
// API-aware switch selection, matching the LiveKit Android SDK's
// AudioSwitchHandler: CommDeviceAudioSwitch uses the modern
// AudioManager.setCommunicationDevice routing on API 31+.
val switch = when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ->
CommDeviceAudioSwitch(context, loggingEnabled, focusListener, preferredDeviceList)

Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ->
AudioSwitch(context, loggingEnabled, focusListener, preferredDeviceList)

else ->
LegacyAudioSwitch(context, loggingEnabled, focusListener, preferredDeviceList)
}
applyConfiguration(switch)
switch.start { _, _ -> }
return switch
}

private fun applyConfiguration(switch: AbstractAudioSwitch) {
switch.manageAudioFocus = manageAudioFocus
switch.audioMode = audioMode
switch.focusMode = focusMode
switch.audioStreamType = audioStreamType
switch.audioAttributeUsageType = audioAttributeUsageType
switch.audioAttributeContentType = audioAttributeContentType
switch.forceHandleAudioRouting = forceHandleAudioRouting
}

private fun applySpeakerRouting(switch: AbstractAudioSwitch) {
switch.setPreferredDeviceList(preferredDeviceList)
val forcedSpeaker = if (speakerOutputForced) {
switch.availableAudioDevices.firstOrNull { it is AudioDevice.Speakerphone }
} else {
null
}
// AudioSwitch selections are sticky. Use them only for forced speaker output;
// clearing the selection lets the preferred-device list handle normal routing
// and headset hot-plug priority.
switch.selectDevice(forcedSpeaker)
}

private fun preferredDeviceList(): List<Class<out AudioDevice>> =
when {
speakerOutputForced -> listOf(
AudioDevice.Speakerphone::class.java,
AudioDevice.BluetoothHeadset::class.java,
AudioDevice.WiredHeadset::class.java,
AudioDevice.Earpiece::class.java,
)

speakerOutputPreferred -> listOf(
AudioDevice.BluetoothHeadset::class.java,
AudioDevice.WiredHeadset::class.java,
AudioDevice.Speakerphone::class.java,
AudioDevice.Earpiece::class.java,
)

else -> listOf(
AudioDevice.BluetoothHeadset::class.java,
AudioDevice.WiredHeadset::class.java,
AudioDevice.Earpiece::class.java,
AudioDevice.Speakerphone::class.java,
)
}
}

// Map the Flutter-side enum names (see android_audio_session_adapter.dart) to
// Android framework constants. Ported from flutter_webrtc's AudioUtils.

private fun audioModeForName(name: String?): Int? = when (name) {
null -> null
"normal" -> AudioManager.MODE_NORMAL
"callScreening" -> AudioManager.MODE_CALL_SCREENING
"inCall" -> AudioManager.MODE_IN_CALL
"inCommunication" -> AudioManager.MODE_IN_COMMUNICATION
"ringtone" -> AudioManager.MODE_RINGTONE
else -> null
}

private fun focusModeForName(name: String?): Int? = when (name) {
null -> null
"gain" -> AudioManager.AUDIOFOCUS_GAIN
"gainTransient" -> AudioManager.AUDIOFOCUS_GAIN_TRANSIENT
"gainTransientExclusive" -> AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE
"gainTransientMayDuck" -> AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK
else -> null
}

private fun streamTypeForName(name: String?): Int? = when (name) {
null -> null
"accessibility" -> AudioManager.STREAM_ACCESSIBILITY
"alarm" -> AudioManager.STREAM_ALARM
"dtmf" -> AudioManager.STREAM_DTMF
"music" -> AudioManager.STREAM_MUSIC
"notification" -> AudioManager.STREAM_NOTIFICATION
"ring" -> AudioManager.STREAM_RING
"system" -> AudioManager.STREAM_SYSTEM
"voiceCall" -> AudioManager.STREAM_VOICE_CALL
else -> null
}

private fun usageTypeForName(name: String?): Int? = when (name) {
null -> null
"alarm" -> AudioAttributes.USAGE_ALARM
"assistanceAccessibility" -> AudioAttributes.USAGE_ASSISTANCE_ACCESSIBILITY
"assistanceNavigationGuidance" -> AudioAttributes.USAGE_ASSISTANCE_NAVIGATION_GUIDANCE
"assistanceSonification" -> AudioAttributes.USAGE_ASSISTANCE_SONIFICATION
"assistant" -> AudioAttributes.USAGE_ASSISTANT
"game" -> AudioAttributes.USAGE_GAME
"media" -> AudioAttributes.USAGE_MEDIA
"notification" -> AudioAttributes.USAGE_NOTIFICATION
"notificationEvent" -> AudioAttributes.USAGE_NOTIFICATION_EVENT
"notificationRingtone" -> AudioAttributes.USAGE_NOTIFICATION_RINGTONE
"unknown" -> AudioAttributes.USAGE_UNKNOWN
"voiceCommunication" -> AudioAttributes.USAGE_VOICE_COMMUNICATION
"voiceCommunicationSignalling" -> AudioAttributes.USAGE_VOICE_COMMUNICATION_SIGNALLING
else -> null
}

private fun contentTypeForName(name: String?): Int? = when (name) {
null -> null
"movie" -> AudioAttributes.CONTENT_TYPE_MOVIE
"music" -> AudioAttributes.CONTENT_TYPE_MUSIC
"sonification" -> AudioAttributes.CONTENT_TYPE_SONIFICATION
"speech" -> AudioAttributes.CONTENT_TYPE_SPEECH
"unknown" -> AudioAttributes.CONTENT_TYPE_UNKNOWN
else -> null
}
29 changes: 29 additions & 0 deletions android/src/main/kotlin/io/livekit/plugin/LiveKitPlugin.kt
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import io.flutter.plugin.common.MethodChannel.Result

import com.cloudwebrtc.webrtc.FlutterWebRTCPlugin
import com.cloudwebrtc.webrtc.audio.AudioSwitchManager
import com.cloudwebrtc.webrtc.audio.LocalAudioTrack
import io.flutter.plugin.common.BinaryMessenger
import org.webrtc.AudioTrack
Expand All @@ -42,6 +43,7 @@ class LiveKitPlugin : FlutterPlugin, MethodCallHandler {
private var audioProcessors = mutableMapOf<String, AudioProcessors>()
private var flutterWebRTCPlugin = FlutterWebRTCPlugin.sharedSingleton
private var binaryMessenger: BinaryMessenger? = null
private var audioSwitchManager: LKAudioSwitchManager? = null

/// The MethodChannel that will the communication between Flutter and native Android
///
Expand All @@ -50,9 +52,13 @@ class LiveKitPlugin : FlutterPlugin, MethodCallHandler {
private lateinit var channel: MethodChannel

override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
// LiveKit owns the platform audio session, so disable flutter_webrtc's own
// native audio management. Set at registration, before any audio op.
AudioSwitchManager.setAudioSessionManagementEnabled(false)
channel = MethodChannel(flutterPluginBinding.binaryMessenger, "livekit_client")
channel.setMethodCallHandler(this)
binaryMessenger = flutterPluginBinding.binaryMessenger
audioSwitchManager = LKAudioSwitchManager(flutterPluginBinding.applicationContext)
}

@SuppressLint("SuspiciousIndentation")
Expand Down Expand Up @@ -350,6 +356,26 @@ class LiveKitPlugin : FlutterPlugin, MethodCallHandler {
handleGetAudioProcessingState(result)
}

"configureAndroidAudioSession" -> {
@Suppress("UNCHECKED_CAST")
val configuration = call.arguments as? Map<String, Any?> ?: emptyMap()
audioSwitchManager?.configure(configuration)
audioSwitchManager?.start()
result.success(null)
}
Comment on lines +359 to +365

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good observation. The coupling here is intentional on Android. Unlike iOS, the Android side has no audio engine lifecycle observer to defer activation to, so the session is activated when LiveKit applies its options (at connect, or on an explicit apply) and released on stop or disconnect. That keeps focus and mode ownership tied to a clear start and stop, which is the standard call session pattern.

Separating configure from activate, or adding an activate flag, is a reasonable future enhancement for finer grained manual mode control, and we can revisit it if a concrete need shows up. For this change the current behavior is the intended one, so we will keep it as is for now.


"stopAndroidAudioSession" -> {
audioSwitchManager?.stop()
result.success(null)
}

"setAndroidSpeakerphoneOn" -> {
val enable = call.argument<Boolean>("enable") ?: false
val force = call.argument<Boolean>("force") ?: false
audioSwitchManager?.setSpeakerphoneOn(enable, force)
result.success(null)
}

else -> {
result.notImplemented()
}
Expand All @@ -359,6 +385,9 @@ class LiveKitPlugin : FlutterPlugin, MethodCallHandler {
override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) {
channel.setMethodCallHandler(null)

audioSwitchManager?.dispose()
audioSwitchManager = null

// Cleanup all processors
audioProcessors.values.forEach { it.cleanup() }
audioProcessors.clear()
Expand Down
Loading
Loading