diff --git a/apps/CLAUDE.md b/apps/CLAUDE.md index 0132554e3..6e8a489dd 100644 --- a/apps/CLAUDE.md +++ b/apps/CLAUDE.md @@ -281,6 +281,10 @@ if (status !== 'Granted') return; ### Interruption handling +Enable emission with `AudioManager.observeAudioInterruptions(true)`, then listen. + +**Playback:** pause on `began` (native does not resume players). The AudioFile example resumes on `ended` when it had been playing. + ```tsx useEffect(() => { const sub = AudioManager.addSystemEventListener('interruption', (event) => { @@ -290,6 +294,8 @@ useEffect(() => { }, []); ``` +**Recording:** native always resumes the engine. Do not `Recorder.pause()` on `began`. iOS has already stopped I/O; the engine is `Interrupted`, not `Paused`. Only `Interrupted` is retried on `ended` / foreground — `Recorder.pause()` would move the engine to `Paused` and disable that retry. JS only freezes UI that still looks live (the Record demo's scrolling waveform). Unfreeze only on `ended` (failed resume does not emit `ended`). + ## Shared UI Components All in `apps/common-app/src/components/`: diff --git a/apps/common-app/src/demos/Record/Record.tsx b/apps/common-app/src/demos/Record/Record.tsx index 6bcb7df73..803fd00f5 100644 --- a/apps/common-app/src/demos/Record/Record.tsx +++ b/apps/common-app/src/demos/Record/Record.tsx @@ -23,11 +23,13 @@ import { RecordingState } from './types'; const Record: FC = () => { const [state, setState] = useState(RecordingState.Idle); const [hasPermissions, setHasPermissions] = useState(false); + const [isInterrupted, setIsInterrupted] = useState(false); const [recordedBuffer, setRecordedBuffer] = useState( null ); const currentPositionSV = useSharedValue(0); const playbackSourceRef = useRef(null); + const stateRef = useRef(state); const stopPlayback = useCallback(() => { const source = playbackSourceRef.current; @@ -78,7 +80,7 @@ const Record: FC = () => { AudioManager.setAudioSessionOptions({ iosCategory: 'playAndRecord', iosMode: 'default', - iosOptions: ['defaultToSpeaker', 'allowBluetoothA2DP'], + iosOptions: ['defaultToSpeaker', 'allowBluetoothA2DP', 'mixWithOthers'], }); try { @@ -97,6 +99,7 @@ const Record: FC = () => { setupNotification(false); if (result.status === 'success') { + setIsInterrupted(false); setState(RecordingState.Recording); return; } @@ -109,6 +112,7 @@ const Record: FC = () => { const onPauseRecording = useCallback(() => { Recorder.pause(); updateNotification(true); + setIsInterrupted(false); setState(RecordingState.Paused); }, []); @@ -121,6 +125,7 @@ const Record: FC = () => { const onStopRecording = useCallback(async () => { const info = await Recorder.stop(); RecordingNotificationManager.hide(); + setIsInterrupted(false); setState(RecordingState.Loading); if (info.status !== 'success') { @@ -227,6 +232,10 @@ const Record: FC = () => { ] ); + useEffect(() => { + stateRef.current = state; + }, [state]); + useEffect(() => { (async () => { const permissionStatus = await AudioManager.checkRecordingPermissions(); @@ -237,6 +246,31 @@ const Record: FC = () => { })(); }, []); + useEffect(() => { + AudioManager.observeAudioInterruptions(true); + + const interruptionSubscription = AudioManager.addSystemEventListener( + 'interruption', + (event) => { + if (event.type === 'began') { + if (stateRef.current === RecordingState.Recording) { + setIsInterrupted(true); + } + return; + } + + if (event.type === 'ended') { + setIsInterrupted(false); + } + } + ); + + return () => { + interruptionSubscription.remove(); + AudioManager.observeAudioInterruptions(false); + }; + }, []); + useEffect(() => { const pauseListener = RecordingNotificationManager.addEventListener( 'recordingNotificationPause', @@ -262,7 +296,10 @@ const Record: FC = () => { }, [onPauseRecording, onResumeRecording]); useEffect(() => { - Recorder.enableFileOutput({ rotateIntervalBytes: 1_000_000, format: FileFormat.M4A }); + Recorder.enableFileOutput({ + rotateIntervalBytes: 1_000_000, + format: FileFormat.M4A, + }); return () => { stopPlayback(); @@ -289,7 +326,7 @@ const Record: FC = () => { <> - + )} diff --git a/apps/common-app/src/demos/Record/RecordingVisualization.tsx b/apps/common-app/src/demos/Record/RecordingVisualization.tsx index 747026742..f896b8e3a 100644 --- a/apps/common-app/src/demos/Record/RecordingVisualization.tsx +++ b/apps/common-app/src/demos/Record/RecordingVisualization.tsx @@ -8,10 +8,7 @@ import { } from '@shopify/react-native-skia'; import React, { useEffect, useMemo, useRef } from 'react'; import { Dimensions, StyleSheet, View } from 'react-native'; -import { - WorkletAudioContext, - WorkletNode, -} from 'react-native-audio-worklets'; +import { WorkletAudioContext, WorkletNode } from 'react-native-audio-worklets'; import { cancelAnimation, Easing, @@ -46,6 +43,7 @@ function getInitialHistory() { interface RecordingVisualizationProps { state: RecordingState; + isInterrupted: boolean; } interface DrawDefaultWaveformParams { @@ -68,8 +66,14 @@ interface DrawHistoryWaveformParams { function drawDefaultWaveform(params: DrawDefaultWaveformParams) { 'worklet'; - const { normalized, canvasHeight, barHeights, translateX, lastIndex, numBars } = - params; + const { + normalized, + canvasHeight, + barHeights, + translateX, + lastIndex, + numBars, + } = params; if (canvasHeight <= 0 || numBars <= 0) { return barHeights; @@ -143,8 +147,68 @@ function drawHistoryWaveform(params: DrawHistoryWaveformParams) { return history; } +function loopingWaveformScroll(target: number, durationMs: number) { + 'worklet'; + return withRepeat( + withTiming(target, { + duration: durationMs, + easing: Easing.linear, + }), + -1, + false + ); +} + +function resumeWaveformScroll( + translateX: SharedValue, + canvasWidth: number +) { + if (canvasWidth <= 0) { + return; + } + + const animationTarget = -canvasWidth; + const cycleDurationMs = 1000 * (canvasWidth / constants.pixelsPerSecond); + + // Restarting withRepeat from a mid-cycle offset would jump back to that + // offset at the end of each loop. Finish the current cycle first. + if (translateX.value >= 0) { + translateX.value = loopingWaveformScroll(animationTarget, cycleDurationMs); + return; + } + + const remainingDistance = translateX.value - animationTarget; + const remainingDurationMs = + cycleDurationMs * (remainingDistance / canvasWidth); + + if (remainingDurationMs <= 0) { + translateX.value = 0; + translateX.value = loopingWaveformScroll(animationTarget, cycleDurationMs); + return; + } + + translateX.value = withTiming( + animationTarget, + { + duration: remainingDurationMs, + easing: Easing.linear, + }, + (finished) => { + if (!finished) { + return; + } + translateX.value = 0; + translateX.value = loopingWaveformScroll( + animationTarget, + cycleDurationMs + ); + } + ); +} + const RecordingVisualization: React.FC = ({ state, + isInterrupted, }) => { const canvasRef = useCanvasRef(); const lifetimeCanvasRef = useCanvasRef(); @@ -166,6 +230,7 @@ const RecordingVisualization: React.FC = ({ const canvasHeightSV = useSharedValue(0); const lifetimeCanvasHeightSV = useSharedValue(0); const numBarsSV = useSharedValue(0); + const isInterruptedSV = useSharedValue(false); const stateRef = useRef(state); const workletContextRef = useRef(null); @@ -273,7 +338,17 @@ const RecordingVisualization: React.FC = ({ numBarsSV.value = numBars; canvasHeightSV.value = size.height; lifetimeCanvasHeightSV.value = lifetimeSize.height; - }, [numBars, size.height, lifetimeSize.height, numBarsSV, canvasHeightSV, lifetimeCanvasHeightSV]); + isInterruptedSV.value = isInterrupted; + }, [ + numBars, + size.height, + lifetimeSize.height, + isInterrupted, + numBarsSV, + canvasHeightSV, + lifetimeCanvasHeightSV, + isInterruptedSV, + ]); useEffect(() => { if (numBars <= 0) { @@ -302,12 +377,11 @@ const RecordingVisualization: React.FC = ({ const lifetimeCanvasHeight = lifetimeCanvasHeightSV.value; const activeNumBars = numBarsSV.value; - if (canvasHeight <= 0 || activeNumBars <= 0) { + if (isInterruptedSV.value || canvasHeight <= 0 || activeNumBars <= 0) { return; } - durationMS.value += - (audioData.length / constants.sampleRate) * 1000; + durationMS.value += (audioData.length / constants.sampleRate) * 1000; let maxValue = 0; for (let i = 0; i < audioData.length; i++) { @@ -317,8 +391,7 @@ const RecordingVisualization: React.FC = ({ } } - const db = - maxValue > 0 ? 20 * Math.log10(maxValue) : constants.minDb; + const db = maxValue > 0 ? 20 * Math.log10(maxValue) : constants.minDb; let normalized = (db - constants.minDb) / (constants.maxDb - constants.minDb); normalized = Math.max(0, Math.min(1, normalized)); @@ -418,18 +491,10 @@ const RecordingVisualization: React.FC = ({ }, [state]); useEffect(() => { - if (state === RecordingState.Recording) { - const animationTarget = -size.width; - const animationDuration = 1000 * (size.width / constants.pixelsPerSecond); - - translateX.value = withRepeat( - withTiming(animationTarget, { - duration: animationDuration, - easing: Easing.linear, - }), - -1, // Infinite loop - false // No reverse - ); + if (state === RecordingState.Recording && isInterrupted) { + cancelAnimation(translateX); + } else if (state === RecordingState.Recording) { + resumeWaveformScroll(translateX, size.width); } else if (state === RecordingState.Paused) { cancelAnimation(translateX); @@ -459,6 +524,7 @@ const RecordingVisualization: React.FC = ({ } }, [ state, + isInterrupted, size, translateX, barHeights, @@ -495,6 +561,7 @@ const RecordingVisualization: React.FC = ({ diff --git a/apps/common-app/src/demos/Record/TimeStream.tsx b/apps/common-app/src/demos/Record/TimeStream.tsx index 0fd4ff022..b78b411d4 100644 --- a/apps/common-app/src/demos/Record/TimeStream.tsx +++ b/apps/common-app/src/demos/Record/TimeStream.tsx @@ -20,6 +20,7 @@ const formatTime = (seconds: number) => { interface TimeStreamProps { isRecording: boolean; + isFrozen?: boolean; durationMS: SharedValue; } @@ -33,41 +34,55 @@ function generateInitialTimestamps() { return timestamps; } -const TimeStream: React.FC = ({ isRecording, durationMS }) => { +const TimeStream: React.FC = ({ + isRecording, + isFrozen = false, + durationMS, +}) => { const [timestamps, setTimestamps] = useState( generateInitialTimestamps() ); const intervalRef = useRef(null); + const wasRecordingRef = useRef(false); + const isAnimating = isRecording && !isFrozen; useEffect(() => { - if (isRecording) { - setTimestamps(generateInitialTimestamps()); - - intervalRef.current = setInterval(() => { - const elapsedSeconds = durationMS.value / 1000; - const futureSecond = Math.ceil(elapsedSeconds + 1); + const startedRecording = isRecording && !wasRecordingRef.current; + wasRecordingRef.current = isRecording; - setTimestamps((prev) => { - if (prev.includes(futureSecond)) { - return prev; - } - - const cleanList = prev.filter((t) => t > elapsedSeconds - 5); - return [...cleanList, futureSecond]; - }); - }, 500); - } else { + if (!isAnimating) { if (intervalRef.current) { clearInterval(intervalRef.current); + intervalRef.current = null; } + return; + } + + if (startedRecording) { + setTimestamps(generateInitialTimestamps()); } + intervalRef.current = setInterval(() => { + const elapsedSeconds = durationMS.value / 1000; + const futureSecond = Math.ceil(elapsedSeconds + 1); + + setTimestamps((prev) => { + if (prev.includes(futureSecond)) { + return prev; + } + + const cleanList = prev.filter((t) => t > elapsedSeconds - 5); + return [...cleanList, futureSecond]; + }); + }, 500); + return () => { if (intervalRef.current) { clearInterval(intervalRef.current); + intervalRef.current = null; } }; - }, [isRecording, durationMS]); + }, [isAnimating, isRecording, durationMS]); return ( @@ -76,7 +91,8 @@ const TimeStream: React.FC = ({ isRecording, durationMS }) => { key={seconds} spawnSeconds={seconds} durationMS={durationMS} - isRecording={isRecording} + isAnimating={isAnimating} + isFrozen={isRecording && isFrozen} /> ))} @@ -90,7 +106,8 @@ const textWidth = 60; interface TimestampProps { spawnSeconds: number; durationMS: SharedValue; - isRecording: boolean; + isAnimating: boolean; + isFrozen: boolean; } const subSeconds = new Array(7).fill(0).map((_, i) => `sub-${i}`); @@ -98,11 +115,17 @@ const subSeconds = new Array(7).fill(0).map((_, i) => `sub-${i}`); const Timestamp: React.FC = ({ spawnSeconds, durationMS, - isRecording, + isAnimating, + isFrozen, }) => { const translateX = useSharedValue(2 * windowWidth); useEffect(() => { + if (isFrozen) { + cancelAnimation(translateX); + return; + } + const originalPositionOfFirstTimestamp = windowWidth - textWidth / 2; const currentPositionOfFirstTimestamp = originalPositionOfFirstTimestamp - @@ -119,7 +142,7 @@ const Timestamp: React.FC = ({ translateX.value = startX; - if (!isRecording) { + if (!isAnimating) { cancelAnimation(translateX); return; } @@ -128,7 +151,7 @@ const Timestamp: React.FC = ({ duration: duration, easing: Easing.linear, }); - }, [spawnSeconds, durationMS, translateX, isRecording]); + }, [spawnSeconds, durationMS, translateX, isAnimating, isFrozen]); const containerStyle = useAnimatedStyle(() => ({ position: 'absolute', diff --git a/apps/fabric-example/ios/FabricExampleTests/AudioAPIModuleTests.mm b/apps/fabric-example/ios/FabricExampleTests/AudioAPIModuleTests.mm index 25f93cd2c..16e9a8264 100644 --- a/apps/fabric-example/ios/FabricExampleTests/AudioAPIModuleTests.mm +++ b/apps/fabric-example/ios/FabricExampleTests/AudioAPIModuleTests.mm @@ -44,6 +44,9 @@ - (void)onSessionDeactivated { self.onSessionDeactivatedCallCount += 1; AppendAudioModuleEvent(self.eventLog, @"onSessionDeactivated"); + if (self.state != AudioEngineStateIdle) { + self.state = AudioEngineStatePaused; + } } @end @@ -63,13 +66,21 @@ - (bool)setActive:(bool)active error:(NSError **)error { self.setActiveCallCount += 1; self.lastSetActiveValue = active; - self.isActive = active; AppendAudioModuleEvent(self.eventLog, @"setActive"); if (error != nil) { *error = nil; } + if (!self.shouldManageSession) { + return true; + } + + if (!active && !self.isActive) { + return true; + } + + self.isActive = active; return true; } @@ -120,14 +131,16 @@ - (void)tearDown [super tearDown]; } -- (void)testSetAudioSessionActivityFalseWaitsForSessionDeactivationBeforeResolve +- (NSArray *)invokeSetAudioSessionActivity:(BOOL)enabled + rejectionCodeOut:(NSString **)rejectionCodeOut { - XCTestExpectation *resolveExpectation = [self expectationWithDescription:@"setAudioSessionActivity"]; + XCTestExpectation *resolveExpectation = + [self expectationWithDescription:@"setAudioSessionActivity"]; __block NSArray *eventsAtResolve = nil; __block NSString *rejectionCode = nil; NSMutableArray *eventLog = self.eventLog; - [self.module setAudioSessionActivity:NO + [self.module setAudioSessionActivity:enabled resolve:^(id result) { AppendAudioModuleEvent(eventLog, @"resolve"); eventsAtResolve = CopyAudioModuleEvents(eventLog); @@ -141,15 +154,70 @@ - (void)testSetAudioSessionActivityFalseWaitsForSessionDeactivationBeforeResolve [self waitForExpectations:@[ resolveExpectation ] timeout:1.0]; + if (rejectionCodeOut != nil) { + *rejectionCodeOut = rejectionCode; + } + + return eventsAtResolve; +} + +- (void)testSetAudioSessionActivityFalseWaitsForSessionDeactivationBeforeResolve +{ + self.fakeSessionManager.isActive = YES; + self.fakeAudioEngine.state = AudioEngineStateInterrupted; + + NSString *rejectionCode = nil; + NSArray *eventsAtResolve = + [self invokeSetAudioSessionActivity:NO rejectionCodeOut:&rejectionCode]; + XCTAssertNil(rejectionCode); XCTAssertEqual(self.fakeSessionManager.setActiveCallCount, 1); XCTAssertFalse(self.fakeSessionManager.lastSetActiveValue); XCTAssertEqual(self.fakeSessionManager.markInactiveCallCount, 1); XCTAssertEqual(self.fakeAudioEngine.onSessionDeactivatedCallCount, 1); + XCTAssertEqual(self.fakeAudioEngine.state, AudioEngineStatePaused); XCTAssertFalse(self.fakeSessionManager.isActive); XCTAssertEqualObjects( eventsAtResolve, (@[ @"setActive", @"markInactive", @"onSessionDeactivated", @"resolve" ])); } +- (void)testSetAudioSessionActivityFalseDoesNotPauseWhenAlreadyInactive +{ + self.fakeSessionManager.isActive = NO; + self.fakeAudioEngine.state = AudioEngineStateInterrupted; + + NSString *rejectionCode = nil; + NSArray *eventsAtResolve = + [self invokeSetAudioSessionActivity:NO rejectionCodeOut:&rejectionCode]; + + XCTAssertNil(rejectionCode); + XCTAssertEqual(self.fakeSessionManager.setActiveCallCount, 1); + XCTAssertFalse(self.fakeSessionManager.lastSetActiveValue); + XCTAssertEqual(self.fakeSessionManager.markInactiveCallCount, 0); + XCTAssertEqual(self.fakeAudioEngine.onSessionDeactivatedCallCount, 0); + XCTAssertEqual(self.fakeAudioEngine.state, AudioEngineStateInterrupted); + XCTAssertFalse(self.fakeSessionManager.isActive); + XCTAssertEqualObjects(eventsAtResolve, (@[ @"setActive", @"resolve" ])); +} + +- (void)testSetAudioSessionActivityFalseDoesNotPauseWhenNotManagingSession +{ + self.fakeSessionManager.shouldManageSession = NO; + self.fakeSessionManager.isActive = YES; + self.fakeAudioEngine.state = AudioEngineStateInterrupted; + + NSString *rejectionCode = nil; + NSArray *eventsAtResolve = + [self invokeSetAudioSessionActivity:NO rejectionCodeOut:&rejectionCode]; + + XCTAssertNil(rejectionCode); + XCTAssertEqual(self.fakeSessionManager.setActiveCallCount, 1); + XCTAssertEqual(self.fakeSessionManager.markInactiveCallCount, 0); + XCTAssertEqual(self.fakeAudioEngine.onSessionDeactivatedCallCount, 0); + XCTAssertEqual(self.fakeAudioEngine.state, AudioEngineStateInterrupted); + XCTAssertTrue(self.fakeSessionManager.isActive); + XCTAssertEqualObjects(eventsAtResolve, (@[ @"setActive", @"resolve" ])); +} + @end diff --git a/apps/fabric-example/ios/FabricExampleTests/AudioEngineTests.mm b/apps/fabric-example/ios/FabricExampleTests/AudioEngineTests.mm index ec8ee9389..f8c91bd3a 100644 --- a/apps/fabric-example/ios/FabricExampleTests/AudioEngineTests.mm +++ b/apps/fabric-example/ios/FabricExampleTests/AudioEngineTests.mm @@ -422,7 +422,7 @@ - (void)testDetachSourceNodeRemovesTrackedNodeAndClearsGraphWhenEmpty { - (void)testDetachSourceNodeKeepsGraphNeedsRebuildWhenInputRemains { NSString *sourceNodeId = [self attachSourceNodeToAudioEngine]; [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] - voiceProcessingEnabled:NO onInputConfigurationChange:nil]; + voiceProcessingEnabled:NO onInputNotification:nil]; self.audioEngine.graphNeedsRebuild = YES; [self.audioEngine detachSourceNodeWithId:sourceNodeId]; @@ -435,7 +435,7 @@ - (void)testDetachSourceNodeKeepsGraphNeedsRebuildWhenInputRemains { - (void)testAttachInputNodeStoresAndConnectsInput { FakeAudioEngine *fakeEngine = self.audioEngine.currentFakeAudioEngine; [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] - voiceProcessingEnabled:NO onInputConfigurationChange:nil]; + voiceProcessingEnabled:NO onInputNotification:nil]; AVAudioSinkNode *inputNode = self.audioEngine.inputNode; XCTAssertNotNil(inputNode); @@ -455,7 +455,7 @@ - (void)testAttachInputNodeDefersConnectionUntilLiveInputFormatIsAvailable { fakeEngine.fakeInputNode.outputFormat = nil; [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] - voiceProcessingEnabled:NO onInputConfigurationChange:nil]; + voiceProcessingEnabled:NO onInputNotification:nil]; XCTAssertNil(self.audioEngine.inputNode); XCTAssertEqual(fakeEngine.attachNodeCallCount, 0); @@ -483,7 +483,7 @@ - (void)testDetachInputNodeWithoutInputDoesNothing { - (void)testDetachInputNodeClearsGraphOnlyWhenNoSourcesRemain { [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] - voiceProcessingEnabled:NO onInputConfigurationChange:nil]; + voiceProcessingEnabled:NO onInputNotification:nil]; self.audioEngine.graphNeedsRebuild = YES; [self.audioEngine detachInputNode]; @@ -493,7 +493,7 @@ - (void)testDetachInputNodeClearsGraphOnlyWhenNoSourcesRemain { [self attachSourceNodeToAudioEngine]; [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] - voiceProcessingEnabled:NO onInputConfigurationChange:nil]; + voiceProcessingEnabled:NO onInputNotification:nil]; self.audioEngine.graphNeedsRebuild = YES; [self.audioEngine detachInputNode]; @@ -507,7 +507,7 @@ - (void)testDetachInputNodePreservesSessionDeactivationInvalidation { fakeEngine.fakeRunning = YES; self.audioEngine.state = AudioEngineStateRunning; [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] - voiceProcessingEnabled:NO onInputConfigurationChange:nil]; + voiceProcessingEnabled:NO onInputNotification:nil]; [self.audioEngine onSessionDeactivated]; [self.audioEngine detachInputNode]; @@ -518,15 +518,15 @@ - (void)testDetachInputNodePreservesSessionDeactivationInvalidation { } - (void)testOnInterruptionBeginOnlyTransitionsFromRunning { - [self.audioEngine onInterruptionBegin]; + XCTAssertFalse([self.audioEngine onInterruptionBegin]); XCTAssertEqual(self.audioEngine.state, AudioEngineStateIdle); self.audioEngine.state = AudioEngineStatePaused; - [self.audioEngine onInterruptionBegin]; + XCTAssertFalse([self.audioEngine onInterruptionBegin]); XCTAssertEqual(self.audioEngine.state, AudioEngineStatePaused); self.audioEngine.state = AudioEngineStateRunning; - [self.audioEngine onInterruptionBegin]; + XCTAssertTrue([self.audioEngine onInterruptionBegin]); XCTAssertEqual(self.audioEngine.state, AudioEngineStateInterrupted); } @@ -578,6 +578,18 @@ - (void)testOnSessionDeactivatedMarksGraphForRebuildWhenNodesAreAttached { XCTAssertTrue(self.audioEngine.sessionDeactivationInvalidatedGraph); } +- (void)testOnSessionDeactivatedTransitionsInterruptedToPaused { + FakeAudioEngine *fakeEngine = self.audioEngine.currentFakeAudioEngine; + fakeEngine.fakeRunning = NO; + self.audioEngine.state = AudioEngineStateInterrupted; + + [self.audioEngine onSessionDeactivated]; + + XCTAssertEqual(self.audioEngine.state, AudioEngineStatePaused); + XCTAssertEqual(fakeEngine.pauseCallCount, 0); + XCTAssertTrue(self.audioEngine.sessionDeactivationInvalidatedGraph); +} + - (void) testOnSessionDeactivatedMarksStoppedGraphForRebuildWhenNodesAreAttached { FakeAudioEngine *fakeEngine = self.audioEngine.currentFakeAudioEngine; @@ -596,7 +608,8 @@ - (void)testOnSessionDeactivatedMarksGraphForRebuildWhenNodesAreAttached { - (void)testOnInterruptionEndNoOpsUnlessInterrupted { FakeAudioEngine *fakeEngine = self.audioEngine.currentFakeAudioEngine; - [self.audioEngine onInterruptionEnd:true]; + XCTAssertEqual([self.audioEngine onInterruptionEnd:true], + AudioEngineInterruptionEndOutcomeNoOp); XCTAssertEqual(self.audioEngine.state, AudioEngineStateIdle); XCTAssertEqual(fakeEngine.resetCallCount, 0); @@ -610,7 +623,8 @@ - (void)testOnInterruptionEndWithoutResumeRebuildsAndPauses { oldEngine.fakeRunning = YES; self.audioEngine.state = AudioEngineStateInterrupted; - [self.audioEngine onInterruptionEnd:false]; + XCTAssertEqual([self.audioEngine onInterruptionEnd:false], + AudioEngineInterruptionEndOutcomePaused); XCTAssertEqual(self.audioEngine.state, AudioEngineStatePaused); XCTAssertEqual(oldEngine.stopCallCount, 1); @@ -627,7 +641,8 @@ - (void)testOnInterruptionEndWithResumeRestartsEngine { oldEngine.fakeRunning = YES; self.audioEngine.state = AudioEngineStateInterrupted; - [self.audioEngine onInterruptionEnd:true]; + XCTAssertEqual([self.audioEngine onInterruptionEnd:true], + AudioEngineInterruptionEndOutcomeRunning); FakeAudioEngine *newEngine = self.audioEngine.currentFakeAudioEngine; XCTAssertEqual(self.audioEngine.state, AudioEngineStateRunning); @@ -637,7 +652,7 @@ - (void)testOnInterruptionEndWithResumeRestartsEngine { XCTAssertEqual(self.audioEngine.createdFakeEngines.count, 2UL); } -- (void)testOnInterruptionEndWithResumeFailureEndsIdle { +- (void)testOnInterruptionEndWithResumeFailureStaysInterrupted { [self attachSourceNodeToAudioEngine]; self.audioEngine.state = AudioEngineStateInterrupted; @@ -646,9 +661,28 @@ - (void)testOnInterruptionEndWithResumeFailureEndsIdle { self.audioEngine.nextCreatedEngineStartError = [NSError errorWithDomain:@"AudioEngineTests" code:5 userInfo:nil]; - [self.audioEngine onInterruptionEnd:true]; + XCTAssertEqual([self.audioEngine onInterruptionEnd:true], + AudioEngineInterruptionEndOutcomeStillInterrupted); - XCTAssertEqual(self.audioEngine.state, AudioEngineStateIdle); + XCTAssertEqual(self.audioEngine.state, AudioEngineStateInterrupted); +} + +- (void)testOnInterruptionEndWithFailedActivationStaysInterrupted { + [self attachSourceNodeToAudioEngine]; + + FakeAudioEngine *oldEngine = self.audioEngine.currentFakeAudioEngine; + oldEngine.fakeRunning = YES; + self.audioEngine.state = AudioEngineStateInterrupted; + self.sessionManager.ensureActiveResult = NO; + self.sessionManager.ensureActiveFailure = + [NSError errorWithDomain:@"AudioEngineTests" code:8 userInfo:nil]; + + XCTAssertEqual([self.audioEngine onInterruptionEnd:true], + AudioEngineInterruptionEndOutcomeStillInterrupted); + + XCTAssertEqual(self.audioEngine.state, AudioEngineStateInterrupted); + XCTAssertEqual(oldEngine.stopCallCount, 0); + XCTAssertEqual(self.audioEngine.createdFakeEngines.count, 1UL); } - (void)testStartIfNecessaryReturnsFalseWhenGraphEmpty { @@ -730,7 +764,7 @@ - (void)testStartIfNecessaryRebuildsWhenGraphNeedsRebuild { - (void) testStartIfNecessaryRebuildsAfterSessionDeactivationEvenWhenTeardownClearsGraph { [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] - voiceProcessingEnabled:NO onInputConfigurationChange:nil]; + voiceProcessingEnabled:NO onInputNotification:nil]; FakeAudioEngine *oldEngine = self.audioEngine.currentFakeAudioEngine; oldEngine.fakeRunning = YES; @@ -748,7 +782,7 @@ - (void)testStartIfNecessaryRebuildsWhenGraphNeedsRebuild { [self testInputFormatWithSampleRate:48000 channelCount:1]; self.audioEngine.nextCreatedEngineInputFormat = recoveredInputFormat; [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] - voiceProcessingEnabled:NO onInputConfigurationChange:nil]; + voiceProcessingEnabled:NO onInputNotification:nil]; AVAudioSinkNode *recoveredInputNode = self.audioEngine.inputNode; XCTAssertTrue([self.audioEngine startIfNecessary]); @@ -770,7 +804,7 @@ - (void)testStartIfNecessaryRebuildsWhenGraphNeedsRebuild { - (void)testStartIfNecessaryRebuildsInputNodeWithFreshInstance { [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] - voiceProcessingEnabled:NO onInputConfigurationChange:nil]; + voiceProcessingEnabled:NO onInputNotification:nil]; FakeAudioEngine *oldEngine = self.audioEngine.currentFakeAudioEngine; AVAudioSinkNode *oldInputNode = self.audioEngine.inputNode; AVAudioFormat *replacementInputFormat = @@ -992,7 +1026,7 @@ - (void)testConcurrentRecordAndPlayPathsDoNotCrash { dispatch_group_enter(group); dispatch_async(queue, ^{ [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] - voiceProcessingEnabled:NO onInputConfigurationChange:nil]; + voiceProcessingEnabled:NO onInputNotification: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 a115ec460..83197635c 100644 --- a/apps/fabric-example/ios/FabricExampleTests/AudioPlayerTests.mm +++ b/apps/fabric-example/ios/FabricExampleTests/AudioPlayerTests.mm @@ -4,6 +4,7 @@ #import #import #import +#import #import #import #import @@ -110,11 +111,14 @@ @interface FakePlayerAudioEngine : AudioEngine @property(nonatomic, assign) NSInteger stopIfPossibleCallCount; @property(nonatomic, assign) NSInteger attachSourceNodeCallCount; @property(nonatomic, assign) NSInteger detachSourceNodeCallCount; +@property(nonatomic, assign) NSInteger attachInputNodeCallCount; @property(nonatomic, copy) AVAudioSourceNodeRenderBlock lastAttachedRenderBlock; @property(nonatomic, assign) float lastAttachedSampleRate; @property(nonatomic, assign) AVAudioChannelCount lastAttachedChannelCount; @property(nonatomic, copy) NSString *returnedSourceNodeId; @property(nonatomic, copy) NSString *lastDetachedSourceNodeId; +@property(nonatomic, strong) NSMutableSet *attachedSourceNodeIds; +@property(nonatomic, copy) NSSet *sourceNodeIdsPresentAtLastStart; @end @@ -125,6 +129,7 @@ - (instancetype)init if (self = [super init]) { self.startIfNecessaryResult = YES; self.returnedSourceNodeId = @"fake-source-node-id"; + self.attachedSourceNodeIds = [NSMutableSet set]; } return self; @@ -148,6 +153,7 @@ - (void)stopIfNecessary - (bool)startIfNecessary { self.startIfNecessaryCallCount += 1; + self.sourceNodeIdsPresentAtLastStart = [self.attachedSourceNodeIds copy]; return self.startIfNecessaryResult; } @@ -164,6 +170,7 @@ - (NSString *)attachSourceNodeWithRenderBlock:(AVAudioSourceNodeRenderBlock)rend self.lastAttachedRenderBlock = renderBlock; self.lastAttachedSampleRate = sampleRate; self.lastAttachedChannelCount = channelCount; + [self.attachedSourceNodeIds addObject:self.returnedSourceNodeId]; return self.returnedSourceNodeId; } @@ -171,6 +178,21 @@ - (void)detachSourceNodeWithId:(NSString *)sourceNodeId { self.detachSourceNodeCallCount += 1; self.lastDetachedSourceNodeId = sourceNodeId; + + if (sourceNodeId != nil) { + [self.attachedSourceNodeIds removeObject:sourceNodeId]; + } +} + +- (void)attachInputNodeWithReceiverBlock:(AVAudioSinkNodeReceiverBlock)receiverBlock + voiceProcessingEnabled:(BOOL)voiceProcessingEnabled + onInputNotification: + (void (^)(AudioEngineInputNotification))onInputNotification +{ + self.attachInputNodeCallCount += 1; + (void)receiverBlock; + (void)voiceProcessingEnabled; + (void)onInputNotification; } @end @@ -421,6 +443,61 @@ - (void)testStartReturnsFalseWhenSessionActivationFails XCTAssertNil(player.sourceNodeId); } +- (void)assertFailedEngineStartDetachesSourceForSelector:(SEL)selector +{ + NativeAudioPlayer *player = [self createPlayerWithRenderCallCount:nullptr]; + self.audioEngine.startIfNecessaryResult = NO; + typedef BOOL (*NativeAudioPlayerBoolMethod)(id, SEL); + NativeAudioPlayerBoolMethod operation = + (NativeAudioPlayerBoolMethod)[player methodForSelector:selector]; + + XCTAssertFalse(operation(player, selector)); + XCTAssertEqual(self.audioEngine.stopIfNecessaryCallCount, 1); + XCTAssertEqual(self.audioEngine.attachSourceNodeCallCount, 1); + XCTAssertEqual(self.audioEngine.startIfNecessaryCallCount, 1); + XCTAssertEqual(self.audioEngine.detachSourceNodeCallCount, 1); + XCTAssertEqualObjects( + self.audioEngine.lastDetachedSourceNodeId, self.audioEngine.returnedSourceNodeId); + XCTAssertEqual(self.audioEngine.stopIfPossibleCallCount, 1); + XCTAssertNil(player.sourceNodeId); + XCTAssertEqual(self.audioEngine.attachedSourceNodeIds.count, 0UL); +} + +- (void)testStartAndResumeDetachSourceWhenEngineFailsToStart +{ + [self assertFailedEngineStartDetachesSourceForSelector:@selector(start)]; + + self.audioEngine.stopIfNecessaryCallCount = 0; + self.audioEngine.attachSourceNodeCallCount = 0; + self.audioEngine.startIfNecessaryCallCount = 0; + self.audioEngine.detachSourceNodeCallCount = 0; + self.audioEngine.stopIfPossibleCallCount = 0; + self.audioEngine.lastDetachedSourceNodeId = nil; + self.sessionManager.ensureActiveCallCount = 0; + + [self assertFailedEngineStartDetachesSourceForSelector:@selector(resume)]; +} + +- (void)testFailedPlayerStartLeavesNoSourceForFollowingRecorderStart +{ + NativeAudioPlayer *player = [self createPlayerWithRenderCallCount:nullptr]; + self.audioEngine.startIfNecessaryResult = NO; + + XCTAssertFalse([player start]); + XCTAssertEqual(self.audioEngine.attachedSourceNodeIds.count, 0UL); + XCTAssertNil(player.sourceNodeId); + + self.audioEngine.startIfNecessaryResult = YES; + NativeAudioRecorder *recorder = [[NativeAudioRecorder alloc] + initWithReceiverBlock:^(const AudioBufferList *inputBuffer, int numFrames) {} + voiceProcessingEnabled:NO]; + + XCTAssertTrue([recorder start:nil]); + XCTAssertEqual(self.audioEngine.attachInputNodeCallCount, 1); + XCTAssertEqual(self.audioEngine.attachedSourceNodeIds.count, 0UL); + XCTAssertEqual(self.audioEngine.sourceNodeIdsPresentAtLastStart.count, 0UL); +} + - (void)testAttachSourceNodeIfNeededIsIdempotent { NativeAudioPlayer *player = [self createPlayerWithRenderCallCount:nullptr]; diff --git a/apps/fabric-example/ios/FabricExampleTests/IOSAudioRecorderTests.mm b/apps/fabric-example/ios/FabricExampleTests/IOSAudioRecorderTests.mm index cf8e8f3d9..d19aa250c 100644 --- a/apps/fabric-example/ios/FabricExampleTests/IOSAudioRecorderTests.mm +++ b/apps/fabric-example/ios/FabricExampleTests/IOSAudioRecorderTests.mm @@ -123,6 +123,7 @@ @interface FakeNativeAudioRecorder : NativeAudioRecorder @property(nonatomic, assign) NSInteger stopCallCount; @property(nonatomic, assign) NSInteger pauseCallCount; @property(nonatomic, assign) NSInteger resumeCallCount; +@property(nonatomic, assign) BOOL resumeResult; @property(nonatomic, assign) NSInteger cleanupCallCount; @property(nonatomic, assign) NSInteger setInputArmedCallCount; @property(nonatomic, assign) BOOL lastInputArmed; @@ -144,6 +145,7 @@ - (instancetype)init { channels:2]; self.mockResolvedBufferSize = 512; self.startResult = YES; + self.resumeResult = YES; } return self; @@ -188,8 +190,9 @@ - (void)pause { self.pauseCallCount += 1; } -- (void)resume { +- (BOOL)resume { self.resumeCallCount += 1; + return self.resumeResult; } - (void)cleanup { @@ -267,7 +270,7 @@ - (void)setUp { _recorder = std::make_unique(std::shared_ptr()); self.originalNativeRecorder = _recorder->replaceNativeRecorder(self.nativeRecorder); - self.nativeRecorder.onInputConfigurationChange = self.originalNativeRecorder.onInputConfigurationChange; + self.nativeRecorder.onInputNotification = self.originalNativeRecorder.onInputNotification; } - (void)tearDown { @@ -478,6 +481,19 @@ - (void)testPauseAndResumeRespectCurrentState { XCTAssertFalse(_recorder->isPaused()); } +- (void)testResumeDoesNotStoreRecordingWhenNativeResumeFails { + _recorder->setRecorderState(AudioRecorder::RecorderState::Recording); + self.audioEngine.state = AudioEngineStateRunning; + _recorder->pause(); + self.nativeRecorder.resumeResult = NO; + + _recorder->resume(); + + XCTAssertEqual(self.nativeRecorder.resumeCallCount, 1); + XCTAssertTrue(_recorder->isPaused()); + XCTAssertFalse(_recorder->isRecording()); +} + - (void)testStopReturnsErrorWhileIdle { auto result = _recorder->stop(); diff --git a/apps/fabric-example/ios/FabricExampleTests/NativeAudioRecorderTests.mm b/apps/fabric-example/ios/FabricExampleTests/NativeAudioRecorderTests.mm index 149c07371..6a19f6654 100644 --- a/apps/fabric-example/ios/FabricExampleTests/NativeAudioRecorderTests.mm +++ b/apps/fabric-example/ios/FabricExampleTests/NativeAudioRecorderTests.mm @@ -115,14 +115,15 @@ - (void)stopIfNecessary - (void)attachInputNodeWithReceiverBlock:(AVAudioSinkNodeReceiverBlock)receiverBlock voiceProcessingEnabled:(BOOL)voiceProcessingEnabled - onInputConfigurationChange:(void (^)(void))onInputConfigurationChange + onInputNotification: + (void (^)(AudioEngineInputNotification))onInputNotification { self.attachInputNodeCallCount += 1; self.inputNode = [[AVAudioSinkNode alloc] initWithReceiverBlock:receiverBlock]; self.lastAttachedInputNode = self.inputNode; self.lastAttachedReceiverBlock = receiverBlock; self.lastAttachedVoiceProcessingEnabled = voiceProcessingEnabled; - (void)onInputConfigurationChange; + (void)onInputNotification; } - (bool)startIfNecessary @@ -431,11 +432,23 @@ - (void)testPauseAndResumeDelegateToAudioEngine voiceProcessingEnabled:NO]; [recorder pause]; - [recorder resume]; + XCTAssertTrue([recorder resume]); XCTAssertEqual(self.audioEngine.pauseIfNecessaryCallCount, 1); XCTAssertEqual(self.audioEngine.startIfNecessaryCallCount, 1); - XCTAssertTrue(recorder.inputArmed); +} + +- (void)testResumeReturnsFalseWhenEngineStartFails +{ + self.audioEngine.startIfNecessaryResult = NO; + NativeAudioRecorder *recorder = [[NativeAudioRecorder alloc] + initWithReceiverBlock:^(const AudioBufferList *inputBuffer, int numFrames) {} + voiceProcessingEnabled:NO]; + + [recorder pause]; + XCTAssertFalse([recorder resume]); + + XCTAssertEqual(self.audioEngine.startIfNecessaryCallCount, 1); } - (void)testStartAfterSessionDeactivationUsesRecoveryRebuildPath diff --git a/apps/fabric-example/ios/FabricExampleTests/SystemNotificationManagerTests.mm b/apps/fabric-example/ios/FabricExampleTests/SystemNotificationManagerTests.mm index 2f1d1b0f3..9ddaf3298 100644 --- a/apps/fabric-example/ios/FabricExampleTests/SystemNotificationManagerTests.mm +++ b/apps/fabric-example/ios/FabricExampleTests/SystemNotificationManagerTests.mm @@ -15,6 +15,8 @@ - (void)handleSecondaryAudio:(NSNotification *)notification; - (void)handleRouteChange:(NSNotification *)notification; - (void)handleMediaServicesReset:(NSNotification *)notification; - (void)handleEngineConfigurationChange:(NSNotification *)notification; +- (void)handleWillEnterForeground:(NSNotification *)notification; +- (void)handleDidBecomeActive:(NSNotification *)notification; - (void)startPollingSecondaryAudioHint; - (void)stopPollingSecondaryAudioHint; - (void)checkSecondaryAudioHint; @@ -97,20 +99,43 @@ @interface SNMFakeAudioEngine : AudioEngine @property(nonatomic, assign) NSInteger interruptionEndCallCount; @property(nonatomic, assign) NSInteger restartAudioEngineCallCount; @property(nonatomic, assign) BOOL lastShouldResume; +@property(nonatomic, assign) BOOL fakeHasInputRegistration; +@property(nonatomic, assign) BOOL interruptionBeginAccepted; +@property(nonatomic, assign) AudioEngineInterruptionEndOutcome interruptionEndOutcome; @end @implementation SNMFakeAudioEngine -- (void)onInterruptionBegin +- (bool)onInterruptionBegin { self.interruptionBeginCallCount += 1; + if (self.interruptionBeginAccepted) { + self.state = AudioEngineStateInterrupted; + } + return self.interruptionBeginAccepted; } -- (void)onInterruptionEnd:(bool)shouldResume +- (AudioEngineInterruptionEndOutcome)onInterruptionEnd:(bool)shouldResume { self.interruptionEndCallCount += 1; self.lastShouldResume = shouldResume; + + switch (self.interruptionEndOutcome) { + case AudioEngineInterruptionEndOutcomeRunning: + self.state = AudioEngineStateRunning; + break; + case AudioEngineInterruptionEndOutcomePaused: + self.state = AudioEngineStatePaused; + break; + case AudioEngineInterruptionEndOutcomeStillInterrupted: + self.state = AudioEngineStateInterrupted; + break; + case AudioEngineInterruptionEndOutcomeNoOp: + break; + } + + return self.interruptionEndOutcome; } - (void)restartAudioEngine @@ -118,6 +143,11 @@ - (void)restartAudioEngine self.restartAudioEngineCallCount += 1; } +- (bool)hasInputRegistration +{ + return self.fakeHasInputRegistration; +} + @end @interface SNMFakeAudioSessionManager : AudioSessionManager @@ -271,6 +301,8 @@ - (void)setUp [super setUp]; self.fakeAudioEngine = [[SNMFakeAudioEngine alloc] init]; + self.fakeAudioEngine.interruptionBeginAccepted = YES; + self.fakeAudioEngine.interruptionEndOutcome = AudioEngineInterruptionEndOutcomeRunning; self.fakeSessionManager = [[SNMFakeAudioSessionManager alloc] init]; self.fakeSharedAudioSession = [[FakeSharedAVAudioSession alloc] init]; SetFakeSharedAudioSession(self.fakeSharedAudioSession); @@ -415,19 +447,64 @@ - (void)testHandleInterruptionBeganMarksInactiveAndEmitsEventWhenObserved XCTAssertEqualObjects(self.module.lastEventBody[@"shouldResume"], @NO); } -- (void)testHandleInterruptionEndedEmitsEventWhenObserved +- (void)testHandleInterruptionBeganDoesNotEmitWhenBeginIsRejected +{ + [self.manager observeAudioInterruptions:YES]; + self.fakeAudioEngine.interruptionBeginAccepted = NO; + + [self.manager handleInterruption:[self interruptionNotificationWithType:AVAudioSessionInterruptionTypeBegan + option:0]]; + [self flushMainQueue]; + + XCTAssertEqual(self.fakeSessionManager.markInactiveCallCount, 1); + XCTAssertEqual(self.fakeAudioEngine.interruptionBeginCallCount, 1); + XCTAssertEqual(self.module.eventInvocationCount, 0); +} + +- (void)testHandleInterruptionEndedRecoversAndEmitsEventWhenObserved { [self.manager observeAudioInterruptions:YES]; [self.manager handleInterruption:[self interruptionNotificationWithType:AVAudioSessionInterruptionTypeEnded option:AVAudioSessionInterruptionOptionShouldResume]]; + [self flushMainQueue]; + XCTAssertEqual(self.fakeAudioEngine.interruptionEndCallCount, 1); + XCTAssertTrue(self.fakeAudioEngine.lastShouldResume); XCTAssertEqual(self.module.eventInvocationCount, 1); XCTAssertEqual(self.module.lastEventNameRaw, static_cast(audioapi::AudioEvent::INTERRUPTION)); XCTAssertEqualObjects(self.module.lastEventBody[@"type"], @"ended"); XCTAssertEqualObjects(self.module.lastEventBody[@"shouldResume"], @YES); - XCTAssertEqual(self.fakeAudioEngine.interruptionEndCallCount, 0); +} + +- (void)testHandleInterruptionEndedDoesNotEmitWhenResumeFails +{ + [self.manager observeAudioInterruptions:YES]; + self.fakeAudioEngine.interruptionEndOutcome = AudioEngineInterruptionEndOutcomeStillInterrupted; + + [self.manager handleInterruption:[self interruptionNotificationWithType:AVAudioSessionInterruptionTypeEnded + option:AVAudioSessionInterruptionOptionShouldResume]]; + [self flushMainQueue]; + + XCTAssertEqual(self.fakeAudioEngine.interruptionEndCallCount, 1); + XCTAssertEqual(self.module.eventInvocationCount, 0); +} + +- (void)testHandleInterruptionEndedEmitsWhenPausedAfterPoliteNonResume +{ + [self.manager observeAudioInterruptions:YES]; + self.fakeAudioEngine.interruptionEndOutcome = AudioEngineInterruptionEndOutcomePaused; + + [self.manager handleInterruption:[self interruptionNotificationWithType:AVAudioSessionInterruptionTypeEnded + option:0]]; + [self flushMainQueue]; + + XCTAssertEqual(self.fakeAudioEngine.interruptionEndCallCount, 1); + XCTAssertFalse(self.fakeAudioEngine.lastShouldResume); + XCTAssertEqual(self.module.eventInvocationCount, 1); + XCTAssertEqualObjects(self.module.lastEventBody[@"type"], @"ended"); + XCTAssertEqualObjects(self.module.lastEventBody[@"shouldResume"], @NO); } - (void)testHandleInterruptionEndedResumesEngineWhenNotObserved @@ -441,6 +518,97 @@ - (void)testHandleInterruptionEndedResumesEngineWhenNotObserved XCTAssertTrue(self.fakeAudioEngine.lastShouldResume); } +- (void)prepareInterruptedRecordingEngine +{ + self.fakeAudioEngine.state = AudioEngineStateInterrupted; + self.fakeAudioEngine.fakeHasInputRegistration = YES; +} + +- (void)testForegroundRetryDoesNotResumeBeforeInterruptionEnded +{ + [self prepareInterruptedRecordingEngine]; + [self.manager handleInterruption:[self interruptionNotificationWithType:AVAudioSessionInterruptionTypeBegan + option:0]]; + [self flushMainQueue]; + + [self.manager handleWillEnterForeground:nil]; + [self.manager handleDidBecomeActive:nil]; + [self flushMainQueue]; + + XCTAssertFalse(self.manager.interruptionEndedDelivered); + XCTAssertEqual(self.fakeAudioEngine.interruptionEndCallCount, 0); +} + +- (void)testForegroundRetryResumesAfterFailedInterruptionEnded +{ + [self prepareInterruptedRecordingEngine]; + self.fakeAudioEngine.interruptionEndOutcome = AudioEngineInterruptionEndOutcomeStillInterrupted; + [self.manager handleInterruption:[self interruptionNotificationWithType:AVAudioSessionInterruptionTypeBegan + option:0]]; + [self.manager handleInterruption:[self interruptionNotificationWithType:AVAudioSessionInterruptionTypeEnded + option:AVAudioSessionInterruptionOptionShouldResume]]; + [self flushMainQueue]; + + NSInteger endCountAfterEnded = self.fakeAudioEngine.interruptionEndCallCount; + XCTAssertTrue(self.manager.interruptionEndedDelivered); + XCTAssertEqual(endCountAfterEnded, 1); + XCTAssertEqual(self.fakeAudioEngine.state, AudioEngineStateInterrupted); + + [self.manager handleWillEnterForeground:nil]; + [self flushMainQueue]; + + XCTAssertEqual(self.fakeAudioEngine.interruptionEndCallCount, endCountAfterEnded + 1); + XCTAssertTrue(self.fakeAudioEngine.lastShouldResume); +} + +- (void)testForegroundRetryEmitsEndedWhenObservedResumeSucceedsAfterFailedEnd +{ + [self.manager observeAudioInterruptions:YES]; + [self prepareInterruptedRecordingEngine]; + self.fakeAudioEngine.interruptionEndOutcome = AudioEngineInterruptionEndOutcomeStillInterrupted; + + [self.manager handleInterruption:[self interruptionNotificationWithType:AVAudioSessionInterruptionTypeBegan + option:0]]; + [self.manager handleInterruption:[self interruptionNotificationWithType:AVAudioSessionInterruptionTypeEnded + option:AVAudioSessionInterruptionOptionShouldResume]]; + [self flushMainQueue]; + + XCTAssertEqual(self.fakeAudioEngine.interruptionEndCallCount, 1); + XCTAssertEqual(self.module.eventInvocationCount, 1); + XCTAssertEqualObjects(self.module.lastEventBody[@"type"], @"began"); + + [self.module resetCapturedEvent]; + self.fakeAudioEngine.interruptionEndOutcome = AudioEngineInterruptionEndOutcomeRunning; + + [self.manager handleWillEnterForeground:nil]; + [self flushMainQueue]; + + XCTAssertEqual(self.fakeAudioEngine.interruptionEndCallCount, 2); + XCTAssertEqual(self.module.eventInvocationCount, 1); + XCTAssertEqualObjects(self.module.lastEventBody[@"type"], @"ended"); +} + +- (void)testForegroundRetryAfterSuccessfulResumeIsNoOp +{ + [self.manager observeAudioInterruptions:YES]; + [self prepareInterruptedRecordingEngine]; + + [self.manager handleInterruption:[self interruptionNotificationWithType:AVAudioSessionInterruptionTypeEnded + option:AVAudioSessionInterruptionOptionShouldResume]]; + [self flushMainQueue]; + + XCTAssertEqual(self.fakeAudioEngine.interruptionEndCallCount, 1); + XCTAssertEqual(self.module.eventInvocationCount, 1); + XCTAssertEqualObjects(self.module.lastEventBody[@"type"], @"ended"); + + [self.module resetCapturedEvent]; + [self.manager handleDidBecomeActive:nil]; + [self flushMainQueue]; + + XCTAssertEqual(self.fakeAudioEngine.interruptionEndCallCount, 1); + XCTAssertEqual(self.module.eventInvocationCount, 0); +} + - (void)testHandleSecondaryAudioBeginMarksInactiveAndEmitsEventWhenObserved { [self.manager observeAudioInterruptions:YES]; @@ -471,6 +639,22 @@ - (void)testHandleSecondaryAudioEndResumesEngineWhenNotObserved XCTAssertTrue(self.fakeAudioEngine.lastShouldResume); } +- (void)testHandleSecondaryAudioEndRecoversAndEmitsEventWhenObserved +{ + [self.manager observeAudioInterruptions:YES]; + + [self.manager + handleSecondaryAudio:[self secondaryAudioNotificationWithType: + AVAudioSessionSilenceSecondaryAudioHintTypeEnd]]; + [self flushMainQueue]; + + XCTAssertEqual(self.fakeAudioEngine.interruptionEndCallCount, 1); + XCTAssertTrue(self.fakeAudioEngine.lastShouldResume); + XCTAssertEqual(self.module.eventInvocationCount, 1); + XCTAssertEqualObjects(self.module.lastEventBody[@"type"], @"ended"); + XCTAssertEqualObjects(self.module.lastEventBody[@"shouldResume"], @YES); +} + - (void)testHandleRouteChangeMapsReasonsAndFallsBackToUnknown { NSArray *cases = @[ @@ -559,21 +743,37 @@ - (void)testCheckSecondaryAudioHintSilencedTransitionMarksInactiveAndEmitsEventW XCTAssertEqualObjects(self.module.lastEventBody[@"shouldResume"], @NO); } -- (void)testCheckSecondaryAudioHintResumeTransitionEmitsEventWhenObserved +- (void)testCheckSecondaryAudioHintResumeTransitionRecoversAndEmitsEventWhenObserved { [self.manager observeAudioInterruptions:YES]; self.fakeSharedAudioSession.secondaryAudioShouldBeSilencedHint = NO; self.manager.wasOtherAudioPlaying = YES; [self.manager checkSecondaryAudioHint]; + [self flushMainQueue]; XCTAssertFalse(self.manager.wasOtherAudioPlaying); + XCTAssertEqual(self.fakeAudioEngine.interruptionEndCallCount, 1); + XCTAssertTrue(self.fakeAudioEngine.lastShouldResume); XCTAssertEqual(self.module.eventInvocationCount, 1); XCTAssertEqual(self.module.lastEventNameRaw, static_cast(audioapi::AudioEvent::INTERRUPTION)); XCTAssertEqualObjects(self.module.lastEventBody[@"type"], @"ended"); XCTAssertEqualObjects(self.module.lastEventBody[@"shouldResume"], @YES); - XCTAssertEqual(self.fakeAudioEngine.interruptionEndCallCount, 0); +} + +- (void)testCheckSecondaryAudioHintResumeTransitionDoesNotEmitWhenResumeFails +{ + [self.manager observeAudioInterruptions:YES]; + self.fakeAudioEngine.interruptionEndOutcome = AudioEngineInterruptionEndOutcomeStillInterrupted; + self.fakeSharedAudioSession.secondaryAudioShouldBeSilencedHint = NO; + self.manager.wasOtherAudioPlaying = YES; + + [self.manager checkSecondaryAudioHint]; + [self flushMainQueue]; + + XCTAssertEqual(self.fakeAudioEngine.interruptionEndCallCount, 1); + XCTAssertEqual(self.module.eventInvocationCount, 0); } - (void)testCheckSecondaryAudioHintResumeTransitionResumesEngineWhenNotObserved diff --git a/apps/fabric-example/ios/Podfile.lock b/apps/fabric-example/ios/Podfile.lock index f330e1426..9d86afa58 100644 --- a/apps/fabric-example/ios/Podfile.lock +++ b/apps/fabric-example/ios/Podfile.lock @@ -2516,4 +2516,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 7a9375c6de5b95bc2125d07cf736baa649d6ba8e -COCOAPODS: 1.16.2 +COCOAPODS: 1.17.0 diff --git a/packages/audiodocs/docs/fundamentals/best-practices.mdx b/packages/audiodocs/docs/fundamentals/best-practices.mdx index c9ed568cb..64969bfba 100644 --- a/packages/audiodocs/docs/fundamentals/best-practices.mdx +++ b/packages/audiodocs/docs/fundamentals/best-practices.mdx @@ -22,7 +22,7 @@ user experience, and maintainability. Here are some key best practices to consid Running `AudioContext` is still playing silence even if there is no playing source node connected to the [`destination`](/docs/core/base-audio-context#properties). Additionally, on iOS devices, the state of the `AudioContext` is directly related with state of the lock screen. If a running `AudioContext` exists, it is impossible to set lock screen state to `state_paused`. -- **Configure the audio session early**: Set [`AudioManager.setAudioSessionOptions()`](/docs/system/audio-manager) once at startup — for example `iosCategory: 'playback'` for media apps or `iosCategory: 'playAndRecord'` when recording and playback coexist. +- **Configure the audio session early**: Set [`AudioManager.setAudioSessionOptions()`](/docs/system/audio-manager#setaudiosessionoptions) once at startup and follow the [session configuration guidelines](/docs/system/audio-manager#sessionoptionsconfigurationguidelines). ## [**AudioRecorder**](/docs/inputs/audio-recorder) Management diff --git a/packages/audiodocs/docs/other/audio-api-plugin.mdx b/packages/audiodocs/docs/other/audio-api-plugin.mdx index ecf3dae12..60cddb526 100644 --- a/packages/audiodocs/docs/other/audio-api-plugin.mdx +++ b/packages/audiodocs/docs/other/audio-api-plugin.mdx @@ -84,7 +84,7 @@ export default { Defaults to `true`. -Allows the app to play audio in the background on iOS. +Allows the app to play audio in the background on iOS. Corresponds to adding `audio` to [`UIBackgroundModes`](https://developer.apple.com/documentation/bundleresources/information-property-list/uibackgroundmodes). ### `iosMicrophonePermission` diff --git a/packages/audiodocs/docs/system/audio-manager.mdx b/packages/audiodocs/docs/system/audio-manager.mdx index e044b6a97..ba83b531a 100644 --- a/packages/audiodocs/docs/system/audio-manager.mdx +++ b/packages/audiodocs/docs/system/audio-manager.mdx @@ -44,21 +44,21 @@ function App() { ## Methods -### `setAudioSessionOptions` +### `setAudioSessionOptions` {#setaudiosessionoptions} + +| Parameter | Type | Description | +| :---: | :---: | :---- | +| options | [`SessionOptions`](/docs/system/audio-manager#sessionoptions) | Options to be set for [AVAudioSession](https://developer.apple.com/documentation/avfaudio/avaudiosession?language=objc#Configuring-standard-audio-behaviors). | :::warning AVAudioSession Compatibility Not all `iosOptions` are compatible with every `iosCategory`. Passing an invalid combination to the native API (for example, explicitly setting `allowBluetoothA2DP` alongside the `playback` category) will cause the configuration to fail. This can result in a `SessionActivationError` and total audio silence. -Always verify valid category and option combinations in [Apple's AVAudioSession Documentation](https://developer.apple.com/documentation/avfaudio/avaudiosession?language=objc). +For details on compatible options and categories, refer to [`SessionOptions`](#sessionoptions). ::: -| Parameter | Type | Description | -| :---: | :---: | :---- | -| options | [`SessionOptions`](/docs/system/audio-manager#sessionoptions) | Options to be set for [AVAudioSession](https://developer.apple.com/documentation/avfaudio/avaudiosession?language=objc#Configuring-standard-audio-behaviors). | - #### Returns `undefined`. -### `setAudioSessionActivity` +### `setAudioSessionActivity` {#setaudiosessionactivity} | Parameter | Type | Description | | :---: | :---: | :---- | @@ -165,6 +165,30 @@ Checks currently used and available devices. ## Remarks +### Resume recording after an interruption {#resumerecordingafteraninterruption} + +An interruption is the system taking the audio session away due to a phone call, an alarm, or another non-mixable audio session. It deactivates the session and stops the I/O unit. Audio playback and capture therefore stop. + +A resume that completes while the app is still backgrounded is possible only when all of the conditions below hold. + +#### App configuration + +- Ensure the session options (set via [`setAudioSessionOptions`](#setaudiosessionoptions)) allow recording and make the app resilient to interruptions from other apps. Refer to the [session configuration guidelines](#sessionoptionsconfigurationguidelines). The following settings are a useful starting point: + - `iosCategory` set to `record` or `playAndRecord`, so the session can record. These categories also allow background recording. An incompatible category leads to a [`cannotStartPlaying`](https://developer.apple.com/documentation/coreaudiotypes/avaudiosession/errorcode/cannotstartplaying) error. + - `iosMode` set to `default`. + - `iosOptions` include `mixWithOthers`, so the system can mix this session with audio from active sessions in other apps. Trying to resume recording in the background with a non-mixable session leads to [`cannotInterruptOthers`](https://developer.apple.com/documentation/coreaudiotypes/avaudiosession/errorcode/cannotinterruptothers). +- The app must declare [background audio mode](/docs/other/audio-api-plugin#iosbackgroundmode). + +#### Execution + +To complete a background resume, audio I/O must have been started in the foreground, and it must still have been running when the app went to the background. The background audio mode lets that already-running I/O *continue*; it does not allow *starting* audio from the background. iOS treats an `interruption` event of type `ended` as the wake on which a background resume may be attempted. A later background timer is not such a wake. Another app must have released the audio route and the microphone. For recording, the recorder must not have been stopped with [`stop()`](/docs/inputs/audio-recorder#stop), because stopping ends the take. + +#### iOS policy + +iOS policy determines the overall outcome of restarts. Activating the session and starting I/O are independent steps. An active session does not grant permission to run I/O. Even when every condition above is met, iOS may still refuse the restart: it applies internal checks on why the app is awake. The library therefore does not promise a background restart after a call, Siri, or a similar interruption. Because of this, the library retries with a best-effort strategy when the app returns to the foreground after a failed resume. + +## Types + ### `AudioFocusType`
Type definitions @@ -181,6 +205,7 @@ type AudioFocusType =
Type definitions + ```typescript type IOSCategory = | 'ambient' @@ -226,11 +251,21 @@ interface SessionOptions { ```
+#### Configuration guidelines {#sessionoptionsconfigurationguidelines} + +Each string union maps directly to the corresponding Apple type: + +- `IOSCategory` maps to [`AVAudioSession.Category`](https://developer.apple.com/documentation/avfaudio/avaudiosession/category-swift.struct) +- `IOSMode` maps to [`AVAudioSession.Mode`](https://developer.apple.com/documentation/avfaudio/avaudiosession/mode-swift.struct) +- `IOSOption` maps to [`AVAudioSession.CategoryOptions`](https://developer.apple.com/documentation/avfaudio/avaudiosession/categoryoptions-swift.struct) + +Not all `iosOptions` are compatible with every `iosCategory`. Passing an invalid combination to the native API will cause the configuration to fail. Always verify valid category and option combinations in [Apple's AVAudioSession Documentation](https://developer.apple.com/documentation/avfaudio/avaudiosession?language=objc). ### `SystemEventName`
Type definitions + ```typescript interface EventEmptyType {} @@ -295,15 +330,18 @@ interface AudioEventSubscription {
Type definitions + ```typescript type PermissionStatus = 'Undetermined' | 'Denied' | 'Granted'; ``` +
### `AudioDeviceInfo`
Type definitions + ```typescript export interface AudioDeviceInfo { id: string; // unique device identifier @@ -311,12 +349,14 @@ export interface AudioDeviceInfo { category: string; // device category (e.g. "Built-In Microphone", "Bluetooth") } ``` +
### `AudioDevicesInfo`
Type definitions + ```typescript export type AudioDeviceList = AudioDeviceInfo[]; @@ -327,4 +367,5 @@ export interface AudioDevicesInfo { currentOutputs: AudioDeviceList; // iOS } ``` +
diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioFileWriter.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioFileWriter.cpp index 1c9823ad1..4c1de5be8 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioFileWriter.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioFileWriter.cpp @@ -19,7 +19,7 @@ void AudioFileWriter::invokeOnErrorCallback(const std::string &message) { errorEvent_.dispatch(StringPayload{.name = "message", .reason = message}); } -bool AudioFileWriter::isFileOpen() { +bool AudioFileWriter::isFileOpen() const { return isFileOpen_.load(std::memory_order_acquire); } diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioFileWriter.h b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioFileWriter.h index 81b4eeac1..db00762ec 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioFileWriter.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioFileWriter.h @@ -39,6 +39,7 @@ class AudioFileWriter { virtual double getCurrentDuration() const = 0; virtual size_t getFileSizeBytes() const = 0; + [[nodiscard]] bool isFileOpen() const; void setOnErrorCallback(uint64_t callbackId) { assignOnErrorCallbackId(callbackId); @@ -50,8 +51,6 @@ class AudioFileWriter { void invokeOnErrorCallback(const std::string &message); protected: - bool isFileOpen(); - std::atomic isFileOpen_{false}; std::atomic framesWritten_{0}; EventCaller errorEvent_; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/RotatingFileWriter.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/RotatingFileWriter.cpp index e713a8fc1..59370c63b 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/RotatingFileWriter.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/RotatingFileWriter.cpp @@ -32,6 +32,9 @@ CloseFileResult RotatingFileWriter::closeFile() { } void RotatingFileWriter::rotateFiles() { + if (currentWriter_ == nullptr || !currentWriter_->isFileOpen()) { + return; + } auto rotatedClose = currentWriter_->closeFile(); if (rotatedClose.is_ok()) { const auto &t = rotatedClose.unwrap(); diff --git a/packages/react-native-audio-api/ios/audioapi/ios/AudioAPIModule.mm b/packages/react-native-audio-api/ios/audioapi/ios/AudioAPIModule.mm index 12206d697..49d690288 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/AudioAPIModule.mm +++ b/packages/react-native-audio-api/ios/audioapi/ios/AudioAPIModule.mm @@ -119,6 +119,8 @@ - (dispatch_queue_t)methodQueue { dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ NSError *error = nil; + const BOOL managedSessionWasActive = + self.audioSessionManager.shouldManageSession && self.audioSessionManager.isActive; auto success = [self.audioSessionManager setActive:enabled error:&error]; if (!success) { @@ -140,7 +142,7 @@ - (dispatch_queue_t)methodQueue return; } - if (!enabled) { + if (!enabled && managedSessionWasActive) { if ([NSThread isMainThread]) { [self handleSessionDeactivation]; } else { 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 d46d2ff06..25530568b 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 @@ -8,6 +8,7 @@ typedef struct objc_object AVAudioFile; typedef struct objc_object AudioBufferList; typedef struct objc_object NativeAudioRecorder; typedef struct objc_object AVAudioFormat; +typedef enum AudioEngineInputNotification : long AudioEngineInputNotification; #endif // __OBJC__ #include @@ -75,7 +76,9 @@ class IOSAudioRecorder : public AudioRecorder { const std::shared_ptr &properties, const std::string &fileNameOverride = ""); Result reprepareForLiveInput(); - void handleInputConfigurationChange(); + void handleInputNotification(AudioEngineInputNotification notification); + void handleHardwareChange(); + void handleCaptureLost(); Result reprepareFileWriter( AVAudioFormat *inputFormat, int maxInputBufferLength); 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 26177044c..1836425c7 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 @@ -83,7 +83,8 @@ static void cleanupStartedRecorder( nativeRecorder_ = [[NativeAudioRecorder alloc] initWithReceiverBlock:receiverBlock voiceProcessingEnabled:options.iosVoiceProcessing]; - nativeRecorder_.onInputConfigurationChange = ^{ this->handleInputConfigurationChange(); }; + nativeRecorder_.onInputNotification = + ^(AudioEngineInputNotification notification) { this->handleInputNotification(notification); }; } void IOSAudioRecorder::runSideEffects(const AudioBufferList *inputBuffer, int numFrames) @@ -112,7 +113,19 @@ static void cleanupStartedRecorder( } } -void IOSAudioRecorder::handleInputConfigurationChange() +void IOSAudioRecorder::handleInputNotification(AudioEngineInputNotification notification) +{ + switch (notification) { + case AudioEngineInputNotificationHardwareChanged: + handleHardwareChange(); + return; + case AudioEngineInputNotificationCaptureLost: + handleCaptureLost(); + return; + } +} + +void IOSAudioRecorder::handleHardwareChange() { if (isIdle()) { return; @@ -123,7 +136,12 @@ static void cleanupStartedRecorder( return; } - if (!formatChanged) { + const bool outputNeedsReprepare = + (wantsFileOutput() && !fileOutputConfigured_.load(std::memory_order_acquire)) || + (wantsCallback() && !callbackOutputConfigured_.load(std::memory_order_acquire)) || + (wantsConnection() && !connectedConfigured_.load(std::memory_order_acquire)); + + if (!formatChanged && !outputNeedsReprepare) { if (state_.load(std::memory_order_acquire) == RecorderState::Recording) { [nativeRecorder_ setInputArmed:true]; } @@ -133,6 +151,30 @@ static void cleanupStartedRecorder( reprepareForLiveInput(); } +void IOSAudioRecorder::handleCaptureLost() +{ + if (isIdle()) { + return; + } + + [nativeRecorder_ setInputArmed:false]; + + std::scoped_lock lock(callbackMutex_, fileWriterMutex_, adapterNodeMutex_); + const bool shouldFinalizeFile = + fileOutputConfigured_.exchange(false, std::memory_order_acq_rel) && fileWriter_ != nullptr; + callbackOutputConfigured_.store(false, std::memory_order_release); + connectedConfigured_.store(false, std::memory_order_release); + + if (shouldFinalizeFile) { + auto closeResult = fileWriter_->closeFile(); + if (closeResult.is_err()) { + NSLog( + @"Error while finalizing recording segment after capture was lost: %s", + closeResult.unwrap_err().c_str()); + } + } +} + Result IOSAudioRecorder::reprepareForLiveInput() { if (isIdle()) { @@ -150,7 +192,10 @@ static void cleanupStartedRecorder( const bool shouldArmInput = state_.load(std::memory_order_acquire) == RecorderState::Recording; [nativeRecorder_ setInputArmed:false]; - if (usesFileOutput()) { + // Capture loss clears *Configured_ (so uses*() is false) but leaves user intent + // (*Enabled_) set. Re-prepare from wants* so a later HardwareChanged opens a new + // segment instead of resuming with nowhere to write. + if (wantsFileOutput()) { auto fileResult = reprepareFileWriter(inputFormat, maxInputBufferLength); if (fileResult.is_err()) { if (shouldArmInput) { @@ -160,7 +205,7 @@ static void cleanupStartedRecorder( } } - if (usesCallback()) { + if (wantsCallback()) { auto callbackResult = reprepareCallback(inputFormat, maxInputBufferLength); if (callbackResult.is_err()) { if (shouldArmInput) { @@ -170,7 +215,7 @@ static void cleanupStartedRecorder( } } - if (isConnected() && adapterNodeHandle_ != nullptr) { + if (wantsConnection() && adapterNodeHandle_ != nullptr) { reprepareAdapter(inputFormat, maxInputBufferLength); } @@ -262,7 +307,7 @@ static void cleanupStartedRecorder( { stop(); - nativeRecorder_.onInputConfigurationChange = nil; + nativeRecorder_.onInputNotification = nil; { std::scoped_lock lock(callbackMutex_, fileWriterMutex_, adapterNodeMutex_); @@ -653,8 +698,12 @@ static void cleanupStartedRecorder( return; } - [nativeRecorder_ resume]; + if (![nativeRecorder_ resume]) { + return; + } + state_.store(RecorderState::Recording, std::memory_order_release); + handleHardwareChange(); } /// @brief Checks if the recorder is currently recording. diff --git a/packages/react-native-audio-api/ios/audioapi/ios/core/NativeAudioPlayer.m b/packages/react-native-audio-api/ios/audioapi/ios/core/NativeAudioPlayer.m index d5292e417..b7df35061 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/core/NativeAudioPlayer.m +++ b/packages/react-native-audio-api/ios/audioapi/ios/core/NativeAudioPlayer.m @@ -29,7 +29,14 @@ - (bool)startPlaybackGraph:(AudioEngine *)audioEngine { [audioEngine stopIfNecessary]; [self attachSourceNodeIfNeeded:audioEngine]; - return [audioEngine startIfNecessary]; + + if (![audioEngine startIfNecessary]) { + [self detachSourceNodeIfAttached:audioEngine]; + [audioEngine stopIfPossible]; + return false; + } + + return true; } - (instancetype)initWithRenderAudio:(RenderAudioBlock)renderAudio 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 20f1f1c32..497675bc2 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 @@ -2,6 +2,7 @@ #import #import +#import typedef void (^AudioReceiverBlock)(const AudioBufferList *inputBuffer, int numFrames); @@ -13,7 +14,7 @@ typedef void (^AudioReceiverBlock)(const AudioBufferList *inputBuffer, int numFr @property (nonatomic, assign) int resolvedBufferSize; @property (atomic, assign) BOOL inputArmed; @property (nonatomic, assign) BOOL voiceProcessingEnabled; -@property (nonatomic, copy) void (^onInputConfigurationChange)(void); +@property (nonatomic, copy) void (^onInputNotification)(AudioEngineInputNotification); - (instancetype)initWithReceiverBlock:(AudioReceiverBlock)receiverBlock voiceProcessingEnabled:(BOOL)voiceProcessingEnabled; @@ -28,7 +29,8 @@ typedef void (^AudioReceiverBlock)(const AudioBufferList *inputBuffer, int numFr - (void)pause; -- (void)resume; +/// @return YES if the engine started. +- (BOOL)resume; - (void)cleanup; 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 89479aa8d..5cd9266f5 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 @@ -118,7 +118,7 @@ - (BOOL)start:(NSError **)error [audioEngine stopIfNecessary]; [audioEngine attachInputNodeWithReceiverBlock:self.receiverSinkBlock voiceProcessingEnabled:self.voiceProcessingEnabled - onInputConfigurationChange:self.onInputConfigurationChange]; + onInputNotification:self.onInputNotification]; if (![audioEngine startIfNecessary]) { [audioEngine detachInputNode]; @@ -167,18 +167,12 @@ - (void)pause [audioEngine pauseIfNecessary]; } -- (void)resume +- (BOOL)resume { AudioEngine *audioEngine = [AudioEngine sharedInstance]; assert(audioEngine != nil); - if ([audioEngine startIfNecessary]) { - if (self.onInputConfigurationChange != nil) { - self.onInputConfigurationChange(); - } else { - self.inputArmed = YES; - } - } + return [audioEngine startIfNecessary]; } - (void)cleanup @@ -188,7 +182,7 @@ - (void)cleanup self.resolvedBufferSize = 0; self.receiverBlock = nil; self.receiverSinkBlock = nil; - self.onInputConfigurationChange = nil; + self.onInputNotification = nil; } @end diff --git a/packages/react-native-audio-api/ios/audioapi/ios/core/utils/IOSFileWriter.mm b/packages/react-native-audio-api/ios/audioapi/ios/core/utils/IOSFileWriter.mm index 6aa5149e9..e3dc692a1 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/core/utils/IOSFileWriter.mm +++ b/packages/react-native-audio-api/ios/audioapi/ios/core/utils/IOSFileWriter.mm @@ -187,7 +187,8 @@ { @autoreleasepool { NSError *error; - std::string filePath = [[fileURL_ path] UTF8String]; + const char *pathCString = [[fileURL_ path] UTF8String]; + std::string filePath = pathCString != nullptr ? pathCString : ""; if (!isFileOpen() || audioFile_ == nil) { return CloseFileResult::Err("file is not open: " + filePath); diff --git a/packages/react-native-audio-api/ios/audioapi/ios/core/utils/IOSRotatingFileWriter.mm b/packages/react-native-audio-api/ios/audioapi/ios/core/utils/IOSRotatingFileWriter.mm index e18d6a377..6e173c361 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/core/utils/IOSRotatingFileWriter.mm +++ b/packages/react-native-audio-api/ios/audioapi/ios/core/utils/IOSRotatingFileWriter.mm @@ -58,13 +58,15 @@ return openInnerWriter(); } - rotateFiles(); - - if (currentWriter_ == nullptr) { - return OpenFileResult::Err("Failed to reopen file for writing after input format change"); + if (currentWriter_->isFileOpen()) { + rotateFiles(); + if (currentWriter_ == nullptr) { + return OpenFileResult::Err("Failed to reopen file for writing after input format change"); + } + return OpenFileResult::Ok(currentWriter_->getFilePath()); } - return OpenFileResult::Ok(currentWriter_->getFilePath()); + return openInnerWriter(); } void IOSRotatingFileWriter::writeAudioData(AudioDataType data, int numFrames) 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 d4039332c..a14a82358 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 @@ -12,6 +12,19 @@ typedef NS_ENUM(NSInteger, AudioEngineState) { AudioEngineStateInterrupted }; +typedef NS_ENUM(NSInteger, AudioEngineInputNotification) { + AudioEngineInputNotificationHardwareChanged = 0, + AudioEngineInputNotificationCaptureLost +}; + +/// Result of `onInterruptionEnd:`. Distinguishes a no-op from a failed resume that stays Interrupted. +typedef NS_ENUM(NSInteger, AudioEngineInterruptionEndOutcome) { + AudioEngineInterruptionEndOutcomeNoOp = 0, + AudioEngineInterruptionEndOutcomeRunning, + AudioEngineInterruptionEndOutcomePaused, + AudioEngineInterruptionEndOutcomeStillInterrupted +}; + @interface AudioEngine : NSObject @property (nonatomic, assign) AudioEngineState state; @@ -35,17 +48,20 @@ typedef NS_ENUM(NSInteger, AudioEngineState) { - (void)attachInputNodeWithReceiverBlock:(AVAudioSinkNodeReceiverBlock)receiverBlock voiceProcessingEnabled:(BOOL)voiceProcessingEnabled - onInputConfigurationChange:(void (^)(void))onInputConfigurationChange; + onInputNotification: + (void (^)(AudioEngineInputNotification))onInputNotification; - (void)detachInputNode; - (AVAudioFormat *)getLiveInputFormat; -- (void)onInterruptionBegin; -- (void)onInterruptionEnd:(bool)shouldResume; +/// @return true if the engine transitioned from Running to Interrupted. +- (bool)onInterruptionBegin; +- (AudioEngineInterruptionEndOutcome)onInterruptionEnd:(bool)shouldResume; - (void)onSessionDeactivated; - (void)markSessionDeactivationInvalidatedGraph; - (AudioEngineState)getState; - (bool)isEngineRunning; +- (bool)hasInputRegistration; - (bool)startIfNecessary; - (void)pauseIfNecessary; 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 e3c73fca0..892321956 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 @@ -18,7 +18,7 @@ @interface AudioEngineInputRegistration : NSObject @property (nonatomic, copy) AVAudioSinkNodeReceiverBlock receiverBlock; @property (nonatomic, assign) BOOL voiceProcessingEnabled; -@property (nonatomic, copy) void (^onInputConfigurationChange)(void); +@property (nonatomic, copy) void (^onInputNotification)(AudioEngineInputNotification); @end @@ -49,7 +49,7 @@ - (void)materializeTrackedNodesIfNeeded; - (AVAudioFormat *)liveInputFormat; - (void)resetInputNode; - (void)rebuildAudioEngineAndResumeIfNeeded; -- (void)notifyConfigurationChanges; +- (void)notifyInput:(AudioEngineInputNotification)notification; @end @@ -338,7 +338,7 @@ - (void)detachSourceNodeWithId:(NSString *)sourceNodeId - (void)attachInputNodeWithReceiverBlock:(AVAudioSinkNodeReceiverBlock)receiverBlock voiceProcessingEnabled:(BOOL)voiceProcessingEnabled - onInputConfigurationChange:(void (^)(void))onInputConfigurationChange + onInputNotification:(void (^)(AudioEngineInputNotification))onInputNotification { std::scoped_lock lock(_engineLock); [self createAudioEngineIfNeeded]; @@ -350,7 +350,7 @@ - (void)attachInputNodeWithReceiverBlock:(AVAudioSinkNodeReceiverBlock)receiverB AudioEngineInputRegistration *registration = [[AudioEngineInputRegistration alloc] init]; registration.receiverBlock = receiverBlock; registration.voiceProcessingEnabled = voiceProcessingEnabled; - registration.onInputConfigurationChange = onInputConfigurationChange; + registration.onInputNotification = onInputNotification; self.inputRegistration = registration; [self materializeInputNodeIfNeeded]; @@ -385,14 +385,15 @@ - (AVAudioFormat *)getLiveInputFormat return [self liveInputFormat]; } -- (void)onInterruptionBegin +- (bool)onInterruptionBegin { std::scoped_lock lock(_engineLock); if (self.state != AudioEngineState::AudioEngineStateRunning) { - return; + return false; } self.state = AudioEngineState::AudioEngineStateInterrupted; + return true; } - (void)onSessionDeactivated @@ -429,22 +430,37 @@ - (void)markSessionDeactivationInvalidatedGraph self.sessionDeactivationInvalidatedGraph = YES; } -- (void)onInterruptionEnd:(bool)shouldResume +- (AudioEngineInterruptionEndOutcome)onInterruptionEnd:(bool)shouldResume { std::scoped_lock lock(_engineLock); NSError *error = nil; if (self.state != AudioEngineState::AudioEngineStateInterrupted) { - return; + return AudioEngineInterruptionEndOutcomeNoOp; + } + + if (!shouldResume && self.inputRegistration == nil) { + [self stopEngine]; + [self rebuildAudioEngine]; + self.state = AudioEngineState::AudioEngineStatePaused; + [self notifyInput:AudioEngineInputNotificationHardwareChanged]; + return AudioEngineInterruptionEndOutcomePaused; + } + + if (![self.sessionManager ensureActive:true error:&error]) { + NSLog(@"Error while activating audio session after interruption: %@", [error debugDescription]); + return AudioEngineInterruptionEndOutcomeStillInterrupted; } [self stopEngine]; [self rebuildAudioEngine]; - if (!shouldResume) { - self.state = AudioEngineState::AudioEngineStatePaused; - [self notifyConfigurationChanges]; - return; + if (self.inputRegistration != nil && self.inputNode == nil) { + NSLog( + @"Error while materializing the audio input node after interruption: missing live input format"); + self.state = AudioEngineState::AudioEngineStateInterrupted; + [self notifyInput:AudioEngineInputNotificationCaptureLost]; + return AudioEngineInterruptionEndOutcomeStillInterrupted; } [self.audioEngine prepare]; @@ -454,20 +470,21 @@ - (void)onInterruptionEnd:(bool)shouldResume NSLog( @"Error while restarting the audio engine after interruption: %@", [error debugDescription]); - self.state = AudioEngineState::AudioEngineStateIdle; - [self notifyConfigurationChanges]; - return; + self.state = AudioEngineState::AudioEngineStateInterrupted; + [self notifyInput:AudioEngineInputNotificationCaptureLost]; + return AudioEngineInterruptionEndOutcomeStillInterrupted; } self.state = AudioEngineState::AudioEngineStateRunning; self.sessionDeactivationInvalidatedGraph = false; - [self notifyConfigurationChanges]; + [self notifyInput:AudioEngineInputNotificationHardwareChanged]; + return AudioEngineInterruptionEndOutcomeRunning; } -- (void)notifyConfigurationChanges +- (void)notifyInput:(AudioEngineInputNotification)notification { - if (self.inputRegistration != nil && self.inputRegistration.onInputConfigurationChange != nil) { - self.inputRegistration.onInputConfigurationChange(); + if (self.inputRegistration != nil && self.inputRegistration.onInputNotification != nil) { + self.inputRegistration.onInputNotification(notification); } } @@ -483,6 +500,12 @@ - (bool)isEngineRunning return self.audioEngine != nil && [self.audioEngine isRunning]; } +- (bool)hasInputRegistration +{ + std::scoped_lock lock(_engineLock); + return self.inputRegistration != nil; +} + - (void)rebuildAudioEngineAndResumeIfNeeded { if (_isRebuildingAudioEngine) { @@ -498,11 +521,14 @@ - (void)rebuildAudioEngineAndResumeIfNeeded [self rebuildAudioEngine]; self.sessionDeactivationInvalidatedGraph = false; + BOOL didStartEngine = NO; if (self.state == AudioEngineState::AudioEngineStateRunning) { - [self startEngine]; + didStartEngine = [self startEngine]; } - [self notifyConfigurationChanges]; + if (didStartEngine) { + [self notifyInput:AudioEngineInputNotificationHardwareChanged]; + } _isRebuildingAudioEngine = NO; } diff --git a/packages/react-native-audio-api/ios/audioapi/ios/system/SystemNotificationManager.h b/packages/react-native-audio-api/ios/audioapi/ios/system/SystemNotificationManager.h index 5ddf0d4e2..32641d5ed 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/system/SystemNotificationManager.h +++ b/packages/react-native-audio-api/ios/audioapi/ios/system/SystemNotificationManager.h @@ -14,6 +14,9 @@ @property (nonatomic, strong) NSTimer *hintPollingTimer; @property (nonatomic, assign) bool hadConfigurationChange; @property (nonatomic, assign) bool audioInterruptionsObserved; +/// Set when AVAudioSession posts InterruptionEnded (or the secondary-audio equivalent). +/// Thanks to it it can be decided, whether interruption end retry is necessary. +@property (nonatomic, assign) bool interruptionEndedDelivered; @property (nonatomic, assign) bool volumeChangesObserved; @property (nonatomic, assign) bool wasOtherAudioPlaying; diff --git a/packages/react-native-audio-api/ios/audioapi/ios/system/SystemNotificationManager.mm b/packages/react-native-audio-api/ios/audioapi/ios/system/SystemNotificationManager.mm index 383ee14f4..f4192bf08 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/system/SystemNotificationManager.mm +++ b/packages/react-native-audio-api/ios/audioapi/ios/system/SystemNotificationManager.mm @@ -1,3 +1,5 @@ +#import + #import #import #import @@ -88,6 +90,14 @@ - (void)configureNotifications selector:@selector(handleInterruption:) name:AVAudioSessionInterruptionNotification object:nil]; + [self.notificationCenter addObserver:self + selector:@selector(handleWillEnterForeground:) + name:UIApplicationWillEnterForegroundNotification + object:nil]; + [self.notificationCenter addObserver:self + selector:@selector(handleDidBecomeActive:) + name:UIApplicationDidBecomeActiveNotification + object:nil]; } - (void)observeValueForKeyPath:(NSString *)keyPath @@ -108,6 +118,60 @@ - (void)observeValueForKeyPath:(NSString *)keyPath } } +- (void)handleWillEnterForeground:(NSNotification *)notification +{ + [self retryInterruptedRecordingIfNeeded]; +} + +- (void)handleDidBecomeActive:(NSNotification *)notification +{ + [self retryInterruptedRecordingIfNeeded]; +} + +- (void)emitInterruptionBeganIfAccepted:(bool)accepted +{ + if (!self.audioInterruptionsObserved || !accepted) { + return; + } + + [self.audioAPIModule invokeHandlerWithEventName:audioapi::AudioEvent::INTERRUPTION + payload:audioapi::InterruptionPayload{ + .type = "began", .shouldResume = false}]; +} + +- (void)emitInterruptionEndedIfTransitioned:(AudioEngineInterruptionEndOutcome)outcome + shouldResume:(bool)shouldResume +{ + if (!self.audioInterruptionsObserved) { + return; + } + + if (outcome == AudioEngineInterruptionEndOutcomeRunning || + outcome == AudioEngineInterruptionEndOutcomePaused) { + [self.audioAPIModule + invokeHandlerWithEventName:audioapi::AudioEvent::INTERRUPTION + payload:audioapi::InterruptionPayload{ + .type = "ended", .shouldResume = shouldResume}]; + } +} + +- (void)performInterruptionEndOnEngine:(AudioEngine *)audioEngine shouldResume:(bool)shouldResume +{ + dispatch_async(dispatch_get_main_queue(), ^{ + AudioEngineInterruptionEndOutcome outcome = [audioEngine onInterruptionEnd:shouldResume]; + [self emitInterruptionEndedIfTransitioned:outcome shouldResume:shouldResume]; + }); +} + +- (void)retryInterruptedRecordingIfNeeded +{ + AudioEngine *audioEngine = self.audioAPIModule.audioEngine; + + if (self.interruptionEndedDelivered && [audioEngine getState] == AudioEngineStateInterrupted) { + [self performInterruptionEndOnEngine:audioEngine shouldResume:true]; + } +} + - (void)handleInterruption:(NSNotification *)notification { AudioEngine *audioEngine = self.audioAPIModule.audioEngine; @@ -119,30 +183,19 @@ - (void)handleInterruption:(NSNotification *)notification [notification.userInfo[AVAudioSessionInterruptionOptionKey] integerValue]; if (interruptionType == AVAudioSessionInterruptionTypeBegan) { + self.interruptionEndedDelivered = false; dispatch_async(dispatch_get_main_queue(), ^{ - [audioEngine onInterruptionBegin]; + bool accepted = [audioEngine onInterruptionBegin]; [sessionManager markInactive]; + [self emitInterruptionBeganIfAccepted:accepted]; }); - - if (self.audioInterruptionsObserved) { - [self.audioAPIModule invokeHandlerWithEventName:audioapi::AudioEvent::INTERRUPTION - payload:audioapi::InterruptionPayload{ - .type = "began", .shouldResume = false}]; - } - return; } bool shouldResume = interruptionOption == AVAudioSessionInterruptionOptionShouldResume; - if (self.audioInterruptionsObserved) { - [self.audioAPIModule - invokeHandlerWithEventName:audioapi::AudioEvent::INTERRUPTION - payload:audioapi::InterruptionPayload{ - .type = "ended", .shouldResume = shouldResume}]; - } else { - dispatch_async(dispatch_get_main_queue(), ^{ [audioEngine onInterruptionEnd:shouldResume]; }); - } + self.interruptionEndedDelivered = true; + [self performInterruptionEndOnEngine:audioEngine shouldResume:shouldResume]; } - (void)handleSecondaryAudio:(NSNotification *)notification @@ -153,29 +206,19 @@ - (void)handleSecondaryAudio:(NSNotification *)notification [notification.userInfo[AVAudioSessionSilenceSecondaryAudioHintTypeKey] integerValue]; if (secondaryAudioType == AVAudioSessionSilenceSecondaryAudioHintTypeBegin) { + self.interruptionEndedDelivered = false; dispatch_async(dispatch_get_main_queue(), ^{ [sessionManager markInactive]; - [audioEngine onInterruptionBegin]; + bool accepted = [audioEngine onInterruptionBegin]; + [self emitInterruptionBeganIfAccepted:accepted]; }); - - if (self.audioInterruptionsObserved) { - [self.audioAPIModule invokeHandlerWithEventName:audioapi::AudioEvent::INTERRUPTION - payload:audioapi::InterruptionPayload{ - .type = "began", .shouldResume = false}]; - } return; } bool shouldResume = secondaryAudioType == AVAudioSessionSilenceSecondaryAudioHintTypeEnd; - if (self.audioInterruptionsObserved) { - [self.audioAPIModule - invokeHandlerWithEventName:audioapi::AudioEvent::INTERRUPTION - payload:audioapi::InterruptionPayload{ - .type = "ended", .shouldResume = shouldResume}]; - } else { - dispatch_async(dispatch_get_main_queue(), ^{ [audioEngine onInterruptionEnd:shouldResume]; }); - } + self.interruptionEndedDelivered = true; + [self performInterruptionEndOnEngine:audioEngine shouldResume:shouldResume]; } - (void)handleRouteChange:(NSNotification *)notification @@ -294,26 +337,18 @@ - (void)checkSecondaryAudioHint self.wasOtherAudioPlaying = shouldSilence; if (shouldSilence) { + self.interruptionEndedDelivered = false; dispatch_async(dispatch_get_main_queue(), ^{ [sessionManager markInactive]; - [audioEngine onInterruptionBegin]; + bool accepted = [audioEngine onInterruptionBegin]; + [self emitInterruptionBeganIfAccepted:accepted]; }); - if (self.audioInterruptionsObserved) { - [self.audioAPIModule invokeHandlerWithEventName:audioapi::AudioEvent::INTERRUPTION - payload:audioapi::InterruptionPayload{ - .type = "began", .shouldResume = false}]; - } return; } - if (self.audioInterruptionsObserved) { - [self.audioAPIModule invokeHandlerWithEventName:audioapi::AudioEvent::INTERRUPTION - payload:audioapi::InterruptionPayload{ - .type = "ended", .shouldResume = true}]; - } else { - dispatch_async(dispatch_get_main_queue(), ^{ [audioEngine onInterruptionEnd:true]; }); - } + self.interruptionEndedDelivered = true; + [self performInterruptionEndOnEngine:audioEngine shouldResume:true]; } @end