diff --git a/apps/fabric-example/ios/FabricExampleTests/AudioEngineTests.mm b/apps/fabric-example/ios/FabricExampleTests/AudioEngineTests.mm index f9e29f45f..48ddc2fee 100644 --- a/apps/fabric-example/ios/FabricExampleTests/AudioEngineTests.mm +++ b/apps/fabric-example/ios/FabricExampleTests/AudioEngineTests.mm @@ -329,7 +329,8 @@ - (NSString *)attachSourceNodeToAudioEngine { return [self.audioEngine attachSourceNodeWithRenderBlock:[self testSourceRenderBlock] sampleRate:44100 - channelCount:2]; + channelCount:2 + onOutputRecoveryFailed:nil]; } - (void)testCleanupDestroysInternalEngineAndResetsStateAndDeactivatesSession { @@ -370,7 +371,8 @@ - (void)testAttachSourceNodeStoresAndConnectsSource { NSString *sourceNodeId = [self.audioEngine attachSourceNodeWithRenderBlock:[self testSourceRenderBlock] sampleRate:44100 - channelCount:2]; + channelCount:2 + onOutputRecoveryFailed:nil]; AVAudioSourceNode *sourceNode = self.audioEngine.sourceNodes[sourceNodeId]; AVAudioFormat *format = self.audioEngine.sourceFormats[sourceNodeId]; diff --git a/apps/fabric-example/ios/FabricExampleTests/AudioPlayerTests.mm b/apps/fabric-example/ios/FabricExampleTests/AudioPlayerTests.mm index a115ec460..9a3b1c379 100644 --- a/apps/fabric-example/ios/FabricExampleTests/AudioPlayerTests.mm +++ b/apps/fabric-example/ios/FabricExampleTests/AudioPlayerTests.mm @@ -12,6 +12,7 @@ #include #include #include +#include #include #include @@ -19,13 +20,17 @@ namespace audioapi { +class AudioContext; + class IOSAudioPlayer : public CommonPlayer { public: IOSAudioPlayer( const std::function &renderAudio, float sampleRate, int channelCount, - std::atomic ¤tRenders); + std::atomic ¤tRenders, + std::weak_ptr context, + std::mutex *driverMutex); ~IOSAudioPlayer() override; bool start() override; @@ -50,6 +55,8 @@ std::atomic flushOverflowNextPull_; int pendingSavedCount_; DSPAudioBuffer pendingSaved_; + std::weak_ptr context_; + std::mutex *driverMutex_; }; } // namespace audioapi @@ -159,6 +166,7 @@ - (void)stopIfPossible - (NSString *)attachSourceNodeWithRenderBlock:(AVAudioSourceNodeRenderBlock)renderBlock sampleRate:(float)sampleRate channelCount:(AVAudioChannelCount)channelCount + onOutputRecoveryFailed:(OnOutputRecoveryFailedBlock)onOutputRecoveryFailed { self.attachSourceNodeCallCount += 1; self.lastAttachedRenderBlock = renderBlock; @@ -235,7 +243,13 @@ - (void)cleanup float sampleRate, int channelCount) : currentRendersStorage_(0), - IOSAudioPlayer(renderAudio, sampleRate, channelCount, currentRendersStorage_) {} + IOSAudioPlayer( + renderAudio, + sampleRate, + channelCount, + currentRendersStorage_, + std::weak_ptr{}, + nullptr) {} NativeAudioPlayer *replaceAudioPlayer(NativeAudioPlayer *audioPlayer) { NativeAudioPlayer *previous = audioPlayer_; diff --git a/packages/audiodocs/docs/core/audio-context.mdx b/packages/audiodocs/docs/core/audio-context.mdx index 45eaebeca..c2ee43fcf 100644 --- a/packages/audiodocs/docs/core/audio-context.mdx +++ b/packages/audiodocs/docs/core/audio-context.mdx @@ -75,3 +75,21 @@ Resumes a previously suspended audio context. Inherits [`onstatechange`](./base-audio-context.mdx#onstatechange) from [`BaseAudioContext`](./base-audio-context.mdx#events); `close`, `suspend` and `resume` each fire it once their returned promise has resolved. +### `onerror` + +Sets (or removes, when `null` is assigned) a callback fired when the live output stream fails natively and cannot keep rendering — for example after the audio device disconnects or the OS audio service dies while the context is running. + + +Typical recovery is to call [`resume`](./audio-context.mdx#resume) from the handler (or recreate the context). A short delay before `resume` gives the OS audio server time to recover from some errors, e.g. hardware errors, and prevents rapid retry loops. + +```tsx +const audioContext = new AudioContext(); + +audioContext.onerror = () => { + console.error('Audio output failed'); + setTimeout(() => { + void audioContext.resume(); + }, 2000); +}; +``` + diff --git a/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AudioPlayer.cpp b/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AudioPlayer.cpp index 38371018f..a08d99e30 100644 --- a/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AudioPlayer.cpp +++ b/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AudioPlayer.cpp @@ -55,8 +55,14 @@ bool AudioPlayer::openAudioStream() { return true; } +bool AudioPlayer::rebuildStream() { + cleanup(); + return openAudioStream(); +} + bool AudioPlayer::start() { std::scoped_lock lock(streamMutex_); + if (!isInitialized_.load(std::memory_order_acquire)) { if (!openAudioStream()) { return false; @@ -157,10 +163,39 @@ AudioPlayer::onAudioReady(AudioStream *oboeStream, void *audioData, int32_t numF return DataCallbackResult::Continue; } +namespace { +struct ReentrancyGuard { + bool &flag; + explicit ReentrancyGuard(bool &f) : flag(f) { + flag = true; + } + ~ReentrancyGuard() { + flag = false; + } +}; +} // namespace + void AudioPlayer::onErrorAfterClose(oboe::AudioStream *stream, oboe::Result error) { - if (error != oboe::Result::ErrorDisconnected || driverMutex_ == nullptr) { + if (driverMutex_ == nullptr) { + return; + } + + switch (error) { + case oboe::Result::ErrorDisconnected: + case oboe::Result::ErrorTimeout: + case oboe::Result::ErrorInternal: + case oboe::Result::ErrorNoService: + break; + default: + return; + } + + // Reentrancy guard - prevent recursive calls to onErrorAfterClose. + static thread_local bool isInsideOnError = false; + if (isInsideOnError) { return; } + ReentrancyGuard guard(isInsideOnError); // Serialize with start()/resume()/suspend()/close() on the JS / promise-pool threads. std::scoped_lock lock(*driverMutex_, streamMutex_); @@ -176,9 +211,28 @@ void AudioPlayer::onErrorAfterClose(oboe::AudioStream *stream, oboe::Result erro return; } - cleanup(); - if (openAudioStream()) { - resume(); + // Check if the stream was expected to be running when the error occurred + const bool wasRunning = isRunning_.load(std::memory_order_acquire); + + if (error == oboe::Result::ErrorDisconnected) { + // Best effort rebuild - only once, then fire AudioContext::onStreamFail. + if (!rebuildStream()) { + isRunning_.store(false, std::memory_order_release); + context->onStreamFail(); + return; + } + + // Restart the stream if it was expected to be running when the error occurred + if (wasRunning && mStream_ != nullptr) { + const bool started = mStream_->requestStart() == oboe::Result::OK; + isRunning_.store(started, std::memory_order_release); + if (!started) { + context->onStreamFail(); + } + } + } else { + isRunning_.store(false, std::memory_order_release); + context->onStreamFail(); } } diff --git a/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AudioPlayer.h b/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AudioPlayer.h index 2f9a4ac01..107ec9236 100644 --- a/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AudioPlayer.h +++ b/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AudioPlayer.h @@ -69,6 +69,7 @@ class AudioPlayer : public CommonPlayer, std::weak_ptr context_; bool openAudioStream(); + bool rebuildStream(); }; } // namespace audioapi diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/AudioEvent.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/AudioEvent.kt index 324fac4e2..a60d76998 100644 --- a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/AudioEvent.kt +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/AudioEvent.kt @@ -27,4 +27,5 @@ enum class AudioEvent { RECORDER_ERROR, BUFFERING_STATE_CHANGE, STATE_CHANGE, + CONTEXT_ERROR, } diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/AudioContextHostObject.cpp b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/AudioContextHostObject.cpp index 4bc1c6843..65917a3e5 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/AudioContextHostObject.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/AudioContextHostObject.cpp @@ -20,6 +20,7 @@ AudioContextHostObject::AudioContextHostObject( callInvoker) { addGetters(JSI_EXPORT_PROPERTY_GETTER(AudioContextHostObject, outputLatency)); addGetters(JSI_EXPORT_PROPERTY_GETTER(AudioContextHostObject, baseLatency)); + addSetters(JSI_EXPORT_PROPERTY_SETTER(AudioContextHostObject, onerror)); addFunctions( JSI_EXPORT_FUNCTION(AudioContextHostObject, close), JSI_EXPORT_FUNCTION(AudioContextHostObject, resume), @@ -27,6 +28,10 @@ AudioContextHostObject::AudioContextHostObject( JSI_EXPORT_FUNCTION(AudioContextHostObject, createMediaElementSource)); } +AudioContextHostObject::~AudioContextHostObject() { + std::static_pointer_cast(context_)->assignOnErrorCallbackId(0); +} + JSI_HOST_FUNCTION_IMPL(AudioContextHostObject, close) { return promiseVendor_->createPromise([this](Promise &&promise) { auto contextPromise = ContextPromiseResolver::makeContextPromiseResolver( @@ -78,4 +83,9 @@ JSI_HOST_FUNCTION_IMPL(AudioContextHostObject, createMediaElementSource) { return object; } +JSI_PROPERTY_SETTER_IMPL(AudioContextHostObject, onerror) { + auto audioContext = std::static_pointer_cast(context_); + audioContext->assignOnErrorCallbackId(std::stoull(value.getString(runtime).utf8(runtime))); +} + } // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/AudioContextHostObject.h b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/AudioContextHostObject.h index 4952c6c85..470bf2a35 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/AudioContextHostObject.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/AudioContextHostObject.h @@ -18,6 +18,7 @@ class AudioContextHostObject : public BaseAudioContextHostObject { const std::shared_ptr &audioEventHandlerRegistry, jsi::Runtime *runtime, const std::shared_ptr &callInvoker); + ~AudioContextHostObject() override; JSI_HOST_FUNCTION_DECL(close); JSI_HOST_FUNCTION_DECL(resume); @@ -26,5 +27,6 @@ class AudioContextHostObject : public BaseAudioContextHostObject { JSI_PROPERTY_GETTER_DECL(outputLatency); JSI_PROPERTY_GETTER_DECL(baseLatency); + JSI_PROPERTY_SETTER_DECL(onerror); }; } // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/JsEnumParser.cpp b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/JsEnumParser.cpp index 0e481a80b..1eaa8b8da 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/JsEnumParser.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/JsEnumParser.cpp @@ -152,6 +152,8 @@ AudioEvent audioEventFromString(const std::string &event) { return AudioEvent::BUFFERING_STATE_CHANGE; if (event == "stateChange") return AudioEvent::STATE_CHANGE; + if (event == "contextError") + return AudioEvent::CONTEXT_ERROR; throw std::invalid_argument("Unknown audio event: " + event); } diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/AudioContext.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/AudioContext.cpp index 6721bb9bf..434c4e8d6 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/AudioContext.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/AudioContext.cpp @@ -8,15 +8,17 @@ #include #include + #include -#include #include namespace audioapi { AudioContext::AudioContext( float sampleRate, const std::shared_ptr &audioEventHandlerRegistry) - : BaseAudioContext(sampleRate, audioEventHandlerRegistry), isInitialized_(false) { + : BaseAudioContext(sampleRate, audioEventHandlerRegistry), + isInitialized_(false), + onErrorEvent_(audioEventHandlerRegistry) { // Context starts SUSPENDED with no audio-thread consumer. Let the producer // drain the channels itself until start()/resume() hands draining to the // audio callback (same pattern as OfflineAudioContext before rendering). @@ -51,7 +53,9 @@ void AudioContext::initialize(const AudioDestinationNode *destination) { [this](DSPAudioBuffer *buf, int n) { processGraph(buf, n); }, getSampleRate(), destination_->getChannelCount(), - currentRenders_); + currentRenders_, + std::static_pointer_cast(shared_from_this()), + &driverMutex_); #endif } @@ -188,4 +192,20 @@ double AudioContext::getOutputLatency() const { return audioPlayer_->getOutputLatency(); } +void AudioContext::assignOnErrorCallbackId(uint64_t callbackId) { + onErrorEvent_.assignCallbackId(callbackId); +} + +void AudioContext::onStreamFail() { + assertDriverMutexHeld(); + + if (audioPlayer_ != nullptr) { + audioPlayer_->cleanup(); + } + + isInitialized_.store(false, std::memory_order_release); + + onErrorEvent_.dispatchEmpty(); +} + } // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/AudioContext.h b/packages/react-native-audio-api/common/cpp/audioapi/core/AudioContext.h index 0cad163a2..16b8efcc6 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/AudioContext.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/AudioContext.h @@ -2,6 +2,8 @@ #include #include +#include +#include #include #include #include @@ -37,6 +39,12 @@ class AudioContext : public BaseAudioContext { /// @returns The output latency in seconds. [[nodiscard]] double getOutputLatency() const; + /// @brief Called when the audio stream failed to rebuild. + /// @note This method is called when the audio stream fails. + void onStreamFail(); + + void assignOnErrorCallbackId(uint64_t callbackId); + private: std::shared_ptr audioPlayer_; std::atomic isInitialized_{false}; @@ -44,6 +52,8 @@ class AudioContext : public BaseAudioContext { /// control thread waits on suspend/close. std::atomic currentRenders_{0}; + EventCaller onErrorEvent_; + bool isDriverRunning() const override; /// Caller must hold `driverMutex_`. diff --git a/packages/react-native-audio-api/common/cpp/audioapi/events/AudioEvent.h b/packages/react-native-audio-api/common/cpp/audioapi/events/AudioEvent.h index beb125403..6042480fd 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/events/AudioEvent.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/events/AudioEvent.h @@ -30,5 +30,6 @@ enum class AudioEvent : uint8_t { RECORDER_ERROR, BUFFERING_STATE_CHANGE, STATE_CHANGE, + CONTEXT_ERROR, }; } // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/audioapi/events/AudioEventPayloadMapping.hpp b/packages/react-native-audio-api/common/cpp/audioapi/events/AudioEventPayloadMapping.hpp index eb44b720c..9385e22c6 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/events/AudioEventPayloadMapping.hpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/events/AudioEventPayloadMapping.hpp @@ -42,6 +42,7 @@ AUDIOAPI_DEFINE_EVENT_PAYLOAD(AudioEvent::BUFFER_ENDED, BufferEndedPayload); AUDIOAPI_DEFINE_EVENT_PAYLOAD(AudioEvent::RECORDER_ERROR, StringPayload); AUDIOAPI_DEFINE_EVENT_PAYLOAD(AudioEvent::BUFFERING_STATE_CHANGE, BoolValuePayload); AUDIOAPI_DEFINE_EVENT_PAYLOAD(AudioEvent::STATE_CHANGE, StringPayload); +AUDIOAPI_DEFINE_EVENT_PAYLOAD(AudioEvent::CONTEXT_ERROR, EmptyPayload); #undef AUDIOAPI_DEFINE_EVENT_PAYLOAD diff --git a/packages/react-native-audio-api/common/cpp/test/src/events/EventCallerTest.cpp b/packages/react-native-audio-api/common/cpp/test/src/events/EventCallerTest.cpp index da36dede5..28a282b55 100644 --- a/packages/react-native-audio-api/common/cpp/test/src/events/EventCallerTest.cpp +++ b/packages/react-native-audio-api/common/cpp/test/src/events/EventCallerTest.cpp @@ -15,6 +15,7 @@ constexpr uint64_t ERROR_CALLBACK_ID = 88; constexpr uint64_t POSITION_CALLBACK_ID = 19; static_assert(EventPayloadFor); +static_assert(EventPayloadFor); static_assert(EventPayloadFor); static_assert(EventPayloadFor); static_assert(EventPayloadFor); diff --git a/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioPlayer.h b/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioPlayer.h index d5203af6b..10a49e9a1 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioPlayer.h +++ b/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioPlayer.h @@ -14,6 +14,9 @@ typedef struct objc_object AudioBufferList; #include #include #include +#include +#include + namespace audioapi { class AudioContext; @@ -24,7 +27,9 @@ class IOSAudioPlayer : public CommonPlayer { const std::function &renderAudio, float sampleRate, int channelCount, - std::atomic ¤tRenders); + std::atomic ¤tRenders, + std::weak_ptr context, + std::mutex *driverMutex); ~IOSAudioPlayer() override; DELETE_COPY_AND_MOVE(IOSAudioPlayer); @@ -60,6 +65,8 @@ class IOSAudioPlayer : public CommonPlayer { /// Frames valid at the front of each `pendingSaved_[ch]` (0 … RENDER_QUANTUM_SIZE). int pendingSavedCount_{0}; DSPAudioBuffer pendingSaved_; + std::weak_ptr context_; + std::mutex *driverMutex_; }; } // namespace audioapi diff --git a/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioPlayer.mm b/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioPlayer.mm index cfd860e3e..cf88986c0 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioPlayer.mm +++ b/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioPlayer.mm @@ -4,6 +4,7 @@ #include #include +#include #include #include #include @@ -11,13 +12,36 @@ #include #include +#include + namespace audioapi { +namespace { + +void reportStreamFailToContext(std::mutex *driverMutex, const std::weak_ptr &context) +{ + if (driverMutex == nullptr) { + return; + } + + std::scoped_lock lock(*driverMutex); + if (auto ctx = context.lock()) { + if (ctx->isClosed()) { + return; + } + ctx->onStreamFail(); + } +} + +} // namespace + IOSAudioPlayer::IOSAudioPlayer( const std::function &renderAudio, float sampleRate, int channelCount, - std::atomic ¤tRenders) + std::atomic ¤tRenders, + std::weak_ptr context, + std::mutex *driverMutex) : audioBuffer_(nullptr), audioPlayer_(nullptr), renderAudio_(renderAudio), @@ -25,7 +49,9 @@ currentRenders_(currentRenders), channelCount_(channelCount), isRunning_(false), - pendingSaved_(RENDER_QUANTUM_SIZE, channelCount_, sampleRate) + pendingSaved_(RENDER_QUANTUM_SIZE, channelCount_, sampleRate), + context_(std::move(context)), + driverMutex_(driverMutex) { RenderAudioBlock renderAudioBlock = ^(AudioBufferList *outputData, int numFrames) { deliverOutputBuffers(outputData, numFrames); @@ -35,6 +61,10 @@ sampleRate:sampleRate channelCount:channelCount_]; audioBuffer_ = std::make_shared(RENDER_QUANTUM_SIZE, channelCount_, sampleRate); + + std::mutex *driverMutexForCallback = driverMutex_; + std::weak_ptr weakContext = context_; + audioPlayer_.onStreamFail = ^{ reportStreamFailToContext(driverMutexForCallback, weakContext); }; } IOSAudioPlayer::~IOSAudioPlayer() diff --git a/packages/react-native-audio-api/ios/audioapi/ios/core/NativeAudioPlayer.h b/packages/react-native-audio-api/ios/audioapi/ios/core/NativeAudioPlayer.h index 31b283a6d..e172e3442 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/core/NativeAudioPlayer.h +++ b/packages/react-native-audio-api/ios/audioapi/ios/core/NativeAudioPlayer.h @@ -12,6 +12,7 @@ typedef void (^RenderAudioBlock)(AudioBufferList *outputBuffer, int numFrames); @property (nonatomic, assign) int channelCount; @property (nonatomic, strong) NSString *sourceNodeId; @property (nonatomic, strong) AVAudioSourceNodeRenderBlock renderBlock; +@property (nonatomic, copy) void (^onStreamFail)(void); - (instancetype)initWithRenderAudio:(RenderAudioBlock)renderAudio sampleRate:(float)sampleRate 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..7593b7738 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 @@ -22,7 +22,8 @@ - (void)attachSourceNodeIfNeeded:(AudioEngine *)audioEngine self.sourceNodeId = [audioEngine attachSourceNodeWithRenderBlock:self.renderBlock sampleRate:self.sampleRate - channelCount:self.channelCount]; + channelCount:self.channelCount + onOutputRecoveryFailed:self.onStreamFail]; } - (bool)startPlaybackGraph:(AudioEngine *)audioEngine @@ -121,6 +122,7 @@ - (void)cleanup { self.renderAudio = nil; self.renderBlock = nil; + self.onStreamFail = 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 02c4b38a2..e87e58aa8 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,8 @@ typedef NS_ENUM(NSInteger, AudioEngineState) { AudioEngineStateInterrupted }; +typedef void (^OnOutputRecoveryFailedBlock)(void); + @interface AudioEngine : NSObject @property (nonatomic, assign) AudioEngineState state; @@ -30,7 +32,9 @@ typedef NS_ENUM(NSInteger, AudioEngineState) { - (NSString *)attachSourceNodeWithRenderBlock:(AVAudioSourceNodeRenderBlock)renderBlock sampleRate:(float)sampleRate - channelCount:(AVAudioChannelCount)channelCount; + channelCount:(AVAudioChannelCount)channelCount + onOutputRecoveryFailed: + (nullable OnOutputRecoveryFailedBlock)onOutputRecoveryFailed; - (void)detachSourceNodeWithId:(NSString *)sourceNodeId; - (void)attachInputNodeWithReceiverBlock:(AVAudioSinkNodeReceiverBlock)receiverBlock diff --git a/packages/react-native-audio-api/ios/audioapi/ios/system/AudioEngine.mm b/packages/react-native-audio-api/ios/audioapi/ios/system/AudioEngine.mm index 519edfcad..fc535c842 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 @@ -8,6 +8,7 @@ @interface AudioEngineSourceRegistration : NSObject @property (nonatomic, copy) AVAudioSourceNodeRenderBlock renderBlock; @property (nonatomic, assign) float sampleRate; @property (nonatomic, assign) AVAudioChannelCount channelCount; +@property (nonatomic, copy) OnOutputRecoveryFailedBlock onOutputRecoveryFailed; @end @@ -147,6 +148,26 @@ - (void)cleanup } } +- (void)notifyOutputRecoveryFailed +{ + NSMutableArray *blocks = [NSMutableArray array]; + for (AudioEngineSourceRegistration *reg in self.sourceRegistrations.allValues) { + if (reg.onOutputRecoveryFailed != nil) { + [blocks addObject:[reg.onOutputRecoveryFailed copy]]; + } + } + if (blocks.count == 0) { + return; + } + + // Invoke after the engine lock is released so player cleanup can detach nodes. + dispatch_async(dispatch_get_main_queue(), ^{ + for (OnOutputRecoveryFailedBlock onOutputRecoveryFailed in blocks) { + onOutputRecoveryFailed(); + } + }); +} + - (void)materializeSourceNodeWithId:(NSString *)sourceNodeId { AudioEngineSourceRegistration *registration = self.sourceRegistrations[sourceNodeId]; @@ -298,6 +319,7 @@ - (void)materializeTrackedNodesIfNeeded - (NSString *)attachSourceNodeWithRenderBlock:(AVAudioSourceNodeRenderBlock)renderBlock sampleRate:(float)sampleRate channelCount:(AVAudioChannelCount)channelCount + onOutputRecoveryFailed:(OnOutputRecoveryFailedBlock)onOutputRecoveryFailed { std::scoped_lock lock(_engineLock); [self createAudioEngineIfNeeded]; @@ -307,6 +329,7 @@ - (NSString *)attachSourceNodeWithRenderBlock:(AVAudioSourceNodeRenderBlock)rend registration.renderBlock = renderBlock; registration.sampleRate = sampleRate; registration.channelCount = channelCount; + registration.onOutputRecoveryFailed = onOutputRecoveryFailed; self.sourceRegistrations[sourceNodeId] = registration; [self materializeSourceNodeWithId:sourceNodeId]; @@ -456,6 +479,9 @@ - (void)onInterruptionEnd:(bool)shouldResume @"Error while restarting the audio engine after interruption: %@", [error debugDescription]); self.state = AudioEngineState::AudioEngineStateIdle; + + [self notifyOutputRecoveryFailed]; + [self notifyConfigurationChanges]; return; } @@ -506,7 +532,10 @@ - (void)rebuildAudioEngineAndResumeIfNeeded self.sessionDeactivationInvalidatedGraph = false; if (self.state == AudioEngineState::AudioEngineStateRunning) { - [self startEngine]; + if (![self startEngine]) { + self.state = AudioEngineState::AudioEngineStateIdle; + [self notifyOutputRecoveryFailed]; + } } [self notifyConfigurationChanges]; diff --git a/packages/react-native-audio-api/src/core/AudioContext.ts b/packages/react-native-audio-api/src/core/AudioContext.ts index f0b075bda..8779bae01 100644 --- a/packages/react-native-audio-api/src/core/AudioContext.ts +++ b/packages/react-native-audio-api/src/core/AudioContext.ts @@ -1,6 +1,7 @@ import { InvalidStateError } from '../errors'; import { assertSupportedSampleRate } from '../utils/validation'; import { AudioTagHandle } from '../Audio/types'; +import { AudioEventEmitter } from '../events'; import { IAudioContext } from '../jsi-interfaces'; import AudioManager from '../system'; import { AudioContextOptions, ContextState } from '../types'; @@ -8,6 +9,12 @@ import BaseAudioContext from './BaseAudioContext'; import MediaElementAudioSourceNode from './MediaElementAudioSourceNode'; export default class AudioContext extends BaseAudioContext { + public onerror: (() => void) | null = null; + + private readonly errorSubscription: ReturnType< + AudioEventEmitter['addAudioEventListener'] + >; + constructor(options?: AudioContextOptions) { if (options?.sampleRate != null) { assertSupportedSampleRate(options.sampleRate); @@ -18,6 +25,13 @@ export default class AudioContext extends BaseAudioContext { options?.sampleRate || AudioManager.getDevicePreferredSampleRate() ) ); + + this.errorSubscription = this.audioEventEmitter.addAudioEventListener( + 'contextError', + () => this.onerror?.() + ); + (this.context as IAudioContext).onerror = + this.errorSubscription.subscriptionId; } public get baseLatency(): number { diff --git a/packages/react-native-audio-api/src/events/types.ts b/packages/react-native-audio-api/src/events/types.ts index c025a1aef..c3114e931 100644 --- a/packages/react-native-audio-api/src/events/types.ts +++ b/packages/react-native-audio-api/src/events/types.ts @@ -82,6 +82,7 @@ interface AudioAPIEvents { /** `value` is true while an `