Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
2c35596
fix: issue #1220 suggested onInterruptionEnd update
SomePersonFromMars Aug 20, 2026
0547b09
fix: minor tweaks to previous flawed patch
SomePersonFromMars Aug 24, 2026
b9fd96e
fix: add mixWithOthers to Recorder demo
SomePersonFromMars Aug 27, 2026
b93c5c1
fix: generalize handleInputConfigurationChange() to handle errors
SomePersonFromMars Aug 27, 2026
523e5a4
fix: retry AudioEngine resume from interrupt when back foreground
SomePersonFromMars Aug 27, 2026
1bfa489
fix: reopen a new rotating segment after capture loss instead of rota…
SomePersonFromMars Aug 27, 2026
6283fb7
fix: retry AudioEngine resume only happens when interruption ended wa…
SomePersonFromMars Aug 28, 2026
bc74d58
fix: always perform native recovery after interruption notification
SomePersonFromMars Aug 31, 2026
f12b250
feat: observeAudioInterruptions(true) in the Record demo
SomePersonFromMars Aug 31, 2026
86a9ae7
feat: document resuming recording after an interruption
SomePersonFromMars Sep 2, 2026
673d326
fix: detach source node in player on failed start similarly to what r…
SomePersonFromMars Sep 2, 2026
d648901
fix: don't poison state on a no-op deactivation
SomePersonFromMars Sep 3, 2026
33c3753
fix: recorder resume() only updates state on successful engine start
SomePersonFromMars Sep 3, 2026
bcadbb3
Merge branch 'main' into fix/ios-recording-interrupted
SomePersonFromMars Sep 17, 2026
32c2906
Merge branch 'main' into fix/ios-recording-interrupted
SomePersonFromMars Sep 17, 2026
6b17b0f
fix: workaround to mitigate infinite loop when starting recording
SomePersonFromMars Sep 18, 2026
d9268b3
fix: schedule rebuild on configuration change instead of doing it int…
SomePersonFromMars Sep 18, 2026
3249d2d
Merge branch 'main' into fix/ios-recording-interrupted
SomePersonFromMars Sep 22, 2026
47f0fe9
fix: update search paths for RN 0.87
closetcaiman Sep 24, 2026
86de938
fix: remove restart recursion
closetcaiman Sep 24, 2026
1b73479
chore: resolve conflicts
closetcaiman Sep 24, 2026
9513b27
fix: move locks out of callbacks
closetcaiman Sep 25, 2026
41dbdd5
Merge branch 'main' into fix/ios-recording-interrupted and verified o…
SomePersonFromMars Sep 28, 2026
c4601b4
fix: explicitely expressed logic of playback and recording resume
SomePersonFromMars Sep 29, 2026
f5c49c4
fix: cache the intended `shouldResume` value for retry
SomePersonFromMars Sep 29, 2026
a6acc6b
fix: wording in apps/CLAUDE.md
SomePersonFromMars Sep 29, 2026
02f7a69
Merge branch 'main' into fix/ios-recording-interrupted
SomePersonFromMars Oct 1, 2026
3c491fc
fix: broken relative audiodocs links
SomePersonFromMars Oct 1, 2026
391ae4e
fix(ios): stop vpio loop
closetcaiman Oct 9, 2026
2dc7757
fix(ios): restart on owned engine
closetcaiman Oct 9, 2026
ba9e6b5
style: cleanups
SomePersonFromMars Oct 9, 2026
aa49c2b
Merge branch 'main' into fix/ios-recording-interrupted
SomePersonFromMars Oct 12, 2026
4196362
chore(merge): merge with stack base
closetcaiman Oct 14, 2026
a623c95
chore: extract docs badges to docs-theme
SomePersonFromMars Oct 15, 2026
df1c080
docs: simplify audiodocs and add detailed library behavior and OS res…
SomePersonFromMars Oct 15, 2026
07dad83
refactor: inputBlock and comment trimming
closetcaiman Oct 16, 2026
dba0ad8
Merge branch 'main' into fix/ios-recording-interrupted
SomePersonFromMars Oct 19, 2026
050d477
chore: merge
closetcaiman Oct 19, 2026
f2361b5
fix: comments
closetcaiman Oct 20, 2026
e6afbca
chore: merge
closetcaiman Oct 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -64,17 +64,25 @@ typedef void (^OnOutputRecoveryFailedBlock)(void);
/// 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;
/// Marks a stopped graph for rebuild on the next start and leaves a running engine alone:
/// a real format change arrives as AVAudioEngineConfigurationChangeNotification instead.
- (void)onRouteChange;

- (AudioEngineState)getState;
- (bool)isEngineRunning;
- (bool)isInUse;
/// True only for the AVAudioEngine this object drives now: not nil, foreign or replaced ones.
- (bool)isCurrentEngine:(AVAudioEngine *)engine;

- (bool)startIfNecessary;
- (void)pauseIfNecessary;
- (void)stopIfNecessary;

- (void)stopIfPossible;

/// @brief Rebuilds the engine against the current hardware and starts it again if it was
/// running. The input hears `HardwareChanged` between the two, outside the lock, so its
/// consumers match the new format before the first render callback.
- (void)restartAudioEngine;

- (void)logAudioEngineState;
Expand Down
221 changes: 179 additions & 42 deletions packages/react-native-audio-api/ios/audioapi/ios/system/AudioEngine.mm
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,9 @@ @implementation AudioEngineInputRegistration
@end

@interface AudioEngine () {
std::recursive_mutex _engineLock;
BOOL _isRebuildingAudioEngine;
/// Guards the engine and its tracked graph. Never taken on a thread AVFoundation owns:
/// dealloc, stop, reset, prepare and start can wait on the calling thread.
std::mutex _engineLock;
/// Tracks whether voice processing is currently engaged on the system input
/// node of the live engine instance. Reset whenever the engine is recreated.
BOOL _voiceProcessingApplied;
Expand All @@ -46,13 +47,20 @@ - (BOOL)hasTrackedGraph;
- (AVAudioFormat *)currentInputConnectionFormat;
- (void)materializeSourceNodeWithId:(NSString *)sourceNodeId;
- (BOOL)materializeInputNodeIfNeeded;
- (void)connectVoiceProcessingIOOutput:(AVAudioFormat *)format;
- (void)applyVoiceProcessing;
- (void)materializeTrackedNodesIfNeeded;

- (AVAudioFormat *)liveInputFormat;
- (void)resetInputNode;
- (void)rebuildAudioEngineAndResumeIfNeeded;
- (void)notifyInput:(AudioEngineInputNotification)notification;
- (BOOL)mustRebuildBeforeStart;
- (void)rebuildGraphForCurrentHardware;
- (void)settleStateAfterFailedStart;
- (void)resumeAfterRebuild;
- (dispatch_block_t)inputNotificationBlock:(AudioEngineInputNotification)notification;
- (AudioEngineInterruptionEndOutcome)rebuildAfterInterruption:(bool)shouldResume
readyToStart:(BOOL *)readyToStart;
- (AudioEngineInterruptionEndOutcome)startAfterInterruption;

@end

Expand Down Expand Up @@ -201,10 +209,9 @@ - (void)materializeSourceNodeWithId:(NSString *)sourceNodeId
[self.audioEngine connect:sourceNode to:self.audioEngine.mainMixerNode format:format];
}

/// @brief The input node's current output format, or nil when unusable. Lock held.
- (AVAudioFormat *)liveInputFormat
{
std::scoped_lock lock(_engineLock);

if (self.audioEngine == nil) {
return nil;
}
Expand Down Expand Up @@ -264,13 +271,27 @@ - (BOOL)materializeInputNodeIfNeeded
return NO;
}

if (_voiceProcessingApplied) {
[self connectVoiceProcessingIOOutput:inputFormat];
}

self.inputNode =
[[AVAudioSinkNode alloc] initWithReceiverBlock:self.inputRegistration.receiverBlock];
[self.audioEngine attachNode:self.inputNode];
[self.audioEngine connect:self.audioEngine.inputNode to:self.inputNode format:inputFormat];
return YES;
}

/// @brief Connects the main mixer to the output in the input's format.
/// @note The voice-processing I/O unit runs both directions and needs matching formats;
/// with its output bus unconnected or implicitly formatted, the engine stops itself.
- (void)connectVoiceProcessingIOOutput:(AVAudioFormat *)format
{
[self.audioEngine connect:self.audioEngine.mainMixerNode
to:self.audioEngine.outputNode
format:format];
}

// Apple's voice-processing I/O (echo cancellation, noise suppression, AGC) is
// opt-in per recorder. Without it, full-duplex apps (VoIP, voice agents) hear
// their own speaker output looped back into the microphone. Toggling it is only
Expand Down Expand Up @@ -418,6 +439,7 @@ - (void)detachInputNode

- (AVAudioFormat *)getLiveInputFormat
{
std::scoped_lock lock(_engineLock);
return [self liveInputFormat];
}

Expand Down Expand Up @@ -472,6 +494,16 @@ - (void)markGraphNeedsRebuild
self.graphNeedsRebuild = true;
}

- (void)onRouteChange
{
std::scoped_lock lock(_engineLock);
if (![self hasTrackedGraph] || self.state == AudioEngineState::AudioEngineStateRunning) {
return;
}

self.graphNeedsRebuild = true;
}

// According to <https://developer.apple.com/documentation/avfaudio/avaudiosession/interruptionoptions/shouldresume>:
// * `shouldResume` only refers to resuming the playback.
// * We choose to resume recording unconditionally (but see below).
Expand All @@ -480,8 +512,6 @@ - (void)markGraphNeedsRebuild
static BOOL
shouldEngineResumeAfterInterruption(BOOL shouldPlaybackResume, BOOL hasInput, BOOL hasSources)
{
BOOL resumeEngine = NO;

if (hasInput) {
if (hasSources) {
if (shouldPlaybackResume) {
Expand All @@ -505,8 +535,41 @@ - (void)markGraphNeedsRebuild

- (AudioEngineInterruptionEndOutcome)onInterruptionEnd:(bool)shouldResume
{
AudioEngineInterruptionEndOutcome outcome;
BOOL readyToStart = NO;
dispatch_block_t notifyInput = nil;

{
std::scoped_lock lock(_engineLock);
outcome = [self rebuildAfterInterruption:shouldResume readyToStart:&readyToStart];

if (outcome == AudioEngineInterruptionEndOutcomePaused || readyToStart) {
notifyInput = [self inputNotificationBlock:AudioEngineInputNotificationHardwareChanged];
}
}

// Before starting: the input's consumers must match the new format before the first render.
if (notifyInput != nil) {
notifyInput();
}

if (!readyToStart) {
return outcome;
}

std::scoped_lock lock(_engineLock);
return [self startAfterInterruption];
}

/// @brief Rebuilds an interrupted engine for resuming, or leaves it `Paused` when policy says
/// not to. Lock held.
/// @discussion Sets @p readyToStart when the rebuilt engine should be started; the returned
/// outcome is final only when it is not set.
- (AudioEngineInterruptionEndOutcome)rebuildAfterInterruption:(bool)shouldResume
readyToStart:(BOOL *)readyToStart
{
NSError *error = nil;
*readyToStart = NO;

if (self.state != AudioEngineState::AudioEngineStateInterrupted) {
return AudioEngineInterruptionEndOutcomeNoOp;
Expand All @@ -519,7 +582,6 @@ - (AudioEngineInterruptionEndOutcome)onInterruptionEnd:(bool)shouldResume
[self stopEngine];
[self rebuildAudioEngine];
self.state = AudioEngineState::AudioEngineStatePaused;
[self notifyInput:AudioEngineInputNotificationHardwareChanged];
return AudioEngineInterruptionEndOutcomePaused;
}

Expand All @@ -538,15 +600,32 @@ - (AudioEngineInterruptionEndOutcome)onInterruptionEnd:(bool)shouldResume
return AudioEngineInterruptionEndOutcomeStillInterrupted;
}

[self notifyInput:AudioEngineInputNotificationHardwareChanged];
// Still Interrupted: startAfterInterruption: runs only if nothing else claimed the engine.
self.state = AudioEngineState::AudioEngineStateInterrupted;
*readyToStart = YES;
return AudioEngineInterruptionEndOutcomeStillInterrupted;
}

/// @brief Starts the engine rebuilt by rebuildAfterInterruption:, unless it was stopped or
/// started meanwhile. Lock held.
- (AudioEngineInterruptionEndOutcome)startAfterInterruption
{
if (self.state == AudioEngineState::AudioEngineStateRunning && [self.audioEngine isRunning]) {
return AudioEngineInterruptionEndOutcomeRunning;
}

if (self.state != AudioEngineState::AudioEngineStateInterrupted) {
return AudioEngineInterruptionEndOutcomeNoOp;
}

NSError *error = nil;
[self.audioEngine prepare];
[self.audioEngine startAndReturnError:&error];

if (error != nil) {
NSLog(
@"Error while restarting the audio engine after interruption: %@",
[error debugDescription]);
self.state = AudioEngineState::AudioEngineStateInterrupted;
return AudioEngineInterruptionEndOutcomeStillInterrupted;
}

Expand All @@ -555,11 +634,18 @@ - (AudioEngineInterruptionEndOutcome)onInterruptionEnd:(bool)shouldResume
return AudioEngineInterruptionEndOutcomeRunning;
}

- (void)notifyInput:(AudioEngineInputNotification)notification
/// @brief The registered input's handler bound to @p notification, or nil without one.
/// @discussion Run it only after releasing `_engineLock`: the handler calls back into the engine.
// TODO: replace the returned blocks with explicit post-lock effects in the engine refactor.
- (dispatch_block_t)inputNotificationBlock:(AudioEngineInputNotification)notification
{
if (self.inputRegistration != nil && self.inputRegistration.onInputNotification != nil) {
self.inputRegistration.onInputNotification(notification);
void (^handler)(AudioEngineInputNotification) = self.inputRegistration.onInputNotification;

if (handler == nil) {
return nil;
}

return ^{ handler(notification); };
}

- (AudioEngineState)getState
Expand All @@ -580,35 +666,61 @@ - (bool)isInUse
return [self hasTrackedGraph] || self.audioEngine != nil;
}

- (void)rebuildAudioEngineAndResumeIfNeeded
- (bool)isCurrentEngine:(AVAudioEngine *)engine
{
std::scoped_lock lock(_engineLock);
return engine != nil && engine == self.audioEngine;
}

/// @brief Starts the rebuilt engine, unless it was paused, stopped or started meanwhile. Lock held.
- (void)resumeAfterRebuild
{
if (_isRebuildingAudioEngine) {
if (self.state != AudioEngineState::AudioEngineStateRunning || ![self hasTrackedGraph] ||
[self.audioEngine isRunning]) {
return;
}

_isRebuildingAudioEngine = YES;
if (![self startEngine]) {
[self settleStateAfterFailedStart];
[self notifyOutputRecoveryFailed];
}
}

/// @brief True when the graph may no longer match the hardware: interrupted, session
/// deactivated, or hardware changed while stopped.
- (BOOL)mustRebuildBeforeStart
{
return self.state == AudioEngineState::AudioEngineStateInterrupted || self.graphNeedsRebuild ||
self.sessionDeactivationInvalidatedGraph;
}

/// @brief Stops the engine if running, rebuilds it, and forgets a past session deactivation;
/// leaves it stopped. Lock held.
/// @discussion Never starts, so rebuilding and starting cannot recurse into each other.
- (void)rebuildGraphForCurrentHardware
{
if ([self.audioEngine isRunning]) {
[self.audioEngine stop];
}

[self rebuildAudioEngine];
self.sessionDeactivationInvalidatedGraph = false;
}

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];
}
/// @brief After a failed start: `Paused` with a tracked graph, `Idle` without one.
/// @discussion `Interrupted` is kept: the interruption-end path and the foreground retry
/// recover only from that state.
- (void)settleStateAfterFailedStart
{
if (self.state == AudioEngineState::AudioEngineStateInterrupted) {
return;
}

_isRebuildingAudioEngine = NO;
self.state = [self hasTrackedGraph] ? AudioEngineState::AudioEngineStatePaused
: AudioEngineState::AudioEngineStateIdle;
}

/// @brief Destroys the engine and creates a new one with the tracked graph, left stopped. Lock held.
- (void)rebuildAudioEngine
{
[self destroyAudioEnginePreservingSessionDeactivationState:YES];
Expand All @@ -634,12 +746,8 @@ - (bool)startEngine
return false;
}

if (self.state == AudioEngineState::AudioEngineStateInterrupted || self.graphNeedsRebuild ||
self.sessionDeactivationInvalidatedGraph) {
[self rebuildAudioEngineAndResumeIfNeeded];
} else {
[self materializeTrackedNodesIfNeeded];
}
// Never rebuilds; callers check `mustRebuildBeforeStart` first.
[self materializeTrackedNodesIfNeeded];

if (self.inputRegistration != nil && self.inputNode == nil) {
NSLog(@"Error while materializing the audio input node: missing live input format");
Expand Down Expand Up @@ -681,10 +789,19 @@ - (bool)startIfNecessary
return true;
}

if ([self hasTrackedGraph]) {
return [self startEngine];
if (![self hasTrackedGraph]) {
return false;
}

if ([self mustRebuildBeforeStart]) {
[self rebuildGraphForCurrentHardware];
}

if ([self startEngine]) {
return true;
}

[self settleStateAfterFailedStart];
return false;
}

Expand Down Expand Up @@ -723,16 +840,36 @@ - (void)stopIfPossible

- (void)restartAudioEngine
{
std::scoped_lock lock(_engineLock);
BOOL shouldResume = NO;
dispatch_block_t notifyInput = nil;

// The engine is created lazily on first node attach. Apps that only use
// session management and notifications never have one, and a system-driven
// restart (media services reset, configuration change) must not create it.
if (![self hasTrackedGraph] && self.audioEngine == nil) {
return;
{
std::scoped_lock lock(_engineLock);

// The engine is created lazily on first node attach. Apps that only use
// session management and notifications never have one, and a system-driven
// restart (media services reset, configuration change) must not create it.
if (![self hasTrackedGraph] && self.audioEngine == nil) {
return;
}

shouldResume = self.state == AudioEngineState::AudioEngineStateRunning;
[self rebuildGraphForCurrentHardware];

if (shouldResume) {
notifyInput = [self inputNotificationBlock:AudioEngineInputNotificationHardwareChanged];
}
}

[self rebuildAudioEngineAndResumeIfNeeded];
// Before starting: the input's consumers must match the new format before the first render.
if (notifyInput != nil) {
notifyInput();
}

if (shouldResume) {
std::scoped_lock lock(_engineLock);
[self resumeAfterRebuild];
}
}

- (void)logAudioEngineState
Expand Down
Loading
Loading