Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions apps/common-app/src/examples/Record/Record.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
4 changes: 3 additions & 1 deletion packages/audiodocs/docs/hooks/select-input.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 18 additions & 1 deletion packages/audiodocs/docs/system/audio-manager.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<AudioDevicesInfo>`, 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<void>`, 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`
Expand Down Expand Up @@ -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
}
```
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#include <audioapi/android/AudioAPIModule.h>
#include <audioapi/android/JniEventPayloadParser.h>
#include <audioapi/android/core/AudioInputSelection.h>
#include <audioapi/android/system/NativeFileInfo.hpp>
#include <memory>

Expand Down Expand Up @@ -32,6 +33,7 @@ void AudioAPIModule::registerNatives() {
makeNativeMethod(
"invokeHandlerWithEventNameAndEventBody",
AudioAPIModule::invokeHandlerWithEventNameAndEventBody),
makeNativeMethod("setPreferredInputDeviceId", AudioAPIModule::setPreferredInputDeviceId),
});
}

Expand All @@ -55,4 +57,9 @@ void AudioAPIModule::invokeHandlerWithEventNameAndEventBody(
event, kBroadcastListenerId, buildPayloadFromJniMap(event, eventBody));
}

jboolean AudioAPIModule::setPreferredInputDeviceId(jint deviceId) {
return static_cast<jboolean>(
AudioInputSelection::setPreferredDeviceId(static_cast<int32_t>(deviceId)));
}

} // namespace audioapi
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ class AudioAPIModule : public jni::HybridClass<AudioAPIModule> {
jint eventOrdinal,
jni::alias_ref<jni::JMap<jstring, jobject>> 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;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#include <android/log.h>
#include <audioapi/android/core/AndroidAudioRecorder.h>
#include <audioapi/android/core/AudioInputSelection.h>
#include <audioapi/android/core/utils/AndroidFileWriterBackend.h>
#include <audioapi/android/core/utils/AndroidRecorderCallback.h>

Expand Down Expand Up @@ -51,6 +52,30 @@ std::optional<oboe::InputPreset> 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 <typename Action>
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(
Expand All @@ -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).
Expand All @@ -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<NoneType, std::string> AndroidAudioRecorder::openAudioStream() {
std::scoped_lock streamLock(streamMutex_);

const int32_t preferredDeviceId = AudioInputSelection::getPreferredDeviceId();

if (mStream_ != nullptr) {
return Result<NoneType, std::string>::Ok(None);
if (streamDeviceId_ == preferredDeviceId) {
return Result<NoneType, std::string>::Ok(None);
}

mStream_->requestStop();
mStream_->close();
mStream_.reset();
}

oboe::AudioStreamBuilder builder;
Expand All @@ -109,13 +148,38 @@ Result<NoneType, std::string> AndroidAudioRecorder::openAudioStream() {
builder.setInputPreset(*preset);
}

if (preferredDeviceId != AudioInputSelection::kSystemDefaultDeviceId) {
builder.setDeviceId(preferredDeviceId);
}

auto result = builder.openStream(mStream_);

if (result != oboe::Result::OK || mStream_ == nullptr) {
return Result<NoneType, std::string>::Err(
"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<NoneType, std::string>::Err(std::move(message));
}

streamDeviceId_ = preferredDeviceId;
streamSampleRate_ = static_cast<float>(mStream_->getSampleRate());
streamChannelCount_ = mStream_->getChannelCount();
streamMaxBufferSizeInFrames_ = mStream_->getBufferSizeInFrames();
Expand All @@ -138,6 +202,15 @@ Result<NoneType, std::string> AndroidAudioRecorder::start(const std::string &fil
return Result<NoneType, std::string>::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()) {
Expand Down Expand Up @@ -187,10 +260,31 @@ Result<NoneType, std::string> 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<NoneType, std::string>::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).
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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();
Expand All @@ -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()) {
Expand All @@ -611,6 +715,7 @@ void AndroidAudioRecorder::onErrorAfterClose(oboe::AudioStream *stream, oboe::Re
}

mStream_->requestStart();
releaseCapture.dismiss();
state_.store(RecorderState::Recording, std::memory_order_release);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,24 @@ class AndroidAudioRecorder : public oboe::AudioStreamCallback,
std::atomic<float> 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<oboe::AudioStream> mStream_;
std::vector<std::string> recordingSegmentPaths_;
/// Updated on the audio thread from each input callback `numFrames`.
std::atomic<int32_t> lastCallbackFrameCount_{0};
/// Whether this recorder is counted among AudioInputSelection's running
/// captures. Guarded by streamMutex_.
bool countedAsRunningCapture_{false};
Result<NoneType, std::string> 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<AudioFileWriter> createFileWriter(
const std::shared_ptr<AudioFileProperties> &props);
Result<NoneType, std::string> setupFileWriter(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
#include <audioapi/android/core/AudioInputSelection.h>

#include <mutex>

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
Loading
Loading