Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
486b2b1
feat(android): select the capture device in setInputDevice
kimchouard Aug 26, 2026
097a81b
Merge branch 'main' into feat/android-input-device-selection
mdydek Sep 14, 2026
18e9ca3
feat: cleanup comments
mdydek Sep 15, 2026
89fa8d8
feat: cleanup comments v2
mdydek Sep 15, 2026
f1ab1ea
Update audio-manager.mdx
kimchouard Sep 16, 2026
ff0553d
Update audio-manager.mdx
kimchouard Sep 16, 2026
00712cd
Merge branch 'main' into feat/android-input-device-selection
mdydek Sep 24, 2026
bc31d8a
feat: handle bluetooth sco, hook working on android, when prev mic is…
mdydek Sep 28, 2026
ca053fe
Merge branch 'main' into feat/android-input-device-selection
mdydek Sep 29, 2026
11d803a
Merge branch 'main' into feat/android-input-device-selection
mdydek Oct 2, 2026
5aeecfa
feat: automatic microphone rerouting in android
mdydek Oct 2, 2026
ebcd7d9
feat: slight improvements after usb mic testing
mdydek Oct 6, 2026
021bac0
Merge branch 'main' into feat/android-input-device-selection
mdydek Oct 6, 2026
26dc6f2
feat: writing to the same file when the input is changed on both systems
mdydek Oct 7, 2026
c9f63dd
feat: review
mdydek Oct 8, 2026
eeccb25
Update packages/audiodocs/docs/system/audio-manager.mdx
mdydek Oct 8, 2026
0dec899
feat: moved the input routing to separate kotlin class
mdydek Oct 12, 2026
4ade317
fix: user comment insights
mdydek Oct 13, 2026
4d2a645
Update packages/react-native-audio-api/src/system/types.ts
mdydek Oct 13, 2026
66722e4
Merge branch 'main' into feat/android-input-device-selection
mdydek Oct 14, 2026
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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ packages/custom-node-generator/ # Code generation tooling
- **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 `<service>` (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
- **Android input device routing**: `MediaSessionManager.inputRouter` (`AudioInputRouter`) owns the selected capture device, the automatic external-mic choice, and the acquire/release count a recorder balances around its stream; `BluetoothScoLink` brings a headset mic's SCO link up and tears it down (the platform only does that for the communication device). The C++ recorder reaches the router through static-JNI `NativeInputRouting`, and the router reaches the recorder back through `NativeInputRouting.rerouteActiveCapture`
- **Testable C++ dependencies**: consumers take interface types (`std::shared_ptr<I…>`); construct concrete implementations only at platform bootstrap. Example: audio event registry (use `IAudioEventHandlerRegistry` more often than `AudioEventHandlerRegistry`).

### Native Module Entry Points
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 Down Expand Up @@ -48,6 +49,28 @@ const Record: FC = () => {
);
const currentPositionSV = useSharedValue(0);
const playbackSourceRef = useRef<AudioBufferSourceNode | null>(null);
const { availableInputs, currentInput, selectInput } = 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 selectInput(input);
} catch (error) {
Alert.alert('Input Device Error', `${error}`);
}
};

const stopPlayback = useCallback(() => {
const source = playbackSourceRef.current;
Expand Down Expand Up @@ -294,6 +317,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 @@ -358,6 +391,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 @@ -386,4 +426,5 @@ export default Record;
const styles = StyleSheet.create({
spacerM: { height: 24 },
spacerS: { height: 12 },
inputPicker: { paddingHorizontal: 16, paddingTop: 12 },
});
18 changes: 13 additions & 5 deletions apps/common-app/src/examples/Record/Record.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ enum Status {
}

const Record: FC = () => {
const { availableInputs, currentInput, onSelectInput } = useAudioInput();
const { availableInputs, currentInput, selectInput } = useAudioInput();

const [status, setStatus] = useState<Status>(Status.Idle);
const [capturedBuffers, setCapturedBuffers] = useState<AudioBuffer[]>([]);
Expand Down Expand Up @@ -216,14 +216,22 @@ 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 selectInput(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]
[availableInputs, selectInput]
);

useEffect(() => {
Expand Down
Comment thread
mdydek marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ 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.
On Android, `currentInput` is `null` whenever the system picks the input: before a device is selected through this hook, after `selectInput('default')`, and after the selected device is disconnected. The platform does not report which device it would choose on its own.
:::

## Signature
Expand All @@ -23,7 +23,7 @@ Input device selection is currently only supported on iOS. On Android, `useAudio
function useAudioInput(): {
availableInputs: AudioDeviceInfo[];
currentInput: AudioDeviceInfo | null;
onSelectInput: (device: AudioDeviceInfo) => Promise<void>;
selectInput: (device: AudioDeviceInfo | 'default') => Promise<void>;
}
```

Expand All @@ -35,17 +35,19 @@ import { View, Text, Button } from 'react-native';
import { useAudioInput } from 'react-native-audio-api';

function AudioInputSelector() {
const { availableInputs, currentInput, onSelectInput } = useAudioInput();
const { availableInputs, currentInput, selectInput } = useAudioInput();

return (
<View>
<Text>Current Input: {currentInput?.name || 'None'}</Text>
<Text>Current Input: {currentInput?.name || 'System default'}</Text>

<Button title="System default" onPress={() => selectInput('default')} />

{availableInputs.map((input) => (
<Button
key={input.id}
title={`${input.name} (${input.category})`}
onPress={() => onSelectInput(input)}
onPress={() => selectInput(input)}
/>
))}
</View>
Expand All @@ -61,7 +63,7 @@ function AudioInputSelector() {
| :----: | :----: | :---- |
| `availableInputs` | [`AudioDeviceInfo[]`](../system/audio-manager.mdx#audiodeviceinfo) | List of available audio input devices under the current device configuration. |
| `currentInput` | [`AudioDeviceInfo`](../system/audio-manager.mdx#audiodeviceinfo) \| `null` | The currently selected audio input device, or `null` if none is yet selected by the system. |
| `onSelectInput` | `(device: AudioDeviceInfo) => Promise<void>` | Selects the given device as the current input. Resolves once the device is selected; throws otherwise. |
| `selectInput` | `(device: AudioDeviceInfo \| 'default') => Promise<void>` | Selects the given device as the current input; `'default'` hands the choice back to the system. Resolves once done; throws otherwise. |

## Related

Expand Down
22 changes: 21 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,28 @@ Checks if notification permissions were previously granted.

Checks currently used and available devices.

On Android, `currentInputs` reports the device a recording in progress captures from, a `routeChange` event with reason `ConfigurationChange` fires whenever that device changes, including when the recording stops. While nothing is recording it reports the selected device, or nothing when no device is selected, since the platform does not report which input it would pick. `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 \| 'default'` | Identifier of the input device to capture from. `'default'` drops the selection, so the system chooses the input again. |

A recording in progress moves to the new input and keeps writing the same file.

On Android, a recorder with no device selected uses the first external microphone connected, in this order: USB microphone or audio interface, USB headset, wired headset, Bluetooth headset. Using a Bluetooth headset's microphone on Android 11 and below requires the `android.permission.MODIFY_AUDIO_SETTINGS` permission; without it such a recorder records from the phone's microphone, and starting one with a Bluetooth microphone explicitly selected fails.
Comment thread
mdydek marked this conversation as resolved.

:::tip
Some USB microphones also expose a headphone output, which the platform then prefers over wired or Bluetooth headphones. To record from such a microphone and hear playback on headphones, connect the microphone first and the headphones after it.
:::

#### Returns `Promise<void>`, resolved once the device is selected and rejected when the device cannot be found or when the system fails to switch to it. A rejected call leaves the previous selection in place.

## Remarks

### `AudioFocusType`
Expand Down Expand Up @@ -339,7 +359,7 @@ export type AudioDeviceList = AudioDeviceInfo[];
export interface AudioDevicesInfo {
availableInputs: AudioDeviceList;
availableOutputs: AudioDeviceList;
currentInputs: AudioDeviceList; // iOS
currentInputs: AudioDeviceList;
currentOutputs: AudioDeviceList; // iOS
}
```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
#include <cstring>
#include <memory>
#include <string>
#include <utility>
#include <vector>

// AMEDIAMUXER_OUTPUT_FORMAT_OGG was added to the NDK at API level 34; fall back
Expand Down Expand Up @@ -746,22 +747,50 @@ OpenEncoderResult AndroidEncoder::open(
return Err(err);
}

const AudioLayout &inputLayout = inputFormat.layout;
if (inputLayout != outputLayout_) {
if (inputLayout.channelCount > MAX_CHANNEL_COUNT ||
outputLayout_.channelCount > MAX_CHANNEL_COUNT) {
backend_->close();
backend_.reset();
return Err("Channel count exceeds MAX_CHANNEL_COUNT");
}
conversion_ = std::make_unique<ConversionState>(
inputLayout, outputLayout_, std::max<size_t>(inputFormat.maxFramesPerBuffer, 1));
auto conversionResult = prepareConversion(inputFormat);
if (conversionResult.is_err()) {
backend_->close();
backend_.reset();
return Err(conversionResult.unwrap_err());
}

markOpen();
return Ok(filePath_);
}

OpenEncoderResult AndroidEncoder::reprepareInput(const StreamFormat &inputFormat) {
if (!isOpen() || backend_ == nullptr) {
return Err("Encoder is not open");
}
if (inputFormat.layout.sampleRate <= 0 || inputFormat.layout.channelCount <= 0) {
return Err("Invalid input format: sampleRate and channelCount must be greater than 0");
}

// The frames the old resampler still holds, at most its group delay, are dropped with it.
Comment thread
mdydek marked this conversation as resolved.
auto conversionResult = prepareConversion(inputFormat);
if (conversionResult.is_err()) {
return Err(conversionResult.unwrap_err());
}

return Ok(filePath_);
}

Result<NoneType, std::string> AndroidEncoder::prepareConversion(const StreamFormat &inputFormat) {
const AudioLayout &inputLayout = inputFormat.layout;
if (inputLayout == outputLayout_) {
conversion_.reset();
return Ok(None);
}
if (inputLayout.channelCount > MAX_CHANNEL_COUNT ||
outputLayout_.channelCount > MAX_CHANNEL_COUNT) {
return Err("Channel count exceeds MAX_CHANNEL_COUNT");
}

conversion_ = std::make_unique<ConversionState>(
inputLayout, outputLayout_, std::max<size_t>(inputFormat.maxFramesPerBuffer, 1));
return Ok(None);
}

std::string AndroidEncoder::encodeConverted(const float *const *channels, int numFrames) {
ConversionState &state = *conversion_;
const auto frames = static_cast<size_t>(numFrames);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,19 @@ class AndroidEncoder : public AudioEncoder {

[[nodiscard]] size_t getFileSizeBytes() const override;

protected:
/// Only the conversion stage is rebuilt; the backend keeps writing at outputLayout_.
OpenEncoderResult reprepareInput(const StreamFormat &inputFormat) override;

private:
static constexpr int RESAMPLE_MAX_IN_FRAMES = 4096;

struct ConversionState;

/// Sets conversion_ to what converts frames from @p inputFormat to the backend's effective
/// format (outputLayout_), or clears it when the two already match.
[[nodiscard]] Result<NoneType, std::string> prepareConversion(const StreamFormat &inputFormat);

/// Input that differs from the backend's effective format: channel mapping and resampling
/// stay planar, and the backend interleaves while it quantizes.
std::string encodeConverted(const float *const *channels, int numFrames);
Expand All @@ -41,7 +51,6 @@ class AndroidEncoder : public AudioEncoder {
/// What the backend encodes at; the input differs from it only when conversion_ is set.
AudioLayout outputLayout_;

struct ConversionState;
std::unique_ptr<ConversionState> conversion_; // null when no conversion is needed
};

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,4 +1,5 @@
#include <audioapi/android/AudioAPIModule.h>
#include <audioapi/android/system/NativeInputRouting.h>
#include <audioapi/android/system/NativeRecorderControl.h>

#include <fbjni/fbjni.h>
Expand All @@ -9,5 +10,6 @@ JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *) {
return facebook::jni::initialize(vm, [] {
AudioAPIModule::registerNatives();
NativeRecorderControl::registerNatives();
NativeInputRouting::registerNatives();
});
}
Loading
Loading