From 2c355965aafcd162cfbd888e6108c12fad7624f8 Mon Sep 17 00:00:00 2001 From: Kacper Orszulak <42392219+SomePersonFromMars@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:18:44 +0200 Subject: [PATCH 01/29] fix: issue #1220 suggested onInterruptionEnd update --- .../ios/audioapi/ios/system/AudioEngine.mm | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/packages/react-native-audio-api/ios/audioapi/ios/system/AudioEngine.mm b/packages/react-native-audio-api/ios/audioapi/ios/system/AudioEngine.mm index e3c73fca0..ee6b41187 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 @@ -438,12 +438,27 @@ - (void)onInterruptionEnd:(bool)shouldResume return; } + if (!shouldResume && self.inputRegistration == nil) { + [self stopEngine]; + [self rebuildAudioEngine]; + self.state = AudioEngineState::AudioEngineStatePaused; + return; + } + + // BEFORE any engine call: the format read inside rebuildAudioEngine needs an active session, and a + // refusal here must return with Interrupted still intact so a later trigger can retry. + if (![self.sessionManager ensureActive:true error:&error]) { + NSLog(@"Error while activating audio session after interruption: %@", [error debugDescription]); + return; + } + [self stopEngine]; [self rebuildAudioEngine]; - if (!shouldResume) { - self.state = AudioEngineState::AudioEngineStatePaused; - [self notifyConfigurationChanges]; + 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; } @@ -454,14 +469,12 @@ - (void)onInterruptionEnd:(bool)shouldResume NSLog( @"Error while restarting the audio engine after interruption: %@", [error debugDescription]); - self.state = AudioEngineState::AudioEngineStateIdle; - [self notifyConfigurationChanges]; - return; + self.state = AudioEngineState::AudioEngineStateInterrupted; + // not Idle: stopEngine already set that return; } self.state = AudioEngineState::AudioEngineStateRunning; self.sessionDeactivationInvalidatedGraph = false; - [self notifyConfigurationChanges]; } - (void)notifyConfigurationChanges From 0547b09ff3501ea5f9314339e21155187807d2e6 Mon Sep 17 00:00:00 2001 From: Kacper Orszulak <42392219+SomePersonFromMars@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:19:36 +0200 Subject: [PATCH 02/29] fix: minor tweaks to previous flawed patch --- .../ios/audioapi/ios/system/AudioEngine.mm | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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 ee6b41187..b62c5c0fd 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 @@ -442,11 +442,10 @@ - (void)onInterruptionEnd:(bool)shouldResume [self stopEngine]; [self rebuildAudioEngine]; self.state = AudioEngineState::AudioEngineStatePaused; + [self notifyConfigurationChanges]; return; } - // BEFORE any engine call: the format read inside rebuildAudioEngine needs an active session, and a - // refusal here must return with Interrupted still intact so a later trigger can retry. if (![self.sessionManager ensureActive:true error:&error]) { NSLog(@"Error while activating audio session after interruption: %@", [error debugDescription]); return; @@ -459,6 +458,7 @@ - (void)onInterruptionEnd:(bool)shouldResume NSLog( @"Error while materializing the audio input node after interruption: missing live input format"); self.state = AudioEngineState::AudioEngineStateInterrupted; + [self notifyConfigurationChanges]; return; } @@ -470,11 +470,13 @@ - (void)onInterruptionEnd:(bool)shouldResume @"Error while restarting the audio engine after interruption: %@", [error debugDescription]); self.state = AudioEngineState::AudioEngineStateInterrupted; - // not Idle: stopEngine already set that return; + [self notifyConfigurationChanges]; + return; } self.state = AudioEngineState::AudioEngineStateRunning; self.sessionDeactivationInvalidatedGraph = false; + [self notifyConfigurationChanges]; } - (void)notifyConfigurationChanges From b9fd96e4f7c360a2000f617932bc20707723c45b Mon Sep 17 00:00:00 2001 From: Kacper Orszulak <42392219+SomePersonFromMars@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:34:42 +0200 Subject: [PATCH 03/29] fix: add mixWithOthers to Recorder demo --- apps/common-app/src/demos/Record/Record.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/common-app/src/demos/Record/Record.tsx b/apps/common-app/src/demos/Record/Record.tsx index 6bcb7df73..1b480da40 100644 --- a/apps/common-app/src/demos/Record/Record.tsx +++ b/apps/common-app/src/demos/Record/Record.tsx @@ -78,7 +78,7 @@ const Record: FC = () => { AudioManager.setAudioSessionOptions({ iosCategory: 'playAndRecord', iosMode: 'default', - iosOptions: ['defaultToSpeaker', 'allowBluetoothA2DP'], + iosOptions: ['defaultToSpeaker', 'allowBluetoothA2DP', 'mixWithOthers'], }); try { From b93c5c137692326f2ef6a25478a86f6964ce5dec Mon Sep 17 00:00:00 2001 From: Kacper Orszulak <42392219+SomePersonFromMars@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:35:54 +0200 Subject: [PATCH 04/29] fix: generalize handleInputConfigurationChange() to handle errors --- .../ios/audioapi/ios/core/IOSAudioRecorder.h | 5 +- .../ios/audioapi/ios/core/IOSAudioRecorder.mm | 56 ++++++++++++++++--- .../audioapi/ios/core/NativeAudioRecorder.h | 3 +- .../audioapi/ios/core/NativeAudioRecorder.m | 8 +-- .../ios/audioapi/ios/system/AudioEngine.h | 8 ++- .../ios/audioapi/ios/system/AudioEngine.mm | 29 +++++----- 6 files changed, 82 insertions(+), 27 deletions(-) diff --git a/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.h b/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.h index d46d2ff06..25530568b 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.h +++ b/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.h @@ -8,6 +8,7 @@ typedef struct objc_object AVAudioFile; typedef struct objc_object AudioBufferList; typedef struct objc_object NativeAudioRecorder; typedef struct objc_object AVAudioFormat; +typedef enum AudioEngineInputNotification : long AudioEngineInputNotification; #endif // __OBJC__ #include @@ -75,7 +76,9 @@ class IOSAudioRecorder : public AudioRecorder { const std::shared_ptr &properties, const std::string &fileNameOverride = ""); Result reprepareForLiveInput(); - void handleInputConfigurationChange(); + void handleInputNotification(AudioEngineInputNotification notification); + void handleHardwareChange(); + void handleCaptureLost(); Result reprepareFileWriter( AVAudioFormat *inputFormat, int maxInputBufferLength); diff --git a/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.mm b/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.mm index 26177044c..017945566 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.mm +++ b/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.mm @@ -83,7 +83,8 @@ static void cleanupStartedRecorder( nativeRecorder_ = [[NativeAudioRecorder alloc] initWithReceiverBlock:receiverBlock voiceProcessingEnabled:options.iosVoiceProcessing]; - nativeRecorder_.onInputConfigurationChange = ^{ this->handleInputConfigurationChange(); }; + nativeRecorder_.onInputNotification = + ^(AudioEngineInputNotification notification) { this->handleInputNotification(notification); }; } void IOSAudioRecorder::runSideEffects(const AudioBufferList *inputBuffer, int numFrames) @@ -112,7 +113,19 @@ static void cleanupStartedRecorder( } } -void IOSAudioRecorder::handleInputConfigurationChange() +void IOSAudioRecorder::handleInputNotification(AudioEngineInputNotification notification) +{ + switch (notification) { + case AudioEngineInputNotificationHardwareChanged: + handleHardwareChange(); + return; + case AudioEngineInputNotificationCaptureLost: + handleCaptureLost(); + return; + } +} + +void IOSAudioRecorder::handleHardwareChange() { if (isIdle()) { return; @@ -123,7 +136,12 @@ static void cleanupStartedRecorder( return; } - if (!formatChanged) { + const bool outputNeedsReprepare = + (wantsFileOutput() && !fileOutputConfigured_.load(std::memory_order_acquire)) || + (wantsCallback() && !callbackOutputConfigured_.load(std::memory_order_acquire)) || + (wantsConnection() && !connectedConfigured_.load(std::memory_order_acquire)); + + if (!formatChanged && !outputNeedsReprepare) { if (state_.load(std::memory_order_acquire) == RecorderState::Recording) { [nativeRecorder_ setInputArmed:true]; } @@ -133,6 +151,30 @@ static void cleanupStartedRecorder( reprepareForLiveInput(); } +void IOSAudioRecorder::handleCaptureLost() +{ + if (isIdle()) { + return; + } + + [nativeRecorder_ setInputArmed:false]; + + std::scoped_lock lock(callbackMutex_, fileWriterMutex_, adapterNodeMutex_); + const bool shouldFinalizeFile = + fileOutputConfigured_.exchange(false, std::memory_order_acq_rel) && fileWriter_ != nullptr; + callbackOutputConfigured_.store(false, std::memory_order_release); + connectedConfigured_.store(false, std::memory_order_release); + + if (shouldFinalizeFile) { + auto closeResult = fileWriter_->closeFile(); + if (closeResult.is_err()) { + NSLog( + @"Error while finalizing recording segment after capture was lost: %s", + closeResult.unwrap_err().c_str()); + } + } +} + Result IOSAudioRecorder::reprepareForLiveInput() { if (isIdle()) { @@ -150,7 +192,7 @@ static void cleanupStartedRecorder( const bool shouldArmInput = state_.load(std::memory_order_acquire) == RecorderState::Recording; [nativeRecorder_ setInputArmed:false]; - if (usesFileOutput()) { + if (wantsFileOutput()) { auto fileResult = reprepareFileWriter(inputFormat, maxInputBufferLength); if (fileResult.is_err()) { if (shouldArmInput) { @@ -160,7 +202,7 @@ static void cleanupStartedRecorder( } } - if (usesCallback()) { + if (wantsCallback()) { auto callbackResult = reprepareCallback(inputFormat, maxInputBufferLength); if (callbackResult.is_err()) { if (shouldArmInput) { @@ -170,7 +212,7 @@ static void cleanupStartedRecorder( } } - if (isConnected() && adapterNodeHandle_ != nullptr) { + if (wantsConnection() && adapterNodeHandle_ != nullptr) { reprepareAdapter(inputFormat, maxInputBufferLength); } @@ -262,7 +304,7 @@ static void cleanupStartedRecorder( { stop(); - nativeRecorder_.onInputConfigurationChange = nil; + nativeRecorder_.onInputNotification = nil; { std::scoped_lock lock(callbackMutex_, fileWriterMutex_, adapterNodeMutex_); diff --git a/packages/react-native-audio-api/ios/audioapi/ios/core/NativeAudioRecorder.h b/packages/react-native-audio-api/ios/audioapi/ios/core/NativeAudioRecorder.h index 20f1f1c32..b75a032b2 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/core/NativeAudioRecorder.h +++ b/packages/react-native-audio-api/ios/audioapi/ios/core/NativeAudioRecorder.h @@ -2,6 +2,7 @@ #import #import +#import typedef void (^AudioReceiverBlock)(const AudioBufferList *inputBuffer, int numFrames); @@ -13,7 +14,7 @@ typedef void (^AudioReceiverBlock)(const AudioBufferList *inputBuffer, int numFr @property (nonatomic, assign) int resolvedBufferSize; @property (atomic, assign) BOOL inputArmed; @property (nonatomic, assign) BOOL voiceProcessingEnabled; -@property (nonatomic, copy) void (^onInputConfigurationChange)(void); +@property (nonatomic, copy) void (^onInputNotification)(AudioEngineInputNotification); - (instancetype)initWithReceiverBlock:(AudioReceiverBlock)receiverBlock voiceProcessingEnabled:(BOOL)voiceProcessingEnabled; diff --git a/packages/react-native-audio-api/ios/audioapi/ios/core/NativeAudioRecorder.m b/packages/react-native-audio-api/ios/audioapi/ios/core/NativeAudioRecorder.m index 89479aa8d..487fb558d 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/core/NativeAudioRecorder.m +++ b/packages/react-native-audio-api/ios/audioapi/ios/core/NativeAudioRecorder.m @@ -118,7 +118,7 @@ - (BOOL)start:(NSError **)error [audioEngine stopIfNecessary]; [audioEngine attachInputNodeWithReceiverBlock:self.receiverSinkBlock voiceProcessingEnabled:self.voiceProcessingEnabled - onInputConfigurationChange:self.onInputConfigurationChange]; + onInputNotification:self.onInputNotification]; if (![audioEngine startIfNecessary]) { [audioEngine detachInputNode]; @@ -173,8 +173,8 @@ - (void)resume assert(audioEngine != nil); if ([audioEngine startIfNecessary]) { - if (self.onInputConfigurationChange != nil) { - self.onInputConfigurationChange(); + if (self.onInputNotification != nil) { + self.onInputNotification(AudioEngineInputNotificationHardwareChanged); } else { self.inputArmed = YES; } @@ -188,7 +188,7 @@ - (void)cleanup self.resolvedBufferSize = 0; self.receiverBlock = nil; self.receiverSinkBlock = nil; - self.onInputConfigurationChange = nil; + self.onInputNotification = nil; } @end diff --git a/packages/react-native-audio-api/ios/audioapi/ios/system/AudioEngine.h b/packages/react-native-audio-api/ios/audioapi/ios/system/AudioEngine.h index d4039332c..a56456414 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,11 @@ typedef NS_ENUM(NSInteger, AudioEngineState) { AudioEngineStateInterrupted }; +typedef NS_ENUM(NSInteger, AudioEngineInputNotification) { + AudioEngineInputNotificationHardwareChanged = 0, + AudioEngineInputNotificationCaptureLost +}; + @interface AudioEngine : NSObject @property (nonatomic, assign) AudioEngineState state; @@ -35,7 +40,8 @@ typedef NS_ENUM(NSInteger, AudioEngineState) { - (void)attachInputNodeWithReceiverBlock:(AVAudioSinkNodeReceiverBlock)receiverBlock voiceProcessingEnabled:(BOOL)voiceProcessingEnabled - onInputConfigurationChange:(void (^)(void))onInputConfigurationChange; + onInputNotification: + (void (^)(AudioEngineInputNotification))onInputNotification; - (void)detachInputNode; - (AVAudioFormat *)getLiveInputFormat; 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 b62c5c0fd..f699e9b8a 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/system/AudioEngine.mm +++ b/packages/react-native-audio-api/ios/audioapi/ios/system/AudioEngine.mm @@ -18,7 +18,7 @@ @interface AudioEngineInputRegistration : NSObject @property (nonatomic, copy) AVAudioSinkNodeReceiverBlock receiverBlock; @property (nonatomic, assign) BOOL voiceProcessingEnabled; -@property (nonatomic, copy) void (^onInputConfigurationChange)(void); +@property (nonatomic, copy) void (^onInputNotification)(AudioEngineInputNotification); @end @@ -49,7 +49,7 @@ - (void)materializeTrackedNodesIfNeeded; - (AVAudioFormat *)liveInputFormat; - (void)resetInputNode; - (void)rebuildAudioEngineAndResumeIfNeeded; -- (void)notifyConfigurationChanges; +- (void)notifyInput:(AudioEngineInputNotification)notification; @end @@ -338,7 +338,7 @@ - (void)detachSourceNodeWithId:(NSString *)sourceNodeId - (void)attachInputNodeWithReceiverBlock:(AVAudioSinkNodeReceiverBlock)receiverBlock voiceProcessingEnabled:(BOOL)voiceProcessingEnabled - onInputConfigurationChange:(void (^)(void))onInputConfigurationChange + onInputNotification:(void (^)(AudioEngineInputNotification))onInputNotification { std::scoped_lock lock(_engineLock); [self createAudioEngineIfNeeded]; @@ -350,7 +350,7 @@ - (void)attachInputNodeWithReceiverBlock:(AVAudioSinkNodeReceiverBlock)receiverB AudioEngineInputRegistration *registration = [[AudioEngineInputRegistration alloc] init]; registration.receiverBlock = receiverBlock; registration.voiceProcessingEnabled = voiceProcessingEnabled; - registration.onInputConfigurationChange = onInputConfigurationChange; + registration.onInputNotification = onInputNotification; self.inputRegistration = registration; [self materializeInputNodeIfNeeded]; @@ -442,7 +442,7 @@ - (void)onInterruptionEnd:(bool)shouldResume [self stopEngine]; [self rebuildAudioEngine]; self.state = AudioEngineState::AudioEngineStatePaused; - [self notifyConfigurationChanges]; + [self notifyInput:AudioEngineInputNotificationHardwareChanged]; return; } @@ -458,7 +458,7 @@ - (void)onInterruptionEnd:(bool)shouldResume NSLog( @"Error while materializing the audio input node after interruption: missing live input format"); self.state = AudioEngineState::AudioEngineStateInterrupted; - [self notifyConfigurationChanges]; + [self notifyInput:AudioEngineInputNotificationCaptureLost]; return; } @@ -470,19 +470,19 @@ - (void)onInterruptionEnd:(bool)shouldResume @"Error while restarting the audio engine after interruption: %@", [error debugDescription]); self.state = AudioEngineState::AudioEngineStateInterrupted; - [self notifyConfigurationChanges]; + [self notifyInput:AudioEngineInputNotificationCaptureLost]; return; } self.state = AudioEngineState::AudioEngineStateRunning; self.sessionDeactivationInvalidatedGraph = false; - [self notifyConfigurationChanges]; + [self notifyInput:AudioEngineInputNotificationHardwareChanged]; } -- (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); } } @@ -513,11 +513,14 @@ - (void)rebuildAudioEngineAndResumeIfNeeded [self rebuildAudioEngine]; self.sessionDeactivationInvalidatedGraph = false; + BOOL didStartEngine = NO; if (self.state == AudioEngineState::AudioEngineStateRunning) { - [self startEngine]; + didStartEngine = [self startEngine]; } - [self notifyConfigurationChanges]; + if (didStartEngine) { + [self notifyInput:AudioEngineInputNotificationHardwareChanged]; + } _isRebuildingAudioEngine = NO; } From 523e5a4a687dee755d061d46bb91ff635b3138dd Mon Sep 17 00:00:00 2001 From: Kacper Orszulak <42392219+SomePersonFromMars@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:32:58 +0200 Subject: [PATCH 05/29] fix: retry AudioEngine resume from interrupt when back foreground --- .../ios/audioapi/ios/system/AudioEngine.h | 1 + .../ios/audioapi/ios/system/AudioEngine.mm | 6 +++ .../ios/system/SystemNotificationManager.mm | 41 +++++++++++++++++++ 3 files changed, 48 insertions(+) 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 a56456414..f3b716216 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 @@ -52,6 +52,7 @@ typedef NS_ENUM(NSInteger, AudioEngineInputNotification) { - (AudioEngineState)getState; - (bool)isEngineRunning; +- (bool)hasInputRegistration; - (bool)startIfNecessary; - (void)pauseIfNecessary; diff --git a/packages/react-native-audio-api/ios/audioapi/ios/system/AudioEngine.mm b/packages/react-native-audio-api/ios/audioapi/ios/system/AudioEngine.mm index f699e9b8a..e7b028707 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 @@ -498,6 +498,12 @@ - (bool)isEngineRunning return self.audioEngine != nil && [self.audioEngine isRunning]; } +- (bool)hasInputRegistration +{ + std::scoped_lock lock(_engineLock); + return self.inputRegistration != nil; +} + - (void)rebuildAudioEngineAndResumeIfNeeded { if (_isRebuildingAudioEngine) { diff --git a/packages/react-native-audio-api/ios/audioapi/ios/system/SystemNotificationManager.mm b/packages/react-native-audio-api/ios/audioapi/ios/system/SystemNotificationManager.mm index 383ee14f4..7b99c39f3 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/system/SystemNotificationManager.mm +++ b/packages/react-native-audio-api/ios/audioapi/ios/system/SystemNotificationManager.mm @@ -1,3 +1,5 @@ +#import + #import #import #import @@ -88,6 +90,18 @@ - (void)configureNotifications selector:@selector(handleInterruption:) name:AVAudioSessionInterruptionNotification object:nil]; + // willEnterForeground is the first legal moment to restart I/O after a + // backgrounded interruption. DidBecomeActive retries if that attempt still + // failed, and is the only of the two that fires when returning from + // inactive-in-foreground (Control Center, Siri overlay). + [self.notificationCenter addObserver:self + selector:@selector(handleWillEnterForeground:) + name:UIApplicationWillEnterForegroundNotification + object:nil]; + [self.notificationCenter addObserver:self + selector:@selector(handleDidBecomeActive:) + name:UIApplicationDidBecomeActiveNotification + object:nil]; } - (void)observeValueForKeyPath:(NSString *)keyPath @@ -108,6 +122,33 @@ - (void)observeValueForKeyPath:(NSString *)keyPath } } +- (void)handleWillEnterForeground:(NSNotification *)notification +{ + [self retryInterruptedRecordingIfNeeded]; +} + +- (void)handleDidBecomeActive:(NSNotification *)notification +{ + [self retryInterruptedRecordingIfNeeded]; +} + +- (void)retryInterruptedRecordingIfNeeded +{ + AudioEngine *audioEngine = self.audioAPIModule.audioEngine; + + dispatch_async(dispatch_get_main_queue(), ^{ + if ([audioEngine getState] != AudioEngineStateInterrupted) { + return; + } + + if (![audioEngine hasInputRegistration]) { + return; + } + + [audioEngine onInterruptionEnd:true]; + }); +} + - (void)handleInterruption:(NSNotification *)notification { AudioEngine *audioEngine = self.audioAPIModule.audioEngine; From 1bfa4899782faeebb8b9db8867aba499660ddf49 Mon Sep 17 00:00:00 2001 From: Kacper Orszulak <42392219+SomePersonFromMars@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:36:42 +0200 Subject: [PATCH 06/29] fix: reopen a new rotating segment after capture loss instead of rotating a closed writer --- .../cpp/audioapi/core/utils/AudioFileWriter.cpp | 2 +- .../common/cpp/audioapi/core/utils/AudioFileWriter.h | 3 +-- .../cpp/audioapi/core/utils/RotatingFileWriter.cpp | 3 +++ .../ios/audioapi/ios/core/IOSAudioRecorder.mm | 3 +++ .../ios/audioapi/ios/core/utils/IOSFileWriter.mm | 3 ++- .../audioapi/ios/core/utils/IOSRotatingFileWriter.mm | 12 +++++++----- 6 files changed, 17 insertions(+), 9 deletions(-) diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioFileWriter.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioFileWriter.cpp index 1c9823ad1..4c1de5be8 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioFileWriter.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioFileWriter.cpp @@ -19,7 +19,7 @@ void AudioFileWriter::invokeOnErrorCallback(const std::string &message) { errorEvent_.dispatch(StringPayload{.name = "message", .reason = message}); } -bool AudioFileWriter::isFileOpen() { +bool AudioFileWriter::isFileOpen() const { return isFileOpen_.load(std::memory_order_acquire); } diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioFileWriter.h b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioFileWriter.h index 81b4eeac1..db00762ec 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioFileWriter.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioFileWriter.h @@ -39,6 +39,7 @@ class AudioFileWriter { virtual double getCurrentDuration() const = 0; virtual size_t getFileSizeBytes() const = 0; + [[nodiscard]] bool isFileOpen() const; void setOnErrorCallback(uint64_t callbackId) { assignOnErrorCallbackId(callbackId); @@ -50,8 +51,6 @@ class AudioFileWriter { void invokeOnErrorCallback(const std::string &message); protected: - bool isFileOpen(); - std::atomic isFileOpen_{false}; std::atomic framesWritten_{0}; EventCaller errorEvent_; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/RotatingFileWriter.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/RotatingFileWriter.cpp index e713a8fc1..59370c63b 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/RotatingFileWriter.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/RotatingFileWriter.cpp @@ -32,6 +32,9 @@ CloseFileResult RotatingFileWriter::closeFile() { } void RotatingFileWriter::rotateFiles() { + if (currentWriter_ == nullptr || !currentWriter_->isFileOpen()) { + return; + } auto rotatedClose = currentWriter_->closeFile(); if (rotatedClose.is_ok()) { const auto &t = rotatedClose.unwrap(); diff --git a/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.mm b/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.mm index 017945566..50a906a7b 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 @@ -192,6 +192,9 @@ static void cleanupStartedRecorder( const bool shouldArmInput = state_.load(std::memory_order_acquire) == RecorderState::Recording; [nativeRecorder_ setInputArmed:false]; + // Capture loss clears *Configured_ (so uses*() is false) but leaves user intent + // (*Enabled_) set. Re-prepare from wants* so a later HardwareChanged opens a new + // segment instead of resuming with nowhere to write. if (wantsFileOutput()) { auto fileResult = reprepareFileWriter(inputFormat, maxInputBufferLength); if (fileResult.is_err()) { diff --git a/packages/react-native-audio-api/ios/audioapi/ios/core/utils/IOSFileWriter.mm b/packages/react-native-audio-api/ios/audioapi/ios/core/utils/IOSFileWriter.mm index 6aa5149e9..e3dc692a1 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/core/utils/IOSFileWriter.mm +++ b/packages/react-native-audio-api/ios/audioapi/ios/core/utils/IOSFileWriter.mm @@ -187,7 +187,8 @@ { @autoreleasepool { NSError *error; - std::string filePath = [[fileURL_ path] UTF8String]; + const char *pathCString = [[fileURL_ path] UTF8String]; + std::string filePath = pathCString != nullptr ? pathCString : ""; if (!isFileOpen() || audioFile_ == nil) { return CloseFileResult::Err("file is not open: " + filePath); diff --git a/packages/react-native-audio-api/ios/audioapi/ios/core/utils/IOSRotatingFileWriter.mm b/packages/react-native-audio-api/ios/audioapi/ios/core/utils/IOSRotatingFileWriter.mm index e18d6a377..6e173c361 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/core/utils/IOSRotatingFileWriter.mm +++ b/packages/react-native-audio-api/ios/audioapi/ios/core/utils/IOSRotatingFileWriter.mm @@ -58,13 +58,15 @@ return openInnerWriter(); } - rotateFiles(); - - if (currentWriter_ == nullptr) { - return OpenFileResult::Err("Failed to reopen file for writing after input format change"); + if (currentWriter_->isFileOpen()) { + rotateFiles(); + if (currentWriter_ == nullptr) { + return OpenFileResult::Err("Failed to reopen file for writing after input format change"); + } + return OpenFileResult::Ok(currentWriter_->getFilePath()); } - return OpenFileResult::Ok(currentWriter_->getFilePath()); + return openInnerWriter(); } void IOSRotatingFileWriter::writeAudioData(AudioDataType data, int numFrames) From 6283fb721a226f2f6445067ab59a53e5746eeac9 Mon Sep 17 00:00:00 2001 From: Kacper Orszulak <42392219+SomePersonFromMars@users.noreply.github.com> Date: Fri, 28 Aug 2026 13:54:58 +0200 Subject: [PATCH 07/29] fix: retry AudioEngine resume only happens when interruption ended was actually delivered --- .../FabricExampleTests/AudioEngineTests.mm | 20 ++++---- .../IOSAudioRecorderTests.mm | 2 +- .../NativeAudioRecorderTests.mm | 5 +- .../SystemNotificationManagerTests.mm | 49 +++++++++++++++++++ .../ios/system/SystemNotificationManager.h | 3 ++ .../ios/system/SystemNotificationManager.mm | 24 ++++----- 6 files changed, 75 insertions(+), 28 deletions(-) diff --git a/apps/fabric-example/ios/FabricExampleTests/AudioEngineTests.mm b/apps/fabric-example/ios/FabricExampleTests/AudioEngineTests.mm index ec8ee9389..3433fc7b0 100644 --- a/apps/fabric-example/ios/FabricExampleTests/AudioEngineTests.mm +++ b/apps/fabric-example/ios/FabricExampleTests/AudioEngineTests.mm @@ -422,7 +422,7 @@ - (void)testDetachSourceNodeRemovesTrackedNodeAndClearsGraphWhenEmpty { - (void)testDetachSourceNodeKeepsGraphNeedsRebuildWhenInputRemains { NSString *sourceNodeId = [self attachSourceNodeToAudioEngine]; [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] - voiceProcessingEnabled:NO onInputConfigurationChange:nil]; + voiceProcessingEnabled:NO onInputNotification:nil]; self.audioEngine.graphNeedsRebuild = YES; [self.audioEngine detachSourceNodeWithId:sourceNodeId]; @@ -435,7 +435,7 @@ - (void)testDetachSourceNodeKeepsGraphNeedsRebuildWhenInputRemains { - (void)testAttachInputNodeStoresAndConnectsInput { FakeAudioEngine *fakeEngine = self.audioEngine.currentFakeAudioEngine; [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] - voiceProcessingEnabled:NO onInputConfigurationChange:nil]; + voiceProcessingEnabled:NO onInputNotification:nil]; AVAudioSinkNode *inputNode = self.audioEngine.inputNode; XCTAssertNotNil(inputNode); @@ -455,7 +455,7 @@ - (void)testAttachInputNodeDefersConnectionUntilLiveInputFormatIsAvailable { fakeEngine.fakeInputNode.outputFormat = nil; [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] - voiceProcessingEnabled:NO onInputConfigurationChange:nil]; + voiceProcessingEnabled:NO onInputNotification:nil]; XCTAssertNil(self.audioEngine.inputNode); XCTAssertEqual(fakeEngine.attachNodeCallCount, 0); @@ -483,7 +483,7 @@ - (void)testDetachInputNodeWithoutInputDoesNothing { - (void)testDetachInputNodeClearsGraphOnlyWhenNoSourcesRemain { [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] - voiceProcessingEnabled:NO onInputConfigurationChange:nil]; + voiceProcessingEnabled:NO onInputNotification:nil]; self.audioEngine.graphNeedsRebuild = YES; [self.audioEngine detachInputNode]; @@ -493,7 +493,7 @@ - (void)testDetachInputNodeClearsGraphOnlyWhenNoSourcesRemain { [self attachSourceNodeToAudioEngine]; [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] - voiceProcessingEnabled:NO onInputConfigurationChange:nil]; + voiceProcessingEnabled:NO onInputNotification:nil]; self.audioEngine.graphNeedsRebuild = YES; [self.audioEngine detachInputNode]; @@ -507,7 +507,7 @@ - (void)testDetachInputNodePreservesSessionDeactivationInvalidation { fakeEngine.fakeRunning = YES; self.audioEngine.state = AudioEngineStateRunning; [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] - voiceProcessingEnabled:NO onInputConfigurationChange:nil]; + voiceProcessingEnabled:NO onInputNotification:nil]; [self.audioEngine onSessionDeactivated]; [self.audioEngine detachInputNode]; @@ -730,7 +730,7 @@ - (void)testStartIfNecessaryRebuildsWhenGraphNeedsRebuild { - (void) testStartIfNecessaryRebuildsAfterSessionDeactivationEvenWhenTeardownClearsGraph { [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] - voiceProcessingEnabled:NO onInputConfigurationChange:nil]; + voiceProcessingEnabled:NO onInputNotification:nil]; FakeAudioEngine *oldEngine = self.audioEngine.currentFakeAudioEngine; oldEngine.fakeRunning = YES; @@ -748,7 +748,7 @@ - (void)testStartIfNecessaryRebuildsWhenGraphNeedsRebuild { [self testInputFormatWithSampleRate:48000 channelCount:1]; self.audioEngine.nextCreatedEngineInputFormat = recoveredInputFormat; [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] - voiceProcessingEnabled:NO onInputConfigurationChange:nil]; + voiceProcessingEnabled:NO onInputNotification:nil]; AVAudioSinkNode *recoveredInputNode = self.audioEngine.inputNode; XCTAssertTrue([self.audioEngine startIfNecessary]); @@ -770,7 +770,7 @@ - (void)testStartIfNecessaryRebuildsWhenGraphNeedsRebuild { - (void)testStartIfNecessaryRebuildsInputNodeWithFreshInstance { [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] - voiceProcessingEnabled:NO onInputConfigurationChange:nil]; + voiceProcessingEnabled:NO onInputNotification:nil]; FakeAudioEngine *oldEngine = self.audioEngine.currentFakeAudioEngine; AVAudioSinkNode *oldInputNode = self.audioEngine.inputNode; AVAudioFormat *replacementInputFormat = @@ -992,7 +992,7 @@ - (void)testConcurrentRecordAndPlayPathsDoNotCrash { dispatch_group_enter(group); dispatch_async(queue, ^{ [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] - voiceProcessingEnabled:NO onInputConfigurationChange:nil]; + voiceProcessingEnabled:NO onInputNotification:nil]; [self.audioEngine startIfNecessary]; dispatch_group_leave(group); }); diff --git a/apps/fabric-example/ios/FabricExampleTests/IOSAudioRecorderTests.mm b/apps/fabric-example/ios/FabricExampleTests/IOSAudioRecorderTests.mm index cf8e8f3d9..d02a443b8 100644 --- a/apps/fabric-example/ios/FabricExampleTests/IOSAudioRecorderTests.mm +++ b/apps/fabric-example/ios/FabricExampleTests/IOSAudioRecorderTests.mm @@ -267,7 +267,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 { diff --git a/apps/fabric-example/ios/FabricExampleTests/NativeAudioRecorderTests.mm b/apps/fabric-example/ios/FabricExampleTests/NativeAudioRecorderTests.mm index 149c07371..00c009a81 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 diff --git a/apps/fabric-example/ios/FabricExampleTests/SystemNotificationManagerTests.mm b/apps/fabric-example/ios/FabricExampleTests/SystemNotificationManagerTests.mm index 2f1d1b0f3..b5573728c 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,6 +99,7 @@ @interface SNMFakeAudioEngine : AudioEngine @property(nonatomic, assign) NSInteger interruptionEndCallCount; @property(nonatomic, assign) NSInteger restartAudioEngineCallCount; @property(nonatomic, assign) BOOL lastShouldResume; +@property(nonatomic, assign) BOOL fakeHasInputRegistration; @end @@ -118,6 +121,11 @@ - (void)restartAudioEngine self.restartAudioEngineCallCount += 1; } +- (bool)hasInputRegistration +{ + return self.fakeHasInputRegistration; +} + @end @interface SNMFakeAudioSessionManager : AudioSessionManager @@ -441,6 +449,47 @@ - (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.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); + + [self.manager handleWillEnterForeground:nil]; + [self flushMainQueue]; + + XCTAssertEqual(self.fakeAudioEngine.interruptionEndCallCount, endCountAfterEnded + 1); + XCTAssertTrue(self.fakeAudioEngine.lastShouldResume); +} + - (void)testHandleSecondaryAudioBeginMarksInactiveAndEmitsEventWhenObserved { [self.manager observeAudioInterruptions:YES]; diff --git a/packages/react-native-audio-api/ios/audioapi/ios/system/SystemNotificationManager.h b/packages/react-native-audio-api/ios/audioapi/ios/system/SystemNotificationManager.h index 5ddf0d4e2..32641d5ed 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/system/SystemNotificationManager.h +++ b/packages/react-native-audio-api/ios/audioapi/ios/system/SystemNotificationManager.h @@ -14,6 +14,9 @@ @property (nonatomic, strong) NSTimer *hintPollingTimer; @property (nonatomic, assign) bool hadConfigurationChange; @property (nonatomic, assign) bool audioInterruptionsObserved; +/// Set when AVAudioSession posts InterruptionEnded (or the secondary-audio equivalent). +/// Thanks to it it can be decided, whether interruption end retry is necessary. +@property (nonatomic, assign) bool interruptionEndedDelivered; @property (nonatomic, assign) bool volumeChangesObserved; @property (nonatomic, assign) bool wasOtherAudioPlaying; diff --git a/packages/react-native-audio-api/ios/audioapi/ios/system/SystemNotificationManager.mm b/packages/react-native-audio-api/ios/audioapi/ios/system/SystemNotificationManager.mm index 7b99c39f3..e82ee5555 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 @@ -90,10 +90,6 @@ - (void)configureNotifications selector:@selector(handleInterruption:) name:AVAudioSessionInterruptionNotification object:nil]; - // willEnterForeground is the first legal moment to restart I/O after a - // backgrounded interruption. DidBecomeActive retries if that attempt still - // failed, and is the only of the two that fires when returning from - // inactive-in-foreground (Control Center, Siri overlay). [self.notificationCenter addObserver:self selector:@selector(handleWillEnterForeground:) name:UIApplicationWillEnterForegroundNotification @@ -136,17 +132,9 @@ - (void)retryInterruptedRecordingIfNeeded { AudioEngine *audioEngine = self.audioAPIModule.audioEngine; - dispatch_async(dispatch_get_main_queue(), ^{ - if ([audioEngine getState] != AudioEngineStateInterrupted) { - return; - } - - if (![audioEngine hasInputRegistration]) { - return; - } - - [audioEngine onInterruptionEnd:true]; - }); + if (self.interruptionEndedDelivered && [audioEngine getState] == AudioEngineStateInterrupted) { + dispatch_async(dispatch_get_main_queue(), ^{ [audioEngine onInterruptionEnd:true]; }); + } } - (void)handleInterruption:(NSNotification *)notification @@ -160,6 +148,7 @@ - (void)handleInterruption:(NSNotification *)notification [notification.userInfo[AVAudioSessionInterruptionOptionKey] integerValue]; if (interruptionType == AVAudioSessionInterruptionTypeBegan) { + self.interruptionEndedDelivered = false; dispatch_async(dispatch_get_main_queue(), ^{ [audioEngine onInterruptionBegin]; [sessionManager markInactive]; @@ -176,6 +165,7 @@ - (void)handleInterruption:(NSNotification *)notification bool shouldResume = interruptionOption == AVAudioSessionInterruptionOptionShouldResume; + self.interruptionEndedDelivered = true; if (self.audioInterruptionsObserved) { [self.audioAPIModule invokeHandlerWithEventName:audioapi::AudioEvent::INTERRUPTION @@ -194,6 +184,7 @@ - (void)handleSecondaryAudio:(NSNotification *)notification [notification.userInfo[AVAudioSessionSilenceSecondaryAudioHintTypeKey] integerValue]; if (secondaryAudioType == AVAudioSessionSilenceSecondaryAudioHintTypeBegin) { + self.interruptionEndedDelivered = false; dispatch_async(dispatch_get_main_queue(), ^{ [sessionManager markInactive]; [audioEngine onInterruptionBegin]; @@ -209,6 +200,7 @@ - (void)handleSecondaryAudio:(NSNotification *)notification bool shouldResume = secondaryAudioType == AVAudioSessionSilenceSecondaryAudioHintTypeEnd; + self.interruptionEndedDelivered = true; if (self.audioInterruptionsObserved) { [self.audioAPIModule invokeHandlerWithEventName:audioapi::AudioEvent::INTERRUPTION @@ -335,6 +327,7 @@ - (void)checkSecondaryAudioHint self.wasOtherAudioPlaying = shouldSilence; if (shouldSilence) { + self.interruptionEndedDelivered = false; dispatch_async(dispatch_get_main_queue(), ^{ [sessionManager markInactive]; [audioEngine onInterruptionBegin]; @@ -348,6 +341,7 @@ - (void)checkSecondaryAudioHint return; } + self.interruptionEndedDelivered = true; if (self.audioInterruptionsObserved) { [self.audioAPIModule invokeHandlerWithEventName:audioapi::AudioEvent::INTERRUPTION payload:audioapi::InterruptionPayload{ From bc74d585f2cd9ec2cc583bf4da5c7c89b6b83b73 Mon Sep 17 00:00:00 2001 From: Kacper Orszulak <42392219+SomePersonFromMars@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:19:39 +0200 Subject: [PATCH 08/29] fix: always perform native recovery after interruption notification Also JS handler is only executed on an engine state change. --- .../FabricExampleTests/AudioEngineTests.mm | 40 ++++- .../SystemNotificationManagerTests.mm | 163 +++++++++++++++++- apps/fabric-example/ios/Podfile.lock | 2 +- .../ios/audioapi/ios/system/AudioEngine.h | 13 +- .../ios/audioapi/ios/system/AudioEngine.mm | 18 +- .../ios/system/SystemNotificationManager.mm | 90 +++++----- 6 files changed, 255 insertions(+), 71 deletions(-) diff --git a/apps/fabric-example/ios/FabricExampleTests/AudioEngineTests.mm b/apps/fabric-example/ios/FabricExampleTests/AudioEngineTests.mm index 3433fc7b0..1cf3dbb31 100644 --- a/apps/fabric-example/ios/FabricExampleTests/AudioEngineTests.mm +++ b/apps/fabric-example/ios/FabricExampleTests/AudioEngineTests.mm @@ -518,15 +518,15 @@ - (void)testDetachInputNodePreservesSessionDeactivationInvalidation { } - (void)testOnInterruptionBeginOnlyTransitionsFromRunning { - [self.audioEngine onInterruptionBegin]; + XCTAssertFalse([self.audioEngine onInterruptionBegin]); XCTAssertEqual(self.audioEngine.state, AudioEngineStateIdle); self.audioEngine.state = AudioEngineStatePaused; - [self.audioEngine onInterruptionBegin]; + XCTAssertFalse([self.audioEngine onInterruptionBegin]); XCTAssertEqual(self.audioEngine.state, AudioEngineStatePaused); self.audioEngine.state = AudioEngineStateRunning; - [self.audioEngine onInterruptionBegin]; + XCTAssertTrue([self.audioEngine onInterruptionBegin]); XCTAssertEqual(self.audioEngine.state, AudioEngineStateInterrupted); } @@ -596,7 +596,8 @@ - (void)testOnSessionDeactivatedMarksGraphForRebuildWhenNodesAreAttached { - (void)testOnInterruptionEndNoOpsUnlessInterrupted { FakeAudioEngine *fakeEngine = self.audioEngine.currentFakeAudioEngine; - [self.audioEngine onInterruptionEnd:true]; + XCTAssertEqual([self.audioEngine onInterruptionEnd:true], + AudioEngineInterruptionEndOutcomeNoOp); XCTAssertEqual(self.audioEngine.state, AudioEngineStateIdle); XCTAssertEqual(fakeEngine.resetCallCount, 0); @@ -610,7 +611,8 @@ - (void)testOnInterruptionEndWithoutResumeRebuildsAndPauses { oldEngine.fakeRunning = YES; self.audioEngine.state = AudioEngineStateInterrupted; - [self.audioEngine onInterruptionEnd:false]; + XCTAssertEqual([self.audioEngine onInterruptionEnd:false], + AudioEngineInterruptionEndOutcomePaused); XCTAssertEqual(self.audioEngine.state, AudioEngineStatePaused); XCTAssertEqual(oldEngine.stopCallCount, 1); @@ -627,7 +629,8 @@ - (void)testOnInterruptionEndWithResumeRestartsEngine { oldEngine.fakeRunning = YES; self.audioEngine.state = AudioEngineStateInterrupted; - [self.audioEngine onInterruptionEnd:true]; + XCTAssertEqual([self.audioEngine onInterruptionEnd:true], + AudioEngineInterruptionEndOutcomeRunning); FakeAudioEngine *newEngine = self.audioEngine.currentFakeAudioEngine; XCTAssertEqual(self.audioEngine.state, AudioEngineStateRunning); @@ -637,7 +640,7 @@ - (void)testOnInterruptionEndWithResumeRestartsEngine { XCTAssertEqual(self.audioEngine.createdFakeEngines.count, 2UL); } -- (void)testOnInterruptionEndWithResumeFailureEndsIdle { +- (void)testOnInterruptionEndWithResumeFailureStaysInterrupted { [self attachSourceNodeToAudioEngine]; self.audioEngine.state = AudioEngineStateInterrupted; @@ -646,9 +649,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 { diff --git a/apps/fabric-example/ios/FabricExampleTests/SystemNotificationManagerTests.mm b/apps/fabric-example/ios/FabricExampleTests/SystemNotificationManagerTests.mm index b5573728c..9ddaf3298 100644 --- a/apps/fabric-example/ios/FabricExampleTests/SystemNotificationManagerTests.mm +++ b/apps/fabric-example/ios/FabricExampleTests/SystemNotificationManagerTests.mm @@ -100,20 +100,42 @@ @interface SNMFakeAudioEngine : AudioEngine @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 @@ -279,6 +301,8 @@ - (void)setUp [super setUp]; self.fakeAudioEngine = [[SNMFakeAudioEngine alloc] init]; + self.fakeAudioEngine.interruptionBeginAccepted = YES; + self.fakeAudioEngine.interruptionEndOutcome = AudioEngineInterruptionEndOutcomeRunning; self.fakeSessionManager = [[SNMFakeAudioSessionManager alloc] init]; self.fakeSharedAudioSession = [[FakeSharedAVAudioSession alloc] init]; SetFakeSharedAudioSession(self.fakeSharedAudioSession); @@ -423,19 +447,64 @@ - (void)testHandleInterruptionBeganMarksInactiveAndEmitsEventWhenObserved XCTAssertEqualObjects(self.module.lastEventBody[@"shouldResume"], @NO); } -- (void)testHandleInterruptionEndedEmitsEventWhenObserved +- (void)testHandleInterruptionBeganDoesNotEmitWhenBeginIsRejected +{ + [self.manager observeAudioInterruptions:YES]; + self.fakeAudioEngine.interruptionBeginAccepted = NO; + + [self.manager handleInterruption:[self interruptionNotificationWithType:AVAudioSessionInterruptionTypeBegan + option:0]]; + [self flushMainQueue]; + + XCTAssertEqual(self.fakeSessionManager.markInactiveCallCount, 1); + XCTAssertEqual(self.fakeAudioEngine.interruptionBeginCallCount, 1); + XCTAssertEqual(self.module.eventInvocationCount, 0); +} + +- (void)testHandleInterruptionEndedRecoversAndEmitsEventWhenObserved { [self.manager observeAudioInterruptions:YES]; [self.manager handleInterruption:[self interruptionNotificationWithType:AVAudioSessionInterruptionTypeEnded option:AVAudioSessionInterruptionOptionShouldResume]]; + [self flushMainQueue]; + XCTAssertEqual(self.fakeAudioEngine.interruptionEndCallCount, 1); + XCTAssertTrue(self.fakeAudioEngine.lastShouldResume); XCTAssertEqual(self.module.eventInvocationCount, 1); XCTAssertEqual(self.module.lastEventNameRaw, static_cast(audioapi::AudioEvent::INTERRUPTION)); XCTAssertEqualObjects(self.module.lastEventBody[@"type"], @"ended"); XCTAssertEqualObjects(self.module.lastEventBody[@"shouldResume"], @YES); - XCTAssertEqual(self.fakeAudioEngine.interruptionEndCallCount, 0); +} + +- (void)testHandleInterruptionEndedDoesNotEmitWhenResumeFails +{ + [self.manager observeAudioInterruptions:YES]; + self.fakeAudioEngine.interruptionEndOutcome = AudioEngineInterruptionEndOutcomeStillInterrupted; + + [self.manager handleInterruption:[self interruptionNotificationWithType:AVAudioSessionInterruptionTypeEnded + option:AVAudioSessionInterruptionOptionShouldResume]]; + [self flushMainQueue]; + + XCTAssertEqual(self.fakeAudioEngine.interruptionEndCallCount, 1); + XCTAssertEqual(self.module.eventInvocationCount, 0); +} + +- (void)testHandleInterruptionEndedEmitsWhenPausedAfterPoliteNonResume +{ + [self.manager observeAudioInterruptions:YES]; + self.fakeAudioEngine.interruptionEndOutcome = AudioEngineInterruptionEndOutcomePaused; + + [self.manager handleInterruption:[self interruptionNotificationWithType:AVAudioSessionInterruptionTypeEnded + option:0]]; + [self flushMainQueue]; + + XCTAssertEqual(self.fakeAudioEngine.interruptionEndCallCount, 1); + XCTAssertFalse(self.fakeAudioEngine.lastShouldResume); + XCTAssertEqual(self.module.eventInvocationCount, 1); + XCTAssertEqualObjects(self.module.lastEventBody[@"type"], @"ended"); + XCTAssertEqualObjects(self.module.lastEventBody[@"shouldResume"], @NO); } - (void)testHandleInterruptionEndedResumesEngineWhenNotObserved @@ -473,6 +542,7 @@ - (void)testForegroundRetryDoesNotResumeBeforeInterruptionEnded - (void)testForegroundRetryResumesAfterFailedInterruptionEnded { [self prepareInterruptedRecordingEngine]; + self.fakeAudioEngine.interruptionEndOutcome = AudioEngineInterruptionEndOutcomeStillInterrupted; [self.manager handleInterruption:[self interruptionNotificationWithType:AVAudioSessionInterruptionTypeBegan option:0]]; [self.manager handleInterruption:[self interruptionNotificationWithType:AVAudioSessionInterruptionTypeEnded @@ -482,6 +552,7 @@ - (void)testForegroundRetryResumesAfterFailedInterruptionEnded NSInteger endCountAfterEnded = self.fakeAudioEngine.interruptionEndCallCount; XCTAssertTrue(self.manager.interruptionEndedDelivered); XCTAssertEqual(endCountAfterEnded, 1); + XCTAssertEqual(self.fakeAudioEngine.state, AudioEngineStateInterrupted); [self.manager handleWillEnterForeground:nil]; [self flushMainQueue]; @@ -490,6 +561,54 @@ - (void)testForegroundRetryResumesAfterFailedInterruptionEnded XCTAssertTrue(self.fakeAudioEngine.lastShouldResume); } +- (void)testForegroundRetryEmitsEndedWhenObservedResumeSucceedsAfterFailedEnd +{ + [self.manager observeAudioInterruptions:YES]; + [self prepareInterruptedRecordingEngine]; + self.fakeAudioEngine.interruptionEndOutcome = AudioEngineInterruptionEndOutcomeStillInterrupted; + + [self.manager handleInterruption:[self interruptionNotificationWithType:AVAudioSessionInterruptionTypeBegan + option:0]]; + [self.manager handleInterruption:[self interruptionNotificationWithType:AVAudioSessionInterruptionTypeEnded + option:AVAudioSessionInterruptionOptionShouldResume]]; + [self flushMainQueue]; + + XCTAssertEqual(self.fakeAudioEngine.interruptionEndCallCount, 1); + XCTAssertEqual(self.module.eventInvocationCount, 1); + XCTAssertEqualObjects(self.module.lastEventBody[@"type"], @"began"); + + [self.module resetCapturedEvent]; + self.fakeAudioEngine.interruptionEndOutcome = AudioEngineInterruptionEndOutcomeRunning; + + [self.manager handleWillEnterForeground:nil]; + [self flushMainQueue]; + + XCTAssertEqual(self.fakeAudioEngine.interruptionEndCallCount, 2); + XCTAssertEqual(self.module.eventInvocationCount, 1); + XCTAssertEqualObjects(self.module.lastEventBody[@"type"], @"ended"); +} + +- (void)testForegroundRetryAfterSuccessfulResumeIsNoOp +{ + [self.manager observeAudioInterruptions:YES]; + [self prepareInterruptedRecordingEngine]; + + [self.manager handleInterruption:[self interruptionNotificationWithType:AVAudioSessionInterruptionTypeEnded + option:AVAudioSessionInterruptionOptionShouldResume]]; + [self flushMainQueue]; + + XCTAssertEqual(self.fakeAudioEngine.interruptionEndCallCount, 1); + XCTAssertEqual(self.module.eventInvocationCount, 1); + XCTAssertEqualObjects(self.module.lastEventBody[@"type"], @"ended"); + + [self.module resetCapturedEvent]; + [self.manager handleDidBecomeActive:nil]; + [self flushMainQueue]; + + XCTAssertEqual(self.fakeAudioEngine.interruptionEndCallCount, 1); + XCTAssertEqual(self.module.eventInvocationCount, 0); +} + - (void)testHandleSecondaryAudioBeginMarksInactiveAndEmitsEventWhenObserved { [self.manager observeAudioInterruptions:YES]; @@ -520,6 +639,22 @@ - (void)testHandleSecondaryAudioEndResumesEngineWhenNotObserved XCTAssertTrue(self.fakeAudioEngine.lastShouldResume); } +- (void)testHandleSecondaryAudioEndRecoversAndEmitsEventWhenObserved +{ + [self.manager observeAudioInterruptions:YES]; + + [self.manager + handleSecondaryAudio:[self secondaryAudioNotificationWithType: + AVAudioSessionSilenceSecondaryAudioHintTypeEnd]]; + [self flushMainQueue]; + + XCTAssertEqual(self.fakeAudioEngine.interruptionEndCallCount, 1); + XCTAssertTrue(self.fakeAudioEngine.lastShouldResume); + XCTAssertEqual(self.module.eventInvocationCount, 1); + XCTAssertEqualObjects(self.module.lastEventBody[@"type"], @"ended"); + XCTAssertEqualObjects(self.module.lastEventBody[@"shouldResume"], @YES); +} + - (void)testHandleRouteChangeMapsReasonsAndFallsBackToUnknown { NSArray *cases = @[ @@ -608,21 +743,37 @@ - (void)testCheckSecondaryAudioHintSilencedTransitionMarksInactiveAndEmitsEventW XCTAssertEqualObjects(self.module.lastEventBody[@"shouldResume"], @NO); } -- (void)testCheckSecondaryAudioHintResumeTransitionEmitsEventWhenObserved +- (void)testCheckSecondaryAudioHintResumeTransitionRecoversAndEmitsEventWhenObserved { [self.manager observeAudioInterruptions:YES]; self.fakeSharedAudioSession.secondaryAudioShouldBeSilencedHint = NO; self.manager.wasOtherAudioPlaying = YES; [self.manager checkSecondaryAudioHint]; + [self flushMainQueue]; XCTAssertFalse(self.manager.wasOtherAudioPlaying); + XCTAssertEqual(self.fakeAudioEngine.interruptionEndCallCount, 1); + XCTAssertTrue(self.fakeAudioEngine.lastShouldResume); XCTAssertEqual(self.module.eventInvocationCount, 1); XCTAssertEqual(self.module.lastEventNameRaw, static_cast(audioapi::AudioEvent::INTERRUPTION)); XCTAssertEqualObjects(self.module.lastEventBody[@"type"], @"ended"); XCTAssertEqualObjects(self.module.lastEventBody[@"shouldResume"], @YES); - XCTAssertEqual(self.fakeAudioEngine.interruptionEndCallCount, 0); +} + +- (void)testCheckSecondaryAudioHintResumeTransitionDoesNotEmitWhenResumeFails +{ + [self.manager observeAudioInterruptions:YES]; + self.fakeAudioEngine.interruptionEndOutcome = AudioEngineInterruptionEndOutcomeStillInterrupted; + self.fakeSharedAudioSession.secondaryAudioShouldBeSilencedHint = NO; + self.manager.wasOtherAudioPlaying = YES; + + [self.manager checkSecondaryAudioHint]; + [self flushMainQueue]; + + XCTAssertEqual(self.fakeAudioEngine.interruptionEndCallCount, 1); + XCTAssertEqual(self.module.eventInvocationCount, 0); } - (void)testCheckSecondaryAudioHintResumeTransitionResumesEngineWhenNotObserved diff --git a/apps/fabric-example/ios/Podfile.lock b/apps/fabric-example/ios/Podfile.lock index f330e1426..9d86afa58 100644 --- a/apps/fabric-example/ios/Podfile.lock +++ b/apps/fabric-example/ios/Podfile.lock @@ -2516,4 +2516,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 7a9375c6de5b95bc2125d07cf736baa649d6ba8e -COCOAPODS: 1.16.2 +COCOAPODS: 1.17.0 diff --git a/packages/react-native-audio-api/ios/audioapi/ios/system/AudioEngine.h b/packages/react-native-audio-api/ios/audioapi/ios/system/AudioEngine.h index f3b716216..a14a82358 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/system/AudioEngine.h +++ b/packages/react-native-audio-api/ios/audioapi/ios/system/AudioEngine.h @@ -17,6 +17,14 @@ typedef NS_ENUM(NSInteger, AudioEngineInputNotification) { AudioEngineInputNotificationCaptureLost }; +/// Result of `onInterruptionEnd:`. Distinguishes a no-op from a failed resume that stays Interrupted. +typedef NS_ENUM(NSInteger, AudioEngineInterruptionEndOutcome) { + AudioEngineInterruptionEndOutcomeNoOp = 0, + AudioEngineInterruptionEndOutcomeRunning, + AudioEngineInterruptionEndOutcomePaused, + AudioEngineInterruptionEndOutcomeStillInterrupted +}; + @interface AudioEngine : NSObject @property (nonatomic, assign) AudioEngineState state; @@ -45,8 +53,9 @@ typedef NS_ENUM(NSInteger, AudioEngineInputNotification) { - (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; 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 e7b028707..892321956 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/system/AudioEngine.mm +++ b/packages/react-native-audio-api/ios/audioapi/ios/system/AudioEngine.mm @@ -385,14 +385,15 @@ - (AVAudioFormat *)getLiveInputFormat return [self liveInputFormat]; } -- (void)onInterruptionBegin +- (bool)onInterruptionBegin { std::scoped_lock lock(_engineLock); if (self.state != AudioEngineState::AudioEngineStateRunning) { - return; + return false; } self.state = AudioEngineState::AudioEngineStateInterrupted; + return true; } - (void)onSessionDeactivated @@ -429,13 +430,13 @@ - (void)markSessionDeactivationInvalidatedGraph self.sessionDeactivationInvalidatedGraph = YES; } -- (void)onInterruptionEnd:(bool)shouldResume +- (AudioEngineInterruptionEndOutcome)onInterruptionEnd:(bool)shouldResume { std::scoped_lock lock(_engineLock); NSError *error = nil; if (self.state != AudioEngineState::AudioEngineStateInterrupted) { - return; + return AudioEngineInterruptionEndOutcomeNoOp; } if (!shouldResume && self.inputRegistration == nil) { @@ -443,12 +444,12 @@ - (void)onInterruptionEnd:(bool)shouldResume [self rebuildAudioEngine]; self.state = AudioEngineState::AudioEngineStatePaused; [self notifyInput:AudioEngineInputNotificationHardwareChanged]; - return; + return AudioEngineInterruptionEndOutcomePaused; } if (![self.sessionManager ensureActive:true error:&error]) { NSLog(@"Error while activating audio session after interruption: %@", [error debugDescription]); - return; + return AudioEngineInterruptionEndOutcomeStillInterrupted; } [self stopEngine]; @@ -459,7 +460,7 @@ - (void)onInterruptionEnd:(bool)shouldResume @"Error while materializing the audio input node after interruption: missing live input format"); self.state = AudioEngineState::AudioEngineStateInterrupted; [self notifyInput:AudioEngineInputNotificationCaptureLost]; - return; + return AudioEngineInterruptionEndOutcomeStillInterrupted; } [self.audioEngine prepare]; @@ -471,12 +472,13 @@ - (void)onInterruptionEnd:(bool)shouldResume [error debugDescription]); self.state = AudioEngineState::AudioEngineStateInterrupted; [self notifyInput:AudioEngineInputNotificationCaptureLost]; - return; + return AudioEngineInterruptionEndOutcomeStillInterrupted; } self.state = AudioEngineState::AudioEngineStateRunning; self.sessionDeactivationInvalidatedGraph = false; [self notifyInput:AudioEngineInputNotificationHardwareChanged]; + return AudioEngineInterruptionEndOutcomeRunning; } - (void)notifyInput:(AudioEngineInputNotification)notification 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 e82ee5555..f4192bf08 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/system/SystemNotificationManager.mm +++ b/packages/react-native-audio-api/ios/audioapi/ios/system/SystemNotificationManager.mm @@ -128,12 +128,47 @@ - (void)handleDidBecomeActive:(NSNotification *)notification [self retryInterruptedRecordingIfNeeded]; } +- (void)emitInterruptionBeganIfAccepted:(bool)accepted +{ + if (!self.audioInterruptionsObserved || !accepted) { + return; + } + + [self.audioAPIModule invokeHandlerWithEventName:audioapi::AudioEvent::INTERRUPTION + payload:audioapi::InterruptionPayload{ + .type = "began", .shouldResume = false}]; +} + +- (void)emitInterruptionEndedIfTransitioned:(AudioEngineInterruptionEndOutcome)outcome + shouldResume:(bool)shouldResume +{ + if (!self.audioInterruptionsObserved) { + return; + } + + if (outcome == AudioEngineInterruptionEndOutcomeRunning || + outcome == AudioEngineInterruptionEndOutcomePaused) { + [self.audioAPIModule + invokeHandlerWithEventName:audioapi::AudioEvent::INTERRUPTION + payload:audioapi::InterruptionPayload{ + .type = "ended", .shouldResume = shouldResume}]; + } +} + +- (void)performInterruptionEndOnEngine:(AudioEngine *)audioEngine shouldResume:(bool)shouldResume +{ + dispatch_async(dispatch_get_main_queue(), ^{ + AudioEngineInterruptionEndOutcome outcome = [audioEngine onInterruptionEnd:shouldResume]; + [self emitInterruptionEndedIfTransitioned:outcome shouldResume:shouldResume]; + }); +} + - (void)retryInterruptedRecordingIfNeeded { AudioEngine *audioEngine = self.audioAPIModule.audioEngine; if (self.interruptionEndedDelivered && [audioEngine getState] == AudioEngineStateInterrupted) { - dispatch_async(dispatch_get_main_queue(), ^{ [audioEngine onInterruptionEnd:true]; }); + [self performInterruptionEndOnEngine:audioEngine shouldResume:true]; } } @@ -150,30 +185,17 @@ - (void)handleInterruption:(NSNotification *)notification if (interruptionType == AVAudioSessionInterruptionTypeBegan) { self.interruptionEndedDelivered = false; dispatch_async(dispatch_get_main_queue(), ^{ - [audioEngine onInterruptionBegin]; + bool accepted = [audioEngine onInterruptionBegin]; [sessionManager markInactive]; + [self emitInterruptionBeganIfAccepted:accepted]; }); - - if (self.audioInterruptionsObserved) { - [self.audioAPIModule invokeHandlerWithEventName:audioapi::AudioEvent::INTERRUPTION - payload:audioapi::InterruptionPayload{ - .type = "began", .shouldResume = false}]; - } - return; } bool shouldResume = interruptionOption == AVAudioSessionInterruptionOptionShouldResume; self.interruptionEndedDelivered = true; - 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 performInterruptionEndOnEngine:audioEngine shouldResume:shouldResume]; } - (void)handleSecondaryAudio:(NSNotification *)notification @@ -187,28 +209,16 @@ - (void)handleSecondaryAudio:(NSNotification *)notification self.interruptionEndedDelivered = false; dispatch_async(dispatch_get_main_queue(), ^{ [sessionManager markInactive]; - [audioEngine onInterruptionBegin]; + bool accepted = [audioEngine onInterruptionBegin]; + [self emitInterruptionBeganIfAccepted:accepted]; }); - - if (self.audioInterruptionsObserved) { - [self.audioAPIModule invokeHandlerWithEventName:audioapi::AudioEvent::INTERRUPTION - payload:audioapi::InterruptionPayload{ - .type = "began", .shouldResume = false}]; - } return; } bool shouldResume = secondaryAudioType == AVAudioSessionSilenceSecondaryAudioHintTypeEnd; self.interruptionEndedDelivered = true; - 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 performInterruptionEndOnEngine:audioEngine shouldResume:shouldResume]; } - (void)handleRouteChange:(NSNotification *)notification @@ -330,25 +340,15 @@ - (void)checkSecondaryAudioHint self.interruptionEndedDelivered = false; dispatch_async(dispatch_get_main_queue(), ^{ [sessionManager markInactive]; - [audioEngine onInterruptionBegin]; + bool accepted = [audioEngine onInterruptionBegin]; + [self emitInterruptionBeganIfAccepted:accepted]; }); - if (self.audioInterruptionsObserved) { - [self.audioAPIModule invokeHandlerWithEventName:audioapi::AudioEvent::INTERRUPTION - payload:audioapi::InterruptionPayload{ - .type = "began", .shouldResume = false}]; - } return; } self.interruptionEndedDelivered = true; - 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 performInterruptionEndOnEngine:audioEngine shouldResume:true]; } @end From f12b2506478859fc49964bfcd105b2bd9bfea9d7 Mon Sep 17 00:00:00 2001 From: Kacper Orszulak <42392219+SomePersonFromMars@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:47:05 +0200 Subject: [PATCH 09/29] feat: observeAudioInterruptions(true) in the Record demo --- apps/CLAUDE.md | 6 + apps/common-app/src/demos/Record/Record.tsx | 41 ++++++- .../demos/Record/RecordingVisualization.tsx | 115 ++++++++++++++---- .../src/demos/Record/TimeStream.tsx | 69 +++++++---- 4 files changed, 182 insertions(+), 49 deletions(-) diff --git a/apps/CLAUDE.md b/apps/CLAUDE.md index 0132554e3..6e8a489dd 100644 --- a/apps/CLAUDE.md +++ b/apps/CLAUDE.md @@ -281,6 +281,10 @@ if (status !== 'Granted') return; ### Interruption handling +Enable emission with `AudioManager.observeAudioInterruptions(true)`, then listen. + +**Playback:** pause on `began` (native does not resume players). The AudioFile example resumes on `ended` when it had been playing. + ```tsx useEffect(() => { const sub = AudioManager.addSystemEventListener('interruption', (event) => { @@ -290,6 +294,8 @@ useEffect(() => { }, []); ``` +**Recording:** native always resumes the engine. Do not `Recorder.pause()` on `began`. iOS has already stopped I/O; the engine is `Interrupted`, not `Paused`. Only `Interrupted` is retried on `ended` / foreground — `Recorder.pause()` would move the engine to `Paused` and disable that retry. JS only freezes UI that still looks live (the Record demo's scrolling waveform). Unfreeze only on `ended` (failed resume does not emit `ended`). + ## Shared UI Components All in `apps/common-app/src/components/`: diff --git a/apps/common-app/src/demos/Record/Record.tsx b/apps/common-app/src/demos/Record/Record.tsx index 1b480da40..803fd00f5 100644 --- a/apps/common-app/src/demos/Record/Record.tsx +++ b/apps/common-app/src/demos/Record/Record.tsx @@ -23,11 +23,13 @@ import { RecordingState } from './types'; const Record: FC = () => { const [state, setState] = useState(RecordingState.Idle); const [hasPermissions, setHasPermissions] = useState(false); + const [isInterrupted, setIsInterrupted] = useState(false); const [recordedBuffer, setRecordedBuffer] = useState( null ); const currentPositionSV = useSharedValue(0); const playbackSourceRef = useRef(null); + const stateRef = useRef(state); const stopPlayback = useCallback(() => { const source = playbackSourceRef.current; @@ -97,6 +99,7 @@ const Record: FC = () => { setupNotification(false); if (result.status === 'success') { + setIsInterrupted(false); setState(RecordingState.Recording); return; } @@ -109,6 +112,7 @@ const Record: FC = () => { const onPauseRecording = useCallback(() => { Recorder.pause(); updateNotification(true); + setIsInterrupted(false); setState(RecordingState.Paused); }, []); @@ -121,6 +125,7 @@ const Record: FC = () => { const onStopRecording = useCallback(async () => { const info = await Recorder.stop(); RecordingNotificationManager.hide(); + setIsInterrupted(false); setState(RecordingState.Loading); if (info.status !== 'success') { @@ -227,6 +232,10 @@ const Record: FC = () => { ] ); + useEffect(() => { + stateRef.current = state; + }, [state]); + useEffect(() => { (async () => { const permissionStatus = await AudioManager.checkRecordingPermissions(); @@ -237,6 +246,31 @@ const Record: FC = () => { })(); }, []); + useEffect(() => { + AudioManager.observeAudioInterruptions(true); + + const interruptionSubscription = AudioManager.addSystemEventListener( + 'interruption', + (event) => { + if (event.type === 'began') { + if (stateRef.current === RecordingState.Recording) { + setIsInterrupted(true); + } + return; + } + + if (event.type === 'ended') { + setIsInterrupted(false); + } + } + ); + + return () => { + interruptionSubscription.remove(); + AudioManager.observeAudioInterruptions(false); + }; + }, []); + useEffect(() => { const pauseListener = RecordingNotificationManager.addEventListener( 'recordingNotificationPause', @@ -262,7 +296,10 @@ const Record: FC = () => { }, [onPauseRecording, onResumeRecording]); useEffect(() => { - Recorder.enableFileOutput({ rotateIntervalBytes: 1_000_000, format: FileFormat.M4A }); + Recorder.enableFileOutput({ + rotateIntervalBytes: 1_000_000, + format: FileFormat.M4A, + }); return () => { stopPlayback(); @@ -289,7 +326,7 @@ const Record: FC = () => { <> - + )} diff --git a/apps/common-app/src/demos/Record/RecordingVisualization.tsx b/apps/common-app/src/demos/Record/RecordingVisualization.tsx index 747026742..f896b8e3a 100644 --- a/apps/common-app/src/demos/Record/RecordingVisualization.tsx +++ b/apps/common-app/src/demos/Record/RecordingVisualization.tsx @@ -8,10 +8,7 @@ import { } from '@shopify/react-native-skia'; import React, { useEffect, useMemo, useRef } from 'react'; import { Dimensions, StyleSheet, View } from 'react-native'; -import { - WorkletAudioContext, - WorkletNode, -} from 'react-native-audio-worklets'; +import { WorkletAudioContext, WorkletNode } from 'react-native-audio-worklets'; import { cancelAnimation, Easing, @@ -46,6 +43,7 @@ function getInitialHistory() { interface RecordingVisualizationProps { state: RecordingState; + isInterrupted: boolean; } interface DrawDefaultWaveformParams { @@ -68,8 +66,14 @@ interface DrawHistoryWaveformParams { function drawDefaultWaveform(params: DrawDefaultWaveformParams) { 'worklet'; - const { normalized, canvasHeight, barHeights, translateX, lastIndex, numBars } = - params; + const { + normalized, + canvasHeight, + barHeights, + translateX, + lastIndex, + numBars, + } = params; if (canvasHeight <= 0 || numBars <= 0) { return barHeights; @@ -143,8 +147,68 @@ function drawHistoryWaveform(params: DrawHistoryWaveformParams) { return history; } +function loopingWaveformScroll(target: number, durationMs: number) { + 'worklet'; + return withRepeat( + withTiming(target, { + duration: durationMs, + easing: Easing.linear, + }), + -1, + false + ); +} + +function resumeWaveformScroll( + translateX: SharedValue, + canvasWidth: number +) { + if (canvasWidth <= 0) { + return; + } + + const animationTarget = -canvasWidth; + const cycleDurationMs = 1000 * (canvasWidth / constants.pixelsPerSecond); + + // Restarting withRepeat from a mid-cycle offset would jump back to that + // offset at the end of each loop. Finish the current cycle first. + if (translateX.value >= 0) { + translateX.value = loopingWaveformScroll(animationTarget, cycleDurationMs); + return; + } + + const remainingDistance = translateX.value - animationTarget; + const remainingDurationMs = + cycleDurationMs * (remainingDistance / canvasWidth); + + if (remainingDurationMs <= 0) { + translateX.value = 0; + translateX.value = loopingWaveformScroll(animationTarget, cycleDurationMs); + return; + } + + translateX.value = withTiming( + animationTarget, + { + duration: remainingDurationMs, + easing: Easing.linear, + }, + (finished) => { + if (!finished) { + return; + } + translateX.value = 0; + translateX.value = loopingWaveformScroll( + animationTarget, + cycleDurationMs + ); + } + ); +} + const RecordingVisualization: React.FC = ({ state, + isInterrupted, }) => { const canvasRef = useCanvasRef(); const lifetimeCanvasRef = useCanvasRef(); @@ -166,6 +230,7 @@ const RecordingVisualization: React.FC = ({ const canvasHeightSV = useSharedValue(0); const lifetimeCanvasHeightSV = useSharedValue(0); const numBarsSV = useSharedValue(0); + const isInterruptedSV = useSharedValue(false); const stateRef = useRef(state); const workletContextRef = useRef(null); @@ -273,7 +338,17 @@ const RecordingVisualization: React.FC = ({ numBarsSV.value = numBars; canvasHeightSV.value = size.height; lifetimeCanvasHeightSV.value = lifetimeSize.height; - }, [numBars, size.height, lifetimeSize.height, numBarsSV, canvasHeightSV, lifetimeCanvasHeightSV]); + isInterruptedSV.value = isInterrupted; + }, [ + numBars, + size.height, + lifetimeSize.height, + isInterrupted, + numBarsSV, + canvasHeightSV, + lifetimeCanvasHeightSV, + isInterruptedSV, + ]); useEffect(() => { if (numBars <= 0) { @@ -302,12 +377,11 @@ const RecordingVisualization: React.FC = ({ const lifetimeCanvasHeight = lifetimeCanvasHeightSV.value; const activeNumBars = numBarsSV.value; - if (canvasHeight <= 0 || activeNumBars <= 0) { + if (isInterruptedSV.value || canvasHeight <= 0 || activeNumBars <= 0) { return; } - durationMS.value += - (audioData.length / constants.sampleRate) * 1000; + durationMS.value += (audioData.length / constants.sampleRate) * 1000; let maxValue = 0; for (let i = 0; i < audioData.length; i++) { @@ -317,8 +391,7 @@ const RecordingVisualization: React.FC = ({ } } - const db = - maxValue > 0 ? 20 * Math.log10(maxValue) : constants.minDb; + const db = maxValue > 0 ? 20 * Math.log10(maxValue) : constants.minDb; let normalized = (db - constants.minDb) / (constants.maxDb - constants.minDb); normalized = Math.max(0, Math.min(1, normalized)); @@ -418,18 +491,10 @@ const RecordingVisualization: React.FC = ({ }, [state]); useEffect(() => { - if (state === RecordingState.Recording) { - const animationTarget = -size.width; - const animationDuration = 1000 * (size.width / constants.pixelsPerSecond); - - translateX.value = withRepeat( - withTiming(animationTarget, { - duration: animationDuration, - easing: Easing.linear, - }), - -1, // Infinite loop - false // No reverse - ); + if (state === RecordingState.Recording && isInterrupted) { + cancelAnimation(translateX); + } else if (state === RecordingState.Recording) { + resumeWaveformScroll(translateX, size.width); } else if (state === RecordingState.Paused) { cancelAnimation(translateX); @@ -459,6 +524,7 @@ const RecordingVisualization: React.FC = ({ } }, [ state, + isInterrupted, size, translateX, barHeights, @@ -495,6 +561,7 @@ const RecordingVisualization: React.FC = ({ diff --git a/apps/common-app/src/demos/Record/TimeStream.tsx b/apps/common-app/src/demos/Record/TimeStream.tsx index 0fd4ff022..b78b411d4 100644 --- a/apps/common-app/src/demos/Record/TimeStream.tsx +++ b/apps/common-app/src/demos/Record/TimeStream.tsx @@ -20,6 +20,7 @@ const formatTime = (seconds: number) => { interface TimeStreamProps { isRecording: boolean; + isFrozen?: boolean; durationMS: SharedValue; } @@ -33,41 +34,55 @@ function generateInitialTimestamps() { return timestamps; } -const TimeStream: React.FC = ({ isRecording, durationMS }) => { +const TimeStream: React.FC = ({ + isRecording, + isFrozen = false, + durationMS, +}) => { const [timestamps, setTimestamps] = useState( generateInitialTimestamps() ); const intervalRef = useRef(null); + const wasRecordingRef = useRef(false); + const isAnimating = isRecording && !isFrozen; useEffect(() => { - if (isRecording) { - setTimestamps(generateInitialTimestamps()); - - intervalRef.current = setInterval(() => { - const elapsedSeconds = durationMS.value / 1000; - const futureSecond = Math.ceil(elapsedSeconds + 1); + const startedRecording = isRecording && !wasRecordingRef.current; + wasRecordingRef.current = isRecording; - setTimestamps((prev) => { - if (prev.includes(futureSecond)) { - return prev; - } - - const cleanList = prev.filter((t) => t > elapsedSeconds - 5); - return [...cleanList, futureSecond]; - }); - }, 500); - } else { + if (!isAnimating) { if (intervalRef.current) { clearInterval(intervalRef.current); + intervalRef.current = null; } + return; + } + + if (startedRecording) { + setTimestamps(generateInitialTimestamps()); } + intervalRef.current = setInterval(() => { + const elapsedSeconds = durationMS.value / 1000; + const futureSecond = Math.ceil(elapsedSeconds + 1); + + setTimestamps((prev) => { + if (prev.includes(futureSecond)) { + return prev; + } + + const cleanList = prev.filter((t) => t > elapsedSeconds - 5); + return [...cleanList, futureSecond]; + }); + }, 500); + return () => { if (intervalRef.current) { clearInterval(intervalRef.current); + intervalRef.current = null; } }; - }, [isRecording, durationMS]); + }, [isAnimating, isRecording, durationMS]); return ( @@ -76,7 +91,8 @@ const TimeStream: React.FC = ({ isRecording, durationMS }) => { key={seconds} spawnSeconds={seconds} durationMS={durationMS} - isRecording={isRecording} + isAnimating={isAnimating} + isFrozen={isRecording && isFrozen} /> ))} @@ -90,7 +106,8 @@ const textWidth = 60; interface TimestampProps { spawnSeconds: number; durationMS: SharedValue; - isRecording: boolean; + isAnimating: boolean; + isFrozen: boolean; } const subSeconds = new Array(7).fill(0).map((_, i) => `sub-${i}`); @@ -98,11 +115,17 @@ const subSeconds = new Array(7).fill(0).map((_, i) => `sub-${i}`); const Timestamp: React.FC = ({ spawnSeconds, durationMS, - isRecording, + isAnimating, + isFrozen, }) => { const translateX = useSharedValue(2 * windowWidth); useEffect(() => { + if (isFrozen) { + cancelAnimation(translateX); + return; + } + const originalPositionOfFirstTimestamp = windowWidth - textWidth / 2; const currentPositionOfFirstTimestamp = originalPositionOfFirstTimestamp - @@ -119,7 +142,7 @@ const Timestamp: React.FC = ({ translateX.value = startX; - if (!isRecording) { + if (!isAnimating) { cancelAnimation(translateX); return; } @@ -128,7 +151,7 @@ const Timestamp: React.FC = ({ duration: duration, easing: Easing.linear, }); - }, [spawnSeconds, durationMS, translateX, isRecording]); + }, [spawnSeconds, durationMS, translateX, isAnimating, isFrozen]); const containerStyle = useAnimatedStyle(() => ({ position: 'absolute', From 86a9ae7e59473166578c85919dae26efb09fa104 Mon Sep 17 00:00:00 2001 From: Kacper Orszulak <42392219+SomePersonFromMars@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:08:53 +0200 Subject: [PATCH 10/29] feat: document resuming recording after an interruption --- .../docs/fundamentals/best-practices.mdx | 2 +- .../audiodocs/docs/other/audio-api-plugin.mdx | 2 +- .../audiodocs/docs/system/audio-manager.mdx | 55 ++++++++++++++++--- 3 files changed, 50 insertions(+), 9 deletions(-) diff --git a/packages/audiodocs/docs/fundamentals/best-practices.mdx b/packages/audiodocs/docs/fundamentals/best-practices.mdx index c9ed568cb..64969bfba 100644 --- a/packages/audiodocs/docs/fundamentals/best-practices.mdx +++ b/packages/audiodocs/docs/fundamentals/best-practices.mdx @@ -22,7 +22,7 @@ user experience, and maintainability. Here are some key best practices to consid Running `AudioContext` is still playing silence even if there is no playing source node connected to the [`destination`](/docs/core/base-audio-context#properties). Additionally, on iOS devices, the state of the `AudioContext` is directly related with state of the lock screen. If a running `AudioContext` exists, it is impossible to set lock screen state to `state_paused`. -- **Configure the audio session early**: Set [`AudioManager.setAudioSessionOptions()`](/docs/system/audio-manager) once at startup — for example `iosCategory: 'playback'` for media apps or `iosCategory: 'playAndRecord'` when recording and playback coexist. +- **Configure the audio session early**: Set [`AudioManager.setAudioSessionOptions()`](/docs/system/audio-manager#setaudiosessionoptions) once at startup and follow the [session configuration guidelines](/docs/system/audio-manager#sessionoptionsconfigurationguidelines). ## [**AudioRecorder**](/docs/inputs/audio-recorder) Management diff --git a/packages/audiodocs/docs/other/audio-api-plugin.mdx b/packages/audiodocs/docs/other/audio-api-plugin.mdx index ecf3dae12..60cddb526 100644 --- a/packages/audiodocs/docs/other/audio-api-plugin.mdx +++ b/packages/audiodocs/docs/other/audio-api-plugin.mdx @@ -84,7 +84,7 @@ export default { Defaults to `true`. -Allows the app to play audio in the background on iOS. +Allows the app to play audio in the background on iOS. Corresponds to adding `audio` to [`UIBackgroundModes`](https://developer.apple.com/documentation/bundleresources/information-property-list/uibackgroundmodes). ### `iosMicrophonePermission` diff --git a/packages/audiodocs/docs/system/audio-manager.mdx b/packages/audiodocs/docs/system/audio-manager.mdx index e044b6a97..ba83b531a 100644 --- a/packages/audiodocs/docs/system/audio-manager.mdx +++ b/packages/audiodocs/docs/system/audio-manager.mdx @@ -44,21 +44,21 @@ function App() { ## Methods -### `setAudioSessionOptions` +### `setAudioSessionOptions` {#setaudiosessionoptions} + +| Parameter | Type | Description | +| :---: | :---: | :---- | +| options | [`SessionOptions`](/docs/system/audio-manager#sessionoptions) | Options to be set for [AVAudioSession](https://developer.apple.com/documentation/avfaudio/avaudiosession?language=objc#Configuring-standard-audio-behaviors). | :::warning AVAudioSession Compatibility Not all `iosOptions` are compatible with every `iosCategory`. Passing an invalid combination to the native API (for example, explicitly setting `allowBluetoothA2DP` alongside the `playback` category) will cause the configuration to fail. This can result in a `SessionActivationError` and total audio silence. -Always verify valid category and option combinations in [Apple's AVAudioSession Documentation](https://developer.apple.com/documentation/avfaudio/avaudiosession?language=objc). +For details on compatible options and categories, refer to [`SessionOptions`](#sessionoptions). ::: -| Parameter | Type | Description | -| :---: | :---: | :---- | -| options | [`SessionOptions`](/docs/system/audio-manager#sessionoptions) | Options to be set for [AVAudioSession](https://developer.apple.com/documentation/avfaudio/avaudiosession?language=objc#Configuring-standard-audio-behaviors). | - #### Returns `undefined`. -### `setAudioSessionActivity` +### `setAudioSessionActivity` {#setaudiosessionactivity} | Parameter | Type | Description | | :---: | :---: | :---- | @@ -165,6 +165,30 @@ Checks currently used and available devices. ## Remarks +### Resume recording after an interruption {#resumerecordingafteraninterruption} + +An interruption is the system taking the audio session away due to a phone call, an alarm, or another non-mixable audio session. It deactivates the session and stops the I/O unit. Audio playback and capture therefore stop. + +A resume that completes while the app is still backgrounded is possible only when all of the conditions below hold. + +#### App configuration + +- Ensure the session options (set via [`setAudioSessionOptions`](#setaudiosessionoptions)) allow recording and make the app resilient to interruptions from other apps. Refer to the [session configuration guidelines](#sessionoptionsconfigurationguidelines). The following settings are a useful starting point: + - `iosCategory` set to `record` or `playAndRecord`, so the session can record. These categories also allow background recording. An incompatible category leads to a [`cannotStartPlaying`](https://developer.apple.com/documentation/coreaudiotypes/avaudiosession/errorcode/cannotstartplaying) error. + - `iosMode` set to `default`. + - `iosOptions` include `mixWithOthers`, so the system can mix this session with audio from active sessions in other apps. Trying to resume recording in the background with a non-mixable session leads to [`cannotInterruptOthers`](https://developer.apple.com/documentation/coreaudiotypes/avaudiosession/errorcode/cannotinterruptothers). +- The app must declare [background audio mode](/docs/other/audio-api-plugin#iosbackgroundmode). + +#### Execution + +To complete a background resume, audio I/O must have been started in the foreground, and it must still have been running when the app went to the background. The background audio mode lets that already-running I/O *continue*; it does not allow *starting* audio from the background. iOS treats an `interruption` event of type `ended` as the wake on which a background resume may be attempted. A later background timer is not such a wake. Another app must have released the audio route and the microphone. For recording, the recorder must not have been stopped with [`stop()`](/docs/inputs/audio-recorder#stop), because stopping ends the take. + +#### iOS policy + +iOS policy determines the overall outcome of restarts. Activating the session and starting I/O are independent steps. An active session does not grant permission to run I/O. Even when every condition above is met, iOS may still refuse the restart: it applies internal checks on why the app is awake. The library therefore does not promise a background restart after a call, Siri, or a similar interruption. Because of this, the library retries with a best-effort strategy when the app returns to the foreground after a failed resume. + +## Types + ### `AudioFocusType`
Type definitions @@ -181,6 +205,7 @@ type AudioFocusType =
Type definitions + ```typescript type IOSCategory = | 'ambient' @@ -226,11 +251,21 @@ interface SessionOptions { ```
+#### Configuration guidelines {#sessionoptionsconfigurationguidelines} + +Each string union maps directly to the corresponding Apple type: + +- `IOSCategory` maps to [`AVAudioSession.Category`](https://developer.apple.com/documentation/avfaudio/avaudiosession/category-swift.struct) +- `IOSMode` maps to [`AVAudioSession.Mode`](https://developer.apple.com/documentation/avfaudio/avaudiosession/mode-swift.struct) +- `IOSOption` maps to [`AVAudioSession.CategoryOptions`](https://developer.apple.com/documentation/avfaudio/avaudiosession/categoryoptions-swift.struct) + +Not all `iosOptions` are compatible with every `iosCategory`. Passing an invalid combination to the native API will cause the configuration to fail. Always verify valid category and option combinations in [Apple's AVAudioSession Documentation](https://developer.apple.com/documentation/avfaudio/avaudiosession?language=objc). ### `SystemEventName`
Type definitions + ```typescript interface EventEmptyType {} @@ -295,15 +330,18 @@ interface AudioEventSubscription {
Type definitions + ```typescript type PermissionStatus = 'Undetermined' | 'Denied' | 'Granted'; ``` +
### `AudioDeviceInfo`
Type definitions + ```typescript export interface AudioDeviceInfo { id: string; // unique device identifier @@ -311,12 +349,14 @@ export interface AudioDeviceInfo { category: string; // device category (e.g. "Built-In Microphone", "Bluetooth") } ``` +
### `AudioDevicesInfo`
Type definitions + ```typescript export type AudioDeviceList = AudioDeviceInfo[]; @@ -327,4 +367,5 @@ export interface AudioDevicesInfo { currentOutputs: AudioDeviceList; // iOS } ``` +
From 673d326f0d87af7ea16fa8772dda3176842c154f Mon Sep 17 00:00:00 2001 From: Kacper Orszulak <42392219+SomePersonFromMars@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:48:35 +0200 Subject: [PATCH 11/29] fix: detach source node in player on failed start similarly to what recorder does --- .../FabricExampleTests/AudioPlayerTests.mm | 77 +++++++++++++++++++ .../ios/audioapi/ios/core/NativeAudioPlayer.m | 9 ++- 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/apps/fabric-example/ios/FabricExampleTests/AudioPlayerTests.mm b/apps/fabric-example/ios/FabricExampleTests/AudioPlayerTests.mm index a115ec460..83197635c 100644 --- a/apps/fabric-example/ios/FabricExampleTests/AudioPlayerTests.mm +++ b/apps/fabric-example/ios/FabricExampleTests/AudioPlayerTests.mm @@ -4,6 +4,7 @@ #import #import #import +#import #import #import #import @@ -110,11 +111,14 @@ @interface FakePlayerAudioEngine : AudioEngine @property(nonatomic, assign) NSInteger stopIfPossibleCallCount; @property(nonatomic, assign) NSInteger attachSourceNodeCallCount; @property(nonatomic, assign) NSInteger detachSourceNodeCallCount; +@property(nonatomic, assign) NSInteger attachInputNodeCallCount; @property(nonatomic, copy) AVAudioSourceNodeRenderBlock lastAttachedRenderBlock; @property(nonatomic, assign) float lastAttachedSampleRate; @property(nonatomic, assign) AVAudioChannelCount lastAttachedChannelCount; @property(nonatomic, copy) NSString *returnedSourceNodeId; @property(nonatomic, copy) NSString *lastDetachedSourceNodeId; +@property(nonatomic, strong) NSMutableSet *attachedSourceNodeIds; +@property(nonatomic, copy) NSSet *sourceNodeIdsPresentAtLastStart; @end @@ -125,6 +129,7 @@ - (instancetype)init if (self = [super init]) { self.startIfNecessaryResult = YES; self.returnedSourceNodeId = @"fake-source-node-id"; + self.attachedSourceNodeIds = [NSMutableSet set]; } return self; @@ -148,6 +153,7 @@ - (void)stopIfNecessary - (bool)startIfNecessary { self.startIfNecessaryCallCount += 1; + self.sourceNodeIdsPresentAtLastStart = [self.attachedSourceNodeIds copy]; return self.startIfNecessaryResult; } @@ -164,6 +170,7 @@ - (NSString *)attachSourceNodeWithRenderBlock:(AVAudioSourceNodeRenderBlock)rend self.lastAttachedRenderBlock = renderBlock; self.lastAttachedSampleRate = sampleRate; self.lastAttachedChannelCount = channelCount; + [self.attachedSourceNodeIds addObject:self.returnedSourceNodeId]; return self.returnedSourceNodeId; } @@ -171,6 +178,21 @@ - (void)detachSourceNodeWithId:(NSString *)sourceNodeId { self.detachSourceNodeCallCount += 1; self.lastDetachedSourceNodeId = sourceNodeId; + + if (sourceNodeId != nil) { + [self.attachedSourceNodeIds removeObject:sourceNodeId]; + } +} + +- (void)attachInputNodeWithReceiverBlock:(AVAudioSinkNodeReceiverBlock)receiverBlock + voiceProcessingEnabled:(BOOL)voiceProcessingEnabled + onInputNotification: + (void (^)(AudioEngineInputNotification))onInputNotification +{ + self.attachInputNodeCallCount += 1; + (void)receiverBlock; + (void)voiceProcessingEnabled; + (void)onInputNotification; } @end @@ -421,6 +443,61 @@ - (void)testStartReturnsFalseWhenSessionActivationFails XCTAssertNil(player.sourceNodeId); } +- (void)assertFailedEngineStartDetachesSourceForSelector:(SEL)selector +{ + NativeAudioPlayer *player = [self createPlayerWithRenderCallCount:nullptr]; + self.audioEngine.startIfNecessaryResult = NO; + typedef BOOL (*NativeAudioPlayerBoolMethod)(id, SEL); + NativeAudioPlayerBoolMethod operation = + (NativeAudioPlayerBoolMethod)[player methodForSelector:selector]; + + XCTAssertFalse(operation(player, selector)); + XCTAssertEqual(self.audioEngine.stopIfNecessaryCallCount, 1); + XCTAssertEqual(self.audioEngine.attachSourceNodeCallCount, 1); + XCTAssertEqual(self.audioEngine.startIfNecessaryCallCount, 1); + XCTAssertEqual(self.audioEngine.detachSourceNodeCallCount, 1); + XCTAssertEqualObjects( + self.audioEngine.lastDetachedSourceNodeId, self.audioEngine.returnedSourceNodeId); + XCTAssertEqual(self.audioEngine.stopIfPossibleCallCount, 1); + XCTAssertNil(player.sourceNodeId); + XCTAssertEqual(self.audioEngine.attachedSourceNodeIds.count, 0UL); +} + +- (void)testStartAndResumeDetachSourceWhenEngineFailsToStart +{ + [self assertFailedEngineStartDetachesSourceForSelector:@selector(start)]; + + self.audioEngine.stopIfNecessaryCallCount = 0; + self.audioEngine.attachSourceNodeCallCount = 0; + self.audioEngine.startIfNecessaryCallCount = 0; + self.audioEngine.detachSourceNodeCallCount = 0; + self.audioEngine.stopIfPossibleCallCount = 0; + self.audioEngine.lastDetachedSourceNodeId = nil; + self.sessionManager.ensureActiveCallCount = 0; + + [self assertFailedEngineStartDetachesSourceForSelector:@selector(resume)]; +} + +- (void)testFailedPlayerStartLeavesNoSourceForFollowingRecorderStart +{ + NativeAudioPlayer *player = [self createPlayerWithRenderCallCount:nullptr]; + self.audioEngine.startIfNecessaryResult = NO; + + XCTAssertFalse([player start]); + XCTAssertEqual(self.audioEngine.attachedSourceNodeIds.count, 0UL); + XCTAssertNil(player.sourceNodeId); + + self.audioEngine.startIfNecessaryResult = YES; + NativeAudioRecorder *recorder = [[NativeAudioRecorder alloc] + initWithReceiverBlock:^(const AudioBufferList *inputBuffer, int numFrames) {} + voiceProcessingEnabled:NO]; + + XCTAssertTrue([recorder start:nil]); + XCTAssertEqual(self.audioEngine.attachInputNodeCallCount, 1); + XCTAssertEqual(self.audioEngine.attachedSourceNodeIds.count, 0UL); + XCTAssertEqual(self.audioEngine.sourceNodeIdsPresentAtLastStart.count, 0UL); +} + - (void)testAttachSourceNodeIfNeededIsIdempotent { NativeAudioPlayer *player = [self createPlayerWithRenderCallCount:nullptr]; diff --git a/packages/react-native-audio-api/ios/audioapi/ios/core/NativeAudioPlayer.m b/packages/react-native-audio-api/ios/audioapi/ios/core/NativeAudioPlayer.m index d5292e417..b7df35061 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/core/NativeAudioPlayer.m +++ b/packages/react-native-audio-api/ios/audioapi/ios/core/NativeAudioPlayer.m @@ -29,7 +29,14 @@ - (bool)startPlaybackGraph:(AudioEngine *)audioEngine { [audioEngine stopIfNecessary]; [self attachSourceNodeIfNeeded:audioEngine]; - return [audioEngine startIfNecessary]; + + if (![audioEngine startIfNecessary]) { + [self detachSourceNodeIfAttached:audioEngine]; + [audioEngine stopIfPossible]; + return false; + } + + return true; } - (instancetype)initWithRenderAudio:(RenderAudioBlock)renderAudio From d648901eedb09e74290e01ed1ca09022f3225f68 Mon Sep 17 00:00:00 2001 From: Kacper Orszulak <42392219+SomePersonFromMars@users.noreply.github.com> Date: Thu, 3 Sep 2026 10:09:48 +0200 Subject: [PATCH 12/29] fix: don't poison state on a no-op deactivation --- .../FabricExampleTests/AudioAPIModuleTests.mm | 76 ++++++++++++++++++- .../FabricExampleTests/AudioEngineTests.mm | 12 +++ .../ios/audioapi/ios/AudioAPIModule.mm | 4 +- 3 files changed, 87 insertions(+), 5 deletions(-) diff --git a/apps/fabric-example/ios/FabricExampleTests/AudioAPIModuleTests.mm b/apps/fabric-example/ios/FabricExampleTests/AudioAPIModuleTests.mm index 25f93cd2c..16e9a8264 100644 --- a/apps/fabric-example/ios/FabricExampleTests/AudioAPIModuleTests.mm +++ b/apps/fabric-example/ios/FabricExampleTests/AudioAPIModuleTests.mm @@ -44,6 +44,9 @@ - (void)onSessionDeactivated { self.onSessionDeactivatedCallCount += 1; AppendAudioModuleEvent(self.eventLog, @"onSessionDeactivated"); + if (self.state != AudioEngineStateIdle) { + self.state = AudioEngineStatePaused; + } } @end @@ -63,13 +66,21 @@ - (bool)setActive:(bool)active error:(NSError **)error { self.setActiveCallCount += 1; self.lastSetActiveValue = active; - self.isActive = active; AppendAudioModuleEvent(self.eventLog, @"setActive"); if (error != nil) { *error = nil; } + if (!self.shouldManageSession) { + return true; + } + + if (!active && !self.isActive) { + return true; + } + + self.isActive = active; return true; } @@ -120,14 +131,16 @@ - (void)tearDown [super tearDown]; } -- (void)testSetAudioSessionActivityFalseWaitsForSessionDeactivationBeforeResolve +- (NSArray *)invokeSetAudioSessionActivity:(BOOL)enabled + rejectionCodeOut:(NSString **)rejectionCodeOut { - XCTestExpectation *resolveExpectation = [self expectationWithDescription:@"setAudioSessionActivity"]; + XCTestExpectation *resolveExpectation = + [self expectationWithDescription:@"setAudioSessionActivity"]; __block NSArray *eventsAtResolve = nil; __block NSString *rejectionCode = nil; NSMutableArray *eventLog = self.eventLog; - [self.module setAudioSessionActivity:NO + [self.module setAudioSessionActivity:enabled resolve:^(id result) { AppendAudioModuleEvent(eventLog, @"resolve"); eventsAtResolve = CopyAudioModuleEvents(eventLog); @@ -141,15 +154,70 @@ - (void)testSetAudioSessionActivityFalseWaitsForSessionDeactivationBeforeResolve [self waitForExpectations:@[ resolveExpectation ] timeout:1.0]; + if (rejectionCodeOut != nil) { + *rejectionCodeOut = rejectionCode; + } + + return eventsAtResolve; +} + +- (void)testSetAudioSessionActivityFalseWaitsForSessionDeactivationBeforeResolve +{ + self.fakeSessionManager.isActive = YES; + self.fakeAudioEngine.state = AudioEngineStateInterrupted; + + NSString *rejectionCode = nil; + NSArray *eventsAtResolve = + [self invokeSetAudioSessionActivity:NO rejectionCodeOut:&rejectionCode]; + XCTAssertNil(rejectionCode); XCTAssertEqual(self.fakeSessionManager.setActiveCallCount, 1); XCTAssertFalse(self.fakeSessionManager.lastSetActiveValue); XCTAssertEqual(self.fakeSessionManager.markInactiveCallCount, 1); XCTAssertEqual(self.fakeAudioEngine.onSessionDeactivatedCallCount, 1); + XCTAssertEqual(self.fakeAudioEngine.state, AudioEngineStatePaused); XCTAssertFalse(self.fakeSessionManager.isActive); XCTAssertEqualObjects( eventsAtResolve, (@[ @"setActive", @"markInactive", @"onSessionDeactivated", @"resolve" ])); } +- (void)testSetAudioSessionActivityFalseDoesNotPauseWhenAlreadyInactive +{ + self.fakeSessionManager.isActive = NO; + self.fakeAudioEngine.state = AudioEngineStateInterrupted; + + NSString *rejectionCode = nil; + NSArray *eventsAtResolve = + [self invokeSetAudioSessionActivity:NO rejectionCodeOut:&rejectionCode]; + + XCTAssertNil(rejectionCode); + XCTAssertEqual(self.fakeSessionManager.setActiveCallCount, 1); + XCTAssertFalse(self.fakeSessionManager.lastSetActiveValue); + XCTAssertEqual(self.fakeSessionManager.markInactiveCallCount, 0); + XCTAssertEqual(self.fakeAudioEngine.onSessionDeactivatedCallCount, 0); + XCTAssertEqual(self.fakeAudioEngine.state, AudioEngineStateInterrupted); + XCTAssertFalse(self.fakeSessionManager.isActive); + XCTAssertEqualObjects(eventsAtResolve, (@[ @"setActive", @"resolve" ])); +} + +- (void)testSetAudioSessionActivityFalseDoesNotPauseWhenNotManagingSession +{ + self.fakeSessionManager.shouldManageSession = NO; + self.fakeSessionManager.isActive = YES; + self.fakeAudioEngine.state = AudioEngineStateInterrupted; + + NSString *rejectionCode = nil; + NSArray *eventsAtResolve = + [self invokeSetAudioSessionActivity:NO rejectionCodeOut:&rejectionCode]; + + XCTAssertNil(rejectionCode); + XCTAssertEqual(self.fakeSessionManager.setActiveCallCount, 1); + XCTAssertEqual(self.fakeSessionManager.markInactiveCallCount, 0); + XCTAssertEqual(self.fakeAudioEngine.onSessionDeactivatedCallCount, 0); + XCTAssertEqual(self.fakeAudioEngine.state, AudioEngineStateInterrupted); + XCTAssertTrue(self.fakeSessionManager.isActive); + XCTAssertEqualObjects(eventsAtResolve, (@[ @"setActive", @"resolve" ])); +} + @end diff --git a/apps/fabric-example/ios/FabricExampleTests/AudioEngineTests.mm b/apps/fabric-example/ios/FabricExampleTests/AudioEngineTests.mm index 1cf3dbb31..f8c91bd3a 100644 --- a/apps/fabric-example/ios/FabricExampleTests/AudioEngineTests.mm +++ b/apps/fabric-example/ios/FabricExampleTests/AudioEngineTests.mm @@ -578,6 +578,18 @@ - (void)testOnSessionDeactivatedMarksGraphForRebuildWhenNodesAreAttached { XCTAssertTrue(self.audioEngine.sessionDeactivationInvalidatedGraph); } +- (void)testOnSessionDeactivatedTransitionsInterruptedToPaused { + FakeAudioEngine *fakeEngine = self.audioEngine.currentFakeAudioEngine; + fakeEngine.fakeRunning = NO; + self.audioEngine.state = AudioEngineStateInterrupted; + + [self.audioEngine onSessionDeactivated]; + + XCTAssertEqual(self.audioEngine.state, AudioEngineStatePaused); + XCTAssertEqual(fakeEngine.pauseCallCount, 0); + XCTAssertTrue(self.audioEngine.sessionDeactivationInvalidatedGraph); +} + - (void) testOnSessionDeactivatedMarksStoppedGraphForRebuildWhenNodesAreAttached { FakeAudioEngine *fakeEngine = self.audioEngine.currentFakeAudioEngine; diff --git a/packages/react-native-audio-api/ios/audioapi/ios/AudioAPIModule.mm b/packages/react-native-audio-api/ios/audioapi/ios/AudioAPIModule.mm index 12206d697..49d690288 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/AudioAPIModule.mm +++ b/packages/react-native-audio-api/ios/audioapi/ios/AudioAPIModule.mm @@ -119,6 +119,8 @@ - (dispatch_queue_t)methodQueue { dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ NSError *error = nil; + const BOOL managedSessionWasActive = + self.audioSessionManager.shouldManageSession && self.audioSessionManager.isActive; auto success = [self.audioSessionManager setActive:enabled error:&error]; if (!success) { @@ -140,7 +142,7 @@ - (dispatch_queue_t)methodQueue return; } - if (!enabled) { + if (!enabled && managedSessionWasActive) { if ([NSThread isMainThread]) { [self handleSessionDeactivation]; } else { From 33c3753c4e131d3d38503971b69b19fa1fbc9ac2 Mon Sep 17 00:00:00 2001 From: Kacper Orszulak <42392219+SomePersonFromMars@users.noreply.github.com> Date: Thu, 3 Sep 2026 10:11:51 +0200 Subject: [PATCH 13/29] fix: recorder resume() only updates state on successful engine start --- .../IOSAudioRecorderTests.mm | 18 +++++++++++++++++- .../NativeAudioRecorderTests.mm | 16 ++++++++++++++-- .../ios/audioapi/ios/core/IOSAudioRecorder.mm | 6 +++++- .../audioapi/ios/core/NativeAudioRecorder.h | 3 ++- .../audioapi/ios/core/NativeAudioRecorder.m | 10 ++-------- 5 files changed, 40 insertions(+), 13 deletions(-) diff --git a/apps/fabric-example/ios/FabricExampleTests/IOSAudioRecorderTests.mm b/apps/fabric-example/ios/FabricExampleTests/IOSAudioRecorderTests.mm index d02a443b8..d19aa250c 100644 --- a/apps/fabric-example/ios/FabricExampleTests/IOSAudioRecorderTests.mm +++ b/apps/fabric-example/ios/FabricExampleTests/IOSAudioRecorderTests.mm @@ -123,6 +123,7 @@ @interface FakeNativeAudioRecorder : NativeAudioRecorder @property(nonatomic, assign) NSInteger stopCallCount; @property(nonatomic, assign) NSInteger pauseCallCount; @property(nonatomic, assign) NSInteger resumeCallCount; +@property(nonatomic, assign) BOOL resumeResult; @property(nonatomic, assign) NSInteger cleanupCallCount; @property(nonatomic, assign) NSInteger setInputArmedCallCount; @property(nonatomic, assign) BOOL lastInputArmed; @@ -144,6 +145,7 @@ - (instancetype)init { channels:2]; self.mockResolvedBufferSize = 512; self.startResult = YES; + self.resumeResult = YES; } return self; @@ -188,8 +190,9 @@ - (void)pause { self.pauseCallCount += 1; } -- (void)resume { +- (BOOL)resume { self.resumeCallCount += 1; + return self.resumeResult; } - (void)cleanup { @@ -478,6 +481,19 @@ - (void)testPauseAndResumeRespectCurrentState { XCTAssertFalse(_recorder->isPaused()); } +- (void)testResumeDoesNotStoreRecordingWhenNativeResumeFails { + _recorder->setRecorderState(AudioRecorder::RecorderState::Recording); + self.audioEngine.state = AudioEngineStateRunning; + _recorder->pause(); + self.nativeRecorder.resumeResult = NO; + + _recorder->resume(); + + XCTAssertEqual(self.nativeRecorder.resumeCallCount, 1); + XCTAssertTrue(_recorder->isPaused()); + XCTAssertFalse(_recorder->isRecording()); +} + - (void)testStopReturnsErrorWhileIdle { auto result = _recorder->stop(); diff --git a/apps/fabric-example/ios/FabricExampleTests/NativeAudioRecorderTests.mm b/apps/fabric-example/ios/FabricExampleTests/NativeAudioRecorderTests.mm index 00c009a81..6a19f6654 100644 --- a/apps/fabric-example/ios/FabricExampleTests/NativeAudioRecorderTests.mm +++ b/apps/fabric-example/ios/FabricExampleTests/NativeAudioRecorderTests.mm @@ -432,11 +432,23 @@ - (void)testPauseAndResumeDelegateToAudioEngine voiceProcessingEnabled:NO]; [recorder pause]; - [recorder resume]; + XCTAssertTrue([recorder resume]); XCTAssertEqual(self.audioEngine.pauseIfNecessaryCallCount, 1); XCTAssertEqual(self.audioEngine.startIfNecessaryCallCount, 1); - XCTAssertTrue(recorder.inputArmed); +} + +- (void)testResumeReturnsFalseWhenEngineStartFails +{ + self.audioEngine.startIfNecessaryResult = NO; + NativeAudioRecorder *recorder = [[NativeAudioRecorder alloc] + initWithReceiverBlock:^(const AudioBufferList *inputBuffer, int numFrames) {} + voiceProcessingEnabled:NO]; + + [recorder pause]; + XCTAssertFalse([recorder resume]); + + XCTAssertEqual(self.audioEngine.startIfNecessaryCallCount, 1); } - (void)testStartAfterSessionDeactivationUsesRecoveryRebuildPath diff --git a/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.mm b/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.mm index 50a906a7b..1836425c7 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.mm +++ b/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.mm @@ -698,8 +698,12 @@ static void cleanupStartedRecorder( return; } - [nativeRecorder_ resume]; + if (![nativeRecorder_ resume]) { + return; + } + state_.store(RecorderState::Recording, std::memory_order_release); + handleHardwareChange(); } /// @brief Checks if the recorder is currently recording. diff --git a/packages/react-native-audio-api/ios/audioapi/ios/core/NativeAudioRecorder.h b/packages/react-native-audio-api/ios/audioapi/ios/core/NativeAudioRecorder.h index b75a032b2..497675bc2 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/core/NativeAudioRecorder.h +++ b/packages/react-native-audio-api/ios/audioapi/ios/core/NativeAudioRecorder.h @@ -29,7 +29,8 @@ typedef void (^AudioReceiverBlock)(const AudioBufferList *inputBuffer, int numFr - (void)pause; -- (void)resume; +/// @return YES if the engine started. +- (BOOL)resume; - (void)cleanup; diff --git a/packages/react-native-audio-api/ios/audioapi/ios/core/NativeAudioRecorder.m b/packages/react-native-audio-api/ios/audioapi/ios/core/NativeAudioRecorder.m index 487fb558d..5cd9266f5 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/core/NativeAudioRecorder.m +++ b/packages/react-native-audio-api/ios/audioapi/ios/core/NativeAudioRecorder.m @@ -167,18 +167,12 @@ - (void)pause [audioEngine pauseIfNecessary]; } -- (void)resume +- (BOOL)resume { AudioEngine *audioEngine = [AudioEngine sharedInstance]; assert(audioEngine != nil); - if ([audioEngine startIfNecessary]) { - if (self.onInputNotification != nil) { - self.onInputNotification(AudioEngineInputNotificationHardwareChanged); - } else { - self.inputArmed = YES; - } - } + return [audioEngine startIfNecessary]; } - (void)cleanup From 6b17b0f2eb5591af50f4befe633e807aa7707560 Mon Sep 17 00:00:00 2001 From: Kacper Orszulak <42392219+SomePersonFromMars@users.noreply.github.com> Date: Fri, 18 Sep 2026 17:20:40 +0200 Subject: [PATCH 14/29] fix: workaround to mitigate infinite loop when starting recording --- apps/common-app/src/singletons/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/common-app/src/singletons/index.ts b/apps/common-app/src/singletons/index.ts index 191a5d20a..2e31ffe4f 100644 --- a/apps/common-app/src/singletons/index.ts +++ b/apps/common-app/src/singletons/index.ts @@ -3,5 +3,7 @@ import { AudioContext, AudioRecorder } from 'react-native-audio-api'; export const audioContext = new AudioContext(); export const audioRecorder = new AudioRecorder({ androidInputPreset: 'voiceCommunication', - iosVoiceProcessing: true, + // FIXME: Should be true for iOS echo cancellation; currently fails with the + // default play-and-record session used by the recording demos. + iosVoiceProcessing: false, }); From d9268b3894a0238b309b52fa5e232f1b190765b5 Mon Sep 17 00:00:00 2001 From: Kacper Orszulak <42392219+SomePersonFromMars@users.noreply.github.com> Date: Fri, 18 Sep 2026 17:21:47 +0200 Subject: [PATCH 15/29] fix: schedule rebuild on configuration change instead of doing it interrupted state --- .../ios/audioapi/ios/system/AudioEngine.h | 3 +++ .../ios/audioapi/ios/system/AudioEngine.mm | 6 ++++++ .../audioapi/ios/system/SystemNotificationManager.mm | 10 ++++++++++ 3 files changed, 19 insertions(+) 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 8f542cdf5..88049e9d9 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/system/AudioEngine.h +++ b/packages/react-native-audio-api/ios/audioapi/ios/system/AudioEngine.h @@ -58,6 +58,9 @@ typedef NS_ENUM(NSInteger, AudioEngineInterruptionEndOutcome) { - (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 6fa8b0658..cdd597ae4 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/system/AudioEngine.mm +++ b/packages/react-native-audio-api/ios/audioapi/ios/system/AudioEngine.mm @@ -431,6 +431,12 @@ - (void)markSessionDeactivationInvalidatedGraph self.sessionDeactivationInvalidatedGraph = YES; } +- (void)markGraphNeedsRebuild +{ + std::scoped_lock lock(_engineLock); + self.graphNeedsRebuild = true; +} + - (AudioEngineInterruptionEndOutcome)onInterruptionEnd:(bool)shouldResume { std::scoped_lock lock(_engineLock); 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 a42fd4e7a..7ac7a58ec 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 @@ -311,6 +311,16 @@ - (void)handleEngineConfigurationChange:(NSNotification *)notification } dispatch_async(dispatch_get_main_queue(), ^{ + // A configuration change is an I/O-unit stop, not a resume trigger. Restarting + // while Interrupted races with onInterruptionEnd and the foreground retry: it + // marks the session inactive and rebuilds a graph that cannot start. Leave + // recovery on those paths; remember the format may have changed so they still + // rebuild. + if ([audioEngine getState] == AudioEngineStateInterrupted) { + [audioEngine markGraphNeedsRebuild]; + return; + } + [sessionManager markInactive]; [audioEngine restartAudioEngine]; }); From 47f0fe9181c6c83b585c945941e2887e29f9ec44 Mon Sep 17 00:00:00 2001 From: Marek Malek Date: Thu, 24 Sep 2026 17:25:28 +0200 Subject: [PATCH 16/29] fix: update search paths for RN 0.87 --- .../build-compilation-dependencies/SKILL.md | 33 +++++++++++ .../common/cpp/clangd/CMakeLists.txt | 55 ++++++++++++++++--- .../common/cpp/clangd/SETUP.md | 5 +- 3 files changed, 85 insertions(+), 8 deletions(-) diff --git a/.claude/skills/build-compilation-dependencies/SKILL.md b/.claude/skills/build-compilation-dependencies/SKILL.md index 1a5910083..505ed2a7f 100644 --- a/.claude/skills/build-compilation-dependencies/SKILL.md +++ b/.claude/skills/build-compilation-dependencies/SKILL.md @@ -368,6 +368,39 @@ Resolution pitfalls learned the hard way (both handled inside `package-root.js`) | New `.cpp` not compiled in tests | Glob picks it up automatically — may need cmake reconfigure | Delete `test/build/` and re-run | | iOS compile error `unknown type 'id'` | C++ file included ObjC-only header | Compile that file as ObjC++ (separate subspec with `-x objective-c++`) | | `RCT_NEW_ARCH_ENABLED` undefined on Android | Old RN gradle plugin | Ensure `newArchEnabled=true` in app's `gradle.properties` | +| clangd only: `'React/RCTBridgeModule.h' file not found` in `.mm` files | `compile_commands.json` has no framework search path | See *clangd compile database* below — regenerate with `yarn setup:clangd` | + +## clangd compile database + +`packages/react-native-audio-api/common/cpp/clangd/` generates the repo-root +`compile_commands.json` that clangd reads (`yarn setup:clangd`, or +`yarn setup:clangd:clean` after a native dependency bump). It is editor tooling +only — it never participates in a real build. See its `SETUP.md`. + +The iOS half reuses whatever `pod install` resolved for `apps/fabric-example`, +reading **both** `HEADER_SEARCH_PATHS` and `FRAMEWORK_SEARCH_PATHS` out of +`Pods/Target Support Files/Pods-FabricExample/Pods-FabricExample.debug.xcconfig`. + +Framework search paths matter because React-Core ships prebuilt as +`React.xcframework` since RN 0.87: `#import ` resolves +via `-F`, not `-I`, so an `-I`-only database fails every `.mm` that imports +React. Xcode's own `-F` points at `PODS_XCFRAMEWORKS_BUILD_DIR`, which only +exists after Xcode has unpacked the slices — the CMakeLists globs the +`ios-*simulator*` slices inside the `.xcframework` bundles instead, so a bare +`pod install` suffices. + +To check the database rather than guessing at clangd, replay an entry directly: + +```bash +python3 -c " +import json,shlex,subprocess +e=[x for x in json.load(open('compile_commands.json')) if x['file'].endswith('ios/AudioAPIModule.mm')][0] +toks=[t for t in shlex.split(e['command']) if t!='-c'] +subprocess.run(toks[:toks.index('-o')]+toks[toks.index('-o')+2:]+['-fsyntax-only'], cwd=e['directory']) +" +``` + +If that reproduces the error, the database is wrong — not the editor's LSP setup. --- diff --git a/packages/react-native-audio-api/common/cpp/clangd/CMakeLists.txt b/packages/react-native-audio-api/common/cpp/clangd/CMakeLists.txt index 96fc1b904..211a16a47 100644 --- a/packages/react-native-audio-api/common/cpp/clangd/CMakeLists.txt +++ b/packages/react-native-audio-api/common/cpp/clangd/CMakeLists.txt @@ -146,19 +146,59 @@ if(APPLE) set(PODS_ROOT "${FABRIC_EXAMPLE_IOS_DIR}/Pods") set(PODS_XCCONFIG "${FABRIC_EXAMPLE_IOS_DIR}/Pods/Target Support Files/Pods-FabricExample/Pods-FabricExample.debug.xcconfig") + # Xcode writes each search path setting as one long line of quoted, + # space-separated entries referencing its own build variables; expand the two + # PODS_ROOT spellings CocoaPods emits and hand back a plain CMake list. + function(read_pods_xcconfig_paths OUT_VAR SETTING) + file(STRINGS "${PODS_XCCONFIG}" SETTING_LINE REGEX "^${SETTING}") + string(REGEX REPLACE "^${SETTING} *= *" "" SETTING_LINE "${SETTING_LINE}") + string(REPLACE "\"" "" SETTING_LINE "${SETTING_LINE}") + string(REPLACE "$(inherited)" "" SETTING_LINE "${SETTING_LINE}") + string(REPLACE "\${PODS_ROOT}" "${PODS_ROOT}" SETTING_LINE "${SETTING_LINE}") + string(REPLACE "$(PODS_ROOT)" "${PODS_ROOT}" SETTING_LINE "${SETTING_LINE}") + separate_arguments(SETTING_PATHS UNIX_COMMAND "${SETTING_LINE}") + set(${OUT_VAR} "${SETTING_PATHS}" PARENT_SCOPE) + endfunction() + set(IOS_POD_INCLUDE_DIRS "") + set(IOS_POD_FRAMEWORK_DIRS "") if(EXISTS "${PODS_XCCONFIG}") - file(STRINGS "${PODS_XCCONFIG}" HEADER_SEARCH_LINE REGEX "^HEADER_SEARCH_PATHS") - string(REGEX REPLACE "^HEADER_SEARCH_PATHS *= *" "" HEADER_SEARCH_LINE "${HEADER_SEARCH_LINE}") - string(REPLACE "\"" "" HEADER_SEARCH_LINE "${HEADER_SEARCH_LINE}") - string(REPLACE "$(inherited)" "" HEADER_SEARCH_LINE "${HEADER_SEARCH_LINE}") - string(REPLACE "\${PODS_ROOT}" "${PODS_ROOT}" HEADER_SEARCH_LINE "${HEADER_SEARCH_LINE}") - string(REPLACE "$(PODS_ROOT)" "${PODS_ROOT}" HEADER_SEARCH_LINE "${HEADER_SEARCH_LINE}") - separate_arguments(IOS_POD_INCLUDE_DIRS UNIX_COMMAND "${HEADER_SEARCH_LINE}") + read_pods_xcconfig_paths(IOS_POD_INCLUDE_DIRS HEADER_SEARCH_PATHS) + read_pods_xcconfig_paths(IOS_POD_FRAMEWORK_DIRS FRAMEWORK_SEARCH_PATHS) else() message(WARNING "clangd/CMakeLists.txt: ${PODS_XCCONFIG} not found — run `pod install` in apps/fabric-example/ios to resolve React/Pod headers for iOS files.") endif() + # React-Core ships prebuilt as React.xcframework since RN 0.87, so + # `#import ` resolves through a framework search + # path (-F) rather than -I — without one, every .mm importing React fails + # with "'React/RCTBridgeModule.h' file not found". Xcode's own -F points at + # PODS_XCFRAMEWORKS_BUILD_DIR, which exists only after Xcode has unpacked the + # slices, so target the slices inside the .xcframework bundles directly and + # keep a bare `pod install` sufficient. + execute_process( + COMMAND find "${PODS_ROOT}" -maxdepth 8 -type d -name "ios-*simulator*" -path "*.xcframework/*" + OUTPUT_VARIABLE IOS_XCFRAMEWORK_SLICES + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET + ) + string(REPLACE "\n" ";" IOS_XCFRAMEWORK_SLICES "${IOS_XCFRAMEWORK_SLICES}") + list(APPEND IOS_POD_FRAMEWORK_DIRS ${IOS_XCFRAMEWORK_SLICES}) + + # Entries naming build-time-only Xcode variables (PODS_XCFRAMEWORKS_BUILD_DIR, + # PODS_CONFIGURATION_BUILD_DIR) survive the expansion above unresolved; drop + # anything that isn't a real directory so the compile commands stay clean. + set(IOS_FRAMEWORK_FLAGS "") + foreach(framework_dir IN LISTS IOS_POD_FRAMEWORK_DIRS) + if(IS_DIRECTORY "${framework_dir}") + list(APPEND IOS_FRAMEWORK_FLAGS "-F${framework_dir}") + endif() + endforeach() + list(REMOVE_DUPLICATES IOS_FRAMEWORK_FLAGS) + + target_compile_options(rnaudioapi_cursor_ios PRIVATE ${IOS_FRAMEWORK_FLAGS}) + target_compile_options(rnaudioapi_cursor_ios_objc PRIVATE ${IOS_FRAMEWORK_FLAGS}) + set(IOS_EXTRA_INCLUDES "${COMMON_CPP_DIR}" "${IOS_DIR}" @@ -187,6 +227,7 @@ if(APPLE) RN_AUDIO_API_TEST=${RN_AUDIO_API_TEST} ) target_include_directories(rnaudioapi_cursor_ios_headers PRIVATE ${IOS_EXTRA_INCLUDES}) + target_compile_options(rnaudioapi_cursor_ios_headers PRIVATE ${IOS_FRAMEWORK_FLAGS}) if(IOS_SDK_PATH) target_compile_options(rnaudioapi_cursor_ios_headers PRIVATE -isysroot "${IOS_SDK_PATH}") endif() diff --git a/packages/react-native-audio-api/common/cpp/clangd/SETUP.md b/packages/react-native-audio-api/common/cpp/clangd/SETUP.md index 6e72784be..34c628891 100644 --- a/packages/react-native-audio-api/common/cpp/clangd/SETUP.md +++ b/packages/react-native-audio-api/common/cpp/clangd/SETUP.md @@ -11,7 +11,10 @@ Generates entries for `common/cpp` (sources + headers), the iOS Objective-C++/Ob **Prerequisites** - **Android**: needs a real Gradle build's `android/.cxx` output — this CMakeLists.txt can't safely reproduce NDK cross-compile flags itself. -- **iOS**: needs `apps/fabric-example/ios/Pods` — reuses its generated xcconfig for React/Pod header paths. +- **iOS**: needs `apps/fabric-example/ios/Pods` — reuses its generated xcconfig for React/Pod header + *and framework* search paths. Since RN 0.87 React-Core ships prebuilt as `React.xcframework`, so + `#import ` resolves via `-F`, not `-I`; missing framework paths show up as + `'React/RCTBridgeModule.h' file not found` in every `.mm` that imports React. If either is missing or stale (e.g. after a dependency bump), refresh both first: From 86de938b48dc87dc0478d3c813bcc5e38fca5d04 Mon Sep 17 00:00:00 2001 From: Marek Malek Date: Thu, 24 Sep 2026 18:49:28 +0200 Subject: [PATCH 17/29] fix: remove restart recursion --- .../ios/audioapi/ios/system/AudioEngine.mm | 81 ++++++++++++++----- 1 file changed, 61 insertions(+), 20 deletions(-) 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 cdd597ae4..9ce541a9f 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 @@ -27,7 +27,6 @@ @implementation AudioEngineInputRegistration @interface AudioEngine () { std::recursive_mutex _engineLock; - BOOL _isRebuildingAudioEngine; /// 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; @@ -48,6 +47,9 @@ - (void)materializeTrackedNodesIfNeeded; - (AVAudioFormat *)liveInputFormat; - (void)resetInputNode; +- (BOOL)graphRequiresRebuild; +- (void)rebuildGraphForCurrentHardware; +- (void)parkAfterFailedStart; - (void)rebuildAudioEngineAndResumeIfNeeded; - (void)notifyInput:(AudioEngineInputNotification)notification; @@ -515,29 +517,61 @@ - (bool)isInUse - (void)rebuildAudioEngineAndResumeIfNeeded { - if (_isRebuildingAudioEngine) { - return; + const BOOL shouldResume = self.state == AudioEngineState::AudioEngineStateRunning; + + [self rebuildGraphForCurrentHardware]; + + BOOL didStartEngine = NO; + if (shouldResume) { + didStartEngine = [self startEngine]; + + if (!didStartEngine) { + [self parkAfterFailedStart]; + } } - _isRebuildingAudioEngine = YES; + if (didStartEngine) { + [self notifyInput:AudioEngineInputNotificationHardwareChanged]; + } +} + +/// @brief Reports whether the graph must be torn down and rebuilt before it can run. +/// @discussion An interruption, a session deactivation and a node registered while the +/// engine was gone all leave connections that no longer match the hardware. Starting such +/// a graph would either fail or capture nothing. +- (BOOL)graphRequiresRebuild +{ + return self.state == AudioEngineState::AudioEngineStateInterrupted || self.graphNeedsRebuild || + self.sessionDeactivationInvalidatedGraph; +} +/// @brief Rebuilds the graph against the current hardware, leaving the engine stopped. +/// @discussion Separate from starting so that no path can start an engine and rebuild it in +/// the same call: the two used to be mutually recursive, and termination depended on +/// `rebuildAudioEngine` clearing `graphNeedsRebuild` between the two frames. +- (void)rebuildGraphForCurrentHardware +{ if ([self.audioEngine isRunning]) { [self.audioEngine stop]; } [self rebuildAudioEngine]; self.sessionDeactivationInvalidatedGraph = false; +} - BOOL didStartEngine = NO; - if (self.state == AudioEngineState::AudioEngineStateRunning) { - didStartEngine = [self startEngine]; - } - - if (didStartEngine) { - [self notifyInput:AudioEngineInputNotificationHardwareChanged]; +/// @brief Records that the engine is not running after a start that was expected to succeed. +/// @discussion Leaving `Running` behind makes `startIfNecessary` early-out on a dead engine +/// and makes `getState` report a recorder that is capturing nothing. `Interrupted` is kept +/// as-is: the interruption, not the failed start, is still the reason the engine is down, +/// and the interruption-end path is what recovers it. +- (void)parkAfterFailedStart +{ + if (self.state == AudioEngineState::AudioEngineStateInterrupted) { + return; } - _isRebuildingAudioEngine = NO; + self.state = [self hasTrackedGraph] ? AudioEngineState::AudioEngineStatePaused + : AudioEngineState::AudioEngineStateIdle; } - (void)rebuildAudioEngine @@ -565,12 +599,10 @@ - (bool)startEngine return false; } - if (self.state == AudioEngineState::AudioEngineStateInterrupted || self.graphNeedsRebuild || - self.sessionDeactivationInvalidatedGraph) { - [self rebuildAudioEngineAndResumeIfNeeded]; - } else { - [self materializeTrackedNodesIfNeeded]; - } + // Materializing attaches nodes registered while the engine was stopped; it never + // rebuilds. A graph that needs rebuilding is the caller's business, so that starting + // cannot silently tear the engine down - see `graphRequiresRebuild`. + [self materializeTrackedNodesIfNeeded]; if (self.inputRegistration != nil && self.inputNode == nil) { NSLog(@"Error while materializing the audio input node: missing live input format"); @@ -612,10 +644,19 @@ - (bool)startIfNecessary return true; } - if ([self hasTrackedGraph]) { - return [self startEngine]; + if (![self hasTrackedGraph]) { + return false; + } + + if ([self graphRequiresRebuild]) { + [self rebuildGraphForCurrentHardware]; + } + + if ([self startEngine]) { + return true; } + [self parkAfterFailedStart]; return false; } From 9513b278d3f7fb5a1c44383e1c8c6b4a162d6ccc Mon Sep 17 00:00:00 2001 From: Marek Malek Date: Fri, 25 Sep 2026 19:54:26 +0200 Subject: [PATCH 18/29] fix: move locks out of callbacks --- .../ios/audioapi/ios/system/AudioEngine.mm | 93 +++++++++++++++---- 1 file changed, 73 insertions(+), 20 deletions(-) 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 9ce541a9f..d548e50d2 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 @@ -26,7 +26,19 @@ @implementation AudioEngineInputRegistration @end @interface AudioEngine () { - std::recursive_mutex _engineLock; + /// Serialises every mutation of the engine and its tracked graph. Never taken on a thread + /// AVFoundation owns: `-[AVAudioEngine dealloc]`, `stop`, `reset`, `prepare` and `start` + /// can all wait on the engine's own queue while this is held, so an observer that locked + /// from that queue would close the cycle. Notification handlers hop to the main queue + /// first, and the registered input's handler runs only after the lock is released. + std::mutex _engineLock; + /// Handler captured under `_engineLock` when the graph changes in a way the registered + /// input has to hear about, invoked only once the lock is released. Running it inside the + /// locked region would re-enter `_engineLock` (the handler reads the live input format + /// back) and would take the recorder's own mutexes in the opposite order from the + /// recorder's start path, which takes them before calling into the engine. + void (^_pendingInputNotification)(AudioEngineInputNotification); + AudioEngineInputNotification _pendingInputNotificationKind; /// 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; @@ -51,7 +63,9 @@ - (BOOL)graphRequiresRebuild; - (void)rebuildGraphForCurrentHardware; - (void)parkAfterFailedStart; - (void)rebuildAudioEngineAndResumeIfNeeded; -- (void)notifyInput:(AudioEngineInputNotification)notification; +- (void)queueInputNotification:(AudioEngineInputNotification)notification; +- (void)deliverPendingInputNotification; +- (AudioEngineInterruptionEndOutcome)resumeAfterInterruption:(bool)shouldResume; @end @@ -170,10 +184,11 @@ - (void)materializeSourceNodeWithId:(NSString *)sourceNodeId [self.audioEngine connect:sourceNode to:self.audioEngine.mainMixerNode format:format]; } +/// @brief Reads the format the input node currently produces. +/// @discussion Expects `_engineLock` to be held: the other caller is the graph +/// materialization, which runs inside a locked rebuild. - (AVAudioFormat *)liveInputFormat { - std::scoped_lock lock(_engineLock); - if (self.audioEngine == nil) { return nil; } @@ -385,6 +400,7 @@ - (void)detachInputNode - (AVAudioFormat *)getLiveInputFormat { + std::scoped_lock lock(_engineLock); return [self liveInputFormat]; } @@ -441,7 +457,20 @@ - (void)markGraphNeedsRebuild - (AudioEngineInterruptionEndOutcome)onInterruptionEnd:(bool)shouldResume { - std::scoped_lock lock(_engineLock); + AudioEngineInterruptionEndOutcome outcome; + + { + std::scoped_lock lock(_engineLock); + outcome = [self resumeAfterInterruption:shouldResume]; + } + + [self deliverPendingInputNotification]; + + return outcome; +} + +- (AudioEngineInterruptionEndOutcome)resumeAfterInterruption:(bool)shouldResume +{ NSError *error = nil; if (self.state != AudioEngineState::AudioEngineStateInterrupted) { @@ -452,7 +481,7 @@ - (AudioEngineInterruptionEndOutcome)onInterruptionEnd:(bool)shouldResume [self stopEngine]; [self rebuildAudioEngine]; self.state = AudioEngineState::AudioEngineStatePaused; - [self notifyInput:AudioEngineInputNotificationHardwareChanged]; + [self queueInputNotification:AudioEngineInputNotificationHardwareChanged]; return AudioEngineInterruptionEndOutcomePaused; } @@ -468,7 +497,7 @@ - (AudioEngineInterruptionEndOutcome)onInterruptionEnd:(bool)shouldResume NSLog( @"Error while materializing the audio input node after interruption: missing live input format"); self.state = AudioEngineState::AudioEngineStateInterrupted; - [self notifyInput:AudioEngineInputNotificationCaptureLost]; + [self queueInputNotification:AudioEngineInputNotificationCaptureLost]; return AudioEngineInterruptionEndOutcomeStillInterrupted; } @@ -480,21 +509,41 @@ - (AudioEngineInterruptionEndOutcome)onInterruptionEnd:(bool)shouldResume @"Error while restarting the audio engine after interruption: %@", [error debugDescription]); self.state = AudioEngineState::AudioEngineStateInterrupted; - [self notifyInput:AudioEngineInputNotificationCaptureLost]; + [self queueInputNotification:AudioEngineInputNotificationCaptureLost]; return AudioEngineInterruptionEndOutcomeStillInterrupted; } self.state = AudioEngineState::AudioEngineStateRunning; self.sessionDeactivationInvalidatedGraph = false; - [self notifyInput:AudioEngineInputNotificationHardwareChanged]; + [self queueInputNotification:AudioEngineInputNotificationHardwareChanged]; return AudioEngineInterruptionEndOutcomeRunning; } -- (void)notifyInput:(AudioEngineInputNotification)notification +- (void)queueInputNotification:(AudioEngineInputNotification)notification +{ + if (self.inputRegistration == nil || self.inputRegistration.onInputNotification == nil) { + return; + } + + // The handler is captured, not looked up at delivery time: the registration may be + // detached between releasing the lock and delivering, and the recorder that asked for + // this notification is the one that must receive it. + _pendingInputNotification = self.inputRegistration.onInputNotification; + _pendingInputNotificationKind = notification; +} + +/// @brief Runs the notification queued by the last graph change, if any. +/// @discussion Must be called with `_engineLock` released; see `_pendingInputNotification`. +- (void)deliverPendingInputNotification { - if (self.inputRegistration != nil && self.inputRegistration.onInputNotification != nil) { - self.inputRegistration.onInputNotification(notification); + void (^notification)(AudioEngineInputNotification) = _pendingInputNotification; + + if (notification == nil) { + return; } + + _pendingInputNotification = nil; + notification(_pendingInputNotificationKind); } - (AudioEngineState)getState @@ -531,7 +580,7 @@ - (void)rebuildAudioEngineAndResumeIfNeeded } if (didStartEngine) { - [self notifyInput:AudioEngineInputNotificationHardwareChanged]; + [self queueInputNotification:AudioEngineInputNotificationHardwareChanged]; } } @@ -697,16 +746,20 @@ - (void)stopIfPossible - (void)restartAudioEngine { - std::scoped_lock lock(_engineLock); + { + 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; + // 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; + } + + [self rebuildAudioEngineAndResumeIfNeeded]; } - [self rebuildAudioEngineAndResumeIfNeeded]; + [self deliverPendingInputNotification]; } - (void)logAudioEngineState From c4601b40e013ef689c6582a8aa79947786913a5d Mon Sep 17 00:00:00 2001 From: Kacper Orszulak <42392219+SomePersonFromMars@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:45:24 +0200 Subject: [PATCH 19/29] fix: explicitely expressed logic of playback and recording resume --- .../ios/audioapi/ios/system/AudioEngine.mm | 52 +++++++++++++++++-- 1 file changed, 47 insertions(+), 5 deletions(-) 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 cdd597ae4..bc5e7c0e7 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 @@ -39,6 +39,8 @@ @interface AudioEngine () { - (void)createAudioEngineIfNeeded; - (void)destroyAudioEnginePreservingSessionDeactivationState:(BOOL)preserveSessionDeactivationState; +- (BOOL)hasInput; +- (BOOL)hasSources; - (BOOL)hasTrackedGraph; - (AVAudioFormat *)currentInputConnectionFormat; - (void)materializeSourceNodeWithId:(NSString *)sourceNodeId; @@ -71,9 +73,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 @@ -446,7 +458,39 @@ - (AudioEngineInterruptionEndOutcome)onInterruptionEnd:(bool)shouldResume return AudioEngineInterruptionEndOutcomeNoOp; } - if (!shouldResume && self.inputRegistration == nil) { + const BOOL hasInput = [self hasInput]; + const BOOL hasSources = [self hasSources]; + BOOL resumeEngine = NO; + + // 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. + + if (hasInput) { + if (hasSources) { + if (shouldResume) { + // Resume recording and playback. + resumeEngine = 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. + resumeEngine = NO; + } + } else { + // There is only recording to resume. + resumeEngine = YES; + } + } else { + // There is only playback to resume. + resumeEngine = hasSources && shouldResume; + } + + if (!resumeEngine) { [self stopEngine]; [self rebuildAudioEngine]; self.state = AudioEngineState::AudioEngineStatePaused; @@ -642,10 +686,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; } From f5c49c441c105705f8fa6c7ef68aa17272c8bd0e Mon Sep 17 00:00:00 2001 From: Kacper Orszulak <42392219+SomePersonFromMars@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:23:33 +0200 Subject: [PATCH 20/29] fix: cache the intended `shouldResume` value for retry --- .../IOSAudioRecorderTests.mm | 2 +- .../SystemNotificationManagerTests.mm | 56 +++++++++++++++++++ .../ios/system/SystemNotificationManager.h | 3 + .../ios/system/SystemNotificationManager.mm | 16 +++++- 4 files changed, 74 insertions(+), 3 deletions(-) diff --git a/apps/fabric-example/ios/FabricExampleTests/IOSAudioRecorderTests.mm b/apps/fabric-example/ios/FabricExampleTests/IOSAudioRecorderTests.mm index 76c47f83a..f2739e5d9 100644 --- a/apps/fabric-example/ios/FabricExampleTests/IOSAudioRecorderTests.mm +++ b/apps/fabric-example/ios/FabricExampleTests/IOSAudioRecorderTests.mm @@ -482,7 +482,7 @@ - (void)testPauseAndResumeRespectCurrentState { } - (void)testResumeDoesNotStoreRecordingWhenNativeResumeFails { - _recorder->setRecorderState(AudioRecorder::RecorderState::Recording); + _recorder->setRecorderState(RecorderState::Recording); self.audioEngine.state = AudioEngineStateRunning; _recorder->pause(); self.nativeRecorder.resumeResult = NO; diff --git a/apps/fabric-example/ios/FabricExampleTests/SystemNotificationManagerTests.mm b/apps/fabric-example/ios/FabricExampleTests/SystemNotificationManagerTests.mm index 9ddaf3298..2293dfb32 100644 --- a/apps/fabric-example/ios/FabricExampleTests/SystemNotificationManagerTests.mm +++ b/apps/fabric-example/ios/FabricExampleTests/SystemNotificationManagerTests.mm @@ -561,6 +561,34 @@ - (void)testForegroundRetryResumesAfterFailedInterruptionEnded 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]; @@ -584,8 +612,10 @@ - (void)testForegroundRetryEmitsEndedWhenObservedResumeSucceedsAfterFailedEnd [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 @@ -790,4 +820,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/react-native-audio-api/ios/audioapi/ios/system/SystemNotificationManager.h b/packages/react-native-audio-api/ios/audioapi/ios/system/SystemNotificationManager.h index 32641d5ed..67a0e8d01 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 @@ -17,6 +17,9 @@ /// Set when AVAudioSession posts InterruptionEnded (or the secondary-audio equivalent). /// Thanks to it it can be decided, whether interruption end retry is necessary. @property (nonatomic, assign) bool interruptionEndedDelivered; +/// `AVAudioSessionInterruptionOptionShouldResume` from the last interruption-ended +/// notification. Nil until that notification arrives; a foreground retry then 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 7de4f4ca4..4cfc7b9f9 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 @@ -166,12 +166,22 @@ - (void)performInterruptionEndOnEngine:(AudioEngine *)audioEngine 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 performInterruptionEndOnEngine:audioEngine shouldResume:true]; + [self performInterruptionEndOnEngine:audioEngine + shouldResume:[self shouldResumeForInterruptionRecovery]]; } } @@ -197,6 +207,7 @@ - (void)handleInterruption:(NSNotification *)notification bool shouldResume = interruptionOption == AVAudioSessionInterruptionOptionShouldResume; + self.interruptionShouldResume = @(shouldResume); self.interruptionEndedDelivered = true; [self performInterruptionEndOnEngine:audioEngine shouldResume:shouldResume]; } @@ -377,7 +388,8 @@ - (void)checkSecondaryAudioHint } self.interruptionEndedDelivered = true; - [self performInterruptionEndOnEngine:audioEngine shouldResume:true]; + [self performInterruptionEndOnEngine:audioEngine + shouldResume:[self shouldResumeForInterruptionRecovery]]; } @end From a6acc6b200d3715ca6864ee5c9c929e781dc02d2 Mon Sep 17 00:00:00 2001 From: Kacper Orszulak <42392219+SomePersonFromMars@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:39:10 +0200 Subject: [PATCH 21/29] fix: wording in apps/CLAUDE.md Co-authored-by: Maciej Makowski --- apps/CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/CLAUDE.md b/apps/CLAUDE.md index 6e8a489dd..5e0021dbf 100644 --- a/apps/CLAUDE.md +++ b/apps/CLAUDE.md @@ -283,7 +283,7 @@ if (status !== 'Granted') return; Enable emission with `AudioManager.observeAudioInterruptions(true)`, then listen. -**Playback:** pause on `began` (native does not resume players). The AudioFile example resumes on `ended` when it had been playing. +**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(() => { From 3c491fc9598984daa379e927a2377c3ddcb86181 Mon Sep 17 00:00:00 2001 From: Kacper Orszulak <42392219+SomePersonFromMars@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:48:23 +0200 Subject: [PATCH 22/29] fix: broken relative audiodocs links --- packages/audiodocs/docs/fundamentals/best-practices.mdx | 2 +- packages/audiodocs/docs/system/audio-manager.mdx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/audiodocs/docs/fundamentals/best-practices.mdx b/packages/audiodocs/docs/fundamentals/best-practices.mdx index fa4b1799d..07c945dca 100644 --- a/packages/audiodocs/docs/fundamentals/best-practices.mdx +++ b/packages/audiodocs/docs/fundamentals/best-practices.mdx @@ -22,7 +22,7 @@ user experience, and maintainability. Here are some key best practices to consid Running `AudioContext` is still playing silence even if there is no playing source node connected to the [`destination`](../core/base-audio-context.mdx#properties). Additionally, on iOS devices, the state of the `AudioContext` is directly related with state of the lock screen. If a running `AudioContext` exists, it is impossible to set lock screen state to `state_paused`. -- **Configure the audio session early**: Set [`AudioManager.setAudioSessionOptions()`](/docs/system/audio-manager#setaudiosessionoptions) once at startup and follow the [session configuration guidelines](/docs/system/audio-manager#sessionoptionsconfigurationguidelines). +- **Configure the audio session early**: Set [`AudioManager.setAudioSessionOptions()`](../system/audio-manager.mdx#setaudiosessionoptions) once at startup and follow the [session configuration guidelines](../system/audio-manager.mdx#sessionoptionsconfigurationguidelines). ## [**AudioRecorder**](../inputs/audio-recorder.mdx) Management diff --git a/packages/audiodocs/docs/system/audio-manager.mdx b/packages/audiodocs/docs/system/audio-manager.mdx index 9c4c97f5a..b3a9c384d 100644 --- a/packages/audiodocs/docs/system/audio-manager.mdx +++ b/packages/audiodocs/docs/system/audio-manager.mdx @@ -193,11 +193,11 @@ A resume that completes while the app is still backgrounded is possible only whe - `iosCategory` set to `record` or `playAndRecord`, so the session can record. These categories also allow background recording. An incompatible category leads to a [`cannotStartPlaying`](https://developer.apple.com/documentation/coreaudiotypes/avaudiosession/errorcode/cannotstartplaying) error. - `iosMode` set to `default`. - `iosOptions` include `mixWithOthers`, so the system can mix this session with audio from active sessions in other apps. Trying to resume recording in the background with a non-mixable session leads to [`cannotInterruptOthers`](https://developer.apple.com/documentation/coreaudiotypes/avaudiosession/errorcode/cannotinterruptothers). -- The app must declare [background audio mode](/docs/other/audio-api-plugin#iosbackgroundmode). +- The app must declare [background audio mode](../other/audio-api-plugin.mdx#iosbackgroundmode). #### Execution -To complete a background resume, audio I/O must have been started in the foreground, and it must still have been running when the app went to the background. The background audio mode lets that already-running I/O *continue*; it does not allow *starting* audio from the background. iOS treats an `interruption` event of type `ended` as the wake on which a background resume may be attempted. A later background timer is not such a wake. Another app must have released the audio route and the microphone. For recording, the recorder must not have been stopped with [`stop()`](/docs/inputs/audio-recorder#stop), because stopping ends the take. +To complete a background resume, audio I/O must have been started in the foreground, and it must still have been running when the app went to the background. The background audio mode lets that already-running I/O *continue*; it does not allow *starting* audio from the background. iOS treats an `interruption` event of type `ended` as the wake on which a background resume may be attempted. A later background timer is not such a wake. Another app must have released the audio route and the microphone. For recording, the recorder must not have been stopped with [`stop()`](../inputs/audio-recorder.mdx#stop), because stopping ends the take. #### iOS policy From 391ae4ed792c55f7d5c170b93ddc18c7ef4c9859 Mon Sep 17 00:00:00 2001 From: Marek Malek Date: Fri, 9 Oct 2026 12:29:10 +0200 Subject: [PATCH 23/29] fix(ios): stop vpio loop --- .../ios/audioapi/ios/system/AudioEngine.h | 7 ++ .../ios/audioapi/ios/system/AudioEngine.mm | 65 ++++++++++++++++--- .../ios/system/SystemNotificationManager.mm | 10 ++- 3 files changed, 70 insertions(+), 12 deletions(-) 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 88049e9d9..dd70c6fcc 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 @@ -61,6 +61,13 @@ typedef NS_ENUM(NSInteger, AudioEngineInterruptionEndOutcome) { /// 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; +/// Responds to an audio route change without restarting the engine. A running engine is left +/// as it is: when a route change alters the hardware sample rate or channel count, the engine +/// stops itself and posts `AVAudioEngineConfigurationChangeNotification`, whose handler +/// rebuilds it. A stopped engine with a tracked graph is marked for rebuild instead, because +/// whether a stopped engine observes the change and posts that notification is not +/// documented; the next start then builds the graph against the current hardware. +- (void)onRouteChange; - (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 d548e50d2..ea7443ce1 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 @@ -37,6 +37,9 @@ @interface AudioEngine () { /// locked region would re-enter `_engineLock` (the handler reads the live input format /// back) and would take the recorder's own mutexes in the opposite order from the /// recorder's start path, which takes them before calling into the engine. + /// Set and taken within one locked region, so it never holds a value while the lock is + /// free: `restartAudioEngine` also runs on the thread that stops the recorder, and that + /// thread must not pick up a notification another thread queued. void (^_pendingInputNotification)(AudioEngineInputNotification); AudioEngineInputNotification _pendingInputNotificationKind; /// Tracks whether voice processing is currently engaged on the system input @@ -54,6 +57,7 @@ - (BOOL)hasTrackedGraph; - (AVAudioFormat *)currentInputConnectionFormat; - (void)materializeSourceNodeWithId:(NSString *)sourceNodeId; - (BOOL)materializeInputNodeIfNeeded; +- (void)connectVoiceProcessingOutputWithFormat:(AVAudioFormat *)format; - (void)applyVoiceProcessing; - (void)materializeTrackedNodesIfNeeded; @@ -64,7 +68,7 @@ - (void)rebuildGraphForCurrentHardware; - (void)parkAfterFailedStart; - (void)rebuildAudioEngineAndResumeIfNeeded; - (void)queueInputNotification:(AudioEngineInputNotification)notification; -- (void)deliverPendingInputNotification; +- (dispatch_block_t)takePendingInputNotification; - (AudioEngineInterruptionEndOutcome)resumeAfterInterruption:(bool)shouldResume; @end @@ -248,6 +252,10 @@ - (BOOL)materializeInputNodeIfNeeded return NO; } + if (_voiceProcessingApplied) { + [self connectVoiceProcessingOutputWithFormat:inputFormat]; + } + self.inputNode = [[AVAudioSinkNode alloc] initWithReceiverBlock:self.inputRegistration.receiverBlock]; [self.audioEngine attachNode:self.inputNode]; @@ -255,6 +263,22 @@ - (BOOL)materializeInputNodeIfNeeded return YES; } +/// @brief Gives the voice-processing unit's output bus a graph to pull from. +/// @discussion Voice processing runs a single I/O unit in both directions, even when only +/// the microphone is used. A recorder-only graph never creates the main mixer, so the output +/// bus has no node behind it and the first render fails. Once the mixer exists, its +/// connection to the output node is formatted by the engine unless made explicitly, and that +/// format can differ from the input's (observed: 44.1 kHz against a 48 kHz input). Either +/// way the engine stops itself shortly after starting. Voice processing requires the input +/// node's output format and the output node's input format to match, so the connection is +/// made with the input's format; the mixer converts whatever sources are attached to it. +- (void)connectVoiceProcessingOutputWithFormat:(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 @@ -455,16 +479,30 @@ - (void)markGraphNeedsRebuild self.graphNeedsRebuild = true; } +- (void)onRouteChange +{ + std::scoped_lock lock(_engineLock); + if (![self hasTrackedGraph] || [self.audioEngine isRunning]) { + return; + } + + self.graphNeedsRebuild = true; +} + - (AudioEngineInterruptionEndOutcome)onInterruptionEnd:(bool)shouldResume { AudioEngineInterruptionEndOutcome outcome; + dispatch_block_t notifyInput; { std::scoped_lock lock(_engineLock); outcome = [self resumeAfterInterruption:shouldResume]; + notifyInput = [self takePendingInputNotification]; } - [self deliverPendingInputNotification]; + if (notifyInput != nil) { + notifyInput(); + } return outcome; } @@ -532,18 +570,20 @@ - (void)queueInputNotification:(AudioEngineInputNotification)notification _pendingInputNotificationKind = notification; } -/// @brief Runs the notification queued by the last graph change, if any. -/// @discussion Must be called with `_engineLock` released; see `_pendingInputNotification`. -- (void)deliverPendingInputNotification +/// @brief Removes the notification queued by the last graph change and returns it ready to run. +/// @discussion Must be called with `_engineLock` held, in the same locked region that queued +/// it; the caller runs the result after releasing the lock. Returns nil when nothing is queued. +- (dispatch_block_t)takePendingInputNotification { - void (^notification)(AudioEngineInputNotification) = _pendingInputNotification; + void (^handler)(AudioEngineInputNotification) = _pendingInputNotification; - if (notification == nil) { - return; + if (handler == nil) { + return nil; } + AudioEngineInputNotification notification = _pendingInputNotificationKind; _pendingInputNotification = nil; - notification(_pendingInputNotificationKind); + return ^{ handler(notification); }; } - (AudioEngineState)getState @@ -746,6 +786,8 @@ - (void)stopIfPossible - (void)restartAudioEngine { + dispatch_block_t notifyInput = nil; + { std::scoped_lock lock(_engineLock); @@ -757,9 +799,12 @@ - (void)restartAudioEngine } [self rebuildAudioEngineAndResumeIfNeeded]; + notifyInput = [self takePendingInputNotification]; } - [self deliverPendingInputNotification]; + if (notifyInput != nil) { + notifyInput(); + } } - (void)logAudioEngineState 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 669d29ade..3b7317f96 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 @@ -268,9 +268,15 @@ - (void)handleRouteChange:(NSNotification *)notification switch (routeChangeReason) { case AVAudioSessionRouteChangeReasonNewDeviceAvailable: case AVAudioSessionRouteChangeReasonOldDeviceUnavailable: - case AVAudioSessionRouteChangeReasonRouteConfigurationChange: - [self handleEngineConfigurationChange:nil]; + case AVAudioSessionRouteChangeReasonRouteConfigurationChange: { + // Not a restart: rebuilding the engine reconfigures its I/O unit, which itself posts + // RouteConfigurationChange, so restarting here would restart without end. A route + // change that alters the hardware format also arrives as + // AVAudioEngineConfigurationChangeNotification, which does restart the engine. + AudioEngine *audioEngine = self.audioAPIModule.audioEngine; + dispatch_async(dispatch_get_main_queue(), ^{ [audioEngine onRouteChange]; }); break; + } default: break; } From 2dc7757cc3566ea3a272a7fed869a62029d83d81 Mon Sep 17 00:00:00 2001 From: Marek Malek Date: Fri, 9 Oct 2026 12:30:11 +0200 Subject: [PATCH 24/29] fix(ios): restart on owned engine --- apps/fabric-example/ios/Podfile.lock | 2 +- .../ios/audioapi/ios/system/AudioEngine.h | 4 ++++ .../ios/audioapi/ios/system/AudioEngine.mm | 6 ++++++ .../ios/system/SystemNotificationManager.mm | 15 ++++++++++----- 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/apps/fabric-example/ios/Podfile.lock b/apps/fabric-example/ios/Podfile.lock index 3dbab261e..78691f710 100644 --- a/apps/fabric-example/ios/Podfile.lock +++ b/apps/fabric-example/ios/Podfile.lock @@ -2516,4 +2516,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 2fbbdb3fb754264aa345dc912d51c3c6d295cdd9 -COCOAPODS: 1.17.0 +COCOAPODS: 1.16.2 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 dd70c6fcc..ee6e35375 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 @@ -72,6 +72,10 @@ typedef NS_ENUM(NSInteger, AudioEngineInterruptionEndOutcome) { - (AudioEngineState)getState; - (bool)isEngineRunning; - (bool)isInUse; +/// True when @p engine is the AVAudioEngine this object currently drives. False for nil, for +/// engines owned by other code in the process, and for engines this object has already +/// replaced during a rebuild. +- (bool)isCurrentEngine:(AVAudioEngine *)engine; - (bool)startIfNecessary; - (void)pauseIfNecessary; diff --git a/packages/react-native-audio-api/ios/audioapi/ios/system/AudioEngine.mm b/packages/react-native-audio-api/ios/audioapi/ios/system/AudioEngine.mm index ea7443ce1..4bc6f4914 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 @@ -604,6 +604,12 @@ - (bool)isInUse return [self hasTrackedGraph] || self.audioEngine != nil; } +- (bool)isCurrentEngine:(AVAudioEngine *)engine +{ + std::scoped_lock lock(_engineLock); + return engine != nil && engine == self.audioEngine; +} + - (void)rebuildAudioEngineAndResumeIfNeeded { const BOOL shouldResume = self.state == AudioEngineState::AudioEngineStateRunning; 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 3b7317f96..b6f19e24d 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 @@ -311,12 +311,17 @@ - (void)handleEngineConfigurationChange:(NSNotification *)notification AudioEngine *audioEngine = self.audioAPIModule.audioEngine; AudioSessionManager *sessionManager = self.audioAPIModule.audioSessionManager; + // Weak, so a queued handler does not keep a replaced engine alive; one that is gone by the + // time the handler runs is no longer ours to restart. + __weak AVAudioEngine *changedEngine = notification.object; + dispatch_async(dispatch_get_main_queue(), ^{ - // This notification is registered with object:nil, so it also fires for - // AVAudioEngine instances owned by other libraries in the host app. Without - // an engine of our own there is nothing to restart, and marking the session - // inactive would corrupt bookkeeping for apps that only manage the session. - if (![audioEngine isInUse]) { + // Registered with object:nil because the engine is replaced on every rebuild, so this + // also fires for AVAudioEngine instances owned by other libraries in the host app and + // for engines we have already replaced. Only a change on the engine we currently drive + // calls for a restart; acting on any other would rebuild ours for nothing and mark the + // session inactive behind the back of an app that only manages the session. + if (![audioEngine isCurrentEngine:changedEngine]) { return; } From ba9e6b5ed31b004ba1a53853b9aec1b3847f3dd5 Mon Sep 17 00:00:00 2001 From: Kacper Orszulak <42392219+SomePersonFromMars@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:21:11 +0200 Subject: [PATCH 25/29] style: cleanups --- .../audiodocs/docs/system/audio-manager.mdx | 2 +- .../ios/audioapi/ios/core/IOSAudioRecorder.h | 2 +- .../ios/audioapi/ios/core/IOSAudioRecorder.mm | 4 +- .../ios/audioapi/ios/system/AudioEngine.mm | 8 +- .../ios/system/SystemNotificationManager.mm | 82 ++++++++----------- 5 files changed, 43 insertions(+), 55 deletions(-) diff --git a/packages/audiodocs/docs/system/audio-manager.mdx b/packages/audiodocs/docs/system/audio-manager.mdx index b3a9c384d..5cbd21b6b 100644 --- a/packages/audiodocs/docs/system/audio-manager.mdx +++ b/packages/audiodocs/docs/system/audio-manager.mdx @@ -185,7 +185,7 @@ Checks currently used and available devices. 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. +A resume that completes while the app is still **backgrounded** is possible only when all of the conditions below hold. #### App configuration 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 25530568b..5bb291062 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 @@ -78,7 +78,7 @@ class IOSAudioRecorder : public AudioRecorder { Result reprepareForLiveInput(); void handleInputNotification(AudioEngineInputNotification notification); void handleHardwareChange(); - void handleCaptureLost(); + void handleRecordingLost(); Result reprepareFileWriter( AVAudioFormat *inputFormat, int maxInputBufferLength); diff --git a/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.mm b/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.mm index 2380743c1..1a619f9ab 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 @@ -120,7 +120,7 @@ static void cleanupStartedRecorder( handleHardwareChange(); return; case AudioEngineInputNotificationCaptureLost: - handleCaptureLost(); + handleRecordingLost(); return; } } @@ -151,7 +151,7 @@ static void cleanupStartedRecorder( reprepareForLiveInput(); } -void IOSAudioRecorder::handleCaptureLost() +void IOSAudioRecorder::handleRecordingLost() { if (isIdle()) { return; 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 bc5e7c0e7..981f9fba4 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 @@ -474,11 +474,9 @@ - (AudioEngineInterruptionEndOutcome)onInterruptionEnd:(bool)shouldResume // Resume recording and playback. resumeEngine = 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. + // 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. resumeEngine = NO; } } else { 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 4cfc7b9f9..ce6f7a22d 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 @@ -131,9 +131,9 @@ - (void)handleDidBecomeActive:(NSNotification *)notification [self retryInterruptedRecordingIfNeeded]; } -- (void)emitInterruptionBeganIfAccepted:(bool)accepted +- (void)emitInterruptionBegan { - if (!self.audioInterruptionsObserved || !accepted) { + if (!self.audioInterruptionsObserved) { return; } @@ -142,27 +142,43 @@ - (void)emitInterruptionBeganIfAccepted:(bool)accepted .type = "began", .shouldResume = false}]; } -- (void)emitInterruptionEndedIfTransitioned:(AudioEngineInterruptionEndOutcome)outcome - shouldResume:(bool)shouldResume +- (void)emitInterruptionEnded:(bool)shouldResume { if (!self.audioInterruptionsObserved) { return; } - if (outcome == AudioEngineInterruptionEndOutcomeRunning || - outcome == AudioEngineInterruptionEndOutcomePaused) { - [self.audioAPIModule - invokeHandlerWithEventName:audioapi::AudioEvent::INTERRUPTION - payload:audioapi::InterruptionPayload{ - .type = "ended", .shouldResume = shouldResume}]; - } + [self.audioAPIModule + invokeHandlerWithEventName:audioapi::AudioEvent::INTERRUPTION + payload:audioapi::InterruptionPayload{ + .type = "ended", .shouldResume = shouldResume}]; } -- (void)performInterruptionEndOnEngine:(AudioEngine *)audioEngine shouldResume:(bool)shouldResume +- (void)handleInterruptionBegin { + AudioEngine *audioEngine = self.audioAPIModule.audioEngine; + AudioSessionManager *sessionManager = self.audioAPIModule.audioSessionManager; + + self.interruptionEndedDelivered = false; + dispatch_async(dispatch_get_main_queue(), ^{ + bool transitioned = [audioEngine onInterruptionBegin]; + [sessionManager markInactive]; + if (transitioned) { + [self emitInterruptionBegan]; + } + }); +} + +- (void)handleInterruptionEnd:(bool)shouldResume +{ + AudioEngine *audioEngine = self.audioAPIModule.audioEngine; + dispatch_async(dispatch_get_main_queue(), ^{ AudioEngineInterruptionEndOutcome outcome = [audioEngine onInterruptionEnd:shouldResume]; - [self emitInterruptionEndedIfTransitioned:outcome shouldResume:shouldResume]; + if (outcome == AudioEngineInterruptionEndOutcomeRunning || + outcome == AudioEngineInterruptionEndOutcomePaused) { + [self emitInterruptionEnded:shouldResume]; + } }); } @@ -180,28 +196,19 @@ - (void)retryInterruptedRecordingIfNeeded AudioEngine *audioEngine = self.audioAPIModule.audioEngine; if (self.interruptionEndedDelivered && [audioEngine getState] == AudioEngineStateInterrupted) { - [self performInterruptionEndOnEngine:audioEngine - shouldResume:[self shouldResumeForInterruptionRecovery]]; + [self handleInterruptionEnd:[self shouldResumeForInterruptionRecovery]]; } } - (void)handleInterruption:(NSNotification *)notification { - AudioEngine *audioEngine = self.audioAPIModule.audioEngine; - AudioSessionManager *sessionManager = self.audioAPIModule.audioSessionManager; - NSInteger interruptionType = [notification.userInfo[AVAudioSessionInterruptionTypeKey] integerValue]; NSInteger interruptionOption = [notification.userInfo[AVAudioSessionInterruptionOptionKey] integerValue]; if (interruptionType == AVAudioSessionInterruptionTypeBegan) { - self.interruptionEndedDelivered = false; - dispatch_async(dispatch_get_main_queue(), ^{ - bool accepted = [audioEngine onInterruptionBegin]; - [sessionManager markInactive]; - [self emitInterruptionBeganIfAccepted:accepted]; - }); + [self handleInterruptionBegin]; return; } @@ -209,30 +216,23 @@ - (void)handleInterruption:(NSNotification *)notification self.interruptionShouldResume = @(shouldResume); self.interruptionEndedDelivered = true; - [self performInterruptionEndOnEngine:audioEngine shouldResume: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) { - self.interruptionEndedDelivered = false; - dispatch_async(dispatch_get_main_queue(), ^{ - [sessionManager markInactive]; - bool accepted = [audioEngine onInterruptionBegin]; - [self emitInterruptionBeganIfAccepted:accepted]; - }); + [self handleInterruptionBegin]; return; } bool shouldResume = secondaryAudioType == AVAudioSessionSilenceSecondaryAudioHintTypeEnd; self.interruptionEndedDelivered = true; - [self performInterruptionEndOnEngine:audioEngine shouldResume:shouldResume]; + [self handleInterruptionEnd:shouldResume]; } - (void)handleRouteChange:(NSNotification *)notification @@ -371,25 +371,15 @@ - (void)checkSecondaryAudioHint return; } - AudioEngine *audioEngine = self.audioAPIModule.audioEngine; - AudioSessionManager *sessionManager = self.audioAPIModule.audioSessionManager; - self.wasOtherAudioPlaying = shouldSilence; if (shouldSilence) { - self.interruptionEndedDelivered = false; - dispatch_async(dispatch_get_main_queue(), ^{ - [sessionManager markInactive]; - bool accepted = [audioEngine onInterruptionBegin]; - [self emitInterruptionBeganIfAccepted:accepted]; - }); - + [self handleInterruptionBegin]; return; } self.interruptionEndedDelivered = true; - [self performInterruptionEndOnEngine:audioEngine - shouldResume:[self shouldResumeForInterruptionRecovery]]; + [self handleInterruptionEnd:[self shouldResumeForInterruptionRecovery]]; } @end From a623c954c01cf70c423f8e79dffe94e09c60abdc Mon Sep 17 00:00:00 2001 From: Kacper Orszulak <42392219+SomePersonFromMars@users.noreply.github.com> Date: Thu, 15 Oct 2026 13:11:58 +0200 Subject: [PATCH 26/29] chore: extract docs badges to docs-theme --- packages/audiodocs/CLAUDE.md | 2 + .../audiodocs/src/components/Badges/index.tsx | 39 +++++-------------- packages/docs-theme/theme/Badges/index.d.ts | 13 +++++++ packages/docs-theme/theme/Badges/index.jsx | 30 ++++++++++++++ .../theme}/Badges/styles.module.css | 1 - packages/internaldocs/CLAUDE.md | 2 + .../src/components/Badges/index.tsx | 9 +++++ packages/internaldocs/yarn.lock | 4 +- 8 files changed, 67 insertions(+), 33 deletions(-) create mode 100644 packages/docs-theme/theme/Badges/index.d.ts create mode 100644 packages/docs-theme/theme/Badges/index.jsx rename packages/{audiodocs/src/components => docs-theme/theme}/Badges/styles.module.css (99%) create mode 100644 packages/internaldocs/src/components/Badges/index.tsx diff --git a/packages/audiodocs/CLAUDE.md b/packages/audiodocs/CLAUDE.md index 113732bc2..ec5828a62 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, Experimental, MobileOnly } from '@site/src/components/Badges'; diff --git a/packages/audiodocs/src/components/Badges/index.tsx b/packages/audiodocs/src/components/Badges/index.tsx index 60bcee97d..ce91e331f 100644 --- a/packages/audiodocs/src/components/Badges/index.tsx +++ b/packages/audiodocs/src/components/Badges/index.tsx @@ -1,30 +1,9 @@ -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 ? '*' : ''}
; -} - -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, + 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..ea9dc1b5b --- /dev/null +++ b/packages/docs-theme/theme/Badges/index.d.ts @@ -0,0 +1,13 @@ +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 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..4f911471e --- /dev/null +++ b/packages/docs-theme/theme/Badges/index.jsx @@ -0,0 +1,30 @@ +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 ? '*' : ''}
; +} + +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/src/components/Badges/index.tsx b/packages/internaldocs/src/components/Badges/index.tsx new file mode 100644 index 000000000..ce91e331f --- /dev/null +++ b/packages/internaldocs/src/components/Badges/index.tsx @@ -0,0 +1,9 @@ +export { + Optional, + ReadOnly, + Overridden, + IOS, + Android, + Experimental, + MobileOnly, +} from 'docs-theme/theme/Badges'; diff --git a/packages/internaldocs/yarn.lock b/packages/internaldocs/yarn.lock index 4a1c5ff0e..ed8051262 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=b7c5f6&locator=internaldocs%40workspace%3A." + checksum: 10/c0852315fd2cfa979a2bae93f05523ed8e31779ed648328cf41fffd016edd6f83b708fb977b6528fb4c5386c2837ef7d0c60b02f4a4fec7816d7f1862b672f1e languageName: node linkType: hard From df1c08096311cdfb854594f6a5b09bc9efe601e8 Mon Sep 17 00:00:00 2001 From: Kacper Orszulak <42392219+SomePersonFromMars@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:50:53 +0200 Subject: [PATCH 27/29] docs: simplify audiodocs and add detailed library behavior and OS research in internaldocs --- .../audiodocs/docs/system/audio-manager.mdx | 26 +--- .../docs/system/interruptions.mdx | 128 +++++++++++++++++- 2 files changed, 133 insertions(+), 21 deletions(-) diff --git a/packages/audiodocs/docs/system/audio-manager.mdx b/packages/audiodocs/docs/system/audio-manager.mdx index 5cbd21b6b..1e54a6423 100644 --- a/packages/audiodocs/docs/system/audio-manager.mdx +++ b/packages/audiodocs/docs/system/audio-manager.mdx @@ -48,13 +48,7 @@ function App() { | Parameter | Type | Description | | :---: | :---: | :---- | -| options | [`SessionOptions`](./audio-manager.mdx#sessionoptions) | Options to be set for [AVAudioSession](https://developer.apple.com/documentation/avfaudio/avaudiosession?language=objc#Configuring-standard-audio-behaviors). | - -:::warning AVAudioSession Compatibility -Not all `iosOptions` are compatible with every `iosCategory`. Passing an invalid combination to the native API (for example, explicitly setting `allowBluetoothA2DP` alongside the `playback` category) will cause the configuration to fail. This can result in a `SessionActivationError` and total audio silence. - -For details on compatible options and categories, refer to [`SessionOptions`](#sessionoptions). -::: +| options | [`SessionOptions`](#sessionoptions) | Options to be set for [AVAudioSession](https://developer.apple.com/documentation/avfaudio/avaudiosession?language=objc#Configuring-standard-audio-behaviors). | #### Returns `undefined`. @@ -183,11 +177,7 @@ Checks currently used and available devices. ### Resume recording after an interruption {#resumerecordingafteraninterruption} -An interruption is the system taking the audio session away due to a phone call, an alarm, or another non-mixable audio session. It deactivates the session and stops the I/O unit. Audio playback and capture therefore stop. - -A resume that completes while the app is still **backgrounded** is possible only when all of the conditions below hold. - -#### App configuration +After the app has been interrupted by a phone call, for example, 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 [`setAudioSessionOptions`](#setaudiosessionoptions)) allow recording and make the app resilient to interruptions from other apps. Refer to the [session configuration guidelines](#sessionoptionsconfigurationguidelines). The following settings are a useful starting point: - `iosCategory` set to `record` or `playAndRecord`, so the session can record. These categories also allow background recording. An incompatible category leads to a [`cannotStartPlaying`](https://developer.apple.com/documentation/coreaudiotypes/avaudiosession/errorcode/cannotstartplaying) error. @@ -195,13 +185,7 @@ A resume that completes while the app is still **backgrounded** is possible only - `iosOptions` include `mixWithOthers`, so the system can mix this session with audio from active sessions in other apps. Trying to resume recording in the background with a non-mixable session leads to [`cannotInterruptOthers`](https://developer.apple.com/documentation/coreaudiotypes/avaudiosession/errorcode/cannotinterruptothers). - The app must declare [background audio mode](../other/audio-api-plugin.mdx#iosbackgroundmode). -#### Execution - -To complete a background resume, audio I/O must have been started in the foreground, and it must still have been running when the app went to the background. The background audio mode lets that already-running I/O *continue*; it does not allow *starting* audio from the background. iOS treats an `interruption` event of type `ended` as the wake on which a background resume may be attempted. A later background timer is not such a wake. Another app must have released the audio route and the microphone. For recording, the recorder must not have been stopped with [`stop()`](../inputs/audio-recorder.mdx#stop), because stopping ends the take. - -#### iOS policy - -iOS policy determines the overall outcome of restarts. Activating the session and starting I/O are independent steps. An active session does not grant permission to run I/O. Even when every condition above is met, iOS may still refuse the restart: it applies internal checks on why the app is awake. The library therefore does not promise a background restart after a call, Siri, or a similar interruption. Because of this, the library retries with a best-effort strategy when the app returns to the foreground after a failed resume. +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 @@ -275,7 +259,9 @@ Each string union maps directly to the corresponding Apple type: - `IOSMode` maps to [`AVAudioSession.Mode`](https://developer.apple.com/documentation/avfaudio/avaudiosession/mode-swift.struct) - `IOSOption` maps to [`AVAudioSession.CategoryOptions`](https://developer.apple.com/documentation/avfaudio/avaudiosession/categoryoptions-swift.struct) -Not all `iosOptions` are compatible with every `iosCategory`. Passing an invalid combination to the native API will cause the configuration to fail. Always verify valid category and option combinations in [Apple's AVAudioSession Documentation](https://developer.apple.com/documentation/avfaudio/avaudiosession?language=objc). +:::warning AVAudioSession Compatibility +Not all `iosOptions` are compatible with every `iosCategory`. Passing an invalid combination to the native API (for example, explicitly setting `allowBluetoothA2DP` alongside the `playback` category) will cause the configuration to fail. This can result in a `SessionActivationError` and total audio silence. Always verify valid category and option combinations in [Apple's AVAudioSession Documentation](https://developer.apple.com/documentation/avfaudio/avaudiosession?language=objc). +::: ### `SystemEventName` 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? From 07dad836868ef64c076e79b365b4300579b9999a Mon Sep 17 00:00:00 2001 From: Marek Malek Date: Fri, 16 Oct 2026 23:52:39 +0200 Subject: [PATCH 28/29] refactor: inputBlock and comment trimming --- .../ios/audioapi/ios/system/AudioEngine.h | 14 +- .../ios/audioapi/ios/system/AudioEngine.mm | 142 +++++++----------- .../ios/system/SystemNotificationManager.mm | 16 +- 3 files changed, 63 insertions(+), 109 deletions(-) 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 06a54f9d6..eff70b4ef 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 @@ -65,20 +65,14 @@ 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; -/// Responds to an audio route change without restarting the engine. A running engine is left -/// as it is: when a route change alters the hardware sample rate or channel count, the engine -/// stops itself and posts `AVAudioEngineConfigurationChangeNotification`, whose handler -/// rebuilds it. A stopped engine with a tracked graph is marked for rebuild instead, because -/// whether a stopped engine observes the change and posts that notification is not -/// documented; the next start then builds the graph against the current hardware. +/// 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 when @p engine is the AVAudioEngine this object currently drives. False for nil, for -/// engines owned by other code in the process, and for engines this object has already -/// replaced during a rebuild. +/// True only for the AVAudioEngine this object drives now: not nil, foreign or replaced ones. - (bool)isCurrentEngine:(AVAudioEngine *)engine; - (bool)startIfNecessary; @@ -87,6 +81,8 @@ typedef void (^OnOutputRecoveryFailedBlock)(void); - (void)stopIfPossible; +/// @brief Rebuilds the engine and resumes it if it was running, then tells the registered +/// input after releasing the lock. No-op when there is neither an engine nor a tracked graph. - (void)restartAudioEngine; - (void)logAudioEngineState; 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 f57055053..5d65d3ec7 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 @@ -27,22 +27,9 @@ @implementation AudioEngineInputRegistration @end @interface AudioEngine () { - /// Serialises every mutation of the engine and its tracked graph. Never taken on a thread - /// AVFoundation owns: `-[AVAudioEngine dealloc]`, `stop`, `reset`, `prepare` and `start` - /// can all wait on the engine's own queue while this is held, so an observer that locked - /// from that queue would close the cycle. Notification handlers hop to the main queue - /// first, and the registered input's handler runs only after the lock is released. + /// Guards the engine and its tracked graph. Never taken on a thread AVFoundation owns: + /// dealloc, stop, reset, prepare and start can wait on that thread while it is held. std::mutex _engineLock; - /// Handler captured under `_engineLock` when the graph changes in a way the registered - /// input has to hear about, invoked only once the lock is released. Running it inside the - /// locked region would re-enter `_engineLock` (the handler reads the live input format - /// back) and would take the recorder's own mutexes in the opposite order from the - /// recorder's start path, which takes them before calling into the engine. - /// Set and taken within one locked region, so it never holds a value while the lock is - /// free: `restartAudioEngine` also runs on the thread that stops the recorder, and that - /// thread must not pick up a notification another thread queued. - void (^_pendingInputNotification)(AudioEngineInputNotification); - AudioEngineInputNotification _pendingInputNotificationKind; /// 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; @@ -60,19 +47,19 @@ - (BOOL)hasTrackedGraph; - (AVAudioFormat *)currentInputConnectionFormat; - (void)materializeSourceNodeWithId:(NSString *)sourceNodeId; - (BOOL)materializeInputNodeIfNeeded; -- (void)connectVoiceProcessingOutputWithFormat:(AVAudioFormat *)format; +- (void)connectVoiceProcessingIOOutput:(AVAudioFormat *)format; - (void)applyVoiceProcessing; - (void)materializeTrackedNodesIfNeeded; - (AVAudioFormat *)liveInputFormat; - (void)resetInputNode; -- (BOOL)graphRequiresRebuild; +- (BOOL)mustRebuildBeforeStart; - (void)rebuildGraphForCurrentHardware; -- (void)parkAfterFailedStart; -- (void)rebuildAudioEngineAndResumeIfNeeded; -- (void)queueInputNotification:(AudioEngineInputNotification)notification; -- (dispatch_block_t)takePendingInputNotification; -- (AudioEngineInterruptionEndOutcome)resumeAfterInterruption:(bool)shouldResume; +- (void)settleStateAfterFailedStart; +- (dispatch_block_t)rebuildAudioEngineAndResumeIfNeeded; +- (dispatch_block_t)inputNotificationBlock:(AudioEngineInputNotification)notification; +- (AudioEngineInterruptionEndOutcome)resumeAfterInterruption:(bool)shouldResume + notifyInput:(dispatch_block_t *)notifyInput; @end @@ -221,9 +208,7 @@ - (void)materializeSourceNodeWithId:(NSString *)sourceNodeId [self.audioEngine connect:sourceNode to:self.audioEngine.mainMixerNode format:format]; } -/// @brief Reads the format the input node currently produces. -/// @discussion Expects `_engineLock` to be held: the other caller is the graph -/// materialization, which runs inside a locked rebuild. +/// @brief The input node's current output format, or nil when unusable. Lock held. - (AVAudioFormat *)liveInputFormat { if (self.audioEngine == nil) { @@ -286,7 +271,7 @@ - (BOOL)materializeInputNodeIfNeeded } if (_voiceProcessingApplied) { - [self connectVoiceProcessingOutputWithFormat:inputFormat]; + [self connectVoiceProcessingIOOutput:inputFormat]; } self.inputNode = @@ -296,16 +281,10 @@ - (BOOL)materializeInputNodeIfNeeded return YES; } -/// @brief Gives the voice-processing unit's output bus a graph to pull from. -/// @discussion Voice processing runs a single I/O unit in both directions, even when only -/// the microphone is used. A recorder-only graph never creates the main mixer, so the output -/// bus has no node behind it and the first render fails. Once the mixer exists, its -/// connection to the output node is formatted by the engine unless made explicitly, and that -/// format can differ from the input's (observed: 44.1 kHz against a 48 kHz input). Either -/// way the engine stops itself shortly after starting. Voice processing requires the input -/// node's output format and the output node's input format to match, so the connection is -/// made with the input's format; the mixer converts whatever sources are attached to it. -- (void)connectVoiceProcessingOutputWithFormat:(AVAudioFormat *)format +/// @brief Connects the main mixer to the output in the input's format. +/// @discussion 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 @@ -517,7 +496,7 @@ - (void)markGraphNeedsRebuild - (void)onRouteChange { std::scoped_lock lock(_engineLock); - if (![self hasTrackedGraph] || [self.audioEngine isRunning]) { + if (![self hasTrackedGraph] || self.state == AudioEngineState::AudioEngineStateRunning) { return; } @@ -527,12 +506,11 @@ - (void)onRouteChange - (AudioEngineInterruptionEndOutcome)onInterruptionEnd:(bool)shouldResume { AudioEngineInterruptionEndOutcome outcome; - dispatch_block_t notifyInput; + dispatch_block_t notifyInput = nil; { std::scoped_lock lock(_engineLock); - outcome = [self resumeAfterInterruption:shouldResume]; - notifyInput = [self takePendingInputNotification]; + outcome = [self resumeAfterInterruption:shouldResume notifyInput:¬ifyInput]; } if (notifyInput != nil) { @@ -542,7 +520,11 @@ - (AudioEngineInterruptionEndOutcome)onInterruptionEnd:(bool)shouldResume return outcome; } +/// @brief Resumes an engine the system interrupted, or leaves it `Paused` when policy says not to. +/// @discussion Expects `_engineLock` to be held. `notifyInput` receives what the registered +/// input must be told, or stays nil; the caller runs it after releasing the lock. - (AudioEngineInterruptionEndOutcome)resumeAfterInterruption:(bool)shouldResume + notifyInput:(dispatch_block_t *)notifyInput { NSError *error = nil; @@ -584,7 +566,7 @@ - (AudioEngineInterruptionEndOutcome)resumeAfterInterruption:(bool)shouldResume [self stopEngine]; [self rebuildAudioEngine]; self.state = AudioEngineState::AudioEngineStatePaused; - [self queueInputNotification:AudioEngineInputNotificationHardwareChanged]; + *notifyInput = [self inputNotificationBlock:AudioEngineInputNotificationHardwareChanged]; return AudioEngineInterruptionEndOutcomePaused; } @@ -600,7 +582,7 @@ - (AudioEngineInterruptionEndOutcome)resumeAfterInterruption:(bool)shouldResume NSLog( @"Error while materializing the audio input node after interruption: missing live input format"); self.state = AudioEngineState::AudioEngineStateInterrupted; - [self queueInputNotification:AudioEngineInputNotificationCaptureLost]; + *notifyInput = [self inputNotificationBlock:AudioEngineInputNotificationCaptureLost]; return AudioEngineInterruptionEndOutcomeStillInterrupted; } @@ -612,42 +594,27 @@ - (AudioEngineInterruptionEndOutcome)resumeAfterInterruption:(bool)shouldResume @"Error while restarting the audio engine after interruption: %@", [error debugDescription]); self.state = AudioEngineState::AudioEngineStateInterrupted; - [self queueInputNotification:AudioEngineInputNotificationCaptureLost]; + *notifyInput = [self inputNotificationBlock:AudioEngineInputNotificationCaptureLost]; return AudioEngineInterruptionEndOutcomeStillInterrupted; } self.state = AudioEngineState::AudioEngineStateRunning; self.sessionDeactivationInvalidatedGraph = false; - [self queueInputNotification:AudioEngineInputNotificationHardwareChanged]; + *notifyInput = [self inputNotificationBlock:AudioEngineInputNotificationHardwareChanged]; return AudioEngineInterruptionEndOutcomeRunning; } -- (void)queueInputNotification:(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) { - return; - } - - // The handler is captured, not looked up at delivery time: the registration may be - // detached between releasing the lock and delivering, and the recorder that asked for - // this notification is the one that must receive it. - _pendingInputNotification = self.inputRegistration.onInputNotification; - _pendingInputNotificationKind = notification; -} - -/// @brief Removes the notification queued by the last graph change and returns it ready to run. -/// @discussion Must be called with `_engineLock` held, in the same locked region that queued -/// it; the caller runs the result after releasing the lock. Returns nil when nothing is queued. -- (dispatch_block_t)takePendingInputNotification -{ - void (^handler)(AudioEngineInputNotification) = _pendingInputNotification; + void (^handler)(AudioEngineInputNotification) = self.inputRegistration.onInputNotification; if (handler == nil) { return nil; } - AudioEngineInputNotification notification = _pendingInputNotificationKind; - _pendingInputNotification = nil; return ^{ handler(notification); }; } @@ -675,7 +642,9 @@ - (bool)isCurrentEngine:(AVAudioEngine *)engine return engine != nil && engine == self.audioEngine; } -- (void)rebuildAudioEngineAndResumeIfNeeded +/// @brief Rebuilds, then starts again only if the engine was `Running`. Lock held. +/// @return What the registered input must be told after the lock is released, or nil. +- (dispatch_block_t)rebuildAudioEngineAndResumeIfNeeded { const BOOL shouldResume = self.state == AudioEngineState::AudioEngineStateRunning; @@ -686,30 +655,29 @@ - (void)rebuildAudioEngineAndResumeIfNeeded didStartEngine = [self startEngine]; if (!didStartEngine) { - [self parkAfterFailedStart]; + [self settleStateAfterFailedStart]; [self notifyOutputRecoveryFailed]; } } if (didStartEngine) { - [self queueInputNotification:AudioEngineInputNotificationHardwareChanged]; + return [self inputNotificationBlock:AudioEngineInputNotificationHardwareChanged]; } + + return nil; } -/// @brief Reports whether the graph must be torn down and rebuilt before it can run. -/// @discussion An interruption, a session deactivation and a node registered while the -/// engine was gone all leave connections that no longer match the hardware. Starting such -/// a graph would either fail or capture nothing. -- (BOOL)graphRequiresRebuild +/// @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 Rebuilds the graph against the current hardware, leaving the engine stopped. -/// @discussion Separate from starting so that no path can start an engine and rebuild it in -/// the same call: the two used to be mutually recursive, and termination depended on -/// `rebuildAudioEngine` clearing `graphNeedsRebuild` between the two frames. +/// @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]) { @@ -720,12 +688,10 @@ - (void)rebuildGraphForCurrentHardware self.sessionDeactivationInvalidatedGraph = false; } -/// @brief Records that the engine is not running after a start that was expected to succeed. -/// @discussion Leaving `Running` behind makes `startIfNecessary` early-out on a dead engine -/// and makes `getState` report a recorder that is capturing nothing. `Interrupted` is kept -/// as-is: the interruption, not the failed start, is still the reason the engine is down, -/// and the interruption-end path is what recovers it. -- (void)parkAfterFailedStart +/// @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; @@ -735,6 +701,7 @@ - (void)parkAfterFailedStart : AudioEngineState::AudioEngineStateIdle; } +/// @brief Destroys the engine and creates a new one with the tracked graph, left stopped. Lock held. - (void)rebuildAudioEngine { [self destroyAudioEnginePreservingSessionDeactivationState:YES]; @@ -760,9 +727,7 @@ - (bool)startEngine return false; } - // Materializing attaches nodes registered while the engine was stopped; it never - // rebuilds. A graph that needs rebuilding is the caller's business, so that starting - // cannot silently tear the engine down - see `graphRequiresRebuild`. + // Never rebuilds; callers check `mustRebuildBeforeStart` first. [self materializeTrackedNodesIfNeeded]; if (self.inputRegistration != nil && self.inputNode == nil) { @@ -809,7 +774,7 @@ - (bool)startIfNecessary return false; } - if ([self graphRequiresRebuild]) { + if ([self mustRebuildBeforeStart]) { [self rebuildGraphForCurrentHardware]; } @@ -817,7 +782,7 @@ - (bool)startIfNecessary return true; } - [self parkAfterFailedStart]; + [self settleStateAfterFailedStart]; return false; } @@ -868,8 +833,7 @@ - (void)restartAudioEngine return; } - [self rebuildAudioEngineAndResumeIfNeeded]; - notifyInput = [self takePendingInputNotification]; + notifyInput = [self rebuildAudioEngineAndResumeIfNeeded]; } if (notifyInput != nil) { 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 f0eaa842c..388420d7e 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 @@ -280,10 +280,8 @@ - (void)handleRouteChange:(NSNotification *)notification case AVAudioSessionRouteChangeReasonNewDeviceAvailable: case AVAudioSessionRouteChangeReasonOldDeviceUnavailable: case AVAudioSessionRouteChangeReasonRouteConfigurationChange: { - // Not a restart: rebuilding the engine reconfigures its I/O unit, which itself posts - // RouteConfigurationChange, so restarting here would restart without end. A route - // change that alters the hardware format also arrives as - // AVAudioEngineConfigurationChangeNotification, which does restart the engine. + // Not a restart: a rebuild posts RouteConfigurationChange itself, so restarting would loop. + // Real format changes arrive as AVAudioEngineConfigurationChangeNotification. AudioEngine *audioEngine = self.audioAPIModule.audioEngine; dispatch_async(dispatch_get_main_queue(), ^{ [audioEngine onRouteChange]; }); break; @@ -322,16 +320,12 @@ - (void)handleEngineConfigurationChange:(NSNotification *)notification AudioEngine *audioEngine = self.audioAPIModule.audioEngine; AudioSessionManager *sessionManager = self.audioAPIModule.audioSessionManager; - // Weak, so a queued handler does not keep a replaced engine alive; one that is gone by the - // time the handler runs is no longer ours to restart. + // Weak, so a queued handler does not keep a replaced engine alive. __weak AVAudioEngine *changedEngine = notification.object; dispatch_async(dispatch_get_main_queue(), ^{ - // Registered with object:nil because the engine is replaced on every rebuild, so this - // also fires for AVAudioEngine instances owned by other libraries in the host app and - // for engines we have already replaced. Only a change on the engine we currently drive - // calls for a restart; acting on any other would rebuild ours for nothing and mark the - // session inactive behind the back of an app that only manages the session. + // object:nil also delivers other libraries' engines and ones we already replaced; + // only a change on our current engine needs a restart. if (![audioEngine isCurrentEngine:changedEngine]) { return; } From f2361b576b6dc1db32fc9e7041706c7b5a0a750f Mon Sep 17 00:00:00 2001 From: Marek Malek Date: Tue, 20 Oct 2026 21:15:36 +0200 Subject: [PATCH 29/29] fix: comments --- .../ios/audioapi/ios/system/AudioEngine.h | 3 +-- .../ios/audioapi/ios/system/AudioEngine.mm | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) 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 6b328a0a7..240dc55e5 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 @@ -83,8 +83,7 @@ typedef void (^OnOutputRecoveryFailedBlock)(void); /// @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. No-op when there is -/// neither an engine nor a tracked graph. +/// consumers match the new format before the first render callback. - (void)restartAudioEngine; - (void)logAudioEngineState; 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 b3f63a4c0..13e4c0f37 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 @@ -28,7 +28,7 @@ @implementation AudioEngineInputRegistration @interface AudioEngine () { /// Guards the engine and its tracked graph. Never taken on a thread AVFoundation owns: - /// dealloc, stop, reset, prepare and start can wait on that thread while it is held. + /// 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. @@ -282,7 +282,7 @@ - (BOOL)materializeInputNodeIfNeeded } /// @brief Connects the main mixer to the output in the input's format. -/// @discussion The voice-processing I/O unit runs both directions and needs matching formats; +/// @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 {