From 486b2b106c178d0ebf91cf208cef48e8672fd392 Mon Sep 17 00:00:00 2001 From: Kim CHOUARD Date: Wed, 26 Aug 2026 17:30:30 +0200 Subject: [PATCH] feat(android): select the capture device in setInputDevice `AudioManager.setInputDevice` was implemented on iOS and did nothing on Android: the module method carried a `// TODO: noop for now` and resolved unconditionally, so a caller could not tell that the selection had been ignored and capture stayed on the platform default. The selection now reaches the recorder. `AudioInputSelection` holds the preferred device id for the process, since Android routes capture per process and the selection arrives through the `AudioAPIModule` TurboModule, which holds no reference to any recorder. `AndroidAudioRecorder::openAudioStream()` reads it and passes it to `oboe::AudioStreamBuilder::setDeviceId()`. Nothing in the shared `common/cpp` layer depends on any of it. Two properties are worth calling out, because both are easy to get wrong: A stream that opened on a different device is replaced rather than reused. `stop()` stops the stream without closing it and `openAudioStream()` returned early whenever a stream already existed, so a recorder reused its first stream for every later `start()`. Applying the device only in the builder would have worked on the first recording and never again. A device that was not honoured fails the open. Oboe applies `setDeviceId` on the AAudio backend only and reports `kUnspecified` under OpenSL ES, so the opened stream's `getDeviceId()` is compared against the request and a mismatch closes the stream and reports an error. Recording from a device the caller did not choose is worse than not recording. Callers that never select a device are unaffected: the selection is `oboe::kUnspecified`, `setDeviceId` is never called, the readback comparison is skipped and the early return behaves exactly as before. Also adds the three `TYPE_USB_*` categories to `parseDeviceCategory`, which previously rendered a USB interface as "Other (11)", and populates `currentInputs`, which was always empty so `useAudioInput` could not report the current device across a remount. --- .../common-app/src/examples/Record/Record.tsx | 14 ++- .../audiodocs/docs/hooks/select-input.mdx | 4 +- .../audiodocs/docs/system/audio-manager.mdx | 19 ++- .../cpp/audioapi/android/AudioAPIModule.cpp | 7 ++ .../cpp/audioapi/android/AudioAPIModule.h | 6 + .../android/core/AndroidAudioRecorder.cpp | 111 +++++++++++++++++- .../android/core/AndroidAudioRecorder.h | 12 ++ .../android/core/AudioInputSelection.cpp | 45 +++++++ .../android/core/AudioInputSelection.h | 41 +++++++ .../com/swmansion/audioapi/AudioAPIModule.kt | 40 ++++++- .../audioapi/system/MediaSessionManager.kt | 61 ++++++++-- .../src/hooks/useAudioInput.ts | 11 +- .../src/system/AudioManager.ts | 8 ++ .../src/system/types.ts | 10 +- 14 files changed, 362 insertions(+), 27 deletions(-) create mode 100644 packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AudioInputSelection.cpp create mode 100644 packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AudioInputSelection.h diff --git a/apps/common-app/src/examples/Record/Record.tsx b/apps/common-app/src/examples/Record/Record.tsx index 77da4f5f0..0e5459808 100644 --- a/apps/common-app/src/examples/Record/Record.tsx +++ b/apps/common-app/src/examples/Record/Record.tsx @@ -216,11 +216,19 @@ const Record: FC = () => { }; const onSelect = useCallback( - (id: string) => { + async (id: string) => { const input = availableInputs.find((d) => d.id === id); - if (input) { - onSelectInput(input); + if (!input) { + return; + } + + try { + await onSelectInput(input); + } catch (error) { + // Android refuses a switch while a recorder is running, and either + // platform refuses a device that went away between listing and picking. + Alert.alert('Input Device Error', `${error}`); } }, [availableInputs, onSelectInput] diff --git a/packages/audiodocs/docs/hooks/select-input.mdx b/packages/audiodocs/docs/hooks/select-input.mdx index 743b87485..787ac0fbb 100644 --- a/packages/audiodocs/docs/hooks/select-input.mdx +++ b/packages/audiodocs/docs/hooks/select-input.mdx @@ -14,7 +14,9 @@ The `useAudioInput` hook provides an interface for: - switching between different input devices :::info Platform support -Input device selection is currently only supported on iOS. On Android, `useAudioInput` is implemented as a no-op: the hook will not list or switch input devices, and any selection calls will effectively be ignored. +Input device selection works on iOS and on Android. The two platforms differ in when a selection takes effect: iOS reroutes the running session immediately, while Android binds the device as a capture stream opens, so the selection applies to recorders started afterwards. Calling `onSelectInput` on Android while a recorder is running or paused throws, rather than deferring the switch without saying so. Android device selection also needs the AAudio backend; see [`setInputDevice`](../system/audio-manager.mdx#setinputdevice). + +On Android, `currentInput` is `null` until a device is selected through this hook, because the platform does not report which input it would pick on its own. ::: ## Signature diff --git a/packages/audiodocs/docs/system/audio-manager.mdx b/packages/audiodocs/docs/system/audio-manager.mdx index 8c356d653..464b154c0 100644 --- a/packages/audiodocs/docs/system/audio-manager.mdx +++ b/packages/audiodocs/docs/system/audio-manager.mdx @@ -167,8 +167,25 @@ Checks if notification permissions were previously granted. Checks currently used and available devices. +On Android, `currentInputs` reports the device selected through [`setInputDevice`](#setinputdevice) and is empty until one is selected, since the platform does not report which input it would pick on its own. `currentOutputs` is always empty on Android. + #### Returns `Promise`, which is resolved after receiving the answer from the system. +### `setInputDevice` + +Selects the device that audio is captured from, using an `id` taken from `getDevicesInfo().availableInputs`. + +| Name | Type | Description | +| :----: | :----: | :---- | +| `deviceId` | `string` | Identifier of the input device to capture from. | + +The two platforms differ in when the selection takes effect: + +- **iOS** sets the session's preferred input, which reroutes a running session right away. +- **Android** binds the device while an Oboe capture stream opens, so the selection applies to recorders started afterwards. Calling it while a recorder is running or paused rejects instead of deferring the switch silently: stop the recorder, select the device, then start it again. A paused recorder still holds its stream and resumes onto the same device, which is why it is refused too. Android also needs the AAudio backend, which is what Oboe picks on Android 8.1 and newer; a recorder that can only open through OpenSL ES fails to start with an explanatory message rather than recording from the wrong device. If the selected device disappears mid-recording, the recorder reports an error through `onError` instead of falling back to the built-in microphone. + +#### Returns `Promise`, which is resolved once the device is selected and rejected when the device cannot be found, when the system fails to switch to it, or, on Android, when a recorder is running or paused. + ## Remarks ### `AudioFocusType` @@ -329,7 +346,7 @@ export type AudioDeviceList = AudioDeviceInfo[]; export interface AudioDevicesInfo { availableInputs: AudioDeviceList; availableOutputs: AudioDeviceList; - currentInputs: AudioDeviceList; // iOS + currentInputs: AudioDeviceList; // iOS, and Android once a device is selected currentOutputs: AudioDeviceList; // iOS } ``` diff --git a/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/AudioAPIModule.cpp b/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/AudioAPIModule.cpp index 943c42a5c..aa08255de 100644 --- a/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/AudioAPIModule.cpp +++ b/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/AudioAPIModule.cpp @@ -1,5 +1,6 @@ #include #include +#include #include #include @@ -32,6 +33,7 @@ void AudioAPIModule::registerNatives() { makeNativeMethod( "invokeHandlerWithEventNameAndEventBody", AudioAPIModule::invokeHandlerWithEventNameAndEventBody), + makeNativeMethod("setPreferredInputDeviceId", AudioAPIModule::setPreferredInputDeviceId), }); } @@ -55,4 +57,9 @@ void AudioAPIModule::invokeHandlerWithEventNameAndEventBody( event, kBroadcastListenerId, buildPayloadFromJniMap(event, eventBody)); } +jboolean AudioAPIModule::setPreferredInputDeviceId(jint deviceId) { + return static_cast( + AudioInputSelection::setPreferredDeviceId(static_cast(deviceId))); +} + } // namespace audioapi diff --git a/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/AudioAPIModule.h b/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/AudioAPIModule.h index b0719a4db..d9ae6ac3b 100644 --- a/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/AudioAPIModule.h +++ b/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/AudioAPIModule.h @@ -26,6 +26,12 @@ class AudioAPIModule : public jni::HybridClass { jint eventOrdinal, jni::alias_ref> eventBody); + /// @brief Hands the capture device chosen through AudioManager.setInputDevice + /// to the recorders. + /// @returns false when a capture stream is already running, in which case the + /// selection is left unchanged. See AudioInputSelection. + jboolean setPreferredInputDeviceId(jint deviceId); + private: friend HybridBase; diff --git a/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AndroidAudioRecorder.cpp b/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AndroidAudioRecorder.cpp index 1a24cf8fe..3e489da6b 100644 --- a/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AndroidAudioRecorder.cpp +++ b/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AndroidAudioRecorder.cpp @@ -1,5 +1,6 @@ #include #include +#include #include #include @@ -51,6 +52,30 @@ std::optional inputPresetFromString(const std::string &name) } return std::nullopt; } + +/// Runs an action on scope exit unless it is dismissed first, so that a +/// multi-step operation can undo a claim it took up front without repeating +/// the undo on every failure path. +template +class ScopeExit { + public: + explicit ScopeExit(Action action) : action_(std::move(action)) {} + ~ScopeExit() { + if (armed_) { + action_(); + } + } + + DELETE_COPY_AND_MOVE(ScopeExit); + + void dismiss() { + armed_ = false; + } + + private: + Action action_; + bool armed_ = true; +}; } // namespace AndroidAudioRecorder::AndroidAudioRecorder( @@ -60,7 +85,8 @@ AndroidAudioRecorder::AndroidAudioRecorder( inputPreset_(std::move(options.androidInputPreset)), streamSampleRate_(0.0), streamChannelCount_(0), - streamMaxBufferSizeInFrames_(0) {} + streamMaxBufferSizeInFrames_(0), + streamDeviceId_(AudioInputSelection::kSystemDefaultDeviceId) {} /// @brief Destructor ensures that the audio stream and each output type are closed and flushed up remaining data. /// callable from the JS thread or handled by audio thread (if js dropped recorder first). @@ -87,12 +113,25 @@ AndroidAudioRecorder::~AndroidAudioRecorder() { /// @brief Creates and opens the Oboe audio input stream for recording. /// calculates the "native" or hardware stream parameters for other interfaces /// to use. -/// Callable from the JS thread only. +/// Callable from the JS thread, and from the Oboe error thread through +/// onErrorAfterClose(). +/// The stream is opened on the device chosen through AudioInputSelection; an +/// already open stream bound to a different device is replaced, since Oboe +/// binds the capture device while the stream opens. /// @returns Success status or Error status with message. Result AndroidAudioRecorder::openAudioStream() { std::scoped_lock streamLock(streamMutex_); + + const int32_t preferredDeviceId = AudioInputSelection::getPreferredDeviceId(); + if (mStream_ != nullptr) { - return Result::Ok(None); + if (streamDeviceId_ == preferredDeviceId) { + return Result::Ok(None); + } + + mStream_->requestStop(); + mStream_->close(); + mStream_.reset(); } oboe::AudioStreamBuilder builder; @@ -109,6 +148,10 @@ Result AndroidAudioRecorder::openAudioStream() { builder.setInputPreset(*preset); } + if (preferredDeviceId != AudioInputSelection::kSystemDefaultDeviceId) { + builder.setDeviceId(preferredDeviceId); + } + auto result = builder.openStream(mStream_); if (result != oboe::Result::OK || mStream_ == nullptr) { @@ -116,6 +159,27 @@ Result AndroidAudioRecorder::openAudioStream() { "Failed to open audio stream: " + std::string(oboe::convertToText(result))); } + // Oboe honours setDeviceId on the AAudio backend only; OpenSL ES drops the + // request and reports kUnspecified instead (see AudioStreamBuilder::setDeviceId). + // Recording from a device the caller did not ask for is worse than not + // recording at all, so the stream is dropped and the open reported as failed. + if (preferredDeviceId != AudioInputSelection::kSystemDefaultDeviceId && + mStream_->getDeviceId() != preferredDeviceId) { + const int32_t openedDeviceId = mStream_->getDeviceId(); + + mStream_->close(); + mStream_.reset(); + + std::string message = std::format( + "Input device {} was requested, but the capture stream opened on device {}. " + "Selecting an input device needs the AAudio backend; OpenSL ES ignores the request.", + preferredDeviceId, + openedDeviceId); + + return Result::Err(std::move(message)); + } + + streamDeviceId_ = preferredDeviceId; streamSampleRate_ = static_cast(mStream_->getSampleRate()); streamChannelCount_ = mStream_->getChannelCount(); streamMaxBufferSizeInFrames_ = mStream_->getBufferSizeInFrames(); @@ -138,6 +202,15 @@ Result AndroidAudioRecorder::start(const std::string &fil return Result::Err("Recorder is already recording"); } + // Claim the input selection before reading it, and keep the claim for the + // whole attempt. setInputDevice runs on the React Native module thread while + // this body runs on the promise thread pool, so without the claim a selection + // could be accepted between openAudioStream() reading the current one and the + // stream actually running, leaving the recorder on the previous device with + // nothing reporting it. + setRunningCapture(true); + ScopeExit releaseCapture([this] { setRunningCapture(false); }); + auto streamResult = openAudioStream(); if (!streamResult.is_ok()) { @@ -187,10 +260,31 @@ Result AndroidAudioRecorder::start(const std::string &fil "Failed to start stream: " + std::string(oboe::convertToText(result))); } + releaseCapture.dismiss(); state_.store(RecorderState::Recording, std::memory_order_release); return Result::Ok(None); } +/// @brief Adds or removes this recorder from AudioInputSelection's +/// running-capture count. +/// Takes streamMutex_, which is recursive, so it can be called from methods +/// already holding it. +void AndroidAudioRecorder::setRunningCapture(bool running) { + std::scoped_lock streamLock(streamMutex_); + + if (countedAsRunningCapture_ == running) { + return; + } + + countedAsRunningCapture_ = running; + + if (running) { + AudioInputSelection::captureStarted(); + } else { + AudioInputSelection::captureStopped(); + } +} + /// @brief Stops the audio stream and finalizes any output (file writing, callback, adapter node). /// This method should be called from the JS thread only. /// @returns On success, returns the file URI, size in MB and duration in seconds of the recorded file (if file output is enabled). @@ -220,6 +314,7 @@ AndroidAudioRecorder::stop() { } state_.store(RecorderState::Idle, std::memory_order_release); + setRunningCapture(false); lastCallbackFrameCount_.store(0, std::memory_order_release); mStream_->requestStop(); @@ -569,6 +664,7 @@ bool AndroidAudioRecorder::isIdle() const { void AndroidAudioRecorder::cleanup() { std::scoped_lock streamLock(streamMutex_); state_.store(RecorderState::Idle, std::memory_order_release); + setRunningCapture(false); if (mStream_ != nullptr) { mStream_->requestStop(); @@ -593,6 +689,14 @@ void AndroidAudioRecorder::onErrorAfterClose(oboe::AudioStream *stream, oboe::Re cleanup(); + // Re-take the claim before the replacement stream reads the selection, and + // hold it until that stream is running. cleanup() above dropped it, and + // opening a device takes long enough that a setInputDevice landing in an + // unclaimed window would be accepted while this stream binds the previous + // device, leaving the API affirming a device that is not being recorded. + setRunningCapture(true); + ScopeExit releaseCapture([this] { setRunningCapture(false); }); + auto streamResult = openAudioStream(); if (!streamResult.is_ok()) { @@ -611,6 +715,7 @@ void AndroidAudioRecorder::onErrorAfterClose(oboe::AudioStream *stream, oboe::Re } mStream_->requestStart(); + releaseCapture.dismiss(); state_.store(RecorderState::Recording, std::memory_order_release); } } diff --git a/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AndroidAudioRecorder.h b/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AndroidAudioRecorder.h index d30318d75..5a5fa716d 100644 --- a/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AndroidAudioRecorder.h +++ b/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AndroidAudioRecorder.h @@ -68,12 +68,24 @@ class AndroidAudioRecorder : public oboe::AudioStreamCallback, std::atomic streamSampleRate_; int32_t streamChannelCount_; int32_t streamMaxBufferSizeInFrames_; + /// Selection mStream_ was opened for. Oboe binds the capture device while the + /// stream opens, so this is compared against the current selection to tell + /// whether an already open stream still points at the right device. + /// Guarded by streamMutex_. + int32_t streamDeviceId_; std::shared_ptr mStream_; std::vector recordingSegmentPaths_; /// Updated on the audio thread from each input callback `numFrames`. std::atomic lastCallbackFrameCount_{0}; + /// Whether this recorder is counted among AudioInputSelection's running + /// captures. Guarded by streamMutex_. + bool countedAsRunningCapture_{false}; Result openAudioStream(); + /// Keeps AudioInputSelection's running-capture count in step with this + /// recorder, so that a device selection made mid-recording is refused rather + /// than silently deferred. Idempotent. + void setRunningCapture(bool running); std::shared_ptr createFileWriter( const std::shared_ptr &props); Result setupFileWriter( diff --git a/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AudioInputSelection.cpp b/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AudioInputSelection.cpp new file mode 100644 index 000000000..aea94cbd9 --- /dev/null +++ b/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AudioInputSelection.cpp @@ -0,0 +1,45 @@ +#include + +#include + +namespace audioapi::AudioInputSelection { + +namespace { +/// Guards both values together, so setPreferredDeviceId decides and writes +/// without a window in which a recorder could claim the selection in between. +std::mutex selectionMutex; +int32_t preferredDeviceId = kSystemDefaultDeviceId; +int32_t runningCaptureCount = 0; +} // namespace + +bool setPreferredDeviceId(int32_t deviceId) { + std::scoped_lock selectionLock(selectionMutex); + + if (deviceId == preferredDeviceId) { + return true; + } + + if (runningCaptureCount > 0) { + return false; + } + + preferredDeviceId = deviceId; + return true; +} + +int32_t getPreferredDeviceId() { + std::scoped_lock selectionLock(selectionMutex); + return preferredDeviceId; +} + +void captureStarted() { + std::scoped_lock selectionLock(selectionMutex); + ++runningCaptureCount; +} + +void captureStopped() { + std::scoped_lock selectionLock(selectionMutex); + --runningCaptureCount; +} + +} // namespace audioapi::AudioInputSelection diff --git a/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AudioInputSelection.h b/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AudioInputSelection.h new file mode 100644 index 000000000..36fd476b1 --- /dev/null +++ b/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AudioInputSelection.h @@ -0,0 +1,41 @@ +#pragma once + +#include +#include + +namespace audioapi { + +/// @brief Process-wide choice of the capture device every AndroidAudioRecorder +/// opens its input stream on, set from Kotlin by AudioManager.setInputDevice. +/// +/// Android routes capture per process rather than per stream, and the selection +/// arrives through the AudioAPIModule TurboModule, which knows nothing about the +/// individual recorders. It therefore cannot live on a recorder and is kept here +/// instead. Nothing in the shared common/cpp layer depends on it. +/// +/// A stream reads the selection once, while it opens, and stays bound to that +/// device until it is reopened. The running-capture count exists so that a +/// selection made while a stream is running can be refused instead of being +/// silently deferred to the next start(). +namespace AudioInputSelection { + +/// @brief Leaves the capture device to the platform, which is Oboe's default. +constexpr int32_t kSystemDefaultDeviceId = oboe::kUnspecified; + +/// @param deviceId An Android AudioDeviceInfo id, or kSystemDefaultDeviceId to +/// hand the choice back to the platform. +/// @returns false when a capture stream is running and the requested device +/// differs from the current selection. The selection is then left unchanged, +/// because a running stream cannot be moved onto it. +bool setPreferredDeviceId(int32_t deviceId); + +int32_t getPreferredDeviceId(); + +/// @brief Reports that a recorder holds the selection: it is about to read it, +/// or is already feeding audio from it. Must be paired with captureStopped(), +/// including on teardown, and calls must balance. +void captureStarted(); +void captureStopped(); + +} // namespace AudioInputSelection +} // namespace audioapi 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..250f3d853 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 @@ -31,6 +31,7 @@ class AudioAPIModule( companion object { const val NAME = NativeAudioAPIModuleSpec.NAME private const val TAG = "AudioAPIModule" + private const val INPUT_DEVICE_ERROR = "E_INPUT_DEVICE" } val reactContext: WeakReference = WeakReference(reactContext) @@ -51,6 +52,15 @@ class AudioAPIModule( eventBody: Map, ) + /** + * Hands the selected capture device to the native recorders. + * + * Returns false when a capture stream is already running, in which case the + * selection is left untouched: Oboe binds the capture device while the stream + * opens, so a running stream cannot be moved onto another one. + */ + private external fun setPreferredInputDeviceId(deviceId: Int): Boolean + init { try { System.loadLibrary("react-native-audio-api") @@ -172,12 +182,38 @@ class AudioAPIModule( promise.resolve(MediaSessionManager.getDevicesInfo()) } + /** + * Selects the capture device every recorder opens its input stream on. + * + * Unlike iOS, which reroutes a live session, the selection is bound while an + * Oboe input stream opens. Changing it therefore only affects streams opened + * afterwards, and is rejected outright while a recorder holds a stream so that + * a caller never mistakes a deferred switch for an applied one. A paused + * recorder still holds its stream and resumes onto the same device, so it + * counts as holding one. + */ + @RequiresApi(Build.VERSION_CODES.M) override fun setInputDevice( deviceId: String?, promise: Promise?, ) { - // TODO: noop for now, but it should be moved to upcoming - // audio engine implementation for android (duplex stream) + val device = deviceId?.let { MediaSessionManager.findInputDevice(it) } + + if (device == null) { + promise?.reject(INPUT_DEVICE_ERROR, "Input device with id $deviceId not found", null) + return + } + + if (!setPreferredInputDeviceId(device.id)) { + promise?.reject( + INPUT_DEVICE_ERROR, + "Cannot change the input device while a recorder is running or paused. Stop the recorder, select the device, then start it again.", + null, + ) + return + } + + MediaSessionManager.setPreferredInputDevice(device) promise?.resolve(null) } 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 900ad1a04..617f82e34 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 @@ -17,6 +17,7 @@ import androidx.core.content.ContextCompat import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.WritableMap import com.facebook.react.modules.core.PermissionAwareActivity import com.facebook.react.modules.core.PermissionListener import com.swmansion.audioapi.AudioAPIModule @@ -210,32 +211,55 @@ object MediaSessionManager { notificationManager.createNotificationChannel(mChannel) } + /** + * Capture device selected through `AudioManager.setInputDevice`, kept so that + * [getDevicesInfo] can report it back. It mirrors the selection held by the + * native capture layer, and `AudioAPIModule.setInputDevice` is the only writer + * of either. + * + * Null means no explicit selection was made and the platform picks the device. + * Android offers no way to learn which one that is before a stream opens, so + * `currentInputs` stays empty in that case. + * + * Written from the React Native module thread and read by whichever thread + * calls `getDevicesInfo`, hence volatile. + */ + @Volatile + private var preferredInputDeviceId: Int? = null + + @RequiresApi(Build.VERSION_CODES.M) + fun findInputDevice(deviceId: String): AudioDeviceInfo? = + this.audioManager + .getDevices(AudioManager.GET_DEVICES_INPUTS) + .firstOrNull { it.id.toString() == deviceId } + + fun setPreferredInputDevice(device: AudioDeviceInfo) { + this.preferredInputDeviceId = device.id + } + @RequiresApi(Build.VERSION_CODES.O) fun getDevicesInfo(): ReadableMap { val availableInputs = Arguments.createArray() + val currentInputs = Arguments.createArray() val availableOutputs = Arguments.createArray() + val selectedInputDeviceId = this.preferredInputDeviceId + for (inputDevice in this.audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS)) { - val deviceInfo = Arguments.createMap() - deviceInfo.putString("id", inputDevice.getId().toString()) - deviceInfo.putString("name", inputDevice.productName.toString()) - deviceInfo.putString("category", parseDeviceCategory(inputDevice)) + availableInputs.pushMap(describeDevice(inputDevice)) - availableInputs.pushMap(deviceInfo) + if (inputDevice.id == selectedInputDeviceId) { + currentInputs.pushMap(describeDevice(inputDevice)) + } } for (outputDevice in this.audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)) { - val deviceInfo = Arguments.createMap() - deviceInfo.putString("id", outputDevice.getId().toString()) - deviceInfo.putString("name", outputDevice.productName.toString()) - deviceInfo.putString("category", parseDeviceCategory(outputDevice)) - - availableOutputs.pushMap(deviceInfo) + availableOutputs.pushMap(describeDevice(outputDevice)) } val devicesInfo = Arguments.createMap() - devicesInfo.putArray("currentInputs", Arguments.createArray()) + devicesInfo.putArray("currentInputs", currentInputs) devicesInfo.putArray("currentOutputs", Arguments.createArray()) devicesInfo.putArray("availableInputs", availableInputs) devicesInfo.putArray("availableOutputs", availableOutputs) @@ -243,6 +267,16 @@ object MediaSessionManager { return devicesInfo } + @RequiresApi(Build.VERSION_CODES.O) + private fun describeDevice(device: AudioDeviceInfo): WritableMap { + val deviceInfo = Arguments.createMap() + deviceInfo.putString("id", device.id.toString()) + deviceInfo.putString("name", device.productName.toString()) + deviceInfo.putString("category", parseDeviceCategory(device)) + + return deviceInfo + } + @RequiresApi(Build.VERSION_CODES.O) fun parseDeviceCategory(device: AudioDeviceInfo): String = when (device.type) { @@ -253,6 +287,9 @@ object MediaSessionManager { AudioDeviceInfo.TYPE_WIRED_HEADPHONES -> "Wired Headphones" AudioDeviceInfo.TYPE_BLUETOOTH_A2DP -> "Bluetooth A2DP" AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> "Bluetooth SCO" + AudioDeviceInfo.TYPE_USB_DEVICE -> "USB Device" + AudioDeviceInfo.TYPE_USB_HEADSET -> "USB Headset" + AudioDeviceInfo.TYPE_USB_ACCESSORY -> "USB Accessory" else -> "Other (${device.type})" } diff --git a/packages/react-native-audio-api/src/hooks/useAudioInput.ts b/packages/react-native-audio-api/src/hooks/useAudioInput.ts index c3a1fc374..0ad49503a 100644 --- a/packages/react-native-audio-api/src/hooks/useAudioInput.ts +++ b/packages/react-native-audio-api/src/hooks/useAudioInput.ts @@ -18,9 +18,14 @@ const meaningfulReasons: RouteChangeReason[] = [ /** * A hook that provides basic information and selection capabilities for audio - * input devices on the system. (iOS only currently). The hook will - * automatically listen for configuration changes and updates its state. If you - * need more granular control, consider using the AudioManager API directly. + * input devices on the system. The hook will automatically listen for + * configuration changes and updates its state. If you need more granular + * control, consider using the AudioManager API directly. + * + * On Android the selection is bound while a capture stream opens, so + * `onSelectInput` throws while a recorder is running or paused, and + * `currentInput` stays null until a device is picked. See + * `AudioManager.setInputDevice`. * * @returns An object containing audio input information and selection * capabilities diff --git a/packages/react-native-audio-api/src/system/AudioManager.ts b/packages/react-native-audio-api/src/system/AudioManager.ts index ace424da3..a069ceb1a 100644 --- a/packages/react-native-audio-api/src/system/AudioManager.ts +++ b/packages/react-native-audio-api/src/system/AudioManager.ts @@ -125,6 +125,14 @@ class AudioManager implements IAudioManager { * * Resolves when the input device was set successfully and rejects when the * device cannot be found or the system fails to switch to it. + * + * On iOS the running session is rerouted right away. On Android the device is + * bound while a capture stream opens, so the selection applies to recorders + * started afterwards, and calling this while a recorder is running or paused + * rejects rather than deferring the switch silently. Android also needs the + * AAudio backend: a recorder that can only open through OpenSL ES fails to + * start with an explanatory message instead of recording from the wrong + * device. */ async setInputDevice(deviceId: string): Promise { await NativeAudioAPIModule.setInputDevice(deviceId); diff --git a/packages/react-native-audio-api/src/system/types.ts b/packages/react-native-audio-api/src/system/types.ts index d010ed95e..74dfa69ab 100644 --- a/packages/react-native-audio-api/src/system/types.ts +++ b/packages/react-native-audio-api/src/system/types.ts @@ -68,8 +68,14 @@ export type AudioDeviceList = AudioDeviceInfo[]; export interface AudioDevicesInfo { availableInputs: AudioDeviceList; availableOutputs: AudioDeviceList; - currentInputs: AudioDeviceList; // iOS only - currentOutputs: AudioDeviceList; // iOS only + /** + * On iOS, the inputs of the current route. On Android, the device selected + * through `setInputDevice`, and empty until one is selected: the platform + * does not report which input it would pick on its own. + */ + currentInputs: AudioDeviceList; + /** Outputs of the current route. Always empty on Android. */ + currentOutputs: AudioDeviceList; } export interface IAudioManager {