Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
40 changes: 37 additions & 3 deletions packages/audiodocs/docs/system/audio-manager.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
sidebar_position: 1
---

import { Optional, ReadOnly, IOS, Experimental } from '@site/src/components/Badges';
import { Optional, ReadOnly, IOS, Android, Experimental } from '@site/src/components/Badges';

# AudioManager

Expand Down Expand Up @@ -44,7 +44,7 @@ function App() {

## Methods

### `setAudioSessionOptions` <IOS />
### `setAudioSessionOptions` <IOS /> <Android />

:::warning AVAudioSession Compatibility
Not all `iosOptions` are compatible with every `iosCategory`. Passing an invalid combination to the native API (for example, explicitly setting `allowBluetoothA2DP` alongside the `playback` category) will cause the configuration to fail. This can result in a `SessionActivationError` and total audio silence.
Expand All @@ -58,7 +58,9 @@ Always verify valid category and option combinations in [Apple's AVAudioSession

#### Returns `undefined`.

### `setAudioSessionActivity` <IOS /> {#setaudiosessionactivity}
On Android 12 (API 31) or later, set `androidMode: 'inCommunication'` before activating the session to request Android's communication audio mode. The optional `androidCommunicationDevice` requests `'speaker'`, `'earpiece'`, or `'systemDefault'` as the initial route. Existing Android behavior is unchanged when `androidMode` is omitted.

### `setAudioSessionActivity` <IOS /> <Android /> {#setaudiosessionactivity}

| Parameter | Type | Description |
| :---: | :---: | :---- |
Expand All @@ -68,6 +70,24 @@ Always verify valid category and option combinations in [Apple's AVAudioSession

Deactivating the session while a recording is in progress or paused stops that recording first, since an inactive session would corrupt its output. The finalized files are then available through [`AudioRecorder.consumeLastRecordingResult()`](../inputs/audio-recorder.mdx#consumelastrecordingresult). Prefer calling [`AudioRecorder.stop()`](../inputs/audio-recorder.mdx#stop) yourself before deactivating, so the file info arrives through its promise.

With Android `inCommunication` mode, activation requests transient focus using voice-communication/speech attributes, enters `MODE_IN_COMMUNICATION`, and applies the configured initial communication-device preference. Deactivation clears the request, abandons that focus, and restores the prior mode. Activation rejects below API 31 or if Android denies focus or the requested route. After a permanent focus loss, deactivate the session before starting a new one; the library does not reacquire focus automatically.

### `setCommunicationDevice` <Android />

| Parameter | Type | Description |
| :---: | :---: | :---- |
| `device` | [`CommunicationDevice`](./audio-manager.mdx#communicationdevice) | Requests an Android communication route while an active `inCommunication` session owns the audio mode. |

#### Returns `Promise<void>`.

Available on Android 12 (API 31) or later. The promise rejects if no communication session is active, the requested built-in device is unavailable, or Android rejects the request. Resolution means Android accepted the request; use `getCommunicationDevice()` and `routeChange` to observe the selected route. A runtime request applies only to the active session; the next activation uses `androidCommunicationDevice` from `setAudioSessionOptions()` again.

### `getCommunicationDevice` <Android />

#### Returns `Promise<AudioDeviceInfo | null>`.

Returns the device Android currently selected for communication, including an accessory chosen by the system. It does not return the requested preference.

### `disableSessionManagement` <IOS />

#### Returns `undefined`.
Expand Down Expand Up @@ -238,10 +258,24 @@ interface SessionOptions {
iosAllowHaptics?: boolean;
// Has no effect when using PlaybackNotificationManager as it takes over the "Now playing" controls
iosNotifyOthersOnDeactivation?: boolean;
androidMode?: 'inCommunication';
androidCommunicationDevice?: CommunicationDevice;
}
```
</details>

### `CommunicationDevice`

<details>
<summary>Type definitions</summary>
```typescript
type CommunicationDevice = 'speaker' | 'earpiece' | 'systemDefault';
```
</details>

:::info
`androidInputPreset: 'voiceCommunication'` on `AudioRecorder` requests the Android capture path. `androidOutputProfile: 'voiceCommunication'` on `AudioContext` classifies Oboe playback as voice communication. `androidMode: 'inCommunication'` owns Android focus, mode, and communication-device routing. These APIs request platform voice-processing facilities; acoustic echo cancellation, noise suppression, and automatic gain control remain device- and route-dependent.
:::

### `SystemEventName`

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ class AudioAPIModule(

override fun invalidate() {
reactContext.get()?.removeLifecycleEventListener(this)
MediaSessionManager.cleanup()
// Cleanup foreground service manager
ForegroundServiceManager.cleanup()
}
Expand All @@ -105,7 +106,13 @@ class AudioAPIModule(
enabled: Boolean,
promise: Promise?,
) {
promise?.resolve(null)
MediaSessionManager.setAudioSessionActivity(enabled) { error ->
if (error == null) {
promise?.resolve(null)
} else {
promise?.reject("E_COMMUNICATION_SESSION", error)
}
}
}

override fun setAudioSessionOptions(
Expand All @@ -114,8 +121,10 @@ class AudioAPIModule(
options: ReadableArray?,
allowHaptics: Boolean,
notifyOthersOnDeactivation: Boolean,
androidMode: String?,
androidCommunicationDevice: String?,
) {
// noting to do here
MediaSessionManager.setAudioSessionOptions(androidMode, androidCommunicationDevice)
}

override fun disableSessionManagement() {
Expand All @@ -126,17 +135,14 @@ class AudioAPIModule(
focusType: String?,
enabled: Boolean,
) {
if (!enabled) {
MediaSessionManager.abandonAudioFocus()
return
}
when (focusType) {
"gain" -> MediaSessionManager.requestAudioFocus(AudioManager.AUDIOFOCUS_GAIN)
"gainTransient" -> MediaSessionManager.requestAudioFocus(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
"gainTransientMayDuck" -> MediaSessionManager.requestAudioFocus(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK)
"gainTransientExclusive" -> MediaSessionManager.requestAudioFocus(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE)
else -> MediaSessionManager.requestAudioFocus(AudioManager.AUDIOFOCUS_GAIN)
}
val focus =
when (focusType) {
"gainTransient" -> AudioManager.AUDIOFOCUS_GAIN_TRANSIENT
"gainTransientMayDuck" -> AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK
"gainTransientExclusive" -> AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE
else -> AudioManager.AUDIOFOCUS_GAIN
}
MediaSessionManager.observeAudioInterruptions(focus, enabled)
}

override fun activelyReclaimSession(enabled: Boolean) {
Expand Down Expand Up @@ -181,6 +187,36 @@ class AudioAPIModule(
promise?.resolve(null)
}

override fun setCommunicationDevice(
device: String?,
promise: Promise?,
) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
promise?.reject("E_UNSUPPORTED", "Communication-device routing requires Android 12 (API 31) or later")
return
}
if (device == null) {
promise?.reject("E_INVALID_COMMUNICATION_DEVICE", "A communication device is required")
return
}

MediaSessionManager.setCommunicationDevice(device) { error ->
if (error == null) {
promise?.resolve(null)
} else {
promise?.reject("E_COMMUNICATION_DEVICE", error)
}
}
}

override fun getCommunicationDevice(promise: Promise?) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
promise?.reject("E_UNSUPPORTED", "Communication-device routing requires Android 12 (API 31) or later")
return
}
MediaSessionManager.getCommunicationDevice { device -> promise?.resolve(device) }
}

// Notification system methods
@RequiresPermission(android.Manifest.permission.POST_NOTIFICATIONS)
override fun showNotification(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,36 @@
package com.swmansion.audioapi.system

import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.os.Build
import android.os.Handler
import android.util.Log
import androidx.annotation.RequiresApi
import com.swmansion.audioapi.AudioAPIModule
import java.lang.ref.WeakReference
import java.util.HashMap

class AudioFocusListener(
private val audioManager: WeakReference<AudioManager>,
private val audioAPIModule: WeakReference<AudioAPIModule>,
private val mainHandler: Handler,
private val isCommunicationSessionActive: () -> Boolean,
) : AudioManager.OnAudioFocusChangeListener {
private var focusRequest: AudioFocusRequest? = null
private var hasLegacyFocusRequest = false
private var isTransientLoss: Boolean = false
private var communicationFocusRequest: AudioFocusRequest? = null

override fun onAudioFocusChange(focusChange: Int) {
val hasCommunicationFocus = communicationFocusRequest != null
if (focusRequest == null && !hasLegacyFocusRequest && !hasCommunicationFocus) {
return
}
if (hasCommunicationFocus && !isCommunicationSessionActive()) {
return
}

Log.d("AudioFocusListener", "onAudioFocusChange: $focusChange")
when (focusChange) {
AudioManager.AUDIOFOCUS_LOSS -> {
Expand Down Expand Up @@ -49,30 +64,80 @@ class AudioFocusListener(
}

AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
isTransientLoss = communicationFocusRequest != null
audioAPIModule.get()?.invokeHandlerWithEventNameAndEventBody(AudioEvent.DUCK.ordinal, emptyMap())
}
}
}

fun requestAudioFocus(focus: Int) {
if (communicationFocusRequest != null) {
return
}
abandonAudioFocus()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
hasLegacyFocusRequest = false
this.focusRequest =
AudioFocusRequest
.Builder(focus)
.setOnAudioFocusChangeListener(this)
.setOnAudioFocusChangeListener(this, mainHandler)
.build()

audioManager.get()?.requestAudioFocus(focusRequest!!)
} else {
audioManager.get()?.requestAudioFocus(this, AudioManager.STREAM_MUSIC, focus)
val result = audioManager.get()?.requestAudioFocus(this, AudioManager.STREAM_MUSIC, focus)
hasLegacyFocusRequest = result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
}
}

fun abandonAudioFocus() {
if (communicationFocusRequest != null) {
return
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && this.focusRequest != null) {
audioManager.get()?.abandonAudioFocusRequest(focusRequest!!)
focusRequest = null
} else {
audioManager.get()?.abandonAudioFocus(this)
hasLegacyFocusRequest = false
}
isTransientLoss = false
}

@RequiresApi(Build.VERSION_CODES.O)
fun requestCommunicationAudioFocus(): Boolean {
if (communicationFocusRequest != null) {
return true
}

abandonAudioFocus()

val request =
AudioFocusRequest
.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
.setAudioAttributes(
AudioAttributes
.Builder()
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build(),
).setAcceptsDelayedFocusGain(false)
.setWillPauseWhenDucked(true)
.setOnAudioFocusChangeListener(this, mainHandler)
.build()

val result = audioManager.get()?.requestAudioFocus(request)
if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
communicationFocusRequest = request
return true
}
return false
}

@RequiresApi(Build.VERSION_CODES.O)
fun abandonCommunicationAudioFocus() {
communicationFocusRequest?.let { audioManager.get()?.abandonAudioFocusRequest(it) }
communicationFocusRequest = null
isTransientLoss = false
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package com.swmansion.audioapi.system

import android.media.AudioDeviceCallback
import android.media.AudioDeviceInfo
import android.media.AudioManager
import android.os.Build
import android.os.Handler
import androidx.annotation.RequiresApi
import java.util.concurrent.Executor

@RequiresApi(Build.VERSION_CODES.S)
fun registerCommunicationDeviceCallbacks(
audioManager: AudioManager,
mainHandler: Handler,
onRouteChange: (String) -> Unit,
): Any = CommunicationDeviceCallbacks.register(audioManager, mainHandler, onRouteChange)

@RequiresApi(Build.VERSION_CODES.S)
fun unregisterCommunicationDeviceCallbacks(callbacks: Any) {
CommunicationDeviceCallbacks.unregister(callbacks)
}

@RequiresApi(Build.VERSION_CODES.S)
private object CommunicationDeviceCallbacks {
fun register(
audioManager: AudioManager,
mainHandler: Handler,
onRouteChange: (String) -> Unit,
): Any = Registration(audioManager, mainHandler, onRouteChange).also { it.register() }

fun unregister(callbacks: Any) {
(callbacks as Registration).unregister()
}

private class Registration(
private val audioManager: AudioManager,
private val mainHandler: Handler,
private val onRouteChange: (String) -> Unit,
) {
private val deviceChangedListener =
AudioManager.OnCommunicationDeviceChangedListener {
onRouteChange("Override")
}
private val deviceCallback =
object : AudioDeviceCallback() {
override fun onAudioDevicesAdded(addedDevices: Array<AudioDeviceInfo>) {
onRouteChange("NewDeviceAvailable")
}

override fun onAudioDevicesRemoved(removedDevices: Array<AudioDeviceInfo>) {
onRouteChange("OldDeviceUnavailable")
}
}
private val mainExecutor = Executor { command -> mainHandler.post(command) }
private var deviceChangedListenerRegistered = false
private var audioDeviceCallbackRegistered = false

fun register() {
try {
audioManager.addOnCommunicationDeviceChangedListener(mainExecutor, deviceChangedListener)
deviceChangedListenerRegistered = true
audioManager.registerAudioDeviceCallback(deviceCallback, mainHandler)
audioDeviceCallbackRegistered = true
} catch (error: Exception) {
try {
unregister()
} catch (_: Exception) {
// The activation transaction still reports its original failure.
}
throw error
}
}

fun unregister() {
var failure: Exception? = null
if (deviceChangedListenerRegistered) {
deviceChangedListenerRegistered = false
try {
audioManager.removeOnCommunicationDeviceChangedListener(deviceChangedListener)
} catch (error: Exception) {
failure = error
}
}
if (audioDeviceCallbackRegistered) {
audioDeviceCallbackRegistered = false
try {
audioManager.unregisterAudioDeviceCallback(deviceCallback)
} catch (error: Exception) {
if (failure == null) {
failure = error
}
}
}
if (failure != null) {
throw failure
}
}
}
}
Loading
Loading