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
2 changes: 1 addition & 1 deletion .claude/skills/turbo-modules/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ void AudioAPIModule::registerNatives() {

`registerNatives()` is called from `android/src/main/cpp/audioapi/android/OnLoad.cpp` at `.so` load time.

**`invokeHandlerWithEventNameAndEventBody`**: called from Kotlin (MediaSessionManager callbacks) to fire events into JS. Takes a Java `Map<String, Object>` and converts to `std::unordered_map<std::string, EventValue>`.
**`invokeHandlerWithEventNameAndEventBody`**: called from Kotlin (MediaSessionManager callbacks) to fire events into JS. Takes a Java `Map<String, Object>` and converts it to a typed `AudioEventPayload` in `buildPayloadFromJniMap` (`android/src/main/cpp/audioapi/android/JniEventPayloadParser.cpp`), which switches on the event. An event without a case there falls to `EmptyPayload{}`: the event still reaches JS, but with an empty body. When Kotlin starts sending a new event, add its case, building the same payload type the iOS side dispatches (e.g. `ROUTE_CHANGE` → `StringPayload{.name = "reason", ...}`).

---

Expand Down
43 changes: 42 additions & 1 deletion apps/common-app/src/demos/Record/Record.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@ import {
concatAudioFiles,
FileFormat,
RecordingNotificationManager,
useAudioInput,
} from 'react-native-audio-api';

import { Alert, StyleSheet, View } from 'react-native';
import { Container } from '../../components';
import { Container, Select } from '../../components';

import { Easing, useSharedValue, withTiming } from 'react-native-reanimated';
import { audioRecorder as Recorder, audioContext } from '../../singletons';
Expand All @@ -37,6 +38,28 @@ const Record: FC = () => {
);
const currentPositionSV = useSharedValue(0);
const playbackSourceRef = useRef<AudioBufferSourceNode | null>(null);
const { availableInputs, currentInput, onSelectInput } = useAudioInput();

const inputLabels = availableInputs.map(
(device) => `${device.name} · ${device.category} #${device.id}`
);
const currentInputLabel = currentInput
? inputLabels[availableInputs.indexOf(currentInput)]
: 'System default';

const onSelectInputLabel = async (label: string) => {
const input = availableInputs[inputLabels.indexOf(label)];

if (!input) {
return;
}

try {
await onSelectInput(input);
} catch (error) {
Alert.alert('Input Device Error', `${error}`);
}
};

const stopPlayback = useCallback(() => {
const source = playbackSourceRef.current;
Expand Down Expand Up @@ -284,6 +307,16 @@ const Record: FC = () => {
})();
}, []);

useEffect(() => {
Recorder.onError((error) => {
Alert.alert('Recorder Error', error.message);
});

return () => {
Recorder.clearOnError();
};
}, []);

useEffect(() => {
const pauseListener = RecordingNotificationManager.addEventListener(
'recordingNotificationPause',
Expand Down Expand Up @@ -344,6 +377,13 @@ const Record: FC = () => {

return (
<Container disablePadding>
<View style={styles.inputPicker}>
<Select
value={currentInputLabel}
options={inputLabels}
onChange={onSelectInputLabel}
/>
</View>
<Status state={state} />
<View style={styles.spacerM} />
{[RecordingState.Playing, RecordingState.ReadyToPlay].includes(state) ? (
Expand Down Expand Up @@ -372,4 +412,5 @@ export default Record;
const styles = StyleSheet.create({
spacerM: { height: 24 },
spacerS: { height: 12 },
inputPicker: { paddingHorizontal: 16, paddingTop: 12 },
});
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.

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
20 changes: 19 additions & 1 deletion packages/audiodocs/docs/system/audio-manager.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -177,8 +177,26 @@ 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.

#### Returns `Promise<void>`
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 @@ -339,7 +357,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,9 @@ class AudioAPIModule : public jni::HybridClass<AudioAPIModule> {
jint eventOrdinal,
jni::alias_ref<jni::JMap<jstring, jobject>> eventBody);

/// @see AudioInputSelection::setPreferredDeviceId
jboolean setPreferredInputDeviceId(jint deviceId);

private:
friend HybridBase;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ AudioEventPayload buildPayloadFromJniMap(
return InterruptionPayload{
.type = jniGetString(map, "type"), .shouldResume = jniGetBool(map, "shouldResume")};

case AudioEvent::ROUTE_CHANGE:
return StringPayload{.name = "reason", .reason = jniGetString(map, "reason")};

case AudioEvent::VOLUME_CHANGE:
case AudioEvent::PLAYBACK_NOTIFICATION_SKIP_FORWARD:
case AudioEvent::PLAYBACK_NOTIFICATION_SKIP_BACKWARD:
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 when the scope ends unless dismiss() is called first. Lets a
/// multi-step operation roll back an early step on every failure path without
/// repeating the rollback before each return.
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,24 @@ 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.
/// Called from start() on the promise thread pool and from onErrorAfterClose()
/// on the Oboe error thread.
/// An already open stream bound to a device other than the current
/// AudioInputSelection is closed and reopened.
/// @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 +147,31 @@ 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)));
}

// The selection is a preference, as on iOS: when the platform routes elsewhere
// (the device is gone, or OpenSL ES, which ignores setDeviceId and reports
// kUnspecified), recording continues on the routed device instead of failing.
if (preferredDeviceId != AudioInputSelection::kSystemDefaultDeviceId &&
mStream_->getDeviceId() != preferredDeviceId) {
__android_log_print(
ANDROID_LOG_WARN,
"AndroidAudioRecorder",
"Input device %d was requested, but the capture stream opened on device %d",
preferredDeviceId,
mStream_->getDeviceId());
}

streamDeviceId_ = preferredDeviceId;
streamSampleRate_ = static_cast<float>(mStream_->getSampleRate());
streamChannelCount_ = mStream_->getChannelCount();
streamMaxBufferSizeInFrames_ = mStream_->getBufferSizeInFrames();
Expand All @@ -138,6 +194,9 @@ Result<NoneType, std::string> AndroidAudioRecorder::start(const std::string &fil
return Result<NoneType, std::string>::Err("Recorder is already recording");
}

setRunningCapture(true);
ScopeExit releaseCapture([this] { setRunningCapture(false); });

auto streamResult = openAudioStream();

if (!streamResult.is_ok()) {
Expand Down Expand Up @@ -187,10 +246,27 @@ 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);
}

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 +296,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 @@ -566,6 +643,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 Down Expand Up @@ -596,6 +674,11 @@ void AndroidAudioRecorder::onErrorAfterClose(oboe::AudioStream *stream, oboe::Re
return;
}

// cleanup() released the claim; take it again before the replacement stream
// reads the selection.
setRunningCapture(true);
ScopeExit releaseCapture([this] { setRunningCapture(false); });

auto streamResult = openAudioStream();

if (!streamResult.is_ok()) {
Expand All @@ -616,6 +699,7 @@ void AndroidAudioRecorder::onErrorAfterClose(oboe::AudioStream *stream, oboe::Re
if (stateBeforeTeardown == RecorderState::Recording) {
mStream_->requestStart();
}
releaseCapture.dismiss();
state_.store(stateBeforeTeardown, std::memory_order_release);
}
}
Expand Down
Loading
Loading