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 9953c6a8b..461eb19d6 100644 --- a/apps/common-app/src/demos/Record/Record.tsx +++ b/apps/common-app/src/demos/Record/Record.tsx @@ -32,11 +32,13 @@ const Record: FC = () => { : RecordingState.Recording; }); 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; @@ -88,7 +90,7 @@ const Record: FC = () => { AudioManager.setAudioSessionOptions({ iosCategory: 'playAndRecord', iosMode: 'default', - iosOptions: ['defaultToSpeaker', 'allowBluetoothA2DP'], + iosOptions: ['defaultToSpeaker', 'allowBluetoothA2DP', 'mixWithOthers'], }); try { @@ -107,6 +109,7 @@ const Record: FC = () => { setupNotification(false); if (result.status === 'success') { + setIsInterrupted(false); setState(RecordingState.Recording); return; } @@ -119,6 +122,7 @@ const Record: FC = () => { const onPauseRecording = useCallback(() => { Recorder.pause(); updateNotification(true); + setIsInterrupted(false); setState(RecordingState.Paused); }, []); @@ -152,6 +156,7 @@ const Record: FC = () => { const onStopRecording = useCallback(async () => { const info = await Recorder.stop(); RecordingNotificationManager.hide(); + setIsInterrupted(false); setState(RecordingState.Loading); if (info.status !== 'success') { @@ -263,6 +268,10 @@ const Record: FC = () => { ] ); + useEffect(() => { + stateRef.current = state; + }, [state]); + useEffect(() => { (async () => { const recordingPermissionStatus = @@ -283,6 +292,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', @@ -357,7 +391,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 84775ba18..50bad45f4 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, @@ -22,6 +19,7 @@ import { withTiming, } from 'react-native-reanimated'; +import { Spacer } from '../../components'; import { audioRecorder as Recorder } from '../../singletons'; import constants from './constants'; import TimeStream from './TimeStream'; @@ -31,12 +29,21 @@ const { width: windowWidth } = Dimensions.get('window'); const defaultNumBars = Math.floor(windowWidth / constants.barStep); +const historyNumBars = Math.floor( + windowWidth / (constants.historyBarWidth + constants.historyBarGap) +); + function getInitialWaveform() { return new Array(defaultNumBars * 2).fill(-1); } +function getInitialHistory() { + return new Array(historyNumBars * 10).fill(-1); +} + interface RecordingVisualizationProps { state: RecordingState; + isInterrupted: boolean; } interface DrawDefaultWaveformParams { @@ -48,10 +55,25 @@ interface DrawDefaultWaveformParams { numBars: number; } +interface DrawHistoryWaveformParams { + normalized: number; + lifetimeCanvasHeight: number; + history: number[]; + historyHead: SharedValue; + durationMS: SharedValue; + historyMidpointMS: SharedValue; +} + 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; @@ -90,19 +112,125 @@ function drawDefaultWaveform(params: DrawDefaultWaveformParams) { return barHeights; } +function drawHistoryWaveform(params: DrawHistoryWaveformParams) { + 'worklet'; + + const { + history, + normalized, + lifetimeCanvasHeight, + historyHead, + durationMS, + historyMidpointMS, + } = params; + + if (lifetimeCanvasHeight <= 0) { + return history; + } + + const value = normalized * lifetimeCanvasHeight * 0.8; + history[historyHead.value] = value; + historyHead.value += 1; + + // downsample if needed + if (historyHead.value >= history.length) { + const halfLength = history.length / 2; + + for (let i = 0; i < halfLength; i++) { + history[i] = Math.max(history[2 * i], history[2 * i + 1]); + } + + historyHead.value = halfLength; + historyMidpointMS.value = durationMS.value; + } + + 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(); const { size } = useCanvasSize(canvasRef); + const { size: lifetimeSize } = useCanvasSize(lifetimeCanvasRef); const barHeights = useSharedValue(getInitialWaveform()); + const history = useSharedValue(getInitialHistory()); + const historyHead = useSharedValue(0); + const historyMidpointMS = useSharedValue(0); + const historyRenderer = useSharedValue( + new Array(historyNumBars).fill(-1) + ); + const translateX = useSharedValue(0); const lastIndex = useSharedValue(-1); const durationMS = useSharedValue(Recorder.getCurrentDuration() * 1000); const canvasHeightSV = useSharedValue(0); + const lifetimeCanvasHeightSV = useSharedValue(0); const numBarsSV = useSharedValue(0); + const isInterruptedSV = useSharedValue(false); const stateRef = useRef(state); const workletContextRef = useRef(null); @@ -142,6 +270,66 @@ const RecordingVisualization: React.FC = ({ return path; }, [size, numBars]); + const historyWaveformPath = useDerivedValue(() => { + const path = Skia.PathBuilder.Make().build(); + const canvasHeight = lifetimeSize.height; + const values = historyRenderer.value; + + if (historyHead.value < historyNumBars) { + // render as it is + for (let i = 0; i < historyHead.value; i++) { + values[i] = history.value[i]; + + if (values[i] < 0) { + continue; + } + + const x = + i * (constants.historyBarWidth + constants.historyBarGap) + + constants.historyBarWidth / 2; + const y1 = (canvasHeight - values[i]) / 2; + const y2 = (canvasHeight + values[i]) / 2; + + path.moveTo(x, y1); + path.lineTo(x, y2); + } + + return path; + } + + const ratio = historyHead.value / historyNumBars; + + // render rest + for (let i = 0; i < historyNumBars; i++) { + let maxVal = -1; + const startIndex = Math.floor(i * ratio); + const endIndex = Math.floor((i + 1) * ratio); + + for (let j = startIndex; j < endIndex; j++) { + if (history.value[j] > maxVal) { + maxVal = history.value[j]; + } + } + + values[i] = maxVal; + + if (values[i] < 0) { + continue; + } + + const x = + i * (constants.historyBarWidth + constants.historyBarGap) + + constants.historyBarWidth / 2; + const y1 = (canvasHeight - values[i]) / 2; + const y2 = (canvasHeight + values[i]) / 2; + + path.moveTo(x, y1); + path.lineTo(x, y2); + } + + return path; + }, [lifetimeSize]); + useEffect(() => { stateRef.current = state; }, [state]); @@ -149,7 +337,18 @@ const RecordingVisualization: React.FC = ({ useEffect(() => { numBarsSV.value = numBars; canvasHeightSV.value = size.height; - }, [numBars, size.height, numBarsSV, canvasHeightSV]); + lifetimeCanvasHeightSV.value = lifetimeSize.height; + isInterruptedSV.value = isInterrupted; + }, [ + numBars, + size.height, + lifetimeSize.height, + isInterrupted, + numBarsSV, + canvasHeightSV, + lifetimeCanvasHeightSV, + isInterruptedSV, + ]); useEffect(() => { if (numBars <= 0) { @@ -175,14 +374,14 @@ const RecordingVisualization: React.FC = ({ 'worklet'; const canvasHeight = canvasHeightSV.value; + 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++) { @@ -192,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)); @@ -210,6 +408,19 @@ const RecordingVisualization: React.FC = ({ numBars: activeNumBars, }) as T; }); + + history.modify((hist: T) => { + 'worklet'; + + return drawHistoryWaveform({ + normalized, + lifetimeCanvasHeight, + history: hist, + historyHead, + durationMS, + historyMidpointMS, + }) as T; + }); }, { domain: 'time-domain', @@ -280,22 +491,10 @@ const RecordingVisualization: React.FC = ({ }, [state]); useEffect(() => { - if (state === RecordingState.Recording) { - if (size.width === 0) { - return; - } - - 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); @@ -316,10 +515,27 @@ const RecordingVisualization: React.FC = ({ cancelAnimation(translateX); translateX.value = 0; barHeights.value = Array(numBars).fill(-1); + historyRenderer.value = Array(historyNumBars).fill(-1); + history.value = Array(historyNumBars * 10).fill(-1); + historyHead.value = 0; + historyMidpointMS.value = 0; durationMS.value = 0; lastIndex.value = -1; } - }, [state, size, translateX, barHeights, numBars, durationMS, lastIndex]); + }, [ + state, + isInterrupted, + size, + translateX, + barHeights, + numBars, + durationMS, + lastIndex, + history, + historyHead, + historyMidpointMS, + historyRenderer, + ]); const transformPath = useDerivedValue(() => [ { @@ -345,9 +561,24 @@ const RecordingVisualization: React.FC = ({ + + + + + + + + ); }; @@ -367,4 +598,11 @@ const styles = StyleSheet.create({ height: 20, marginTop: 8, }, + lifetimeContainer: { + marginTop: 16, + height: 75, + width: '100%', + backgroundColor: 'rgba(0, 0, 0, 0.15)', + flexDirection: 'column', + }, }); diff --git a/apps/common-app/src/demos/Record/TimeStream.tsx b/apps/common-app/src/demos/Record/TimeStream.tsx index ac3ff709a..0119be791 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,57 @@ function generateInitialTimestamps(baseSecond: number) { return timestamps; } -const TimeStream: React.FC = ({ isRecording, durationMS }) => { +const TimeStream: React.FC = ({ + isRecording, + isFrozen = false, + durationMS, +}) => { const [timestamps, setTimestamps] = useState(() => generateInitialTimestamps(Math.floor(durationMS.value / 1000)) ); const intervalRef = useRef | null>(null); + const wasRecordingRef = useRef(false); + const isAnimating = isRecording && !isFrozen; useEffect(() => { - if (isRecording) { - setTimestamps(generateInitialTimestamps(Math.floor(durationMS.value / 1000))); - - 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); - } else { + const startedRecording = isRecording && !wasRecordingRef.current; + wasRecordingRef.current = isRecording; + + if (!isAnimating) { if (intervalRef.current) { clearInterval(intervalRef.current); + intervalRef.current = null; } + return; + } + + if (startedRecording) { + setTimestamps( + generateInitialTimestamps(Math.floor(durationMS.value / 1000)) + ); } + 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 +93,8 @@ const TimeStream: React.FC = ({ isRecording, durationMS }) => { key={seconds} spawnSeconds={seconds} durationMS={durationMS} - isRecording={isRecording} + isAnimating={isAnimating} + isFrozen={isRecording && isFrozen} /> ))} @@ -90,7 +108,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 +117,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 +144,7 @@ const Timestamp: React.FC = ({ translateX.value = startX; - if (!isRecording) { + if (!isAnimating) { cancelAnimation(translateX); return; } @@ -128,7 +153,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/common-app/src/demos/Record/constants.tsx b/apps/common-app/src/demos/Record/constants.tsx index ba3b26063..4bd332c91 100644 --- a/apps/common-app/src/demos/Record/constants.tsx +++ b/apps/common-app/src/demos/Record/constants.tsx @@ -9,6 +9,8 @@ const constants = { barGap: 2, minDb: -40, maxDb: 0, + historyBarWidth: 2, + historyBarGap: 2, get barStep() { return this.barWidth + this.barGap; }, diff --git a/apps/common-app/src/singletons/index.ts b/apps/common-app/src/singletons/index.ts index 191a5d20a..2e31ffe4f 100644 --- a/apps/common-app/src/singletons/index.ts +++ b/apps/common-app/src/singletons/index.ts @@ -3,5 +3,7 @@ import { AudioContext, AudioRecorder } from 'react-native-audio-api'; export const audioContext = new AudioContext(); export const audioRecorder = new AudioRecorder({ androidInputPreset: 'voiceCommunication', - iosVoiceProcessing: true, + // FIXME: Should be true for iOS echo cancellation; currently fails with the + // default play-and-record session used by the recording demos. + iosVoiceProcessing: false, }); 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 f9e29f45f..0170a0a71 100644 --- a/apps/fabric-example/ios/FabricExampleTests/AudioEngineTests.mm +++ b/apps/fabric-example/ios/FabricExampleTests/AudioEngineTests.mm @@ -423,7 +423,7 @@ - (void)testDetachSourceNodeKeepsGraphNeedsRebuildWhenInputRemains { [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] voiceProcessingEnabled:NO - onInputConfigurationChange:nil]; + onInputNotification:nil]; self.audioEngine.graphNeedsRebuild = YES; [self.audioEngine detachSourceNodeWithId:sourceNodeId]; @@ -438,7 +438,7 @@ - (void)testAttachInputNodeStoresAndConnectsInput { [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] voiceProcessingEnabled:NO - onInputConfigurationChange:nil]; + onInputNotification:nil]; AVAudioSinkNode *inputNode = self.audioEngine.inputNode; XCTAssertNotNil(inputNode); @@ -460,7 +460,7 @@ - (void)testAttachInputNodeDefersConnectionUntilLiveInputFormatIsAvailable { [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] voiceProcessingEnabled:NO - onInputConfigurationChange:nil]; + onInputNotification:nil]; XCTAssertNil(self.audioEngine.inputNode); XCTAssertEqual(fakeEngine.attachNodeCallCount, 0); @@ -490,7 +490,7 @@ - (void)testDetachInputNodeClearsGraphOnlyWhenNoSourcesRemain { [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] voiceProcessingEnabled:NO - onInputConfigurationChange:nil]; + onInputNotification:nil]; self.audioEngine.graphNeedsRebuild = YES; [self.audioEngine detachInputNode]; @@ -502,7 +502,7 @@ - (void)testDetachInputNodeClearsGraphOnlyWhenNoSourcesRemain { [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] voiceProcessingEnabled:NO - onInputConfigurationChange:nil]; + onInputNotification:nil]; self.audioEngine.graphNeedsRebuild = YES; [self.audioEngine detachInputNode]; @@ -518,7 +518,7 @@ - (void)testDetachInputNodePreservesSessionDeactivationInvalidation { [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] voiceProcessingEnabled:NO - onInputConfigurationChange:nil]; + onInputNotification:nil]; [self.audioEngine onSessionDeactivated]; [self.audioEngine detachInputNode]; @@ -529,15 +529,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); } @@ -589,6 +589,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; @@ -607,7 +619,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); @@ -621,7 +634,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); @@ -638,7 +652,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); @@ -648,7 +663,7 @@ - (void)testOnInterruptionEndWithResumeRestartsEngine { XCTAssertEqual(self.audioEngine.createdFakeEngines.count, 2UL); } -- (void)testOnInterruptionEndWithResumeFailureEndsIdle { +- (void)testOnInterruptionEndWithResumeFailureStaysInterrupted { [self attachSourceNodeToAudioEngine]; self.audioEngine.state = AudioEngineStateInterrupted; @@ -657,9 +672,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 { @@ -743,7 +777,7 @@ - (void)testStartIfNecessaryRebuildsWhenGraphNeedsRebuild { [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] voiceProcessingEnabled:NO - onInputConfigurationChange:nil]; + onInputNotification:nil]; FakeAudioEngine *oldEngine = self.audioEngine.currentFakeAudioEngine; oldEngine.fakeRunning = YES; @@ -763,7 +797,7 @@ - (void)testStartIfNecessaryRebuildsWhenGraphNeedsRebuild { [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] voiceProcessingEnabled:NO - onInputConfigurationChange:nil]; + onInputNotification:nil]; AVAudioSinkNode *recoveredInputNode = self.audioEngine.inputNode; XCTAssertTrue([self.audioEngine startIfNecessary]); @@ -787,7 +821,7 @@ - (void)testStartIfNecessaryRebuildsInputNodeWithFreshInstance { [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] voiceProcessingEnabled:NO - onInputConfigurationChange:nil]; + onInputNotification:nil]; FakeAudioEngine *oldEngine = self.audioEngine.currentFakeAudioEngine; AVAudioSinkNode *oldInputNode = self.audioEngine.inputNode; AVAudioFormat *replacementInputFormat = @@ -961,10 +995,12 @@ - (void)testRestartAudioEngineStopsAndRestartsWhenStateRunning { [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] voiceProcessingEnabled:NO - onInputConfigurationChange:^{ - callbackRan = YES; - formatSeenDuringRebuild = [self.audioEngine getLiveInputFormat]; - }]; + onInputNotification:^( + __unused AudioEngineInputNotification notification) { + callbackRan = YES; + formatSeenDuringRebuild = + [self.audioEngine getLiveInputFormat]; + }]; self.audioEngine.state = AudioEngineStateRunning; self.audioEngine.currentFakeAudioEngine.fakeRunning = YES; @@ -1043,7 +1079,7 @@ - (void)testConcurrentRecordAndPlayPathsDoNotCrash { [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] voiceProcessingEnabled:NO - onInputConfigurationChange:nil]; + onInputNotification:nil]; [self.audioEngine startIfNecessary]; dispatch_group_leave(group); }); @@ -1092,7 +1128,7 @@ - (void)testConcurrentLiveInputFormatReadAndRestartDoesNotCrash { [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] voiceProcessingEnabled:NO - onInputConfigurationChange:nil]; + onInputNotification:nil]; self.audioEngine.state = AudioEngineStateRunning; self.audioEngine.currentFakeAudioEngine.fakeRunning = YES; 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 9283624b8..76c47f83a 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/packages/audiodocs/docs/fundamentals/best-practices.mdx b/packages/audiodocs/docs/fundamentals/best-practices.mdx index b2e481785..fa4b1799d 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`](../core/base-audio-context.mdx#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()`](../system/audio-manager.mdx) 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**](../inputs/audio-recorder.mdx) Management diff --git a/packages/audiodocs/docs/other/audio-api-plugin.mdx b/packages/audiodocs/docs/other/audio-api-plugin.mdx index de6754127..879541d20 100644 --- a/packages/audiodocs/docs/other/audio-api-plugin.mdx +++ b/packages/audiodocs/docs/other/audio-api-plugin.mdx @@ -85,7 +85,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 b9d3964c7..9c4c97f5a 100644 --- a/packages/audiodocs/docs/system/audio-manager.mdx +++ b/packages/audiodocs/docs/system/audio-manager.mdx @@ -44,18 +44,18 @@ function App() { ## Methods -### `setAudioSessionOptions` +### `setAudioSessionOptions` {#setaudiosessionoptions} + +| Parameter | Type | Description | +| :---: | :---: | :---- | +| options | [`SessionOptions`](./audio-manager.mdx#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`](./audio-manager.mdx#sessionoptions) | Options to be set for [AVAudioSession](https://developer.apple.com/documentation/avfaudio/avaudiosession?language=objc#Configuring-standard-audio-behaviors). | - #### Returns `undefined`. ### `setAudioSessionActivity` {#setaudiosessionactivity} @@ -181,6 +181,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 @@ -197,6 +221,7 @@ type AudioFocusType =
Type definitions + ```typescript type IOSCategory = | 'ambient' @@ -242,11 +267,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 {} @@ -311,15 +346,18 @@ interface AudioEventSubscription {
Type definitions + ```typescript type PermissionStatus = 'Undetermined' | 'Denied' | 'Granted'; ``` +
### `AudioDeviceInfo`
Type definitions + ```typescript export interface AudioDeviceInfo { id: string; // unique device identifier @@ -327,12 +365,14 @@ export interface AudioDeviceInfo { category: string; // device category (e.g. "Built-In Microphone", "Bluetooth") } ``` +
### `AudioDevicesInfo`
Type definitions + ```typescript export type AudioDeviceList = AudioDeviceInfo[]; @@ -343,4 +383,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 d4c527d80..05a763ece 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/AudioAPIModule.mm +++ b/packages/react-native-audio-api/ios/audioapi/ios/AudioAPIModule.mm @@ -131,6 +131,8 @@ - (dispatch_queue_t)methodQueue } NSError *error = nil; + const BOOL managedSessionWasActive = + self.audioSessionManager.shouldManageSession && self.audioSessionManager.isActive; auto success = [self.audioSessionManager setActive:enabled error:&error]; if (!success) { @@ -152,7 +154,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 4360efade..2380743c1 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_); @@ -644,8 +689,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 02c4b38a2..88049e9d9 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,14 +48,19 @@ 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; +/// Records that hardware format may have changed while the engine must not rebuild +/// yet (`Interrupted`). The next start or interruption-end resume rebuilds the graph. +- (void)markGraphNeedsRebuild; - (AudioEngineState)getState; - (bool)isEngineRunning; 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 519edfcad..cdd597ae4 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 @@ -339,7 +339,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]; @@ -351,7 +351,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]; @@ -386,14 +386,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 @@ -430,22 +431,43 @@ - (void)markSessionDeactivationInvalidatedGraph self.sessionDeactivationInvalidatedGraph = YES; } -- (void)onInterruptionEnd:(bool)shouldResume +- (void)markGraphNeedsRebuild +{ + std::scoped_lock lock(_engineLock); + self.graphNeedsRebuild = true; +} + +- (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]; @@ -455,20 +477,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); } } @@ -505,11 +528,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 13724523c..0becf9a1b 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 @@ -91,6 +93,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 @@ -111,6 +121,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; @@ -122,30 +186,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 @@ -156,29 +209,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 @@ -271,6 +314,16 @@ - (void)handleEngineConfigurationChange:(NSNotification *)notification } dispatch_async(dispatch_get_main_queue(), ^{ + // A configuration change is an I/O-unit stop, not a resume trigger. Restarting + // while Interrupted races with onInterruptionEnd and the foreground retry: it + // marks the session inactive and rebuilds a graph that cannot start. Leave + // recovery on those paths; remember the format may have changed so they still + // rebuild. + if ([audioEngine getState] == AudioEngineStateInterrupted) { + [audioEngine markGraphNeedsRebuild]; + return; + } + [sessionManager markInactive]; [audioEngine restartAudioEngine]; }); @@ -313,26 +366,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