From 077b98da463389f277ece102179bc0b3c48ce605 Mon Sep 17 00:00:00 2001 From: "maciej.kucmierz" Date: Thu, 24 Sep 2026 16:21:01 +0200 Subject: [PATCH 1/2] feat(android): add communication session focus and routing --- .../audiodocs/docs/system/audio-manager.mdx | 40 +++- .../com/swmansion/audioapi/AudioAPIModule.kt | 62 ++++-- .../audioapi/system/AudioFocusListener.kt | 69 +++++- .../system/CommunicationDeviceCallbacks.kt | 99 +++++++++ .../audioapi/system/MediaSessionManager.kt | 200 +++++++++++++++++- .../src/oldarch/NativeAudioAPIModuleSpec.java | 10 +- .../ios/audioapi/ios/AudioAPIModule.mm | 16 +- .../react-native-audio-api/src/mock/index.ts | 8 + .../src/specs/NativeAudioAPIModule.ts | 12 +- .../src/specs/NativeAudioAPIModule.web.ts | 11 +- .../src/system/AudioManager.ts | 22 +- .../src/system/types.ts | 16 ++ .../src/web-system/AudioManager.ts | 12 +- .../react-native-audio-api/tests/mock.test.ts | 10 +- 14 files changed, 556 insertions(+), 31 deletions(-) create mode 100644 packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/CommunicationDeviceCallbacks.kt diff --git a/packages/audiodocs/docs/system/audio-manager.mdx b/packages/audiodocs/docs/system/audio-manager.mdx index b9d3964c7..689473969 100644 --- a/packages/audiodocs/docs/system/audio-manager.mdx +++ b/packages/audiodocs/docs/system/audio-manager.mdx @@ -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 @@ -44,7 +44,7 @@ function App() { ## Methods -### `setAudioSessionOptions` +### `setAudioSessionOptions` :::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. @@ -58,7 +58,9 @@ Always verify valid category and option combinations in [Apple's AVAudioSession #### Returns `undefined`. -### `setAudioSessionActivity` {#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` {#setaudiosessionactivity} | Parameter | Type | Description | | :---: | :---: | :---- | @@ -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` + +| 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`. + +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` + +#### Returns `Promise`. + +Returns the device Android currently selected for communication, including an accessory chosen by the system. It does not return the requested preference. + ### `disableSessionManagement` #### Returns `undefined`. @@ -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; } ``` +### `CommunicationDevice` + +
+Type definitions +```typescript +type CommunicationDevice = 'speaker' | 'earpiece' | 'systemDefault'; +``` +
+ +:::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` diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/AudioAPIModule.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/AudioAPIModule.kt index 5411f005d..abed8cee1 100644 --- a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/AudioAPIModule.kt +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/AudioAPIModule.kt @@ -93,6 +93,7 @@ class AudioAPIModule( override fun invalidate() { reactContext.get()?.removeLifecycleEventListener(this) + MediaSessionManager.cleanup() // Cleanup foreground service manager ForegroundServiceManager.cleanup() } @@ -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( @@ -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() { @@ -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) { @@ -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( diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/AudioFocusListener.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/AudioFocusListener.kt index b9c463666..a375169b3 100644 --- a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/AudioFocusListener.kt +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/AudioFocusListener.kt @@ -1,9 +1,12 @@ 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 @@ -11,11 +14,23 @@ import java.util.HashMap class AudioFocusListener( private val audioManager: WeakReference, private val audioAPIModule: WeakReference, + 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 -> { @@ -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 } } diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/CommunicationDeviceCallbacks.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/CommunicationDeviceCallbacks.kt new file mode 100644 index 000000000..ee4575da0 --- /dev/null +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/CommunicationDeviceCallbacks.kt @@ -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) { + onRouteChange("NewDeviceAvailable") + } + + override fun onAudioDevicesRemoved(removedDevices: Array) { + 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 + } + } + } +} diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/MediaSessionManager.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/MediaSessionManager.kt index d29d8c360..84ace4ba3 100644 --- a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/MediaSessionManager.kt +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/MediaSessionManager.kt @@ -9,6 +9,9 @@ import android.content.pm.PackageManager import android.media.AudioDeviceInfo import android.media.AudioManager import android.os.Build +import android.os.Handler +import android.os.Looper +import android.util.Log import androidx.annotation.RequiresApi import androidx.annotation.RequiresPermission import androidx.core.app.ActivityCompat @@ -37,6 +40,16 @@ object MediaSessionManager { private lateinit var volumeChangeListener: VolumeChangeListener private lateinit var playbackNotificationReceiver: PlaybackNotificationReceiver + private val mainHandler = Handler(Looper.getMainLooper()) + private var androidMode: String? = null + private var configuredCommunicationDevice: String? = null + private var previousAudioMode: Int? = null + private var communicationSessionActive = false + + // The API-31 helper is intentionally opaque here so API-30 startup does not + // need to resolve its Android-12-only callback types. + private var communicationDeviceCallbacks: Any? = null + // New notification system private lateinit var notificationRegistry: NotificationRegistry @@ -77,7 +90,9 @@ object MediaSessionManager { } this.audioFocusListener = - AudioFocusListener(WeakReference(this.audioManager), this.audioAPIModule) + AudioFocusListener(WeakReference(this.audioManager), this.audioAPIModule, mainHandler) { + communicationSessionActive + } this.volumeChangeListener = VolumeChangeListener(WeakReference(this.audioManager), this.audioAPIModule) // Initialize new notification system @@ -97,12 +112,179 @@ object MediaSessionManager { return sampleRate.toDouble() } - fun requestAudioFocus(focus: Int) { - audioFocusListener.requestAudioFocus(focus) + fun setAudioSessionOptions( + androidMode: String?, + androidCommunicationDevice: String?, + ) { + runOnMain { + this.androidMode = androidMode?.takeIf { it.isNotEmpty() } + this.configuredCommunicationDevice = androidCommunicationDevice?.takeIf { it.isNotEmpty() } + } } - fun abandonAudioFocus() { - audioFocusListener.abandonAudioFocus() + fun setAudioSessionActivity( + enabled: Boolean, + onComplete: (String?) -> Unit, + ) { + runOnMain { + onComplete(setAudioSessionActivityOnMain(enabled)) + } + } + + fun observeAudioInterruptions( + focus: Int, + enabled: Boolean, + ) { + runOnMain { + if (communicationSessionActive) { + return@runOnMain + } + if (enabled) { + audioFocusListener.requestAudioFocus(focus) + } else { + audioFocusListener.abandonAudioFocus() + } + } + } + + fun setCommunicationDevice( + device: String, + onComplete: (String?) -> Unit, + ) { + runOnMain { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + onComplete("Android communication devices require Android 12 (API 31) or later") + return@runOnMain + } + if (!communicationSessionActive) { + onComplete("An Android communication session is not active") + return@runOnMain + } + onComplete(requestCommunicationDevice(device)) + } + } + + fun getCommunicationDevice(onComplete: (ReadableMap?) -> Unit) { + runOnMain { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + onComplete(null) + return@runOnMain + } + onComplete(audioManager.communicationDevice?.let(::toReadableDeviceInfo)) + } + } + + @RequiresApi(Build.VERSION_CODES.S) + private fun activateCommunicationSession(): String? { + if (communicationSessionActive) { + return null + } + + previousAudioMode = audioManager.mode + if (!audioFocusListener.requestCommunicationAudioFocus()) { + previousAudioMode = null + return "Android denied voice-communication audio focus" + } + + return try { + audioManager.mode = AudioManager.MODE_IN_COMMUNICATION + communicationDeviceCallbacks = + registerCommunicationDeviceCallbacks(audioManager, mainHandler, ::emitRouteChange) + configuredCommunicationDevice?.let { device -> + requestCommunicationDevice(device)?.let { error -> throw IllegalStateException(error) } + } + communicationSessionActive = true + null + } catch (error: Exception) { + deactivateCommunicationSession() + error.message ?: "Could not activate Android communication audio session" + } + } + + @RequiresApi(Build.VERSION_CODES.S) + private fun deactivateCommunicationSession() { + if (!communicationSessionActive && previousAudioMode == null) { + return + } + + communicationSessionActive = false + val callbacks = communicationDeviceCallbacks + communicationDeviceCallbacks = null + if (callbacks != null) { + try { + unregisterCommunicationDeviceCallbacks(callbacks) + } catch (error: Exception) { + Log.w("MediaSessionManager", "Could not unregister communication-device callbacks", error) + } + } + audioManager.clearCommunicationDevice() + audioFocusListener.abandonCommunicationAudioFocus() + previousAudioMode?.let { audioManager.mode = it } + previousAudioMode = null + } + + @RequiresApi(Build.VERSION_CODES.S) + private fun requestCommunicationDevice(device: String): String? { + if (device == "systemDefault") { + audioManager.clearCommunicationDevice() + return null + } + + val deviceType = + when (device) { + "speaker" -> AudioDeviceInfo.TYPE_BUILTIN_SPEAKER + "earpiece" -> AudioDeviceInfo.TYPE_BUILTIN_EARPIECE + else -> return "Unsupported communication device: $device" + } + val target = + audioManager.availableCommunicationDevices.firstOrNull { it.type == deviceType } + ?: return "Requested communication device is unavailable: $device" + + if (!audioManager.setCommunicationDevice(target)) { + return "Android rejected requested communication device: $device" + } + return null + } + + private fun setAudioSessionActivityOnMain(enabled: Boolean): String? { + if (!enabled) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + deactivateCommunicationSession() + } + return null + } + if (androidMode != "inCommunication") { + return null + } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + return "Android communication sessions require Android 12 (API 31) or later" + } + return activateCommunicationSession() + } + + fun cleanup() { + runOnMain { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + deactivateCommunicationSession() + } + } + } + + private fun emitRouteChange(reason: String) { + if (communicationSessionActive) { + audioAPIModule.get()?.invokeHandlerWithEventNameAndEventBody( + AudioEvent.ROUTE_CHANGE.ordinal, + mapOf("reason" to reason), + ) + } + } + + private fun runOnMain(action: () -> Unit) { + if (Looper.myLooper() == mainHandler.looper) { + action() + } else { + mainHandler.post(action) + } } fun activelyReclaimSession(enabled: Boolean) { @@ -244,6 +426,14 @@ object MediaSessionManager { return devicesInfo } + @RequiresApi(Build.VERSION_CODES.S) + private fun toReadableDeviceInfo(device: AudioDeviceInfo): ReadableMap = + Arguments.createMap().apply { + putString("id", device.id.toString()) + putString("name", device.productName.toString()) + putString("category", parseDeviceCategory(device)) + } + @RequiresApi(Build.VERSION_CODES.O) fun parseDeviceCategory(device: AudioDeviceInfo): String = when (device.type) { diff --git a/packages/react-native-audio-api/android/src/oldarch/NativeAudioAPIModuleSpec.java b/packages/react-native-audio-api/android/src/oldarch/NativeAudioAPIModuleSpec.java index 5f2eb276e..7ed9dca7d 100644 --- a/packages/react-native-audio-api/android/src/oldarch/NativeAudioAPIModuleSpec.java +++ b/packages/react-native-audio-api/android/src/oldarch/NativeAudioAPIModuleSpec.java @@ -53,7 +53,7 @@ public NativeAudioAPIModuleSpec(ReactApplicationContext reactContext) { @ReactMethod @DoNotStrip - public abstract void setAudioSessionOptions(String category, String mode, ReadableArray options, boolean allowHaptics, boolean notifyOthersOnDeactivation); + public abstract void setAudioSessionOptions(String category, String mode, ReadableArray options, boolean allowHaptics, boolean notifyOthersOnDeactivation, String androidMode, String androidCommunicationDevice); @ReactMethod @DoNotStrip @@ -95,6 +95,14 @@ public NativeAudioAPIModuleSpec(ReactApplicationContext reactContext) { @DoNotStrip public abstract void setInputDevice(String deviceId, Promise promise); + @ReactMethod + @DoNotStrip + public abstract void setCommunicationDevice(String device, Promise promise); + + @ReactMethod + @DoNotStrip + public abstract void getCommunicationDevice(Promise promise); + @ReactMethod @DoNotStrip public abstract void showNotification(String type, String key, ReadableMap options, Promise promise); diff --git a/packages/react-native-audio-api/ios/audioapi/ios/AudioAPIModule.mm b/packages/react-native-audio-api/ios/audioapi/ios/AudioAPIModule.mm index d4c527d80..806f57e6a 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/AudioAPIModule.mm +++ b/packages/react-native-audio-api/ios/audioapi/ios/AudioAPIModule.mm @@ -167,7 +167,8 @@ - (dispatch_queue_t)methodQueue RCT_EXPORT_METHOD( setAudioSessionOptions : (NSString *)category mode : (NSString *)mode options : (NSArray *) options allowHaptics : (BOOL)allowHaptics notifyOthersOnDeactivation : (BOOL) - notifyOthersOnDeactivation) + notifyOthersOnDeactivation androidMode : (NSString *) + androidMode androidCommunicationDevice : (NSString *)androidCommunicationDevice) { if (!self.audioSessionManager.shouldManageSession) { [self.audioSessionManager setShouldManageSession:true]; @@ -250,6 +251,19 @@ - (dispatch_queue_t)methodQueue }); } +RCT_EXPORT_METHOD( + setCommunicationDevice : (NSString *)device resolve : (RCTPromiseResolveBlock) + resolve reject : (RCTPromiseRejectBlock)reject) +{ + reject(@"E_PLATFORM", @"Communication-device selection is only available on Android", nil); +} + +RCT_EXPORT_METHOD( + getCommunicationDevice : (RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)reject) +{ + reject(@"E_PLATFORM", @"Communication-device selection is only available on Android", nil); +} + RCT_EXPORT_METHOD(disableSessionManagement) { [self.audioSessionManager disableSessionManagement]; diff --git a/packages/react-native-audio-api/src/mock/index.ts b/packages/react-native-audio-api/src/mock/index.ts index 52a6b6e3d..95005679c 100644 --- a/packages/react-native-audio-api/src/mock/index.ts +++ b/packages/react-native-audio-api/src/mock/index.ts @@ -1224,6 +1224,14 @@ class AudioManagerMock { static observeVolumeChanges(_observe: boolean): void {} + static setCommunicationDevice(_device: string): Promise { + return Promise.resolve(); + } + + static getCommunicationDevice(): Promise { + return Promise.resolve(null); + } + static addSystemEventListener( _event: string, _callback: (event: { value: number }) => void diff --git a/packages/react-native-audio-api/src/specs/NativeAudioAPIModule.ts b/packages/react-native-audio-api/src/specs/NativeAudioAPIModule.ts index 2085f8dee..c16f281ee 100644 --- a/packages/react-native-audio-api/src/specs/NativeAudioAPIModule.ts +++ b/packages/react-native-audio-api/src/specs/NativeAudioAPIModule.ts @@ -1,7 +1,11 @@ 'use strict'; import type { TurboModule } from 'react-native'; import { TurboModuleRegistry } from 'react-native'; -import { AudioDevicesInfo, PermissionStatus } from '../system/types'; +import { + AudioDeviceInfo, + AudioDevicesInfo, + PermissionStatus, +} from '../system/types'; type OptionsMap = { [key: string]: string | boolean | number | undefined }; type NotificationOpResponse = { success: boolean; error?: string }; @@ -24,7 +28,9 @@ interface Spec extends TurboModule { mode: string, options: Array, allowHaptics: boolean, - notifyOthersOnDeactivation: boolean + notifyOthersOnDeactivation: boolean, + androidMode: string, + androidCommunicationDevice: string ): void; disableSessionManagement(): void; @@ -43,6 +49,8 @@ interface Spec extends TurboModule { // Audio devices getDevicesInfo(): Promise; setInputDevice(deviceId: string): Promise; + setCommunicationDevice(device: string): Promise; + getCommunicationDevice(): Promise; // Notification system showNotification( diff --git a/packages/react-native-audio-api/src/specs/NativeAudioAPIModule.web.ts b/packages/react-native-audio-api/src/specs/NativeAudioAPIModule.web.ts index 6f2393201..0e33c4ef4 100644 --- a/packages/react-native-audio-api/src/specs/NativeAudioAPIModule.web.ts +++ b/packages/react-native-audio-api/src/specs/NativeAudioAPIModule.web.ts @@ -1,5 +1,6 @@ import { TurboModule } from 'react-native'; import { + AudioDeviceInfo, AudioDevicesInfo, AudioFocusType, PermissionStatus, @@ -24,7 +25,9 @@ interface Spec extends TurboModule { mode: string, options: Array, allowHaptics: boolean, - notifyOthersOnDeactivation: boolean + notifyOthersOnDeactivation: boolean, + androidMode: string, + androidCommunicationDevice: string ): void; disableSessionManagement(): void; @@ -42,6 +45,8 @@ interface Spec extends TurboModule { // Audio devices getDevicesInfo(): Promise; setInputDevice(deviceId: string): Promise; + setCommunicationDevice(device: string): Promise; + getCommunicationDevice(): Promise; // New notification system showNotification( @@ -84,6 +89,10 @@ const NativeAudioAPIModule: Spec = { currentOutputs: [], }), setInputDevice: mockAsync(undefined), + setCommunicationDevice: () => + Promise.reject(new Error('setCommunicationDevice is not supported on web')), + getCommunicationDevice: () => + Promise.reject(new Error('getCommunicationDevice is not supported on web')), showNotification: mockAsync({ success: true }), hideNotification: mockAsync({ success: true }), isNotificationActive: mockAsync(false), diff --git a/packages/react-native-audio-api/src/system/AudioManager.ts b/packages/react-native-audio-api/src/system/AudioManager.ts index ace424da3..ace421676 100644 --- a/packages/react-native-audio-api/src/system/AudioManager.ts +++ b/packages/react-native-audio-api/src/system/AudioManager.ts @@ -5,6 +5,8 @@ import { parseNativeError } from './errors'; import { AudioDevicesInfo, AudioFocusType, + AudioDeviceInfo, + CommunicationDevice, IAudioManager, PermissionStatus, SessionOptions, @@ -43,7 +45,9 @@ class AudioManager implements IAudioManager { options.iosMode ?? '', options.iosOptions ?? [], options.iosAllowHaptics ?? false, - options.iosNotifyOthersOnDeactivation ?? true + options.iosNotifyOthersOnDeactivation ?? true, + options.androidMode ?? '', + options.androidCommunicationDevice ?? '' ); } @@ -129,6 +133,22 @@ class AudioManager implements IAudioManager { async setInputDevice(deviceId: string): Promise { await NativeAudioAPIModule.setInputDevice(deviceId); } + + /** + * Requests an Android communication device while an `inCommunication` session + * is active. `systemDefault` clears the explicit request. + */ + async setCommunicationDevice(device: CommunicationDevice): Promise { + await NativeAudioAPIModule.setCommunicationDevice(device); + } + + /** + * Returns Android's currently selected communication device, including a + * route selected by the system after an accessory change. + */ + async getCommunicationDevice(): Promise { + return NativeAudioAPIModule.getCommunicationDevice(); + } } export default new AudioManager(); diff --git a/packages/react-native-audio-api/src/system/types.ts b/packages/react-native-audio-api/src/system/types.ts index d010ed95e..fac8f2e9e 100644 --- a/packages/react-native-audio-api/src/system/types.ts +++ b/packages/react-native-audio-api/src/system/types.ts @@ -40,6 +40,10 @@ export type AudioFocusType = | 'gainTransientExclusive' | 'gainTransientMayDuck'; +export type AndroidAudioMode = 'inCommunication'; + +export type CommunicationDevice = 'speaker' | 'earpiece' | 'systemDefault'; + export interface SessionOptions { iosMode?: IOSMode; iosOptions?: IOSOption[]; @@ -53,6 +57,16 @@ export interface SessionOptions { * "Now playing" controls. */ iosNotifyOthersOnDeactivation?: boolean; + /** + * Opts into Android's communication audio mode. Android 12 (API 31) or later + * is required when this option is used. + */ + androidMode?: AndroidAudioMode; + /** + * Initial Android communication-device preference. `systemDefault` clears an + * explicit preference and lets Android choose the route. + */ + androidCommunicationDevice?: CommunicationDevice; } export type PermissionStatus = 'Undetermined' | 'Denied' | 'Granted'; @@ -91,4 +105,6 @@ export interface IAudioManager { checkNotificationPermissions(): Promise; getDevicesInfo(): Promise; setInputDevice(deviceId: string): Promise; + setCommunicationDevice(device: CommunicationDevice): Promise; + getCommunicationDevice(): Promise; } diff --git a/packages/react-native-audio-api/src/web-system/AudioManager.ts b/packages/react-native-audio-api/src/web-system/AudioManager.ts index f6cbd6a4c..faf675c13 100644 --- a/packages/react-native-audio-api/src/web-system/AudioManager.ts +++ b/packages/react-native-audio-api/src/web-system/AudioManager.ts @@ -1,4 +1,8 @@ -import type { IAudioManager, PermissionStatus } from '../system/types'; +import type { + CommunicationDevice, + IAudioManager, + PermissionStatus, +} from '../system/types'; const mockAsync = (value: T) => @@ -24,6 +28,12 @@ class AudioManager implements IAudioManager { requestNotificationPermissions = mockAsync('Granted' as PermissionStatus); checkNotificationPermissions = mockAsync('Granted' as PermissionStatus); setInputDevice = mockAsync(undefined); + setCommunicationDevice = (_device: CommunicationDevice) => + Promise.reject(new Error('setCommunicationDevice is not supported on web')); + + getCommunicationDevice = () => + Promise.reject(new Error('getCommunicationDevice is not supported on web')); + getDevicesInfo = mockAsync({ availableInputs: [], availableOutputs: [], diff --git a/packages/react-native-audio-api/tests/mock.test.ts b/packages/react-native-audio-api/tests/mock.test.ts index 55f83c494..26245985c 100644 --- a/packages/react-native-audio-api/tests/mock.test.ts +++ b/packages/react-native-audio-api/tests/mock.test.ts @@ -204,7 +204,6 @@ describe('React Native Audio API Mocks', () => { expect(queueSource.onbufferended).toBe(callback); }); }); - }); describe('AudioRecorder', () => { @@ -362,6 +361,15 @@ describe('React Native Audio API Mocks', () => { MockAPI.AudioManager.removeSystemEventListener(listener) ).not.toThrow(); }); + + it('should provide communication-device methods', async () => { + await expect( + MockAPI.AudioManager.setCommunicationDevice('speaker') + ).resolves.toBeUndefined(); + await expect( + MockAPI.AudioManager.getCommunicationDevice() + ).resolves.toBeNull(); + }); }); describe('Notification Managers', () => { From 93c7c73480b91382a647687f02661f9e18b3d879 Mon Sep 17 00:00:00 2001 From: michal Date: Tue, 29 Sep 2026 19:57:54 +0200 Subject: [PATCH 2/2] feat: change names and meaning in audio manager --- .../build-compilation-dependencies/SKILL.md | 1 + .claude/skills/thread-safety-itc/SKILL.md | 2 +- .claude/skills/turbo-modules/SKILL.md | 4 +- CLAUDE.md | 2 +- apps/CLAUDE.md | 6 +- .../src/demos/Crossfade/Crossfade.tsx | 6 +- .../src/demos/PedalBoard/PedalBoard.tsx | 4 +- apps/common-app/src/demos/Record/Record.tsx | 6 +- .../src/examples/AudioFile/AudioFile.tsx | 8 +- .../ChannelMergerSplitter.tsx | 6 +- .../src/examples/ConvolverIR/ConvolverIR.tsx | 8 +- .../EchoCancellation/EchoCancellation.tsx | 410 ++++++++++++++++++ .../src/examples/EchoCancellation/index.ts | 1 + .../common-app/src/examples/Record/Record.tsx | 18 +- apps/common-app/src/examples/index.ts | 8 + .../AudioPipelineStress.tsx | 16 +- .../StressResourceOwner.ts | 2 +- .../AudioPipelineStress/audioSessions.ts | 8 +- .../LatencyValidation/LatencyValidation.tsx | 4 +- .../other/LatencyValidation/latencyTests.ts | 2 +- .../FabricExampleTests/AudioAPIModuleTests.mm | 32 +- .../audiodocs/docs/core/audio-context.mdx | 2 +- .../docs/fundamentals/best-practices.mdx | 4 +- .../audiodocs/docs/inputs/audio-recorder.mdx | 20 +- .../audiodocs/docs/system/audio-manager.mdx | 18 +- .../com/swmansion/audioapi/AudioAPIModule.kt | 8 +- .../system/CommunicationDeviceCallbacks.kt | 14 +- .../audioapi/system/MediaSessionManager.kt | 34 +- .../src/oldarch/NativeAudioAPIModuleSpec.java | 4 +- .../ios/audioapi/ios/AudioAPIModule.mm | 4 +- .../react-native-audio-api/src/api.web.ts | 2 +- .../src/specs/NativeAudioAPIModule.ts | 4 +- .../src/specs/NativeAudioAPIModule.web.ts | 8 +- .../src/system/AudioManager.ts | 15 +- .../src/system/types.ts | 6 +- .../src/web-system/AudioManager.ts | 4 +- packages/test-app-screen/src/Test.tsx | 2 +- 37 files changed, 559 insertions(+), 144 deletions(-) create mode 100644 apps/common-app/src/examples/EchoCancellation/EchoCancellation.tsx create mode 100644 apps/common-app/src/examples/EchoCancellation/index.ts diff --git a/.claude/skills/build-compilation-dependencies/SKILL.md b/.claude/skills/build-compilation-dependencies/SKILL.md index 505ed2a7f..0e78a0518 100644 --- a/.claude/skills/build-compilation-dependencies/SKILL.md +++ b/.claude/skills/build-compilation-dependencies/SKILL.md @@ -368,6 +368,7 @@ Resolution pitfalls learned the hard way (both handled inside `package-root.js`) | New `.cpp` not compiled in tests | Glob picks it up automatically — may need cmake reconfigure | Delete `test/build/` and re-run | | iOS compile error `unknown type 'id'` | C++ file included ObjC-only header | Compile that file as ObjC++ (separate subspec with `-x objective-c++`) | | `RCT_NEW_ARCH_ENABLED` undefined on Android | Old RN gradle plugin | Ensure `newArchEnabled=true` in app's `gradle.properties` | +| Android incremental build only: `Unresolved reference` to a top-level Kotlin function, or `Cannot access '…': it is internal in file`, while `--rerun-tasks` passes | Incremental Kotlin rounds treat the module's own earlier classes as a foreign module (library is built through the `node_modules/react-native-audio-api` symlink) | Use only public classes/objects across files in `android/src/main/java`; no top-level functions, no `internal` | | clangd only: `'React/RCTBridgeModule.h' file not found` in `.mm` files | `compile_commands.json` has no framework search path | See *clangd compile database* below — regenerate with `yarn setup:clangd` | ## clangd compile database diff --git a/.claude/skills/thread-safety-itc/SKILL.md b/.claude/skills/thread-safety-itc/SKILL.md index 8d4d6b222..52be48903 100644 --- a/.claude/skills/thread-safety-itc/SKILL.md +++ b/.claude/skills/thread-safety-itc/SKILL.md @@ -132,7 +132,7 @@ Per-quantum processable state (`ALWAYS_`/`CONDITIONAL_`/`NOT_PROCESSABLE`) is de | Non-primitive, can be written by audio thread | Triple buffer (see `AnalyserNode` for reference) | | CPU-heavy work, must not block JS or audio | `TaskOffloader` on a dedicated worker thread | | Context lifecycle (`resume`/`suspend`/`close`) | `scheduleContextPromise` → `pendingPromisesOffloader_` | -| Platform code must reach the live recorder without going through JS | Process-global handle (`ActiveRecorderHandle` — recursive mutex + `weak_ptr`; `tryStart` / `stopAndReturnInfo` / `stopAndReturnState` / `pause` / `resume` take the handle mutex before `AudioRecorder`). Android: static-JNI `NativeRecorderControl` (no HybridData). iOS: `AudioAPIModule.setAudioSessionActivity(false)` calls `stopAndReturnInfo()` before deactivating the session. HostObject `start` / `stop` go through the handle; HostObject `pause` / `resume` still call `AudioRecorder` directly. Android blocking calls run on a Kotlin executor (`goAsync()` in receivers), never a detached `std::thread` | +| Platform code must reach the live recorder without going through JS | Process-global handle (`ActiveRecorderHandle` — recursive mutex + `weak_ptr`; `tryStart` / `stopAndReturnInfo` / `stopAndReturnState` / `pause` / `resume` take the handle mutex before `AudioRecorder`). Android: static-JNI `NativeRecorderControl` (no HybridData). iOS: `AudioAPIModule.setSystemActivity(false)` calls `stopAndReturnInfo()` before deactivating the session. HostObject `start` / `stop` go through the handle; HostObject `pause` / `resume` still call `AudioRecorder` directly. Android blocking calls run on a Kotlin executor (`goAsync()` in receivers), never a detached `std::thread` | --- diff --git a/.claude/skills/turbo-modules/SKILL.md b/.claude/skills/turbo-modules/SKILL.md index 3c6372582..ba9667e27 100644 --- a/.claude/skills/turbo-modules/SKILL.md +++ b/.claude/skills/turbo-modules/SKILL.md @@ -80,8 +80,8 @@ Defines the TypeScript interface for the native module. Codegen (React Native's interface Spec extends TurboModule { install(): boolean; // synchronous — MUST run first getDevicePreferredSampleRate(): number; // synchronous - setAudioSessionActivity(enabled: boolean): Promise; - setAudioSessionOptions(...): void; + setSystemActivity(enabled: boolean): Promise; + setSystemOptions(...): void; observeAudioInterruptions(focusType, enabled): void; requestRecordingPermissions(): Promise; // ... audio devices, notifications ... diff --git a/CLAUDE.md b/CLAUDE.md index 9c0545389..a1e50e43a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,7 +33,7 @@ packages/custom-node-generator/ # Code generation tooling - **Optional FFmpeg**: Audio decoding via FFmpeg can be conditionally compiled out - **Audio Worklets**: JavaScript runs on the audio thread via React Native Worklets - **Notification-Driven Foreground Service (Android)**: `NotificationRegistry.showNotification` → `ForegroundServiceManager.subscribe` → `CentralizedForegroundService`; service lifetime follows notification visibility, never recorder/player state. The library manifest is empty — consuming apps declare the `` (Expo plugin `withAudioAPI.ts` or manually), where `android:stopWithTask` (plugin option `androidFSStopWithTask`) decides whether the service and an in-progress recording survive task removal. The recording notification's swipe policy (`dismissible` option) is fixed when the notification is built and travels as an extra of its delete intent, so `RecordingNotificationReceiver` stays stateless: a pinned notification is re-posted after a swipe (Android 14+ lets users swipe `setOngoing(true)` notifications; 13 honours the flag; 12 and older pin every foreground-service notification), a dismissible one stops the recording through the same path as the stop action -- **JS-Independent Recorder Control**: `ActiveRecorderHandle` (common C++, one-slot `weak_ptr` published by `tryStart`, released by a successful stop) is the process-global occupant. Android notification actions reach it through static-JNI `NativeRecorderControl` (no HybridData/React context — the reverse of `NativeFileInfo`). iOS `setAudioSessionActivity(false)` stops a live recording through the same handle before deactivating the session. Session methods take the handle mutex before `AudioRecorder`. `pause` / `resume` / `stopAndReturnState` / `currentState` return `RecorderState` (`core/inputs/RecorderState.h`, Kotlin ordinal mirror) so `RecordingNotificationReceiver` can render from that value. `stopAndReturnInfo` returns the recorder's file-info `Result` (JS `stop`). Successful stops with paths stash consume-once for `AudioRecorder.consumeLastRecordingResult()`; `AudioRecorder.isRecordingOngoing()` probes a session that outlived the UI +- **JS-Independent Recorder Control**: `ActiveRecorderHandle` (common C++, one-slot `weak_ptr` published by `tryStart`, released by a successful stop) is the process-global occupant. Android notification actions reach it through static-JNI `NativeRecorderControl` (no HybridData/React context — the reverse of `NativeFileInfo`). iOS `setSystemActivity(false)` stops a live recording through the same handle before deactivating the session. Session methods take the handle mutex before `AudioRecorder`. `pause` / `resume` / `stopAndReturnState` / `currentState` return `RecorderState` (`core/inputs/RecorderState.h`, Kotlin ordinal mirror) so `RecordingNotificationReceiver` can render from that value. `stopAndReturnInfo` returns the recorder's file-info `Result` (JS `stop`). Successful stops with paths stash consume-once for `AudioRecorder.consumeLastRecordingResult()`; `AudioRecorder.isRecordingOngoing()` probes a session that outlived the UI - **Testable C++ dependencies**: consumers take interface types (`std::shared_ptr`); construct concrete implementations only at platform bootstrap. Example: audio event registry (use `IAudioEventHandlerRegistry` more often than `AudioEventHandlerRegistry`). ### Native Module Entry Points diff --git a/apps/CLAUDE.md b/apps/CLAUDE.md index 0132554e3..3d99b42b7 100644 --- a/apps/CLAUDE.md +++ b/apps/CLAUDE.md @@ -33,7 +33,7 @@ apps/ Bottom Tabs ├── Tests → 2-column grid of Examples (simple feature demos) ├── Demo Apps → Single-column list of Demos (full mini-apps) -└── Other → Placeholder +└── Other → Internal tooling / repro screens, registered in `other/index.ts` (`otherScreens`); add the key to `NavigationParamList` in `examples/index.ts` ``` Each item navigates into a Stack screen. No manual stack registration needed — `examples/index.ts` and `demos/index.ts` drive it automatically. @@ -264,12 +264,12 @@ function MyPedal({ context, inputNode, outputNode }: PedalProps) { ```tsx import { AudioManager } from 'react-native-audio-api'; -AudioManager.setAudioSessionOptions({ +AudioManager.setSystemOptions({ iosCategory: 'playback', // or 'playAndRecord' for mic input iosMode: 'default', iosOptions: [], }); -await AudioManager.setAudioSessionActivity(true); +await AudioManager.setSystemActivity(true); ``` ### Recording permission diff --git a/apps/common-app/src/demos/Crossfade/Crossfade.tsx b/apps/common-app/src/demos/Crossfade/Crossfade.tsx index d56641b42..a8c763370 100644 --- a/apps/common-app/src/demos/Crossfade/Crossfade.tsx +++ b/apps/common-app/src/demos/Crossfade/Crossfade.tsx @@ -160,12 +160,12 @@ const Crossfade: FC = () => { return; } - AudioManager.setAudioSessionOptions({ + AudioManager.setSystemOptions({ iosCategory: 'playback', iosMode: 'default', iosOptions: [], }); - await AudioManager.setAudioSessionActivity(true); + await AudioManager.setSystemActivity(true); if (audioContext.current.state === 'suspended') { await audioContext.current.resume(); @@ -226,7 +226,7 @@ const Crossfade: FC = () => { gainNode2.current = null; await audioContext.current.suspend(); - await AudioManager.setAudioSessionActivity(false); + await AudioManager.setSystemActivity(false); setIsPlaying(false); }, [isPlaying]); diff --git a/apps/common-app/src/demos/PedalBoard/PedalBoard.tsx b/apps/common-app/src/demos/PedalBoard/PedalBoard.tsx index 82f7ec02c..c8e80012a 100644 --- a/apps/common-app/src/demos/PedalBoard/PedalBoard.tsx +++ b/apps/common-app/src/demos/PedalBoard/PedalBoard.tsx @@ -38,11 +38,11 @@ export default function PedalBoard() { useEffect(() => { const init = async () => { setIsLoading(true); - AudioManager.setAudioSessionOptions({ + AudioManager.setSystemOptions({ iosCategory: 'playAndRecord', iosMode: 'default', }) - AudioManager.setAudioSessionActivity(true); + AudioManager.setSystemActivity(true); try { // Load audio file diff --git a/apps/common-app/src/demos/Record/Record.tsx b/apps/common-app/src/demos/Record/Record.tsx index 37ddb0e2b..4f88707b1 100644 --- a/apps/common-app/src/demos/Record/Record.tsx +++ b/apps/common-app/src/demos/Record/Record.tsx @@ -85,14 +85,14 @@ const Record: FC = () => { setHasPermissions(true); } - AudioManager.setAudioSessionOptions({ + AudioManager.setSystemOptions({ iosCategory: 'playAndRecord', iosMode: 'default', iosOptions: ['defaultToSpeaker', 'allowBluetoothA2DP'], }); try { - await AudioManager.setAudioSessionActivity(true); + await AudioManager.setSystemActivity(true); } catch (error) { console.error(error); Alert.alert('Error', 'Failed to activate audio session for recording.'); @@ -337,7 +337,7 @@ const Record: FC = () => { stopPlayback(); if (!AudioRecorder.isRecordingOngoing()) { - AudioManager.setAudioSessionActivity(false); + AudioManager.setSystemActivity(false); } }; }, [stopPlayback]); diff --git a/apps/common-app/src/examples/AudioFile/AudioFile.tsx b/apps/common-app/src/examples/AudioFile/AudioFile.tsx index 6e88994bc..612e9bbe1 100644 --- a/apps/common-app/src/examples/AudioFile/AudioFile.tsx +++ b/apps/common-app/src/examples/AudioFile/AudioFile.tsx @@ -29,14 +29,14 @@ const AudioFile: FC = () => { setPositionPercentage(offset); }); - AudioManager.setAudioSessionOptions({ + AudioManager.setSystemOptions({ iosCategory: 'playback', iosMode: 'default', iosOptions: [], }); try { - await AudioManager.setAudioSessionActivity(true); + await AudioManager.setSystemActivity(true); } catch (error) { console.error('Failed to activate audio session:', error); Alert.alert( @@ -100,7 +100,7 @@ const AudioFile: FC = () => { return () => { AudioPlayer.reset(); PlaybackNotificationManager.hide(); - AudioManager.setAudioSessionActivity(false); + AudioManager.setSystemActivity(false); }; }, [fetchAudioBuffer]); @@ -152,7 +152,7 @@ const AudioFile: FC = () => { if (event.type === 'ended' && wasPlaying) { BackgroundTimer.setTimeout(async () => { AudioPlayer.setVolume(1.0); - AudioManager.setAudioSessionActivity(true); + AudioManager.setSystemActivity(true); await AudioPlayer.play(); setIsPlaying(true); setWasPlaying(false); diff --git a/apps/common-app/src/examples/ChannelMergerSplitter/ChannelMergerSplitter.tsx b/apps/common-app/src/examples/ChannelMergerSplitter/ChannelMergerSplitter.tsx index 39d154941..3bc5a0d35 100644 --- a/apps/common-app/src/examples/ChannelMergerSplitter/ChannelMergerSplitter.tsx +++ b/apps/common-app/src/examples/ChannelMergerSplitter/ChannelMergerSplitter.tsx @@ -64,12 +64,12 @@ const ChannelMergerSplitter: FC = () => { return; } - AudioManager.setAudioSessionOptions({ + AudioManager.setSystemOptions({ iosCategory: 'playback', iosMode: 'default', iosOptions: [], }); - await AudioManager.setAudioSessionActivity(true); + await AudioManager.setSystemActivity(true); if (ctx.state === 'suspended') { await ctx.resume(); @@ -170,7 +170,7 @@ const ChannelMergerSplitter: FC = () => { bufferRef.current = null; ctx.close().catch(() => {}); audioContextRef.current = null; - AudioManager.setAudioSessionActivity(false).catch(() => {}); + AudioManager.setSystemActivity(false).catch(() => {}); }; }, [teardown]); diff --git a/apps/common-app/src/examples/ConvolverIR/ConvolverIR.tsx b/apps/common-app/src/examples/ConvolverIR/ConvolverIR.tsx index 4a1598f35..dd8d5f339 100644 --- a/apps/common-app/src/examples/ConvolverIR/ConvolverIR.tsx +++ b/apps/common-app/src/examples/ConvolverIR/ConvolverIR.tsx @@ -62,7 +62,7 @@ const ConvolverIR: FC = () => { bufferRef.current = null; ctx.close().catch(() => {}); audioContextRef.current = null; - AudioManager.setAudioSessionActivity(false).catch(() => {}); + AudioManager.setSystemActivity(false).catch(() => {}); }; }, []); @@ -72,7 +72,7 @@ const ConvolverIR: FC = () => { return; } bufferSourceRef.current?.stop(0); - // await AudioManager.setAudioSessionActivity(false); + // await AudioManager.setSystemActivity(false); setIsPlaying(false); }, []); @@ -88,12 +88,12 @@ const ConvolverIR: FC = () => { return; } - AudioManager.setAudioSessionOptions({ + AudioManager.setSystemOptions({ iosCategory: 'playback', iosMode: 'default', iosOptions: [], }); - await AudioManager.setAudioSessionActivity(true); + await AudioManager.setSystemActivity(true); if (ctx.state === 'suspended') { await ctx.resume(); diff --git a/apps/common-app/src/examples/EchoCancellation/EchoCancellation.tsx b/apps/common-app/src/examples/EchoCancellation/EchoCancellation.tsx new file mode 100644 index 000000000..5ee8348f6 --- /dev/null +++ b/apps/common-app/src/examples/EchoCancellation/EchoCancellation.tsx @@ -0,0 +1,410 @@ +import React, { FC, useEffect, useRef, useState } from 'react'; +import { Platform, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { + AudioContext, + AudioManager, + AudioRecorder, +} from 'react-native-audio-api'; +import type { + AndroidAudioMode, + AndroidInputPreset, + AndroidOutputProfile, + AudioBufferSourceNode, + IOSMode, +} from 'react-native-audio-api'; + +import { Button, Container } from '../../components'; +import { colors, layout } from '../../styles'; +import { UnsupportedNotice } from '../../testComponents'; +import voiceSample from '../AudioFile/voice-sample-landing.mp3'; + +interface SetupText { + title: string; + description: string; +} + +interface AndroidEchoConfig extends SetupText { + androidMode?: AndroidAudioMode; + /** + * Routes voice-communication output to the speaker via setCommunicationDevice + * without a session. + */ + requestSpeakerWithoutSession?: boolean; + outputProfile: AndroidOutputProfile; + inputPreset: AndroidInputPreset; +} + +interface IOSEchoConfig extends SetupText { + iosMode: IOSMode; + voiceProcessing: boolean; +} + +interface EchoSetup { + key: string; + android: AndroidEchoConfig; + ios: IOSEchoConfig; +} + +const ECHO_SETUPS: EchoSetup[] = [ + { + key: 'sessionAndVoiceStreams', + android: { + title: 'Session + voice streams', + description: + 'inCommunication session routed to the speaker, voiceCommunication output and input', + androidMode: 'inCommunication', + outputProfile: 'voiceCommunication', + inputPreset: 'voiceCommunication', + }, + ios: { + title: 'voiceChat + voice processing', + description: + 'playAndRecord with voiceChat mode on the speaker, recorder voice processing on', + iosMode: 'voiceChat', + voiceProcessing: true, + }, + }, + { + key: 'voiceStreamsOnly', + android: { + title: 'Voice streams, no session', + description: + 'voiceCommunication output and input, normal audio mode, speaker requested with setCommunicationDevice', + requestSpeakerWithoutSession: true, + outputProfile: 'voiceCommunication', + inputPreset: 'voiceCommunication', + }, + ios: { + title: 'Voice processing, default mode', + description: + 'playAndRecord with default mode on the speaker, recorder voice processing on', + iosMode: 'default', + voiceProcessing: true, + }, + }, + { + key: 'noEchoCancellation', + android: { + title: 'No echo cancellation', + description: + 'media output, voiceRecognition input (platform default), normal audio mode', + outputProfile: 'media', + inputPreset: 'voiceRecognition', + }, + ios: { + title: 'No echo cancellation', + description: + 'playAndRecord with default mode on the speaker, recorder voice processing off', + iosMode: 'default', + voiceProcessing: false, + }, + }, +]; + +const variantOf = (setup: EchoSetup): SetupText => + Platform.OS === 'ios' ? setup.ios : setup.android; + +const HINT = Platform.select({ + ios: 'Stay silent, keep the phone on the table, and set the volume to maximum. Voice processing also lowers the playback volume, so use Replay to hear how much of the drop is cancelled echo.', + default: + 'Stay silent, keep the phone on the table, and set both media and call volume to maximum: the voice setups play at call volume.', +}); + +const NOISE_FLOOR_SECONDS = 1.5; +const PLAYBACK_SECONDS = 5; +// Skips the start of playback so output latency does not dilute the echo level. +const PLAYBACK_SETTLE_SECONDS = 0.3; +const RECORDER_CHUNK_FRAMES = 2048; + +interface EchoResult { + setup: EchoSetup; + noiseFloorDb: number; + echoDb: number; + samples: Float32Array; + sampleRate: number; +} + +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const rmsDb = (samples: Float32Array, from: number, to: number) => { + let sumOfSquares = 0; + const end = Math.min(to, samples.length); + for (let i = from; i < end; i++) { + sumOfSquares += samples[i] * samples[i]; + } + const rms = Math.sqrt(sumOfSquares / Math.max(1, end - from)); + return 20 * Math.log10(Math.max(rms, 1e-9)); +}; + +const concatChunks = (chunks: Float32Array[]) => { + const samples = new Float32Array( + chunks.reduce((length, chunk) => length + chunk.length, 0) + ); + let offset = 0; + chunks.forEach((chunk) => { + samples.set(chunk, offset); + offset += chunk.length; + }); + return samples; +}; + +const formatDb = (db: number) => `${db.toFixed(1)} dBFS`; + +async function recordEcho(setup: EchoSetup): Promise { + const { android, ios } = setup; + AudioManager.setSystemOptions({ + iosCategory: 'playAndRecord', + iosMode: ios.iosMode, + // playAndRecord routes to the receiver unless told otherwise. + iosOptions: ['defaultToSpeaker'], + androidMode: android.androidMode, + androidCommunicationDevice: android.androidMode ? 'speaker' : undefined, + }); + // Without androidMode this only activates the iOS session; Android no-ops. + await AudioManager.setSystemActivity(true); + if (Platform.OS === 'android' && android.requestSpeakerWithoutSession) { + await AudioManager.setCommunicationDevice('speaker'); + } + + const context = new AudioContext({ + androidOutputProfile: android.outputProfile, + }); + const recorder = new AudioRecorder({ + androidInputPreset: android.inputPreset, + iosVoiceProcessing: ios.voiceProcessing, + }); + let source: AudioBufferSourceNode | null = null; + + try { + const voice = await context.decodeAudioData(voiceSample); + if (context.state === 'suspended') { + await context.resume(); + } + + const chunks: Float32Array[] = []; + const callback = recorder.onAudioReady( + { + sampleRate: context.sampleRate, + channelCount: 1, + bufferLength: RECORDER_CHUNK_FRAMES, + }, + (event) => chunks.push(event.buffer.getChannelData(0).slice()) + ); + if (callback.status === 'error') { + throw new Error(callback.message); + } + + const started = await recorder.start(); + if (started.status === 'error') { + throw new Error(started.message); + } + + await wait(NOISE_FLOOR_SECONDS * 1000); + const playbackStartFrame = chunks.reduce( + (length, chunk) => length + chunk.length, + 0 + ); + + source = context.createBufferSource(); + source.buffer = voice; + source.loop = true; + source.connect(context.destination); + source.start(); + await wait(PLAYBACK_SECONDS * 1000); + + source.stop(); + await recorder.stop(); + + const samples = concatChunks(chunks); + const settleFrames = Math.round( + PLAYBACK_SETTLE_SECONDS * context.sampleRate + ); + return { + setup, + noiseFloorDb: rmsDb(samples, 0, playbackStartFrame), + echoDb: rmsDb(samples, playbackStartFrame + settleFrames, samples.length), + samples, + sampleRate: context.sampleRate, + }; + } finally { + source?.stop(); + recorder.clearOnAudioReady(); + if (recorder.isRecording()) { + await recorder.stop(); + } + await context.close(); + await AudioManager.setSystemActivity(false).catch(() => {}); + } +} + +const EchoCancellation: FC = () => { + const [runningSetup, setRunningSetup] = useState(null); + const [results, setResults] = useState([]); + const [error, setError] = useState(null); + const replayContextRef = useRef(null); + + useEffect( + () => () => { + replayContextRef.current?.close(); + replayContextRef.current = null; + }, + [] + ); + + const runSetup = async (setup: EchoSetup) => { + setError(null); + const permission = await AudioManager.requestRecordingPermissions(); + if (permission !== 'Granted') { + setError(`Recording permission: ${permission}`); + return; + } + + setRunningSetup(setup.key); + try { + const result = await recordEcho(setup); + setResults((previous) => [ + result, + ...previous.filter((entry) => entry.setup.key !== setup.key), + ]); + } catch (runError) { + setError( + `${variantOf(setup).title} failed: ${runError instanceof Error ? runError.message : String(runError)}` + ); + } finally { + setRunningSetup(null); + } + }; + + // Replays as plain media playback so every recording is heard at the same + // volume, without voice processing. + const replay = async (result: EchoResult) => { + AudioManager.setSystemOptions({ + iosCategory: 'playback', + iosMode: 'default', + iosOptions: [], + }); + await AudioManager.setSystemActivity(true); + + replayContextRef.current?.close(); + const context = new AudioContext({ androidOutputProfile: 'media' }); + replayContextRef.current = context; + + const buffer = context.createBuffer( + 1, + result.samples.length, + result.sampleRate + ); + buffer.copyToChannel(result.samples, 0); + const source = context.createBufferSource(); + source.buffer = buffer; + source.connect(context.destination); + source.start(); + }; + + if (Platform.OS !== 'android' && Platform.OS !== 'ios') { + return ( + + + + ); + } + + return ( + + + + Each run records {NOISE_FLOOR_SECONDS} s of silence, then{' '} + {PLAYBACK_SECONDS} s while the voice sample plays on the speaker.{' '} + {HINT} The smaller the rise above the floor, the more echo was + cancelled. + + {ECHO_SETUPS.map((setup) => ( + + + {variantOf(setup).title} + {variantOf(setup).description} + +