Skip to content

Commit 3bd4f80

Browse files
committed
feat(audio): add AudioManager session and routing APIs (livekit#1108)
## What Adds first-class, process-wide audio session and routing control through `AudioManager` on iOS and Android. LiveKit owns the platform audio session by default, while apps that need exact platform behavior can switch to manual mode and apply typed session configs. ## API and behavior - **Automatic by default**: calls need no setup. LiveKit applies a managed communication policy. - **iOS automatic mode**: the native WebRTC audio-engine delegate drives `AVAudioSession` from engine lifecycle events. Listen-only playout uses `playback`; recording uses `playAndRecord`. - **Android automatic mode**: LiveKit uses a communication session through the new AudioSwitch-backed `LKAudioSwitchManager`. - **Manual mode**: `setAudioSessionOptions(...)` and `deactivateAudioSession()` switch `AudioManager` to manual mode. `setAudioSessionManagementMode(AudioSessionManagementMode.automatic)` hands lifecycle control back to LiveKit. - **Typed options**: `AudioSessionOptions.communication()` and `AudioSessionOptions.media()` pre-fill Apple and Android configs, with per-platform overrides applied verbatim in manual mode. - **Speaker routing**: `AudioManager.instance.setSpeakerOutputPreferred(...)` owns speaker preference and forced speaker routing. Wired and Bluetooth devices still win unless `force: true`. ## Compatibility - Existing calls keep working without audio-session setup. - `Hardware` audio members and `Room.setSpeakerOn(...)` are deprecated forwarders to `AudioManager`. - `flutter_webrtc` native audio-session management is disabled so LiveKit has one owner for the session. - `bypassVoiceProcessing` now only controls WebRTC voice processing; it no longer changes the session intent. ## Docs and tests - Adds `docs/audio.md` and updates the README audio sections. - Adds coverage for session options, automatic/manual mode transitions, Apple/Android policy resolution, routing serialization, and engine-state observation. - Verified locally with `dart analyze`, `flutter test test/audio/audio_session_test.dart`, and `flutter test --reporter compact`.
1 parent 726ffcd commit 3bd4f80

26 files changed

Lines changed: 2408 additions & 387 deletions

.changes/audio-manager-api

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
minor type="added" "AudioManager audio session options with engine-driven native lifecycle and platform routing controls"

README.md

Lines changed: 14 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -150,30 +150,18 @@ void main() async {
150150

151151
#### Audio Modes
152152

153-
By default, we use the `communication` audio mode on Android which works best for two-way voice communication.
153+
By default LiveKit uses the `communication` audio mode on Android, which works best for two-way voice communication.
154154

155-
If your app is media playback oriented and does not need the use of the device's microphone, you can use the `media`
156-
audio mode which will provide better audio quality.
155+
If your app is media playback oriented and does not need the device's microphone, apply the `media` session yourself. This
156+
switches `AudioManager` to manual mode, where your app owns the session.
157157

158158
```dart
159-
import 'package:flutter_webrtc/flutter_webrtc.dart' as webrtc;
160-
161-
Future<void> _initializeAndroidAudioSettings() async {
162-
await webrtc.WebRTC.initialize(options: {
163-
'androidAudioConfiguration': webrtc.AndroidAudioConfiguration.media.toMap()
164-
});
165-
webrtc.Helper.setAndroidAudioConfiguration(
166-
webrtc.AndroidAudioConfiguration.media);
167-
}
168-
169-
void main() async {
170-
await _initializeAudioSettings();
171-
runApp(const MyApp());
172-
}
159+
await AudioManager.instance.setAudioSessionOptions(
160+
const AudioSessionOptions.media(),
161+
);
173162
```
174163

175-
Note: the audio routing will become controlled by the system and cannot be manually changed with functions like
176-
`Hardware.selectAudioOutput`.
164+
See the [audio session guide](https://github.com/livekit/client-sdk-flutter/blob/main/docs/audio.md) for more.
177165

178166
### Desktop support
179167

@@ -322,6 +310,13 @@ Widget build(BuildContext context) {
322310

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

313+
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/docs/audio.md) for examples covering the automatic and manual modes, speaker routing, per platform overrides, and migration from the older `Hardware` APIs.
314+
315+
```dart
316+
// A call is managed automatically. Route to the speaker when you want.
317+
await AudioManager.instance.setSpeakerOutputPreferred(true);
318+
```
319+
325320
### Handling changes
326321

327322
LiveKit client makes it simple to build declarative UI that reacts to state changes. It notifies changes in two ways

android/build.gradle

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ allprojects {
1818
repositories {
1919
google()
2020
mavenCentral()
21+
maven { url 'https://jitpack.io' }
2122
}
2223
}
2324

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

6670
testOptions {
Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
1+
/*
2+
* Copyright 2026 LiveKit, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.livekit.plugin
18+
19+
import android.content.Context
20+
import android.media.AudioAttributes
21+
import android.media.AudioManager
22+
import android.os.Build
23+
import android.os.Handler
24+
import android.os.HandlerThread
25+
import com.twilio.audioswitch.AbstractAudioSwitch
26+
import com.twilio.audioswitch.AudioDevice
27+
import com.twilio.audioswitch.AudioSwitch
28+
import com.twilio.audioswitch.CommDeviceAudioSwitch
29+
import com.twilio.audioswitch.LegacyAudioSwitch
30+
31+
/**
32+
* Manages the Android platform audio session (audio mode, audio focus, and
33+
* output routing) for the LiveKit Flutter SDK, built on top of [AudioSwitch].
34+
*
35+
* This is LiveKit's own port of the audio-handling best practices from the
36+
* LiveKit Android SDK (`AudioSwitchHandler`) and flutter_webrtc
37+
* (`AudioSwitchManager`), so the Flutter SDK can own the platform audio session
38+
* directly instead of delegating to flutter_webrtc's native audio management.
39+
*
40+
* [AudioSwitch] is not thread-safe, so every interaction with it runs on a
41+
* single dedicated [HandlerThread].
42+
*/
43+
internal class LKAudioSwitchManager(private val context: Context) {
44+
// AudioSwitch is not threadsafe, so confine all access to a single long-lived
45+
// thread. The AudioSwitch instance is recreated per active session, while
46+
// queued lifecycle work stays serialized on this thread.
47+
private val thread = HandlerThread("LKAudioSwitchThread").also { it.start() }
48+
private val handler = Handler(thread.looper)
49+
50+
private var audioSwitch: AbstractAudioSwitch? = null
51+
private var isActive = false
52+
53+
// Configuration. Defaults mirror a communication/VoIP session and match the
54+
// AudioSwitchHandler defaults in the LiveKit Android SDK.
55+
private var manageAudioFocus = true
56+
private var audioMode = AudioManager.MODE_IN_COMMUNICATION
57+
private var focusMode = AudioManager.AUDIOFOCUS_GAIN
58+
private var audioStreamType = AudioManager.STREAM_VOICE_CALL
59+
private var audioAttributeUsageType = AudioAttributes.USAGE_VOICE_COMMUNICATION
60+
private var audioAttributeContentType = AudioAttributes.CONTENT_TYPE_SPEECH
61+
private var forceHandleAudioRouting = false
62+
63+
private var speakerOutputPreferred = true
64+
private var speakerOutputForced = false
65+
66+
/**
67+
* Apply an audio session configuration. Unspecified keys keep their current
68+
* value. When the session is already active, changes that only take effect at
69+
* activate() time trigger a deactivate and activate cycle so they apply live.
70+
*/
71+
@Synchronized
72+
fun configure(configuration: Map<String, Any?>) {
73+
val previous = sessionConfigSnapshot()
74+
(configuration["manageAudioFocus"] as? Boolean)?.let { manageAudioFocus = it }
75+
audioModeForName(configuration["androidAudioMode"] as? String)?.let { audioMode = it }
76+
focusModeForName(configuration["androidAudioFocusMode"] as? String)?.let { focusMode = it }
77+
streamTypeForName(configuration["androidAudioStreamType"] as? String)?.let { audioStreamType = it }
78+
usageTypeForName(configuration["androidAudioAttributesUsageType"] as? String)?.let { audioAttributeUsageType = it }
79+
contentTypeForName(configuration["androidAudioAttributesContentType"] as? String)?.let { audioAttributeContentType = it }
80+
(configuration["forceHandleAudioRouting"] as? Boolean)?.let { forceHandleAudioRouting = it }
81+
val sessionConfig = sessionConfigSnapshot()
82+
val sessionConfigChanged = sessionConfig != previous
83+
val speakerRouting = speakerRoutingSnapshot()
84+
85+
handler.post {
86+
val switch = audioSwitch ?: return@post
87+
applyConfiguration(switch, sessionConfig)
88+
// AudioSwitch applies the audio mode, focus, and attributes at activate()
89+
// time, so a live reconfiguration (e.g. communication to media) needs a
90+
// deactivate and activate cycle to take effect on an already active
91+
// session. Reassert speaker routing afterward.
92+
if (isActive && sessionConfigChanged) {
93+
switch.deactivate()
94+
switch.activate()
95+
applySpeakerRouting(switch, speakerRouting)
96+
}
97+
}
98+
}
99+
100+
// Snapshot of the AudioSwitch properties applied only at activate() time, used
101+
// to detect when a live session needs a deactivate and activate cycle to pick
102+
// up a configuration change.
103+
private fun sessionConfigSnapshot() = SessionConfig(
104+
manageAudioFocus = manageAudioFocus,
105+
audioMode = audioMode,
106+
focusMode = focusMode,
107+
audioStreamType = audioStreamType,
108+
audioAttributeUsageType = audioAttributeUsageType,
109+
audioAttributeContentType = audioAttributeContentType,
110+
forceHandleAudioRouting = forceHandleAudioRouting,
111+
)
112+
113+
/** Create (if needed) and activate the audio session: acquire focus, set mode and routing. */
114+
@Synchronized
115+
fun start() {
116+
val sessionConfig = sessionConfigSnapshot()
117+
val speakerRouting = speakerRoutingSnapshot()
118+
handler.post {
119+
val switch = audioSwitch ?: createSwitch(sessionConfig, speakerRouting).also { audioSwitch = it }
120+
if (!isActive) {
121+
switch.activate()
122+
applySpeakerRouting(switch, speakerRouting)
123+
isActive = true
124+
}
125+
}
126+
}
127+
128+
/** Deactivate and tear down the audio session: release focus and restore the previous mode. */
129+
@Synchronized
130+
fun stop() {
131+
handler.post {
132+
audioSwitch?.stop()
133+
audioSwitch = null
134+
isActive = false
135+
}
136+
}
137+
138+
/** Final cleanup when the plugin detaches. The manager must not be used after this. */
139+
@Synchronized
140+
fun dispose() {
141+
handler.post {
142+
audioSwitch?.stop()
143+
audioSwitch = null
144+
isActive = false
145+
thread.quitSafely()
146+
}
147+
}
148+
149+
/**
150+
* Prefer routing to/from the speaker, letting a connected headset keep priority
151+
* unless [force] is true.
152+
*/
153+
@Synchronized
154+
fun setSpeakerphoneOn(enable: Boolean, force: Boolean) {
155+
speakerOutputPreferred = enable
156+
speakerOutputForced = enable && force
157+
val speakerRouting = speakerRoutingSnapshot()
158+
handler.post {
159+
val switch = audioSwitch ?: return@post
160+
applySpeakerRouting(switch, speakerRouting)
161+
}
162+
}
163+
164+
private fun createSwitch(
165+
sessionConfig: SessionConfig,
166+
speakerRouting: SpeakerRouting,
167+
): AbstractAudioSwitch {
168+
val focusListener = AudioManager.OnAudioFocusChangeListener { }
169+
// API-aware switch selection, matching the LiveKit Android SDK's
170+
// AudioSwitchHandler: CommDeviceAudioSwitch uses the modern
171+
// AudioManager.setCommunicationDevice routing on API 31+.
172+
val switch = when {
173+
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ->
174+
CommDeviceAudioSwitch(context, false, focusListener, speakerRouting.preferredDeviceList)
175+
176+
Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ->
177+
AudioSwitch(context, false, focusListener, speakerRouting.preferredDeviceList)
178+
179+
else ->
180+
LegacyAudioSwitch(context, false, focusListener, speakerRouting.preferredDeviceList)
181+
}
182+
applyConfiguration(switch, sessionConfig)
183+
switch.start { _, _ -> }
184+
return switch
185+
}
186+
187+
private fun applyConfiguration(switch: AbstractAudioSwitch, sessionConfig: SessionConfig) {
188+
switch.manageAudioFocus = sessionConfig.manageAudioFocus
189+
switch.audioMode = sessionConfig.audioMode
190+
switch.focusMode = sessionConfig.focusMode
191+
switch.audioStreamType = sessionConfig.audioStreamType
192+
switch.audioAttributeUsageType = sessionConfig.audioAttributeUsageType
193+
switch.audioAttributeContentType = sessionConfig.audioAttributeContentType
194+
switch.forceHandleAudioRouting = sessionConfig.forceHandleAudioRouting
195+
}
196+
197+
private fun applySpeakerRouting(switch: AbstractAudioSwitch, speakerRouting: SpeakerRouting) {
198+
switch.setPreferredDeviceList(speakerRouting.preferredDeviceList)
199+
val forcedSpeaker = if (speakerRouting.speakerOutputForced) {
200+
switch.availableAudioDevices.firstOrNull { it is AudioDevice.Speakerphone }
201+
} else {
202+
null
203+
}
204+
// AudioSwitch selections are sticky. Use them only for forced speaker output.
205+
// Clearing the selection lets the preferred-device list handle normal routing
206+
// and headset hot-plug priority.
207+
switch.selectDevice(forcedSpeaker)
208+
}
209+
210+
private fun speakerRoutingSnapshot() = SpeakerRouting(
211+
speakerOutputForced = speakerOutputForced,
212+
preferredDeviceList = preferredDeviceList(
213+
speakerOutputPreferred = speakerOutputPreferred,
214+
speakerOutputForced = speakerOutputForced,
215+
),
216+
)
217+
218+
private fun preferredDeviceList(
219+
speakerOutputPreferred: Boolean,
220+
speakerOutputForced: Boolean,
221+
): List<Class<out AudioDevice>> =
222+
when {
223+
speakerOutputForced -> listOf(
224+
AudioDevice.Speakerphone::class.java,
225+
AudioDevice.BluetoothHeadset::class.java,
226+
AudioDevice.WiredHeadset::class.java,
227+
AudioDevice.Earpiece::class.java,
228+
)
229+
230+
speakerOutputPreferred -> listOf(
231+
AudioDevice.BluetoothHeadset::class.java,
232+
AudioDevice.WiredHeadset::class.java,
233+
AudioDevice.Speakerphone::class.java,
234+
AudioDevice.Earpiece::class.java,
235+
)
236+
237+
else -> listOf(
238+
AudioDevice.BluetoothHeadset::class.java,
239+
AudioDevice.WiredHeadset::class.java,
240+
AudioDevice.Earpiece::class.java,
241+
AudioDevice.Speakerphone::class.java,
242+
)
243+
}
244+
245+
private data class SessionConfig(
246+
val manageAudioFocus: Boolean,
247+
val audioMode: Int,
248+
val focusMode: Int,
249+
val audioStreamType: Int,
250+
val audioAttributeUsageType: Int,
251+
val audioAttributeContentType: Int,
252+
val forceHandleAudioRouting: Boolean,
253+
)
254+
255+
private data class SpeakerRouting(
256+
val speakerOutputForced: Boolean,
257+
val preferredDeviceList: List<Class<out AudioDevice>>,
258+
)
259+
}
260+
261+
// Map the Flutter-side enum names (see android_audio_session_adapter.dart) to
262+
// Android framework constants. Ported from flutter_webrtc's AudioUtils.
263+
264+
private fun audioModeForName(name: String?): Int? = when (name) {
265+
null -> null
266+
"normal" -> AudioManager.MODE_NORMAL
267+
"callScreening" -> AudioManager.MODE_CALL_SCREENING
268+
"inCall" -> AudioManager.MODE_IN_CALL
269+
"inCommunication" -> AudioManager.MODE_IN_COMMUNICATION
270+
"ringtone" -> AudioManager.MODE_RINGTONE
271+
else -> null
272+
}
273+
274+
private fun focusModeForName(name: String?): Int? = when (name) {
275+
null -> null
276+
"gain" -> AudioManager.AUDIOFOCUS_GAIN
277+
"gainTransient" -> AudioManager.AUDIOFOCUS_GAIN_TRANSIENT
278+
"gainTransientExclusive" -> AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE
279+
"gainTransientMayDuck" -> AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK
280+
else -> null
281+
}
282+
283+
private fun streamTypeForName(name: String?): Int? = when (name) {
284+
null -> null
285+
"accessibility" -> AudioManager.STREAM_ACCESSIBILITY
286+
"alarm" -> AudioManager.STREAM_ALARM
287+
"dtmf" -> AudioManager.STREAM_DTMF
288+
"music" -> AudioManager.STREAM_MUSIC
289+
"notification" -> AudioManager.STREAM_NOTIFICATION
290+
"ring" -> AudioManager.STREAM_RING
291+
"system" -> AudioManager.STREAM_SYSTEM
292+
"voiceCall" -> AudioManager.STREAM_VOICE_CALL
293+
else -> null
294+
}
295+
296+
private fun usageTypeForName(name: String?): Int? = when (name) {
297+
null -> null
298+
"alarm" -> AudioAttributes.USAGE_ALARM
299+
"assistanceAccessibility" -> AudioAttributes.USAGE_ASSISTANCE_ACCESSIBILITY
300+
"assistanceNavigationGuidance" -> AudioAttributes.USAGE_ASSISTANCE_NAVIGATION_GUIDANCE
301+
"assistanceSonification" -> AudioAttributes.USAGE_ASSISTANCE_SONIFICATION
302+
"assistant" -> AudioAttributes.USAGE_ASSISTANT
303+
"game" -> AudioAttributes.USAGE_GAME
304+
"media" -> AudioAttributes.USAGE_MEDIA
305+
"notification" -> AudioAttributes.USAGE_NOTIFICATION
306+
"notificationEvent" -> AudioAttributes.USAGE_NOTIFICATION_EVENT
307+
"notificationRingtone" -> AudioAttributes.USAGE_NOTIFICATION_RINGTONE
308+
"unknown" -> AudioAttributes.USAGE_UNKNOWN
309+
"voiceCommunication" -> AudioAttributes.USAGE_VOICE_COMMUNICATION
310+
"voiceCommunicationSignalling" -> AudioAttributes.USAGE_VOICE_COMMUNICATION_SIGNALLING
311+
else -> null
312+
}
313+
314+
private fun contentTypeForName(name: String?): Int? = when (name) {
315+
null -> null
316+
"movie" -> AudioAttributes.CONTENT_TYPE_MOVIE
317+
"music" -> AudioAttributes.CONTENT_TYPE_MUSIC
318+
"sonification" -> AudioAttributes.CONTENT_TYPE_SONIFICATION
319+
"speech" -> AudioAttributes.CONTENT_TYPE_SPEECH
320+
"unknown" -> AudioAttributes.CONTENT_TYPE_UNKNOWN
321+
else -> null
322+
}

0 commit comments

Comments
 (0)