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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 136 additions & 2 deletions apps/fabric-example/ios/FabricExampleTests/AudioEngineTests.mm
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,14 @@ - (void)createAudioEngineIfNeeded;
@interface FakeAudioInputNode : NSObject

@property(nonatomic, strong) AVAudioFormat *outputFormat;
@property(nonatomic, assign) BOOL voiceProcessingEnabled;
@property(nonatomic, assign) NSInteger setVoiceProcessingCallCount;
@property(nonatomic, strong) NSError *voiceProcessingError;

- (instancetype)initWithOutputFormat:(AVAudioFormat *)outputFormat;
- (AVAudioFormat *)outputFormatForBus:(AVAudioNodeBus)bus;
- (BOOL)isVoiceProcessingEnabled;
- (BOOL)setVoiceProcessingEnabled:(BOOL)enabled error:(NSError **)error;

@end

Expand All @@ -32,6 +37,22 @@ - (AVAudioFormat *)outputFormatForBus:(AVAudioNodeBus)bus {
return self.outputFormat;
}

- (BOOL)isVoiceProcessingEnabled {
return self.voiceProcessingEnabled;
}

- (BOOL)setVoiceProcessingEnabled:(BOOL)enabled error:(NSError **)error {
self.setVoiceProcessingCallCount += 1;
if (error != nil) {
*error = self.voiceProcessingError;
}
if (self.voiceProcessingError != nil) {
return NO;
}
self.voiceProcessingEnabled = enabled;
return YES;
}

@end

@interface FakeAudioEngine : AVAudioEngine
Expand All @@ -41,6 +62,7 @@ @interface FakeAudioEngine : AVAudioEngine
@property(nonatomic, assign) NSInteger attachNodeCallCount;
@property(nonatomic, assign) NSInteger detachNodeCallCount;
@property(nonatomic, assign) NSInteger connectCallCount;
@property(nonatomic, assign) BOOL voiceProcessingEnabledWhenInputConnected;
@property(nonatomic, assign) NSInteger prepareCallCount;
@property(nonatomic, assign) NSInteger startCallCount;
@property(nonatomic, assign) NSInteger stopCallCount;
Expand Down Expand Up @@ -103,6 +125,10 @@ - (void)connect:(AVAudioNode *)node1
to:(AVAudioNode *)node2
format:(AVAudioFormat *)format {
self.connectCallCount += 1;
if (node1 == (AVAudioNode *)self.fakeInputNode) {
self.voiceProcessingEnabledWhenInputConnected =
self.fakeInputNode.voiceProcessingEnabled;
}
[self.connections addObject:@{
@"from" : node1,
@"to" : node2,
Expand Down Expand Up @@ -264,8 +290,6 @@ - (void)setUp {
AVAudioFormat *inputFormat = [self testInputFormat];
self.audioEngine.defaultCreatedEngineInputFormat = inputFormat;
[self.audioEngine createAudioEngineIfNeeded];
self.audioEngine.currentFakeAudioEngine.fakeInputNode.outputFormat =
inputFormat;
self.sessionManager = [[FakeAudioSessionManager alloc] init];
self.audioEngine.sessionManager = self.sessionManager;
}
Expand Down Expand Up @@ -480,6 +504,60 @@ - (void)testAttachInputNodeDefersConnectionUntilLiveInputFormatIsAvailable {
liveInputFormat);
}

- (void)testVoiceProcessingActivatesSessionBeforeMaterializingInput {
FakeAudioEngine *fakeEngine = self.audioEngine.currentFakeAudioEngine;
[self.audioEngine
attachInputNodeWithReceiverBlock:[self testInputReceiverBlock]
voiceProcessingEnabled:YES
onInputConfigurationChange:nil];

XCTAssertNil(self.audioEngine.inputNode);
XCTAssertEqual(fakeEngine.connectCallCount, 0);

XCTAssertTrue([self.audioEngine startIfNecessary]);
XCTAssertGreaterThan(self.sessionManager.ensureActiveCallCount, 0);
XCTAssertEqual(fakeEngine.fakeInputNode.setVoiceProcessingCallCount, 1);
XCTAssertTrue(fakeEngine.fakeInputNode.voiceProcessingEnabled);
XCTAssertNotNil(self.audioEngine.inputNode);
XCTAssertEqual(fakeEngine.connectCallCount, 1);
}

- (void)testVoiceProcessingFailurePreventsInputMaterialization {
FakeAudioEngine *fakeEngine = self.audioEngine.currentFakeAudioEngine;
fakeEngine.fakeInputNode.voiceProcessingError =
[NSError errorWithDomain:@"AudioEngineTests" code:1 userInfo:nil];
[self.audioEngine
attachInputNodeWithReceiverBlock:[self testInputReceiverBlock]
voiceProcessingEnabled:YES
onInputConfigurationChange:nil];

XCTAssertFalse([self.audioEngine startIfNecessary]);
XCTAssertNil(self.audioEngine.inputNode);
XCTAssertEqual(fakeEngine.connectCallCount, 0);
}

- (void)testVoiceProcessingIsReappliedBeforeInputConnectionAfterRebuild {
[self.audioEngine
attachInputNodeWithReceiverBlock:[self testInputReceiverBlock]
voiceProcessingEnabled:YES
onInputConfigurationChange:nil];

XCTAssertTrue([self.audioEngine startIfNecessary]);
FakeAudioEngine *oldEngine = self.audioEngine.currentFakeAudioEngine;
oldEngine.fakeRunning = YES;
self.audioEngine.state = AudioEngineStateRunning;

[self.audioEngine restartAudioEngine];

FakeAudioEngine *replacementEngine = self.audioEngine.currentFakeAudioEngine;
XCTAssertNotEqual(replacementEngine, oldEngine);
XCTAssertEqual(replacementEngine.fakeInputNode.setVoiceProcessingCallCount,
1);
XCTAssertTrue(replacementEngine.fakeInputNode.voiceProcessingEnabled);
XCTAssertEqual(replacementEngine.connectCallCount, 1);
XCTAssertTrue(replacementEngine.voiceProcessingEnabledWhenInputConnected);
}

- (void)testDetachInputNodeWithoutInputDoesNothing {
FakeAudioEngine *fakeEngine = self.audioEngine.currentFakeAudioEngine;

Expand Down Expand Up @@ -956,6 +1034,62 @@ - (void)testRestartAudioEngineStopsAndRestartsWhenStateRunning {
XCTAssertFalse(self.audioEngine.graphNeedsRebuild);
}

- (void)testOnRouteChangedKeepsEngineWhenInputFormatIsUnchanged {
__block NSInteger configurationChangeCount = 0;
[self.audioEngine
attachInputNodeWithReceiverBlock:[self testInputReceiverBlock]
voiceProcessingEnabled:YES
onInputConfigurationChange:^{
configurationChangeCount += 1;
}];
XCTAssertTrue([self.audioEngine startIfNecessary]);
FakeAudioEngine *engine = self.audioEngine.currentFakeAudioEngine;

[self.audioEngine onRouteChanged];

XCTAssertEqual(self.audioEngine.currentFakeAudioEngine, engine);
XCTAssertEqual(engine.fakeInputNode.setVoiceProcessingCallCount, 1);
XCTAssertEqual(engine.stopCallCount, 0);
XCTAssertEqual(configurationChangeCount, 1);
XCTAssertEqual(self.audioEngine.state, AudioEngineStateRunning);
}

- (void)testOnRouteChangedRebuildsWhenInputFormatChanged {
[self.audioEngine
attachInputNodeWithReceiverBlock:[self testInputReceiverBlock]
voiceProcessingEnabled:NO
onInputConfigurationChange:nil];
XCTAssertTrue([self.audioEngine startIfNecessary]);
FakeAudioEngine *oldEngine = self.audioEngine.currentFakeAudioEngine;
AVAudioFormat *handsFreeFormat = [self testInputFormatWithSampleRate:16000
channelCount:1];
oldEngine.fakeInputNode.outputFormat = handsFreeFormat;
self.audioEngine.nextCreatedEngineInputFormat = handsFreeFormat;

[self.audioEngine onRouteChanged];

FakeAudioEngine *newEngine = self.audioEngine.currentFakeAudioEngine;
XCTAssertNotEqual(newEngine, oldEngine);
XCTAssertEqual(oldEngine.stopCallCount, 1);
XCTAssertEqual(newEngine.connectCallCount, 1);
AVAudioFormat *connectionFormat = newEngine.connections.firstObject[@"format"];
XCTAssertEqualWithAccuracy(connectionFormat.sampleRate, 16000.0, 0.1);
XCTAssertEqual(newEngine.startCallCount, 1);
XCTAssertEqual(self.audioEngine.state, AudioEngineStateRunning);
}

- (void)testOnRouteChangedLeavesPlaybackOnlyGraphAlone {
[self attachSourceNodeToAudioEngine];
XCTAssertTrue([self.audioEngine startIfNecessary]);
FakeAudioEngine *engine = self.audioEngine.currentFakeAudioEngine;

[self.audioEngine onRouteChanged];

XCTAssertEqual(self.audioEngine.currentFakeAudioEngine, engine);
XCTAssertEqual(engine.stopCallCount, 0);
XCTAssertEqual(self.audioEngine.state, AudioEngineStateRunning);
}

- (void)
testConfigurationChangeCallbackCanReadLiveInputFormatWhileRestartHoldsLock {
__block BOOL callbackRan = NO;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,11 +97,23 @@ @interface SNMFakeAudioEngine : AudioEngine
@property(nonatomic, assign) NSInteger interruptionEndCallCount;
@property(nonatomic, assign) NSInteger restartAudioEngineCallCount;
@property(nonatomic, assign) BOOL lastShouldResume;
@property(nonatomic, assign) BOOL fakeInUse;
@property(nonatomic, assign) BOOL fakeOwnsNotifyingEngine;

@end

@implementation SNMFakeAudioEngine

- (bool)isInUse
{
return self.fakeInUse;
}

- (bool)ownsAudioEngine:(AVAudioEngine *)engine
{
return self.fakeOwnsNotifyingEngine;
}

- (void)onInterruptionBegin
{
self.interruptionBeginCallCount += 1;
Expand All @@ -118,13 +130,6 @@ - (void)restartAudioEngine
self.restartAudioEngineCallCount += 1;
}

/// The engine is created lazily, so a fresh fake reads as not in use and the manager would
/// ignore every notification meant for it.
- (bool)isInUse
{
return true;
}

@end

@interface SNMFakeAudioSessionManager : AudioSessionManager
Expand Down Expand Up @@ -307,8 +312,12 @@ - (void)tearDown
[super tearDown];
}

- (void)flushMainQueue
- (void)flushPendingWork
{
// Engine reactions run on the manager's own queue; the secondary-audio polling
// timer is started on the main queue.
dispatch_sync(self.manager.engineLifecycleQueue, ^{});

XCTestExpectation *expectation = [self expectationWithDescription:@"Flush main queue"];
dispatch_async(dispatch_get_main_queue(), ^{ [expectation fulfill]; });
[self waitForExpectations:@[ expectation ] timeout:1.0];
Expand Down Expand Up @@ -390,7 +399,7 @@ - (void)testActivelyReclaimSessionRegistersObserverAndStartsPolling
self.manager.notificationCenter = (NSNotificationCenter *)fakeNotificationCenter;

[self.manager activelyReclaimSession:YES];
[self flushMainQueue];
[self flushPendingWork];

XCTAssertEqual(fakeNotificationCenter.addObserverCallCount, 1);
XCTAssertEqualObjects(fakeNotificationCenter.addedNotificationNames.firstObject,
Expand All @@ -411,7 +420,7 @@ - (void)testHandleInterruptionBeganMarksInactiveAndEmitsEventWhenObserved

[self.manager handleInterruption:[self interruptionNotificationWithType:AVAudioSessionInterruptionTypeBegan
option:0]];
[self flushMainQueue];
[self flushPendingWork];

XCTAssertEqual(self.fakeSessionManager.markInactiveCallCount, 1);
XCTAssertEqual(self.fakeAudioEngine.interruptionBeginCallCount, 1);
Expand Down Expand Up @@ -441,7 +450,7 @@ - (void)testHandleInterruptionEndedResumesEngineWhenNotObserved
{
[self.manager handleInterruption:[self interruptionNotificationWithType:AVAudioSessionInterruptionTypeEnded
option:AVAudioSessionInterruptionOptionShouldResume]];
[self flushMainQueue];
[self flushPendingWork];

XCTAssertEqual(self.module.eventInvocationCount, 0);
XCTAssertEqual(self.fakeAudioEngine.interruptionEndCallCount, 1);
Expand All @@ -455,7 +464,7 @@ - (void)testHandleSecondaryAudioBeginMarksInactiveAndEmitsEventWhenObserved
[self.manager
handleSecondaryAudio:[self secondaryAudioNotificationWithType:
AVAudioSessionSilenceSecondaryAudioHintTypeBegin]];
[self flushMainQueue];
[self flushPendingWork];

XCTAssertEqual(self.fakeSessionManager.markInactiveCallCount, 1);
XCTAssertEqual(self.fakeAudioEngine.interruptionBeginCallCount, 1);
Expand All @@ -471,7 +480,7 @@ - (void)testHandleSecondaryAudioEndResumesEngineWhenNotObserved
[self.manager
handleSecondaryAudio:[self secondaryAudioNotificationWithType:
AVAudioSessionSilenceSecondaryAudioHintTypeEnd]];
[self flushMainQueue];
[self flushPendingWork];

XCTAssertEqual(self.module.eventInvocationCount, 0);
XCTAssertEqual(self.fakeAudioEngine.interruptionEndCallCount, 1);
Expand Down Expand Up @@ -516,9 +525,11 @@ - (void)testHandleRouteChangeMapsReasonsAndFallsBackToUnknown
- (void)testHandleMediaServicesResetReactivatesSessionAndRestartsEngine
{
// Only a session that was active gets re-activated after the reset.
self.fakeAudioEngine.fakeInUse = YES;
self.fakeSessionManager.isActive = true;

[self.manager handleMediaServicesReset:nil];
[self flushMainQueue];
[self flushPendingWork];

XCTAssertEqual(self.fakeSessionManager.markInactiveCallCount, 1);
XCTAssertEqual(self.fakeSessionManager.ensureActiveCallCount, 1);
Expand All @@ -528,8 +539,11 @@ - (void)testHandleMediaServicesResetReactivatesSessionAndRestartsEngine

- (void)testHandleEngineConfigurationChangeMarksInactiveAndRestartsEngine
{
self.fakeAudioEngine.fakeInUse = YES;
self.fakeAudioEngine.fakeOwnsNotifyingEngine = YES;

[self.manager handleEngineConfigurationChange:nil];
[self flushMainQueue];
[self flushPendingWork];

XCTAssertEqual(self.fakeSessionManager.markInactiveCallCount, 1);
XCTAssertEqual(self.fakeAudioEngine.restartAudioEngineCallCount, 1);
Expand All @@ -541,7 +555,7 @@ - (void)testCheckSecondaryAudioHintDoesNothingWhenStateIsUnchanged
self.manager.wasOtherAudioPlaying = NO;

[self.manager checkSecondaryAudioHint];
[self flushMainQueue];
[self flushPendingWork];

XCTAssertEqual(self.module.eventInvocationCount, 0);
XCTAssertEqual(self.fakeSessionManager.markInactiveCallCount, 0);
Expand All @@ -556,7 +570,7 @@ - (void)testCheckSecondaryAudioHintSilencedTransitionMarksInactiveAndEmitsEventW
self.manager.wasOtherAudioPlaying = NO;

[self.manager checkSecondaryAudioHint];
[self flushMainQueue];
[self flushPendingWork];

XCTAssertTrue(self.manager.wasOtherAudioPlaying);
XCTAssertEqual(self.fakeSessionManager.markInactiveCallCount, 1);
Expand Down Expand Up @@ -591,7 +605,7 @@ - (void)testCheckSecondaryAudioHintResumeTransitionResumesEngineWhenNotObserved
self.manager.wasOtherAudioPlaying = YES;

[self.manager checkSecondaryAudioHint];
[self flushMainQueue];
[self flushPendingWork];

XCTAssertFalse(self.manager.wasOtherAudioPlaying);
XCTAssertEqual(self.module.eventInvocationCount, 0);
Expand Down
5 changes: 4 additions & 1 deletion packages/audiodocs/docs/inputs/audio-recorder.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -422,14 +422,17 @@ export default MyRecorder;
const audioRecorder = new AudioRecorder();
```
<div class="text-content">
Full-duplex voice apps (playing audio while recording) need platform echo cancellation on both platforms, otherwise the speaker output leaks back into the microphone:
Full-duplex voice apps can request the platforms' communication capture paths:
</div>
```tsx
const audioRecorder = new AudioRecorder({
androidInputPreset: 'voiceCommunication',
iosVoiceProcessing: true,
});
```
<div class="text-content">
These options request platform processing; echo cancellation, noise suppression, and automatic gain control remain device- and route-dependent.
</div>
</td>
</tr>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ typedef void (^OnOutputRecoveryFailedBlock)(void);
- (AudioEngineState)getState;
- (bool)isEngineRunning;
- (bool)isInUse;
/// Whether `engine` is the live AVAudioEngine instance, as opposed to one a rebuild has replaced.
- (bool)ownsAudioEngine:(AVAudioEngine *)engine;

- (bool)startIfNecessary;
- (void)pauseIfNecessary;
Expand All @@ -59,6 +61,9 @@ typedef void (^OnOutputRecoveryFailedBlock)(void);
- (void)stopIfPossible;

- (void)restartAudioEngine;
/// Rebuilds the graph only when the live input format no longer matches the sink connection;
/// otherwise lets the recorder re-check its format without touching the engine.
- (void)onRouteChanged;

- (void)logAudioEngineState;

Expand Down
Loading
Loading