diff --git a/.claude/skills/thread-safety-itc/SKILL.md b/.claude/skills/thread-safety-itc/SKILL.md index f41991963..621a346bc 100644 --- a/.claude/skills/thread-safety-itc/SKILL.md +++ b/.claude/skills/thread-safety-itc/SKILL.md @@ -160,7 +160,7 @@ That flag is **not** what keeps the audio thread out of the pool being freed, an Never hold a lock the worker takes while joining it: `finishCurrentFile()` drains and joins, and only then takes `fileMutex_` to retire the encoder — otherwise the join deadlocks against whatever the worker is doing under that lock. -**iOS lock order: engine lock before consumer mutexes.** `AudioEngine` invokes `onInputConfigurationChange` while holding `_engineLock` (interruption end, engine rebuild), and that handler takes the recorder's consumer mutexes (`fileWriterMutex_`, `callbackMutex_`, `adapterNodeMutex_`) one at a time. So no recorder method may call into the native recorder (`start`, `stop`, `detachInputNode`, anything that takes `_engineLock`) while holding a consumer mutex. `IOSAudioRecorder::stop()` therefore stores `Idle`, disarms and stops the native side first, and only then locks to detach the side effects; a handler that fires in between sees `Idle` and returns. The same gate is why `resume()` must store `Recording` *before* the native resume: the native side re-arms input only through that handler, and the handler arms only while the state reads `Recording` (issue #1294). +**iOS lock order: engine lock before consumer mutexes.** `AudioEngine` invokes the recorder's `onInputNotification` handler while holding `_engineLock` (interruption end, engine rebuild), and that handler takes the recorder's consumer mutexes (`fileWriterMutex_`, `callbackMutex_`, `adapterNodeMutex_`) one at a time. So no recorder method may call into the native recorder (`start`, `stop`, `detachInputNode`, anything that takes `_engineLock`) while holding a consumer mutex. `IOSAudioRecorder::stop()` therefore stores `Idle`, disarms and stops the native side first, and only then locks to detach the side effects; a handler that fires in between sees `Idle` and returns. The same gate is why `resume()` stores `Recording` before re-arming through `handleHardwareChange()`: the native resume does not re-arm input, and the handler arms only while the state reads `Recording` (issue #1294). **Pattern — reacting to audio without blocking on it.** When a decision depends on data the audio thread produced but costs blocking work to make (rotating a recording once its file outgrows a cap), do not make it in `writeAudioData()`. Measuring a file can be a `stat`, and acting on it closes and opens an encoder — both forbidden on the audio thread. `AudioFileWriter` makes that decision **on its own worker thread**, in the task that just encoded a buffer (`rotateIfFileOutgrowsCap()`), and only when the file properties carry a non-zero `rotateIntervalBytes`, so a session that never rotates never pays for the `stat`. One mutex (`fileMutex_`) guards the open `RecordingFile` (encoder, path, and the frames it holds — a plain counter, since only the worker encodes and only under this lock) together with the session bookkeeping the rotation advances (file count, finished-file totals); the JS thread reads those under the same lock. `RecordingFile` itself is not thread-safe; rotation is swapping one for the next. The same lock guards the session's file list, which `closeFile()` hands back after joining the worker, so the recorder needs no lock or callback of its own to learn which files a rotation produced. Anything that reaches back out of the writer — the error event — is invoked **after** that lock is released, so a handler may call straight back in (`getFilePath()`) without re-entering a non-recursive mutex. The join-versus-lock ordering is the shutdown pitfall above: `finishCurrentFile()` stops the worker first, then takes the lock to retire the encoder. diff --git a/apps/CLAUDE.md b/apps/CLAUDE.md index c6cbc77d7..38a467dca 100644 --- a/apps/CLAUDE.md +++ b/apps/CLAUDE.md @@ -283,6 +283,10 @@ if (status !== 'Granted') return; ### Interruption handling +Enable emission with `AudioManager.observeAudioInterruptions(true)`, then listen. + +**Playback:** native pause on `began`, but does not resume any audio contexts. The AudioFile example resumes on `ended` when it had been playing. + ```tsx useEffect(() => { const sub = AudioManager.addSystemEventListener('interruption', (event) => { @@ -292,6 +296,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 16edc481d..a6c482b18 100644 --- a/apps/common-app/src/demos/Record/Record.tsx +++ b/apps/common-app/src/demos/Record/Record.tsx @@ -44,11 +44,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 { availableInputs, currentInput, selectInput } = useAudioInput(); const inputLabels = availableInputs.map( @@ -123,7 +125,7 @@ const Record: FC = () => { ios: { category: 'playAndRecord', mode: 'default', - options: ['defaultToSpeaker', 'allowBluetoothA2DP'], + options: ['defaultToSpeaker', 'allowBluetoothA2DP', 'mixWithOthers'], }, }); @@ -141,6 +143,7 @@ const Record: FC = () => { setupNotification(false); if (result.status === 'success') { + setIsInterrupted(false); setState(RecordingState.Recording); return; } @@ -153,6 +156,7 @@ const Record: FC = () => { const onPauseRecording = useCallback(() => { Recorder.pause(); updateNotification(true); + setIsInterrupted(false); setState(RecordingState.Paused); }, []); @@ -188,6 +192,7 @@ const Record: FC = () => { const info = await Recorder.stop(); AudioRecorder.consumeLastRecordingResult(); RecordingNotificationManager.hide(); + setIsInterrupted(false); setState(RecordingState.Loading); if (info.status !== 'success') { @@ -299,6 +304,10 @@ const Record: FC = () => { ] ); + useEffect(() => { + stateRef.current = state; + }, [state]); + useEffect(() => { (async () => { const recordingPermissionStatus = @@ -319,6 +328,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(() => { Recorder.onError((error) => { Alert.alert('Recorder Error', error.message); @@ -414,7 +448,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 727cc909b..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', + // 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 1e81a508d..16e9a8264 100644 --- a/apps/fabric-example/ios/FabricExampleTests/AudioAPIModuleTests.mm +++ b/apps/fabric-example/ios/FabricExampleTests/AudioAPIModuleTests.mm @@ -7,8 +7,8 @@ @interface AudioAPIModule (TestingPrivate) - (void)setAudioSessionActivity:(BOOL)enabled - resolve:(RCTPromiseResolveBlock)resolve - reject:(RCTPromiseRejectBlock)reject; + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject; @end @@ -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,36 +131,93 @@ - (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 - resolve:^(id result) { - AppendAudioModuleEvent(eventLog, @"resolve"); - eventsAtResolve = CopyAudioModuleEvents(eventLog); - XCTAssertNil(result); - [resolveExpectation fulfill]; - } - reject:^(NSString *code, NSString *message, NSError *error) { - rejectionCode = code; - [resolveExpectation fulfill]; - }]; + [self.module setAudioSessionActivity:enabled + resolve:^(id result) { + AppendAudioModuleEvent(eventLog, @"resolve"); + eventsAtResolve = CopyAudioModuleEvents(eventLog); + XCTAssertNil(result); + [resolveExpectation fulfill]; + } + reject:^(NSString *code, NSString *message, NSError *error) { + rejectionCode = code; + [resolveExpectation fulfill]; + }]; [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 d98823630..5c3944915 100644 --- a/apps/fabric-example/ios/FabricExampleTests/AudioEngineTests.mm +++ b/apps/fabric-example/ios/FabricExampleTests/AudioEngineTests.mm @@ -426,7 +426,7 @@ - (void)testDetachSourceNodeKeepsGraphNeedsRebuildWhenInputRemains { [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] voiceProcessingEnabled:NO - onInputConfigurationChange:nil]; + onInputNotification:nil]; self.audioEngine.graphNeedsRebuild = YES; [self.audioEngine detachSourceNodeWithId:sourceNodeId]; @@ -441,7 +441,7 @@ - (void)testAttachInputNodeStoresAndConnectsInput { [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] voiceProcessingEnabled:NO - onInputConfigurationChange:nil]; + onInputNotification:nil]; AVAudioSinkNode *inputNode = self.audioEngine.inputNode; XCTAssertNotNil(inputNode); @@ -463,7 +463,7 @@ - (void)testAttachInputNodeDefersConnectionUntilLiveInputFormatIsAvailable { [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] voiceProcessingEnabled:NO - onInputConfigurationChange:nil]; + onInputNotification:nil]; XCTAssertNil(self.audioEngine.inputNode); XCTAssertEqual(fakeEngine.attachNodeCallCount, 0); @@ -493,7 +493,7 @@ - (void)testDetachInputNodeClearsGraphOnlyWhenNoSourcesRemain { [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] voiceProcessingEnabled:NO - onInputConfigurationChange:nil]; + onInputNotification:nil]; self.audioEngine.graphNeedsRebuild = YES; [self.audioEngine detachInputNode]; @@ -505,7 +505,7 @@ - (void)testDetachInputNodeClearsGraphOnlyWhenNoSourcesRemain { [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] voiceProcessingEnabled:NO - onInputConfigurationChange:nil]; + onInputNotification:nil]; self.audioEngine.graphNeedsRebuild = YES; [self.audioEngine detachInputNode]; @@ -521,7 +521,7 @@ - (void)testDetachInputNodePreservesSessionDeactivationInvalidation { [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] voiceProcessingEnabled:NO - onInputConfigurationChange:nil]; + onInputNotification:nil]; [self.audioEngine onSessionDeactivated]; [self.audioEngine detachInputNode]; @@ -532,15 +532,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); } @@ -592,6 +592,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; @@ -610,7 +622,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); @@ -624,7 +637,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); @@ -641,7 +655,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); @@ -651,7 +666,7 @@ - (void)testOnInterruptionEndWithResumeRestartsEngine { XCTAssertEqual(self.audioEngine.createdFakeEngines.count, 2UL); } -- (void)testOnInterruptionEndWithResumeFailureEndsIdle { +- (void)testOnInterruptionEndWithResumeFailureStaysInterrupted { [self attachSourceNodeToAudioEngine]; self.audioEngine.state = AudioEngineStateInterrupted; @@ -660,9 +675,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 { @@ -746,7 +780,7 @@ - (void)testStartIfNecessaryRebuildsWhenGraphNeedsRebuild { [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] voiceProcessingEnabled:NO - onInputConfigurationChange:nil]; + onInputNotification:nil]; FakeAudioEngine *oldEngine = self.audioEngine.currentFakeAudioEngine; oldEngine.fakeRunning = YES; @@ -766,7 +800,7 @@ - (void)testStartIfNecessaryRebuildsWhenGraphNeedsRebuild { [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] voiceProcessingEnabled:NO - onInputConfigurationChange:nil]; + onInputNotification:nil]; AVAudioSinkNode *recoveredInputNode = self.audioEngine.inputNode; XCTAssertTrue([self.audioEngine startIfNecessary]); @@ -790,7 +824,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 = @@ -964,10 +998,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; @@ -1046,7 +1082,7 @@ - (void)testConcurrentRecordAndPlayPathsDoNotCrash { [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] voiceProcessingEnabled:NO - onInputConfigurationChange:nil]; + onInputNotification:nil]; [self.audioEngine startIfNecessary]; dispatch_group_leave(group); }); @@ -1095,7 +1131,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 5fe6fc41a..f70ceafec 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 @@ -103,11 +104,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 @@ -118,6 +122,7 @@ - (instancetype)init if (self = [super init]) { self.startIfNecessaryResult = YES; self.returnedSourceNodeId = @"fake-source-node-id"; + self.attachedSourceNodeIds = [NSMutableSet set]; } return self; @@ -141,6 +146,7 @@ - (void)stopIfNecessary - (bool)startIfNecessary { self.startIfNecessaryCallCount += 1; + self.sourceNodeIdsPresentAtLastStart = [self.attachedSourceNodeIds copy]; return self.startIfNecessaryResult; } @@ -158,6 +164,7 @@ - (NSString *)attachSourceNodeWithRenderBlock:(AVAudioSourceNodeRenderBlock)rend self.lastAttachedRenderBlock = renderBlock; self.lastAttachedSampleRate = sampleRate; self.lastAttachedChannelCount = channelCount; + [self.attachedSourceNodeIds addObject:self.returnedSourceNodeId]; return self.returnedSourceNodeId; } @@ -165,6 +172,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 @@ -424,6 +446,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 b3fedd24f..7edb7b4aa 100644 --- a/apps/fabric-example/ios/FabricExampleTests/IOSAudioRecorderTests.mm +++ b/apps/fabric-example/ios/FabricExampleTests/IOSAudioRecorderTests.mm @@ -124,6 +124,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; @@ -145,6 +146,7 @@ - (instancetype)init { channels:2]; self.mockResolvedBufferSize = 512; self.startResult = YES; + self.resumeResult = YES; } return self; @@ -191,7 +193,7 @@ - (void)pause { - (BOOL)resume { self.resumeCallCount += 1; - return YES; + return self.resumeResult; } - (void)cleanup { @@ -272,7 +274,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 { @@ -496,6 +498,19 @@ - (void)testPauseAndResumeRespectCurrentState { XCTAssertFalse(_recorder->isPaused()); } +- (void)testResumeDoesNotStoreRecordingWhenNativeResumeFails { + _recorder->setRecorderState(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 fe97c1202..55a393e01 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 @@ -432,22 +433,29 @@ - (void)testPauseAndResumeDelegateToAudioEngine voiceProcessingEnabled:NO]; self.audioEngine.startIfNecessaryResult = YES; - __block NSInteger configurationChangeCallCount = 0; - recorder.onInputConfigurationChange = ^{ - configurationChangeCallCount += 1; - }; [recorder pause]; - BOOL resumed = [recorder resume]; + XCTAssertTrue([recorder resume]); XCTAssertEqual(self.audioEngine.pauseIfNecessaryCallCount, 1); XCTAssertEqual(self.audioEngine.startIfNecessaryCallCount, 1); - XCTAssertTrue(resumed); - // Re-arming is the owner's decision, taken in the configuration-change handler. - XCTAssertEqual(configurationChangeCallCount, 1); + // Re-arming is the owner's decision, taken in the HardwareChanged handler. XCTAssertFalse(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 { AVAudioFormat *initialFormat = [self validFormat]; diff --git a/apps/fabric-example/ios/FabricExampleTests/SystemNotificationManagerTests.mm b/apps/fabric-example/ios/FabricExampleTests/SystemNotificationManagerTests.mm index 9c2161429..f27673ec8 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; +} + /// The engine is created lazily, so a fresh fake reads as not in use and the manager would /// ignore every notification meant for it. - (bool)isInUse @@ -278,6 +308,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); @@ -422,19 +454,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 @@ -448,6 +525,127 @@ - (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)testForegroundRetryReplaysCapturedInterruptionShouldResumeNo +{ + [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:0]]; + [self flushMainQueue]; + + XCTAssertEqual(self.fakeAudioEngine.interruptionEndCallCount, 1); + XCTAssertFalse(self.fakeAudioEngine.lastShouldResume); + XCTAssertEqualObjects(self.manager.interruptionShouldResume, @NO); + + [self.module resetCapturedEvent]; + self.fakeAudioEngine.interruptionEndOutcome = AudioEngineInterruptionEndOutcomeRunning; + + [self.manager handleWillEnterForeground:nil]; + [self flushMainQueue]; + + XCTAssertEqual(self.fakeAudioEngine.interruptionEndCallCount, 2); + XCTAssertFalse(self.fakeAudioEngine.lastShouldResume); + XCTAssertEqualObjects(self.module.lastEventBody[@"type"], @"ended"); + XCTAssertEqualObjects(self.module.lastEventBody[@"shouldResume"], @NO); +} + +- (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); + XCTAssertTrue(self.fakeAudioEngine.lastShouldResume); + XCTAssertEqual(self.module.eventInvocationCount, 1); + XCTAssertEqualObjects(self.module.lastEventBody[@"type"], @"ended"); + XCTAssertEqualObjects(self.module.lastEventBody[@"shouldResume"], @YES); +} + +- (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]; @@ -478,6 +676,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 = @[ @@ -568,21 +782,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 @@ -599,4 +829,30 @@ - (void)testCheckSecondaryAudioHintResumeTransitionResumesEngineWhenNotObserved XCTAssertTrue(self.fakeAudioEngine.lastShouldResume); } +- (void)testCheckSecondaryAudioHintResumeKeepsCapturedInterruptionShouldResumeNo +{ + [self.manager observeAudioInterruptions:YES]; + [self prepareInterruptedRecordingEngine]; + self.fakeAudioEngine.interruptionEndOutcome = AudioEngineInterruptionEndOutcomeStillInterrupted; + + [self.manager handleInterruption:[self interruptionNotificationWithType:AVAudioSessionInterruptionTypeEnded + option:0]]; + [self flushMainQueue]; + + XCTAssertEqualObjects(self.manager.interruptionShouldResume, @NO); + XCTAssertFalse(self.fakeAudioEngine.lastShouldResume); + + self.fakeSharedAudioSession.secondaryAudioShouldBeSilencedHint = NO; + self.manager.wasOtherAudioPlaying = YES; + self.fakeAudioEngine.interruptionEndOutcome = AudioEngineInterruptionEndOutcomeRunning; + + [self.manager checkSecondaryAudioHint]; + [self flushMainQueue]; + + XCTAssertEqual(self.fakeAudioEngine.interruptionEndCallCount, 2); + XCTAssertFalse(self.fakeAudioEngine.lastShouldResume); + XCTAssertEqualObjects(self.manager.interruptionShouldResume, @NO); + XCTAssertEqualObjects(self.module.lastEventBody[@"shouldResume"], @NO); +} + @end diff --git a/packages/audiodocs/CLAUDE.md b/packages/audiodocs/CLAUDE.md index 6af6765eb..e431a85bd 100644 --- a/packages/audiodocs/CLAUDE.md +++ b/packages/audiodocs/CLAUDE.md @@ -207,6 +207,8 @@ Inherits all methods from [`AudioNode`](/docs/core/audio-node#methods). ### Badges — inline in tables or text +Implemented in `packages/docs-theme` and re-exported from `@site/src/components/Badges`, so the internal docs site can use the same import. + ```tsx import { Optional, ReadOnly, Overridden, IOS, Android, AndroidAPI, Experimental, MobileOnly } from '@site/src/components/Badges'; diff --git a/packages/audiodocs/docs/fundamentals/best-practices.mdx b/packages/audiodocs/docs/fundamentals/best-practices.mdx index a9ac181a5..cfef2f36c 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 system options early**: Set [`AudioManager.setSystemOptions()`](../system/audio-manager.mdx) once at startup — for example `ios: { category: 'playback' }` for media apps or `ios: { category: 'playAndRecord' }` when recording and playback coexist. +- **Configure the system options early**: Set [`AudioManager.setSystemOptions()`](../system/audio-manager.mdx#setsystemoptions) once at startup and follow the [session configuration guidelines](../system/audio-manager.mdx#sessionoptionsconfigurationguidelines). For example, `ios: { category: 'playback' }` for media apps or `ios: { category: 'playAndRecord' }` when recording and playback coexist. ## [**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 ef3f7df9f..de2d5a515 100644 --- a/packages/audiodocs/docs/other/audio-api-plugin.mdx +++ b/packages/audiodocs/docs/other/audio-api-plugin.mdx @@ -87,7 +87,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 83e33607d..c3555ee94 100644 --- a/packages/audiodocs/docs/system/audio-manager.mdx +++ b/packages/audiodocs/docs/system/audio-manager.mdx @@ -46,13 +46,7 @@ function App() { ## Methods -### `setSystemOptions` - -:::warning AVAudioSession Compatibility -Not all `ios.options` are compatible with every `ios.category`. 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). -::: +### `setSystemOptions` {#setsystemoptions} | Parameter | Type | Description | | :---: | :---: | :---- | @@ -233,6 +227,20 @@ Some USB microphones also expose a headphone output, which the platform then pre ## Remarks +### Resume recording after an interruption {#resumerecordingafteraninterruption} + +After the app has been interrupted by, for example a phone call, it is possible to *resume* an audio recording while the app is still backgrounded. It is impossible to start a new recording session from the background. Due to iOS policies and privacy features, the app has to be configured appropriately first: + +- Ensure the session options (set via [`setSystemOptions`](#setsystemoptions)) 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: + - `ios.category` 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. + - `ios.mode` set to `default`. + - `ios.options` 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](../other/audio-api-plugin.mdx#iosbackgroundmode). + +Even if background interruption recovery is unsuccessful, the library retries with a best-effort strategy when the app returns to the foreground after a failed resume. + +## Types + ### `AudioFocusType`
Type definitions @@ -249,6 +257,7 @@ type AudioFocusType =
Type definitions + ```typescript type IOSCategory = | 'ambient' @@ -304,6 +313,20 @@ interface SystemOptions { ```
+#### 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) + +:::warning AVAudioSession Compatibility +Not all `ios.options` are compatible with every `ios.category`. 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). +::: + ### `CommunicationDevice`
@@ -317,6 +340,7 @@ type CommunicationDevice = 'speaker' | 'earpiece' | 'systemDefault';
Type definitions + ```typescript interface EventEmptyType {} @@ -381,15 +405,18 @@ interface AudioEventSubscription {
Type definitions + ```typescript type PermissionStatus = 'Undetermined' | 'Denied' | 'Granted'; ``` +
### `AudioDeviceInfo`
Type definitions + ```typescript export interface AudioDeviceInfo { id: string; // unique device identifier @@ -397,12 +424,14 @@ export interface AudioDeviceInfo { category: string; // device category (e.g. "Built-In Microphone", "Bluetooth") } ``` +
### `AudioDevicesInfo`
Type definitions + ```typescript export type AudioDeviceList = AudioDeviceInfo[]; @@ -413,4 +442,5 @@ export interface AudioDevicesInfo { currentOutputs: AudioDeviceList; // iOS } ``` +
diff --git a/packages/audiodocs/src/components/Badges/index.tsx b/packages/audiodocs/src/components/Badges/index.tsx index 7da7f2c67..fbc470683 100644 --- a/packages/audiodocs/src/components/Badges/index.tsx +++ b/packages/audiodocs/src/components/Badges/index.tsx @@ -1,48 +1,10 @@ -import React from 'react'; -import styles from './styles.module.css'; - -export function Optional({ footnote }: { footnote?: boolean }) { - return
Optional{footnote ? '*' : ''}
; -} - -export function ReadOnly({ footnote }: { footnote?: boolean }) { - return
Read only{footnote ? '*' : ''}
; -} - -export function Overridden({ footnote }: { footnote?: boolean }) { - return
Overridden{footnote ? '*' : ''}
; -} - -export function IOS({ footnote }: { footnote?: boolean }) { - return
iOS{footnote ? '*' : ''}
; -} - -export function Android({ footnote }: { footnote?: boolean }) { - return
Android{footnote ? '*' : ''}
; -} - -const ANDROID_VERSION_BY_API_LEVEL: Record = { - 26: 8, - 28: 9, - 29: 10, - 30: 11, - 31: 12, - 33: 13, - 34: 14, - 35: 15, - 36: 16, -}; - -export function AndroidAPI({ level }: { level: number }) { - const version = ANDROID_VERSION_BY_API_LEVEL[level]; - const label = version ? `Android ${version}+ (API ${level})` : `Android API ${level}+`; - return
{label}
; -} - -export function Experimental({ footnote }: { footnote?: boolean }) { - return
Experimental{footnote ? '*' : ''}
; -} - -export function MobileOnly({ footnote }: { footnote?: boolean }) { - return
Mobile only{footnote ? '*' : ''}
; -} +export { + Optional, + ReadOnly, + Overridden, + IOS, + Android, + AndroidAPI, + Experimental, + MobileOnly, +} from 'docs-theme/theme/Badges'; diff --git a/packages/docs-theme/theme/Badges/index.d.ts b/packages/docs-theme/theme/Badges/index.d.ts new file mode 100644 index 000000000..7a6993302 --- /dev/null +++ b/packages/docs-theme/theme/Badges/index.d.ts @@ -0,0 +1,14 @@ +import type { FC } from 'react'; + +type BadgeProps = { + footnote?: boolean; +}; + +export const Optional: FC; +export const ReadOnly: FC; +export const Overridden: FC; +export const IOS: FC; +export const Android: FC; +export const AndroidAPI: FC<{ level: number }>; +export const Experimental: FC; +export const MobileOnly: FC; diff --git a/packages/docs-theme/theme/Badges/index.jsx b/packages/docs-theme/theme/Badges/index.jsx new file mode 100644 index 000000000..f1d3e2f58 --- /dev/null +++ b/packages/docs-theme/theme/Badges/index.jsx @@ -0,0 +1,48 @@ +import React from 'react'; +import styles from './styles.module.css'; + +export function Optional({ footnote }) { + return
Optional{footnote ? '*' : ''}
; +} + +export function ReadOnly({ footnote }) { + return
Read only{footnote ? '*' : ''}
; +} + +export function Overridden({ footnote }) { + return
Overridden{footnote ? '*' : ''}
; +} + +export function IOS({ footnote }) { + return
iOS{footnote ? '*' : ''}
; +} + +export function Android({ footnote }) { + return
Android{footnote ? '*' : ''}
; +} + +const ANDROID_VERSION_BY_API_LEVEL = { + 26: 8, + 28: 9, + 29: 10, + 30: 11, + 31: 12, + 33: 13, + 34: 14, + 35: 15, + 36: 16, +}; + +export function AndroidAPI({ level }) { + const version = ANDROID_VERSION_BY_API_LEVEL[level]; + const label = version ? `Android ${version}+ (API ${level})` : `Android API ${level}+`; + return
{label}
; +} + +export function Experimental({ footnote }) { + return
Experimental{footnote ? '*' : ''}
; +} + +export function MobileOnly({ footnote }) { + return
Mobile only{footnote ? '*' : ''}
; +} diff --git a/packages/audiodocs/src/components/Badges/styles.module.css b/packages/docs-theme/theme/Badges/styles.module.css similarity index 99% rename from packages/audiodocs/src/components/Badges/styles.module.css rename to packages/docs-theme/theme/Badges/styles.module.css index 69c365410..12de6e133 100644 --- a/packages/audiodocs/src/components/Badges/styles.module.css +++ b/packages/docs-theme/theme/Badges/styles.module.css @@ -24,7 +24,6 @@ background-color: var(--swm-red-light-100); } - [data-theme='dark'] .experimental { background-color: var(--swm-red-dark-120); } diff --git a/packages/internaldocs/CLAUDE.md b/packages/internaldocs/CLAUDE.md index 77992cbde..d795e6c17 100644 --- a/packages/internaldocs/CLAUDE.md +++ b/packages/internaldocs/CLAUDE.md @@ -22,6 +22,8 @@ Write for a maintainer who was not in the design conversation. No chat-log voice Prefer short pages. Split when a heading is a standalone topic. Cross-links use relative `.mdx` paths. +Shared MDX components live in `packages/docs-theme` and are re-exported from this site. Import badges from `@site/src/components/Badges` (same path as the public docs). + ## Sidebar Same pattern as `audiodocs`: topic folders, `_category_.json` (`label`, `position`), `sidebar_position` on pages. Do not create `architecture/`, `research/`, or `plans/` folders. diff --git a/packages/internaldocs/docs/system/interruptions.mdx b/packages/internaldocs/docs/system/interruptions.mdx index 1a6e35ba3..5173801be 100644 --- a/packages/internaldocs/docs/system/interruptions.mdx +++ b/packages/internaldocs/docs/system/interruptions.mdx @@ -6,6 +6,132 @@ sidebar_custom_props: - wip --- +import { IOS } from '@site/src/components/Badges'; + # Interruptions -Research on the exact behavior of the operating system when an app's audio is interrupted. +## Library behavior + +### Resume recording after an interruption {#resumerecordingafteraninterruption} + +*Extension of `audiodocs/docs/system/audio-manager.mdx#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 + +Refer to the following: +* `audiodocs/docs/system/audio-manager.mdx#resumerecordingafteraninterruption` for an example configuration. +* Apple guidelines. + * [Configuring background execution modes](https://developer.apple.com/documentation/xcode/configuring-background-execution-modes). + * [Configuring your app for media playback](https://developer.apple.com/documentation/avfoundation/configuring-your-app-for-media-playback). + +#### 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 `AudioRecorder.stop()`, because stopping ends the track. + +#### 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 an interruption. Because of this, the library retries with a best-effort strategy when the app returns to the foreground after a failed resume. + +## OS behavior and UX + +### Possible policies + +On one hand, iOS is quite restrictive about an app's background activity because of privacy implications. On the other hand, there is still a wide range of policies an app developer might want to adopt. The library currently **doesn't expose a way to configure all of them**. The possible policies include: + +* Playback resume. + * Follow `shouldResume` OR ignore `shouldResume` and always resume. + * [`shouldResume`](https://developer.apple.com/documentation/avfaudio/avaudiosession/interruptionoptions/shouldresume?language=objc#Discussion) + > Apps that don’t require user input to begin audio playback (such as games) can ignore this flag and resume playback when an interruption ends. + * **Short-term decision.** The more compatible choice is to *follow* `shouldResume`. +* Recording resume. + * Always resume OR never resume + * Apple seems to promote not resuming after an interruption, but it is not a hard requirement: + > Closing the Smart Folio automatically mutes the iPad microphone and by default interrupts the audio session associated with it. If a VoIP app restarts the audio session when people reopen their Smart Folio, it risks invading people’s privacy by unmuting the microphone without their knowledge. You can inspect an audio-session interruption to help determine the right way to respond; for developer guidance, see [Handling audio interruptions](https://developer.apple.com/documentation/avfaudio/handling-audio-interruptions?language=objc). + * [Handling audio interruptions](https://developer.apple.com/documentation/avfaudio/handling-audio-interruptions?language=objc) on recording: + > Recent iPad models provide a feature that mutes the built-in microphone at the hardware level when the user closes the device’s Smart Folio cover. If your app plays and **records audio**, you may want to continue playback even if the system mutes the microphone. You can disable the default interruption behavior by setting the [AVAudioSessionCategoryOptionOverrideMutedMicrophoneInterruption](https://developer.apple.com/documentation/avfaudio/avaudiosession/categoryoptions-swift.struct/overridemutedmicrophoneinterruption?language=objc) option when configuring your audio session. + * **Short-term decision.** The more compatible and desired option by our users is to *always resume*. + * (OR follow `shouldResume`) + * `shouldResume` apparently doesn't refer to recording according to a few Apple pages, which only mention playback: [Handle audio session interruptions](https://developer.apple.com/documentation/avfaudio/handling-audio-interruptions#Handle-audio-session-interruptions), [`shouldResume` discussion](https://developer.apple.com/documentation/avfaudio/avaudiosession/interruptionoptions/shouldresume#Discussion), [Handling interruptions in Human Interface Guidelines](https://developer.apple.com/design/human-interface-guidelines/playing-audio#Handling-interruptions). +* More policies? + * It can be seen that the built-in iOS voice recorder app is able to continue recording even when the phone is *ringing*. Later, it stops recording after *answering a call*. + * Our library goes into interrupted state even when merely *ringing*. + * Hypothesis: this can probably be configured by looking at the other keys of [AVAudioSessionInterruptionNotification](https://developer.apple.com/documentation/avfaudio/avaudiosession/interruptionnotification?language=objc). + * We ignore [`AVAudioSessionInterruptionReasonKey`](https://developer.apple.com/documentation/avfaudio/avaudiosessioninterruptionreasonkey?language=objc) for example. Is it useful in this case? It is not well documented. + +### Exact `shouldResume` semantics + +It is an option that can be retrieved when handling an interruption of type `ended`. According to [Apple docs](https://developer.apple.com/documentation/avfaudio/avaudiosession/interruptionoptions/shouldresume): +> An option that indicates the interruption by another audio session has ended and the app can resume its audio session. [...] +> +> It serves as a hint that it’s appropriate for your app to resume audio playback without waiting for user input. +> +> Apps that don’t require user input to begin audio playback (such as games) can ignore this flag and resume playback when an interruption ends. + +In most cases, `shouldResume` seems to be `YES`. It is not obvious in which cases it is `NO`, which makes testing harder. The official documentation isn't very helpful. + +From experiments, one example of the `NO` case was found. See the *"Start record; go background; interrupt with meet.jit.si; disconnect and close the Safari tab; stop record"* test below. It is not obvious *why* this test emits such an outcome. There is one hyphothesis: + +* It is probably due to [`routeConfigurationChange`](https://developer.apple.com/documentation/avfaudio/avaudiosession/routechangereason/routeconfigurationchange), which is emitted when the app is interrupted with such a WebRTC call. + * From ["Responding to audio route changes"](https://developer.apple.com/documentation/avfaudio/responding-to-audio-route-changes). + > However, when users disconnect their headphones, they don’t want to automatically share what they’re listening to with others. Applications **should** respect this implicit **privacy request** and automatically **pause playback when users disconnect their headphones**. + +:::note Deprecation note +The current approach to retrieve the resumption recommendation via [`AVAudioSession.InterruptionOptions`](https://developer.apple.com/documentation/avfaudio/avaudiosession/interruptionoptions) and [`shouldResume`](https://developer.apple.com/documentation/avfaudio/avaudiosession/interruptionoptions/shouldresume) seems to be deprecated. + +The alternative is to use [`AVAudioSession.ResumptionRecommendation`](https://developer.apple.com/documentation/avfaudio/avaudiosession/resumptionrecommendation) instead. It doesn't seem to be documented nearly at all, though. What's interesting is that even ["Handling audio interruptions"](https://developer.apple.com/documentation/avfaudio/handling-audio-interruptions) still mentions the old `*Options` approach, instead of the `*Recommendation*`. +::: + +### Useful documentation pages, headers, etc. + +* `AudioSessionTypes.h` + * `/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator.sdk/System/Library/Frameworks/CoreAudioTypes.framework/Headers/AudioSessionTypes.h` + * ```c + AVAudioSessionErrorCodeCannotInterruptOthers = '!int', // 0x21696E74, 560557684 + AVAudioSessionErrorCodeCannotStartPlaying = '!pla', // 0x21706C61, 561015905 + AVAudioSessionErrorCodeInsufficientPriority = '!pri', // 0x21707269, 561017449 + AVAudioSessionErrorCodeUnspecified = 'what', // 0x77686174, 2003329396 + ``` + * [`AVAudioSession.ErrorCode`](https://developer.apple.com/documentation/coreaudiotypes/avaudiosession/errorcode) +* [Apple Audio Guidelines By App Type](https://developer.apple.com/library/archive/documentation/Audio/Conceptual/AudioSessionProgrammingGuide/AudioGuidelinesByAppType/AudioGuidelinesByAppType.html#//apple_ref/doc/uid/TP40007875-CH11-SW3). + +## Testing + +It is hard to perform automatic end-to-end tests involving an interruption of the app and its audio, because they often require non-trivial interaction with the physical or digital environment, and human input and output. Until an automated solution is found, the following manual tests are recommended. + +:::tip +When performing each test, save the Xcode iOS logs and note the visible behavior. That makes it much easier to draw conclusions after several tests. +::: + +The following tests use the `Record` demo in the FabricExample app: + +* Start record; go background; interrupt with Siri; stop record +* Start record; go background; interrupt with Siri; tell it to open FabricExample; stop record +* Start record; go background; interrupt with meet.jit.si; disconnect; stop record +* Start record; go background; interrupt with meet.jit.si; disconnect and close the Safari tab; stop record +* Start record; interrupt with FaceTime; stop record +* Start record; go background; interrupt with FaceTime; stop record +* Record a short track and stop; start a call; play the recorded track; end the call; wait until playback ends; record again + +:::note +Some of the tests above use a browser call such as meet.jit.si on purpose. It has been empirically shown to be useful in debugging because, unlike other interruption methods, it interrupts the audio session with [`shouldResume == NO`](https://developer.apple.com/documentation/avfaudio/avaudiosession/interruptionoptions/shouldresume?language=objc). +::: + +## ToDo +- Test, document and possibly fix the following behaviors: + - iOS + - [ ] Playback interruptions (also combined with recording). + - Right now, the [`#resumerecordingafteraninterruption`](#resumerecordingafteraninterruption) section deliberately doesn't mention playback. That is not the long-term intent. The section should be generalized. + - [ ] More audio session options combinations, including: + - [ ] `iosCategory: 'playback', iosMode: 'spokenAudio'`. + - [ ] [`allowBluetoothHFP`](https://developer.apple.com/documentation/avfaudio/avaudiosession/categoryoptions-swift.struct/allowbluetoothhfp?language=objc). + - Interruptions recovery probably doesn't work with Bluetooth HFP sessions. + - [ ] Secondary audio and the library's `activelyReclaimSession` experimental method. + - What is the useful case for them? Are they useful? + - [ ] Android in the same scenarios. + - [ ] Make sure that behavior on both platforms is uniform. +- `AudioContext` + - [ ] The library doesn't implement [`AudioContextState`'s `interrupted`](https://webaudio.github.io/web-audio-api/#dom-audiocontextstate-interrupted). Should it? diff --git a/packages/internaldocs/src/components/Badges/index.tsx b/packages/internaldocs/src/components/Badges/index.tsx new file mode 100644 index 000000000..fbc470683 --- /dev/null +++ b/packages/internaldocs/src/components/Badges/index.tsx @@ -0,0 +1,10 @@ +export { + Optional, + ReadOnly, + Overridden, + IOS, + Android, + AndroidAPI, + Experimental, + MobileOnly, +} from 'docs-theme/theme/Badges'; diff --git a/packages/internaldocs/yarn.lock b/packages/internaldocs/yarn.lock index 4a1c5ff0e..f3fe156ea 100644 --- a/packages/internaldocs/yarn.lock +++ b/packages/internaldocs/yarn.lock @@ -6648,8 +6648,8 @@ __metadata: "docs-theme@file:../docs-theme::locator=internaldocs%40workspace%3A.": version: 0.0.0 - resolution: "docs-theme@file:../docs-theme#../docs-theme::hash=fa8538&locator=internaldocs%40workspace%3A." - checksum: 10/d223c07798021d3817b0b7d2874c58ebc458d09768eb77894845599f3adfd259c2dd0379b76c60712f097c23e3c35a5d38281926cb71a3981172c7ff43d37bde + resolution: "docs-theme@file:../docs-theme#../docs-theme::hash=45ad13&locator=internaldocs%40workspace%3A." + checksum: 10/378423cee34725c7aa7df5fa7893f4685b9e79ced31e2c54706df96659208c6373ccd35be8f67c405de4be417df1203770a4f2fc6c2dbebd9d9c7b53e901a136 languageName: node linkType: hard 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 ff213c03c..03e69f658 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 7c51f3c15..55d1a8801 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 @@ -57,7 +57,8 @@ class IOSAudioRecorder : public AudioRecorder { /// Re-points the live outputs at the format the rebuilt engine input now delivers, with the /// input disarmed meanwhile so no buffer in either format slips through. Result reprepareForLiveInput(); - void handleInputConfigurationChange(); + void handleInputNotification(AudioEngineInputNotification notification); + void handleHardwareChange(); int32_t inputChannelCount_{0}; }; 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 31146363f..6cc5e7642 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 @@ -92,7 +92,8 @@ static void cleanupStartedRecorder( nativeRecorder_ = [[NativeAudioRecorder alloc] initWithReceiverBlock:receiverBlock voiceProcessingEnabled:options.iosVoiceProcessing]; - nativeRecorder_.onInputConfigurationChange = ^{ this->handleInputConfigurationChange(); }; + nativeRecorder_.onInputNotification = + ^(AudioEngineInputNotification notification) { this->handleInputNotification(notification); }; } Result IOSAudioRecorder::resolveStreamFormat() const @@ -114,7 +115,16 @@ static void cleanupStartedRecorder( .maxFramesPerBuffer = static_cast(maxFramesPerBuffer)}); } -void IOSAudioRecorder::handleInputConfigurationChange() +void IOSAudioRecorder::handleInputNotification(AudioEngineInputNotification notification) +{ + switch (notification) { + case AudioEngineInputNotificationHardwareChanged: + handleHardwareChange(); + return; + } +} + +void IOSAudioRecorder::handleHardwareChange() { if (isIdle()) { return; @@ -178,7 +188,7 @@ static void cleanupStartedRecorder( { stop(); - nativeRecorder_.onInputConfigurationChange = nil; + nativeRecorder_.onInputNotification = nil; { std::scoped_lock lock(callbackMutex_, fileWriterMutex_, adapterNodeMutex_); @@ -356,11 +366,12 @@ static void cleanupStartedRecorder( return; } - state_.store(RecorderState::Recording, std::memory_order_release); - if (![nativeRecorder_ resume]) { - state_.store(RecorderState::Paused, std::memory_order_release); + return; } + + state_.store(RecorderState::Recording, std::memory_order_release); + handleHardwareChange(); } /// Any thread. Also requires the audio engine to be running, so a recorder whose session was 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 d119921f7..16130a195 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 @@ -35,7 +35,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 6a2814e95..6330b2a90 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); @@ -15,7 +16,7 @@ typedef void (^AudioReceiverBlock)(const AudioBufferList *inputBuffer, int numFr /// Buffers per callback for resolvedInputFormat; a callback with another count is dropped. @property (atomic, assign) UInt32 expectedBufferCount; @property (nonatomic, assign) BOOL voiceProcessingEnabled; -@property (nonatomic, copy) void (^onInputConfigurationChange)(void); +@property (nonatomic, copy) void (^onInputNotification)(AudioEngineInputNotification); - (instancetype)initWithReceiverBlock:(AudioReceiverBlock)receiverBlock voiceProcessingEnabled:(BOOL)voiceProcessingEnabled; 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 f2ffd8c77..11e528168 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 @@ -124,7 +124,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]; @@ -176,17 +176,7 @@ - (BOOL)resume AudioEngine *audioEngine = [AudioEngine sharedInstance]; assert(audioEngine != nil); - if (![audioEngine startIfNecessary]) { - return NO; - } - - // Re-arming is the owner's call: the handler re-resolves the input format and arms only - // when the recorder is back in the recording state. - if (self.onInputConfigurationChange != nil) { - self.onInputConfigurationChange(); - } - - return YES; + return [audioEngine startIfNecessary]; } - (void)cleanup @@ -195,7 +185,7 @@ - (void)cleanup [self storeResolvedInputFormat:nil]; self.receiverBlock = nil; self.receiverSinkBlock = nil; - self.onInputConfigurationChange = nil; + self.onInputNotification = nil; } @end 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 e87e58aa8..9bba7e60b 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,18 @@ typedef NS_ENUM(NSInteger, AudioEngineState) { AudioEngineStateInterrupted }; +typedef NS_ENUM(NSInteger, AudioEngineInputNotification) { + AudioEngineInputNotificationHardwareChanged = 0, +}; + +/// Result of `onInterruptionEnd:`. Distinguishes a no-op from a failed resume that stays Interrupted. +typedef NS_ENUM(NSInteger, AudioEngineInterruptionEndOutcome) { + AudioEngineInterruptionEndOutcomeNoOp = 0, + AudioEngineInterruptionEndOutcomeRunning, + AudioEngineInterruptionEndOutcomePaused, + AudioEngineInterruptionEndOutcomeStillInterrupted +}; + typedef void (^OnOutputRecoveryFailedBlock)(void); @interface AudioEngine : NSObject @@ -39,14 +51,19 @@ typedef void (^OnOutputRecoveryFailedBlock)(void); - (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 2529c9b7c..ee32c3d25 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 @@ -19,7 +19,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 @@ -40,6 +40,8 @@ @interface AudioEngine () { - (void)createAudioEngineIfNeeded; - (void)destroyAudioEnginePreservingSessionDeactivationState:(BOOL)preserveSessionDeactivationState; +- (BOOL)hasInput; +- (BOOL)hasSources; - (BOOL)hasTrackedGraph; - (AVAudioFormat *)currentInputConnectionFormat; - (void)materializeSourceNodeWithId:(NSString *)sourceNodeId; @@ -50,7 +52,7 @@ - (void)materializeTrackedNodesIfNeeded; - (AVAudioFormat *)liveInputFormat; - (void)resetInputNode; - (void)rebuildAudioEngineAndResumeIfNeeded; -- (void)notifyConfigurationChanges; +- (void)notifyInput:(AudioEngineInputNotification)notification; @end @@ -72,9 +74,19 @@ - (void)destroyAudioEngine [self destroyAudioEnginePreservingSessionDeactivationState:NO]; } +- (BOOL)hasInput +{ + return self.inputRegistration != nil; +} + +- (BOOL)hasSources +{ + return [self.sourceRegistrations count] > 0; +} + - (BOOL)hasTrackedGraph { - return [self.sourceRegistrations count] > 0 || self.inputRegistration != nil; + return [self hasInput] || [self hasSources]; } - (void)destroyAudioEnginePreservingSessionDeactivationState:(BOOL)preserveSessionDeactivationState @@ -362,7 +374,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]; @@ -374,7 +386,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]; @@ -409,14 +421,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 @@ -453,24 +466,79 @@ - (void)markSessionDeactivationInvalidatedGraph self.sessionDeactivationInvalidatedGraph = YES; } -- (void)onInterruptionEnd:(bool)shouldResume +- (void)markGraphNeedsRebuild +{ + std::scoped_lock lock(_engineLock); + self.graphNeedsRebuild = true; +} + +// According to : +// * `shouldResume` only refers to resuming the playback. +// * We choose to resume recording unconditionally (but see below). +// Below decision tree reflects this policy. +// On the long term it would be useful to make this policy configurable. +static BOOL +shouldEngineResumeAfterInterruption(BOOL shouldPlaybackResume, BOOL hasInput, BOOL hasSources) +{ + BOOL resumeEngine = NO; + + if (hasInput) { + if (hasSources) { + if (shouldPlaybackResume) { + // Resume recording and playback. + return YES; + } else { + // TODO: Ideally, we would only resume recording and not playback, + // but it would be complex to implement and the usecase is not common, + // therefore not resuming the engine is safer. + return NO; + } + } else { + // There is only recording to resume. + return YES; + } + } else { + // There is only playback to resume. + return hasSources && shouldPlaybackResume; + } +} + +- (AudioEngineInterruptionEndOutcome)onInterruptionEnd:(bool)shouldResume { std::scoped_lock lock(_engineLock); NSError *error = nil; if (self.state != AudioEngineState::AudioEngineStateInterrupted) { - return; + return AudioEngineInterruptionEndOutcomeNoOp; + } + + const BOOL resumeEngine = + shouldEngineResumeAfterInterruption(shouldResume, [self hasInput], [self hasSources]); + + if (!resumeEngine) { + [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]; - [self notifyConfigurationChanges]; - if (!shouldResume) { - self.state = AudioEngineState::AudioEngineStatePaused; - 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; + return AudioEngineInterruptionEndOutcomeStillInterrupted; } + [self notifyInput:AudioEngineInputNotificationHardwareChanged]; [self.audioEngine prepare]; [self.audioEngine startAndReturnError:&error]; @@ -478,19 +546,19 @@ - (void)onInterruptionEnd:(bool)shouldResume NSLog( @"Error while restarting the audio engine after interruption: %@", [error debugDescription]); - self.state = AudioEngineState::AudioEngineStateIdle; - [self notifyOutputRecoveryFailed]; - return; + self.state = AudioEngineState::AudioEngineStateInterrupted; + return AudioEngineInterruptionEndOutcomeStillInterrupted; } self.state = AudioEngineState::AudioEngineStateRunning; self.sessionDeactivationInvalidatedGraph = false; + 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); } } @@ -527,11 +595,11 @@ - (void)rebuildAudioEngineAndResumeIfNeeded [self rebuildAudioEngine]; self.sessionDeactivationInvalidatedGraph = false; - // Before startEngine: the input consumers must match the new route's format - // before the first render callback arrives on it. - [self notifyConfigurationChanges]; - if (self.state == AudioEngineState::AudioEngineStateRunning) { + // Before startEngine: the input consumers must match the new route's format + // before the first render callback arrives on it. + [self notifyInput:AudioEngineInputNotificationHardwareChanged]; + if (![self startEngine]) { self.state = AudioEngineState::AudioEngineStateIdle; [self notifyOutputRecoveryFailed]; @@ -643,10 +711,8 @@ - (void)stopIfNecessary - (void)stopIfPossible { std::scoped_lock lock(_engineLock); - BOOL hasInput = self.inputRegistration != nil; - BOOL hasSources = [self.sourceRegistrations count] > 0; - if (hasInput || hasSources) { + if ([self hasInput] || [self hasSources]) { return; } 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..53a17f139 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,13 @@ @property (nonatomic, strong) NSTimer *hintPollingTimer; @property (nonatomic, assign) bool hadConfigurationChange; @property (nonatomic, assign) bool audioInterruptionsObserved; +/// Main queue. Set when AVAudioSession posts InterruptionEnded (or the secondary-audio +/// equivalent), cleared by the next begin. A foreground retry runs only while it is set. +@property (nonatomic, assign) bool interruptionEndedDelivered; +/// Main queue. `AVAudioSessionInterruptionOptionShouldResume` of the current interruption's +/// ended notification. Nil until it arrives, and again once the engine accepts the next begin, +/// so a later secondary-audio end never replays an earlier interruption's hint; nil resumes. +@property (nonatomic, strong) NSNumber *interruptionShouldResume; @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 e3c27a524..1ea6cfed1 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,74 +121,118 @@ - (void)observeValueForKeyPath:(NSString *)keyPath } } -- (void)handleInterruption:(NSNotification *)notification +- (void)handleWillEnterForeground:(NSNotification *)notification +{ + [self retryInterruptedRecordingIfNeeded]; +} + +- (void)handleDidBecomeActive:(NSNotification *)notification +{ + [self retryInterruptedRecordingIfNeeded]; +} + +- (void)emitInterruptionBegan +{ + if (!self.audioInterruptionsObserved) { + return; + } + + [self.audioAPIModule invokeHandlerWithEventName:audioapi::AudioEvent::INTERRUPTION + payload:audioapi::InterruptionPayload{ + .type = "began", .shouldResume = false}]; +} + +- (void)emitInterruptionEnded:(bool)shouldResume +{ + if (!self.audioInterruptionsObserved) { + return; + } + + [self.audioAPIModule + invokeHandlerWithEventName:audioapi::AudioEvent::INTERRUPTION + payload:audioapi::InterruptionPayload{ + .type = "ended", .shouldResume = shouldResume}]; +} + +- (void)handleInterruptionBegin { AudioEngine *audioEngine = self.audioAPIModule.audioEngine; AudioSessionManager *sessionManager = self.audioAPIModule.audioSessionManager; + dispatch_async(dispatch_get_main_queue(), ^{ + self.interruptionEndedDelivered = false; + bool transitioned = [audioEngine onInterruptionBegin]; + [sessionManager markInactive]; + if (transitioned) { + self.interruptionShouldResume = nil; + [self emitInterruptionBegan]; + } + }); +} + +- (void)handleInterruptionEnd:(bool)shouldResume +{ + AudioEngine *audioEngine = self.audioAPIModule.audioEngine; + + dispatch_async(dispatch_get_main_queue(), ^{ + self.interruptionEndedDelivered = true; + AudioEngineInterruptionEndOutcome outcome = [audioEngine onInterruptionEnd:shouldResume]; + if (outcome == AudioEngineInterruptionEndOutcomeRunning || + outcome == AudioEngineInterruptionEndOutcomePaused) { + [self emitInterruptionEnded:shouldResume]; + } + }); +} + +- (bool)shouldResumeForInterruptionRecovery +{ + if (self.interruptionShouldResume == nil) { + return true; + } + + return self.interruptionShouldResume.boolValue; +} + +- (void)retryInterruptedRecordingIfNeeded +{ + AudioEngine *audioEngine = self.audioAPIModule.audioEngine; + + if (self.interruptionEndedDelivered && [audioEngine getState] == AudioEngineStateInterrupted) { + [self handleInterruptionEnd:[self shouldResumeForInterruptionRecovery]]; + } +} + +- (void)handleInterruption:(NSNotification *)notification +{ NSInteger interruptionType = [notification.userInfo[AVAudioSessionInterruptionTypeKey] integerValue]; NSInteger interruptionOption = [notification.userInfo[AVAudioSessionInterruptionOptionKey] integerValue]; if (interruptionType == AVAudioSessionInterruptionTypeBegan) { - dispatch_async(dispatch_get_main_queue(), ^{ - [audioEngine onInterruptionBegin]; - [sessionManager markInactive]; - }); - - if (self.audioInterruptionsObserved) { - [self.audioAPIModule invokeHandlerWithEventName:audioapi::AudioEvent::INTERRUPTION - payload:audioapi::InterruptionPayload{ - .type = "began", .shouldResume = false}]; - } - + [self handleInterruptionBegin]; 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]; }); - } + dispatch_async(dispatch_get_main_queue(), ^{ self.interruptionShouldResume = @(shouldResume); }); + [self handleInterruptionEnd:shouldResume]; } - (void)handleSecondaryAudio:(NSNotification *)notification { - AudioEngine *audioEngine = self.audioAPIModule.audioEngine; - AudioSessionManager *sessionManager = self.audioAPIModule.audioSessionManager; NSInteger secondaryAudioType = [notification.userInfo[AVAudioSessionSilenceSecondaryAudioHintTypeKey] integerValue]; if (secondaryAudioType == AVAudioSessionSilenceSecondaryAudioHintTypeBegin) { - dispatch_async(dispatch_get_main_queue(), ^{ - [sessionManager markInactive]; - [audioEngine onInterruptionBegin]; - }); - - if (self.audioInterruptionsObserved) { - [self.audioAPIModule invokeHandlerWithEventName:audioapi::AudioEvent::INTERRUPTION - payload:audioapi::InterruptionPayload{ - .type = "began", .shouldResume = false}]; - } + [self handleInterruptionBegin]; 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 handleInterruptionEnd:shouldResume]; } - (void)handleRouteChange:(NSNotification *)notification @@ -271,6 +325,16 @@ - (void)handleEngineConfigurationChange:(NSNotification *)notification return; } + // 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]; }); @@ -307,32 +371,14 @@ - (void)checkSecondaryAudioHint return; } - AudioEngine *audioEngine = self.audioAPIModule.audioEngine; - AudioSessionManager *sessionManager = self.audioAPIModule.audioSessionManager; - self.wasOtherAudioPlaying = shouldSilence; if (shouldSilence) { - dispatch_async(dispatch_get_main_queue(), ^{ - [sessionManager markInactive]; - [audioEngine onInterruptionBegin]; - }); - if (self.audioInterruptionsObserved) { - [self.audioAPIModule invokeHandlerWithEventName:audioapi::AudioEvent::INTERRUPTION - payload:audioapi::InterruptionPayload{ - .type = "began", .shouldResume = false}]; - } - + [self handleInterruptionBegin]; 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 handleInterruptionEnd:[self shouldResumeForInterruptionRecovery]]; } @end