diff --git a/.claude/skills/thread-safety-itc/SKILL.md b/.claude/skills/thread-safety-itc/SKILL.md index 83a029480..c173a474e 100644 --- a/.claude/skills/thread-safety-itc/SKILL.md +++ b/.claude/skills/thread-safety-itc/SKILL.md @@ -166,7 +166,9 @@ Control-plane synchronization uses two layers — both are non-recursive `std::m On Android, `AudioPlayer::onErrorAfterClose` also takes `driverMutex_` because Oboe error callbacks bypass `AudioContext`. -**Live `AudioContext` render quiescence:** `currentRenders_` on `AudioContext` is incremented at the start of each platform I/O callback (`IOSAudioPlayer::deliverOutputBuffers` / `AudioPlayer::onAudioReady`) via a reference passed in `initialize()`, and decremented when the callback returns (RAII scope). `suspend()` and `close()` call `waitForRenderQuiescence()` (under `driverMutex_`) before `processAudioEvents()` / `cleanup()`. Platform drivers share the `CommonPlayer` abstract base (`common/cpp/audioapi/core/CommonPlayer.h`). +**Live `AudioContext` render quiescence:** `currentRenders_` on `AudioContext` is incremented at the start of each platform I/O callback (`IOSAudioPlayer::deliverOutputBuffers` / `AudioPlayer::onAudioReady`) via a reference passed in `initialize()`, and decremented when the callback returns (RAII scope). `suspend()` and `close()` call `waitForRenderQuiescence()` (under `driverMutex_`) before `processAudioEvents()` / `cleanup()`. `NodeAudioPlayer` (WPT harness) receives the same reference but never opens a render scope. + +**Player base class:** all three drivers (`AudioPlayer` on Android, `IOSAudioPlayer`, `NodeAudioPlayer` in `wpt_tests/src/`) derive from `CommonPlayer` (`common/cpp/audioapi/core/CommonPlayer.h`). It owns the shared driver state as protected members: `renderAudio_`, `renderBuffer_` (one render quantum, allocated once in the constructor), `sampleRate_`, `channelCount_`, `currentRenders_`, `context_`, `driverMutex_`, and `isRunning_`. It also takes one constructor signature for every platform, so `AudioContext::initialize()` builds a single `PlatformAudioPlayer` alias. Render through `renderNormalizedQuantum()`, which keeps the output peak-limiting in the player so offline renders are never limited. `AudioPlayerTests.mm` (fabric-example XCTest) hand-copies `IOSAudioPlayer`'s member layout to reach its private members, so any change to `IOSAudioPlayer`'s data members must also be made there. **Graph producer self-drain:** `Graph::enableProducerSelfDrain()` makes producer threads drain the event channels themselves after each enqueue; `disableProducerSelfDrain()` hands consumption back to the audio/render thread. Both flush the channels internally (no separate `processEvents()` call needed) and serialize with in-flight drains via `selfDrainMutex_`, because two producers can drain concurrently: the JS thread (mutations) and the GC finalizer thread (`removeNode`, which self-drains after its Channel B orphan send — otherwise a finalizer burst with no consumer fills the bounded channel and blocks forever, e.g. at process exit). Enable only when there is no audio/render consumer (realtime: construction + after `suspend`/`close` quiescence; offline: before `startRendering` and after a scheduled suspend); disable *before* starting the audio/render consumer; re-enable if start/resume fails. 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..a0f16ee62 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 @@ -25,7 +26,9 @@ 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; @@ -40,13 +43,7 @@ [[nodiscard]] double getOutputLatency() const override; protected: - std::shared_ptr audioBuffer_; NativeAudioPlayer *audioPlayer_; - float sampleRate_; - std::function renderAudio_; - std::atomic ¤tRenders_; - int channelCount_; - std::atomic isRunning_; std::atomic flushOverflowNextPull_; int pendingSavedCount_; DSPAudioBuffer pendingSaved_; @@ -159,6 +156,7 @@ - (void)stopIfPossible - (NSString *)attachSourceNodeWithRenderBlock:(AVAudioSourceNodeRenderBlock)renderBlock sampleRate:(float)sampleRate channelCount:(AVAudioChannelCount)channelCount + onOutputRecoveryFailed:(OnOutputRecoveryFailedBlock)onOutputRecoveryFailed { self.attachSourceNodeCallCount += 1; self.lastAttachedRenderBlock = renderBlock; @@ -235,7 +233,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_; @@ -248,7 +252,7 @@ - (void)cleanup } std::shared_ptr getAudioBuffer() const { - return audioBuffer_; + return renderBuffer_; } void setRunning(bool isRunning) { 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..d03a04a2b 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 @@ -4,30 +4,17 @@ #include #include #include +#include #include #include +#include #include #include namespace audioapi { -AudioPlayer::AudioPlayer( - const std::function &renderAudio, - float sampleRate, - int channelCount, - std::mutex *driverMutex, - const std::shared_ptr &context, - std::atomic ¤tRenders) - : renderAudio_(renderAudio), - currentRenders_(currentRenders), - sampleRate_(sampleRate), - channelCount_(channelCount), - isRunning_(false), - driverMutex_(driverMutex), - context_(context) {} - bool AudioPlayer::openAudioStream() { std::scoped_lock lock(streamMutex_); AudioStreamBuilder builder; @@ -50,17 +37,20 @@ bool AudioPlayer::openAudioStream() { return false; } - buffer_ = std::make_shared(RENDER_QUANTUM_SIZE, channelCount_, sampleRate_); isInitialized_.store(true, std::memory_order_release); 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; - } + + if ((!isInitialized_.load(std::memory_order_acquire)) && (!openAudioStream())) { + return false; } if (mStream_ != nullptr) { @@ -87,6 +77,11 @@ bool AudioPlayer::resume() { return true; } + // The stream may have been dropped by onErrorAfterClose while suspended. + if ((!isInitialized_.load(std::memory_order_acquire)) && (!openAudioStream())) { + return false; + } + if (mStream_ != nullptr) { auto result = mStream_->requestStart() == oboe::Result::OK; isRunning_.store(result, std::memory_order_release); @@ -139,34 +134,66 @@ AudioPlayer::onAudioReady(AudioStream *oboeStream, void *audioData, int32_t numF auto framesToProcess = std::min(numFrames - processedFrames, RENDER_QUANTUM_SIZE); if (isRunning_.load(std::memory_order_acquire)) { - renderAudio_(buffer_.get(), framesToProcess); - // Peak-normalize the rendered quantum before it reaches the hardware. - // This limiting lives in the player (not the destination node) so - // offline renders stay spec-accurate. - buffer_->normalize(); + renderNormalizedQuantum(framesToProcess); } else { - buffer_->zero(); + renderBuffer_->zero(); } - float *destination = buffer + (processedFrames * channelCount_); + float *destination = buffer + (static_cast(processedFrames * channelCount_)); - buffer_->interleaveTo(destination, framesToProcess); + renderBuffer_->interleaveTo(destination, framesToProcess); processedFrames += framesToProcess; } return DataCallbackResult::Continue; } +namespace { +struct ReentrancyGuard { + DELETE_COPY_AND_MOVE(ReentrancyGuard); + explicit ReentrancyGuard(bool *f) : flag(f) { + *flag = true; + } + ~ReentrancyGuard() { + *flag = false; + } + + private: + bool *flag; +}; +} // 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); + + auto context = context_.lock(); + if (context == nullptr) { return; } // Serialize with start()/resume()/suspend()/close() on the JS / promise-pool threads. std::scoped_lock lock(*driverMutex_, streamMutex_); - auto context = context_.lock(); - if (context == nullptr || context->isClosed()) { + if (context->isClosed()) { return; } @@ -176,10 +203,30 @@ 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); + + // Best effort rebuild; a suspended context keeps the rebuilt stream paused until resume(). + if (error == oboe::Result::ErrorDisconnected && rebuildStream()) { + if (!wasRunning) { + return; + } + if (mStream_->requestStart() == oboe::Result::OK) { + isRunning_.store(true, std::memory_order_release); + return; + } + } + + isRunning_.store(false, std::memory_order_release); + + if (!wasRunning) { + // Nothing was playing, so there is no failure to report: drop the dead stream + // and let resume() open a new one. + cleanup(); + return; } + + context->onStreamFail(); } double AudioPlayer::getBaseLatency() const { 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..e67939ede 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 @@ -5,31 +5,21 @@ #include #include #include -#include #include #include #include -#include namespace audioapi { using namespace oboe; -class AudioContext; - class AudioPlayer : public CommonPlayer, public AudioStreamDataCallback, public AudioStreamErrorCallback, public std::enable_shared_from_this { public: - AudioPlayer( - const std::function &renderAudio, - float sampleRate, - int channelCount, - std::mutex *driverMutex, - const std::shared_ptr &context, - std::atomic ¤tRenders); + using CommonPlayer::CommonPlayer; ~AudioPlayer() override { cleanup(); @@ -54,21 +44,14 @@ class AudioPlayer : public CommonPlayer, void onErrorAfterClose(AudioStream *audioStream, Result error) override; private: - std::function renderAudio_; - std::atomic ¤tRenders_; std::shared_ptr mStream_; mutable std::recursive_mutex streamMutex_; - std::shared_ptr buffer_; std::atomic isInitialized_{false}; - float sampleRate_; - int channelCount_; - std::atomic isRunning_; /// Updated on the audio thread from each Oboe callback `numFrames`. std::atomic lastCallbackFrameCount_{0}; - std::mutex *driverMutex_; - 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..95d2a8954 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,26 @@ #include #include + #include -#include #include namespace audioapi { + +#ifdef RN_AUDIO_API_NODE +using PlatformAudioPlayer = NodeAudioPlayer; +#elif defined(ANDROID) +using PlatformAudioPlayer = AudioPlayer; +#else +using PlatformAudioPlayer = IOSAudioPlayer; +#endif + 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). @@ -33,26 +44,13 @@ AudioContext::~AudioContext() { void AudioContext::initialize(const AudioDestinationNode *destination) { BaseAudioContext::initialize(destination); -#ifdef RN_AUDIO_API_NODE - audioPlayer_ = std::make_shared( - [this](DSPAudioBuffer *buf, int n) { processGraph(buf, n); }, - getSampleRate(), - destination_->getChannelCount()); -#elif defined(ANDROID) - audioPlayer_ = std::make_shared( + audioPlayer_ = std::make_shared( [this](DSPAudioBuffer *buf, int n) { processGraph(buf, n); }, getSampleRate(), destination_->getChannelCount(), - &driverMutex_, + currentRenders_, std::static_pointer_cast(shared_from_this()), - currentRenders_); -#else - audioPlayer_ = std::make_shared( - [this](DSPAudioBuffer *buf, int n) { processGraph(buf, n); }, - getSampleRate(), - destination_->getChannelCount(), - currentRenders_); -#endif + &driverMutex_); } bool AudioContext::tryStartDriver() { @@ -92,6 +90,8 @@ void AudioContext::close(const std::shared_ptr> &pr // No audio-thread consumer after stop; allow producer self-drain for any // remaining graph mutations (and flush events already queued). getGraph()->enableProducerSelfDrain(); + + // safe to call because the promise worker holds the driver mutex while calling close() processAudioEvents(); audioPlayer_->cleanup(); @@ -188,4 +188,25 @@ double AudioContext::getOutputLatency() const { return audioPlayer_->getOutputLatency(); } +void AudioContext::assignOnErrorCallbackId(uint64_t callbackId) { + onErrorEvent_.assignCallbackId(callbackId); +} + +void AudioContext::onStreamFail() { + assertDriverMutexHeld(); + + audioPlayer_->stop(); + waitForRenderQuiescence(); + + // The failed driver was the only consumer of the graph channels. + getGraph()->enableProducerSelfDrain(); + + // safe to call because the driver mutex is held + processAudioEvents(); + + 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/core/CommonPlayer.h b/packages/react-native-audio-api/common/cpp/audioapi/core/CommonPlayer.h index 632e9300c..eeecc9099 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/CommonPlayer.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/CommonPlayer.h @@ -1,12 +1,37 @@ #pragma once +#include +#include #include +#include +#include +#include +#include +#include +#include + namespace audioapi { +class AudioContext; + class CommonPlayer { public: - CommonPlayer() = default; + CommonPlayer( + const std::function &renderAudio, + float sampleRate, + int channelCount, + std::atomic ¤tRenders, + std::weak_ptr context, + std::mutex *driverMutex) + : renderAudio_(renderAudio), + renderBuffer_( + std::make_shared(RENDER_QUANTUM_SIZE, channelCount, sampleRate)), + sampleRate_(sampleRate), + channelCount_(channelCount), + currentRenders_(currentRenders), + context_(std::move(context)), + driverMutex_(driverMutex) {} DELETE_COPY_AND_MOVE(CommonPlayer); virtual ~CommonPlayer() = default; @@ -20,6 +45,26 @@ class CommonPlayer { [[nodiscard]] virtual double getOutputLatency() const = 0; [[nodiscard]] virtual bool isRunning() const = 0; + + protected: + /// @brief Pulls `framesToProcess` frames from the graph into `renderBuffer_` and peak-normalizes + /// them. + /// @note Audio Thread only. The limiting lives in the player (not the destination node) so + /// offline renders stay spec-accurate. + void renderNormalizedQuantum(int framesToProcess) { + renderAudio_(renderBuffer_.get(), framesToProcess); + renderBuffer_->normalize(); + } + + std::function renderAudio_; + std::shared_ptr renderBuffer_; + float sampleRate_; + int channelCount_; + std::atomic ¤tRenders_; + std::weak_ptr context_; + /// The owning context's driver mutex; `nullptr` disables stream-failure reporting. + std::mutex *driverMutex_; + std::atomic isRunning_{false}; }; } // namespace audioapi 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/audioapi/events/DeferredEventQueue.hpp b/packages/react-native-audio-api/common/cpp/audioapi/events/DeferredEventQueue.hpp index 429ad0cb3..5f3fdbba0 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/events/DeferredEventQueue.hpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/events/DeferredEventQueue.hpp @@ -23,8 +23,9 @@ namespace audioapi { /// never a reference to the emitter; a dispatch whose handler has since been /// unregistered is dropped by the registry on the JS thread. /// -/// @note Render-serialized only (audio thread, or the synchronous -/// `scheduleAudioEvent` path) — no lock, so no other thread may touch it. +/// @note Render-serialized only — no lock of its own. Touched by the audio thread, +/// or by any thread that holds `driverMutex_` while the driver is stopped and +/// quiescent (the synchronous `scheduleAudioEvent` path, `AudioContext::close()`). class DeferredEventQueue { public: explicit DeferredEventQueue(std::shared_ptr registry) diff --git a/packages/react-native-audio-api/common/cpp/audioapi/jsi/ContextPromiseResolver.cpp b/packages/react-native-audio-api/common/cpp/audioapi/jsi/ContextPromiseResolver.cpp index 0295f32db..7e724cc76 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/jsi/ContextPromiseResolver.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/jsi/ContextPromiseResolver.cpp @@ -21,7 +21,7 @@ std::shared_ptr> ContextPromiseResolver::makeCon { auto jsiPromise = std::make_shared(std::move(promise)); return std::make_shared>( - [jsiPromise, audioContext, nextState]() { + [jsiPromise, audioContext, nextState] { // Spec: update the state attribute in the same follow-up task that // resolves the lifecycle promise (before statechange reactions). audioContext->setState(nextState); 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..7162a201c 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 @@ -12,11 +12,12 @@ typedef struct objc_object AudioBufferList; #include #include -#include +#include #include -namespace audioapi { +#include +#include -class AudioContext; +namespace audioapi { class IOSAudioPlayer : public CommonPlayer { public: @@ -24,7 +25,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); @@ -48,13 +51,7 @@ class IOSAudioPlayer : public CommonPlayer { /// is kept (max 128 frames) and played at the start of the next callback. void deliverOutputBuffers(AudioBufferList *outputData, int numFrames); - std::shared_ptr audioBuffer_; NativeAudioPlayer *audioPlayer_; - float sampleRate_; - std::function renderAudio_; - std::atomic ¤tRenders_; - int channelCount_; - std::atomic isRunning_; /// Set from main thread on start/resume; consumed on audio thread to drop stale pending audio. std::atomic flushOverflowNextPull_{false}; /// Frames valid at the front of each `pendingSaved_[ch]` (0 … RENDER_QUANTUM_SIZE). 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..6eb862e92 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,21 +12,47 @@ #include #include +#include + namespace audioapi { +namespace { + +void reportStreamFailToContext( + std::mutex *driverMutex, + const std::weak_ptr &context, + const std::function &isStillFailed) +{ + auto ctx = context.lock(); + if (driverMutex == nullptr || ctx == nullptr) { + return; + } + + std::scoped_lock lock(*driverMutex); + if (ctx->isClosed() || !isStillFailed()) { + return; + } + ctx->onStreamFail(); +} + +} // namespace + IOSAudioPlayer::IOSAudioPlayer( const std::function &renderAudio, float sampleRate, int channelCount, - std::atomic ¤tRenders) - : audioBuffer_(nullptr), + std::atomic ¤tRenders, + std::weak_ptr context, + std::mutex *driverMutex) + : CommonPlayer( + renderAudio, + sampleRate, + channelCount, + currentRenders, + std::move(context), + driverMutex), audioPlayer_(nullptr), - renderAudio_(renderAudio), - sampleRate_(sampleRate), - currentRenders_(currentRenders), - channelCount_(channelCount), - isRunning_(false), - pendingSaved_(RENDER_QUANTUM_SIZE, channelCount_, sampleRate) + pendingSaved_(RENDER_QUANTUM_SIZE, channelCount, sampleRate) { RenderAudioBlock renderAudioBlock = ^(AudioBufferList *outputData, int numFrames) { deliverOutputBuffers(outputData, numFrames); @@ -34,7 +61,16 @@ audioPlayer_ = [[NativeAudioPlayer alloc] initWithRenderAudio:renderAudioBlock sampleRate:sampleRate channelCount:channelCount_]; - audioBuffer_ = std::make_shared(RENDER_QUANTUM_SIZE, channelCount_, sampleRate); + + std::mutex *driverMutexForCallback = driverMutex_; + std::weak_ptr weakContext = context_; + IOSAudioPlayer *player = this; + audioPlayer_.onStreamFail = ^{ + // Called only once the context, which owns this player, is locked alive. + reportStreamFailToContext(driverMutexForCallback, weakContext, [player] { + return player->isRunning_.load(std::memory_order_acquire) && !player->isRunning(); + }); + }; } IOSAudioPlayer::~IOSAudioPlayer() @@ -92,18 +128,13 @@ continue; } - renderAudio_(audioBuffer_.get(), RENDER_QUANTUM_SIZE); - - // Peak-normalize the rendered quantum before it reaches the hardware. This - // limiting lives in the player (not the destination node) so offline - // renders stay spec-accurate. - audioBuffer_->normalize(); + renderNormalizedQuantum(RENDER_QUANTUM_SIZE); // normal rendering - take RENDER_QUANTUM_SIZE frames from the graph and copy to output const int stillNeed = numFrames - outPos; if (stillNeed >= RENDER_QUANTUM_SIZE) { for (int ch = 0; ch < channelCount_; ++ch) { - auto *src = (*audioBuffer_)[ch].begin(); + auto *src = (*renderBuffer_)[ch].begin(); float *dst = static_cast(outputData->mBuffers[ch].mData) + outPos; std::memcpy(dst, src, RENDER_QUANTUM_SIZE * sizeof(float)); } @@ -112,11 +143,11 @@ // when output will be sliced, copy the remaining frames to pendingSaved const int tail = RENDER_QUANTUM_SIZE - stillNeed; for (int ch = 0; ch < channelCount_; ++ch) { - auto *src = (*audioBuffer_)[ch].begin(); + auto *src = (*renderBuffer_)[ch].begin(); float *dst = static_cast(outputData->mBuffers[ch].mData) + outPos; std::memcpy(dst, src, stillNeed * sizeof(float)); } - pendingSaved_.copy(*audioBuffer_, stillNeed, 0, tail); + pendingSaved_.copy(*renderBuffer_, stillNeed, 0, tail); pendingSavedCount_ = tail; outPos += stillNeed; } @@ -175,7 +206,7 @@ { stop(); [audioPlayer_ cleanup]; - audioBuffer_ = nullptr; + renderBuffer_ = nullptr; } double IOSAudioPlayer::getBaseLatency() const 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..d69973851 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 { @@ -33,9 +47,11 @@ export default class AudioContext extends BaseAudioContext { throw new InvalidStateError('Cannot close a closed audio context.'); } - return this.transitionTo('closed', () => + await this.transitionTo('closed', () => (this.context as IAudioContext).close() ); + this.errorSubscription.remove(); + return undefined; } async resume(): Promise { 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 `