From e13b30ebf9d3783cacbb1e487506192e4c50410e Mon Sep 17 00:00:00 2001 From: maciejmakowski2003 Date: Thu, 2 Jul 2026 11:45:22 +0200 Subject: [PATCH 1/2] fix: fixed channel count negotiations algorithm --- .../AudioVisualizer/AudioVisualizer.tsx | 2 +- apps/fabric-example/ios/Podfile.lock | 26 +-- .../common/cpp/audioapi/core/AudioNode.h | 17 ++ .../core/effects/StereoPannerNode.cpp | 12 ++ .../audioapi/core/effects/StereoPannerNode.h | 3 + .../audioapi/core/utils/graph/HostGraph.cpp | 201 +++++++++++++----- .../common/cpp/test/src/graph/GraphTest.cpp | 134 +++++++++++- 7 files changed, 320 insertions(+), 75 deletions(-) diff --git a/apps/common-app/src/examples/AudioVisualizer/AudioVisualizer.tsx b/apps/common-app/src/examples/AudioVisualizer/AudioVisualizer.tsx index 5c17169b5..1a22f27c6 100644 --- a/apps/common-app/src/examples/AudioVisualizer/AudioVisualizer.tsx +++ b/apps/common-app/src/examples/AudioVisualizer/AudioVisualizer.tsx @@ -48,6 +48,7 @@ const AudioVisualizer: React.FC = () => { bufferSourceRef.current = audioContextRef.current.createBufferSource(); bufferSourceRef.current.buffer = audioBuffer; bufferSourceRef.current.connect(analyserRef.current); + bufferSourceRef.current.connect(audioContextRef.current.destination); const when = audioContextRef.current.currentTime; setStartTime(when); @@ -84,7 +85,6 @@ const AudioVisualizer: React.FC = () => { fftSize: FFT_SIZE, smoothingTimeConstant: 0.2, }); - analyserRef.current.connect(audioContextRef.current.destination); } fetchAudioBuffer(); diff --git a/apps/fabric-example/ios/Podfile.lock b/apps/fabric-example/ios/Podfile.lock index 0d31d17ec..922bc68cb 100644 --- a/apps/fabric-example/ios/Podfile.lock +++ b/apps/fabric-example/ios/Podfile.lock @@ -1842,7 +1842,7 @@ PODS: - React-utils (= 0.85.0) - ReactNativeDependencies - ReactNativeDependencies (0.85.0) - - RNAudioAPI (0.13.0): + - RNAudioAPI (1.0.0): - hermes-engine - RCTRequired - RCTTypeSafety @@ -1863,10 +1863,10 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - ReactNativeDependencies - - RNAudioAPI/audioapi (= 0.13.0) + - RNAudioAPI/audioapi (= 1.0.0) - RNWorklets - Yoga - - RNAudioAPI/audioapi (0.13.0): + - RNAudioAPI/audioapi (1.0.0): - hermes-engine - RCTRequired - RCTTypeSafety @@ -1887,12 +1887,12 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - ReactNativeDependencies - - RNAudioAPI/audioapi/audioapi_dsp (= 0.13.0) - - RNAudioAPI/audioapi/ios (= 0.13.0) - - RNAudioAPI/audioapi/miniaudio_impl (= 0.13.0) + - RNAudioAPI/audioapi/audioapi_dsp (= 1.0.0) + - RNAudioAPI/audioapi/ios (= 1.0.0) + - RNAudioAPI/audioapi/miniaudio_impl (= 1.0.0) - RNWorklets - Yoga - - RNAudioAPI/audioapi/audioapi_dsp (0.13.0): + - RNAudioAPI/audioapi/audioapi_dsp (1.0.0): - hermes-engine - RCTRequired - RCTTypeSafety @@ -1915,7 +1915,7 @@ PODS: - ReactNativeDependencies - RNWorklets - Yoga - - RNAudioAPI/audioapi/ios (0.13.0): + - RNAudioAPI/audioapi/ios (1.0.0): - hermes-engine - RCTRequired - RCTTypeSafety @@ -1938,7 +1938,7 @@ PODS: - ReactNativeDependencies - RNWorklets - Yoga - - RNAudioAPI/audioapi/miniaudio_impl (0.13.0): + - RNAudioAPI/audioapi/miniaudio_impl (1.0.0): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2476,7 +2476,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: FBLazyVector: c00c20551d40126351a6783c47ce75f5b374851b - hermes-engine: 91023181d4bc5948b457de5314623fbfe4f8604e + hermes-engine: c399a2e224a0b13c589d76b4fc05e14bdd76fa88 RCTDeprecation: 3bb167081b134461cfeb875ff7ae1945f8635257 RCTRequired: 74839f55d5058a133a0bc4569b0afec750957f64 RCTSwiftUI: 87a316382f3eab4dd13d2a0d0fd2adcce917361a @@ -2485,7 +2485,7 @@ SPEC CHECKSUMS: React: 1b1536b9099195944034e65b1830f463caaa8390 React-callinvoker: 6dff6d17d1d6cc8fdf85468a649bafed473c65f5 React-Core: 00faa4d038298089a1d5a5b21dde8660c4f0820d - React-Core-prebuilt: a6d614de037caff7898424dfc22915ec792de921 + React-Core-prebuilt: ab26be1216323aea7c76f96ca450bffa7bcd4a72 React-CoreModules: a17807f849bfd86045b0b9a75ec8c19373b482f6 React-cxxreact: c7b53ace5827be54048288bce5c55f337c41e95f React-debug: e1f00fcd2cef58a2897471a6d76a4ef5f5f90c74 @@ -2549,8 +2549,8 @@ SPEC CHECKSUMS: ReactAppDependencyProvider: 5787b37b8e2e51dfeab697ec031cc7c4080dcea2 ReactCodegen: d07ee3c8db75b43d1cbe479ae6affebf9925c733 ReactCommon: fe2a3af8975e63efa60f95fca8c34dc85deee360 - ReactNativeDependencies: 4d5ce2683b6d74f7c686bf90a88c7d381295cf3c - RNAudioAPI: b689aba6e8b4e6ac85b7e4eb53ebb74a020fbed4 + ReactNativeDependencies: 212738cc51e6c4cc34ee487890497d6f41979ec0 + RNAudioAPI: 6b07e5393a8016de35ba8d2f8b010ce2d7e0c3fd RNGestureHandler: 187c5c7936abf427bc4d22d6c3b1ac80ad1f63c0 RNReanimated: 64f4b3b33b48b19e0ba76a352571b52b1e931981 RNScreens: 01b065ded2dfe7987bcce770ff3a196be417ff41 diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/AudioNode.h b/packages/react-native-audio-api/common/cpp/audioapi/core/AudioNode.h index 9c9359a61..2d15b8826 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/AudioNode.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/AudioNode.h @@ -78,6 +78,23 @@ class AudioNode : public utils::graph::GraphObject, public std::enable_shared_fr audioBuffer_ = buffer; } + /// @brief Buffer whose channel layout is negotiated from upstream connections. + /// Default: same as the output buffer. StereoPanner negotiates the input + /// buffer while keeping a fixed stereo output. + [[nodiscard]] virtual std::shared_ptr getNegotiatedBuffer() const { + return getOutputBuffer(); + } + + virtual void setNegotiatedBuffer(const std::shared_ptr &buffer) { + setOutputBuffer(buffer); + } + + /// @brief Channel count presented to downstream nodes after negotiation. + /// Default: the negotiated channel count. StereoPanner always outputs stereo. + [[nodiscard]] virtual size_t getDownstreamChannelCount(size_t negotiatedChannelCount) const { + return negotiatedChannelCount; + } + /// @note JS Thread only [[nodiscard]] bool requiresTailProcessing() const; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/effects/StereoPannerNode.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/effects/StereoPannerNode.cpp index 58ea85519..c8014a208 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/effects/StereoPannerNode.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/effects/StereoPannerNode.cpp @@ -29,6 +29,18 @@ std::shared_ptr StereoPannerNode::getOutputBuffer() const { return outputBuffer_; } +std::shared_ptr StereoPannerNode::getNegotiatedBuffer() const { + return getInputBuffer(); +} + +void StereoPannerNode::setNegotiatedBuffer(const std::shared_ptr &buffer) { + audioBuffer_ = buffer; +} + +size_t StereoPannerNode::getDownstreamChannelCount(size_t /*negotiatedChannelCount*/) const { + return outputBuffer_->getNumberOfChannels(); +} + void StereoPannerNode::processNode(int framesToProcess) { std::shared_ptr context = context_.lock(); if (context == nullptr) { diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/effects/StereoPannerNode.h b/packages/react-native-audio-api/common/cpp/audioapi/core/effects/StereoPannerNode.h index 5f699cfe8..bfa55431c 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/effects/StereoPannerNode.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/effects/StereoPannerNode.h @@ -19,6 +19,9 @@ class StereoPannerNode : public AudioNode { [[nodiscard]] std::shared_ptr getPanParam() const; [[nodiscard]] std::shared_ptr getOutputBuffer() const override; + [[nodiscard]] std::shared_ptr getNegotiatedBuffer() const override; + void setNegotiatedBuffer(const std::shared_ptr &buffer) override; + [[nodiscard]] size_t getDownstreamChannelCount(size_t negotiatedChannelCount) const override; protected: void processNode(int framesToProcess) override; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.cpp index f0188d165..88c8999ee 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.cpp @@ -11,6 +11,7 @@ #include #include +#include #include #include @@ -39,19 +40,35 @@ inline audioapi::AudioNode *audioNodeOf(const HostGraph::Node *node) { return node->handle->audioNode->asAudioNode(); } -/// @brief Computes the channel count that `dest`'s output buffer must carry +/// @brief Returns the effective number of output channels that `audio` presents +/// to downstream connections. Uses the output buffer when present. +size_t outputChannelCountOf(const audioapi::AudioNode *audio) { + if (audio == nullptr) { + return 0; + } + const auto out = audio->getOutputBuffer(); + if (out != nullptr) { + return out->getNumberOfChannels(); + } + return audio->getChannelCount(); +} + +/// @brief Computes the channel count that `dest`'s negotiated buffer must carry /// after the current set of inputs (`dest->inputs`). Follows the Web Audio /// rules for `channelCountMode`: /// - EXPLICIT -> `channelCount` attribute (inputs ignored) -/// - MAX -> max over inputs' channel counts +/// - MAX -> max over inputs' computed output channel counts /// - CLAMPED_MAX -> min(channelCount attribute, max over inputs') /// /// When there are no inputs the node keeps its own `channelCount` attribute /// (matches the shape the buffer already had at construction time). /// -/// Reads only host-thread-owned state: the const `channelCountMode_` and -/// the `channelCount` attributes of the upstream nodes. -size_t negotiateChannelCount(const HostGraph::Node *dest) { +/// `downstreamChannels` carries channel counts already computed for upstream +/// nodes during a cascade walk so that not-yet-applied buffer swaps are still +/// visible to downstream negotiation. +size_t negotiateChannelCount( + const HostGraph::Node *dest, + const std::unordered_map &downstreamChannels) { auto *destAudio = audioNodeOf(dest); if (destAudio == nullptr) { return 0; @@ -70,7 +87,12 @@ size_t negotiateChannelCount(const HostGraph::Node *dest) { if (inAudio == nullptr) { continue; } - const auto c = inAudio->getChannelCount(); + size_t c = 0; + if (const auto it = downstreamChannels.find(input); it != downstreamChannels.end()) { + c = it->second; + } else { + c = outputChannelCountOf(inAudio); + } maxInputChannels = std::max(c, maxInputChannels); } @@ -89,30 +111,23 @@ size_t negotiateChannelCount(const HostGraph::Node *dest) { return attr; } -/// @brief If `dest` is a real AudioNode and the newly negotiated channel -/// count differs from what its output buffer already has, allocates a +/// @brief If `dest` needs a newly negotiated channel layout, allocates a /// replacement `DSPAudioBuffer` on the host thread and returns it. /// Returns `nullptr` when no swap is needed (no audio payload, negotiation /// converged, or context gone). std::shared_ptr buildNegotiatedBufferIfNeeded( - const HostGraph::Node *dest) { + const HostGraph::Node *dest, + size_t desired) { auto *destAudio = audioNodeOf(dest); if (destAudio == nullptr) { return nullptr; } - const size_t desired = negotiateChannelCount(dest); if (desired == 0) { return nullptr; } - // The current buffer shape is the only authoritative "current state" for - // channel count (the attribute can differ from the effective buffer in - // MAX / CLAMPED_MAX modes). Reading `getNumberOfChannels()` here is a - // plain load of a size_t owned by the shared_ptr we already hold, so it - // is safe on the host thread as long as the buffer pointer itself is - // only ever swapped under an AGEvent (which is the case). - const auto current = destAudio->getOutputBuffer(); + const auto current = destAudio->getNegotiatedBuffer(); if (current != nullptr && current->getNumberOfChannels() == desired) { return nullptr; } @@ -121,6 +136,83 @@ std::shared_ptr buildNegotiatedBufferIfNeeded( audioapi::RENDER_QUANTUM_SIZE, static_cast(desired), destAudio->getContextSampleRate()); } +struct ChannelNegotiation { + HostGraph::Node *node = nullptr; + std::shared_ptr buffer; +}; + +using NegotiationBatch = std::vector; + +/// @brief Recursively computes the downstream channel count for `node` and +/// every upstream ancestor, using already-resolved entries in +/// `downstreamChannels`. This lets a later addEdge() see the layout that +/// earlier (not yet applied) negotiations will produce. +void resolveChannelCountForNode( + HostGraph::Node *node, + std::unordered_map &downstreamChannels) { + if (node == nullptr || downstreamChannels.contains(node)) { + return; + } + + for (HostGraph::Node *input : node->inputs) { + resolveChannelCountForNode(input, downstreamChannels); + } + + auto *audio = audioNodeOf(node); + if (audio == nullptr) { + return; + } + + const size_t desired = negotiateChannelCount(node, downstreamChannels); + downstreamChannels[node] = audio->getDownstreamChannelCount(desired); +} + +/// @brief Starting at `dest` (the edge destination / connect target), negotiates +/// channel layouts for `dest` and every node further toward the audio +/// destination. Uses `downstreamChannels` to thread not-yet-applied upstream +/// output counts through the walk. +void collectChannelNegotiations( + HostGraph::Node *dest, + std::unordered_map &downstreamChannels, + NegotiationBatch &out) { + if (dest == nullptr) { + return; + } + + for (HostGraph::Node *input : dest->inputs) { + resolveChannelCountForNode(input, downstreamChannels); + } + + if (auto *destAudio = audioNodeOf(dest)) { + const size_t desired = negotiateChannelCount(dest, downstreamChannels); + downstreamChannels[dest] = destAudio->getDownstreamChannelCount(desired); + + if (auto negotiatedBuffer = buildNegotiatedBufferIfNeeded(dest, desired)) { + out.push_back({.node = dest, .buffer = std::move(negotiatedBuffer)}); + } + } + + for (HostGraph::Node *downstream : dest->outputs) { + collectChannelNegotiations(downstream, downstreamChannels, out); + } +} + +void applyChannelNegotiations( + const NegotiationBatch &negotiations, + Disposer &disposer) { + for (const ChannelNegotiation &negotiation : negotiations) { + auto *audio = audioNodeOf(negotiation.node); + if (audio == nullptr || negotiation.buffer == nullptr) { + continue; + } + auto oldBuffer = audio->getNegotiatedBuffer(); + audio->setNegotiatedBuffer(negotiation.buffer); + if (oldBuffer) { + disposer.dispose(std::move(oldBuffer)); + } + } +} + } // namespace // ========================================================================= @@ -255,28 +347,23 @@ auto HostGraph::addEdge(Node *from, Node *to) -> Res { edgeCount_++; // Channel-count negotiation: computed + allocated on the host thread, - // applied on the audio thread by the AGEvent below. - auto negotiatedBuffer = buildNegotiatedBufferIfNeeded(to); + // applied on the audio thread by the AGEvent below. Cascade through every + // downstream node so late upstream connects still propagate. + std::unordered_map downstreamChannels; + auto negotiations = std::make_unique(); + collectChannelNegotiations(to, downstreamChannels, *negotiations); // could be problematic, since we are passing raw pointer to the lambda return Res::Ok( - [from, - hTo = to->handle, - hFrom = from->handle, - negotiatedBuffer = std::move(negotiatedBuffer)](AudioGraph &graph, auto &disposer) mutable { + [from, hTo = to->handle, hFrom = from->handle, negotiations = std::move(negotiations)]( + AudioGraph &graph, auto &disposer) mutable { auto *fromNode = hFrom->audioNode.get(); auto *toNode = hTo->audioNode.get(); if (!fromNode->isProcessable() && toNode->isProcessable()) { markNodesAsProcessing(from); } - auto *toAudio = toNode->asAudioNode(); - if (toAudio != nullptr && negotiatedBuffer != nullptr) { - auto oldBuffer = toAudio->getOutputBuffer(); - toAudio->setOutputBuffer(negotiatedBuffer); - if (oldBuffer) { - disposer.dispose(std::move(oldBuffer)); - } - } + applyChannelNegotiations(*negotiations, disposer); + disposer.dispose(std::move(negotiations)); graph.pool().push(graph[hTo->index].input_head, from->handle->index); graph.markDirty(); }); @@ -330,37 +417,33 @@ auto HostGraph::removeEdge(Node *from, Node *to) -> Res { } // Channel-count negotiation: computed + allocated on the host thread, - // applied on the audio thread by the AGEvent below. - auto negotiatedBuffer = buildNegotiatedBufferIfNeeded(to); + // applied on the audio thread by the AGEvent below. Cascade through every + // downstream node so disconnects still propagate. + std::unordered_map downstreamChannels; + auto negotiations = std::make_unique(); + collectChannelNegotiations(to, downstreamChannels, *negotiations); // could be problematic, since we are passing raw pointer to the lambda - return Res::Ok([from, - hTo = to->handle, - hFrom = from->handle, - negotiatedBuffer = std::move(negotiatedBuffer)]( - AudioGraph &graph, auto &disposer) mutable { - auto *fromAudio = hFrom->audioNode.get(); - - if (fromAudio->processableState_ == GraphObject::PROCESSABLE_STATE::CONDITIONAL_PROCESSABLE) { - bool updateProcessingNodes = std::ranges::all_of(from->outputs, [](Node *output) { - auto *outAudio = output->handle->audioNode.get(); - return !outAudio->isProcessable(); + return Res::Ok( + [from, hTo = to->handle, hFrom = from->handle, negotiations = std::move(negotiations)]( + AudioGraph &graph, auto &disposer) mutable { + auto *fromAudio = hFrom->audioNode.get(); + + if (fromAudio->processableState_ == + GraphObject::PROCESSABLE_STATE::CONDITIONAL_PROCESSABLE) { + bool updateProcessingNodes = std::ranges::all_of(from->outputs, [](Node *output) { + auto *outAudio = output->handle->audioNode.get(); + return !outAudio->isProcessable(); + }); + if (updateProcessingNodes) { + HostGraph::markNodesAsNotProcessing(from); + } + } + applyChannelNegotiations(*negotiations, disposer); + disposer.dispose(std::move(negotiations)); + graph.pool().remove(graph[hTo->index].input_head, from->handle->index); + graph.markDirty(); }); - if (updateProcessingNodes) { - HostGraph::markNodesAsNotProcessing(from); - } - } - if (auto *toAudio = hTo->audioNode->asAudioNode(); - toAudio != nullptr && negotiatedBuffer != nullptr) { - auto oldBuffer = toAudio->getOutputBuffer(); - toAudio->setOutputBuffer(negotiatedBuffer); - if (oldBuffer) { - disposer.dispose(std::move(oldBuffer)); - } - } - graph.pool().remove(graph[hTo->index].input_head, from->handle->index); - graph.markDirty(); - }); } auto HostGraph::removeAllEdges(Node *from) -> Res { diff --git a/packages/react-native-audio-api/common/cpp/test/src/graph/GraphTest.cpp b/packages/react-native-audio-api/common/cpp/test/src/graph/GraphTest.cpp index bc34196d7..d2a4db782 100644 --- a/packages/react-native-audio-api/common/cpp/test/src/graph/GraphTest.cpp +++ b/packages/react-native-audio-api/common/cpp/test/src/graph/GraphTest.cpp @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -69,6 +70,11 @@ inline size_t channelsOf(const HostGraph::Node *node) { return audioNode->getOutputBuffer()->getNumberOfChannels(); } +inline size_t inputChannelsOf(const HostGraph::Node *node) { + auto *audioNode = node->handle->audioNode->asAudioNode(); + return audioNode->getInputBuffer()->getNumberOfChannels(); +} + /// Adds a ChannelCountTestNode with the given options to the managed /// `graph`. Returns the HostGraph::Node pointer; the associated AudioGraph /// slot is populated once `graph->processEvents()` is called. @@ -81,6 +87,12 @@ inline HostGraph::Node *addChannelCountNode(Graph &graph, const ChannelOpts &opt return graph.addNode(std::move(audioNode)); } +inline HostGraph::Node *addStereoPannerNode(Graph &graph) { + audioapi::StereoPannerOptions options; + auto audioNode = std::make_unique(getGraphTestContext(), options); + return graph.addNode(std::move(audioNode)); +} + TEST_F(GraphTest, EventsAreScheduledButNotExecutedUntilProcess) { auto audioNode = std::make_unique(getGraphTestContext(), AudioNodeOptions()); auto *node = graph->addNode(std::move(audioNode)); @@ -214,10 +226,10 @@ TEST_F(GraphTest, ThreadRaceConcurrency) { // // These tests assert the Web Audio contract: the computed number of // channels on a node's output buffer must follow `channelCountMode` -// - MAX -> max(channelCount of each connected input) +// - MAX -> max(computed output channel count of each connected input) // (the node's channelCount attribute is ignored) // - CLAMPED_MAX -> min(channelCount attribute, -// max(channelCount of each connected input)) +// max(computed output channel count of each connected input)) // - EXPLICIT -> always the channelCount attribute // // The computation must happen on the HostGraph side at addEdge/removeEdge @@ -332,4 +344,122 @@ TEST_F(GraphTest, ChannelCountNegotiation_MaxMode_RecomputesOnDisconnection) { << "MAX: removing the 4-channel source should shrink the buffer back to 2 channels"; } +TEST_F(GraphTest, ChannelCountNegotiation_MaxMode_ChainedNodes_ConnectDownstreamFirst) { + auto *source = + addChannelCountNode(*graph, {.channelCount = 1, .mode = ChannelCountMode::EXPLICIT}); + auto *gain1 = addChannelCountNode(*graph, {.channelCount = 2, .mode = ChannelCountMode::MAX}); + auto *gain2 = addChannelCountNode(*graph, {.channelCount = 2, .mode = ChannelCountMode::MAX}); + graph->processEvents(); + + ASSERT_TRUE(graph->addEdge(gain1, gain2).is_ok()); + graph->processEvents(); + ASSERT_TRUE(graph->addEdge(source, gain1).is_ok()); + graph->processEvents(); + + EXPECT_EQ(channelsOf(gain1), 1u); + EXPECT_EQ(channelsOf(gain2), 1u); +} + +TEST_F(GraphTest, ChannelCountNegotiation_MaxMode_ChainedNodes_ConnectUpstreamFirst) { + auto *source = + addChannelCountNode(*graph, {.channelCount = 1, .mode = ChannelCountMode::EXPLICIT}); + auto *gain1 = addChannelCountNode(*graph, {.channelCount = 2, .mode = ChannelCountMode::MAX}); + auto *gain2 = addChannelCountNode(*graph, {.channelCount = 2, .mode = ChannelCountMode::MAX}); + graph->processEvents(); + + ASSERT_TRUE(graph->addEdge(source, gain1).is_ok()); + ASSERT_TRUE(graph->addEdge(gain1, gain2).is_ok()); + graph->processEvents(); + + EXPECT_EQ(channelsOf(gain1), 1u); + EXPECT_EQ(channelsOf(gain2), 1u); +} + +TEST_F(GraphTest, ChannelCountNegotiation_ClampedMaxMode_ChainedNodes) { + auto *source = + addChannelCountNode(*graph, {.channelCount = 6, .mode = ChannelCountMode::EXPLICIT}); + auto *gain1 = + addChannelCountNode(*graph, {.channelCount = 2, .mode = ChannelCountMode::CLAMPED_MAX}); + auto *gain2 = + addChannelCountNode(*graph, {.channelCount = 4, .mode = ChannelCountMode::CLAMPED_MAX}); + graph->processEvents(); + + ASSERT_TRUE(graph->addEdge(gain1, gain2).is_ok()); + graph->processEvents(); + ASSERT_TRUE(graph->addEdge(source, gain1).is_ok()); + graph->processEvents(); + + EXPECT_EQ(channelsOf(gain1), 2u); + EXPECT_EQ(channelsOf(gain2), 2u); +} + +TEST_F(GraphTest, ChannelCountNegotiation_MaxMode_CascadeOnLateUpstreamConnect) { + auto *quadSource = + addChannelCountNode(*graph, {.channelCount = 4, .mode = ChannelCountMode::EXPLICIT}); + auto *monoSource = + addChannelCountNode(*graph, {.channelCount = 1, .mode = ChannelCountMode::EXPLICIT}); + auto *gain1 = addChannelCountNode(*graph, {.channelCount = 2, .mode = ChannelCountMode::MAX}); + auto *gain2 = addChannelCountNode(*graph, {.channelCount = 2, .mode = ChannelCountMode::MAX}); + graph->processEvents(); + + ASSERT_TRUE(graph->addEdge(gain1, gain2).is_ok()); + ASSERT_TRUE(graph->addEdge(quadSource, gain1).is_ok()); + graph->processEvents(); + ASSERT_EQ(channelsOf(gain2), 4u); + + ASSERT_TRUE(graph->addEdge(monoSource, gain1).is_ok()); + graph->processEvents(); + + EXPECT_EQ(channelsOf(gain1), 4u); + EXPECT_EQ(channelsOf(gain2), 4u); +} + +TEST_F(GraphTest, ChannelCountNegotiation_MaxMode_CascadeOnUpstreamDisconnect) { + auto *quadSource = + addChannelCountNode(*graph, {.channelCount = 4, .mode = ChannelCountMode::EXPLICIT}); + auto *monoSource = + addChannelCountNode(*graph, {.channelCount = 1, .mode = ChannelCountMode::EXPLICIT}); + auto *gain1 = addChannelCountNode(*graph, {.channelCount = 2, .mode = ChannelCountMode::MAX}); + auto *gain2 = addChannelCountNode(*graph, {.channelCount = 2, .mode = ChannelCountMode::MAX}); + graph->processEvents(); + + ASSERT_TRUE(graph->addEdge(gain1, gain2).is_ok()); + ASSERT_TRUE(graph->addEdge(quadSource, gain1).is_ok()); + ASSERT_TRUE(graph->addEdge(monoSource, gain1).is_ok()); + graph->processEvents(); + + ASSERT_TRUE(graph->removeEdge(quadSource, gain1).is_ok()); + graph->processEvents(); + + EXPECT_EQ(channelsOf(gain1), 1u); + EXPECT_EQ(channelsOf(gain2), 1u); +} + +TEST_F(GraphTest, ChannelCountNegotiation_StereoPanner_MonoInputKeepsStereoOutput) { + auto *source = + addChannelCountNode(*graph, {.channelCount = 1, .mode = ChannelCountMode::EXPLICIT}); + auto *panner = addStereoPannerNode(*graph); + graph->processEvents(); + + ASSERT_TRUE(graph->addEdge(source, panner).is_ok()); + graph->processEvents(); + + EXPECT_EQ(inputChannelsOf(panner), 1u); + EXPECT_EQ(channelsOf(panner), 2u); +} + +TEST_F(GraphTest, ChannelCountNegotiation_StereoPanner_DownstreamSeesStereoOutput) { + auto *source = + addChannelCountNode(*graph, {.channelCount = 1, .mode = ChannelCountMode::EXPLICIT}); + auto *panner = addStereoPannerNode(*graph); + auto *dest = addChannelCountNode(*graph, {.channelCount = 2, .mode = ChannelCountMode::MAX}); + graph->processEvents(); + + ASSERT_TRUE(graph->addEdge(source, panner).is_ok()); + ASSERT_TRUE(graph->addEdge(panner, dest).is_ok()); + graph->processEvents(); + + EXPECT_EQ(channelsOf(dest), 2u); +} + } // namespace audioapi::utils::graph From b7cbdb505c8b51d08af62b3f37b2c57fdd93c326 Mon Sep 17 00:00:00 2001 From: maciejmakowski2003 Date: Thu, 2 Jul 2026 12:24:27 +0200 Subject: [PATCH 2/2] fix: naming --- .../common/cpp/audioapi/core/AudioNode.h | 7 +- .../core/effects/StereoPannerNode.cpp | 2 +- .../audioapi/core/effects/StereoPannerNode.h | 2 +- .../audioapi/core/utils/graph/HostGraph.cpp | 86 +++++++++---------- .../cpp/audioapi/core/utils/graph/HostGraph.h | 32 ++++++- 5 files changed, 79 insertions(+), 50 deletions(-) diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/AudioNode.h b/packages/react-native-audio-api/common/cpp/audioapi/core/AudioNode.h index 2d15b8826..e55d1b0f0 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/AudioNode.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/AudioNode.h @@ -89,9 +89,10 @@ class AudioNode : public utils::graph::GraphObject, public std::enable_shared_fr setOutputBuffer(buffer); } - /// @brief Channel count presented to downstream nodes after negotiation. - /// Default: the negotiated channel count. StereoPanner always outputs stereo. - [[nodiscard]] virtual size_t getDownstreamChannelCount(size_t negotiatedChannelCount) const { + /// @brief Channel count this node presents on upstream connections (toward + /// AudioDestinationNode) after negotiation. Default: the negotiated channel + /// count. StereoPanner always outputs stereo. + [[nodiscard]] virtual size_t getUpstreamChannelCount(size_t negotiatedChannelCount) const { return negotiatedChannelCount; } diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/effects/StereoPannerNode.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/effects/StereoPannerNode.cpp index c8014a208..0cc3e1447 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/effects/StereoPannerNode.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/effects/StereoPannerNode.cpp @@ -37,7 +37,7 @@ void StereoPannerNode::setNegotiatedBuffer(const std::shared_ptr audioBuffer_ = buffer; } -size_t StereoPannerNode::getDownstreamChannelCount(size_t /*negotiatedChannelCount*/) const { +size_t StereoPannerNode::getUpstreamChannelCount(size_t /*negotiatedChannelCount*/) const { return outputBuffer_->getNumberOfChannels(); } diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/effects/StereoPannerNode.h b/packages/react-native-audio-api/common/cpp/audioapi/core/effects/StereoPannerNode.h index bfa55431c..d37c37eec 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/effects/StereoPannerNode.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/effects/StereoPannerNode.h @@ -21,7 +21,7 @@ class StereoPannerNode : public AudioNode { [[nodiscard]] std::shared_ptr getOutputBuffer() const override; [[nodiscard]] std::shared_ptr getNegotiatedBuffer() const override; void setNegotiatedBuffer(const std::shared_ptr &buffer) override; - [[nodiscard]] size_t getDownstreamChannelCount(size_t negotiatedChannelCount) const override; + [[nodiscard]] size_t getUpstreamChannelCount(size_t negotiatedChannelCount) const override; protected: void processNode(int framesToProcess) override; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.cpp index 88c8999ee..07d0536be 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.cpp @@ -11,7 +11,6 @@ #include #include -#include #include #include @@ -19,6 +18,10 @@ namespace audioapi::utils::graph { namespace { +// Channel-layout vocabulary in this codebase: +// downstream — away from AudioDestinationNode (toward sources); node->inputs +// upstream — toward AudioDestinationNode; node->outputs + /// @brief Returns the AudioNode associated with `node`, or nullptr if the /// node has no audio payload (e.g. the lightweight `graph->addNode()` used /// in tests that exercise topology only). @@ -40,8 +43,8 @@ inline audioapi::AudioNode *audioNodeOf(const HostGraph::Node *node) { return node->handle->audioNode->asAudioNode(); } -/// @brief Returns the effective number of output channels that `audio` presents -/// to downstream connections. Uses the output buffer when present. +/// @brief Returns how many channels `audio` presents on upstream connections +/// (toward AudioDestinationNode). Uses the output buffer when present. size_t outputChannelCountOf(const audioapi::AudioNode *audio) { if (audio == nullptr) { return 0; @@ -63,12 +66,9 @@ size_t outputChannelCountOf(const audioapi::AudioNode *audio) { /// When there are no inputs the node keeps its own `channelCount` attribute /// (matches the shape the buffer already had at construction time). /// -/// `downstreamChannels` carries channel counts already computed for upstream -/// nodes during a cascade walk so that not-yet-applied buffer swaps are still -/// visible to downstream negotiation. -size_t negotiateChannelCount( - const HostGraph::Node *dest, - const std::unordered_map &downstreamChannels) { +/// `term` identifies the current negotiation pass. Input nodes resolved in +/// that pass expose their pending upstream width via `channelLayout`. +size_t negotiateChannelCount(const HostGraph::Node *dest, size_t term) { auto *destAudio = audioNodeOf(dest); if (destAudio == nullptr) { return 0; @@ -88,8 +88,8 @@ size_t negotiateChannelCount( continue; } size_t c = 0; - if (const auto it = downstreamChannels.find(input); it != downstreamChannels.end()) { - c = it->second; + if (input->channelLayout.isResolvedFor(term)) { + c = input->channelLayout.upstreamChannelCount; } else { c = outputChannelCountOf(inAudio); } @@ -143,19 +143,15 @@ struct ChannelNegotiation { using NegotiationBatch = std::vector; -/// @brief Recursively computes the downstream channel count for `node` and -/// every upstream ancestor, using already-resolved entries in -/// `downstreamChannels`. This lets a later addEdge() see the layout that -/// earlier (not yet applied) negotiations will produce. -void resolveChannelCountForNode( - HostGraph::Node *node, - std::unordered_map &downstreamChannels) { - if (node == nullptr || downstreamChannels.contains(node)) { +/// @brief Recursively resolves upstream channel counts for `node` and every +/// downstream ancestor (`node->inputs`) in negotiation pass `term`. +void resolveChannelCountForNode(HostGraph::Node *node, size_t term) { + if (node == nullptr || node->channelLayout.isResolvedFor(term)) { return; } for (HostGraph::Node *input : node->inputs) { - resolveChannelCountForNode(input, downstreamChannels); + resolveChannelCountForNode(input, term); } auto *audio = audioNodeOf(node); @@ -163,37 +159,33 @@ void resolveChannelCountForNode( return; } - const size_t desired = negotiateChannelCount(node, downstreamChannels); - downstreamChannels[node] = audio->getDownstreamChannelCount(desired); + const size_t desired = negotiateChannelCount(node, term); + node->channelLayout.setResolved(term, audio->getUpstreamChannelCount(desired)); } -/// @brief Starting at `dest` (the edge destination / connect target), negotiates -/// channel layouts for `dest` and every node further toward the audio -/// destination. Uses `downstreamChannels` to thread not-yet-applied upstream -/// output counts through the walk. -void collectChannelNegotiations( - HostGraph::Node *dest, - std::unordered_map &downstreamChannels, - NegotiationBatch &out) { +/// @brief Starting at `dest` (the connect `to` node), negotiates channel +/// layouts for `dest` and every node further upstream (toward +/// AudioDestinationNode via `dest->outputs`). +void collectChannelNegotiations(HostGraph::Node *dest, size_t term, NegotiationBatch &out) { if (dest == nullptr) { return; } for (HostGraph::Node *input : dest->inputs) { - resolveChannelCountForNode(input, downstreamChannels); + resolveChannelCountForNode(input, term); } if (auto *destAudio = audioNodeOf(dest)) { - const size_t desired = negotiateChannelCount(dest, downstreamChannels); - downstreamChannels[dest] = destAudio->getDownstreamChannelCount(desired); + const size_t desired = negotiateChannelCount(dest, term); + dest->channelLayout.setResolved(term, destAudio->getUpstreamChannelCount(desired)); if (auto negotiatedBuffer = buildNegotiatedBufferIfNeeded(dest, desired)) { out.push_back({.node = dest, .buffer = std::move(negotiatedBuffer)}); } } - for (HostGraph::Node *downstream : dest->outputs) { - collectChannelNegotiations(downstream, downstreamChannels, out); + for (HostGraph::Node *upstream : dest->outputs) { + collectChannelNegotiations(upstream, term, out); } } @@ -252,9 +244,13 @@ HostGraph::~HostGraph() { } HostGraph::HostGraph(HostGraph &&other) noexcept - : nodes(std::move(other.nodes)), edgeCount_(other.edgeCount_), last_term(other.last_term) { + : nodes(std::move(other.nodes)), + edgeCount_(other.edgeCount_), + last_term(other.last_term), + channelLayoutTerm_(other.channelLayoutTerm_) { other.edgeCount_ = 0; other.last_term = 0; + other.channelLayoutTerm_ = 0; } auto HostGraph::operator=(HostGraph &&other) noexcept -> HostGraph & { @@ -269,8 +265,10 @@ auto HostGraph::operator=(HostGraph &&other) noexcept -> HostGraph & { nodes = std::move(other.nodes); edgeCount_ = other.edgeCount_; last_term = other.last_term; + channelLayoutTerm_ = other.channelLayoutTerm_; other.edgeCount_ = 0; other.last_term = 0; + other.channelLayoutTerm_ = 0; } return *this; } @@ -347,11 +345,11 @@ auto HostGraph::addEdge(Node *from, Node *to) -> Res { edgeCount_++; // Channel-count negotiation: computed + allocated on the host thread, - // applied on the audio thread by the AGEvent below. Cascade through every - // downstream node so late upstream connects still propagate. - std::unordered_map downstreamChannels; + // applied on the audio thread by the AGEvent below. Cascade upstream + // (toward AudioDestinationNode) so late downstream connects still propagate. + const size_t channelLayoutTerm = ++channelLayoutTerm_; auto negotiations = std::make_unique(); - collectChannelNegotiations(to, downstreamChannels, *negotiations); + collectChannelNegotiations(to, channelLayoutTerm, *negotiations); // could be problematic, since we are passing raw pointer to the lambda return Res::Ok( @@ -417,11 +415,11 @@ auto HostGraph::removeEdge(Node *from, Node *to) -> Res { } // Channel-count negotiation: computed + allocated on the host thread, - // applied on the audio thread by the AGEvent below. Cascade through every - // downstream node so disconnects still propagate. - std::unordered_map downstreamChannels; + // applied on the audio thread by the AGEvent below. Cascade upstream + // (toward AudioDestinationNode) so disconnects still propagate. + const size_t channelLayoutTerm = ++channelLayoutTerm_; auto negotiations = std::make_unique(); - collectChannelNegotiations(to, downstreamChannels, *negotiations); + collectChannelNegotiations(to, channelLayoutTerm, *negotiations); // could be problematic, since we are passing raw pointer to the lambda return Res::Ok( diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.h b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.h index ac483b35c..e9c4b9477 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.h @@ -56,6 +56,34 @@ class HostGraph { bool visit(size_t currentTerm); }; + /// Per-node scratch for channel-layout negotiation (host thread only). + /// + /// Follows the same generation-stamp pattern as `TraversalState`: each + /// `addEdge` / `removeEdge` bumps `HostGraph::channelLayoutTerm_`, and nodes + /// visited in that pass stamp `term` with the current value. A node is + /// considered resolved for the pass when `term == channelLayoutTerm_`. + /// + /// During the pass, `upstreamChannelCount` stores how many channels this + /// node will present on upstream connections (toward AudioDestinationNode) + /// after negotiation — including overrides such as StereoPanner's fixed + /// stereo output. Downstream nodes read these pending widths from their + /// inputs instead of live output buffers, so multiple connects in one batch + /// (before the AGEvent applies buffer swaps on the audio thread) still see + /// consistent layouts. Values are never read on the audio thread. + struct ChannelLayoutState { + size_t term = 0; + size_t upstreamChannelCount = 0; + + [[nodiscard]] bool isResolvedFor(size_t currentTerm) const { + return term == currentTerm; + } + + void setResolved(size_t currentTerm, size_t count) { + term = currentTerm; + upstreamChannelCount = count; + } + }; + /// A single node in the HostGraph. struct Node { std::vector inputs; // reversed edges @@ -68,6 +96,7 @@ class HostGraph { /// entry in `linkedNodes` is marked too (recursively). std::vector linkedNodes; TraversalState traversalState; + ChannelLayoutState channelLayout; std::shared_ptr handle; // shared handle bridging to AudioGraph bool ghost = false; // kept for cycle detection until AudioGraph confirms deletion @@ -144,7 +173,8 @@ class HostGraph { /// another while holding the lock, so a plain mutex is sufficient. mutable std::mutex nodesMutex_; size_t edgeCount_ = 0; - size_t last_term = 0; // monotonic counter for traversal freshness + size_t last_term = 0; // monotonic counter for traversal freshness + size_t channelLayoutTerm_ = 0; // monotonic counter for channel negotiation passes /// @brief DFS reachability check (traverses ghosts too). bool hasPath(Node *start, Node *end);