From e5b468f0117d58dd7d6e27c5f2e0bbed979cae19 Mon Sep 17 00:00:00 2001 From: Louis Vetter Date: Sun, 2 Aug 2026 14:45:37 +0200 Subject: [PATCH 1/3] feat(android): recorder input preset option (androidInputPreset) The Oboe input stream builder never calls setInputPreset, so every capture stream runs on Oboe's implicit default, InputPreset::VoiceRecognition - the speech-recognition preprocessing chain, which applies no acoustic echo cancellation. For duplex voice apps (playing audio through the same device while recording, VoIP-style) that makes Android capture echo-raw, while the equivalent iOS setup gets AEC from the voiceChat session mode. This adds an optional constructor option to AudioRecorder: new AudioRecorder({ androidInputPreset: 'voiceCommunication' }) mapping to Oboe's InputPreset on the capture stream builder. When the option is omitted (or names an unknown preset) no setInputPreset call is made, so existing behavior is preserved exactly. iOS ignores the option; its input chain is selected by the AVAudioSession mode instead. Presets exposed: generic, camcorder, voiceRecognition, voiceCommunication, unprocessed, voicePerformance. --- .../android/core/AndroidAudioRecorder.cpp | 36 ++++++++++++++++++- .../android/core/AndroidAudioRecorder.h | 4 ++- .../cpp/audioapi/AudioAPIModuleInstaller.h | 6 +++- .../inputs/AudioRecorderHostObject.cpp | 7 ++-- .../inputs/AudioRecorderHostObject.h | 4 ++- .../src/AudioAPIModule/globals.d.ts | 2 +- packages/react-native-audio-api/src/api.ts | 1 + .../src/core/AudioRecorder.ts | 27 ++++++++++++-- 8 files changed, 78 insertions(+), 9 deletions(-) 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 c7403429b..e4b0e3ac6 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 @@ -18,15 +18,45 @@ #include #include +#include #include #include #include namespace audioapi { +namespace { +/// Maps the JS-facing preset name to Oboe's InputPreset. An unknown or empty +/// name yields no preset call, preserving Oboe's own default +/// (InputPreset::VoiceRecognition) exactly as before this option existed. +std::optional inputPresetFromString(const std::string &name) { + if (name == "generic") { + return oboe::InputPreset::Generic; + } + if (name == "camcorder") { + return oboe::InputPreset::Camcorder; + } + if (name == "voiceRecognition") { + return oboe::InputPreset::VoiceRecognition; + } + if (name == "voiceCommunication") { + return oboe::InputPreset::VoiceCommunication; + } + if (name == "unprocessed") { + return oboe::InputPreset::Unprocessed; + } + if (name == "voicePerformance") { + return oboe::InputPreset::VoicePerformance; + } + return std::nullopt; +} +} // namespace + AndroidAudioRecorder::AndroidAudioRecorder( - const std::shared_ptr &audioEventHandlerRegistry) + const std::shared_ptr &audioEventHandlerRegistry, + const std::string &inputPreset) : AudioRecorder(audioEventHandlerRegistry), + inputPreset_(inputPreset), streamSampleRate_(0.0), streamChannelCount_(0), streamMaxBufferSizeInFrames_(0) {} @@ -74,6 +104,10 @@ Result AndroidAudioRecorder::openAudioStream() { ->setDataCallback(shared_from_this()) ->setErrorCallback(shared_from_this()); + if (auto preset = inputPresetFromString(inputPreset_)) { + builder.setInputPreset(*preset); + } + auto result = builder.openStream(mStream_); if (result != oboe::Result::OK || mStream_ == nullptr) { 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 5f311434f..680c11ed7 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 @@ -25,7 +25,8 @@ class AndroidAudioRecorder : public oboe::AudioStreamCallback, public std::enable_shared_from_this { public: explicit AndroidAudioRecorder( - const std::shared_ptr &audioEventHandlerRegistry); + const std::shared_ptr &audioEventHandlerRegistry, + const std::string &inputPreset = ""); ~AndroidAudioRecorder() override; void cleanup(); @@ -63,6 +64,7 @@ class AndroidAudioRecorder : public oboe::AudioStreamCallback, private: std::shared_ptr deinterleavingBuffer_; + std::string inputPreset_; std::atomic streamSampleRate_; int32_t streamChannelCount_; int32_t streamMaxBufferSizeInFrames_; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/AudioAPIModuleInstaller.h b/packages/react-native-audio-api/common/cpp/audioapi/AudioAPIModuleInstaller.h index 762c4ce20..805744fe6 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/AudioAPIModuleInstaller.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/AudioAPIModuleInstaller.h @@ -117,8 +117,12 @@ class AudioAPIModuleInstaller { const jsi::Value &thisValue, const jsi::Value *args, size_t count) -> jsi::Value { + std::string androidInputPreset; + if (count > 0 && args[0].isString()) { + androidInputPreset = args[0].getString(runtime).utf8(runtime); + } auto audioRecorderHostObject = std::make_shared( - audioEventHandlerRegistry, &runtime, jsCallInvoker); + audioEventHandlerRegistry, &runtime, jsCallInvoker, androidInputPreset); auto jsiObject = jsi::Object::createFromHostObject(runtime, audioRecorderHostObject); diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/inputs/AudioRecorderHostObject.cpp b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/inputs/AudioRecorderHostObject.cpp index 5fe0476c4..53301d898 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/inputs/AudioRecorderHostObject.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/inputs/AudioRecorderHostObject.cpp @@ -22,10 +22,13 @@ namespace audioapi { AudioRecorderHostObject::AudioRecorderHostObject( const std::shared_ptr &audioEventHandlerRegistry, jsi::Runtime *runtime, - const std::shared_ptr &callInvoker) { + const std::shared_ptr &callInvoker, + const std::string &androidInputPreset) { #ifdef ANDROID - audioRecorder_ = std::make_shared(audioEventHandlerRegistry); + audioRecorder_ = std::make_shared(audioEventHandlerRegistry, androidInputPreset); #else + // The input preset is an Android concept (Oboe); iOS configures its input + // through the AVAudioSession mode instead. audioRecorder_ = std::make_shared(audioEventHandlerRegistry); #endif diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/inputs/AudioRecorderHostObject.h b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/inputs/AudioRecorderHostObject.h index 6fda9690e..a9530d4d7 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/inputs/AudioRecorderHostObject.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/inputs/AudioRecorderHostObject.h @@ -5,6 +5,7 @@ #include #include +#include namespace audioapi { using namespace facebook; @@ -17,7 +18,8 @@ class AudioRecorderHostObject : public HostObject { AudioRecorderHostObject( const std::shared_ptr &audioEventHandlerRegistry, jsi::Runtime *runtime, - const std::shared_ptr &callInvoker); + const std::shared_ptr &callInvoker, + const std::string &androidInputPreset); JSI_HOST_FUNCTION_DECL(start); JSI_HOST_FUNCTION_DECL(stop); diff --git a/packages/react-native-audio-api/src/AudioAPIModule/globals.d.ts b/packages/react-native-audio-api/src/AudioAPIModule/globals.d.ts index 655cde36b..60794261a 100644 --- a/packages/react-native-audio-api/src/AudioAPIModule/globals.d.ts +++ b/packages/react-native-audio-api/src/AudioAPIModule/globals.d.ts @@ -17,7 +17,7 @@ declare global { sampleRate: number ) => IOfflineAudioContext; - var createAudioRecorder: () => IAudioRecorder; + var createAudioRecorder: (androidInputPreset: string) => IAudioRecorder; var createAudioBuffer: ( numberOfChannels: number, diff --git a/packages/react-native-audio-api/src/api.ts b/packages/react-native-audio-api/src/api.ts index 5aa212b3b..40ea82d94 100644 --- a/packages/react-native-audio-api/src/api.ts +++ b/packages/react-native-audio-api/src/api.ts @@ -38,6 +38,7 @@ export * from './Audio'; export { default as Audio } from './Audio'; export { default as AudioControls } from './Audio/controls/AudioControls'; export type { MediaElementAudioSourceOptions } from './core/MediaElementAudioSourceNode'; +export type { AudioRecorderOptions, AndroidInputPreset } from './core/AudioRecorder'; export type { default as AudioEventSubscription } from './events/AudioEventSubscription'; export { default as FilePreset } from './utils/filePresets'; diff --git a/packages/react-native-audio-api/src/core/AudioRecorder.ts b/packages/react-native-audio-api/src/core/AudioRecorder.ts index 7c9256cfb..9f03b3335 100644 --- a/packages/react-native-audio-api/src/core/AudioRecorder.ts +++ b/packages/react-native-audio-api/src/core/AudioRecorder.ts @@ -39,6 +39,29 @@ function withDefaultOptions( }; } +/** + * Options applied when the recorder is created. + */ +export interface AudioRecorderOptions { + /** + * Android only: the Oboe input preset for the capture stream. Android + * routes the microphone through a preprocessing chain selected by this + * preset; Oboe's implicit default is `voiceRecognition`, which applies NO + * acoustic echo cancellation. Duplex voice use cases (VoIP-style apps that + * play audio while recording) should pass `voiceCommunication` to engage + * the platform AEC/NS chain. Has no effect on iOS. + */ + androidInputPreset?: AndroidInputPreset; +} + +export type AndroidInputPreset = + | 'generic' + | 'camcorder' + | 'voiceRecognition' + | 'voiceCommunication' + | 'unprocessed' + | 'voicePerformance'; + export default class AudioRecorder { protected onAudioReadySubscription: AudioEventSubscription | null = null; protected onErrorSubscription: AudioEventSubscription | null = null; @@ -51,8 +74,8 @@ export default class AudioRecorder { globalThis.AudioEventEmitter ); - constructor() { - this.recorder = globalThis.createAudioRecorder(); + constructor(options?: AudioRecorderOptions) { + this.recorder = globalThis.createAudioRecorder(options?.androidInputPreset ?? ''); } /** From c6e15796d436babbc318c925621513b1bac69af1 Mon Sep 17 00:00:00 2001 From: michal Date: Wed, 12 Aug 2026 11:58:46 +0200 Subject: [PATCH 2/3] feat: ios aec also --- .claude/skills/turbo-modules/SKILL.md | 5 ++ apps/common-app/src/singletons/index.ts | 5 +- .../FabricExampleTests/AudioEngineTests.mm | 39 +++++----- .../FabricExampleTests/AudioPlayerTests.mm | 4 + .../IOSAudioRecorderTests.mm | 11 ++- .../NativeAudioRecorderTests.mm | 78 +++++++++++-------- apps/fabric-example/ios/Podfile.lock | 6 +- .../audiodocs/docs/inputs/audio-recorder.mdx | 65 +++++++++++++--- .../cpp/audioapi/AudioAPIModuleInstaller.h | 14 +++- .../inputs/AudioRecorderHostObject.cpp | 15 ++-- .../inputs/AudioRecorderHostObject.h | 3 +- .../ios/audioapi/ios/core/IOSAudioRecorder.h | 4 +- .../ios/audioapi/ios/core/IOSAudioRecorder.mm | 8 +- .../audioapi/ios/core/NativeAudioRecorder.h | 4 +- .../audioapi/ios/core/NativeAudioRecorder.m | 5 +- .../ios/audioapi/ios/system/AudioEngine.h | 3 +- .../ios/audioapi/ios/system/AudioEngine.mm | 56 +++++++++++++ .../src/AudioAPIModule/globals.d.ts | 5 +- packages/react-native-audio-api/src/api.ts | 5 +- .../src/core/AudioRecorder.ts | 25 +++--- .../react-native-audio-api/src/mock/index.ts | 4 +- 21 files changed, 267 insertions(+), 97 deletions(-) diff --git a/.claude/skills/turbo-modules/SKILL.md b/.claude/skills/turbo-modules/SKILL.md index 4a315a18b..d0f0006bc 100644 --- a/.claude/skills/turbo-modules/SKILL.md +++ b/.claude/skills/turbo-modules/SKILL.md @@ -127,6 +127,11 @@ Each `get*Function()` private method creates a `jsi::Function` via `jsi::Functio **Adding a new top-level global**: add a `static jsi::Function getCreateXxxFunction(...)` private method and a `setProperty("createXxx", ...)` call in `injectJSIBindings`. This is only needed for objects that JS creates directly (not objects created as properties of another HostObject). +**Construction-time options** (e.g. `new AudioRecorder({ androidInputPreset, iosVoiceProcessing })`) travel as positional args of these factory functions — there is no options object and no TurboModule method involved. Three rules: +- Parse defensively — `if (count > N && args[N].isBool())` — and keep the no-arg behavior as the default, so an older JS bundle against a newer binary still works. +- Update `src/AudioAPIModule/globals.d.ts` in the same change; it is the only type contract for these globals. +- Platform-specific options are passed to *both* platforms and consumed by the `#ifdef ANDROID` branch in the HostObject constructor; the other platform ignores its counterpart. Name them with the platform prefix (`androidInputPreset`, `iosVoiceProcessing`) so the asymmetry is visible from JS. + --- ## iOS Native Module (`AudioAPIModule.mm`) diff --git a/apps/common-app/src/singletons/index.ts b/apps/common-app/src/singletons/index.ts index b3e505737..191a5d20a 100644 --- a/apps/common-app/src/singletons/index.ts +++ b/apps/common-app/src/singletons/index.ts @@ -1,4 +1,7 @@ import { AudioContext, AudioRecorder } from 'react-native-audio-api'; export const audioContext = new AudioContext(); -export const audioRecorder = new AudioRecorder(); +export const audioRecorder = new AudioRecorder({ + androidInputPreset: 'voiceCommunication', + iosVoiceProcessing: true, +}); diff --git a/apps/fabric-example/ios/FabricExampleTests/AudioEngineTests.mm b/apps/fabric-example/ios/FabricExampleTests/AudioEngineTests.mm index e85646121..010b8a6b6 100644 --- a/apps/fabric-example/ios/FabricExampleTests/AudioEngineTests.mm +++ b/apps/fabric-example/ios/FabricExampleTests/AudioEngineTests.mm @@ -421,8 +421,8 @@ - (void)testDetachSourceNodeRemovesTrackedNodeAndClearsGraphWhenEmpty { - (void)testDetachSourceNodeKeepsGraphNeedsRebuildWhenInputRemains { NSString *sourceNodeId = [self attachSourceNodeToAudioEngine]; - [self.audioEngine - attachInputNodeWithReceiverBlock:[self testInputReceiverBlock]]; + [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] + voiceProcessingEnabled:NO]; self.audioEngine.graphNeedsRebuild = YES; [self.audioEngine detachSourceNodeWithId:sourceNodeId]; @@ -434,8 +434,8 @@ - (void)testDetachSourceNodeKeepsGraphNeedsRebuildWhenInputRemains { - (void)testAttachInputNodeStoresAndConnectsInput { FakeAudioEngine *fakeEngine = self.audioEngine.currentFakeAudioEngine; - [self.audioEngine - attachInputNodeWithReceiverBlock:[self testInputReceiverBlock]]; + [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] + voiceProcessingEnabled:NO]; AVAudioSinkNode *inputNode = self.audioEngine.inputNode; XCTAssertNotNil(inputNode); @@ -454,8 +454,8 @@ - (void)testAttachInputNodeDefersConnectionUntilLiveInputFormatIsAvailable { FakeAudioEngine *fakeEngine = self.audioEngine.currentFakeAudioEngine; fakeEngine.fakeInputNode.outputFormat = nil; - [self.audioEngine - attachInputNodeWithReceiverBlock:[self testInputReceiverBlock]]; + [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] + voiceProcessingEnabled:NO]; XCTAssertNil(self.audioEngine.inputNode); XCTAssertEqual(fakeEngine.attachNodeCallCount, 0); @@ -482,8 +482,8 @@ - (void)testDetachInputNodeWithoutInputDoesNothing { } - (void)testDetachInputNodeClearsGraphOnlyWhenNoSourcesRemain { - [self.audioEngine - attachInputNodeWithReceiverBlock:[self testInputReceiverBlock]]; + [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] + voiceProcessingEnabled:NO]; self.audioEngine.graphNeedsRebuild = YES; [self.audioEngine detachInputNode]; @@ -492,8 +492,8 @@ - (void)testDetachInputNodeClearsGraphOnlyWhenNoSourcesRemain { XCTAssertFalse(self.audioEngine.graphNeedsRebuild); [self attachSourceNodeToAudioEngine]; - [self.audioEngine - attachInputNodeWithReceiverBlock:[self testInputReceiverBlock]]; + [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] + voiceProcessingEnabled:NO]; self.audioEngine.graphNeedsRebuild = YES; [self.audioEngine detachInputNode]; @@ -506,8 +506,8 @@ - (void)testDetachInputNodePreservesSessionDeactivationInvalidation { FakeAudioEngine *fakeEngine = self.audioEngine.currentFakeAudioEngine; fakeEngine.fakeRunning = YES; self.audioEngine.state = AudioEngineStateRunning; - [self.audioEngine - attachInputNodeWithReceiverBlock:[self testInputReceiverBlock]]; + [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] + voiceProcessingEnabled:NO]; [self.audioEngine onSessionDeactivated]; [self.audioEngine detachInputNode]; @@ -729,8 +729,8 @@ - (void)testStartIfNecessaryRebuildsWhenGraphNeedsRebuild { - (void) testStartIfNecessaryRebuildsAfterSessionDeactivationEvenWhenTeardownClearsGraph { - [self.audioEngine - attachInputNodeWithReceiverBlock:[self testInputReceiverBlock]]; + [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] + voiceProcessingEnabled:NO]; FakeAudioEngine *oldEngine = self.audioEngine.currentFakeAudioEngine; oldEngine.fakeRunning = YES; @@ -747,8 +747,8 @@ - (void)testStartIfNecessaryRebuildsWhenGraphNeedsRebuild { AVAudioFormat *recoveredInputFormat = [self testInputFormatWithSampleRate:48000 channelCount:1]; self.audioEngine.nextCreatedEngineInputFormat = recoveredInputFormat; - [self.audioEngine - attachInputNodeWithReceiverBlock:[self testInputReceiverBlock]]; + [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] + voiceProcessingEnabled:NO]; AVAudioSinkNode *recoveredInputNode = self.audioEngine.inputNode; XCTAssertTrue([self.audioEngine startIfNecessary]); @@ -769,8 +769,8 @@ - (void)testStartIfNecessaryRebuildsWhenGraphNeedsRebuild { } - (void)testStartIfNecessaryRebuildsInputNodeWithFreshInstance { - [self.audioEngine - attachInputNodeWithReceiverBlock:[self testInputReceiverBlock]]; + [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] + voiceProcessingEnabled:NO]; FakeAudioEngine *oldEngine = self.audioEngine.currentFakeAudioEngine; AVAudioSinkNode *oldInputNode = self.audioEngine.inputNode; AVAudioFormat *replacementInputFormat = @@ -991,7 +991,8 @@ - (void)testConcurrentRecordAndPlayPathsDoNotCrash { for (NSInteger index = 0; index < 10; index += 1) { dispatch_group_enter(group); dispatch_async(queue, ^{ - [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock]]; + [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] + voiceProcessingEnabled:NO]; [self.audioEngine startIfNecessary]; dispatch_group_leave(group); }); diff --git a/apps/fabric-example/ios/FabricExampleTests/AudioPlayerTests.mm b/apps/fabric-example/ios/FabricExampleTests/AudioPlayerTests.mm index 9a9b57789..a115ec460 100644 --- a/apps/fabric-example/ios/FabricExampleTests/AudioPlayerTests.mm +++ b/apps/fabric-example/ios/FabricExampleTests/AudioPlayerTests.mm @@ -36,9 +36,13 @@ [[nodiscard]] bool isRunning() const override; + [[nodiscard]] double getBaseLatency() const override; + [[nodiscard]] double getOutputLatency() const override; + protected: std::shared_ptr audioBuffer_; NativeAudioPlayer *audioPlayer_; + float sampleRate_; std::function renderAudio_; std::atomic ¤tRenders_; int channelCount_; diff --git a/apps/fabric-example/ios/FabricExampleTests/IOSAudioRecorderTests.mm b/apps/fabric-example/ios/FabricExampleTests/IOSAudioRecorderTests.mm index 4953e52c9..6e00ee0dd 100644 --- a/apps/fabric-example/ios/FabricExampleTests/IOSAudioRecorderTests.mm +++ b/apps/fabric-example/ios/FabricExampleTests/IOSAudioRecorderTests.mm @@ -26,7 +26,9 @@ class IOSAudioRecorder : public AudioRecorder { public: - IOSAudioRecorder(const std::shared_ptr &audioEventHandlerRegistry); + IOSAudioRecorder( + const std::shared_ptr &audioEventHandlerRegistry, + bool voiceProcessingEnabled = false); ~IOSAudioRecorder() override; Result start(const std::string &fileNameOverride = "") override; @@ -53,6 +55,8 @@ uint64_t callbackId) override; void clearOnAudioReadyCallback() override; + [[nodiscard]] double getInputLatency() const override; + protected: NativeAudioRecorder *nativeRecorder_; }; @@ -171,8 +175,9 @@ @implementation FakeNativeAudioRecorder - (instancetype)init { - if (self = [super initWithReceiverBlock:^(const AudioBufferList *inputBuffer, int numFrames) { - }]) { + if (self = [super + initWithReceiverBlock:^(const AudioBufferList *inputBuffer, int numFrames) {} + voiceProcessingEnabled:NO]) { self.mockResolvedInputFormat = [[AVAudioFormat alloc] initStandardFormatWithSampleRate:44100 channels:2]; self.mockResolvedBufferSize = 512; diff --git a/apps/fabric-example/ios/FabricExampleTests/NativeAudioRecorderTests.mm b/apps/fabric-example/ios/FabricExampleTests/NativeAudioRecorderTests.mm index 86e8736da..fc26c777d 100644 --- a/apps/fabric-example/ios/FabricExampleTests/NativeAudioRecorderTests.mm +++ b/apps/fabric-example/ios/FabricExampleTests/NativeAudioRecorderTests.mm @@ -90,6 +90,7 @@ @interface FakeRecorderAudioEngine : AudioEngine @property(nonatomic, assign) NSInteger rebuildAfterDeactivationCallCount; @property(nonatomic, strong) AVAudioSinkNode *lastAttachedInputNode; @property(nonatomic, copy) AVAudioSinkNodeReceiverBlock lastAttachedReceiverBlock; +@property (nonatomic, assign) BOOL lastAttachedVoiceProcessingEnabled; @end @@ -113,11 +114,13 @@ - (void)stopIfNecessary } - (void)attachInputNodeWithReceiverBlock:(AVAudioSinkNodeReceiverBlock)receiverBlock + voiceProcessingEnabled:(BOOL)voiceProcessingEnabled { self.attachInputNodeCallCount += 1; self.inputNode = [[AVAudioSinkNode alloc] initWithReceiverBlock:receiverBlock]; self.lastAttachedInputNode = self.inputNode; self.lastAttachedReceiverBlock = receiverBlock; + self.lastAttachedVoiceProcessingEnabled = voiceProcessingEnabled; } - (bool)startIfNecessary @@ -290,12 +293,12 @@ - (void)testInitCreatesSinkNodeAndForwardsReceiverBlock { __block const AudioBufferList *receivedBuffer = nullptr; __block int receivedFrames = 0; - NativeAudioRecorder *recorder = [[NativeAudioRecorder alloc] initWithReceiverBlock:^( - const AudioBufferList *inputBuffer, - int numFrames) { - receivedBuffer = inputBuffer; - receivedFrames = numFrames; - }]; + NativeAudioRecorder *recorder = [[NativeAudioRecorder alloc] + initWithReceiverBlock:^(const AudioBufferList *inputBuffer, int numFrames) { + receivedBuffer = inputBuffer; + receivedFrames = numFrames; + } + voiceProcessingEnabled:NO]; XCTAssertNotNil(recorder.receiverBlock); XCTAssertNotNil(recorder.receiverSinkBlock); @@ -321,10 +324,9 @@ - (void)testStartResolvesInputFormatFromTheLiveEngine AVAudioFormat *expectedFormat = [self validFormat]; self.audioEngine.fakeAVAudioEngine.fakeInputNode.outputFormat = expectedFormat; - NativeAudioRecorder *recorder = - [[NativeAudioRecorder alloc] initWithReceiverBlock:^(const AudioBufferList *inputBuffer, - int numFrames){ - }]; + NativeAudioRecorder *recorder = [[NativeAudioRecorder alloc] + initWithReceiverBlock:^(const AudioBufferList *inputBuffer, int numFrames) {} + voiceProcessingEnabled:NO]; XCTAssertTrue([recorder start:nil]); XCTAssertEqualObjects([recorder getResolvedInputFormat], expectedFormat); @@ -333,10 +335,9 @@ - (void)testStartResolvesInputFormatFromTheLiveEngine - (void)testGetBufferSizeUsesMinimumDurationAndRoundsUpToPowerOfTwo { - NativeAudioRecorder *recorder = - [[NativeAudioRecorder alloc] initWithReceiverBlock:^(const AudioBufferList *inputBuffer, - int numFrames){ - }]; + NativeAudioRecorder *recorder = [[NativeAudioRecorder alloc] + initWithReceiverBlock:^(const AudioBufferList *inputBuffer, int numFrames) {} + voiceProcessingEnabled:NO]; int bufferSize = [recorder getBufferSize]; XCTAssertEqual(bufferSize, 16384); @@ -347,10 +348,9 @@ - (void)testStartStopsEngineAttachesSinkNodeAndStartsEngine { AVAudioFormat *expectedFormat = [self validFormat]; self.audioEngine.fakeAVAudioEngine.fakeInputNode.outputFormat = expectedFormat; - NativeAudioRecorder *recorder = - [[NativeAudioRecorder alloc] initWithReceiverBlock:^(const AudioBufferList *inputBuffer, - int numFrames){ - }]; + NativeAudioRecorder *recorder = [[NativeAudioRecorder alloc] + initWithReceiverBlock:^(const AudioBufferList *inputBuffer, int numFrames) {} + voiceProcessingEnabled:NO]; XCTAssertTrue([recorder start:nil]); @@ -359,15 +359,28 @@ - (void)testStartStopsEngineAttachesSinkNodeAndStartsEngine XCTAssertEqual(self.audioEngine.startIfNecessaryCallCount, 1); XCTAssertNotNil(self.audioEngine.lastAttachedInputNode); XCTAssertNotNil(self.audioEngine.lastAttachedReceiverBlock); + XCTAssertFalse(self.audioEngine.lastAttachedVoiceProcessingEnabled); +} + +- (void)testStartForwardsVoiceProcessingPreferenceToAudioEngine +{ + self.audioEngine.fakeAVAudioEngine.fakeInputNode.outputFormat = [self validFormat]; + NativeAudioRecorder *recorder = [[NativeAudioRecorder alloc] + initWithReceiverBlock:^(const AudioBufferList *inputBuffer, int numFrames) {} + voiceProcessingEnabled:YES]; + + XCTAssertTrue([recorder start:nil]); + + XCTAssertEqual(self.audioEngine.attachInputNodeCallCount, 1); + XCTAssertTrue(self.audioEngine.lastAttachedVoiceProcessingEnabled); } - (void)testStartReturnsErrorWhenAudioEngineFailsToStart { FakeRecorderAudioEngine *originalAudioEngine = self.audioEngine; - NativeAudioRecorder *recorder = - [[NativeAudioRecorder alloc] initWithReceiverBlock:^(const AudioBufferList *inputBuffer, - int numFrames){ - }]; + NativeAudioRecorder *recorder = [[NativeAudioRecorder alloc] + initWithReceiverBlock:^(const AudioBufferList *inputBuffer, int numFrames) {} + voiceProcessingEnabled:NO]; self.audioEngine.startIfNecessaryResult = NO; NSError *error = nil; @@ -383,10 +396,9 @@ - (void)testStopDetachesInputClearsResolvedStateAndNeverRestartsEngine { auto assertStopBehaviorForState = ^(AudioEngineState state) { self.audioEngine.fakeAVAudioEngine.fakeInputNode.outputFormat = [self validFormat]; - NativeAudioRecorder *recorder = - [[NativeAudioRecorder alloc] initWithReceiverBlock:^(const AudioBufferList *inputBuffer, - int numFrames){ - }]; + NativeAudioRecorder *recorder = [[NativeAudioRecorder alloc] + initWithReceiverBlock:^(const AudioBufferList *inputBuffer, int numFrames) {} + voiceProcessingEnabled:NO]; XCTAssertTrue([recorder start:nil]); XCTAssertNotNil([recorder getResolvedInputFormat]); @@ -412,10 +424,9 @@ - (void)testStopDetachesInputClearsResolvedStateAndNeverRestartsEngine - (void)testPauseAndResumeDelegateToAudioEngine { - NativeAudioRecorder *recorder = - [[NativeAudioRecorder alloc] initWithReceiverBlock:^(const AudioBufferList *inputBuffer, - int numFrames){ - }]; + NativeAudioRecorder *recorder = [[NativeAudioRecorder alloc] + initWithReceiverBlock:^(const AudioBufferList *inputBuffer, int numFrames) {} + voiceProcessingEnabled:NO]; [recorder pause]; [recorder resume]; @@ -432,10 +443,9 @@ - (void)testStartAfterSessionDeactivationUsesRecoveryRebuildPath channels:1]; self.audioEngine.fakeAVAudioEngine.fakeInputNode.outputFormat = initialFormat; - NativeAudioRecorder *recorder = - [[NativeAudioRecorder alloc] initWithReceiverBlock:^(const AudioBufferList *inputBuffer, - int numFrames){ - }]; + NativeAudioRecorder *recorder = [[NativeAudioRecorder alloc] + initWithReceiverBlock:^(const AudioBufferList *inputBuffer, int numFrames) {} + voiceProcessingEnabled:NO]; XCTAssertTrue([recorder start:nil]); XCTAssertEqualObjects([recorder getResolvedInputFormat], initialFormat); diff --git a/apps/fabric-example/ios/Podfile.lock b/apps/fabric-example/ios/Podfile.lock index 337ae517c..9e60c91a6 100644 --- a/apps/fabric-example/ios/Podfile.lock +++ b/apps/fabric-example/ios/Podfile.lock @@ -2523,7 +2523,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: FBLazyVector: c00c20551d40126351a6783c47ce75f5b374851b - hermes-engine: 146211e12d60a1951d9eb0287be07211e86cf5d5 + hermes-engine: 91023181d4bc5948b457de5314623fbfe4f8604e RCTDeprecation: 3bb167081b134461cfeb875ff7ae1945f8635257 RCTRequired: 74839f55d5058a133a0bc4569b0afec750957f64 RCTSwiftUI: 87a316382f3eab4dd13d2a0d0fd2adcce917361a @@ -2532,7 +2532,7 @@ SPEC CHECKSUMS: React: 1b1536b9099195944034e65b1830f463caaa8390 React-callinvoker: 6dff6d17d1d6cc8fdf85468a649bafed473c65f5 React-Core: 00faa4d038298089a1d5a5b21dde8660c4f0820d - React-Core-prebuilt: ef40616103ee11f8c2517697c3aa4f48ce790549 + React-Core-prebuilt: a6d614de037caff7898424dfc22915ec792de921 React-CoreModules: a17807f849bfd86045b0b9a75ec8c19373b482f6 React-cxxreact: c7b53ace5827be54048288bce5c55f337c41e95f React-debug: e1f00fcd2cef58a2897471a6d76a4ef5f5f90c74 @@ -2596,7 +2596,7 @@ SPEC CHECKSUMS: ReactAppDependencyProvider: 5787b37b8e2e51dfeab697ec031cc7c4080dcea2 ReactCodegen: d07ee3c8db75b43d1cbe479ae6affebf9925c733 ReactCommon: fe2a3af8975e63efa60f95fca8c34dc85deee360 - ReactNativeDependencies: 54189f1570b1308686cb21564e755e1daa77ea03 + ReactNativeDependencies: 4d5ce2683b6d74f7c686bf90a88c7d381295cf3c RNAudioAPI: bf5a0c9caaa6d553c5aaa4e0b024596dea4634f7 RNAudioWorklets: febe470be646585d9b8b0de320a5fb8684cb012c RNGestureHandler: 187c5c7936abf427bc4d22d6c3b1ac80ad1f63c0 diff --git a/packages/audiodocs/docs/inputs/audio-recorder.mdx b/packages/audiodocs/docs/inputs/audio-recorder.mdx index c82fec84a..c2b96873c 100644 --- a/packages/audiodocs/docs/inputs/audio-recorder.mdx +++ b/packages/audiodocs/docs/inputs/audio-recorder.mdx @@ -6,7 +6,7 @@ toc_max_heading_level: 5 import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; -import { Optional } from '@site/src/components/Badges'; +import { Optional, IOS, Android } from '@site/src/components/Badges'; # AudioRecorder @@ -347,10 +347,10 @@ export default MyRecorder; ## Constructor ```tsx -constructor() +constructor(options?: AudioRecorderOptions) ``` -Creates a new `AudioRecorder` instance. +Creates a new `AudioRecorder` instance, optionally configuring the platform capture chain - see [`AudioRecorderOptions`](#audiorecorderoptions). :::info It is preferred to create only a single instance of `AudioRecorder` for the best performance, memory, and battery consumption. While an idle recorder has minimal impact, switching between separate recorder instances might have a noticeable impact on the device. @@ -362,6 +362,15 @@ import { AudioRecorder } from 'react-native-audio-api'; const audioRecorder = new AudioRecorder(); ``` +Full-duplex voice apps (playing audio while recording) need platform echo cancellation on both platforms, otherwise the speaker output leaks back into the microphone: + +```tsx +const audioRecorder = new AudioRecorder({ + androidInputPreset: 'voiceCommunication', + iosVoiceProcessing: true, +}); +``` + ## Properties | Name | Type | Description | @@ -379,7 +388,7 @@ Starts the stream from the system audio input device. | :---: | :---: | :---- | | `options` | [`AudioRecorderStartOptions`](#audiorecorderstartoptions) | Optional recording start configuration. | -#### Returns `Promise>`. +###### Returns `Promise>`. ```tsx const result = await audioRecorder.start({ @@ -395,7 +404,7 @@ Stops the input stream and cleans up each input access method. For details on the returned file information, see [`FileInfo`](#fileinfo). -#### Returns `Promise>`. +###### Returns `Promise>`. ```tsx const result = await audioRecorder.stop(); @@ -427,7 +436,7 @@ audioRecorder.resume(); Returns `true` if the recorder is in an active recording state. -#### Returns `boolean`. +###### Returns `boolean`. ```tsx const isRecording = audioRecorder.isRecording(); @@ -437,7 +446,7 @@ const isRecording = audioRecorder.isRecording(); Returns `true` if the recorder is in a paused state. -#### Returns `boolean`. +###### Returns `boolean`. ```tsx const isPaused = audioRecorder.isPaused(); @@ -447,7 +456,7 @@ const isPaused = audioRecorder.isPaused(); Returns the current recording duration when file output is enabled. -#### Returns `number`. +###### Returns `number`. ```tsx const duration = audioRecorder.getCurrentDuration(); @@ -463,7 +472,7 @@ For further information, see [`AudioRecorderFileOptions`](#audiorecorderfileopti | :---: | :---: | :---- | | `options` | [`AudioRecorderFileOptions`](#audiorecorderfileoptions) | File output configuration. | -#### Returns `Result<{}>`. +###### Returns `Result<{}>`. ```tsx audioRecorder.enableFileOutput(); @@ -490,7 +499,7 @@ For further information, see [`AudioRecorderCallbackOptions`](#audiorecordercall | `options` | [`AudioRecorderCallbackOptions`](#audiorecordercallbackoptions) | Preferred callback buffer configuration. | | `callback` | `(event: OnAudioReadyEventType) => void` | Function invoked when a new audio buffer is available. | -#### Returns `Result`. +###### Returns `Result`. ```tsx const sampleRate = 16000; @@ -524,7 +533,7 @@ Routes captured audio into an audio graph by creating a recorder adapter with [` | `context` | [`BaseAudioContext`](/docs/core/base-audio-context) | Audio context used to create the recorder adapter. | | `destination` | [`AudioNode`](/docs/core/audio-node) | Destination node in the audio graph. | -#### Returns [`AudioNode`](/docs/core/audio-node). +###### Returns [`AudioNode`](/docs/core/audio-node). ```tsx audioRecorder.connect(audioContext, audioContext.destination); @@ -564,6 +573,40 @@ audioRecorder.clearOnError(); ## Types +#### `AudioRecorderOptions` + +```tsx +interface AudioRecorderOptions { + androidInputPreset?: AndroidInputPreset; + iosVoiceProcessing?: boolean; +} +``` + +| Parameter | Type | Default | Description | +| :---: | :---: | :---: | :---- | +| `androidInputPreset` | [`AndroidInputPreset`](#androidinputpreset) | `'voiceRecognition'` | Preprocessing chain applied to the capture stream. The platform default, `voiceRecognition`, applies **no** acoustic echo cancellation - use `voiceCommunication` to engage the platform AEC/NS chain. | +| `iosVoiceProcessing` | `boolean` | `false` | Runs the capture chain through Apple's voice-processing I/O: acoustic echo cancellation, noise suppression and automatic gain control. | + +Both options are applied when the capture stream is created and cannot be changed afterwards - create a new recorder to switch configuration. Each option is ignored on the other platform. + +:::caution +Voice processing changes the hardware input format and engages a shared platform processing unit, so the resolved sample rate and channel count of the recorded audio may differ from the raw microphone format. +::: + +#### `AndroidInputPreset` + +```tsx +type AndroidInputPreset = + | 'generic' + | 'camcorder' + | 'voiceRecognition' + | 'voiceCommunication' + | 'unprocessed' + | 'voicePerformance'; +``` + +Maps to the Android [audio input preset](https://developer.android.com/ndk/reference/group/audio#anonymous-enum-9) of the capture stream. + #### `AudioRecorderStartOptions` ```tsx diff --git a/packages/react-native-audio-api/common/cpp/audioapi/AudioAPIModuleInstaller.h b/packages/react-native-audio-api/common/cpp/audioapi/AudioAPIModuleInstaller.h index 805744fe6..6b996d48a 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/AudioAPIModuleInstaller.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/AudioAPIModuleInstaller.h @@ -16,6 +16,8 @@ #include #include +#include + namespace audioapi { using namespace facebook; @@ -121,8 +123,18 @@ class AudioAPIModuleInstaller { if (count > 0 && args[0].isString()) { androidInputPreset = args[0].getString(runtime).utf8(runtime); } + + bool iosVoiceProcessing = false; + if (count > 1 && args[1].isBool()) { + iosVoiceProcessing = args[1].getBool(); + } + auto audioRecorderHostObject = std::make_shared( - audioEventHandlerRegistry, &runtime, jsCallInvoker, androidInputPreset); + audioEventHandlerRegistry, + &runtime, + jsCallInvoker, + androidInputPreset, + iosVoiceProcessing); auto jsiObject = jsi::Object::createFromHostObject(runtime, audioRecorderHostObject); diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/inputs/AudioRecorderHostObject.cpp b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/inputs/AudioRecorderHostObject.cpp index 53301d898..01969afb1 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/inputs/AudioRecorderHostObject.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/inputs/AudioRecorderHostObject.cpp @@ -23,13 +23,18 @@ AudioRecorderHostObject::AudioRecorderHostObject( const std::shared_ptr &audioEventHandlerRegistry, jsi::Runtime *runtime, const std::shared_ptr &callInvoker, - const std::string &androidInputPreset) { + const std::string &androidInputPreset, + bool iosVoiceProcessing) { #ifdef ANDROID - audioRecorder_ = std::make_shared(audioEventHandlerRegistry, androidInputPreset); + // Voice processing is an iOS concept (AVAudioEngine); Android selects its + // input preprocessing chain through the Oboe input preset instead. + audioRecorder_ = + std::make_shared(audioEventHandlerRegistry, androidInputPreset); #else - // The input preset is an Android concept (Oboe); iOS configures its input - // through the AVAudioSession mode instead. - audioRecorder_ = std::make_shared(audioEventHandlerRegistry); + // The input preset is an Android concept (Oboe); iOS gets echo cancellation + // from the voice-processing I/O unit instead. + audioRecorder_ = + std::make_shared(audioEventHandlerRegistry, iosVoiceProcessing); #endif promiseVendor_ = std::make_shared(runtime, callInvoker); diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/inputs/AudioRecorderHostObject.h b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/inputs/AudioRecorderHostObject.h index a9530d4d7..8980fb996 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/inputs/AudioRecorderHostObject.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/inputs/AudioRecorderHostObject.h @@ -19,7 +19,8 @@ class AudioRecorderHostObject : public HostObject { const std::shared_ptr &audioEventHandlerRegistry, jsi::Runtime *runtime, const std::shared_ptr &callInvoker, - const std::string &androidInputPreset); + const std::string &androidInputPreset, + bool iosVoiceProcessing); JSI_HOST_FUNCTION_DECL(start); JSI_HOST_FUNCTION_DECL(stop); diff --git a/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.h b/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.h index 3ecfa37f2..4f1faabb0 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.h +++ b/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.h @@ -29,7 +29,9 @@ class AudioFileWriter; class IOSAudioRecorder : public AudioRecorder { public: - IOSAudioRecorder(const std::shared_ptr &audioEventHandlerRegistry); + IOSAudioRecorder( + const std::shared_ptr &audioEventHandlerRegistry, + bool voiceProcessingEnabled = false); ~IOSAudioRecorder() override; Result start(const std::string &fileNameOverride = "") override; diff --git a/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.mm b/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.mm index 2f1bf413a..17a726bcc 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.mm +++ b/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.mm @@ -103,8 +103,11 @@ static void cleanupStartedRecorder( /// All other necessary fields (like buffers) are initialized in start() method. /// This "method" should be called from the JS thread only. /// @param audioEventHandlerRegistry Shared pointer to the AudioEventHandlerRegistry for event handling. +/// @param voiceProcessingEnabled Whether the capture chain runs through Apple's voice-processing I/O +/// (echo cancellation, noise suppression, automatic gain control). IOSAudioRecorder::IOSAudioRecorder( - const std::shared_ptr &audioEventHandlerRegistry) + const std::shared_ptr &audioEventHandlerRegistry, + bool voiceProcessingEnabled) : AudioRecorder(audioEventHandlerRegistry) { AudioReceiverBlock receiverBlock = ^(const AudioBufferList *inputBuffer, int numFrames) { @@ -136,7 +139,8 @@ static void cleanupStartedRecorder( } }; - nativeRecorder_ = [[NativeAudioRecorder alloc] initWithReceiverBlock:receiverBlock]; + nativeRecorder_ = [[NativeAudioRecorder alloc] initWithReceiverBlock:receiverBlock + voiceProcessingEnabled:voiceProcessingEnabled]; } IOSAudioRecorder::~IOSAudioRecorder() diff --git a/packages/react-native-audio-api/ios/audioapi/ios/core/NativeAudioRecorder.h b/packages/react-native-audio-api/ios/audioapi/ios/core/NativeAudioRecorder.h index 576f3ac8a..f420b6300 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/core/NativeAudioRecorder.h +++ b/packages/react-native-audio-api/ios/audioapi/ios/core/NativeAudioRecorder.h @@ -12,8 +12,10 @@ typedef void (^AudioReceiverBlock)(const AudioBufferList *inputBuffer, int numFr @property (nonatomic, strong) AVAudioFormat *resolvedInputFormat; @property (nonatomic, assign) int resolvedBufferSize; @property (atomic, assign) BOOL inputArmed; +@property (nonatomic, assign) BOOL voiceProcessingEnabled; -- (instancetype)initWithReceiverBlock:(AudioReceiverBlock)receiverBlock; +- (instancetype)initWithReceiverBlock:(AudioReceiverBlock)receiverBlock + voiceProcessingEnabled:(BOOL)voiceProcessingEnabled; - (int)getBufferSize; - (AVAudioFormat *)getResolvedInputFormat; diff --git a/packages/react-native-audio-api/ios/audioapi/ios/core/NativeAudioRecorder.m b/packages/react-native-audio-api/ios/audioapi/ios/core/NativeAudioRecorder.m index d5724728d..a5a99d5d6 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/core/NativeAudioRecorder.m +++ b/packages/react-native-audio-api/ios/audioapi/ios/core/NativeAudioRecorder.m @@ -26,9 +26,11 @@ - (AVAudioFormat *)readLiveInputFormat } - (instancetype)initWithReceiverBlock:(AudioReceiverBlock)receiverBlock + voiceProcessingEnabled:(BOOL)voiceProcessingEnabled { if (self = [super init]) { self.receiverBlock = [receiverBlock copy]; + self.voiceProcessingEnabled = voiceProcessingEnabled; self.inputArmed = NO; self.resolvedBufferSize = 0; @@ -93,7 +95,8 @@ - (BOOL)start:(NSError **)error self.resolvedBufferSize = 0; [audioEngine stopIfNecessary]; - [audioEngine attachInputNodeWithReceiverBlock:self.receiverSinkBlock]; + [audioEngine attachInputNodeWithReceiverBlock:self.receiverSinkBlock + voiceProcessingEnabled:self.voiceProcessingEnabled]; if (![audioEngine startIfNecessary]) { [audioEngine detachInputNode]; diff --git a/packages/react-native-audio-api/ios/audioapi/ios/system/AudioEngine.h b/packages/react-native-audio-api/ios/audioapi/ios/system/AudioEngine.h index e4f9fdd39..fe9dc250c 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/system/AudioEngine.h +++ b/packages/react-native-audio-api/ios/audioapi/ios/system/AudioEngine.h @@ -33,7 +33,8 @@ typedef NS_ENUM(NSInteger, AudioEngineState) { channelCount:(AVAudioChannelCount)channelCount; - (void)detachSourceNodeWithId:(NSString *)sourceNodeId; -- (void)attachInputNodeWithReceiverBlock:(AVAudioSinkNodeReceiverBlock)receiverBlock; +- (void)attachInputNodeWithReceiverBlock:(AVAudioSinkNodeReceiverBlock)receiverBlock + voiceProcessingEnabled:(BOOL)voiceProcessingEnabled; - (void)detachInputNode; - (AVAudioFormat *)getLiveInputFormat; diff --git a/packages/react-native-audio-api/ios/audioapi/ios/system/AudioEngine.mm b/packages/react-native-audio-api/ios/audioapi/ios/system/AudioEngine.mm index d91ed60f9..159311a21 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/system/AudioEngine.mm +++ b/packages/react-native-audio-api/ios/audioapi/ios/system/AudioEngine.mm @@ -17,6 +17,7 @@ @implementation AudioEngineSourceRegistration @interface AudioEngineInputRegistration : NSObject @property (nonatomic, copy) AVAudioSinkNodeReceiverBlock receiverBlock; +@property (nonatomic, assign) BOOL voiceProcessingEnabled; @end @@ -26,6 +27,9 @@ @implementation AudioEngineInputRegistration @interface AudioEngine () { std::mutex _engineLock; BOOL _isRebuildingAudioEngine; + /// Tracks whether voice processing is currently engaged on the system input + /// node of the live engine instance. Reset whenever the engine is recreated. + BOOL _voiceProcessingApplied; } @property (nonatomic, strong) @@ -38,6 +42,7 @@ - (BOOL)hasTrackedGraph; - (AVAudioFormat *)currentInputConnectionFormat; - (void)materializeSourceNodeWithId:(NSString *)sourceNodeId; - (BOOL)materializeInputNodeIfNeeded; +- (void)applyVoiceProcessing; - (void)materializeTrackedNodesIfNeeded; - (AVAudioFormat *)liveInputFormat; @@ -86,6 +91,7 @@ - (void)destroyAudioEnginePreservingSessionDeactivationState:(BOOL)preserveSessi self.sourceFormats = [[NSMutableDictionary alloc] init]; self.inputNode = nil; self.graphNeedsRebuild = hadGraph; + _voiceProcessingApplied = NO; if (!preserveSessionDeactivationState) { self.sessionDeactivationInvalidatedGraph = false; @@ -186,6 +192,8 @@ - (BOOL)materializeInputNodeIfNeeded return YES; } + [self applyVoiceProcessing]; + AVAudioFormat *inputFormat = [self currentInputConnectionFormat]; if (inputFormat == nil) { @@ -199,8 +207,54 @@ - (BOOL)materializeInputNodeIfNeeded return YES; } +// Apple's voice-processing I/O (echo cancellation, noise suppression, AGC) is +// opt-in per recorder. Without it, full-duplex apps (VoIP, voice agents) hear +// their own speaker output looped back into the microphone. Toggling it is only +// allowed while the engine is stopped, and it changes the hardware input format, +// so this has to run before the input connection format is read. +- (void)applyVoiceProcessing +{ + BOOL wantsVoiceProcessing = self.inputRegistration.voiceProcessingEnabled; + + // A freshly created engine has voice processing off, so for playback-only + // graphs there is nothing to undo - and reading `inputNode` would needlessly + // pull the microphone into the engine. + if (!wantsVoiceProcessing && !_voiceProcessingApplied) { + return; + } + + if (self.audioEngine == nil) { + return; + } + + AVAudioInputNode *systemInputNode = self.audioEngine.inputNode; + + if (systemInputNode.isVoiceProcessingEnabled == wantsVoiceProcessing) { + _voiceProcessingApplied = wantsVoiceProcessing; + return; + } + + if ([self.audioEngine isRunning]) { + [self.audioEngine stop]; + } + + NSError *error = nil; + + if (![systemInputNode setVoiceProcessingEnabled:wantsVoiceProcessing error:&error]) { + NSLog( + @"[AudioEngine] Error while setting voice processing to %@: %@", + wantsVoiceProcessing ? @"true" : @"false", + [error debugDescription]); + return; + } + + _voiceProcessingApplied = wantsVoiceProcessing; +} + - (void)materializeTrackedNodesIfNeeded { + [self applyVoiceProcessing]; + NSArray *sourceNodeIds = [[self.sourceRegistrations allKeys] sortedArrayUsingSelector:@selector(compare:)]; for (NSString *sourceNodeId in sourceNodeIds) { @@ -253,6 +307,7 @@ - (void)detachSourceNodeWithId:(NSString *)sourceNodeId } - (void)attachInputNodeWithReceiverBlock:(AVAudioSinkNodeReceiverBlock)receiverBlock + voiceProcessingEnabled:(BOOL)voiceProcessingEnabled { std::scoped_lock lock(_engineLock); [self createAudioEngineIfNeeded]; @@ -263,6 +318,7 @@ - (void)attachInputNodeWithReceiverBlock:(AVAudioSinkNodeReceiverBlock)receiverB AudioEngineInputRegistration *registration = [[AudioEngineInputRegistration alloc] init]; registration.receiverBlock = receiverBlock; + registration.voiceProcessingEnabled = voiceProcessingEnabled; self.inputRegistration = registration; [self materializeInputNodeIfNeeded]; diff --git a/packages/react-native-audio-api/src/AudioAPIModule/globals.d.ts b/packages/react-native-audio-api/src/AudioAPIModule/globals.d.ts index 60794261a..8ac97a75e 100644 --- a/packages/react-native-audio-api/src/AudioAPIModule/globals.d.ts +++ b/packages/react-native-audio-api/src/AudioAPIModule/globals.d.ts @@ -17,7 +17,10 @@ declare global { sampleRate: number ) => IOfflineAudioContext; - var createAudioRecorder: (androidInputPreset: string) => IAudioRecorder; + var createAudioRecorder: ( + androidInputPreset: string, + iosVoiceProcessing: boolean + ) => IAudioRecorder; var createAudioBuffer: ( numberOfChannels: number, diff --git a/packages/react-native-audio-api/src/api.ts b/packages/react-native-audio-api/src/api.ts index 40ea82d94..38e4f5365 100644 --- a/packages/react-native-audio-api/src/api.ts +++ b/packages/react-native-audio-api/src/api.ts @@ -38,7 +38,10 @@ export * from './Audio'; export { default as Audio } from './Audio'; export { default as AudioControls } from './Audio/controls/AudioControls'; export type { MediaElementAudioSourceOptions } from './core/MediaElementAudioSourceNode'; -export type { AudioRecorderOptions, AndroidInputPreset } from './core/AudioRecorder'; +export type { + AudioRecorderOptions, + AndroidInputPreset, +} from './core/AudioRecorder'; export type { default as AudioEventSubscription } from './events/AudioEventSubscription'; export { default as FilePreset } from './utils/filePresets'; diff --git a/packages/react-native-audio-api/src/core/AudioRecorder.ts b/packages/react-native-audio-api/src/core/AudioRecorder.ts index 9f03b3335..82e1410f7 100644 --- a/packages/react-native-audio-api/src/core/AudioRecorder.ts +++ b/packages/react-native-audio-api/src/core/AudioRecorder.ts @@ -39,19 +39,21 @@ function withDefaultOptions( }; } -/** - * Options applied when the recorder is created. - */ +/** Options applied when the recorder is created. */ export interface AudioRecorderOptions { /** - * Android only: the Oboe input preset for the capture stream. Android - * routes the microphone through a preprocessing chain selected by this - * preset; Oboe's implicit default is `voiceRecognition`, which applies NO - * acoustic echo cancellation. Duplex voice use cases (VoIP-style apps that - * play audio while recording) should pass `voiceCommunication` to engage - * the platform AEC/NS chain. Has no effect on iOS. + * Android only: use `voiceRecognition` for acoustic echo cancellation see + * https://developer.android.com/ndk/reference/group/audio#anonymous-enum-9 + * for more definitions */ androidInputPreset?: AndroidInputPreset; + + /** + * Enables Apple's voice-processing I/O on the capture chain (iOS only): + * acoustic echo cancellation, noise suppression and automatic gain control. + * Defaults to `false`, which keeps the raw microphone signal. + */ + iosVoiceProcessing?: boolean; } export type AndroidInputPreset = @@ -75,7 +77,10 @@ export default class AudioRecorder { ); constructor(options?: AudioRecorderOptions) { - this.recorder = globalThis.createAudioRecorder(options?.androidInputPreset ?? ''); + this.recorder = globalThis.createAudioRecorder( + options?.androidInputPreset ?? '', + options?.iosVoiceProcessing ?? false + ); } /** diff --git a/packages/react-native-audio-api/src/mock/index.ts b/packages/react-native-audio-api/src/mock/index.ts index 7b85feed3..e852020b9 100644 --- a/packages/react-native-audio-api/src/mock/index.ts +++ b/packages/react-native-audio-api/src/mock/index.ts @@ -1,3 +1,4 @@ +import type { AudioRecorderOptions } from '../core/AudioRecorder'; import { AudioContextOptions, AudioRecorderCallbackOptions, @@ -860,7 +861,8 @@ class AudioRecorderMock { private onAudioReadySubscription: MockEventSubscription | null = null; private onErrorSubscription: MockEventSubscription | null = null; - constructor() {} + // Options only configure the native capture chain, so the mock ignores them. + constructor(_options?: AudioRecorderOptions) {} enableFileOutput( options?: AudioRecorderFileOptions From 507c209a837dc94143a721a43ab48c86acfd3222 Mon Sep 17 00:00:00 2001 From: michal Date: Tue, 18 Aug 2026 13:43:23 +0200 Subject: [PATCH 3/3] feat: comments --- .../audiodocs/docs/inputs/audio-recorder.mdx | 2 +- .../android/core/AndroidAudioRecorder.cpp | 5 ++- .../android/core/AndroidAudioRecorder.h | 3 +- .../cpp/audioapi/AudioAPIModuleInstaller.h | 22 +++-------- .../inputs/AudioRecorderHostObject.cpp | 13 ++----- .../inputs/AudioRecorderHostObject.h | 5 +-- .../audioapi/utils/AudioRecorderOptions.cpp | 31 +++++++++++++++ .../cpp/audioapi/utils/AudioRecorderOptions.h | 34 ++++++++++++++++ .../ios/audioapi/ios/core/IOSAudioRecorder.h | 8 +++- .../ios/audioapi/ios/core/IOSAudioRecorder.mm | 9 ++--- .../src/AudioAPIModule/globals.d.ts | 6 +-- packages/react-native-audio-api/src/api.ts | 4 -- .../src/core/AudioRecorder.ts | 39 +++---------------- .../react-native-audio-api/src/mock/index.ts | 2 +- packages/react-native-audio-api/src/types.ts | 28 +++++++++++++ 15 files changed, 130 insertions(+), 81 deletions(-) create mode 100644 packages/react-native-audio-api/common/cpp/audioapi/utils/AudioRecorderOptions.cpp create mode 100644 packages/react-native-audio-api/common/cpp/audioapi/utils/AudioRecorderOptions.h diff --git a/packages/audiodocs/docs/inputs/audio-recorder.mdx b/packages/audiodocs/docs/inputs/audio-recorder.mdx index c2b96873c..6d8fdfe43 100644 --- a/packages/audiodocs/docs/inputs/audio-recorder.mdx +++ b/packages/audiodocs/docs/inputs/audio-recorder.mdx @@ -605,7 +605,7 @@ type AndroidInputPreset = | 'voicePerformance'; ``` -Maps to the Android [audio input preset](https://developer.android.com/ndk/reference/group/audio#anonymous-enum-9) of the capture stream. +Names of Oboe's [`InputPreset`](https://github.com/google/oboe/blob/0da326e4ef878eac0c032e11ea84ca0a6811aafd/include/oboe/Definitions.h#L470) values, which select the preprocessing chain the capture stream is opened with. #### `AudioRecorderStartOptions` 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 1e8b9b072..1a24cf8fe 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 @@ -14,6 +14,7 @@ #include #include #include +#include #include #include @@ -54,9 +55,9 @@ std::optional inputPresetFromString(const std::string &name) AndroidAudioRecorder::AndroidAudioRecorder( const std::shared_ptr &audioEventHandlerRegistry, - const std::string &inputPreset) + AudioRecorderOptions options) : AudioRecorder(audioEventHandlerRegistry), - inputPreset_(inputPreset), + inputPreset_(std::move(options.androidInputPreset)), streamSampleRate_(0.0), streamChannelCount_(0), streamMaxBufferSizeInFrames_(0) {} 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 7adbbe281..d30318d75 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 @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -25,7 +26,7 @@ class AndroidAudioRecorder : public oboe::AudioStreamCallback, public: explicit AndroidAudioRecorder( const std::shared_ptr &audioEventHandlerRegistry, - const std::string &inputPreset = ""); + AudioRecorderOptions options = {}); ~AndroidAudioRecorder() override; void cleanup(); diff --git a/packages/react-native-audio-api/common/cpp/audioapi/AudioAPIModuleInstaller.h b/packages/react-native-audio-api/common/cpp/audioapi/AudioAPIModuleInstaller.h index 1b5882d5b..33068db7d 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/AudioAPIModuleInstaller.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/AudioAPIModuleInstaller.h @@ -11,12 +11,13 @@ #include #include #include +#include #include #include #include -#include +#include namespace audioapi { @@ -113,28 +114,17 @@ class AudioAPIModuleInstaller { return jsi::Function::createFromHostFunction( *jsiRuntime, jsi::PropNameID::forAscii(*jsiRuntime, "createAudioRecorder"), - 0, + 1, [jsCallInvoker, audioEventHandlerRegistry]( jsi::Runtime &runtime, const jsi::Value &thisValue, const jsi::Value *args, size_t count) -> jsi::Value { - std::string androidInputPreset; - if (count > 0 && args[0].isString()) { - androidInputPreset = args[0].getString(runtime).utf8(runtime); - } - - bool iosVoiceProcessing = false; - if (count > 1 && args[1].isBool()) { - iosVoiceProcessing = args[1].getBool(); - } + auto options = count > 0 ? AudioRecorderOptions::CreateFromJSIValue(runtime, args[0]) + : AudioRecorderOptions{}; auto audioRecorderHostObject = std::make_shared( - audioEventHandlerRegistry, - &runtime, - jsCallInvoker, - androidInputPreset, - iosVoiceProcessing); + audioEventHandlerRegistry, &runtime, jsCallInvoker, std::move(options)); auto jsiObject = jsi::Object::createFromHostObject(runtime, audioRecorderHostObject); diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/inputs/AudioRecorderHostObject.cpp b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/inputs/AudioRecorderHostObject.cpp index 71f629395..ab999d80c 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/inputs/AudioRecorderHostObject.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/inputs/AudioRecorderHostObject.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #ifdef ANDROID #include #else @@ -23,18 +24,12 @@ AudioRecorderHostObject::AudioRecorderHostObject( const std::shared_ptr &audioEventHandlerRegistry, jsi::Runtime *runtime, const std::shared_ptr &callInvoker, - const std::string &androidInputPreset, - bool iosVoiceProcessing) { + AudioRecorderOptions options) { #ifdef ANDROID - // Voice processing is an iOS concept (AVAudioEngine); Android selects its - // input preprocessing chain through the Oboe input preset instead. audioRecorder_ = - std::make_shared(audioEventHandlerRegistry, androidInputPreset); + std::make_shared(audioEventHandlerRegistry, std::move(options)); #else - // The input preset is an Android concept (Oboe); iOS gets echo cancellation - // from the voice-processing I/O unit instead. - audioRecorder_ = - std::make_shared(audioEventHandlerRegistry, iosVoiceProcessing); + audioRecorder_ = std::make_shared(audioEventHandlerRegistry, options); #endif promiseVendor_ = std::make_shared(runtime, callInvoker); diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/inputs/AudioRecorderHostObject.h b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/inputs/AudioRecorderHostObject.h index 320cfd670..c2bd4e8eb 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/inputs/AudioRecorderHostObject.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/inputs/AudioRecorderHostObject.h @@ -3,9 +3,9 @@ #include #include #include +#include #include -#include namespace audioapi { using namespace facebook; @@ -19,8 +19,7 @@ class AudioRecorderHostObject : public HostObject { const std::shared_ptr &audioEventHandlerRegistry, jsi::Runtime *runtime, const std::shared_ptr &callInvoker, - const std::string &androidInputPreset, - bool iosVoiceProcessing); + AudioRecorderOptions options); JSI_HOST_FUNCTION_DECL(start); JSI_HOST_FUNCTION_DECL(stop); diff --git a/packages/react-native-audio-api/common/cpp/audioapi/utils/AudioRecorderOptions.cpp b/packages/react-native-audio-api/common/cpp/audioapi/utils/AudioRecorderOptions.cpp new file mode 100644 index 000000000..0e60fbe58 --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/audioapi/utils/AudioRecorderOptions.cpp @@ -0,0 +1,31 @@ +#include + +#include + +namespace audioapi { + +AudioRecorderOptions AudioRecorderOptions::CreateFromJSIValue( + facebook::jsi::Runtime &runtime, + const facebook::jsi::Value &value) { + AudioRecorderOptions options; + + if (!value.isObject()) { + return options; + } + + auto jsOptions = value.getObject(runtime); + + auto androidInputPreset = jsOptions.getProperty(runtime, "androidInputPreset"); + if (androidInputPreset.isString()) { + options.androidInputPreset = androidInputPreset.getString(runtime).utf8(runtime); + } + + auto iosVoiceProcessing = jsOptions.getProperty(runtime, "iosVoiceProcessing"); + if (iosVoiceProcessing.isBool()) { + options.iosVoiceProcessing = iosVoiceProcessing.getBool(); + } + + return options; +} + +} // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/audioapi/utils/AudioRecorderOptions.h b/packages/react-native-audio-api/common/cpp/audioapi/utils/AudioRecorderOptions.h new file mode 100644 index 000000000..70a669aef --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/audioapi/utils/AudioRecorderOptions.h @@ -0,0 +1,34 @@ +#pragma once + +#include + +namespace facebook { +namespace jsi { +class Runtime; +class Value; +} // namespace jsi +} // namespace facebook + +namespace audioapi { + +/// Creation-time configuration of the platform capture chain, mirroring the +/// JS-side `AudioRecorderOptions`. Every field is honoured by a single platform +/// and ignored by the other one. +struct AudioRecorderOptions { + /// Name of the Oboe input preset, e.g. "voiceCommunication". An empty or + /// unknown name leaves Oboe's own default in place. Android only. + std::string androidInputPreset; + + /// Runs the capture chain through Apple's voice-processing I/O unit: echo + /// cancellation, noise suppression and automatic gain control. iOS only. + bool iosVoiceProcessing = false; + + /// Reads the options out of the object passed to `createAudioRecorder`. + /// Missing or mistyped properties keep their default, so an absent options + /// object yields the pre-existing platform behavior. + static AudioRecorderOptions CreateFromJSIValue( + facebook::jsi::Runtime &runtime, + const facebook::jsi::Value &value); +}; + +} // namespace audioapi diff --git a/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.h b/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.h index 04c42c4fb..d46d2ff06 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.h +++ b/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.h @@ -12,6 +12,8 @@ typedef struct objc_object AVAudioFormat; #include #include +#include +#include #include #include @@ -30,11 +32,13 @@ class AudioFileWriter; class IOSAudioRecorder : public AudioRecorder { public: - IOSAudioRecorder( + explicit IOSAudioRecorder( const std::shared_ptr &audioEventHandlerRegistry, - bool voiceProcessingEnabled = false); + const AudioRecorderOptions &options = {}); ~IOSAudioRecorder() override; + DELETE_COPY_AND_MOVE(IOSAudioRecorder); + Result start(const std::string &fileNameOverride = "") override; Result, double, double>, std::string> stop() override; diff --git a/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.mm b/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.mm index 26ca90d5a..26177044c 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.mm +++ b/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.mm @@ -65,12 +65,11 @@ static void cleanupStartedRecorder( /// This constructor initializes the receiver block and native side recorder wrapper (AVAudioSinkNode). /// All other necessary fields (like buffers) are initialized in start() method. /// This "method" should be called from the JS thread only. -/// @param audioEventHandlerRegistry Shared pointer to the AudioEventHandlerRegistry for event handling. -/// @param voiceProcessingEnabled Whether the capture chain runs through Apple's voice-processing I/O -/// (echo cancellation, noise suppression, automatic gain control). +/// @param audioEventHandlerRegistry Shared pointer to the IAudioEventHandlerRegistry for event handling. +/// @param options Creation-time capture chain configuration; only the iOS fields are read. IOSAudioRecorder::IOSAudioRecorder( const std::shared_ptr &audioEventHandlerRegistry, - bool voiceProcessingEnabled) + const AudioRecorderOptions &options) : AudioRecorder(audioEventHandlerRegistry) { AudioReceiverBlock receiverBlock = ^(const AudioBufferList *inputBuffer, int numFrames) { @@ -82,7 +81,7 @@ static void cleanupStartedRecorder( }; nativeRecorder_ = [[NativeAudioRecorder alloc] initWithReceiverBlock:receiverBlock - voiceProcessingEnabled:voiceProcessingEnabled]; + voiceProcessingEnabled:options.iosVoiceProcessing]; nativeRecorder_.onInputConfigurationChange = ^{ this->handleInputConfigurationChange(); }; } diff --git a/packages/react-native-audio-api/src/AudioAPIModule/globals.d.ts b/packages/react-native-audio-api/src/AudioAPIModule/globals.d.ts index 8ac97a75e..5bd2be419 100644 --- a/packages/react-native-audio-api/src/AudioAPIModule/globals.d.ts +++ b/packages/react-native-audio-api/src/AudioAPIModule/globals.d.ts @@ -7,6 +7,7 @@ import type { IAudioBuffer, IOfflineAudioContext, } from '../jsi-interfaces'; +import type { AudioRecorderOptions } from '../types'; /* eslint-disable no-var */ declare global { @@ -17,10 +18,7 @@ declare global { sampleRate: number ) => IOfflineAudioContext; - var createAudioRecorder: ( - androidInputPreset: string, - iosVoiceProcessing: boolean - ) => IAudioRecorder; + var createAudioRecorder: (options: AudioRecorderOptions) => IAudioRecorder; var createAudioBuffer: ( numberOfChannels: number, diff --git a/packages/react-native-audio-api/src/api.ts b/packages/react-native-audio-api/src/api.ts index 38e4f5365..5aa212b3b 100644 --- a/packages/react-native-audio-api/src/api.ts +++ b/packages/react-native-audio-api/src/api.ts @@ -38,10 +38,6 @@ export * from './Audio'; export { default as Audio } from './Audio'; export { default as AudioControls } from './Audio/controls/AudioControls'; export type { MediaElementAudioSourceOptions } from './core/MediaElementAudioSourceNode'; -export type { - AudioRecorderOptions, - AndroidInputPreset, -} from './core/AudioRecorder'; export type { default as AudioEventSubscription } from './events/AudioEventSubscription'; export { default as FilePreset } from './utils/filePresets'; diff --git a/packages/react-native-audio-api/src/core/AudioRecorder.ts b/packages/react-native-audio-api/src/core/AudioRecorder.ts index 82e1410f7..f1cd377f1 100644 --- a/packages/react-native-audio-api/src/core/AudioRecorder.ts +++ b/packages/react-native-audio-api/src/core/AudioRecorder.ts @@ -7,6 +7,7 @@ import { IAudioRecorder, IRecorderAdapterNode } from '../jsi-interfaces'; import { AudioRecorderCallbackOptions, AudioRecorderFileOptions, + AudioRecorderOptions, AudioRecorderStartOptions, FileDirectory, FileFormat, @@ -21,9 +22,9 @@ import AudioBuffer from './AudioBuffer'; import type AudioNode from './AudioNode'; import type BaseAudioContext from './BaseAudioContext'; -// Enforces default options, making sure that all properties are defined -// for the contract with native code. -function withDefaultOptions( +// Enforces default file output options, making sure that all properties are +// defined for the contract with native code. +function withDefaultFileOptions( inOptions: AudioRecorderFileOptions ): Required { return { @@ -39,31 +40,6 @@ function withDefaultOptions( }; } -/** Options applied when the recorder is created. */ -export interface AudioRecorderOptions { - /** - * Android only: use `voiceRecognition` for acoustic echo cancellation see - * https://developer.android.com/ndk/reference/group/audio#anonymous-enum-9 - * for more definitions - */ - androidInputPreset?: AndroidInputPreset; - - /** - * Enables Apple's voice-processing I/O on the capture chain (iOS only): - * acoustic echo cancellation, noise suppression and automatic gain control. - * Defaults to `false`, which keeps the raw microphone signal. - */ - iosVoiceProcessing?: boolean; -} - -export type AndroidInputPreset = - | 'generic' - | 'camcorder' - | 'voiceRecognition' - | 'voiceCommunication' - | 'unprocessed' - | 'voicePerformance'; - export default class AudioRecorder { protected onAudioReadySubscription: AudioEventSubscription | null = null; protected onErrorSubscription: AudioEventSubscription | null = null; @@ -77,10 +53,7 @@ export default class AudioRecorder { ); constructor(options?: AudioRecorderOptions) { - this.recorder = globalThis.createAudioRecorder( - options?.androidInputPreset ?? '', - options?.iosVoiceProcessing ?? false - ); + this.recorder = globalThis.createAudioRecorder(options ?? {}); } /** @@ -100,7 +73,7 @@ export default class AudioRecorder { */ enableFileOutput(options?: AudioRecorderFileOptions): Result<{}> { this.options_ = options || {}; - const parsedOptions = withDefaultOptions(this.options_); + const parsedOptions = withDefaultFileOptions(this.options_); const result = this.recorder.enableFileOutput(parsedOptions); this.isFileOutputEnabled = true; diff --git a/packages/react-native-audio-api/src/mock/index.ts b/packages/react-native-audio-api/src/mock/index.ts index e852020b9..5d596e392 100644 --- a/packages/react-native-audio-api/src/mock/index.ts +++ b/packages/react-native-audio-api/src/mock/index.ts @@ -1,8 +1,8 @@ -import type { AudioRecorderOptions } from '../core/AudioRecorder'; import { AudioContextOptions, AudioRecorderCallbackOptions, AudioRecorderFileOptions, + AudioRecorderOptions, AudioRecorderStartOptions, BiquadFilterType, ChannelCountMode, diff --git a/packages/react-native-audio-api/src/types.ts b/packages/react-native-audio-api/src/types.ts index 7afdae22c..d5db672e5 100644 --- a/packages/react-native-audio-api/src/types.ts +++ b/packages/react-native-audio-api/src/types.ts @@ -101,6 +101,34 @@ export interface FilePresetType { flacCompressionLevel: FlacCompressionLevel; } +export type AndroidInputPreset = + | 'generic' + | 'camcorder' + | 'voiceRecognition' + | 'voiceCommunication' + | 'unprocessed' + | 'voicePerformance'; + +/** Configures the platform capture chain when the recorder is created. */ +export interface AudioRecorderOptions { + /** + * Preprocessing chain applied to the capture stream (Android only), mapped to + * Oboe's + * [`InputPreset`](https://github.com/google/oboe/blob/0da326e4ef878eac0c032e11ea84ca0a6811aafd/include/oboe/Definitions.h#L470). + * The platform default, `voiceRecognition`, applies no acoustic echo + * cancellation - use `voiceCommunication` to engage the platform AEC/NS + * chain. + */ + androidInputPreset?: AndroidInputPreset; + + /** + * Runs the capture chain through Apple's voice-processing I/O (iOS only): + * acoustic echo cancellation, noise suppression and automatic gain control. + * Defaults to `false`, which keeps the raw microphone signal. + */ + iosVoiceProcessing?: boolean; +} + export interface AudioRecorderFileOptions { channelCount?: number; rotateIntervalBytes?: number;