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 7a3e47895..ec8ee9389 100644 --- a/apps/fabric-example/ios/FabricExampleTests/AudioEngineTests.mm +++ b/apps/fabric-example/ios/FabricExampleTests/AudioEngineTests.mm @@ -421,9 +421,8 @@ - (void)testDetachSourceNodeRemovesTrackedNodeAndClearsGraphWhenEmpty { - (void)testDetachSourceNodeKeepsGraphNeedsRebuildWhenInputRemains { NSString *sourceNodeId = [self attachSourceNodeToAudioEngine]; - [self.audioEngine - attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] - onInputConfigurationChange:nil]; + [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] + voiceProcessingEnabled:NO onInputConfigurationChange:nil]; self.audioEngine.graphNeedsRebuild = YES; [self.audioEngine detachSourceNodeWithId:sourceNodeId]; @@ -435,9 +434,8 @@ - (void)testDetachSourceNodeKeepsGraphNeedsRebuildWhenInputRemains { - (void)testAttachInputNodeStoresAndConnectsInput { FakeAudioEngine *fakeEngine = self.audioEngine.currentFakeAudioEngine; - [self.audioEngine - attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] - onInputConfigurationChange:nil]; + [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] + voiceProcessingEnabled:NO onInputConfigurationChange:nil]; AVAudioSinkNode *inputNode = self.audioEngine.inputNode; XCTAssertNotNil(inputNode); @@ -456,9 +454,8 @@ - (void)testAttachInputNodeDefersConnectionUntilLiveInputFormatIsAvailable { FakeAudioEngine *fakeEngine = self.audioEngine.currentFakeAudioEngine; fakeEngine.fakeInputNode.outputFormat = nil; - [self.audioEngine - attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] - onInputConfigurationChange:nil]; + [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] + voiceProcessingEnabled:NO onInputConfigurationChange:nil]; XCTAssertNil(self.audioEngine.inputNode); XCTAssertEqual(fakeEngine.attachNodeCallCount, 0); @@ -485,9 +482,8 @@ - (void)testDetachInputNodeWithoutInputDoesNothing { } - (void)testDetachInputNodeClearsGraphOnlyWhenNoSourcesRemain { - [self.audioEngine - attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] - onInputConfigurationChange:nil]; + [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] + voiceProcessingEnabled:NO onInputConfigurationChange:nil]; self.audioEngine.graphNeedsRebuild = YES; [self.audioEngine detachInputNode]; @@ -496,9 +492,8 @@ - (void)testDetachInputNodeClearsGraphOnlyWhenNoSourcesRemain { XCTAssertFalse(self.audioEngine.graphNeedsRebuild); [self attachSourceNodeToAudioEngine]; - [self.audioEngine - attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] - onInputConfigurationChange:nil]; + [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] + voiceProcessingEnabled:NO onInputConfigurationChange:nil]; self.audioEngine.graphNeedsRebuild = YES; [self.audioEngine detachInputNode]; @@ -511,9 +506,8 @@ - (void)testDetachInputNodePreservesSessionDeactivationInvalidation { FakeAudioEngine *fakeEngine = self.audioEngine.currentFakeAudioEngine; fakeEngine.fakeRunning = YES; self.audioEngine.state = AudioEngineStateRunning; - [self.audioEngine - attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] - onInputConfigurationChange:nil]; + [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] + voiceProcessingEnabled:NO onInputConfigurationChange:nil]; [self.audioEngine onSessionDeactivated]; [self.audioEngine detachInputNode]; @@ -735,9 +729,8 @@ - (void)testStartIfNecessaryRebuildsWhenGraphNeedsRebuild { - (void) testStartIfNecessaryRebuildsAfterSessionDeactivationEvenWhenTeardownClearsGraph { - [self.audioEngine - attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] - onInputConfigurationChange:nil]; + [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] + voiceProcessingEnabled:NO onInputConfigurationChange:nil]; FakeAudioEngine *oldEngine = self.audioEngine.currentFakeAudioEngine; oldEngine.fakeRunning = YES; @@ -754,9 +747,8 @@ - (void)testStartIfNecessaryRebuildsWhenGraphNeedsRebuild { AVAudioFormat *recoveredInputFormat = [self testInputFormatWithSampleRate:48000 channelCount:1]; self.audioEngine.nextCreatedEngineInputFormat = recoveredInputFormat; - [self.audioEngine - attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] - onInputConfigurationChange:nil]; + [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] + voiceProcessingEnabled:NO onInputConfigurationChange:nil]; AVAudioSinkNode *recoveredInputNode = self.audioEngine.inputNode; XCTAssertTrue([self.audioEngine startIfNecessary]); @@ -777,9 +769,8 @@ - (void)testStartIfNecessaryRebuildsWhenGraphNeedsRebuild { } - (void)testStartIfNecessaryRebuildsInputNodeWithFreshInstance { - [self.audioEngine - attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] - onInputConfigurationChange:nil]; + [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] + voiceProcessingEnabled:NO onInputConfigurationChange:nil]; FakeAudioEngine *oldEngine = self.audioEngine.currentFakeAudioEngine; AVAudioSinkNode *oldInputNode = self.audioEngine.inputNode; AVAudioFormat *replacementInputFormat = @@ -1000,8 +991,8 @@ - (void)testConcurrentRecordAndPlayPathsDoNotCrash { for (NSInteger index = 0; index < 10; index += 1) { dispatch_group_enter(group); dispatch_async(queue, ^{ - [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] - onInputConfigurationChange:nil]; + [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] + voiceProcessingEnabled:NO onInputConfigurationChange:nil]; [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 9cc6086ac..a115ec460 100644 --- a/apps/fabric-example/ios/FabricExampleTests/AudioPlayerTests.mm +++ b/apps/fabric-example/ios/FabricExampleTests/AudioPlayerTests.mm @@ -42,6 +42,7 @@ 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 807e6e0a3..cf8e8f3d9 100644 --- a/apps/fabric-example/ios/FabricExampleTests/IOSAudioRecorderTests.mm +++ b/apps/fabric-example/ios/FabricExampleTests/IOSAudioRecorderTests.mm @@ -8,6 +8,9 @@ #import #import #import +#import +#import +#import #import #include @@ -16,8 +19,7 @@ using namespace audioapi; -static NSString *NSStringFromStdString(const std::string &value) -{ +static NSString *NSStringFromStdString(const std::string &value) { return [NSString stringWithUTF8String:value.c_str()]; } @@ -27,18 +29,18 @@ std::shared_ptr context; std::shared_ptr handle; - RecorderAdapterNode *adapter() const - { + RecorderAdapterNode *adapter() const { return static_cast(handle->audioNode.get()); } }; -static RecorderAdapterTestFixture makeRecorderAdapterFixture() -{ +static RecorderAdapterTestFixture makeRecorderAdapterFixture() { RecorderAdapterTestFixture fixture; - fixture.context = std::make_shared(2, 512, 44100.0f, nullptr); + fixture.context = + std::make_shared(2, 512, 44100.0f, nullptr); auto adapterNode = std::make_unique(fixture.context); - fixture.handle = std::make_shared(0, std::move(adapterNode)); + fixture.handle = + std::make_shared(0, std::move(adapterNode)); return fixture; } @@ -60,8 +62,7 @@ @interface FakeIOSRecorderAudioEngine : AudioEngine @implementation FakeIOSRecorderAudioEngine -- (void)createAudioEngineIfNeeded -{ +- (void)createAudioEngineIfNeeded { // Avoid spinning up a real AVAudioEngine inside unit tests. } @@ -78,8 +79,7 @@ @interface FakeIOSRecorderAudioSessionManager : AudioSessionManager @implementation FakeIOSRecorderAudioSessionManager -- (instancetype)init -{ +- (instancetype)init { if (self = [super init]) { self.recordingPermissions = @"Granted"; self.diagnosticSampleRate = 44100; @@ -90,27 +90,25 @@ - (instancetype)init return self; } -- (NSString *)checkRecordingPermissions -{ +- (NSString *)checkRecordingPermissions { return self.recordingPermissions; } -- (bool)ensureActive:(bool)force error:(NSError **)error -{ +- (bool)ensureActive:(bool)force error:(NSError **)error { if (error != nil) { *error = nil; } return true; } -- (NSString *)inputDiagnosticsSnapshot -{ - return [NSString stringWithFormat:@"session={active=%@, sampleRate=%f, inputChannels=%lu}; " - @"route={routeReady=%@}", - self.isActive ? @"true" : @"false", - self.diagnosticSampleRate, - (unsigned long)self.diagnosticInputChannels, - self.routeReady ? @"true" : @"false"]; +- (NSString *)inputDiagnosticsSnapshot { + return [NSString + stringWithFormat: + @"session={active=%@, sampleRate=%f, inputChannels=%lu}; " + @"route={routeReady=%@}", + self.isActive ? @"true" : @"false", self.diagnosticSampleRate, + (unsigned long)self.diagnosticInputChannels, + self.routeReady ? @"true" : @"false"]; } @end @@ -135,12 +133,15 @@ @interface FakeNativeAudioRecorder : NativeAudioRecorder @implementation FakeNativeAudioRecorder -- (instancetype)init -{ - if (self = [super initWithReceiverBlock:^(const AudioBufferList *inputBuffer, int numFrames) { - }]) { +- (instancetype)init { + if (self = [super + initWithReceiverBlock:^(const AudioBufferList *inputBuffer, + int numFrames) { + } + voiceProcessingEnabled:NO]) { self.mockResolvedInputFormat = - [[AVAudioFormat alloc] initStandardFormatWithSampleRate:44100 channels:2]; + [[AVAudioFormat alloc] initStandardFormatWithSampleRate:44100 + channels:2]; self.mockResolvedBufferSize = 512; self.startResult = YES; } @@ -148,24 +149,22 @@ - (instancetype)init return self; } -- (AVAudioFormat *)getResolvedInputFormat -{ +- (AVAudioFormat *)getResolvedInputFormat { return (AVAudioFormat *)self.mockResolvedInputFormat; } -- (int)getResolvedBufferSize -{ +- (int)getResolvedBufferSize { return self.mockResolvedBufferSize; } -- (BOOL)start:(NSError **)error -{ +- (BOOL)start:(NSError **)error { self.startCallCount += 1; if (self.throwsOnStart) { - @throw self.startException ?: [NSException exceptionWithName:@"FakeStartException" - reason:@"boom" - userInfo:nil]; + @throw self.startException + ?: [NSException exceptionWithName:@"FakeStartException" + reason:@"boom" + userInfo:nil]; } if (error != nil) { @@ -175,30 +174,25 @@ - (BOOL)start:(NSError **)error return self.startResult; } -- (void)setInputArmed:(BOOL)armed -{ +- (void)setInputArmed:(BOOL)armed { self.setInputArmedCallCount += 1; self.lastInputArmed = armed; [super setInputArmed:armed]; } -- (void)stop -{ +- (void)stop { self.stopCallCount += 1; } -- (void)pause -{ +- (void)pause { self.pauseCallCount += 1; } -- (void)resume -{ +- (void)resume { self.resumeCallCount += 1; } -- (void)cleanup -{ +- (void)cleanup { self.cleanupCallCount += 1; [super cleanup]; } @@ -206,61 +200,52 @@ - (void)cleanup @end class TestableIOSAudioRecorder : public IOSAudioRecorder { - public: +public: explicit TestableIOSAudioRecorder( - const std::shared_ptr &audioEventHandlerRegistry) + const std::shared_ptr + &audioEventHandlerRegistry) : IOSAudioRecorder(audioEventHandlerRegistry) {} - NativeAudioRecorder *replaceNativeRecorder(NativeAudioRecorder *nativeRecorder) - { + NativeAudioRecorder * + replaceNativeRecorder(NativeAudioRecorder *nativeRecorder) { NativeAudioRecorder *previous = nativeRecorder_; nativeRecorder_ = nativeRecorder; return previous; } - void setRecorderState(RecorderState state) - { + void setRecorderState(RecorderState state) { state_.store(state, std::memory_order_release); } - std::string currentFilePath() const - { - return filePath_; - } + std::string currentFilePath() const { return filePath_; } - bool fileOutputEnabledIntent() const - { + bool fileOutputEnabledIntent() const { return fileOutputEnabled_.load(std::memory_order_acquire); } - bool fileOutputConfigured() const - { + bool fileOutputConfigured() const { return fileOutputConfigured_.load(std::memory_order_acquire); } - bool callbackOutputEnabledIntent() const - { + bool callbackOutputEnabledIntent() const { return callbackOutputEnabled_.load(std::memory_order_acquire); } - bool callbackOutputConfigured() const - { + bool callbackOutputConfigured() const { return callbackOutputConfigured_.load(std::memory_order_acquire); } - bool connectionEnabledIntent() const - { + bool connectionEnabledIntent() const { return isConnected_.load(std::memory_order_acquire); } - bool connectionConfigured() const - { + bool connectionConfigured() const { return connectedConfigured_.load(std::memory_order_acquire); } }; @interface IOSAudioRecorderTests : XCTestCase { - @private +@private std::unique_ptr _recorder; } @@ -273,8 +258,7 @@ @interface IOSAudioRecorderTests : XCTestCase { @implementation IOSAudioRecorderTests -- (void)setUp -{ +- (void)setUp { [super setUp]; self.sessionManager = [[FakeIOSRecorderAudioSessionManager alloc] init]; @@ -286,8 +270,7 @@ - (void)setUp self.nativeRecorder.onInputConfigurationChange = self.originalNativeRecorder.onInputConfigurationChange; } -- (void)tearDown -{ +- (void)tearDown { if (self.originalNativeRecorder != nil) { [self.originalNativeRecorder cleanup]; } @@ -305,25 +288,15 @@ - (void)tearDown [super tearDown]; } -- (std::shared_ptr)validFileProperties -{ +- (std::shared_ptr)validFileProperties { return std::make_shared( - AudioFileProperties::FileDirectory::Cache, - "fabric-example-tests", - "ios-recorder-test", - 2, - 0, - AudioFileProperties::Format::WAV, - 44100, - 128000, - AudioFileProperties::BitDepth::Bit16, - 0, - 0, + AudioFileProperties::FileDirectory::Cache, "fabric-example-tests", + "ios-recorder-test", 2, 0, AudioFileProperties::Format::WAV, 44100, + 128000, AudioFileProperties::BitDepth::Bit16, 0, 0, AudioFileProperties::IOSAudioQuality::High); } -- (id)invalidFormat -{ +- (id)invalidFormat { FakeIOSRecorderFormat *format = [[FakeIOSRecorderFormat alloc] init]; format.sampleRate = 0; format.channelCount = 0; @@ -331,43 +304,41 @@ - (id)invalidFormat return format; } -- (id)validMultichannelFormat -{ - AVAudioChannelLayout *layout = - [[AVAudioChannelLayout alloc] initWithLayoutTag:kAudioChannelLayoutTag_MPEG_7_1_A]; +- (id)validMultichannelFormat { + AVAudioChannelLayout *layout = [[AVAudioChannelLayout alloc] + initWithLayoutTag:kAudioChannelLayoutTag_MPEG_7_1_A]; return [[AVAudioFormat alloc] initWithCommonFormat:AVAudioPCMFormatFloat32 sampleRate:44100 interleaved:NO channelLayout:layout]; } -- (void)testStartReturnsErrorWhenRecorderIsNotIdle -{ +- (void)testStartReturnsErrorWhenRecorderIsNotIdle { _recorder->setRecorderState(AudioRecorder::RecorderState::Paused); auto result = _recorder->start(""); XCTAssertTrue(result.is_err()); - XCTAssertEqualObjects(NSStringFromStdString(result.unwrap_err()), @"Recorder is already recording"); + XCTAssertEqualObjects(NSStringFromStdString(result.unwrap_err()), + @"Recorder is already recording"); } -- (void)testStartReturnsErrorWhenRecordingPermissionIsDenied -{ +- (void)testStartReturnsErrorWhenRecordingPermissionIsDenied { self.sessionManager.recordingPermissions = @"Denied"; auto result = _recorder->start(""); XCTAssertTrue(result.is_err()); - XCTAssertEqualObjects( - NSStringFromStdString(result.unwrap_err()), - @"Microphone permissions are not granted"); + XCTAssertEqualObjects(NSStringFromStdString(result.unwrap_err()), + @"Microphone permissions are not granted"); } -- (void)testStartReturnsErrorWhenSessionActivationFails -{ +- (void)testStartReturnsErrorWhenSessionActivationFails { self.nativeRecorder.startResult = NO; self.nativeRecorder.startError = - [NSError errorWithDomain:@"RecorderTests" code:7 userInfo:@{NSLocalizedDescriptionKey : @"boom"}]; + [NSError errorWithDomain:@"RecorderTests" + code:7 + userInfo:@{NSLocalizedDescriptionKey : @"boom"}]; auto result = _recorder->start(""); @@ -377,12 +348,12 @@ - (void)testStartReturnsErrorWhenSessionActivationFails XCTAssertTrue([message containsString:@"RecorderTests"]); } -- (void)testStartReturnsErrorWhenNativeRecorderThrows -{ +- (void)testStartReturnsErrorWhenNativeRecorderThrows { self.nativeRecorder.throwsOnStart = YES; - self.nativeRecorder.startException = [NSException exceptionWithName:@"WrongCategory" - reason:@"attempt-wrong-category-record" - userInfo:nil]; + self.nativeRecorder.startException = + [NSException exceptionWithName:@"WrongCategory" + reason:@"attempt-wrong-category-record" + userInfo:nil]; auto result = _recorder->start(""); @@ -397,8 +368,7 @@ - (void)testStartReturnsErrorWhenNativeRecorderThrows XCTAssertTrue([message containsString:@"session={"]); } -- (void)testStartReturnsErrorWhenEngineInputFormatIsUnavailable -{ +- (void)testStartReturnsErrorWhenEngineInputFormatIsUnavailable { self.nativeRecorder.mockResolvedInputFormat = [self invalidFormat]; self.sessionManager.diagnosticSampleRate = 0; self.sessionManager.diagnosticInputChannels = 0; @@ -412,14 +382,14 @@ - (void)testStartReturnsErrorWhenEngineInputFormatIsUnavailable NSString *message = NSStringFromStdString(result.unwrap_err()); XCTAssertTrue([message containsString:@"Audio input format is unavailable"]); - XCTAssertTrue([message containsString:@"engineFormat={sampleRate=0.000000, channelCount=0"]); + XCTAssertTrue([message + containsString:@"engineFormat={sampleRate=0.000000, channelCount=0"]); XCTAssertTrue([message containsString:@"sampleRate=0.000000"]); XCTAssertTrue([message containsString:@"inputChannels=0"]); XCTAssertTrue([message containsString:@"routeReady=false"]); } -- (void)testStartSucceedsWhenResolvedInputFormatIsAvailable -{ +- (void)testStartSucceedsWhenResolvedInputFormatIsAvailable { self.audioEngine.state = AudioEngineStateRunning; self.nativeRecorder.mockResolvedInputFormat = [self validMultichannelFormat]; @@ -434,8 +404,7 @@ - (void)testStartSucceedsWhenResolvedInputFormatIsAvailable XCTAssertEqual(_recorder->currentFilePath(), ""); } -- (void)testStartPreparesMonoCallbackAgainstResolvedMultichannelInputFormat -{ +- (void)testStartPreparesMonoCallbackAgainstResolvedMultichannelInputFormat { self.audioEngine.state = AudioEngineStateRunning; self.nativeRecorder.mockResolvedInputFormat = [self validMultichannelFormat]; @@ -449,8 +418,7 @@ - (void)testStartPreparesMonoCallbackAgainstResolvedMultichannelInputFormat XCTAssertTrue(self.nativeRecorder.lastInputArmed); } -- (void)testEnableFileOutputWhileIdleTracksIntentWithoutLiveWriter -{ +- (void)testEnableFileOutputWhileIdleTracksIntentWithoutLiveWriter { auto enableResult = _recorder->enableFileOutput([self validFileProperties]); XCTAssertTrue(enableResult.is_ok()); @@ -462,8 +430,7 @@ - (void)testEnableFileOutputWhileIdleTracksIntentWithoutLiveWriter _recorder->clearOnErrorCallback(); } -- (void)testSetOnAudioReadyWhileIdleTracksIntentWithoutLiveCallback -{ +- (void)testSetOnAudioReadyWhileIdleTracksIntentWithoutLiveCallback { auto callbackResult = _recorder->setOnAudioReadyCallback(48000, 256, 1, 99); XCTAssertTrue(callbackResult.is_ok()); @@ -472,8 +439,7 @@ - (void)testSetOnAudioReadyWhileIdleTracksIntentWithoutLiveCallback XCTAssertFalse(_recorder->usesCallback()); } -- (void)testConnectWhileIdleTracksIntentWithoutLiveConnection -{ +- (void)testConnectWhileIdleTracksIntentWithoutLiveConnection { auto adapter = std::make_shared(0, nullptr); _recorder->connect(adapter); @@ -483,8 +449,7 @@ - (void)testConnectWhileIdleTracksIntentWithoutLiveConnection XCTAssertFalse(_recorder->isConnected()); } -- (void)testStartDoesNotAttemptToManageSessionWhenOwnershipIsExternal -{ +- (void)testStartDoesNotAttemptToManageSessionWhenOwnershipIsExternal { self.sessionManager.shouldManageSession = NO; auto result = _recorder->start(""); @@ -493,8 +458,7 @@ - (void)testStartDoesNotAttemptToManageSessionWhenOwnershipIsExternal XCTAssertEqual(self.nativeRecorder.startCallCount, 1); } -- (void)testPauseAndResumeRespectCurrentState -{ +- (void)testPauseAndResumeRespectCurrentState { _recorder->pause(); _recorder->resume(); @@ -514,18 +478,15 @@ - (void)testPauseAndResumeRespectCurrentState XCTAssertFalse(_recorder->isPaused()); } -- (void)testStopReturnsErrorWhileIdle -{ +- (void)testStopReturnsErrorWhileIdle { auto result = _recorder->stop(); XCTAssertTrue(result.is_err()); - XCTAssertEqualObjects( - NSStringFromStdString(result.unwrap_err()), - @"Recorder is not in recording state."); + XCTAssertEqualObjects(NSStringFromStdString(result.unwrap_err()), + @"Recorder is not in recording state."); } -- (void)testStopSucceedsAfterStartAndResetsState -{ +- (void)testStopSucceedsAfterStartAndResetsState { self.audioEngine.state = AudioEngineStateRunning; auto startResult = _recorder->start(""); XCTAssertTrue(startResult.is_ok()); @@ -541,12 +502,12 @@ - (void)testStopSucceedsAfterStartAndResetsState XCTAssertEqual(std::get<2>(stopResult.unwrap()), 0); } -- (void)testStopClearsConfiguredStateButPreservesConfiguredIntent -{ +- (void)testStopClearsConfiguredStateButPreservesConfiguredIntent { self.audioEngine.state = AudioEngineStateRunning; auto adapterFixture = makeRecorderAdapterFixture(); - XCTAssertTrue(_recorder->enableFileOutput([self validFileProperties]).is_ok()); + XCTAssertTrue( + _recorder->enableFileOutput([self validFileProperties]).is_ok()); XCTAssertTrue(_recorder->setOnAudioReadyCallback(48000, 256, 1, 99).is_ok()); _recorder->connect(adapterFixture.handle); @@ -570,8 +531,7 @@ - (void)testStopClearsConfiguredStateButPreservesConfiguredIntent XCTAssertFalse(_recorder->isConnected()); } -- (void)testRestartAfterStopReusesConfiguredCallback -{ +- (void)testRestartAfterStopReusesConfiguredCallback { self.audioEngine.state = AudioEngineStateRunning; self.nativeRecorder.mockResolvedInputFormat = [self validMultichannelFormat]; @@ -586,14 +546,14 @@ - (void)testRestartAfterStopReusesConfiguredCallback XCTAssertTrue(_recorder->callbackOutputConfigured()); } -- (void)testFileOutputSmokeTest -{ +- (void)testFileOutputSmokeTest { self.audioEngine.state = AudioEngineStateRunning; auto enableResult = _recorder->enableFileOutput([self validFileProperties]); XCTAssertTrue(enableResult.is_ok()); NSString *uuid = [[NSUUID UUID] UUIDString]; - std::string fileName = [[NSString stringWithFormat:@"ios-recorder-smoke-%@", uuid] UTF8String]; + std::string fileName = + [[NSString stringWithFormat:@"ios-recorder-smoke-%@", uuid] UTF8String]; auto startResult = _recorder->start(fileName); XCTAssertTrue(startResult.is_ok()); @@ -606,9 +566,8 @@ - (void)testFileOutputSmokeTest XCTAssertTrue(stopResult.is_ok()); const auto &outputPaths = std::get<0>(stopResult.unwrap()); XCTAssertEqual(outputPaths.size(), 1U); - XCTAssertEqualObjects( - NSStringFromStdString(outputPaths.front()), - [@"file://" stringByAppendingString:path]); + XCTAssertEqualObjects(NSStringFromStdString(outputPaths.front()), + [@"file://" stringByAppendingString:path]); XCTAssertGreaterThanOrEqual(std::get<1>(stopResult.unwrap()), 0.0); XCTAssertGreaterThanOrEqual(std::get<2>(stopResult.unwrap()), 0.0); XCTAssertEqual(_recorder->currentFilePath(), ""); @@ -616,21 +575,18 @@ - (void)testFileOutputSmokeTest [[NSFileManager defaultManager] removeItemAtPath:path error:nil]; } -- (void)testStartReturnsCallbackPreparationFailureForInvalidCallbackFormat -{ +- (void)testStartReturnsCallbackPreparationFailureForInvalidCallbackFormat { auto callbackResult = _recorder->setOnAudioReadyCallback(0, 256, 2, 99); XCTAssertTrue(callbackResult.is_ok()); auto result = _recorder->start(""); XCTAssertTrue(result.is_err()); - XCTAssertTrue( - [NSStringFromStdString(result.unwrap_err()) - containsString:@"Failed to prepare callback: Invalid callback format"]); + XCTAssertTrue([NSStringFromStdString(result.unwrap_err()) + containsString:@"Failed to prepare callback: Invalid callback format"]); } -- (void)testConnectWhileActiveInitializesAdapterAndDisconnectClearsIt -{ +- (void)testConnectWhileActiveInitializesAdapterAndDisconnectClearsIt { auto adapterFixture = makeRecorderAdapterFixture(); auto *adapter = adapterFixture.adapter(); diff --git a/apps/fabric-example/ios/FabricExampleTests/NativeAudioRecorderTests.mm b/apps/fabric-example/ios/FabricExampleTests/NativeAudioRecorderTests.mm index 98de6424f..149c07371 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,12 +114,14 @@ - (void)stopIfNecessary } - (void)attachInputNodeWithReceiverBlock:(AVAudioSinkNodeReceiverBlock)receiverBlock + voiceProcessingEnabled:(BOOL)voiceProcessingEnabled onInputConfigurationChange:(void (^)(void))onInputConfigurationChange { self.attachInputNodeCallCount += 1; self.inputNode = [[AVAudioSinkNode alloc] initWithReceiverBlock:receiverBlock]; self.lastAttachedInputNode = self.inputNode; self.lastAttachedReceiverBlock = receiverBlock; + self.lastAttachedVoiceProcessingEnabled = voiceProcessingEnabled; (void)onInputConfigurationChange; } @@ -292,12 +295,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); @@ -323,10 +326,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); @@ -335,10 +337,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); @@ -349,10 +350,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]); @@ -361,15 +361,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; @@ -385,10 +398,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]); @@ -414,10 +426,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]; @@ -434,10 +445,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/packages/audiodocs/docs/inputs/audio-recorder.mdx b/packages/audiodocs/docs/inputs/audio-recorder.mdx index c82fec84a..6d8fdfe43 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'; +``` + +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` ```tsx 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 6adcc308a..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,19 +14,50 @@ #include #include #include +#include #include #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, + AudioRecorderOptions options) : AudioRecorder(audioEventHandlerRegistry), + inputPreset_(std::move(options.androidInputPreset)), streamSampleRate_(0.0), streamChannelCount_(0), streamMaxBufferSizeInFrames_(0) {} @@ -74,6 +105,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 0ea2c06b8..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 @@ -24,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, + AudioRecorderOptions options = {}); ~AndroidAudioRecorder() override; void cleanup(); @@ -62,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 e4dc7cbce..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,11 +11,14 @@ #include #include #include +#include #include #include #include +#include + namespace audioapi { using namespace facebook; @@ -111,14 +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 { + auto options = count > 0 ? AudioRecorderOptions::CreateFromJSIValue(runtime, args[0]) + : AudioRecorderOptions{}; + auto audioRecorderHostObject = std::make_shared( - audioEventHandlerRegistry, &runtime, jsCallInvoker); + 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 51d33eaaf..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 @@ -22,11 +23,13 @@ namespace audioapi { AudioRecorderHostObject::AudioRecorderHostObject( const std::shared_ptr &audioEventHandlerRegistry, jsi::Runtime *runtime, - const std::shared_ptr &callInvoker) { + const std::shared_ptr &callInvoker, + AudioRecorderOptions options) { #ifdef ANDROID - audioRecorder_ = std::make_shared(audioEventHandlerRegistry); + audioRecorder_ = + std::make_shared(audioEventHandlerRegistry, std::move(options)); #else - audioRecorder_ = std::make_shared(audioEventHandlerRegistry); + 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 ec534ca00..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,6 +3,7 @@ #include #include #include +#include #include @@ -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, + 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 40a56cbc4..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,9 +32,13 @@ class AudioFileWriter; class IOSAudioRecorder : public AudioRecorder { public: - IOSAudioRecorder(const std::shared_ptr &audioEventHandlerRegistry); + explicit IOSAudioRecorder( + const std::shared_ptr &audioEventHandlerRegistry, + 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 abeae66d5..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 @@ -66,8 +66,10 @@ 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 IAudioEventHandlerRegistry for event handling. +/// @param options Creation-time capture chain configuration; only the iOS fields are read. IOSAudioRecorder::IOSAudioRecorder( - const std::shared_ptr &audioEventHandlerRegistry) + const std::shared_ptr &audioEventHandlerRegistry, + const AudioRecorderOptions &options) : AudioRecorder(audioEventHandlerRegistry) { AudioReceiverBlock receiverBlock = ^(const AudioBufferList *inputBuffer, int numFrames) { @@ -78,7 +80,8 @@ static void cleanupStartedRecorder( runSideEffects(inputBuffer, numFrames); }; - nativeRecorder_ = [[NativeAudioRecorder alloc] initWithReceiverBlock:receiverBlock]; + nativeRecorder_ = [[NativeAudioRecorder alloc] initWithReceiverBlock:receiverBlock + voiceProcessingEnabled:options.iosVoiceProcessing]; nativeRecorder_.onInputConfigurationChange = ^{ this->handleInputConfigurationChange(); }; } 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 3c7339188..20f1f1c32 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,9 +12,11 @@ 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; @property (nonatomic, copy) void (^onInputConfigurationChange)(void); -- (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 d2c3f2536..89479aa8d 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; @@ -115,6 +117,7 @@ - (BOOL)start:(NSError **)error [audioEngine stopIfNecessary]; [audioEngine attachInputNodeWithReceiverBlock:self.receiverSinkBlock + voiceProcessingEnabled:self.voiceProcessingEnabled onInputConfigurationChange:self.onInputConfigurationChange]; if (![audioEngine startIfNecessary]) { 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 71279285c..d4039332c 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 @@ -34,6 +34,7 @@ typedef NS_ENUM(NSInteger, AudioEngineState) { - (void)detachSourceNodeWithId:(NSString *)sourceNodeId; - (void)attachInputNodeWithReceiverBlock:(AVAudioSinkNodeReceiverBlock)receiverBlock + voiceProcessingEnabled:(BOOL)voiceProcessingEnabled onInputConfigurationChange:(void (^)(void))onInputConfigurationChange; - (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 6aa349dba..e3c73fca0 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; @property (nonatomic, copy) void (^onInputConfigurationChange)(void); @end @@ -27,6 +28,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) @@ -39,6 +43,7 @@ - (BOOL)hasTrackedGraph; - (AVAudioFormat *)currentInputConnectionFormat; - (void)materializeSourceNodeWithId:(NSString *)sourceNodeId; - (BOOL)materializeInputNodeIfNeeded; +- (void)applyVoiceProcessing; - (void)materializeTrackedNodesIfNeeded; - (AVAudioFormat *)liveInputFormat; @@ -88,6 +93,7 @@ - (void)destroyAudioEnginePreservingSessionDeactivationState:(BOOL)preserveSessi self.sourceFormats = [[NSMutableDictionary alloc] init]; self.inputNode = nil; self.graphNeedsRebuild = hadGraph; + _voiceProcessingApplied = NO; if (!preserveSessionDeactivationState) { self.sessionDeactivationInvalidatedGraph = false; @@ -209,6 +215,7 @@ - (BOOL)materializeInputNodeIfNeeded return YES; } + [self applyVoiceProcessing]; NSError *sessionError = nil; if (![self.sessionManager ensureActive:true error:&sessionError]) { NSLog( @@ -230,8 +237,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) { @@ -284,6 +337,7 @@ - (void)detachSourceNodeWithId:(NSString *)sourceNodeId } - (void)attachInputNodeWithReceiverBlock:(AVAudioSinkNodeReceiverBlock)receiverBlock + voiceProcessingEnabled:(BOOL)voiceProcessingEnabled onInputConfigurationChange:(void (^)(void))onInputConfigurationChange { std::scoped_lock lock(_engineLock); @@ -295,6 +349,7 @@ - (void)attachInputNodeWithReceiverBlock:(AVAudioSinkNodeReceiverBlock)receiverB AudioEngineInputRegistration *registration = [[AudioEngineInputRegistration alloc] init]; registration.receiverBlock = receiverBlock; + registration.voiceProcessingEnabled = voiceProcessingEnabled; registration.onInputConfigurationChange = onInputConfigurationChange; self.inputRegistration = registration; 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..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,7 +18,7 @@ declare global { sampleRate: number ) => IOfflineAudioContext; - var createAudioRecorder: () => IAudioRecorder; + var createAudioRecorder: (options: AudioRecorderOptions) => IAudioRecorder; var createAudioBuffer: ( numberOfChannels: number, diff --git a/packages/react-native-audio-api/src/core/AudioRecorder.ts b/packages/react-native-audio-api/src/core/AudioRecorder.ts index 7c9256cfb..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 { @@ -51,8 +52,8 @@ export default class AudioRecorder { globalThis.AudioEventEmitter ); - constructor() { - this.recorder = globalThis.createAudioRecorder(); + constructor(options?: AudioRecorderOptions) { + this.recorder = globalThis.createAudioRecorder(options ?? {}); } /** @@ -72,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 7b85feed3..5d596e392 100644 --- a/packages/react-native-audio-api/src/mock/index.ts +++ b/packages/react-native-audio-api/src/mock/index.ts @@ -2,6 +2,7 @@ import { AudioContextOptions, AudioRecorderCallbackOptions, AudioRecorderFileOptions, + AudioRecorderOptions, AudioRecorderStartOptions, BiquadFilterType, ChannelCountMode, @@ -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 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;