diff --git a/.claude/skills/audio-nodes/SKILL.md b/.claude/skills/audio-nodes/SKILL.md
index cb6d6d963..8050dd641 100644
--- a/.claude/skills/audio-nodes/SKILL.md
+++ b/.claude/skills/audio-nodes/SKILL.md
@@ -109,7 +109,7 @@ Settle algorithm (allocation-free):
Key invariants:
- **Pull from `processableState_`, never `AudioNode::isProcessable()`.** A tail-bearing node (Delay/Convolver/Biquad) overrides `isProcessable()` to stay `true` while its tail drains after a disconnect; using that for the pull would wrongly re-activate its whole upstream cone. The tail node stays scheduled via that override; its `processableState_` is `NOT_PROCESSABLE`, so it correctly does not pull upstream.
- **`disable()` is sticky.** `AudioNode::disable()` sets `NOT_PROCESSABLE` **and** `alwaysNotProcessable_ = true`, so a finished source still wired to a live consumer is not re-activated by the every-quantum pull. Sources call `disable()` from the audio thread when playback finishes.
-- **DelayReader → DelayWriter** have no audio edge (they share a ring buffer). `Graph::linkNodes(reader, writer)` records a processable-link, mirrored onto `AudioGraph::Node::link_head`. Settle follows links so pulling the reader also pulls the writer and the writer's inputs. Links are NOT part of the topological sort (that would create a cycle for feedback delays).
+- **DelayReader → DelayWriter** have no audio edge (they share a ring buffer). `Graph::linkNodes(reader, writer)` records a processable-link, mirrored onto `AudioGraph::Node::link_head`. Settle follows links so pulling the reader also pulls the writer and the writer's inputs. The toposort also treats a link as an ordering constraint (target before holder) so the writer runs before the reader within a quantum; without that the writer, being an audio sink, would be sorted last and every delay shorter than one quantum would lose samples (the `DelayLine` read snapshot does not make order irrelevant). When the link would close a cycle (feedback delay), the sort drops the constraint for that cycle and orders it by edges only, which is fine because a delay inside a cycle is at least one quantum. Inside such a cycle the reader therefore still runs first; `DelayLine::readerRanThisQuantum()` tells the writer so, and it then writes at least one quantum ahead (the spec's in-cycle clamp). The ring holds `max(maxDelay frames, quantum) + quantum` so neither a delay at `maxDelayTime` nor the in-cycle clamp wraps onto the current read window. Composite host objects must map `getInput()` to the node that receives audio (writer) and `getOutput()` to the node that emits it (reader); `AudioNodeHostObject::connect` uses `source.getOutput()` → `dest.getInput()`.
---
@@ -353,6 +353,12 @@ All graph mutations are queued via `AudioGraphManager` using its own SPSC channe
### Settable channel attributes (channelCount / channelCountMode / channelInterpretation)
These are mutable after construction. `AudioNode` (core) exposes virtual `setChannelCount` / `setChannelCountMode` / `setChannelInterpretation`. `channelCount` and `channelCountMode` are read only on the host thread during negotiation, so the JSI setter updates the core field directly then calls `HostNode::renegotiate()` → `Graph::renegotiateNodeChannels()` → `HostGraph::renegotiateNodeChannels()` (reuses `collectNegotiations` + an `AGEvent` buffer swap, self-drain aware when there is no audio/render consumer — offline construction/suspend and realtime suspended/stopped windows). When `AudioBufferSourceNode` `setBuffer` changes channel width, update `channelCount_` on the host thread then `renegotiate()` so MAX/CLAMPED_MAX downstream nodes update; the audio event still installs the prebuilt buffer (no audio-thread alloc). `channelInterpretation` is read on the audio thread in `processInputs` (`getInputBuffer()->sum(*input, channelInterpretation_)`), so it MUST be applied via `scheduleAudioEvent`, not mutated directly.
+### Output width that differs from the negotiated input width
+Negotiation computes an input width per node, asks `getUpstreamChannelCount(negotiated)` (JS thread) what the node presents downstream, and allocates one buffer via `negotiateBufferChannelCount(negotiated)` (JS thread, default identity). Two patterns exist:
+- **Fixed output width, input-dependent processing** (`StereoPannerNode`): keep the default buffer width (the input width, needed to pick the mono pan law) and override `getNegotiatedBuffer()`/`setNegotiatedBuffer()` to route negotiation at the input buffer plus `getOutputBuffer()`/`getOutput()` for a separately owned output buffer.
+- **Output width computable on the JS thread** (`ConvolverNode`): override `negotiateBufferChannelCount()` to size the single in-place buffer at the output width and `getUpstreamChannelCount()` to report the same. Anything the width depends on that arrives later (Convolver's IR) is handed to the node on the host thread *before* `renegotiate()` (`setImpulseResponseForNegotiation`). Convolver instances carry per-stream state, so a mono IR is installed with one and `negotiateBufferChannelCount` schedules the second lazily when a stereo input first appears; the audio-thread routing clamps to `min(buffer channels, convolvers)` for the window between the buffer event and that append event. Note for tests: an idle `OfflineAudioContext` drains scheduled audio events synchronously, so such lazily scheduled work lands immediately. The IR event and the buffer event travel on different queues, so audio-thread routing must clamp to the buffer's real channel count for the quantum they disagree. The base mixer mixes sources straight into the buffer at the buffer's width, which is wrong when the buffer is wider than the computed input (a quad source with `channelCount: 1` must fold to mono first, with mono gains). Such a node overrides `mixInputs()` (new `AudioNode` hook) to mix at the negotiated width into a preallocated scratch and copy the result to every buffer channel.
+Source nodes report their `channelCount_` attribute as output width; `OscillatorNode`/`ConstantSourceNode` pass a mono copy of their options to the base (`withMonoOutput()`) while the host object keeps the spec attribute value.
+
### Idle-node stale-buffer zeroing (settleProcessableState)
`AudioGraph::iter()` filters to `isProcessable()` nodes, so a node that has gone idle (e.g. a finished source) is skipped and its output buffer is NOT refreshed — it keeps the samples from an earlier quantum. Downstream consumers still read that buffer via `getOutput()` when collecting inputs, which would re-sum ghost echoes every quantum (this broke the `audionode-channel-rules` ~170-node WPT test).
diff --git a/packages/audiodocs/docs/effects/convolver-node.mdx b/packages/audiodocs/docs/effects/convolver-node.mdx
index 75121af7c..cfe59ccfe 100644
--- a/packages/audiodocs/docs/effects/convolver-node.mdx
+++ b/packages/audiodocs/docs/effects/convolver-node.mdx
@@ -44,6 +44,12 @@ Inherits all properties from [`AudioNode`](../core/audio-node.mdx#properties).
| `buffer` | [`AudioBuffer`](../sources/audio-buffer.mdx) | Associated AudioBuffer. Setting it throws `NotSupportedError` unless the buffer has 1, 2 or 4 channels and the same sample rate as the context. |
| `normalize` | `boolean` | Whether the impulse response from the buffer will be scaled by an equal-power normalization when the buffer attribute is set. |
+:::info Channel constraints
+The input is mixed down to mono or stereo before convolution. `channelCount` cannot be set above `2` and `channelCountMode` cannot be set to `'max'`; either attempt, in the constructor options or via the setter, throws `NotSupportedError`.
+
+The output is mono only when a mono input meets a mono impulse response. Every other supported combination (a stereo input, or a 2- or 4-channel impulse response) produces stereo. A 4-channel buffer performs "true stereo" convolution: channels 0 and 1 are driven by the left input and channels 2 and 3 by the right input, with even channels summed into the left output and odd channels into the right. Without a buffer the node outputs a single channel of silence.
+:::
+
:::caution
Linear convolution is a heavy computational process, so if your audio has some weird artefacts that should not be there, try to decrease the duration of impulse response buffer.
:::
diff --git a/packages/audiodocs/docs/effects/delay-node.mdx b/packages/audiodocs/docs/effects/delay-node.mdx
index ccf1c6843..e7dc7dd74 100644
--- a/packages/audiodocs/docs/effects/delay-node.mdx
+++ b/packages/audiodocs/docs/effects/delay-node.mdx
@@ -27,7 +27,7 @@ Inherits all properties from [`AudioNodeOptions`](../core/audio-node.mdx#audiono
| Parameter | Type | Default | |
| :---: | :---: | :----: | :---- |
-| `maxDelayTime` | `number` | `1.0` | Maximum amount of time, in seconds, to buffer delayed values. |
+| `maxDelayTime` | `number` | `1.0` | Maximum amount of time, in seconds, to buffer delayed values. Must be greater than `0` and less than `180`, otherwise the constructor throws `NotSupportedError`. |
| `delayTime` | `number` | `0.0` | Initial value for [`delayTime`](./delay-node.mdx#properties). |
You can also create a `DelayNode` via the [`BaseAudioContext.createDelay(maxDelayTime?: number)`](../core/base-audio-context.mdx#createdelay) factory method, which uses default values when called without arguments.
diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/effects/ConvolverNodeHostObject.cpp b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/effects/ConvolverNodeHostObject.cpp
index 7dd0e798d..3a4b14a5a 100644
--- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/effects/ConvolverNodeHostObject.cpp
+++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/effects/ConvolverNodeHostObject.cpp
@@ -10,6 +10,7 @@
#include
+#include
#include
#include
#include
@@ -45,6 +46,8 @@ JSI_PROPERTY_SETTER_IMPL(ConvolverNodeHostObject, normalize) {
JSI_HOST_FUNCTION_IMPL(ConvolverNodeHostObject, setBuffer) {
if (!args[0].isObject()) {
+ clearBuffer();
+ thisValue.asObject(runtime).setExternalMemoryPressure(runtime, getMemoryPressure());
return jsi::Value::undefined();
}
@@ -57,8 +60,20 @@ JSI_HOST_FUNCTION_IMPL(ConvolverNodeHostObject, setBuffer) {
return jsi::Value::undefined();
}
+void ConvolverNodeHostObject::clearBuffer() {
+ irBytes_ = 0;
+
+ convolverNode_->scheduleAudioEvent(
+ [node = convolverNode_](BaseAudioContext & /*context*/) { node->clearBuffer(); });
+
+ // The output collapses to one channel of silence; downstream widths follow.
+ convolverNode_->setImpulseResponseForNegotiation(nullptr, 0);
+ renegotiate();
+}
+
void ConvolverNodeHostObject::setBuffer(const std::shared_ptr &buffer) {
if (buffer == nullptr) {
+ clearBuffer();
return;
}
@@ -72,23 +87,19 @@ void ConvolverNodeHostObject::setBuffer(const std::shared_ptr &buff
}
auto threadPool = std::make_shared(4);
+ const size_t irChannels = copiedBuffer->getNumberOfChannels();
std::vector> convolvers;
- for (size_t i = 0; i < copiedBuffer->getNumberOfChannels(); ++i) {
- AudioArray channelData(*copiedBuffer->getChannel(i));
- convolvers.push_back(std::make_unique());
- convolvers.back()->init(RENDER_QUANTUM_SIZE, channelData, copiedBuffer->getSize());
- }
- if (copiedBuffer->getNumberOfChannels() == 1) {
- // add one more convolver, because right now input is always stereo
- AudioArray channelData(*copiedBuffer->getChannel(0));
- convolvers.push_back(std::make_unique());
- convolvers.back()->init(RENDER_QUANTUM_SIZE, channelData, copiedBuffer->getSize());
+ convolvers.reserve(irChannels);
+ for (size_t channel = 0; channel < irChannels; ++channel) {
+ convolvers.push_back(ConvolverNode::makeConvolver(*copiedBuffer, channel));
}
+ constexpr size_t kMaxOutputChannels = 2;
auto internalBuffer = std::make_shared(
- RENDER_QUANTUM_SIZE * 2, convolverNode_->getChannelCount(), copiedBuffer->getSampleRate());
+ RENDER_QUANTUM_SIZE * 2, kMaxOutputChannels, copiedBuffer->getSampleRate());
+ // Room for the second convolver a mono response may gain later
auto intermediateBuffer = std::make_shared(
- RENDER_QUANTUM_SIZE, convolvers.size(), copiedBuffer->getSampleRate());
+ RENDER_QUANTUM_SIZE, std::max(irChannels, kMaxOutputChannels), copiedBuffer->getSampleRate());
struct SetupData {
std::shared_ptr buffer;
@@ -118,5 +129,10 @@ void ConvolverNodeHostObject::setBuffer(const std::shared_ptr &buff
context.getDisposer()->dispose(std::move(setupData));
};
convolverNode_->scheduleAudioEvent(std::move(event));
+
+ // Negotiation on this thread must see the new IR before the graph recomputes
+ // what this node presents downstream.
+ convolverNode_->setImpulseResponseForNegotiation(copiedBuffer, convolvers.size());
+ renegotiate();
}
} // namespace audioapi
diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/effects/ConvolverNodeHostObject.h b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/effects/ConvolverNodeHostObject.h
index 9c5cdad21..09aa697fe 100644
--- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/effects/ConvolverNodeHostObject.h
+++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/effects/ConvolverNodeHostObject.h
@@ -33,5 +33,6 @@ class ConvolverNodeHostObject : public AudioNodeHostObject {
bool normalize_;
size_t irBytes_ = 0;
void setBuffer(const std::shared_ptr &buffer);
+ void clearBuffer();
};
} // namespace audioapi
diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/effects/DelayNodeHostObject.cpp b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/effects/DelayNodeHostObject.cpp
index 4a24973a4..9f7fe7f83 100644
--- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/effects/DelayNodeHostObject.cpp
+++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/effects/DelayNodeHostObject.cpp
@@ -25,11 +25,6 @@ DelayNodeHostObject::DelayNodeHostObject(
delayTimeParam_ =
std::make_shared(graph_, node_, delayNode_->getDelayTimeParam());
- auto delayBuffer = std::make_shared(
- static_cast(options.maxDelayTime * context->getSampleRate() + 1),
- channelCount_,
- context->getSampleRate());
-
// order has to be preserved because adding cycle would not change their order in the graph
delayReaderHostNode_ =
std::make_shared(graph_, std::move(delayNode_->delayReader_));
@@ -47,12 +42,12 @@ DelayNodeHostObject::DelayNodeHostObject(
addGetters(JSI_EXPORT_PROPERTY_GETTER(DelayNodeHostObject, delayTime));
}
-std::shared_ptr DelayNodeHostObject::getInput(int /*outputIndex*/) {
- return delayReaderHostNode_;
+std::shared_ptr DelayNodeHostObject::getInput(int /*inputIndex*/) {
+ return delayWriterHostNode_;
}
-std::shared_ptr DelayNodeHostObject::getOutput(int /*inputIndex*/) {
- return delayWriterHostNode_;
+std::shared_ptr DelayNodeHostObject::getOutput(int /*outputIndex*/) {
+ return delayReaderHostNode_;
}
JSI_PROPERTY_GETTER_IMPL(DelayNodeHostObject, delayTime) {
@@ -60,11 +55,9 @@ JSI_PROPERTY_GETTER_IMPL(DelayNodeHostObject, delayTime) {
}
size_t DelayNodeHostObject::getMemoryPressure() const {
- const float maxDelaySeconds = delayNode_->getDelayTimeParam()->getMaxValue();
- const float sampleRate = delayNode_->getContextSampleRate();
- // The delay line ring buffer dominates: (maxDelay * sr + 1) frames * channels * float.
+ // The delay line ring buffer dominates: ring frames * channels * float.
const size_t ringBytes =
- static_cast(maxDelaySeconds * sampleRate + 1) * channelCount_ * sizeof(float);
+ delayNode_->delayLine_->getBuffer()->getSize() * channelCount_ * sizeof(float);
// Base `audioBuffer_` from AudioNodeHostObject::getMemoryPressure(), plus
// the reader/writer AudioNode sub-nodes (each owns its own RQ audioBuffer_)
// and the delayTime AudioParam.
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 be1bf2133..1537e8fd0 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
@@ -129,6 +129,15 @@ class AudioNode : public utils::graph::GraphObject, public std::enable_shared_fr
return negotiatedChannelCount;
}
+ /// @brief Channel count of the buffer negotiation allocates for this node,
+ /// given the negotiated input width. The default returns the input width,
+ /// which is right for a node that processes in place. A node whose output
+ /// is wider than its input overrides it to get the buffer at output width
+ /// and then mixes at the input width itself in `mixInputs`.
+ [[nodiscard]] virtual size_t negotiateBufferChannelCount(size_t negotiatedChannelCount) {
+ return negotiatedChannelCount;
+ }
+
/// @note JS Thread only
[[nodiscard]] bool requiresTailProcessing() const;
@@ -252,13 +261,22 @@ class AudioNode : public utils::graph::GraphObject, public std::enable_shared_fr
updateTailStateForQuantum(inputs, numFrames);
}
+ mixInputs(inputs);
+
+ processNode(numFrames);
+ }
+
+ /// @brief Mixes the upstream outputs into the input buffer: zeroes it, then
+ /// sums every input using this node's channelInterpretation, so the mix
+ /// happens at the buffer's channel count. A node whose buffer is wider than
+ /// its negotiated input width overrides this to mix at that width first.
+ /// @note Audio Thread only
+ virtual void mixInputs(const std::vector &inputs) {
getInputBuffer()->zero();
for (const DSPAudioBuffer *input : inputs) {
getInputBuffer()->sum(*input, channelInterpretation_);
}
-
- processNode(numFrames);
}
/// @brief Returns the tail length in audio frames for the current node
diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/effects/ConvolverNode.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/effects/ConvolverNode.cpp
index 215357f38..730b9b532 100644
--- a/packages/react-native-audio-api/common/cpp/audioapi/core/effects/ConvolverNode.cpp
+++ b/packages/react-native-audio-api/common/cpp/audioapi/core/effects/ConvolverNode.cpp
@@ -14,6 +14,39 @@
#include
namespace audioapi {
+
+namespace {
+
+constexpr size_t kMonoChannelCount = 1;
+constexpr size_t kStereoChannelCount = 2;
+constexpr size_t kTrueStereoImpulseResponseChannelCount = 4;
+
+/// Hands an audio-thread-owned object to the disposer (freed off-thread) and
+/// leaves the pointer empty.
+template
+void disposeIfSet(BaseAudioContext &context, Pointer &pointer) {
+ if (pointer != nullptr) {
+ context.getDisposer()->dispose(std::move(pointer));
+ }
+}
+
+void warnIfNotRenderQuantum(int framesToProcess) {
+ if (framesToProcess == RENDER_QUANTUM_SIZE) {
+ return;
+ }
+#ifdef ANDROID
+ __android_log_print(
+ ANDROID_LOG_WARN,
+ "RN_AUDIOAPI",
+ "convolver requires 128 buffer size for each render quantum, otherwise quality of convolution is very poor");
+#else
+ printf(
+ "[RN_AUDIOAPI WARN] convolver requires 128 buffer size for each render quantum, otherwise quality of convolution is very poor\n");
+#endif
+}
+
+} // namespace
+
ConvolverNode::ConvolverNode(
const std::shared_ptr &context,
const ConvolverOptions &options)
@@ -23,7 +56,27 @@ ConvolverNode::ConvolverNode(
scaleFactor_(1.0f),
intermediateBuffer_(nullptr),
buffer_(nullptr),
- internalBuffer_(nullptr) {}
+ internalBuffer_(nullptr),
+ impulseResponseForNegotiation_(nullptr),
+ convolversScheduled_(0),
+ negotiatedInputChannelCount_(options.channelCount),
+ monoMixBuffer_(
+ std::make_shared(
+ RENDER_QUANTUM_SIZE,
+ kMonoChannelCount,
+ context->getSampleRate())) {}
+
+void ConvolverNode::releaseImpulseResponse(BaseAudioContext &context) {
+ disposeIfSet(context, buffer_);
+ disposeIfSet(context, threadPool_);
+ for (auto &convolver : convolvers_) {
+ disposeIfSet(context, convolver);
+ }
+ convolvers_.clear();
+ disposeIfSet(context, internalBuffer_);
+ disposeIfSet(context, intermediateBuffer_);
+ internalBufferIndex_ = 0;
+}
void ConvolverNode::setBuffer(
const std::shared_ptr &buffer,
@@ -37,25 +90,7 @@ void ConvolverNode::setBuffer(
return;
}
- if (buffer_ != nullptr) {
- context->getDisposer()->dispose(std::move(buffer_));
- }
-
- if (threadPool_ != nullptr) {
- context->getDisposer()->dispose(std::move(threadPool_));
- }
-
- for (auto &convolver : convolvers_) {
- context->getDisposer()->dispose(std::move(convolver));
- }
-
- if (internalBuffer_ != nullptr) {
- context->getDisposer()->dispose(std::move(internalBuffer_));
- }
-
- if (intermediateBuffer_ != nullptr) {
- context->getDisposer()->dispose(std::move(intermediateBuffer_));
- }
+ releaseImpulseResponse(*context);
buffer_ = buffer;
convolvers_ = std::move(convolvers);
@@ -73,6 +108,36 @@ void ConvolverNode::setBuffer(
tailFramesRemaining_ = 0;
}
+void ConvolverNode::clearBuffer() {
+ std::shared_ptr context = context_.lock();
+ if (context == nullptr) {
+ return;
+ }
+
+ releaseImpulseResponse(*context);
+ audioBuffer_->zero();
+}
+
+void ConvolverNode::appendConvolver(std::unique_ptr &&convolver) {
+ convolvers_.push_back(std::move(convolver));
+}
+
+void ConvolverNode::setImpulseResponseForNegotiation(
+ std::shared_ptr impulseResponse,
+ size_t scheduledConvolvers) {
+ impulseResponseForNegotiation_ = std::move(impulseResponse);
+ convolversScheduled_ = scheduledConvolvers;
+}
+
+std::unique_ptr ConvolverNode::makeConvolver(
+ const AudioBuffer &impulseResponse,
+ size_t channel) {
+ AudioArray channelData(*impulseResponse.getChannel(channel));
+ auto convolver = std::make_unique();
+ convolver->init(RENDER_QUANTUM_SIZE, channelData, impulseResponse.getSize());
+ return convolver;
+}
+
float ConvolverNode::calculateNormalizationScale(const std::shared_ptr &buffer) const {
auto numberOfChannels = buffer->getNumberOfChannels();
auto length = buffer->getSize();
@@ -95,26 +160,99 @@ float ConvolverNode::calculateNormalizationScale(const std::shared_ptrgetSampleRate();
+ if (numberOfChannels == kTrueStereoImpulseResponseChannelCount) {
+ // Spec "true-stereo compensation": each output channel sums two convolutions.
+ power *= 0.5f;
+ }
+
return power;
}
-// processing pipeline: audioBuffer_ (input) -> intermediateBuffer_ -> audioBuffer_ (output)
+size_t ConvolverNode::outputChannelCountFor(size_t inputChannelCount) const {
+ if (impulseResponseForNegotiation_ == nullptr) {
+ return kMonoChannelCount;
+ }
+ const size_t irChannels = impulseResponseForNegotiation_->getNumberOfChannels();
+ if (irChannels == kMonoChannelCount && inputChannelCount == kMonoChannelCount) {
+ return kMonoChannelCount;
+ }
+ return kStereoChannelCount;
+}
+
+size_t ConvolverNode::getUpstreamChannelCount(size_t negotiatedChannelCount) const {
+ return outputChannelCountFor(negotiatedChannelCount);
+}
+
+size_t ConvolverNode::negotiateBufferChannelCount(size_t negotiatedChannelCount) {
+ negotiatedInputChannelCount_.store(negotiatedChannelCount, std::memory_order_release);
+
+ const bool monoResponse = impulseResponseForNegotiation_ != nullptr &&
+ impulseResponseForNegotiation_->getNumberOfChannels() == kMonoChannelCount;
+ if (monoResponse && negotiatedChannelCount == kStereoChannelCount &&
+ convolversScheduled_ < kStereoChannelCount) {
+ convolversScheduled_ = kStereoChannelCount;
+ scheduleAudioEvent(
+ [this, convolver = makeConvolver(*impulseResponseForNegotiation_, 0)](
+ BaseAudioContext & /*context*/) mutable { appendConvolver(std::move(convolver)); });
+ }
+
+ return outputChannelCountFor(negotiatedChannelCount);
+}
+
+void ConvolverNode::mixInputs(const std::vector &inputs) {
+ const size_t inputChannels = negotiatedInputChannelCount_.load(std::memory_order_acquire);
+ if (inputChannels >= audioBuffer_->getNumberOfChannels()) {
+ AudioNode::mixInputs(inputs);
+ return;
+ }
+
+ monoMixBuffer_->zero();
+ for (const DSPAudioBuffer *input : inputs) {
+ monoMixBuffer_->sum(*input, getChannelInterpretation());
+ }
+ for (size_t ch = 0; ch < audioBuffer_->getNumberOfChannels(); ++ch) {
+ audioBuffer_->getChannel(ch)->copy(*monoMixBuffer_->getChannel(0));
+ }
+}
+
+// processing pipeline: audioBuffer_ (input) -> intermediateBuffer_ -> internalBuffer_
+void ConvolverNode::renderQuantum() {
+ const size_t bufferChannels = audioBuffer_->getNumberOfChannels();
+ const bool trueStereo = buffer_->getNumberOfChannels() == kTrueStereoImpulseResponseChannelCount;
+ // A mono response runs one convolver per buffer channel, never more than exist.
+ const size_t activeConvolvers = buffer_->getNumberOfChannels() == kMonoChannelCount
+ ? std::min(bufferChannels, convolvers_.size())
+ : convolvers_.size();
+
+ for (size_t i = 0; i < activeConvolvers; ++i) {
+ const size_t inputChannel = std::min(trueStereo ? i / 2 : i, bufferChannels - 1);
+ threadPool_->schedule([this, i, inputChannel] {
+ convolvers_[i]->process(
+ *audioBuffer_->getChannel(inputChannel), *intermediateBuffer_->getChannel(i));
+ });
+ }
+ threadPool_->wait();
+
+ for (size_t ch = 0; ch < internalBuffer_->getNumberOfChannels(); ++ch) {
+ internalBuffer_->getChannel(ch)->zero(internalBufferIndex_, RENDER_QUANTUM_SIZE);
+ }
+ for (size_t i = 0; i < activeConvolvers; ++i) {
+ const size_t outputChannel = trueStereo ? i % 2 : i;
+ if (outputChannel < bufferChannels) {
+ internalBuffer_->getChannel(outputChannel)
+ ->sum(*intermediateBuffer_->getChannel(i), 0, internalBufferIndex_, RENDER_QUANTUM_SIZE);
+ }
+ }
+}
+
void ConvolverNode::processNode(int framesToProcess) {
if (buffer_ == nullptr) {
+ // Spec: a convolver without an impulse response outputs silence.
+ audioBuffer_->zero();
return;
}
- if (framesToProcess != RENDER_QUANTUM_SIZE) {
-#ifdef ANDROID
- __android_log_print(
- ANDROID_LOG_WARN,
- "RN_AUDIOAPI",
- "convolver requires 128 buffer size for each render quantum, otherwise quality of convolution is very poor");
-#else
- printf(
- "[RN_AUDIOAPI WARN] convolver requires 128 buffer size for each render quantum, otherwise quality of convolution is very poor\n");
-#endif
- }
+ warnIfNotRenderQuantum(framesToProcess);
// Once the base-class tail counter has fully drained, stop convolving and
// emit silence; the IR's contribution has decayed beyond audibility.
@@ -125,16 +263,15 @@ void ConvolverNode::processNode(int framesToProcess) {
}
if (internalBufferIndex_ < framesToProcess) {
- performConvolution(audioBuffer_); // reads from audioBuffer_, result goes to intermediateBuffer_
- audioBuffer_->zero();
- audioBuffer_->sum(*intermediateBuffer_);
-
- internalBuffer_->copy(*audioBuffer_, 0, internalBufferIndex_, RENDER_QUANTUM_SIZE);
+ renderQuantum();
internalBufferIndex_ += RENDER_QUANTUM_SIZE;
}
- audioBuffer_->zero();
- audioBuffer_->copy(*internalBuffer_, 0, 0, framesToProcess);
+ const size_t outputChannels = std::min(audioBuffer_->getNumberOfChannels(), kStereoChannelCount);
+ for (size_t ch = 0; ch < outputChannels; ++ch) {
+ audioBuffer_->getChannel(ch)->copy(*internalBuffer_->getChannel(ch), 0, 0, framesToProcess);
+ }
+
auto remainingFrames = static_cast(internalBufferIndex_ - framesToProcess);
if (remainingFrames > 0) {
for (size_t ch = 0; ch < internalBuffer_->getNumberOfChannels(); ++ch) {
@@ -144,8 +281,8 @@ void ConvolverNode::processNode(int framesToProcess) {
internalBufferIndex_ -= framesToProcess;
- for (int i = 0; i < audioBuffer_->getNumberOfChannels(); ++i) {
- audioBuffer_->getChannel(i)->scale(scaleFactor_);
+ for (size_t ch = 0; ch < outputChannels; ++ch) {
+ audioBuffer_->getChannel(ch)->scale(scaleFactor_);
}
}
@@ -156,30 +293,4 @@ int ConvolverNode::computeTailFrames() const {
return buffer_ ? static_cast(buffer_->getSize()) : 0;
}
-void ConvolverNode::performConvolution(const std::shared_ptr &processingBuffer) {
- if (processingBuffer->getNumberOfChannels() == 1) {
- for (int i = 0; i < convolvers_.size(); ++i) {
- threadPool_->schedule([&, i] {
- convolvers_[i]->process(
- *processingBuffer->getChannel(0), *intermediateBuffer_->getChannel(i));
- });
- }
- } else if (processingBuffer->getNumberOfChannels() == 2) {
- if (convolvers_.size() == 2) {
- inputChannelMap_ = {0, 1, 0, 0};
- outputChannelMap_ = {0, 1, 0, 0};
- } else { // 4 channel IR
- inputChannelMap_ = {0, 0, 1, 1};
- outputChannelMap_ = {0, 3, 2, 1};
- }
- for (int i = 0; i < convolvers_.size(); ++i) {
- threadPool_->schedule([this, i, &processingBuffer] {
- convolvers_[i]->process(
- *processingBuffer->getChannel(inputChannelMap_[i]),
- *intermediateBuffer_->getChannel(outputChannelMap_[i]));
- });
- }
- }
- threadPool_->wait();
-}
} // namespace audioapi
diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/effects/ConvolverNode.h b/packages/react-native-audio-api/common/cpp/audioapi/core/effects/ConvolverNode.h
index 7e2880ecd..15f1a4ed4 100644
--- a/packages/react-native-audio-api/common/cpp/audioapi/core/effects/ConvolverNode.h
+++ b/packages/react-native-audio-api/common/cpp/audioapi/core/effects/ConvolverNode.h
@@ -5,6 +5,8 @@
#include
#include
+#include
+#include
#include
#include
@@ -18,12 +20,22 @@ namespace audioapi {
struct ConvolverOptions;
+/// Channel handling follows the spec's convolution matrix
+/// (https://webaudio.github.io/web-audio-api/#Convolution-channel-configurations):
+/// the input is negotiated to mono or stereo (`clamped-max`, channelCount <= 2),
+/// the output is mono only for a mono input with a mono impulse response and
+/// stereo otherwise, and a node without an impulse response emits one channel
+/// of silence. The node processes in place in a single buffer that negotiation
+/// sizes at the output width.
class ConvolverNode : public AudioNode {
public:
explicit ConvolverNode(
const std::shared_ptr &context,
const ConvolverOptions &options);
+ /// @brief Installs a prebuilt impulse response and its processing state,
+ /// handing the previous ones to the disposer. Everything passed in was
+ /// allocated on the JS thread; nothing is allocated here.
/// @note Audio Thread only
void setBuffer(
const std::shared_ptr &buffer,
@@ -33,9 +45,57 @@ class ConvolverNode : public AudioNode {
const std::shared_ptr &intermediateBuffer,
float scaleFactor);
+ /// @brief Drops the impulse response and its processing state; the node
+ /// renders silence until a new one arrives.
+ /// @note Audio Thread only
+ void clearBuffer();
+
+ /// @brief Appends a convolver built for the installed impulse response (see
+ /// `negotiateBufferChannelCount`).
+ /// @note Audio Thread only
+ void appendConvolver(std::unique_ptr &&convolver);
+
+ /// @brief Records the impulse response negotiation should assume (nullptr
+ /// when there is none) and how many convolvers were scheduled for it. Must
+ /// be called before the graph renegotiates.
+ /// @note JS Thread only
+ void setImpulseResponseForNegotiation(
+ std::shared_ptr impulseResponse,
+ size_t scheduledConvolvers);
+
+ /// @brief Builds a partitioned convolver for one impulse-response channel.
+ /// @note JS Thread only (allocates)
+ [[nodiscard]] static std::unique_ptr makeConvolver(
+ const AudioBuffer &impulseResponse,
+ size_t channel);
+
float calculateNormalizationScale(const std::shared_ptr &buffer) const;
+ /// @brief Output width for a negotiated input width.
+ /// @note JS Thread only (negotiation).
+ [[nodiscard]] size_t getUpstreamChannelCount(size_t negotiatedChannelCount) const override;
+
+ /// @brief Sizes the in-place buffer at the output width and remembers the
+ /// negotiated input width for `mixInputs`.
+ ///
+ /// This is also where the second convolver of a mono impulse response is
+ /// created: each input channel needs its own convolver state, the input
+ /// width is only known here, and a mono response is installed with a single
+ /// convolver. The first stereo negotiation schedules the second one through
+ /// an audio event, once per installed response; it is never removed again,
+ /// so it idles if the input becomes mono later. Until that event lands, the
+ /// stereo buffer is rendered with the convolvers that exist.
+ /// @note JS Thread only (negotiation).
+ [[nodiscard]] size_t negotiateBufferChannelCount(size_t negotiatedChannelCount) override;
+
protected:
+ /// @brief Mixes at the negotiated input width, not the buffer width: a
+ /// mono computed input is mixed into a mono scratch first and then copied
+ /// to every buffer channel, so multichannel sources fold down with the
+ /// spec's mono gains and the mono input drives every IR channel.
+ /// @note Audio Thread only
+ void mixInputs(const std::vector &inputs) override;
+
void processNode(int framesToProcess) override;
/// @brief Tail length equals the impulse-response length in frames. A
@@ -52,15 +112,28 @@ class ConvolverNode : public AudioNode {
// impulse response buffer
std::shared_ptr buffer_;
- // buffer to hold internal processed data
+ // rendered output not yet handed out, always stereo; the mono case uses channel 0
std::shared_ptr internalBuffer_;
- // vectors of convolvers, one per channel
+ // one per impulse-response channel, or one per input channel for a mono response
std::vector> convolvers_;
std::shared_ptr threadPool_;
- std::array inputChannelMap_;
- std::array outputChannelMap_;
- void performConvolution(const std::shared_ptr &processingBuffer);
+ /// The impulse response negotiation must assume (nullptr = none). Set
+ /// ahead of the audio event that installs it, so it can briefly differ
+ /// from `buffer_`. JS thread only.
+ std::shared_ptr impulseResponseForNegotiation_;
+ /// Convolvers scheduled so far for `impulseResponseForNegotiation_`. JS
+ /// thread only.
+ size_t convolversScheduled_;
+ /// Input width negotiation last handed us; narrower than the buffer for a
+ /// mono computed input meeting a 2- or 4-channel impulse response.
+ std::atomic negotiatedInputChannelCount_;
+ /// Mixing target when the computed input is mono but the buffer is stereo.
+ const std::shared_ptr monoMixBuffer_;
+
+ void releaseImpulseResponse(BaseAudioContext &context);
+ [[nodiscard]] size_t outputChannelCountFor(size_t inputChannelCount) const;
+ void renderQuantum();
};
} // namespace audioapi
diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/effects/DelayNode.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/effects/DelayNode.cpp
index 7dd37cd4f..2e49c9c6e 100644
--- a/packages/react-native-audio-api/common/cpp/audioapi/core/effects/DelayNode.cpp
+++ b/packages/react-native-audio-api/common/cpp/audioapi/core/effects/DelayNode.cpp
@@ -4,10 +4,12 @@
#include
#include
#include
+#include
#include
#include
#include
+#include
#include
namespace audioapi {
@@ -18,9 +20,15 @@ DelayNode::DelayNode(const std::shared_ptr &context, const Del
std::make_shared(options.delayTime, 0, options.maxDelayTime, context)),
delayBuffer_(
std::make_shared(
- static_cast(
- options.maxDelayTime * context->getSampleRate() +
- 1), // +1 to enable delayTime equal to maxDelayTime
+ // The writer stores a whole quantum starting `delayTime` frames
+ // (at least one quantum inside a feedback cycle) ahead of the
+ // read head, so the ring holds that lead plus one quantum of
+ // headroom; otherwise the write wraps onto frames read this
+ // quantum.
+ std::max(
+ static_cast(options.maxDelayTime * context->getSampleRate()),
+ static_cast(RENDER_QUANTUM_SIZE)) +
+ RENDER_QUANTUM_SIZE,
channelCount_,
context->getSampleRate())) {
delayLine_ = std::make_shared(delayBuffer_, delayTimeParam_);
diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/effects/delay/DelayLine.h b/packages/react-native-audio-api/common/cpp/audioapi/core/effects/delay/DelayLine.h
index 5d69abb31..76c9bc315 100644
--- a/packages/react-native-audio-api/common/cpp/audioapi/core/effects/delay/DelayLine.h
+++ b/packages/react-native-audio-api/common/cpp/audioapi/core/effects/delay/DelayLine.h
@@ -34,9 +34,26 @@ class DelayLine {
if (currentSampleFrame != quantumSampleFrame_) {
writeIndex_ = readIndex_;
quantumSampleFrame_ = currentSampleFrame;
+ readerRanThisQuantum_ = false;
}
}
+ /// @brief Called by DelayReader once it has consumed this quantum's frames.
+ /// @note Audio Thread only.
+ void markReaderRan() {
+ readerRanThisQuantum_ = true;
+ }
+
+ /// @brief True when the reader has already run in the current quantum, i.e.
+ /// the writer is being processed after it. Outside a feedback cycle the
+ /// graph orders the writer first, so this only happens inside a cycle,
+ /// where the spec clamps the delay to at least one render quantum anyway
+ /// for this exact purpose
+ /// @note Audio Thread only.
+ [[nodiscard]] bool readerRanThisQuantum() const {
+ return readerRanThisQuantum_;
+ }
+
/// Read head used by DelayWriter for `(snapshot + delaySamples) % N` (not `readIndex_` after
/// reader may have advanced).
[[nodiscard]] size_t readSnapshotForWrite() const {
@@ -58,6 +75,7 @@ class DelayLine {
size_t readIndex_{0};
size_t writeIndex_{0};
size_t quantumSampleFrame_{std::numeric_limits::max()};
+ bool readerRanThisQuantum_{false};
};
} // namespace audioapi
diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/effects/delay/DelayReader.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/effects/delay/DelayReader.cpp
index 20636840c..1a941d156 100644
--- a/packages/react-native-audio-api/common/cpp/audioapi/core/effects/delay/DelayReader.cpp
+++ b/packages/react-native-audio-api/common/cpp/audioapi/core/effects/delay/DelayReader.cpp
@@ -29,6 +29,7 @@ void DelayReader::processNode(int framesToProcess) {
delay_ring::bufferOperation(
delayBuffer, audioBuffer_, framesToProcess, readIdx, delay_ring::BufferAction::READ);
+ delayLine_->markReaderRan();
}
} // namespace audioapi
diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/effects/delay/DelayWriter.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/effects/delay/DelayWriter.cpp
index aaf13e3dd..bd87865f0 100644
--- a/packages/react-native-audio-api/common/cpp/audioapi/core/effects/delay/DelayWriter.cpp
+++ b/packages/react-native-audio-api/common/cpp/audioapi/core/effects/delay/DelayWriter.cpp
@@ -2,8 +2,10 @@
#include
#include
#include
+#include
#include
+#include
#include
#include
@@ -27,9 +29,15 @@ void DelayWriter::processNode(int framesToProcess) {
auto delayBuffer = delayLine_->getBuffer();
auto delayTime = delayLine_->getDelayTimeParam()->processKRateParam(context->getCurrentTime());
const size_t readForWrite = delayLine_->readSnapshotForWrite();
+ float delayFrames = delayTime * context->getSampleRate();
+ if (delayLine_->readerRanThisQuantum()) {
+ // Inside a feedback cycle the reader can be processed first, so frames written
+ // less than a quantum ahead of its snapshot would land behind its head and
+ // be lost. The spec clamps a delay inside a cycle to one render quantum.
+ delayFrames = std::max(delayFrames, static_cast(RENDER_QUANTUM_SIZE));
+ }
auto writeIndex =
- static_cast(static_cast(readForWrite) + delayTime * context->getSampleRate()) %
- delayBuffer->getSize();
+ static_cast(static_cast(readForWrite) + delayFrames) % delayBuffer->getSize();
delay_ring::bufferOperation(
delayBuffer, audioBuffer_, framesToProcess, writeIndex, delay_ring::BufferAction::WRITE);
diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/ConstantSourceNode.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/ConstantSourceNode.cpp
index b87eb434b..b0f0b96ce 100644
--- a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/ConstantSourceNode.cpp
+++ b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/ConstantSourceNode.cpp
@@ -10,7 +10,7 @@ namespace audioapi {
ConstantSourceNode::ConstantSourceNode(
const std::shared_ptr &context,
const ConstantSourceOptions &options)
- : AudioScheduledSourceNode(context),
+ : AudioScheduledSourceNode(context, options.withMonoOutput()),
offsetParam_(
std::make_shared(
options.offset,
diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/ConstantSourceNode.h b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/ConstantSourceNode.h
index f2c5d1f4c..c98365da4 100644
--- a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/ConstantSourceNode.h
+++ b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/ConstantSourceNode.h
@@ -18,6 +18,10 @@ class ConstantSourceNode : public AudioScheduledSourceNode {
[[nodiscard]] std::shared_ptr getOffsetParam() const;
+ /// @brief The output stays mono whatever the `channelCount` attribute says;
+ /// the attribute is tracked by the host object only.
+ void setChannelCount(size_t /*channelCount*/) override {}
+
protected:
void processNode(int framesToProcess) override;
diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/OscillatorNode.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/OscillatorNode.cpp
index 107eb5431..892121f88 100644
--- a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/OscillatorNode.cpp
+++ b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/OscillatorNode.cpp
@@ -13,7 +13,7 @@ namespace audioapi {
OscillatorNode::OscillatorNode(
const std::shared_ptr &context,
const OscillatorOptions &options)
- : AudioScheduledSourceNode(context, options), type_(options.type) {
+ : AudioScheduledSourceNode(context, options.withMonoOutput()), type_(options.type) {
frequencyParam_ = std::make_shared(
options.frequency, -getNyquistFrequency(), getNyquistFrequency(), context);
detuneParam_ = std::make_shared(
diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/OscillatorNode.h b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/OscillatorNode.h
index 28890fa81..9384697d5 100644
--- a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/OscillatorNode.h
+++ b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/OscillatorNode.h
@@ -34,6 +34,10 @@ class OscillatorNode : public AudioScheduledSourceNode {
/// @note Audio Thread only
void setPeriodicWave(const std::shared_ptr &periodicWave);
+ /// @brief The output stays mono whatever the `channelCount` attribute says;
+ /// the attribute is tracked by the host object only.
+ void setChannelCount(size_t /*channelCount*/) override {}
+
protected:
void processNode(int framesToProcess) override;
diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/AudioGraph.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/AudioGraph.cpp
index 92857e93f..67dd49b25 100644
--- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/AudioGraph.cpp
+++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/AudioGraph.cpp
@@ -160,10 +160,11 @@ void AudioGraph::settleProcessableState() {
return false;
};
- // Inputs always sit at a lower index than their consumer, but link nodes
- // can target a higher index. If we switch nodes in higher hierarchy first,
- // we may miss some nodes in lower hierarchy that are now processable.
- // We need to iterate again to ensure we process all nodes in the graph.
+ // Inputs always sit at a lower index than their consumer, and so do link
+ // targets except inside a feedback cycle, where the toposort had to drop
+ // the link constraint and the target may sit at a higher index. If we
+ // switch nodes in higher hierarchy first, we may miss some nodes in lower
+ // hierarchy that are now processable, so iterate to a fixpoint.
bool changed = true;
while (changed) {
changed = false;
@@ -192,11 +193,18 @@ void AudioGraph::kahn_toposort() {
return;
}
- // Phase 1: compute out-degree
+ // Phase 1: out-degree = audio consumers + processable-link holders. A link
+ // holder (DelayReader) must run after its target (DelayWriter) so that a
+ // sample written this quantum can be read this quantum; otherwise every
+ // delay shorter than one render quantum would lose the samples the writer
+ // stores behind the reader's already-advanced head.
for (const auto &nd : nodes) {
for (std::uint32_t inp : pool_.view(nd.input_head)) {
nodes[inp].topo_out_degree++;
}
+ for (std::uint32_t lnk : pool_.view(nd.link_head)) {
+ nodes[lnk].topo_out_degree++;
+ }
}
// Phase 2: reverse Kahn BFS — sinks first, sources last in dequeue order.
@@ -212,23 +220,49 @@ void AudioGraph::kahn_toposort() {
}
};
+ std::uint32_t write = n;
+ auto placeQueued = [&](bool followLinks) {
+ while (qh != -1) {
+ auto idx = static_cast(qh);
+ qh = nodes[idx].after_compaction_ind;
+ nodes[idx].after_compaction_ind = static_cast(--write);
+
+ for (std::uint32_t inp : pool_.view(nodes[idx].input_head)) {
+ if (--nodes[inp].topo_out_degree == 0) {
+ enq(inp);
+ }
+ }
+ if (!followLinks) {
+ continue;
+ }
+ for (std::uint32_t lnk : pool_.view(nodes[idx].link_head)) {
+ if (--nodes[lnk].topo_out_degree == 0) {
+ enq(lnk);
+ }
+ }
+ }
+ };
+
for (std::uint32_t i = 0; i < n; i++) {
if (nodes[i].topo_out_degree == 0) {
enq(i);
}
}
+ placeQueued(/*followLinks=*/true);
- std::uint32_t write = n;
- while (qh != -1) {
- auto idx = static_cast(qh);
- qh = nodes[idx].after_compaction_ind;
- nodes[idx].after_compaction_ind = static_cast(--write);
-
- for (std::uint32_t inp : pool_.view(nodes[idx].input_head)) {
- if (--nodes[inp].topo_out_degree == 0) {
- enq(inp);
+ // means that we have a cycle, so we need to do a second pass without links
+ if (write > 0) {
+ for (std::uint32_t i = 0; i < n; i++) {
+ if (nodes[i].after_compaction_ind != -1) {
+ continue;
+ }
+ for (std::uint32_t lnk : pool_.view(nodes[i].link_head)) {
+ if (--nodes[lnk].topo_out_degree == 0) {
+ enq(lnk);
+ }
}
}
+ placeQueued(/*followLinks=*/false);
}
// Phase 3: remap input (and link) indices to new positions (before nodes move)
diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/AudioGraph.h b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/AudioGraph.h
index 06ad9b658..fb874b041 100644
--- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/AudioGraph.h
+++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/AudioGraph.h
@@ -32,8 +32,9 @@ class AudioGraph {
/// Head of the processable-link linked list in pool_. These are NOT audio
/// edges: they mark other nodes whose processable state must follow this
/// node's (e.g. DelayReader -> DelayWriter, which communicate through a
- /// ring buffer rather than a graph edge). Links do not participate in the
- /// topological sort, only in settleProcessableState().
+ /// ring buffer rather than a graph edge). The toposort orders a link
+ /// target before its holder whenever that does not close a cycle, and
+ /// settleProcessableState() follows links when pulling.
std::uint32_t link_head = InputPool::kNull;
std::uint32_t topo_out_degree : 31 = 0; // scratch — Kahn's out-degree counter
@@ -169,9 +170,9 @@ class AudioGraph {
/// nodes (AudioDestinationNode, AnalyserNode, ...) are ALWAYS_PROCESSABLE
/// and act as pull roots.
///
- /// Because links are not part of the topological order, a marked link
- /// target may sit *after* the node that pulled it; the pull therefore
- /// iterates to a fixpoint. State only ever transitions NOT -> CONDITIONAL,
+ /// Inside a feedback cycle the toposort drops the link constraint, so a
+ /// marked link target may sit *after* the node that pulled it; the pull
+ /// therefore iterates to a fixpoint. State only ever transitions NOT -> CONDITIONAL,
/// so the loop is monotonic and terminates. Link-free graphs settle in a
/// single pass.
///
@@ -190,7 +191,9 @@ class AudioGraph {
InputPool pool_; // pool backing all input linked lists
bool topo_order_dirty = false; // set by markDirty(), cleared by process()
- /// @brief In-place Kahn's toposort (sources first, sinks last).
+ /// @brief In-place Kahn's toposort (sources first, sinks last). Processable
+ /// links count as ordering constraints (target before holder) unless they
+ /// would close a cycle, in which case edges alone order that cycle's nodes.
///
/// Uses `after_compaction_ind` as an embedded FIFO linked-list for the
/// BFS queue, and cycle-sort for the final permutation.
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 39cce23c5..1f1633a15 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
@@ -184,7 +184,8 @@ void collectChannelNegotiations(HostGraph::Node *dest, size_t term, NegotiationB
const size_t desired = negotiateChannelCount(dest, term);
dest->channelLayout.setResolved(term, destAudio->getUpstreamChannelCount(desired));
- if (auto negotiatedBuffer = buildNegotiatedBufferIfNeeded(dest, desired)) {
+ const size_t bufferChannels = destAudio->negotiateBufferChannelCount(desired);
+ if (auto negotiatedBuffer = buildNegotiatedBufferIfNeeded(dest, bufferChannels)) {
out.push_back({.node = dest, .buffer = std::move(negotiatedBuffer)});
}
} else {
diff --git a/packages/react-native-audio-api/common/cpp/audioapi/dsp/Convolver.cpp b/packages/react-native-audio-api/common/cpp/audioapi/dsp/Convolver.cpp
index 3d40707a9..4d8beaf6c 100644
--- a/packages/react-native-audio-api/common/cpp/audioapi/dsp/Convolver.cpp
+++ b/packages/react-native-audio-api/common/cpp/audioapi/dsp/Convolver.cpp
@@ -85,7 +85,6 @@ bool Convolver::init(size_t blockSize, const AudioArray &ir, size_t irLen) {
// 2B-point real-to-complex FFT.
_fftBuffer->zero(_blockSize, _blockSize);
_fft->doFFT(*_fftBuffer, segment);
- segment.at(0).imag(0.0f); // ensure DC component is real
_segmentsIR.push_back(segment);
}
@@ -108,11 +107,17 @@ void pairwise_complex_multiply_fast(
Convolver::aligned_vec_complex &pre) {
size_t n = ir.size();
+ // Bin 0 holds two packed real coefficients (pffft layout): DC in the real
+ // part and Nyquist in the imaginary part. They multiply independently as
+ // reals, not as one complex number.
+ pre[0].real(pre[0].real() + ir[0].real() * audio[0].real());
+ pre[0].imag(pre[0].imag() + ir[0].imag() * audio[0].imag());
+
/// @note Using ARM NEON intrinsics for SIMD optimization
/// This implementation is on average 2x faster than the scalar version on ARM
/// architectures With 16-byte alignment it can be even faster up to 2.5x
#ifdef __ARM_NEON
- size_t j = 0;
+ size_t j = 1;
// Main vector loop: process 4 complex samples (8 floats) per iteration using
// vld2q/vst2q deinterleave
@@ -156,7 +161,7 @@ void pairwise_complex_multiply_fast(
#else
// Fallback scalar implementation
- for (size_t i = 0; i < n; ++i) {
+ for (size_t i = 1; i < n; ++i) {
pre[i] += ir[i] * audio[i];
}
#endif
@@ -176,7 +181,6 @@ void Convolver::process(const DSPAudioArray &input, DSPAudioArray &output) {
// result is stored in the first FDL slot.
// _current marks first FDL slot, which is the current input block.
_fft->doFFT(*_inputBuffer, _segments[_current]);
- _segments[_current][0].imag(0.0f); // ensure DC component is real
// The P sub filter spectra are pairwisely multiplied with the input spectra
// in the FDL. The results are accumulated in the frequency-domain.
diff --git a/packages/react-native-audio-api/common/cpp/audioapi/types/NodeOptions.h b/packages/react-native-audio-api/common/cpp/audioapi/types/NodeOptions.h
index a3da1a9d1..fff40066c 100644
--- a/packages/react-native-audio-api/common/cpp/audioapi/types/NodeOptions.h
+++ b/packages/react-native-audio-api/common/cpp/audioapi/types/NodeOptions.h
@@ -44,6 +44,15 @@ struct AudioScheduledSourceNodeOptions : AudioNodeOptions {
explicit AudioScheduledSourceNodeOptions(AudioNodeOptions options) : AudioNodeOptions(options) {
numberOfInputs = 0;
}
+
+ /// Spec: OscillatorNode and ConstantSourceNode emit a single channel; their
+ /// `channelCount` attribute only describes input mixing, which they have
+ /// none of. The host object keeps the attribute, the core node renders mono.
+ [[nodiscard]] AudioScheduledSourceNodeOptions withMonoOutput() const {
+ AudioScheduledSourceNodeOptions mono = *this;
+ mono.channelCount = 1;
+ return mono;
+ }
};
struct GainOptions : AudioNodeOptions {
@@ -68,10 +77,14 @@ struct ConvolverOptions : AudioNodeOptions {
ConvolverOptions() {
requiresTailProcessing = true;
+ channelCountMode = ChannelCountMode::CLAMPED_MAX;
}
explicit ConvolverOptions(AudioNodeOptions options) : AudioNodeOptions(options) {
requiresTailProcessing = true;
+ if (channelCountMode == ChannelCountMode::MAX) {
+ channelCountMode = ChannelCountMode::CLAMPED_MAX;
+ }
}
};
diff --git a/packages/react-native-audio-api/common/cpp/test/src/core/effects/ConvolverTest.cpp b/packages/react-native-audio-api/common/cpp/test/src/core/effects/ConvolverTest.cpp
new file mode 100644
index 000000000..e58a807e3
--- /dev/null
+++ b/packages/react-native-audio-api/common/cpp/test/src/core/effects/ConvolverTest.cpp
@@ -0,0 +1,379 @@
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+using namespace audioapi;
+
+// NOLINTBEGIN
+
+// Spec channel configurations under test:
+// https://webaudio.github.io/web-audio-api/#Convolution-channel-configurations
+//
+// The node processes in place in a buffer negotiation sizes at the output
+// width (see ConvolverNode::negotiateBufferChannelCount). A mono input into a
+// stereo buffer arrives up-mixed by the graph mixer, so `inputWithChannels`
+// fills every extra buffer channel with the last real input channel.
+
+class ConvolverTest : public ::testing::Test {
+ protected:
+ std::shared_ptr eventRegistry;
+ std::shared_ptr context;
+ std::shared_ptr destination;
+ static constexpr int sampleRate = 44100;
+ static constexpr int FRAMES = RENDER_QUANTUM_SIZE;
+
+ void SetUp() override {
+ eventRegistry = std::make_shared();
+ context = std::make_shared(2, 5 * sampleRate, sampleRate, eventRegistry);
+ destination = std::make_shared(context);
+ context->initialize(destination.get());
+ }
+
+ /// Impulse response whose channel `c` is a single unit tap at `taps[c]`.
+ std::shared_ptr impulseResponseWithTaps(const std::vector &taps) {
+ constexpr size_t kLength = 4;
+ auto ir = std::make_shared(kLength, static_cast(taps.size()), sampleRate);
+ ir->zero();
+ for (size_t c = 0; c < taps.size(); ++c) {
+ (*ir->getChannel(c))[taps[c]] = 1.0f;
+ }
+ return ir;
+ }
+
+ /// A `bufferChannels`-wide buffer carrying `inputChannels` distinct ramps;
+ /// extra channels repeat the last input channel like the speakers up-mix.
+ std::shared_ptr inputWithChannels(int inputChannels, int bufferChannels) {
+ auto input = std::make_shared(FRAMES, bufferChannels, sampleRate);
+ for (int c = 0; c < bufferChannels; ++c) {
+ const int source = std::min(c, inputChannels - 1);
+ for (size_t i = 0; i < FRAMES; ++i) {
+ (*input->getChannel(c))[i] = static_cast(i + 1) * static_cast(source + 1);
+ }
+ }
+ return input;
+ }
+
+ static float ramp(int inputChannel, size_t frame, size_t delay) {
+ return frame >= delay
+ ? static_cast(frame - delay + 1) * static_cast(inputChannel + 1)
+ : 0.0f;
+ }
+};
+
+class TestableConvolverNode : public ConvolverNode {
+ public:
+ explicit TestableConvolverNode(std::shared_ptr context)
+ : ConvolverNode(context, ConvolverOptions()) {}
+
+ /// Plays the graph's part: negotiate the width for `inputChannels` and
+ /// install a buffer of that width.
+ size_t negotiate(size_t inputChannels) {
+ const size_t bufferChannels = negotiateBufferChannelCount(inputChannels);
+ audioBuffer_ = std::make_shared(
+ FRAMES_PER_QUANTUM, static_cast(bufferChannels), getContextSampleRate());
+ return bufferChannels;
+ }
+
+ void setInputBuffer(const std::shared_ptr &input) {
+ audioBuffer_ = input;
+ }
+
+ /// Mirrors ConvolverNodeHostObject::setBuffer without normalization.
+ void loadImpulseResponse(const std::shared_ptr &ir) {
+ const size_t irChannels = ir->getNumberOfChannels();
+
+ std::vector> convolvers;
+ for (size_t channel = 0; channel < irChannels; ++channel) {
+ convolvers.push_back(makeConvolver(*ir, channel));
+ }
+
+ auto internalBuffer =
+ std::make_shared(RENDER_QUANTUM_SIZE * 2, 2, ir->getSampleRate());
+ auto intermediateBuffer = std::make_shared(
+ RENDER_QUANTUM_SIZE,
+ static_cast(std::max(irChannels, 2)),
+ ir->getSampleRate());
+
+ setImpulseResponseForNegotiation(ir, irChannels);
+ setBuffer(
+ ir,
+ std::move(convolvers),
+ std::make_shared(2),
+ internalBuffer,
+ intermediateBuffer,
+ 1.0f);
+ }
+
+ void processNode(int framesToProcess) override {
+ ConvolverNode::processNode(framesToProcess);
+ }
+
+ void mixInputs(const std::vector &inputs) override {
+ ConvolverNode::mixInputs(inputs);
+ }
+
+ using AudioNode::setChannelInterpretation;
+
+ private:
+ static constexpr int FRAMES_PER_QUANTUM = RENDER_QUANTUM_SIZE;
+};
+
+TEST_F(ConvolverTest, ConvolverCanBeCreated) {
+ auto convolver = std::make_shared(context, ConvolverOptions());
+ ASSERT_NE(convolver, nullptr);
+}
+
+TEST_F(ConvolverTest, DefaultChannelCountModeIsClampedMax) {
+ ConvolverOptions defaults;
+ EXPECT_EQ(defaults.channelCountMode, ChannelCountMode::CLAMPED_MAX);
+
+ AudioNodeOptions unspecified;
+ EXPECT_EQ(ConvolverOptions(unspecified).channelCountMode, ChannelCountMode::CLAMPED_MAX);
+
+ AudioNodeOptions explicitMode;
+ explicitMode.channelCountMode = ChannelCountMode::EXPLICIT;
+ EXPECT_EQ(ConvolverOptions(explicitMode).channelCountMode, ChannelCountMode::EXPLICIT);
+}
+
+TEST_F(ConvolverTest, NegotiatesOutputWidthFromInputAndImpulseResponse) {
+ TestableConvolverNode convolver(context);
+ EXPECT_EQ(convolver.getUpstreamChannelCount(1), 1u);
+ EXPECT_EQ(convolver.getUpstreamChannelCount(2), 1u); // no IR: one channel of silence
+
+ convolver.loadImpulseResponse(impulseResponseWithTaps({1}));
+ EXPECT_EQ(convolver.negotiate(1), 1u);
+ EXPECT_EQ(convolver.negotiate(2), 2u);
+ EXPECT_EQ(convolver.getUpstreamChannelCount(1), 1u);
+ EXPECT_EQ(convolver.getUpstreamChannelCount(2), 2u);
+
+ convolver.loadImpulseResponse(impulseResponseWithTaps({1, 2}));
+ EXPECT_EQ(convolver.negotiate(1), 2u);
+ EXPECT_EQ(convolver.getUpstreamChannelCount(1), 2u);
+
+ convolver.loadImpulseResponse(impulseResponseWithTaps({0, 1, 2, 3}));
+ EXPECT_EQ(convolver.negotiate(1), 2u);
+ EXPECT_EQ(convolver.negotiate(2), 2u);
+}
+
+TEST_F(ConvolverTest, WithoutImpulseResponseOutputsSilence) {
+ TestableConvolverNode convolver(context);
+ ASSERT_EQ(convolver.negotiate(2), 1u);
+ convolver.setInputBuffer(inputWithChannels(1, 1));
+ convolver.processNode(FRAMES);
+
+ auto output = convolver.getOutputBuffer();
+ ASSERT_EQ(output->getNumberOfChannels(), 1u);
+ for (size_t i = 0; i < FRAMES; ++i) {
+ EXPECT_FLOAT_EQ((*output->getChannel(0))[i], 0.0f);
+ }
+}
+
+TEST_F(ConvolverTest, MonoInputWithMonoResponseOutputsMono) {
+ TestableConvolverNode convolver(context);
+ convolver.loadImpulseResponse(impulseResponseWithTaps({1}));
+ ASSERT_EQ(convolver.negotiate(1), 1u);
+ convolver.setInputBuffer(inputWithChannels(1, 1));
+ convolver.processNode(FRAMES);
+
+ auto output = convolver.getOutputBuffer();
+ ASSERT_EQ(output->getNumberOfChannels(), 1u);
+ for (size_t i = 0; i < FRAMES; ++i) {
+ EXPECT_NEAR((*output->getChannel(0))[i], ramp(0, i, 1), 1e-3);
+ }
+}
+
+TEST_F(ConvolverTest, MonoInputWithStereoResponseOutputsStereo) {
+ TestableConvolverNode convolver(context);
+ convolver.loadImpulseResponse(impulseResponseWithTaps({1, 2}));
+ ASSERT_EQ(convolver.negotiate(1), 2u);
+ convolver.setInputBuffer(inputWithChannels(1, 2));
+ convolver.processNode(FRAMES);
+
+ auto output = convolver.getOutputBuffer();
+ ASSERT_EQ(output->getNumberOfChannels(), 2u);
+ for (size_t i = 0; i < FRAMES; ++i) {
+ EXPECT_NEAR((*output->getChannel(0))[i], ramp(0, i, 1), 1e-3);
+ EXPECT_NEAR((*output->getChannel(1))[i], ramp(0, i, 2), 1e-3);
+ }
+}
+
+TEST_F(ConvolverTest, MonoResponseWithStereoBufferBeforeSecondConvolverArrivesStaysInBounds) {
+ // The wider buffer comes through a negotiation event and the second
+ // convolver through a later audio event; in between only the left channel
+ // can be rendered. Installing the stereo buffer without negotiating models
+ // that window (an idle offline context would otherwise deliver the event
+ // synchronously).
+ TestableConvolverNode convolver(context);
+ convolver.loadImpulseResponse(impulseResponseWithTaps({1}));
+ convolver.setInputBuffer(inputWithChannels(2, 2));
+ convolver.processNode(FRAMES);
+
+ auto output = convolver.getOutputBuffer();
+ for (size_t i = 0; i < FRAMES; ++i) {
+ EXPECT_NEAR((*output->getChannel(0))[i], ramp(0, i, 1), 1e-3);
+ EXPECT_FLOAT_EQ((*output->getChannel(1))[i], 0.0f);
+ }
+}
+
+TEST_F(ConvolverTest, StereoInputWithMonoResponseConvolvesEachChannel) {
+ // A mono response is installed with one convolver; negotiating a stereo
+ // input schedules the second, which an idle offline context runs at once.
+ TestableConvolverNode convolver(context);
+ convolver.loadImpulseResponse(impulseResponseWithTaps({1}));
+ ASSERT_EQ(convolver.negotiate(2), 2u);
+ convolver.setInputBuffer(inputWithChannels(2, 2));
+ convolver.processNode(FRAMES);
+
+ auto output = convolver.getOutputBuffer();
+ for (size_t i = 0; i < FRAMES; ++i) {
+ EXPECT_NEAR((*output->getChannel(0))[i], ramp(0, i, 1), 1e-3);
+ EXPECT_NEAR((*output->getChannel(1))[i], ramp(1, i, 1), 1e-3);
+ }
+}
+
+TEST_F(ConvolverTest, StereoInputWithStereoResponseHasNoCrossTerms) {
+ TestableConvolverNode convolver(context);
+ convolver.loadImpulseResponse(impulseResponseWithTaps({1, 2}));
+ ASSERT_EQ(convolver.negotiate(2), 2u);
+ convolver.setInputBuffer(inputWithChannels(2, 2));
+ convolver.processNode(FRAMES);
+
+ auto output = convolver.getOutputBuffer();
+ for (size_t i = 0; i < FRAMES; ++i) {
+ EXPECT_NEAR((*output->getChannel(0))[i], ramp(0, i, 1), 1e-3);
+ EXPECT_NEAR((*output->getChannel(1))[i], ramp(1, i, 2), 1e-3);
+ }
+}
+
+TEST_F(ConvolverTest, StereoInputWithTrueStereoResponseFollowsSpecMatrix) {
+ TestableConvolverNode convolver(context);
+ // L -> IR0 -> L, L -> IR1 -> R, R -> IR2 -> L, R -> IR3 -> R
+ convolver.loadImpulseResponse(impulseResponseWithTaps({0, 1, 2, 3}));
+ ASSERT_EQ(convolver.negotiate(2), 2u);
+ convolver.setInputBuffer(inputWithChannels(2, 2));
+ convolver.processNode(FRAMES);
+
+ auto output = convolver.getOutputBuffer();
+ for (size_t i = 0; i < FRAMES; ++i) {
+ EXPECT_NEAR((*output->getChannel(0))[i], ramp(0, i, 0) + ramp(1, i, 2), 1e-3);
+ EXPECT_NEAR((*output->getChannel(1))[i], ramp(0, i, 1) + ramp(1, i, 3), 1e-3);
+ }
+}
+
+TEST_F(ConvolverTest, MonoInputWithTrueStereoResponseFeedsEveryChannel) {
+ TestableConvolverNode convolver(context);
+ convolver.loadImpulseResponse(impulseResponseWithTaps({0, 1, 2, 3}));
+ ASSERT_EQ(convolver.negotiate(1), 2u);
+ convolver.setInputBuffer(inputWithChannels(1, 2));
+ convolver.processNode(FRAMES);
+
+ auto output = convolver.getOutputBuffer();
+ for (size_t i = 0; i < FRAMES; ++i) {
+ EXPECT_NEAR((*output->getChannel(0))[i], ramp(0, i, 0) + ramp(0, i, 2), 1e-3);
+ EXPECT_NEAR((*output->getChannel(1))[i], ramp(0, i, 1) + ramp(0, i, 3), 1e-3);
+ }
+}
+
+TEST_F(ConvolverTest, MonoComputedInputMixesMultichannelSourceToMonoThenDuplicates) {
+ // channelCount 1 (explicit) with a quad source and a stereo IR: the source
+ // must fold down with the spec's quad->mono gains (0.25 each) and that mono
+ // signal must reach both buffer channels.
+ TestableConvolverNode convolver(context);
+ convolver.loadImpulseResponse(impulseResponseWithTaps({1, 2}));
+ ASSERT_EQ(convolver.negotiate(1), 2u);
+
+ auto quad = std::make_shared(FRAMES, 4, sampleRate);
+ for (int c = 0; c < 4; ++c) {
+ for (size_t i = 0; i < FRAMES; ++i) {
+ (*quad->getChannel(c))[i] = static_cast(c + 1);
+ }
+ }
+ convolver.mixInputs({quad.get()});
+
+ auto mixed = convolver.getOutputBuffer();
+ ASSERT_EQ(mixed->getNumberOfChannels(), 2u);
+ for (size_t i = 0; i < FRAMES; ++i) {
+ EXPECT_NEAR((*mixed->getChannel(0))[i], 0.25f * (1 + 2 + 3 + 4), 1e-5);
+ EXPECT_NEAR((*mixed->getChannel(1))[i], 0.25f * (1 + 2 + 3 + 4), 1e-5);
+ }
+}
+
+TEST_F(ConvolverTest, MonoComputedInputUnderDiscreteInterpretationKeepsChannelZero) {
+ TestableConvolverNode convolver(context);
+ convolver.setChannelInterpretation(ChannelInterpretation::DISCRETE);
+ convolver.loadImpulseResponse(impulseResponseWithTaps({1, 2}));
+ ASSERT_EQ(convolver.negotiate(1), 2u);
+
+ auto stereo = inputWithChannels(2, 2);
+ convolver.mixInputs({stereo.get()});
+
+ auto mixed = convolver.getOutputBuffer();
+ for (size_t i = 0; i < FRAMES; ++i) {
+ EXPECT_FLOAT_EQ((*mixed->getChannel(0))[i], ramp(0, i, 0));
+ EXPECT_FLOAT_EQ((*mixed->getChannel(1))[i], ramp(0, i, 0));
+ }
+}
+
+TEST_F(ConvolverTest, StereoComputedInputUsesDefaultMixing) {
+ TestableConvolverNode convolver(context);
+ convolver.loadImpulseResponse(impulseResponseWithTaps({1, 2}));
+ ASSERT_EQ(convolver.negotiate(2), 2u);
+
+ auto stereo = inputWithChannels(2, 2);
+ convolver.mixInputs({stereo.get()});
+
+ auto mixed = convolver.getOutputBuffer();
+ for (size_t i = 0; i < FRAMES; ++i) {
+ EXPECT_FLOAT_EQ((*mixed->getChannel(0))[i], ramp(0, i, 0));
+ EXPECT_FLOAT_EQ((*mixed->getChannel(1))[i], ramp(1, i, 0));
+ }
+}
+
+TEST_F(ConvolverTest, StereoResponseWithNotYetWidenedBufferStaysInBounds) {
+ // The IR lands through an audio event and the wider buffer through a
+ // negotiation event; for a quantum the buffer may still be mono.
+ TestableConvolverNode convolver(context);
+ convolver.loadImpulseResponse(impulseResponseWithTaps({1, 2}));
+ convolver.setInputBuffer(inputWithChannels(1, 1));
+ convolver.processNode(FRAMES);
+
+ auto output = convolver.getOutputBuffer();
+ ASSERT_EQ(output->getNumberOfChannels(), 1u);
+ for (size_t i = 0; i < FRAMES; ++i) {
+ EXPECT_NEAR((*output->getChannel(0))[i], ramp(0, i, 1), 1e-3);
+ }
+}
+
+TEST_F(ConvolverTest, ClearingImpulseResponseReturnsToMonoSilence) {
+ TestableConvolverNode convolver(context);
+ convolver.loadImpulseResponse(impulseResponseWithTaps({1, 2}));
+ ASSERT_EQ(convolver.negotiate(2), 2u);
+ convolver.setInputBuffer(inputWithChannels(2, 2));
+ convolver.processNode(FRAMES);
+
+ convolver.setImpulseResponseForNegotiation(nullptr, 0);
+ convolver.clearBuffer();
+ ASSERT_EQ(convolver.negotiate(2), 1u);
+ convolver.setInputBuffer(inputWithChannels(1, 1));
+ convolver.processNode(FRAMES);
+
+ auto output = convolver.getOutputBuffer();
+ ASSERT_EQ(output->getNumberOfChannels(), 1u);
+ for (size_t i = 0; i < FRAMES; ++i) {
+ EXPECT_FLOAT_EQ((*output->getChannel(0))[i], 0.0f);
+ }
+ EXPECT_EQ(convolver.getUpstreamChannelCount(2), 1u);
+}
+
+// NOLINTEND
diff --git a/packages/react-native-audio-api/common/cpp/test/src/core/effects/DelayTest.cpp b/packages/react-native-audio-api/common/cpp/test/src/core/effects/DelayTest.cpp
index fe055851a..f5db5b1b3 100644
--- a/packages/react-native-audio-api/common/cpp/test/src/core/effects/DelayTest.cpp
+++ b/packages/react-native-audio-api/common/cpp/test/src/core/effects/DelayTest.cpp
@@ -79,6 +79,12 @@ class TestableDelayNode : public DelayNode {
testableDelayReader_.processNode(framesToProcess);
}
+ /// Order the graph produces inside a feedback cycle.
+ void processNodeReaderFirst(int framesToProcess) {
+ testableDelayReader_.processNode(framesToProcess);
+ testableDelayWriter_.processNode(framesToProcess);
+ }
+
private:
TestableDelayWriter testableDelayWriter_;
TestableDelayReader testableDelayReader_;
@@ -138,6 +144,70 @@ TEST_F(DelayTest, DelayAppliesTimeShiftCorrectly) {
}
}
+TEST_F(DelayTest, DelayEqualToMaxDelayDoesNotWrapIntoCurrentQuantum) {
+ // A delay of exactly `maxDelayTime` writes one quantum starting at the far
+ // end of the ring. Without a quantum of headroom the write wraps onto the
+ // frames the reader consumes this quantum and the signal leaks through
+ // with almost no delay.
+ static constexpr int FRAMES_TO_PROCESS = 128;
+ const float MAX_DELAY_TIME = FRAMES_TO_PROCESS / context->getSampleRate();
+ auto options = DelayOptions();
+ options.maxDelayTime = MAX_DELAY_TIME;
+ auto delayNode = TestableDelayNode(context, options);
+ delayNode.setDelayTimeParam(MAX_DELAY_TIME);
+
+ auto ones = std::make_shared(FRAMES_TO_PROCESS, 1, sampleRate);
+ for (size_t i = 0; i < ones->getSize(); ++i) {
+ (*ones->getChannel(0))[i] = 1.0f;
+ }
+
+ delayNode.setInputBuffer(ones);
+ delayNode.processNode(FRAMES_TO_PROCESS);
+ auto firstQuantum = delayNode.getOutputBuffer();
+ for (size_t i = 0; i < FRAMES_TO_PROCESS; ++i) {
+ EXPECT_FLOAT_EQ((*firstQuantum->getChannel(0))[i], 0.0f);
+ }
+
+ // The writer zeroes its buffer after storing it, so the second quantum
+ // writes silence and the reader drains the delayed ones.
+ delayNode.getOutputBuffer()->zero();
+ delayNode.processNode(FRAMES_TO_PROCESS);
+ auto secondQuantum = delayNode.getOutputBuffer();
+ for (size_t i = 0; i < FRAMES_TO_PROCESS; ++i) {
+ EXPECT_FLOAT_EQ((*secondQuantum->getChannel(0))[i], 1.0f);
+ }
+}
+
+TEST_F(DelayTest, SubQuantumDelayIsClampedToOneQuantumWhenReaderRunsFirst) {
+ // Inside a feedback cycle the reader runs before the writer. A 64-frame
+ // delay would then store frames behind the read head and lose them; the
+ // spec clamps such a delay to one render quantum instead.
+ static constexpr int FRAMES_TO_PROCESS = 128;
+ const float DELAY_TIME = (FRAMES_TO_PROCESS / context->getSampleRate()) * 0.5;
+ auto options = DelayOptions();
+ options.maxDelayTime = 1.0f;
+ auto delayNode = TestableDelayNode(context, options);
+ delayNode.setDelayTimeParam(DELAY_TIME);
+
+ auto impulse = std::make_shared(FRAMES_TO_PROCESS, 1, sampleRate);
+ (*impulse->getChannel(0))[0] = 1.0f;
+ delayNode.setInputBuffer(impulse);
+
+ delayNode.processNodeReaderFirst(FRAMES_TO_PROCESS);
+ auto firstQuantum = delayNode.getOutputBuffer();
+ for (size_t i = 0; i < FRAMES_TO_PROCESS; ++i) {
+ EXPECT_FLOAT_EQ((*firstQuantum->getChannel(0))[i], 0.0f);
+ }
+
+ delayNode.getOutputBuffer()->zero();
+ delayNode.processNodeReaderFirst(FRAMES_TO_PROCESS);
+ auto secondQuantum = delayNode.getOutputBuffer();
+ EXPECT_FLOAT_EQ((*secondQuantum->getChannel(0))[0], 1.0f);
+ for (size_t i = 1; i < FRAMES_TO_PROCESS; ++i) {
+ EXPECT_FLOAT_EQ((*secondQuantum->getChannel(0))[i], 0.0f);
+ }
+}
+
TEST_F(DelayTest, DelayHandlesTailCorrectly) {
static constexpr int FRAMES_TO_PROCESS = 128;
float DELAY_TIME = (FRAMES_TO_PROCESS / context->getSampleRate()) * 0.5;
diff --git a/packages/react-native-audio-api/common/cpp/test/src/core/sources/ConstantSourceTest.cpp b/packages/react-native-audio-api/common/cpp/test/src/core/sources/ConstantSourceTest.cpp
index c108e29ca..3f122b41b 100644
--- a/packages/react-native-audio-api/common/cpp/test/src/core/sources/ConstantSourceTest.cpp
+++ b/packages/react-native-audio-api/common/cpp/test/src/core/sources/ConstantSourceTest.cpp
@@ -41,6 +41,17 @@ class TestableConstantSourceNode : public ConstantSourceNode {
}
};
+TEST_F(ConstantSourceTest, ConstantSourceRendersMonoRegardlessOfChannelCountAttribute) {
+ // Spec: the constant source output is a single channel; `channelCount`
+ // only describes input mixing, which a source does not have.
+ auto constantSource = std::make_shared(context, ConstantSourceOptions());
+ EXPECT_EQ(constantSource->getOutputBuffer()->getNumberOfChannels(), 1u);
+ EXPECT_EQ(constantSource->getChannelCount(), 1u);
+
+ constantSource->setChannelCount(2);
+ EXPECT_EQ(constantSource->getChannelCount(), 1u);
+}
+
TEST_F(ConstantSourceTest, ConstantSourceCanBeCreated) {
auto constantSource = std::make_shared(context, ConstantSourceOptions());
ASSERT_NE(constantSource, nullptr);
diff --git a/packages/react-native-audio-api/common/cpp/test/src/core/sources/OscillatorTest.cpp b/packages/react-native-audio-api/common/cpp/test/src/core/sources/OscillatorTest.cpp
index 1d6adf325..4536a46f0 100644
--- a/packages/react-native-audio-api/common/cpp/test/src/core/sources/OscillatorTest.cpp
+++ b/packages/react-native-audio-api/common/cpp/test/src/core/sources/OscillatorTest.cpp
@@ -24,6 +24,17 @@ class OscillatorTest : public ::testing::Test {
}
};
+TEST_F(OscillatorTest, OscillatorRendersMonoRegardlessOfChannelCountAttribute) {
+ // Spec: the oscillator output is a single channel; `channelCount` only
+ // describes input mixing, which a source does not have.
+ auto osc = std::make_shared(context, OscillatorOptions());
+ EXPECT_EQ(osc->getOutputBuffer()->getNumberOfChannels(), 1u);
+ EXPECT_EQ(osc->getChannelCount(), 1u);
+
+ osc->setChannelCount(2);
+ EXPECT_EQ(osc->getChannelCount(), 1u);
+}
+
TEST_F(OscillatorTest, OscillatorCanBeCreated) {
auto osc = std::make_shared(context, OscillatorOptions());
ASSERT_NE(osc, nullptr);
diff --git a/packages/react-native-audio-api/common/cpp/test/src/graph/AudioGraphTest.cpp b/packages/react-native-audio-api/common/cpp/test/src/graph/AudioGraphTest.cpp
index bf9ca3030..e21ead8ec 100644
--- a/packages/react-native-audio-api/common/cpp/test/src/graph/AudioGraphTest.cpp
+++ b/packages/react-native-audio-api/common/cpp/test/src/graph/AudioGraphTest.cpp
@@ -459,6 +459,45 @@ TEST_F(AudioGraphTest, TopoSort_ComplexDAG) {
EXPECT_LT(posOf(4), posOf(5));
}
+TEST_F(AudioGraphTest, TopoSort_LinkTargetOrderedBeforeHolder) {
+ // A DelayNode in a straight chain: 0 = source, 1 = writer (audio sink),
+ // 2 = reader (no audio inputs), 3 = destination. The reader links the
+ // writer, so the writer must run before the reader within a quantum.
+ auto h = addNodes(4);
+ graph.pool().push(graph[h[1]->index].input_head, h[0]->index);
+ graph.pool().push(graph[h[3]->index].input_head, h[2]->index);
+ graph.pool().push(graph[h[2]->index].link_head, h[1]->index);
+
+ graph.markDirty();
+ graph.process();
+
+ EXPECT_LT(posOf(0), posOf(1));
+ EXPECT_LT(posOf(1), posOf(2));
+ EXPECT_LT(posOf(2), posOf(3));
+}
+
+TEST_F(AudioGraphTest, TopoSort_LinkCycleFallsBackToEdgeOrder) {
+ // Feedback delay: 0 = reader, 1 = gain, 2 = writer, 3 = destination.
+ // Edges reader -> gain -> writer plus the reader -> writer link form a
+ // cycle, so the link constraint is dropped and edges alone order them.
+ auto h = addNodes(4);
+ graph.pool().push(graph[h[1]->index].input_head, h[0]->index);
+ graph.pool().push(graph[h[2]->index].input_head, h[1]->index);
+ graph.pool().push(graph[h[3]->index].input_head, h[0]->index);
+ graph.pool().push(graph[h[0]->index].link_head, h[2]->index);
+
+ graph.markDirty();
+ graph.process();
+
+ ASSERT_EQ(graph.size(), 4u);
+ EXPECT_LT(posOf(0), posOf(1));
+ EXPECT_LT(posOf(1), posOf(2));
+ EXPECT_LT(posOf(0), posOf(3));
+ for (size_t id = 0; id < 4; ++id) {
+ EXPECT_NE(posOf(id), -1);
+ }
+}
+
// =====================================================================
// Successive process calls — interleaved add & compact
// =====================================================================
diff --git a/packages/react-native-audio-api/src/core/ConvolverNode.ts b/packages/react-native-audio-api/src/core/ConvolverNode.ts
index ead117aff..4519d0303 100644
--- a/packages/react-native-audio-api/src/core/ConvolverNode.ts
+++ b/packages/react-native-audio-api/src/core/ConvolverNode.ts
@@ -1,5 +1,5 @@
import { IConvolverNode } from '../jsi-interfaces';
-import { ConvolverOptions } from '../types';
+import { ChannelCountMode, ConvolverOptions } from '../types';
import type BaseAudioContext from './BaseAudioContext';
import AudioNode from './AudioNode';
import AudioBuffer from './AudioBuffer';
@@ -7,6 +7,8 @@ import {
ConvolverOptionsValidator,
validateConvolverBufferChannelCount,
validateConvolverBufferSampleRate,
+ validateConvolverChannelCount,
+ validateConvolverChannelCountMode,
} from '../utils/validation';
export default class ConvolverNode extends AudioNode {
@@ -25,6 +27,24 @@ export default class ConvolverNode extends AudioNode {
this.normalize = convolverNode.normalize;
}
+ public override get channelCount(): number {
+ return super.channelCount;
+ }
+
+ public override set channelCount(value: number) {
+ validateConvolverChannelCount(value);
+ super.channelCount = value;
+ }
+
+ public override get channelCountMode(): ChannelCountMode {
+ return super.channelCountMode;
+ }
+
+ public override set channelCountMode(value: ChannelCountMode) {
+ validateConvolverChannelCountMode(value);
+ super.channelCountMode = value;
+ }
+
public get buffer(): AudioBuffer | null {
return this._buffer;
}
diff --git a/packages/react-native-audio-api/src/core/DelayNode.ts b/packages/react-native-audio-api/src/core/DelayNode.ts
index 7e23bd12b..6d57f1fb0 100644
--- a/packages/react-native-audio-api/src/core/DelayNode.ts
+++ b/packages/react-native-audio-api/src/core/DelayNode.ts
@@ -2,11 +2,13 @@ import AudioNode from './AudioNode';
import AudioParam from './AudioParam';
import type BaseAudioContext from './BaseAudioContext';
import { DelayOptions } from '../types';
+import { DelayOptionsValidator } from '../utils/validation';
export default class DelayNode extends AudioNode {
readonly delayTime: AudioParam;
constructor(context: BaseAudioContext, options?: DelayOptions) {
+ DelayOptionsValidator.validate(options);
const delay = context.context.createDelay(options || {});
super(context, delay, options);
this.delayTime = new AudioParam(delay.delayTime, context, this);
diff --git a/packages/react-native-audio-api/src/utils/validation/convolver.ts b/packages/react-native-audio-api/src/utils/validation/convolver.ts
index 876f15260..ecb919596 100644
--- a/packages/react-native-audio-api/src/utils/validation/convolver.ts
+++ b/packages/react-native-audio-api/src/utils/validation/convolver.ts
@@ -1,5 +1,37 @@
import { NotSupportedError } from '../../errors';
-import { ConvolverOptions, OptionsValidator } from '../../types';
+import {
+ ChannelCountMode,
+ ConvolverOptions,
+ OptionsValidator,
+} from '../../types';
+
+const MAX_CONVOLVER_CHANNEL_COUNT = 2;
+
+/**
+ * Spec channel limitation: a ConvolverNode processes at most stereo input, so
+ * `channelCount` above 2 is a NotSupportedError (constructor and setter).
+ */
+export function validateConvolverChannelCount(channelCount: number): void {
+ if (channelCount > MAX_CONVOLVER_CHANNEL_COUNT) {
+ throw new NotSupportedError(
+ `The channelCount value (${channelCount}) of ConvolverNode must be 1 or 2.`
+ );
+ }
+}
+
+/**
+ * Spec channel limitation: `max` would let a multichannel input bypass the
+ * stereo limit, so only `clamped-max` and `explicit` are allowed.
+ */
+export function validateConvolverChannelCountMode(
+ channelCountMode: ChannelCountMode
+): void {
+ if (channelCountMode === 'max') {
+ throw new NotSupportedError(
+ `The channelCountMode value ('max') is not supported by ConvolverNode; use 'clamped-max' or 'explicit'.`
+ );
+ }
+}
export function validateConvolverBufferChannelCount(
numberOfChannels: number
@@ -28,10 +60,20 @@ export function validateConvolverBufferSampleRate(
export const ConvolverOptionsValidator: OptionsValidator = {
validate(options?: ConvolverOptions): void {
- if (!options?.buffer) {
+ if (!options) {
return;
}
- validateConvolverBufferChannelCount(options.buffer.numberOfChannels);
+ if (options.channelCount !== undefined) {
+ validateConvolverChannelCount(options.channelCount);
+ }
+
+ if (options.channelCountMode !== undefined) {
+ validateConvolverChannelCountMode(options.channelCountMode);
+ }
+
+ if (options.buffer) {
+ validateConvolverBufferChannelCount(options.buffer.numberOfChannels);
+ }
},
};
diff --git a/packages/react-native-audio-api/src/utils/validation/delay.ts b/packages/react-native-audio-api/src/utils/validation/delay.ts
new file mode 100644
index 000000000..4eccc46c5
--- /dev/null
+++ b/packages/react-native-audio-api/src/utils/validation/delay.ts
@@ -0,0 +1,29 @@
+import { NotSupportedError } from '../../errors';
+import { DelayOptions, OptionsValidator } from '../../types';
+
+const MAX_DELAY_TIME_LIMIT_SECONDS = 180;
+
+/**
+ * Spec: `maxDelayTime` must be strictly between 0 and 180 seconds, otherwise
+ * the constructor throws NotSupportedError. NaN is rejected earlier by WebIDL
+ * `double` conversion, which is a TypeError.
+ */
+export function validateDelayMaxDelayTime(maxDelayTime: number): void {
+ if (Number.isNaN(maxDelayTime)) {
+ throw new TypeError('The maxDelayTime value must be a finite number.');
+ }
+
+ if (!(maxDelayTime > 0 && maxDelayTime < MAX_DELAY_TIME_LIMIT_SECONDS)) {
+ throw new NotSupportedError(
+ `The maxDelayTime value (${maxDelayTime}) must be greater than 0 and less than ${MAX_DELAY_TIME_LIMIT_SECONDS} seconds.`
+ );
+ }
+}
+
+export const DelayOptionsValidator: OptionsValidator = {
+ validate(options?: DelayOptions): void {
+ if (options?.maxDelayTime !== undefined) {
+ validateDelayMaxDelayTime(options.maxDelayTime);
+ }
+ },
+};
diff --git a/packages/react-native-audio-api/src/utils/validation/index.ts b/packages/react-native-audio-api/src/utils/validation/index.ts
index e8a9dfa7d..ccdf005d4 100644
--- a/packages/react-native-audio-api/src/utils/validation/index.ts
+++ b/packages/react-native-audio-api/src/utils/validation/index.ts
@@ -13,8 +13,12 @@ export {
ConvolverOptionsValidator,
validateConvolverBufferChannelCount,
validateConvolverBufferSampleRate,
+ validateConvolverChannelCount,
+ validateConvolverChannelCountMode,
} from './convolver';
+export { DelayOptionsValidator, validateDelayMaxDelayTime } from './delay';
+
export { OscillatorOptionsValidator } from './oscillator';
export { PeriodicWaveOptionsValidator } from './periodicWave';