diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.cpp index 54affc0a2..374122b46 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.cpp @@ -19,6 +19,7 @@ BaseAudioContext::BaseAudioContext( : state_(ContextState::SUSPENDED), sampleRate_(sampleRate), audioEventHandlerRegistry_(audioEventHandlerRegistry), + audioEventProducer_(audioEventHandlerRegistry->createAudioEventProducer()), stateChangeEvent_(audioEventHandlerRegistry), pendingPromisesOffloader_( std::make_unique>(AUDIO_SCHEDULER_CAPACITY)), graph_(std::make_shared(AUDIO_SCHEDULER_CAPACITY, disposer_.get())), - deferredEvents_(audioEventHandlerRegistry) {} + deferredEvents_(audioEventHandlerRegistry, audioEventProducer_) {} void BaseAudioContext::initialize(const AudioDestinationNode *destination) { destination_ = destination; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.h b/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.h index 2c3f9d173..a76384631 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.h @@ -99,6 +99,13 @@ class BaseAudioContext : public std::enable_shared_from_this { return deferredEvents_; } + /// @brief This context's dispatch lane, for nodes that emit events from the render thread. + /// Every node of a context shares it because they all render on that one thread; a node of + /// another context must never use it. + [[nodiscard]] std::shared_ptr getAudioEventProducer() const { + return audioEventProducer_; + } + template bool scheduleAudioEvent(F &&event) noexcept { // NOLINT(cppcoreguidelines-missing-std-forward) std::scoped_lock lock(driverMutex_); @@ -176,6 +183,8 @@ class BaseAudioContext : public std::enable_shared_from_this { private: std::atomic sampleRate_; std::shared_ptr audioEventHandlerRegistry_; + /// context's own lane into the registry's dispatch queue, shared with every node it owns. + std::shared_ptr audioEventProducer_; EventCaller stateChangeEvent_; /// Ledger backing dispatchStateChange()'s dedupe; contexts start suspended. diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferBaseSourceNode.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferBaseSourceNode.cpp index ed3c56639..3848e4c79 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferBaseSourceNode.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferBaseSourceNode.cpp @@ -38,6 +38,7 @@ AudioBufferBaseSourceNode::AudioBufferBaseSourceNode( detuneParam_)), positionChanged_( context->getAudioEventHandlerRegistry(), + context->getAudioEventProducer(), static_cast(context->getSampleRate())) { setOnPositionChangedInterval(options.onPositionChangedInterval); } diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferQueueSourceNode.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferQueueSourceNode.cpp index 5bda58098..110f0e667 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferQueueSourceNode.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferQueueSourceNode.cpp @@ -19,7 +19,9 @@ AudioBufferQueueSourceNode::AudioBufferQueueSourceNode( const std::shared_ptr &context, const BaseAudioBufferSourceOptions &options) : AudioBufferBaseSourceNode(context, options), - onBufferEndedEvent_(context->getAudioEventHandlerRegistry()) { + onBufferEndedEvent_( + context->getAudioEventHandlerRegistry(), + context->getAudioEventProducer()) { if (options.pitchCorrection) { // If pitch correction is enabled, add extra frames at the end // to compensate for processing latency. diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferSourceNode.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferSourceNode.cpp index 24dbf20f6..83ee3566c 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferSourceNode.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferSourceNode.cpp @@ -24,7 +24,7 @@ AudioBufferSourceNode::AudioBufferSourceNode( loopSkip_(options.loopSkip), loopStart_(options.loopStart), loopEnd_(options.loopEnd), - onLoopEndedEvent_(context->getAudioEventHandlerRegistry()) { + onLoopEndedEvent_(context->getAudioEventHandlerRegistry(), context->getAudioEventProducer()) { auto onLoopEnded = [this]() { sendOnLoopEndedEvent(); }; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioFileSourceNode.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioFileSourceNode.cpp index e3b623f29..494e59c39 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioFileSourceNode.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioFileSourceNode.cpp @@ -33,10 +33,12 @@ AudioFileSourceNode::AudioFileSourceNode( targetPlaybackRate_(options.playbackRate), positionChanged_( context->getAudioEventHandlerRegistry(), + context->getAudioEventProducer(), static_cast(context->getSampleRate() * ON_POSITION_CHANGED_INTERVAL), true), bufferingStateDispatcher_( context->getAudioEventHandlerRegistry(), + context->getAudioEventProducer(), static_cast(context->getSampleRate() * ON_BUFFERING_STATE_DEBOUNCE_INTERVAL)) { decoderState_->playbackRate.store(options.playbackRate, std::memory_order_release); decoderState_->preservesPitch.store(options.preservesPitch, std::memory_order_release); diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioScheduledSourceNode.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioScheduledSourceNode.cpp index f768d6b15..705d30a0c 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioScheduledSourceNode.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioScheduledSourceNode.cpp @@ -21,7 +21,7 @@ AudioScheduledSourceNode::AudioScheduledSourceNode( startTime_(-1.0), stopTime_(-1.0), playbackState_(PlaybackState::UNSCHEDULED), - onEndedEvent_(context->getAudioEventHandlerRegistry()) {} + onEndedEvent_(context->getAudioEventHandlerRegistry(), context->getAudioEventProducer()) {} void AudioScheduledSourceNode::start(double when) { playbackState_ = PlaybackState::SCHEDULED; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/events/AudioEventHandlerRegistry.cpp b/packages/react-native-audio-api/common/cpp/audioapi/events/AudioEventHandlerRegistry.cpp index 639dc7e84..c3bb9f441 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/events/AudioEventHandlerRegistry.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/events/AudioEventHandlerRegistry.cpp @@ -11,10 +11,7 @@ namespace audioapi { AudioEventHandlerRegistry::AudioEventHandlerRegistry( jsi::Runtime *runtime, const std::shared_ptr &callInvoker) - : callInvoker_(callInvoker), - runtime_(runtime), - dispatchQueue_(kDispatchCapacity), - audioProducerToken_(dispatchQueue_) { + : callInvoker_(callInvoker), runtime_(runtime), dispatchQueue_(kDispatchCapacity) { // Dispatch worker: wait for an item, dequeue it, then hop to the JS thread. workerThread_ = std::thread([this]() { while (true) { @@ -86,7 +83,12 @@ bool AudioEventHandlerRegistry::dispatchEvent( return true; } +std::shared_ptr AudioEventHandlerRegistry::createAudioEventProducer() { + return std::make_shared(dispatchQueue_); +} + bool AudioEventHandlerRegistry::dispatchEventFromAudioThread( + AudioEventProducer &producer, AudioEvent eventName, uint64_t listenerId, AudioEventPayload &&payload) noexcept { @@ -94,7 +96,7 @@ bool AudioEventHandlerRegistry::dispatchEventFromAudioThread( return false; } if (!dispatchQueue_.try_enqueue( - audioProducerToken_, + producer.token(), DispatchEvent{ .event = eventName, .listenerId = listenerId, .payload = std::move(payload)})) { return false; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/events/AudioEventHandlerRegistry.h b/packages/react-native-audio-api/common/cpp/audioapi/events/AudioEventHandlerRegistry.h index cd961ff18..af09bd71f 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/events/AudioEventHandlerRegistry.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/events/AudioEventHandlerRegistry.h @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -27,8 +28,8 @@ using namespace facebook; /// Both entry points enqueue a DispatchEvent and signal itemsAvailable_: /// /// - dispatchEventFromAudioThread() — real-time audio thread (e.g. `processNode()`). -/// Uses a pre-created moodycamel ProducerToken so try_enqueue() is wait-free and never -/// allocates. Drops the event if the queue is full. +/// Enqueues through the caller's own AudioEventProducer so try_enqueue() is wait-free and +/// never allocates. Drops the event if the queue is full. /// /// - dispatchEvent() — any other (non-RT) thread (worker, platform/JNI callbacks, recorder /// cleanup, AudioAPIModule, etc.). Uses the implicit (multi-producer) enqueue path. @@ -57,9 +58,12 @@ class AudioEventHandlerRegistry : public IAudioEventHandlerRegistry, uint64_t listenerId, AudioEventPayload &&payload) noexcept override; + std::shared_ptr createAudioEventProducer() override; + /// @brief Enqueue an event from the real-time audio thread. /// Wait-free and allocation-free on the calling thread; drops when the queue is full. bool dispatchEventFromAudioThread( + AudioEventProducer &producer, AudioEvent eventName, uint64_t listenerId, AudioEventPayload &&payload) noexcept override; @@ -84,12 +88,9 @@ class AudioEventHandlerRegistry : public IAudioEventHandlerRegistry, std::unordered_map>> eventHandlers_; - // Single producer-to-consumer channel for every thread. Declared before - // audioProducerToken_ so the token can bind to it during construction. + // Single producer-to-consumer channel for every thread. Audio threads bind their own + // AudioEventProducer to it; every other thread uses the implicit-producer path. moodycamel::ConcurrentQueue dispatchQueue_; - // Dedicated token for the audio thread; lets it enqueue without the implicit-producer - // lookup/allocation that the first enqueue from a new thread would otherwise trigger. - moodycamel::ProducerToken audioProducerToken_; // Counts queued items; workerThread_ waits on it instead of busy-spinning. moodycamel::LightweightSemaphore itemsAvailable_; std::atomic isExiting_{false}; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/events/AudioEventProducer.h b/packages/react-native-audio-api/common/cpp/audioapi/events/AudioEventProducer.h new file mode 100644 index 000000000..bb19407aa --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/audioapi/events/AudioEventProducer.h @@ -0,0 +1,35 @@ +#pragma once + +#include +#include + +namespace audioapi { + +/// @brief One audio thread's private lane into the event registry's dispatch queue. +/// +/// A moodycamel ProducerToken is single-producer by construction: do not share tokens between threads. +/// +/// every context owns a producer and hands it to +/// IAudioEventHandlerRegistry::dispatchEventFromAudioThread. +/// +/// @note A producer may move between threads over time — an OfflineAudioContext spawns a +/// fresh render thread on every resume — but never concurrently +class AudioEventProducer { + public: + /// @param queue The registry's dispatch queue; the token binds to it for its whole life. + template + explicit AudioEventProducer(TQueue &queue) : token_(queue) {} + + ~AudioEventProducer() = default; + + DELETE_COPY_AND_MOVE(AudioEventProducer); + + [[nodiscard]] moodycamel::ProducerToken &token() noexcept { + return token_; + } + + private: + moodycamel::ProducerToken token_; +}; + +} // namespace audioapi 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..d411d8411 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 @@ -27,8 +27,12 @@ namespace audioapi { /// `scheduleAudioEvent` path) — no lock, so no other thread may touch it. class DeferredEventQueue { public: - explicit DeferredEventQueue(std::shared_ptr registry) - : registry_(std::move(registry)) {} + /// @param audioEventProducer The owning context's dispatch lane — `dispatchDue` always runs + /// on that context's render thread. + DeferredEventQueue( + std::shared_ptr registry, + std::shared_ptr audioEventProducer) + : registry_(std::move(registry)), audioEventProducer_(std::move(audioEventProducer)) {} /// @brief Queues @p event for dispatch once the clock reaches @p dueTime. /// @return False when there is nothing to queue (@p callbackId unset) or no @@ -74,16 +78,20 @@ class DeferredEventQueue { }; void dispatch(const DeferredEvent &deferred) const { - if (registry_ == nullptr) { + if (registry_ == nullptr || audioEventProducer_ == nullptr) { return; } registry_->dispatchEventFromAudioThread( - deferred.event, deferred.callbackId, AudioEventPayload{EmptyPayload{}}); + *audioEventProducer_, + deferred.event, + deferred.callbackId, + AudioEventPayload{EmptyPayload{}}); } BoundedPriorityQueue pending_; std::shared_ptr registry_; + std::shared_ptr audioEventProducer_; }; } // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/audioapi/events/EventCaller.hpp b/packages/react-native-audio-api/common/cpp/audioapi/events/EventCaller.hpp index 405bcd92a..2ad64eed2 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/events/EventCaller.hpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/events/EventCaller.hpp @@ -20,6 +20,15 @@ class EventCaller { explicit EventCaller(const std::shared_ptr &audioEventHandlerRegistry) : eventHandlerRegistry_(audioEventHandlerRegistry) {} + /// @param audioEventProducer The owning context's dispatch lane. Required to dispatch from + /// the audio thread; without it `dispatchFromAudioThread` reports failure instead of + /// enqueueing through a lane that belongs to another thread. + EventCaller( + const std::shared_ptr &audioEventHandlerRegistry, + std::shared_ptr audioEventProducer) + : eventHandlerRegistry_(audioEventHandlerRegistry), + audioEventProducer_(std::move(audioEventProducer)) {} + ~EventCaller() { unregisterCallback(); } @@ -91,16 +100,18 @@ class EventCaller { requires EventPayloadFor bool dispatchFromAudioThread(Payload &&payload) const noexcept { const auto callbackId = getCallbackId(); - if (eventHandlerRegistry_ == nullptr || callbackId == 0) { + if (eventHandlerRegistry_ == nullptr || audioEventProducer_ == nullptr || callbackId == 0) { return false; } return eventHandlerRegistry_->dispatchEventFromAudioThread( - Event, callbackId, AudioEventPayload{std::forward(payload)}); + *audioEventProducer_, Event, callbackId, AudioEventPayload{std::forward(payload)}); } private: std::shared_ptr eventHandlerRegistry_; + /// Null for events only ever dispatched NOT on the audio thread + std::shared_ptr audioEventProducer_; std::atomic callbackId_{0}; }; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/events/IAudioEventHandlerRegistry.h b/packages/react-native-audio-api/common/cpp/audioapi/events/IAudioEventHandlerRegistry.h index 933eef0ea..7fac46615 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/events/IAudioEventHandlerRegistry.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/events/IAudioEventHandlerRegistry.h @@ -13,6 +13,8 @@ namespace audioapi { +class AudioEventProducer; + class IAudioEventHandlerRegistry { public: IAudioEventHandlerRegistry() = default; @@ -31,7 +33,14 @@ class IAudioEventHandlerRegistry { uint64_t listenerId, AudioEventPayload &&payload) noexcept = 0; + /// @brief Creates a dispatch lane for one audio thread. Every owner of an audio + /// thread needs its own — see AudioEventProducer for why sharing one corrupts the queue. + virtual std::shared_ptr createAudioEventProducer() = 0; + + /// @param producer The calling audio thread's own producer, never one shared with + /// another thread. virtual bool dispatchEventFromAudioThread( + AudioEventProducer &producer, AudioEvent eventName, uint64_t listenerId, AudioEventPayload &&payload) noexcept = 0; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/utils/events/BufferingStateDispatcher.cpp b/packages/react-native-audio-api/common/cpp/audioapi/utils/events/BufferingStateDispatcher.cpp index 356b13dc6..c06f9c098 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/utils/events/BufferingStateDispatcher.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/utils/events/BufferingStateDispatcher.cpp @@ -1,13 +1,15 @@ #include #include +#include namespace audioapi { BufferingStateDispatcher::BufferingStateDispatcher( const std::shared_ptr &audioEventHandlerRegistry, + std::shared_ptr audioEventProducer, int startThresholdFrames) - : bufferingStateChangeEvent_(audioEventHandlerRegistry), + : bufferingStateChangeEvent_(audioEventHandlerRegistry, std::move(audioEventProducer)), startThresholdFrames_(startThresholdFrames) {} void BufferingStateDispatcher::assignCallbackId(uint64_t callbackId) noexcept { diff --git a/packages/react-native-audio-api/common/cpp/audioapi/utils/events/BufferingStateDispatcher.h b/packages/react-native-audio-api/common/cpp/audioapi/utils/events/BufferingStateDispatcher.h index 3ababb45b..e099a4932 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/utils/events/BufferingStateDispatcher.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/utils/events/BufferingStateDispatcher.h @@ -16,6 +16,7 @@ class BufferingStateDispatcher { public: BufferingStateDispatcher( const std::shared_ptr &audioEventHandlerRegistry, + std::shared_ptr audioEventProducer, int startThresholdFrames); void assignCallbackId(uint64_t callbackId) noexcept; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/utils/events/PositionChangedDispatcher.cpp b/packages/react-native-audio-api/common/cpp/audioapi/utils/events/PositionChangedDispatcher.cpp index 0bfb857ec..02637eada 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/utils/events/PositionChangedDispatcher.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/utils/events/PositionChangedDispatcher.cpp @@ -3,14 +3,16 @@ #include #include +#include namespace audioapi { PositionChangedDispatcher::PositionChangedDispatcher( const std::shared_ptr &audioEventHandlerRegistry, + std::shared_ptr audioEventProducer, int intervalInFrames, bool shouldFlush) - : positionChangedEvent_(audioEventHandlerRegistry), + : positionChangedEvent_(audioEventHandlerRegistry, std::move(audioEventProducer)), shouldFlush_(shouldFlush), intervalInFrames_(intervalInFrames) {} diff --git a/packages/react-native-audio-api/common/cpp/audioapi/utils/events/PositionChangedDispatcher.h b/packages/react-native-audio-api/common/cpp/audioapi/utils/events/PositionChangedDispatcher.h index 392c899e3..85f3d7d20 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/utils/events/PositionChangedDispatcher.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/utils/events/PositionChangedDispatcher.h @@ -14,6 +14,7 @@ class PositionChangedDispatcher { public: PositionChangedDispatcher( const std::shared_ptr &audioEventHandlerRegistry, + std::shared_ptr audioEventProducer, int intervalInFrames, bool shouldFlush = false); diff --git a/packages/react-native-audio-api/common/cpp/test/src/MockAudioEventHandlerRegistry.h b/packages/react-native-audio-api/common/cpp/test/src/MockAudioEventHandlerRegistry.h index ea71407fb..5cd12cae4 100644 --- a/packages/react-native-audio-api/common/cpp/test/src/MockAudioEventHandlerRegistry.h +++ b/packages/react-native-audio-api/common/cpp/test/src/MockAudioEventHandlerRegistry.h @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -24,6 +25,19 @@ class MockAudioEventHandlerRegistry : public IAudioEventHandlerRegistry { MOCK_METHOD( bool, dispatchEventFromAudioThread, - (AudioEvent eventName, uint64_t listenerId, AudioEventPayload &&payload), + (AudioEventProducer & producer, + AudioEvent eventName, + uint64_t listenerId, + AudioEventPayload &&payload), (noexcept, override)); + + /// Real producers, not mocked: the expectations above never touch the token, but callers + /// refuse to dispatch from the audio thread without one. + std::shared_ptr createAudioEventProducer() override { + return std::make_shared(producerQueue_); + } + + private: + /// Only ever a binding target for the producers handed out above; nothing is enqueued. + moodycamel::ConcurrentQueue producerQueue_; }; diff --git a/packages/react-native-audio-api/common/cpp/test/src/core/sources/AudioScheduledSourceTest.cpp b/packages/react-native-audio-api/common/cpp/test/src/core/sources/AudioScheduledSourceTest.cpp index df746bf2a..7ffabd6ce 100644 --- a/packages/react-native-audio-api/common/cpp/test/src/core/sources/AudioScheduledSourceTest.cpp +++ b/packages/react-native-audio-api/common/cpp/test/src/core/sources/AudioScheduledSourceTest.cpp @@ -142,13 +142,13 @@ TEST_F(AudioScheduledSourceTest, StopBeforeStartFiresEndedWhenContextTimeReaches EXPECT_CALL( *eventRegistry, - dispatchEventFromAudioThread(AudioEvent::ENDED, ENDED_CALLBACK_ID, testing::_)) + dispatchEventFromAudioThread(testing::_, AudioEvent::ENDED, ENDED_CALLBACK_ID, testing::_)) .Times(0); sourceNode.playFrames(RENDER_QUANTUM); // context time is still before the stop time EXPECT_CALL( *eventRegistry, - dispatchEventFromAudioThread(AudioEvent::ENDED, ENDED_CALLBACK_ID, testing::_)) + dispatchEventFromAudioThread(testing::_, AudioEvent::ENDED, ENDED_CALLBACK_ID, testing::_)) .WillOnce(testing::Return(true)); sourceNode.playFrames(RENDER_QUANTUM); // context time reaches the stop time @@ -171,21 +171,24 @@ TEST_F(AudioScheduledSourceTest, DeferredEndedEventsFireInDueTimeOrderNotInserti earlyNode.stop(RENDER_QUANTUM_TIME); EXPECT_CALL( - *eventRegistry, dispatchEventFromAudioThread(AudioEvent::ENDED, testing::_, testing::_)) + *eventRegistry, + dispatchEventFromAudioThread(testing::_, AudioEvent::ENDED, testing::_, testing::_)) .Times(0); lateNode.playFrames(RENDER_QUANTUM); // context time is still 0 EXPECT_CALL( - *eventRegistry, dispatchEventFromAudioThread(AudioEvent::ENDED, LATE_CALLBACK_ID, testing::_)) + *eventRegistry, + dispatchEventFromAudioThread(testing::_, AudioEvent::ENDED, LATE_CALLBACK_ID, testing::_)) .Times(0); EXPECT_CALL( *eventRegistry, - dispatchEventFromAudioThread(AudioEvent::ENDED, EARLY_CALLBACK_ID, testing::_)) + dispatchEventFromAudioThread(testing::_, AudioEvent::ENDED, EARLY_CALLBACK_ID, testing::_)) .WillOnce(testing::Return(true)); lateNode.playFrames(RENDER_QUANTUM); // context time reaches the earlier stop time EXPECT_CALL( - *eventRegistry, dispatchEventFromAudioThread(AudioEvent::ENDED, LATE_CALLBACK_ID, testing::_)) + *eventRegistry, + dispatchEventFromAudioThread(testing::_, AudioEvent::ENDED, LATE_CALLBACK_ID, testing::_)) .WillOnce(testing::Return(true)); lateNode.playFrames(RENDER_QUANTUM); // context time reaches the later stop time @@ -214,12 +217,12 @@ TEST_F(AudioScheduledSourceTest, DeferredEndedEventsDueInTheSameQuantumFireInDue testing::Sequence dueTimeOrder; EXPECT_CALL( *eventRegistry, - dispatchEventFromAudioThread(AudioEvent::ENDED, SOONER_CALLBACK_ID, testing::_)) + dispatchEventFromAudioThread(testing::_, AudioEvent::ENDED, SOONER_CALLBACK_ID, testing::_)) .InSequence(dueTimeOrder) .WillOnce(testing::Return(true)); EXPECT_CALL( *eventRegistry, - dispatchEventFromAudioThread(AudioEvent::ENDED, LATER_CALLBACK_ID, testing::_)) + dispatchEventFromAudioThread(testing::_, AudioEvent::ENDED, LATER_CALLBACK_ID, testing::_)) .InSequence(dueTimeOrder) .WillOnce(testing::Return(true)); laterNode.playFrames(RENDER_QUANTUM); // both due times have passed diff --git a/packages/react-native-audio-api/common/cpp/test/src/events/DeferredEventQueueTest.cpp b/packages/react-native-audio-api/common/cpp/test/src/events/DeferredEventQueueTest.cpp index a77f6f65c..ecd4e2e00 100644 --- a/packages/react-native-audio-api/common/cpp/test/src/events/DeferredEventQueueTest.cpp +++ b/packages/react-native-audio-api/common/cpp/test/src/events/DeferredEventQueueTest.cpp @@ -18,7 +18,7 @@ class DeferredEventQueueTest : public ::testing::Test { protected: std::shared_ptr registry = std::make_shared(); - DeferredEventQueue queue{registry}; + DeferredEventQueue queue{registry, registry->createAudioEventProducer()}; }; TEST_F(DeferredEventQueueTest, IgnoresEventsWithoutACallback) { @@ -29,7 +29,9 @@ TEST_F(DeferredEventQueueTest, IgnoresEventsWithoutACallback) { TEST_F(DeferredEventQueueTest, HoldsEventsBackUntilTheirDueTime) { EXPECT_TRUE(queue.defer(AudioEvent::ENDED, SOONER_CALLBACK_ID, SOONER_DUE_TIME)); - EXPECT_CALL(*registry, dispatchEventFromAudioThread(testing::_, testing::_, testing::_)).Times(0); + EXPECT_CALL( + *registry, dispatchEventFromAudioThread(testing::_, testing::_, testing::_, testing::_)) + .Times(0); queue.dispatchDue(SOONER_DUE_TIME - 0.001); EXPECT_EQ(queue.pendingCount(), 1); } @@ -38,7 +40,8 @@ TEST_F(DeferredEventQueueTest, DispatchesEventsDueExactlyNow) { queue.defer(AudioEvent::ENDED, SOONER_CALLBACK_ID, SOONER_DUE_TIME); EXPECT_CALL( - *registry, dispatchEventFromAudioThread(AudioEvent::ENDED, SOONER_CALLBACK_ID, testing::_)) + *registry, + dispatchEventFromAudioThread(testing::_, AudioEvent::ENDED, SOONER_CALLBACK_ID, testing::_)) .WillOnce(testing::Return(true)); queue.dispatchDue(SOONER_DUE_TIME); EXPECT_EQ(queue.pendingCount(), 0); @@ -50,11 +53,13 @@ TEST_F(DeferredEventQueueTest, DispatchesInDueTimeOrderNotInsertionOrder) { testing::Sequence dueTimeOrder; EXPECT_CALL( - *registry, dispatchEventFromAudioThread(AudioEvent::ENDED, SOONER_CALLBACK_ID, testing::_)) + *registry, + dispatchEventFromAudioThread(testing::_, AudioEvent::ENDED, SOONER_CALLBACK_ID, testing::_)) .InSequence(dueTimeOrder) .WillOnce(testing::Return(true)); EXPECT_CALL( - *registry, dispatchEventFromAudioThread(AudioEvent::ENDED, LATER_CALLBACK_ID, testing::_)) + *registry, + dispatchEventFromAudioThread(testing::_, AudioEvent::ENDED, LATER_CALLBACK_ID, testing::_)) .InSequence(dueTimeOrder) .WillOnce(testing::Return(true)); @@ -67,13 +72,15 @@ TEST_F(DeferredEventQueueTest, LeavesNotYetDueEventsQueuedAfterASweep) { queue.defer(AudioEvent::ENDED, LATER_CALLBACK_ID, LATER_DUE_TIME); EXPECT_CALL( - *registry, dispatchEventFromAudioThread(AudioEvent::ENDED, SOONER_CALLBACK_ID, testing::_)) + *registry, + dispatchEventFromAudioThread(testing::_, AudioEvent::ENDED, SOONER_CALLBACK_ID, testing::_)) .WillOnce(testing::Return(true)); queue.dispatchDue(SOONER_DUE_TIME); EXPECT_EQ(queue.pendingCount(), 1); EXPECT_CALL( - *registry, dispatchEventFromAudioThread(AudioEvent::ENDED, LATER_CALLBACK_ID, testing::_)) + *registry, + dispatchEventFromAudioThread(testing::_, AudioEvent::ENDED, LATER_CALLBACK_ID, testing::_)) .WillOnce(testing::Return(true)); queue.dispatchDue(LATER_DUE_TIME); EXPECT_EQ(queue.pendingCount(), 0); @@ -89,9 +96,12 @@ TEST_F(DeferredEventQueueTest, DropsEventsOnceFullInsteadOfGrowing) { // The dropped event never fires; everything accepted before it still does. EXPECT_CALL( - *registry, dispatchEventFromAudioThread(AudioEvent::ENDED, LATER_CALLBACK_ID, testing::_)) + *registry, + dispatchEventFromAudioThread(testing::_, AudioEvent::ENDED, LATER_CALLBACK_ID, testing::_)) .Times(0); - EXPECT_CALL(*registry, dispatchEventFromAudioThread(AudioEvent::ENDED, testing::_, testing::_)) + EXPECT_CALL( + *registry, + dispatchEventFromAudioThread(testing::_, AudioEvent::ENDED, testing::_, testing::_)) .Times(DeferredEventQueue::MAX_PENDING_EVENTS) .WillRepeatedly(testing::Return(true)); queue.dispatchDue(LATER_DUE_TIME); @@ -99,7 +109,7 @@ TEST_F(DeferredEventQueueTest, DropsEventsOnceFullInsteadOfGrowing) { } TEST_F(DeferredEventQueueTest, ToleratesAMissingRegistry) { - DeferredEventQueue queueWithoutRegistry{nullptr}; + DeferredEventQueue queueWithoutRegistry{nullptr, nullptr}; EXPECT_TRUE(queueWithoutRegistry.defer(AudioEvent::ENDED, SOONER_CALLBACK_ID, SOONER_DUE_TIME)); queueWithoutRegistry.dispatchDue(SOONER_DUE_TIME); EXPECT_EQ(queueWithoutRegistry.pendingCount(), 0); diff --git a/packages/react-native-audio-api/common/cpp/test/src/utils/BufferingStateDispatcherTest.cpp b/packages/react-native-audio-api/common/cpp/test/src/utils/BufferingStateDispatcherTest.cpp index f085d155f..288b2d087 100644 --- a/packages/react-native-audio-api/common/cpp/test/src/utils/BufferingStateDispatcherTest.cpp +++ b/packages/react-native-audio-api/common/cpp/test/src/utils/BufferingStateDispatcherTest.cpp @@ -17,9 +17,12 @@ constexpr int kThresholdFrames = 6615; // 150ms @ 44100Hz, matching production u TEST(BufferingStateDispatcherTest, NoCallbackMeansNoDispatchRegardlessOfStarvation) { auto registry = std::make_shared(); - BufferingStateDispatcher dispatcher(registry, kThresholdFrames); + BufferingStateDispatcher dispatcher( + registry, registry->createAudioEventProducer(), kThresholdFrames); - EXPECT_CALL(*registry, dispatchEventFromAudioThread(testing::_, testing::_, testing::_)).Times(0); + EXPECT_CALL( + *registry, dispatchEventFromAudioThread(testing::_, testing::_, testing::_, testing::_)) + .Times(0); // Well past the debounce threshold, but no listener is registered. dispatcher.advance(/* hasData */ false, kThresholdFrames * 2); @@ -29,12 +32,14 @@ TEST(BufferingStateDispatcherTest, NoCallbackMeansNoDispatchRegardlessOfStarvati TEST(BufferingStateDispatcherTest, StarvationBelowThresholdDoesNotDispatch) { auto registry = std::make_shared(); - BufferingStateDispatcher dispatcher(registry, kThresholdFrames); + BufferingStateDispatcher dispatcher( + registry, registry->createAudioEventProducer(), kThresholdFrames); dispatcher.assignCallbackId(kCallbackId); EXPECT_CALL( *registry, - dispatchEventFromAudioThread(AudioEvent::BUFFERING_STATE_CHANGE, kCallbackId, testing::_)) + dispatchEventFromAudioThread( + testing::_, AudioEvent::BUFFERING_STATE_CHANGE, kCallbackId, testing::_)) .Times(0); dispatcher.advance(/* hasData */ false, kThresholdFrames - 1); @@ -44,19 +49,23 @@ TEST(BufferingStateDispatcherTest, StarvationBelowThresholdDoesNotDispatch) { TEST(BufferingStateDispatcherTest, StarvationCrossingThresholdDispatchesTrueExactlyOnce) { auto registry = std::make_shared(); - BufferingStateDispatcher dispatcher(registry, kThresholdFrames); + BufferingStateDispatcher dispatcher( + registry, registry->createAudioEventProducer(), kThresholdFrames); dispatcher.assignCallbackId(kCallbackId); EXPECT_CALL( *registry, - dispatchEventFromAudioThread(AudioEvent::BUFFERING_STATE_CHANGE, kCallbackId, testing::_)) + dispatchEventFromAudioThread( + testing::_, AudioEvent::BUFFERING_STATE_CHANGE, kCallbackId, testing::_)) .Times(1) - .WillOnce(testing::Invoke([](AudioEvent, uint64_t, AudioEventPayload payload) { - auto *boolPayload = std::get_if(&payload); - EXPECT_NE(boolPayload, nullptr); - EXPECT_TRUE(boolPayload->value); - return true; - })); + .WillOnce( + testing::Invoke( + [](AudioEventProducer &, AudioEvent, uint64_t, AudioEventPayload payload) { + auto *boolPayload = std::get_if(&payload); + EXPECT_NE(boolPayload, nullptr); + EXPECT_TRUE(boolPayload->value); + return true; + })); // Accumulates across calls, like consecutive starved render quanta would. dispatcher.advance(false, kThresholdFrames / 2); @@ -68,19 +77,23 @@ TEST(BufferingStateDispatcherTest, StarvationCrossingThresholdDispatchesTrueExac TEST(BufferingStateDispatcherTest, RecoveryDispatchesFalseImmediatelyWithNoDebounce) { auto registry = std::make_shared(); - BufferingStateDispatcher dispatcher(registry, kThresholdFrames); + BufferingStateDispatcher dispatcher( + registry, registry->createAudioEventProducer(), kThresholdFrames); dispatcher.assignCallbackId(kCallbackId); EXPECT_CALL( *registry, - dispatchEventFromAudioThread(AudioEvent::BUFFERING_STATE_CHANGE, kCallbackId, testing::_)) + dispatchEventFromAudioThread( + testing::_, AudioEvent::BUFFERING_STATE_CHANGE, kCallbackId, testing::_)) .WillOnce(testing::Return(true)) - .WillOnce(testing::Invoke([](AudioEvent, uint64_t, AudioEventPayload payload) { - auto *boolPayload = std::get_if(&payload); - EXPECT_NE(boolPayload, nullptr); - EXPECT_FALSE(boolPayload->value); - return true; - })); + .WillOnce( + testing::Invoke( + [](AudioEventProducer &, AudioEvent, uint64_t, AudioEventPayload payload) { + auto *boolPayload = std::get_if(&payload); + EXPECT_NE(boolPayload, nullptr); + EXPECT_FALSE(boolPayload->value); + return true; + })); dispatcher.advance(false, kThresholdFrames * 2); ASSERT_TRUE(dispatcher.isBuffering()); @@ -93,10 +106,13 @@ TEST(BufferingStateDispatcherTest, RecoveryDispatchesFalseImmediatelyWithNoDebou TEST(BufferingStateDispatcherTest, HasDataWhileNotBufferingNeverDispatches) { auto registry = std::make_shared(); - BufferingStateDispatcher dispatcher(registry, kThresholdFrames); + BufferingStateDispatcher dispatcher( + registry, registry->createAudioEventProducer(), kThresholdFrames); dispatcher.assignCallbackId(kCallbackId); - EXPECT_CALL(*registry, dispatchEventFromAudioThread(testing::_, testing::_, testing::_)).Times(0); + EXPECT_CALL( + *registry, dispatchEventFromAudioThread(testing::_, testing::_, testing::_, testing::_)) + .Times(0); for (int i = 0; i < 10; ++i) { dispatcher.advance(true, 128); @@ -107,10 +123,13 @@ TEST(BufferingStateDispatcherTest, HasDataWhileNotBufferingNeverDispatches) { TEST(BufferingStateDispatcherTest, IntermittentDataResetsStarvationCounter) { auto registry = std::make_shared(); - BufferingStateDispatcher dispatcher(registry, kThresholdFrames); + BufferingStateDispatcher dispatcher( + registry, registry->createAudioEventProducer(), kThresholdFrames); dispatcher.assignCallbackId(kCallbackId); - EXPECT_CALL(*registry, dispatchEventFromAudioThread(testing::_, testing::_, testing::_)).Times(0); + EXPECT_CALL( + *registry, dispatchEventFromAudioThread(testing::_, testing::_, testing::_, testing::_)) + .Times(0); // Never accumulates enough consecutive starvation to cross the threshold, // because a data quantum resets the counter each time — mirrors ordinary @@ -125,7 +144,8 @@ TEST(BufferingStateDispatcherTest, IntermittentDataResetsStarvationCounter) { TEST(BufferingStateDispatcherTest, AssignCallbackIdUnregistersPreviousCallback) { auto registry = std::make_shared(); - BufferingStateDispatcher dispatcher(registry, kThresholdFrames); + BufferingStateDispatcher dispatcher( + registry, registry->createAudioEventProducer(), kThresholdFrames); testing::InSequence sequence; EXPECT_CALL(*registry, unregisterHandler(AudioEvent::BUFFERING_STATE_CHANGE, kCallbackId))