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..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 @@ -78,6 +78,24 @@ 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 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; + } + /// @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..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 @@ -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::getUpstreamChannelCount(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..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 @@ -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 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 f0188d165..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 @@ -18,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). @@ -39,19 +43,32 @@ 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 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; + } + 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) { +/// `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; @@ -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 (input->channelLayout.isResolvedFor(term)) { + c = input->channelLayout.upstreamChannelCount; + } 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,75 @@ 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 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, term); + } + + auto *audio = audioNodeOf(node); + if (audio == nullptr) { + return; + } + + const size_t desired = negotiateChannelCount(node, term); + node->channelLayout.setResolved(term, audio->getUpstreamChannelCount(desired)); +} + +/// @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, term); + } + + if (auto *destAudio = audioNodeOf(dest)) { + 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 *upstream : dest->outputs) { + collectChannelNegotiations(upstream, term, 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 // ========================================================================= @@ -160,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 & { @@ -177,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; } @@ -255,28 +345,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 upstream + // (toward AudioDestinationNode) so late downstream connects still propagate. + const size_t channelLayoutTerm = ++channelLayoutTerm_; + auto negotiations = std::make_unique(); + collectChannelNegotiations(to, channelLayoutTerm, *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 +415,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 upstream + // (toward AudioDestinationNode) so disconnects still propagate. + const size_t channelLayoutTerm = ++channelLayoutTerm_; + auto negotiations = std::make_unique(); + collectChannelNegotiations(to, channelLayoutTerm, *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/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); 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