From 20f7019368cda36d6acd4b2a69860a4656dfbbe0 Mon Sep 17 00:00:00 2001 From: michal Date: Thu, 24 Sep 2026 13:16:17 +0200 Subject: [PATCH 1/4] refactor: changes for the wpt convolver and delay --- .../audiodocs/docs/effects/convolver-node.mdx | 4 ++ .../audiodocs/docs/effects/delay-node.mdx | 2 +- .../effects/DelayNodeHostObject.cpp | 19 +++----- .../core/sources/ConstantSourceNode.cpp | 2 +- .../core/sources/ConstantSourceNode.h | 4 ++ .../audioapi/core/sources/OscillatorNode.cpp | 2 +- .../audioapi/core/sources/OscillatorNode.h | 4 ++ .../common/cpp/audioapi/dsp/Convolver.cpp | 12 +++-- .../common/cpp/audioapi/types/NodeOptions.h | 13 +++++ .../src/core/sources/ConstantSourceTest.cpp | 11 +++++ .../test/src/core/sources/OscillatorTest.cpp | 11 +++++ .../src/core/ConvolverNode.ts | 22 ++++++++- .../src/core/DelayNode.ts | 2 + .../src/utils/validation/convolver.ts | 48 +++++++++++++++++-- .../src/utils/validation/delay.ts | 29 +++++++++++ .../src/utils/validation/index.ts | 4 ++ 16 files changed, 165 insertions(+), 24 deletions(-) create mode 100644 packages/react-native-audio-api/src/utils/validation/delay.ts diff --git a/packages/audiodocs/docs/effects/convolver-node.mdx b/packages/audiodocs/docs/effects/convolver-node.mdx index 75121af7c..5989a58f9 100644 --- a/packages/audiodocs/docs/effects/convolver-node.mdx +++ b/packages/audiodocs/docs/effects/convolver-node.mdx @@ -44,6 +44,10 @@ 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`. +::: + :::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/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/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/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/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/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'; From 79029390bd33b01c160dc4f1fed754c209fc7b68 Mon Sep 17 00:00:00 2001 From: michal Date: Fri, 25 Sep 2026 19:25:24 +0200 Subject: [PATCH 2/4] refactor: do not treat channel count as number of output channels --- .claude/skills/audio-nodes/SKILL.md | 8 +++- .../AudioBufferQueueSourceNodeHostObject.cpp | 2 +- .../AudioBufferSourceNodeHostObject.cpp | 7 +-- .../MediaElementAudioSourceNodeHostObject.h | 3 +- .../common/cpp/audioapi/core/AudioNode.cpp | 9 +++- .../common/cpp/audioapi/core/AudioNode.h | 28 +++++++----- .../sources/AudioBufferQueueSourceNode.cpp | 8 ++-- .../core/sources/AudioBufferQueueSourceNode.h | 6 +-- .../core/sources/AudioBufferSourceNode.cpp | 4 +- .../core/sources/AudioFileSourceNode.cpp | 20 +++++---- .../core/sources/ConstantSourceNode.cpp | 2 +- .../core/sources/ConstantSourceNode.h | 4 -- .../audioapi/core/sources/OscillatorNode.cpp | 2 +- .../audioapi/core/sources/OscillatorNode.h | 4 -- .../core/sources/RecorderAdapterNode.cpp | 20 ++++----- .../core/sources/RecorderAdapterNode.h | 2 +- .../audioapi/core/utils/graph/HostGraph.cpp | 13 +++--- .../common/cpp/audioapi/types/NodeOptions.h | 43 ++++++++++++------- .../src/core/sources/ConstantSourceTest.cpp | 16 ++++--- .../MediaElementAudioSourceNodeTest.cpp | 6 +-- .../test/src/core/sources/OscillatorTest.cpp | 14 +++--- .../common/cpp/test/src/graph/GraphTest.cpp | 28 ++++++++++++ .../ios/audioapi/ios/core/IOSAudioRecorder.mm | 2 +- 23 files changed, 156 insertions(+), 95 deletions(-) diff --git a/.claude/skills/audio-nodes/SKILL.md b/.claude/skills/audio-nodes/SKILL.md index cb6d6d963..7d379bbe9 100644 --- a/.claude/skills/audio-nodes/SKILL.md +++ b/.claude/skills/audio-nodes/SKILL.md @@ -351,7 +351,13 @@ Callback IDs are stored as `std::atomic` on the node. `0` means no lis All graph mutations are queued via `AudioGraphManager` using its own SPSC channel (`addPendingNodeConnection`, `addPendingParamConnection`). The audio thread calls `graphManager_->preProcessGraph()` before each render pass to apply pending changes. ### 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. +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 `outputChannelNumber_` together with the buffer 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 channel number vs `channelCount` (sources) +`channelCount` is the spec's input-mixing attribute and never decides how many channels a node emits. `AudioNode` keeps a separate atomic `outputChannelNumber_` (`getOutputChannelNumber()`), initialised from `AudioNodeOptions::outputChannelNumber` and falling back to `channelCount` for nodes whose output follows their negotiated input layout. `HostGraph` negotiation reads the output channel number for source inputs (`getUpstreamChannelCount` returns it when `numberOfInputs_ == 0`), so a spec-default `channelCount = 2` oscillator still negotiates a mono downstream buffer. Rules: +- A node that always emits one channel (Oscillator, ConstantSource) derives its options from `MonoSourceNodeOptions`; do not lower `channelCount` or override `setChannelCount` to fake it. +- A source that learns its width later (AudioBufferSource, AudioFileSource, AudioBufferQueueSource, RecorderAdapter) writes `outputChannelNumber_` in the same step it swaps `audioBuffer_`, never `channelCount_`. +- Read the emitted width from `getOutputChannelNumber()` on any thread; the buffer pointer itself is audio-thread only. ### 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/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferQueueSourceNodeHostObject.cpp b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferQueueSourceNodeHostObject.cpp index da65979ac..945bb68ae 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferQueueSourceNodeHostObject.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferQueueSourceNodeHostObject.cpp @@ -116,7 +116,7 @@ JSI_HOST_FUNCTION_IMPL(AudioBufferQueueSourceNodeHostObject, enqueueBuffer) { swapBuffer, channelCount = channelCount_](BaseAudioContext &) { if (swapBuffer) { - node->setChannelCount(static_cast(channelCount)); + node->setOutputChannelNumber(static_cast(channelCount)); } node->enqueueBuffer(copiedBuffer, bufferId, tailBuffer); }; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferSourceNodeHostObject.cpp b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferSourceNodeHostObject.cpp index 4f3373658..a0250467f 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferSourceNodeHostObject.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferSourceNodeHostObject.cpp @@ -161,14 +161,15 @@ void AudioBufferSourceNodeHostObject::setBuffer(const std::shared_ptr copiedBuffer; std::shared_ptr audioBuffer; - const size_t newChannelCount = buffer == nullptr ? AudioBufferSourceOptions::kDefaultChannelCount - : buffer->getNumberOfChannels(); + const size_t newChannelCount = buffer == nullptr + ? AudioBufferSourceOptions::kDefaultOutputChannelNumber + : buffer->getNumberOfChannels(); if (buffer == nullptr) { copiedBuffer = nullptr; audioBuffer = std::make_shared( RENDER_QUANTUM_SIZE, - AudioBufferSourceOptions::kDefaultChannelCount, + AudioBufferSourceOptions::kDefaultOutputChannelNumber, audioBufferSourceNode_->getContextSampleRate()); } else { if (pitchCorrection_) { diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/MediaElementAudioSourceNodeHostObject.h b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/MediaElementAudioSourceNodeHostObject.h index 192dfb2d9..edf4b76a7 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/MediaElementAudioSourceNodeHostObject.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/MediaElementAudioSourceNodeHostObject.h @@ -20,7 +20,8 @@ class MediaElementAudioSourceNodeHostObject : public AudioNodeHostObject { std::make_unique( context, fileSource, - MediaElementAudioSourceOptions(static_cast(fileSource->getChannelCount())))) {} + MediaElementAudioSourceOptions( + static_cast(fileSource->getOutputChannelNumber())))) {} }; } // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/AudioNode.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/AudioNode.cpp index 9973b143f..c6afd8255 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/AudioNode.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/AudioNode.cpp @@ -18,11 +18,12 @@ AudioNode::AudioNode( numberOfInputs_(options.numberOfInputs), numberOfOutputs_(options.numberOfOutputs), channelCount_(options.channelCount), + outputChannelNumber_(options.outputChannelNumber.value_or(options.channelCount)), channelCountMode_(options.channelCountMode), channelInterpretation_(options.channelInterpretation), requiresTailProcessing_(options.requiresTailProcessing) { audioBuffer_ = std::make_shared( - RENDER_QUANTUM_SIZE, channelCount_, context->getSampleRate()); + RENDER_QUANTUM_SIZE, outputChannelNumber_.load(), context->getSampleRate()); } bool AudioNode::canBeDestructed() const { @@ -44,7 +45,11 @@ bool AudioNode::isProcessable() const { } size_t AudioNode::getChannelCount() const { - return channelCount_.load(std::memory_order_acquire); + return static_cast(channelCount_); +} + +size_t AudioNode::getOutputChannelNumber() const { + return static_cast(outputChannelNumber_.load(std::memory_order_acquire)); } bool AudioNode::requiresTailProcessing() const { 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..10a6da200 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 @@ -29,12 +29,15 @@ class AudioNode : public utils::graph::GraphObject, public std::enable_shared_fr ~AudioNode() override = default; DELETE_COPY_AND_MOVE(AudioNode); - /// @brief Returns this node's `channelCount` attribute. - /// @note Safe to call from any thread: `channelCount_` is atomic because - /// source subclasses update it on the audio thread while the JS thread reads - /// it during channel-count negotiation. + /// @brief Returns this node's `channelCount` attribute: the width inputs are + /// mixed to. + /// @note Read only on the host thread (channel-count negotiation), so + /// `setChannelCount` from the JS thread is race-free with audio processing. [[nodiscard]] size_t getChannelCount() const; + /// @brief Returns how many channels this node emits on its output. + [[nodiscard]] size_t getOutputChannelNumber() const; + /// @brief Returns this node's `channelCountMode` attribute. /// @note Read only on the host thread (channel-count negotiation) — never on /// the audio thread — so mutating it from the JS thread via @@ -124,9 +127,10 @@ class AudioNode : public utils::graph::GraphObject, public std::enable_shared_fr /// @brief Channel count this node presents on upstream connections (toward /// AudioDestinationNode) after negotiation. Default: the negotiated channel - /// count. StereoPanner always outputs stereo. + /// count, except for sources (no inputs, nothing to negotiate), which + /// present `getOutputChannelNumber`. StereoPanner always outputs stereo. [[nodiscard]] virtual size_t getUpstreamChannelCount(size_t negotiatedChannelCount) const { - return negotiatedChannelCount; + return numberOfInputs_ == 0 ? getOutputChannelNumber() : negotiatedChannelCount; } /// @note JS Thread only @@ -206,14 +210,16 @@ class AudioNode : public utils::graph::GraphObject, public std::enable_shared_fr const int numberOfInputs_ = 1; const int numberOfOutputs_ = 1; - /// @brief Number of channels this node presents. + /// @brief The `channelCount` attribute (input mixing width). Host thread only. + int channelCount_ = 2; + /// @brief Number of channels this node emits; the width of `audioBuffer_` /// /// Atomic because it is read on the JS thread during channel-count - /// negotiation (`HostGraph`/`getChannelCount`) while source subclasses - /// (AudioBufferSource, Streamer, AudioFileSource, RecorderAdapter, + /// negotiation (`HostGraph`/`getOutputChannelNumber`) while source + /// subclasses (AudioBufferSource, AudioFileSource, RecorderAdapter, /// AudioBufferQueueSource) write it on the audio thread once they learn the - /// decoded/buffer channel count. Plain reads/writes here would race. - std::atomic channelCount_ = 2; + /// decoded/buffer channel count. + std::atomic outputChannelNumber_ = 2; ChannelCountMode channelCountMode_ = ChannelCountMode::MAX; ChannelInterpretation channelInterpretation_ = ChannelInterpretation::SPEAKERS; const bool requiresTailProcessing_; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferQueueSourceNode.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferQueueSourceNode.cpp index 3a8b77164..e48c912aa 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferQueueSourceNode.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferQueueSourceNode.cpp @@ -145,11 +145,11 @@ void AudioBufferQueueSourceNode::assignOnBufferEndedCallbackId(uint64_t callback onBufferEndedEvent_.assignCallbackId(callbackId); } -void AudioBufferQueueSourceNode::setChannelCount(int channelCount) { - if (channelCount_ != channelCount) { - channelCount_ = channelCount; +void AudioBufferQueueSourceNode::setOutputChannelNumber(int outputChannelNumber) { + if (outputChannelNumber_ != outputChannelNumber) { + outputChannelNumber_ = outputChannelNumber; audioBuffer_ = std::make_shared( - RENDER_QUANTUM_SIZE, channelCount_, getContextSampleRate()); + RENDER_QUANTUM_SIZE, outputChannelNumber, getContextSampleRate()); } } diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferQueueSourceNode.h b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferQueueSourceNode.h index 71ff621e9..5c66b0d02 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferQueueSourceNode.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferQueueSourceNode.h @@ -47,10 +47,10 @@ class AudioBufferQueueSourceNode : public AudioBufferBaseSourceNode { void assignOnBufferEndedCallbackId(uint64_t callbackId); - /// @brief Set the channel count of the node. Channel count is set only once when the first buffer is enqueued. - /// @param channelCount The channel count to set. + /// @brief Resizes the output to `outputChannelNumber` channels. Set once, + /// when the first buffer is enqueued. /// @note Audio Thread only - void setChannelCount(int channelCount); + void setOutputChannelNumber(int outputChannelNumber); protected: double getCurrentPosition() const override; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferSourceNode.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferSourceNode.cpp index fd8bd7cf8..47ff9b904 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferSourceNode.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferSourceNode.cpp @@ -75,7 +75,7 @@ void AudioBufferSourceNode::setBuffer( if (buffer == nullptr) { loopEnd_ = 0; - channelCount_ = AudioBufferSourceOptions::kDefaultChannelCount; + outputChannelNumber_ = AudioBufferSourceOptions::kDefaultOutputChannelNumber; buffer_ = nullptr; processor_->setBuffer(nullptr); @@ -85,7 +85,7 @@ void AudioBufferSourceNode::setBuffer( buffer_ = buffer; audioBuffer_ = audioBuffer; - channelCount_ = static_cast(buffer_->getNumberOfChannels()); + outputChannelNumber_ = static_cast(buffer_->getNumberOfChannels()); loopEnd_ = buffer_->getDuration(); processor_->setBuffer(buffer_); } diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioFileSourceNode.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioFileSourceNode.cpp index 494e59c39..b5f129eb7 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioFileSourceNode.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioFileSourceNode.cpp @@ -117,17 +117,18 @@ bool AudioFileSourceNode::initDecoder( return false; } - channelCount_ = decoderState_->channelCount.load(); + outputChannelNumber_ = decoderState_->channelCount.load(); sampleRate_ = decoderState_->sampleRate; duration_ = decoderState_->duration; audioBuffer_ = std::make_shared( - static_cast(RENDER_QUANTUM_SIZE), channelCount_, context->getSampleRate()); - wsolaStretcher_.configure(static_cast(channelCount_), static_cast(sampleRate_)); + static_cast(RENDER_QUANTUM_SIZE), outputChannelNumber_, context->getSampleRate()); + wsolaStretcher_.configure( + static_cast(outputChannelNumber_), static_cast(sampleRate_)); playbackRateBuffer_ = std::make_shared( std::max( static_cast(DecoderData::MAX_FRAMES), wsolaStretcher_.getRequiredInputFrames()), - channelCount_, + outputChannelNumber_, context->getSampleRate()); return true; @@ -173,7 +174,7 @@ void AudioFileSourceNode::stashPendingDecoderChunk( } const size_t remaining = chunk.size - consumedFrames; - const auto channels = static_cast(channelCount_); + const auto channels = static_cast(outputChannelNumber_); pendingDecoderChunk_.state = chunk.state; pendingDecoderChunk_.timestamp = chunk.timestamp; @@ -196,7 +197,7 @@ void AudioFileSourceNode::consumePendingDecoderChunkFront(size_t consumedFrames) } const size_t remaining = pendingDecoderChunk_.size - consumedFrames; - const auto channels = static_cast(channelCount_); + const auto channels = static_cast(outputChannelNumber_); std::memmove( pendingDecoderChunk_.interleavedBuffer.data(), @@ -231,7 +232,8 @@ bool AudioFileSourceNode::ensurePlaybackRateBufferSize(size_t frames) { if (playbackRateBuffer_ == nullptr || playbackRateBuffer_->getSize() < frames) { const float bufferSampleRate = audioBuffer_ != nullptr ? audioBuffer_->getSampleRate() : static_cast(sampleRate_); - playbackRateBuffer_ = std::make_shared(frames, channelCount_, bufferSampleRate); + playbackRateBuffer_ = + std::make_shared(frames, outputChannelNumber_, bufferSampleRate); } return playbackRateBuffer_ != nullptr; @@ -482,7 +484,7 @@ size_t AudioFileSourceNode::appendFromInterleaved( return 0; } - const auto channels = static_cast(channelCount_); + const auto channels = static_cast(outputChannelNumber_); playbackRateBuffer_->deinterleaveFrom( interleaved + startFrame * channels, totalInputFrames, frames); @@ -608,7 +610,7 @@ size_t AudioFileSourceNode::renderWithoutPitchPreservation( ? static_cast(inputFrames - 1) / static_cast(outputFrames - 1) : 0.0f; - for (size_t channel = 0; channel < static_cast(channelCount_); ++channel) { + for (size_t channel = 0; channel < static_cast(outputChannelNumber_); ++channel) { const float *input = playbackRateBuffer_->getChannel(channel)->begin(); float *output = processingBuffer->getChannel(channel)->begin(); 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 b0f0b96ce..b8162b91d 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, options.withMonoOutput()), + : AudioScheduledSourceNode(context, options), 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 c98365da4..f2c5d1f4c 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,10 +18,6 @@ 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 892121f88..107eb5431 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.withMonoOutput()), type_(options.type) { + : AudioScheduledSourceNode(context, options), 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 9384697d5..28890fa81 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,10 +34,6 @@ 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/sources/RecorderAdapterNode.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/RecorderAdapterNode.cpp index 9b3ac4099..927b81b5f 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/RecorderAdapterNode.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/RecorderAdapterNode.cpp @@ -27,11 +27,11 @@ void RecorderAdapterNode::init(size_t bufferSize, int channelCount, float sample return; } - channelCount_ = channelCount; + outputChannelNumber_ = channelCount; - buff_.resize(channelCount_); + buff_.resize(outputChannelNumber_); - for (int i = 0; i < channelCount_; ++i) { + for (int i = 0; i < outputChannelNumber_; ++i) { buff_[i] = std::make_shared(bufferSize); } @@ -39,21 +39,21 @@ void RecorderAdapterNode::init(size_t bufferSize, int channelCount, float sample needsResampling_ = static_cast(sampleRate) != static_cast(contextSampleRate); adapterOutputBuffer_ = - std::make_shared(RENDER_QUANTUM_SIZE, channelCount_, contextSampleRate); + std::make_shared(RENDER_QUANTUM_SIZE, outputChannelNumber_, contextSampleRate); if (needsResampling_) { inputChunkSize_ = static_cast(std::ceil(RENDER_QUANTUM_SIZE * sampleRate / contextSampleRate)) + 4; resampler_ = std::make_unique( - sampleRate, contextSampleRate, channelCount_, static_cast(inputChunkSize_)); + sampleRate, contextSampleRate, outputChannelNumber_, static_cast(inputChunkSize_)); const int maxOutLen = resampler_->getMaxOutLen(); - resamplerInputBuffer_ = AudioBuffer(inputChunkSize_, channelCount_, sampleRate); + resamplerInputBuffer_ = AudioBuffer(inputChunkSize_, outputChannelNumber_, sampleRate); resamplerOutputBuffer_ = - AudioBuffer(static_cast(maxOutLen), channelCount_, contextSampleRate); - overflowBuffer_ = AudioBuffer(2 * maxOutLen, channelCount_, contextSampleRate); + AudioBuffer(static_cast(maxOutLen), outputChannelNumber_, contextSampleRate); + overflowBuffer_ = AudioBuffer(2 * maxOutLen, outputChannelNumber_, contextSampleRate); overflowSize_ = 0; } @@ -108,7 +108,7 @@ void RecorderAdapterNode::processResampled(int framesToProcess) { if (toCopy < overflowSize_) { const size_t remaining = overflowSize_ - toCopy; - for (int ch = 0; ch < channelCount_; ++ch) { + for (int ch = 0; ch < outputChannelNumber_; ++ch) { overflowBuffer_[ch].copyWithin(toCopy, 0, remaining); } } @@ -142,7 +142,7 @@ void RecorderAdapterNode::processResampled(int framesToProcess) { void RecorderAdapterNode::readFrames(AudioBuffer &target, const size_t framesToRead) { target.zero(); - for (size_t channel = 0; channel < channelCount_; ++channel) { + for (size_t channel = 0; channel < outputChannelNumber_; ++channel) { buff_[channel]->read(*target.getChannel(channel), framesToRead); } } diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/RecorderAdapterNode.h b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/RecorderAdapterNode.h index bb72c90d8..b7ea6f954 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/RecorderAdapterNode.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/RecorderAdapterNode.h @@ -26,7 +26,7 @@ class RecorderAdapterNode : public AudioNode { /// @brief Initialize the RecorderAdapterNode with a buffer size and channel count. /// @note This method should be called ONLY ONCE when the buffer size is known. /// @param bufferSize The size of the buffer to be used. - /// @param channelCount The number of channels. + /// @param channelCount The number of channels the recorder delivers; becomes the output channel number. /// @param sampleRate The recorder's native sample rate. void init(size_t bufferSize, int channelCount, float sampleRate); void adapterCleanup(); 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..4a05ccf24 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 @@ -49,18 +49,17 @@ inline audioapi::AudioNode *audioNodeOf(const HostGraph::Node *node) { /// @brief Returns how many channels `audio` presents on upstream connections /// (toward AudioDestinationNode). /// -/// Reads the atomic `channelCount_` attribute rather than the output buffer: -/// the buffer's `shared_ptr` is swapped on the audio thread (setBuffer / +/// Reads the atomic output channel number rather than the output buffer: the +/// buffer's `shared_ptr` is swapped on the audio thread (setBuffer / /// applyChannelNegotiations), so reading it here on the JS thread would race. -/// For source nodes `channelCount_` already tracks the buffer's width, and this -/// helper is only the fallback for inputs not yet resolved in the current +/// This helper is only the fallback for inputs not yet resolved in the current /// negotiation pass (traversals resolve every input first via /// `resolveChannelCountForNode`). -size_t outputChannelCountOf(const audioapi::AudioNode *audio) { +size_t outputChannelNumberOf(const audioapi::AudioNode *audio) { if (audio == nullptr) { return 0; } - return audio->getChannelCount(); + return audio->getOutputChannelNumber(); } /// @brief Computes the channel count that `dest`'s negotiated buffer must carry @@ -98,7 +97,7 @@ size_t negotiateChannelCount(const HostGraph::Node *dest, size_t term) { if (input->channelLayout.isResolvedFor(term)) { c = input->channelLayout.upstreamChannelCount; } else { - c = outputChannelCountOf(inAudio); + c = outputChannelNumberOf(inAudio); } maxInputChannels = std::max(c, maxInputChannels); } 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 fff40066c..53f67217a 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 @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -18,6 +19,10 @@ namespace audioapi { struct AudioNodeOptions { int channelCount = 2; + /// Number of channels the node emits. Unset means "same as `channelCount`", + /// which is the right default for every node whose output follows its + /// negotiated input layout. + std::optional outputChannelNumber; ChannelCountMode channelCountMode = ChannelCountMode::MAX; ChannelInterpretation channelInterpretation = ChannelInterpretation::SPEAKERS; int numberOfInputs = 1; @@ -44,14 +49,20 @@ struct AudioScheduledSourceNodeOptions : AudioNodeOptions { explicit AudioScheduledSourceNodeOptions(AudioNodeOptions options) : AudioNodeOptions(options) { numberOfInputs = 0; } +}; + +/// Sources whose output is always a single channel (spec: OscillatorNode, +/// ConstantSourceNode). +struct MonoSourceNodeOptions : AudioScheduledSourceNodeOptions { + static constexpr int kOutputChannelNumber = 1; - /// 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; + MonoSourceNodeOptions() { + outputChannelNumber = kOutputChannelNumber; + } + + explicit MonoSourceNodeOptions(AudioNodeOptions options) + : AudioScheduledSourceNodeOptions(options) { + outputChannelNumber = kOutputChannelNumber; } }; @@ -88,8 +99,10 @@ struct ConvolverOptions : AudioNodeOptions { } }; -struct ConstantSourceOptions : AudioScheduledSourceNodeOptions { +struct ConstantSourceOptions : MonoSourceNodeOptions { float offset = 1.0f; + + using MonoSourceNodeOptions::MonoSourceNodeOptions; }; struct AnalyserOptions : AudioNodeOptions { @@ -120,14 +133,14 @@ struct BiquadFilterOptions : AudioNodeOptions { } }; -struct OscillatorOptions : AudioScheduledSourceNodeOptions { +struct OscillatorOptions : MonoSourceNodeOptions { static constexpr float kDefaultFrequency = 440.0f; std::shared_ptr periodicWave = nullptr; float frequency = kDefaultFrequency; float detune = 0.0f; OscillatorType type = OscillatorType::SINE; - using AudioScheduledSourceNodeOptions::AudioScheduledSourceNodeOptions; + using MonoSourceNodeOptions::MonoSourceNodeOptions; }; struct BaseAudioBufferSourceOptions : AudioScheduledSourceNodeOptions { @@ -138,8 +151,8 @@ struct BaseAudioBufferSourceOptions : AudioScheduledSourceNodeOptions { }; struct AudioBufferSourceOptions : BaseAudioBufferSourceOptions { - /// Spec default when no buffer is set (mono). - static constexpr size_t kDefaultChannelCount = 1; + /// Spec: with no buffer assigned the node emits one channel of silence. + static constexpr size_t kDefaultOutputChannelNumber = 1; std::shared_ptr buffer = nullptr; float loopStart = 0.0f; @@ -149,7 +162,7 @@ struct AudioBufferSourceOptions : BaseAudioBufferSourceOptions { explicit AudioBufferSourceOptions(BaseAudioBufferSourceOptions options) : BaseAudioBufferSourceOptions(options) { - channelCount = kDefaultChannelCount; + outputChannelNumber = kDefaultOutputChannelNumber; } }; @@ -166,10 +179,10 @@ struct AudioFileSourceOptions : AudioScheduledSourceNodeOptions { }; struct MediaElementAudioSourceOptions : AudioNodeOptions { - explicit MediaElementAudioSourceOptions(int mediaChannelCount = 2) { + explicit MediaElementAudioSourceOptions(int mediaOutputChannelNumber = 2) { numberOfInputs = 0; numberOfOutputs = 1; - channelCount = mediaChannelCount; + outputChannelNumber = mediaOutputChannelNumber; } }; 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 3f122b41b..30eb8148e 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 @@ -42,14 +42,18 @@ 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. + // Spec: the constant source output is a single channel while `channelCount` + // keeps its default of 2 and stays settable; it only describes input mixing. 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); + EXPECT_EQ(constantSource->getOutputChannelNumber(), 1u); + EXPECT_EQ(constantSource->getChannelCount(), 2u); + EXPECT_EQ(constantSource->getUpstreamChannelCount(2), 1u); + + constantSource->setChannelCount(4); + EXPECT_EQ(constantSource->getChannelCount(), 4u); + EXPECT_EQ(constantSource->getOutputChannelNumber(), 1u); + EXPECT_EQ(constantSource->getUpstreamChannelCount(4), 1u); } TEST_F(ConstantSourceTest, ConstantSourceCanBeCreated) { diff --git a/packages/react-native-audio-api/common/cpp/test/src/core/sources/MediaElementAudioSourceNodeTest.cpp b/packages/react-native-audio-api/common/cpp/test/src/core/sources/MediaElementAudioSourceNodeTest.cpp index 62b871e15..6c666e8eb 100644 --- a/packages/react-native-audio-api/common/cpp/test/src/core/sources/MediaElementAudioSourceNodeTest.cpp +++ b/packages/react-native-audio-api/common/cpp/test/src/core/sources/MediaElementAudioSourceNodeTest.cpp @@ -128,7 +128,7 @@ TEST_F(MediaElementAudioSourceNodeTest, DisconnectingLastOutputReleasesMediaBind auto media = std::make_shared( context, fileSource.get(), - MediaElementAudioSourceOptions(static_cast(fileSource->getChannelCount()))); + MediaElementAudioSourceOptions(static_cast(fileSource->getOutputChannelNumber()))); ASSERT_TRUE(fileSource->isRoutedThroughMediaElement()); @@ -144,11 +144,11 @@ TEST_F(MediaElementAudioSourceNodeTest, StaleMediaNodeOutputsSilence) { auto mediaA = std::make_shared( context, fileSource.get(), - MediaElementAudioSourceOptions(static_cast(fileSource->getChannelCount()))); + MediaElementAudioSourceOptions(static_cast(fileSource->getOutputChannelNumber()))); auto mediaB = std::make_shared( context, fileSource.get(), - MediaElementAudioSourceOptions(static_cast(fileSource->getChannelCount()))); + MediaElementAudioSourceOptions(static_cast(fileSource->getOutputChannelNumber()))); ASSERT_TRUE(fileSource->isCurrentMediaElementSource(mediaB->getBindingId())); 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 4536a46f0..3a2a80003 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 @@ -25,14 +25,18 @@ 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. + // Spec: the oscillator output is a single channel while `channelCount` + // keeps its default of 2 and stays settable; it only describes input mixing. auto osc = std::make_shared(context, OscillatorOptions()); EXPECT_EQ(osc->getOutputBuffer()->getNumberOfChannels(), 1u); - EXPECT_EQ(osc->getChannelCount(), 1u); + EXPECT_EQ(osc->getOutputChannelNumber(), 1u); + EXPECT_EQ(osc->getChannelCount(), 2u); + EXPECT_EQ(osc->getUpstreamChannelCount(2), 1u); - osc->setChannelCount(2); - EXPECT_EQ(osc->getChannelCount(), 1u); + osc->setChannelCount(4); + EXPECT_EQ(osc->getChannelCount(), 4u); + EXPECT_EQ(osc->getOutputChannelNumber(), 1u); + EXPECT_EQ(osc->getUpstreamChannelCount(4), 1u); } TEST_F(OscillatorTest, OscillatorCanBeCreated) { 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 275cea9d0..4a5cc8548 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 @@ -87,6 +87,19 @@ inline HostGraph::Node *addChannelCountNode(Graph &graph, const ChannelOpts &opt return graph.addNode(std::move(audioNode)); } +/// Adds a true source (no inputs) whose emitted width is decoupled from its +/// `channelCount` attribute, the way OscillatorNode / AudioBufferSourceNode are. +inline HostGraph::Node * +addSourceNode(Graph &graph, int channelCountAttribute, int outputChannelNumber) { + audioapi::AudioNodeOptions audioNodeOpts; + audioNodeOpts.numberOfInputs = 0; + audioNodeOpts.channelCount = channelCountAttribute; + audioNodeOpts.outputChannelNumber = outputChannelNumber; + + auto audioNode = std::make_unique(getGraphTestContext(), audioNodeOpts); + return graph.addNode(std::move(audioNode)); +} + inline HostGraph::Node *addStereoPannerNode(Graph &graph) { audioapi::StereoPannerOptions options; auto audioNode = std::make_unique(getGraphTestContext(), options); @@ -251,6 +264,21 @@ TEST_F(GraphTest, ChannelCountNegotiation_MaxMode_SingleInput) { "must be resized to 4 channels (channelCount attribute is ignored)"; } +TEST_F(GraphTest, ChannelCountNegotiation_SourceOutputChannelNumberIgnoresItsChannelCount) { + auto *monoSource = addSourceNode(*graph, /*channelCountAttribute=*/2, /*outputChannelNumber=*/1); + auto *dest = addChannelCountNode(*graph, {.channelCount = 2, .mode = ChannelCountMode::MAX}); + graph->processEvents(); + + EXPECT_EQ(channelsOf(monoSource), 1u) << "A source's buffer follows outputChannelNumber"; + + ASSERT_TRUE(graph->addEdge(monoSource, dest).is_ok()); + graph->processEvents(); + + EXPECT_EQ(channelsOf(dest), 1u) + << "MAX mode: a mono source with channelCount=2 (spec default for oscillator / " + "constant source) must negotiate a 1-channel downstream buffer"; +} + TEST_F(GraphTest, ChannelCountNegotiation_MaxMode_MultipleInputsTakeMax) { auto *mono = addChannelCountNode(*graph, {.channelCount = 1, .mode = ChannelCountMode::EXPLICIT}); auto *six = addChannelCountNode(*graph, {.channelCount = 6, .mode = ChannelCountMode::EXPLICIT}); diff --git a/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.mm b/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.mm index 4360efade..2c09c1490 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.mm +++ b/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.mm @@ -104,7 +104,7 @@ static void cleanupStartedRecorder( if (isConnected()) { if (auto lock = Locker::tryLock(adapterNodeMutex_)) { auto *adapterNode = static_cast(adapterNodeHandle_->audioNode.get()); - for (size_t channel = 0; channel < adapterNode->getChannelCount(); ++channel) { + for (size_t channel = 0; channel < adapterNode->getOutputChannelNumber(); ++channel) { auto *data = static_cast(inputBuffer->mBuffers[channel].mData); adapterNode->buff_[channel]->write(data, numFrames); } From c8578e37d480619047ed3180403293f271fc3628 Mon Sep 17 00:00:00 2001 From: michal Date: Fri, 25 Sep 2026 19:49:15 +0200 Subject: [PATCH 3/4] fix: failing wpt tests --- .claude/skills/audio-nodes/SKILL.md | 1 + .../AudioBufferQueueSourceNodeHostObject.cpp | 2 +- .../AudioBufferSourceNodeHostObject.cpp | 11 ++++++---- .../common/cpp/audioapi/core/AudioNode.h | 4 ++++ .../sources/AudioBufferQueueSourceNode.cpp | 4 ++-- .../core/sources/AudioBufferQueueSourceNode.h | 6 +++--- .../common/cpp/test/src/graph/GraphTest.cpp | 20 +++++++++++++++++++ 7 files changed, 38 insertions(+), 10 deletions(-) diff --git a/.claude/skills/audio-nodes/SKILL.md b/.claude/skills/audio-nodes/SKILL.md index 7d379bbe9..89dbf40b3 100644 --- a/.claude/skills/audio-nodes/SKILL.md +++ b/.claude/skills/audio-nodes/SKILL.md @@ -357,6 +357,7 @@ These are mutable after construction. `AudioNode` (core) exposes virtual `setCha `channelCount` is the spec's input-mixing attribute and never decides how many channels a node emits. `AudioNode` keeps a separate atomic `outputChannelNumber_` (`getOutputChannelNumber()`), initialised from `AudioNodeOptions::outputChannelNumber` and falling back to `channelCount` for nodes whose output follows their negotiated input layout. `HostGraph` negotiation reads the output channel number for source inputs (`getUpstreamChannelCount` returns it when `numberOfInputs_ == 0`), so a spec-default `channelCount = 2` oscillator still negotiates a mono downstream buffer. Rules: - A node that always emits one channel (Oscillator, ConstantSource) derives its options from `MonoSourceNodeOptions`; do not lower `channelCount` or override `setChannelCount` to fake it. - A source that learns its width later (AudioBufferSource, AudioFileSource, AudioBufferQueueSource, RecorderAdapter) writes `outputChannelNumber_` in the same step it swaps `audioBuffer_`, never `channelCount_`. +- When the host object schedules that swap (AudioBufferSourceNode `setBuffer`), it must first call `setOutputChannelNumber()` on the host thread and `renegotiate()`, otherwise downstream MAX / CLAMPED_MAX nodes negotiate against the stale width until the audio event lands (this broke the StereoPanner WPT `stereopannernode-panning` test when it lived on `updateChannelCount`). Never route this through `updateChannelCount`: that changes the JS-visible `channelCount` attribute. - Read the emitted width from `getOutputChannelNumber()` on any thread; the buffer pointer itself is audio-thread only. ### Idle-node stale-buffer zeroing (settleProcessableState) diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferQueueSourceNodeHostObject.cpp b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferQueueSourceNodeHostObject.cpp index 945bb68ae..6e8a98d35 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferQueueSourceNodeHostObject.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferQueueSourceNodeHostObject.cpp @@ -116,7 +116,7 @@ JSI_HOST_FUNCTION_IMPL(AudioBufferQueueSourceNodeHostObject, enqueueBuffer) { swapBuffer, channelCount = channelCount_](BaseAudioContext &) { if (swapBuffer) { - node->setOutputChannelNumber(static_cast(channelCount)); + node->resizeOutputBuffer(static_cast(channelCount)); } node->enqueueBuffer(copiedBuffer, bufferId, tailBuffer); }; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferSourceNodeHostObject.cpp b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferSourceNodeHostObject.cpp index a0250467f..141388eb8 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferSourceNodeHostObject.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferSourceNodeHostObject.cpp @@ -161,7 +161,7 @@ void AudioBufferSourceNodeHostObject::setBuffer(const std::shared_ptr copiedBuffer; std::shared_ptr audioBuffer; - const size_t newChannelCount = buffer == nullptr + const size_t newOutputChannelNumber = buffer == nullptr ? AudioBufferSourceOptions::kDefaultOutputChannelNumber : buffer->getNumberOfChannels(); @@ -191,9 +191,12 @@ void AudioBufferSourceNodeHostObject::setBuffer(const std::shared_ptrgetContextSampleRate()); } - // Update channelCount on the host thread before renegotiation so MAX / - // CLAMPED_MAX downstream nodes see the new width immediately. - updateChannelCount(newChannelCount); + // Publish the new output width on the host thread before renegotiation so + // MAX / CLAMPED_MAX downstream nodes see it immediately + if (newOutputChannelNumber != audioBufferSourceNode_->getOutputChannelNumber()) { + audioBufferSourceNode_->setOutputChannelNumber(newOutputChannelNumber); + renegotiate(); + } auto event = [handle, node = audioBufferSourceNode_, copiedBuffer, audioBuffer](BaseAudioContext &) { 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 10a6da200..cc0f7c040 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 @@ -38,6 +38,10 @@ class AudioNode : public utils::graph::GraphObject, public std::enable_shared_fr /// @brief Returns how many channels this node emits on its output. [[nodiscard]] size_t getOutputChannelNumber() const; + void setOutputChannelNumber(size_t outputChannelNumber) { + outputChannelNumber_.store(static_cast(outputChannelNumber), std::memory_order_release); + } + /// @brief Returns this node's `channelCountMode` attribute. /// @note Read only on the host thread (channel-count negotiation) — never on /// the audio thread — so mutating it from the JS thread via diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferQueueSourceNode.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferQueueSourceNode.cpp index e48c912aa..585a2cc23 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferQueueSourceNode.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferQueueSourceNode.cpp @@ -145,9 +145,9 @@ void AudioBufferQueueSourceNode::assignOnBufferEndedCallbackId(uint64_t callback onBufferEndedEvent_.assignCallbackId(callbackId); } -void AudioBufferQueueSourceNode::setOutputChannelNumber(int outputChannelNumber) { +void AudioBufferQueueSourceNode::resizeOutputBuffer(int outputChannelNumber) { if (outputChannelNumber_ != outputChannelNumber) { - outputChannelNumber_ = outputChannelNumber; + setOutputChannelNumber(static_cast(outputChannelNumber)); audioBuffer_ = std::make_shared( RENDER_QUANTUM_SIZE, outputChannelNumber, getContextSampleRate()); } diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferQueueSourceNode.h b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferQueueSourceNode.h index 5c66b0d02..1ad869526 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferQueueSourceNode.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferQueueSourceNode.h @@ -47,10 +47,10 @@ class AudioBufferQueueSourceNode : public AudioBufferBaseSourceNode { void assignOnBufferEndedCallbackId(uint64_t callbackId); - /// @brief Resizes the output to `outputChannelNumber` channels. Set once, - /// when the first buffer is enqueued. + /// @brief Replaces the output buffer with one of `outputChannelNumber` + /// channels. Happens once, when the first buffer is enqueued. /// @note Audio Thread only - void setOutputChannelNumber(int outputChannelNumber); + void resizeOutputBuffer(int outputChannelNumber); protected: double getCurrentPosition() const override; 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 4a5cc8548..ef8145de7 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 @@ -514,6 +514,26 @@ TEST_F(GraphTest, RenegotiateNode_ExplicitCountChange_UpdatesBuffer) { << "After changing channelCount to 6 and renegotiating, the buffer must be 6 channels"; } +TEST_F(GraphTest, RenegotiateNode_SourceOutputChannelNumberChange_CascadesDownstream) { + // Mirrors AudioBufferSourceNode.setBuffer: the host object publishes the new + // output width, then renegotiates, before the audio thread swaps the buffer. + auto *source = addSourceNode(*graph, /*channelCountAttribute=*/2, /*outputChannelNumber=*/1); + auto *dest = addChannelCountNode(*graph, {.channelCount = 2, .mode = ChannelCountMode::MAX}); + graph->processEvents(); + + ASSERT_TRUE(graph->addEdge(source, dest).is_ok()); + graph->processEvents(); + ASSERT_EQ(channelsOf(dest), 1u) << "MAX mode follows the mono source"; + + source->handle->audioNode->asAudioNode()->setOutputChannelNumber(2); + ASSERT_TRUE(graph->renegotiateNodeChannels(source).is_ok()); + graph->processEvents(); + + EXPECT_EQ(channelsOf(dest), 2u) + << "After the source publishes a stereo output and renegotiates, the MAX-mode " + "downstream buffer must widen to 2 channels"; +} + TEST_F(GraphTest, RenegotiateNode_CascadesDownstream) { auto *source = addChannelCountNode(*graph, {.channelCount = 2, .mode = ChannelCountMode::EXPLICIT}); From 62ee6bf331bc7996db077c1c4292d6ea27126b02 Mon Sep 17 00:00:00 2001 From: michal Date: Tue, 29 Sep 2026 10:29:22 +0200 Subject: [PATCH 4/4] refactor: renaming for much clearer view on channel negotiation --- .../AudioBufferQueueSourceNodeHostObject.cpp | 13 ++- .../AudioBufferQueueSourceNodeHostObject.h | 3 +- .../MediaElementAudioSourceNodeHostObject.h | 2 +- .../common/cpp/audioapi/core/AudioNode.cpp | 4 - .../common/cpp/audioapi/core/AudioNode.h | 32 ++++--- .../core/effects/StereoPannerNode.cpp | 6 +- .../audioapi/core/effects/StereoPannerNode.h | 5 +- .../core/sources/AudioBufferSourceNode.cpp | 2 - .../core/sources/AudioScheduledSourceNode.cpp | 4 + .../core/sources/AudioScheduledSourceNode.h | 3 + .../sources/MediaElementAudioSourceNode.cpp | 4 + .../sources/MediaElementAudioSourceNode.h | 2 + .../core/sources/RecorderAdapterNode.cpp | 4 + .../core/sources/RecorderAdapterNode.h | 3 + .../cpp/audioapi/core/utils/graph/Graph.h | 2 +- .../audioapi/core/utils/graph/HostGraph.cpp | 90 ++++++++----------- .../cpp/audioapi/core/utils/graph/HostGraph.h | 10 +-- .../cpp/audioapi/core/utils/graph/HostNode.h | 2 +- .../sources/AudioBufferSourceNodeTest.cpp | 16 ++++ .../src/core/sources/ConstantSourceTest.cpp | 2 - .../MediaElementAudioSourceNodeTest.cpp | 9 +- .../test/src/core/sources/OscillatorTest.cpp | 2 - .../common/cpp/test/src/graph/GraphTest.cpp | 78 +++++++++++----- .../ios/audioapi/ios/core/IOSAudioRecorder.mm | 2 +- 24 files changed, 173 insertions(+), 127 deletions(-) diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferQueueSourceNodeHostObject.cpp b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferQueueSourceNodeHostObject.cpp index 6e8a98d35..e4ffc912f 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferQueueSourceNodeHostObject.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferQueueSourceNodeHostObject.cpp @@ -82,16 +82,15 @@ JSI_HOST_FUNCTION_IMPL(AudioBufferQueueSourceNodeHostObject, enqueueBuffer) { // buffer modification is not allowed on JS thread auto swapBuffer = false; // whether to swap internal node buffer with the new buffer - if (!channelCountSet_) { - channelCount_ = static_cast(audioBufferHostObject->audioBuffer_->getNumberOfChannels()); - channelCountSet_ = true; + if (outputChannelNumber_ == 0) { + outputChannelNumber_ = audioBufferHostObject->audioBuffer_->getNumberOfChannels(); swapBuffer = true; } - // first buffer defines channel count, rest of them is mixed to channel count of the first buffer + // first buffer defines the output channel number, the rest are mixed to it auto copiedBuffer = std::make_shared( audioBufferHostObject->audioBuffer_->getSize(), - channelCount_, + outputChannelNumber_, audioBufferHostObject->audioBuffer_->getSampleRate()); copiedBuffer->sum(*audioBufferHostObject->audioBuffer_); @@ -114,9 +113,9 @@ JSI_HOST_FUNCTION_IMPL(AudioBufferQueueSourceNodeHostObject, enqueueBuffer) { bufferId = bufferId_, tailBuffer, swapBuffer, - channelCount = channelCount_](BaseAudioContext &) { + outputChannelNumber = outputChannelNumber_](BaseAudioContext &) { if (swapBuffer) { - node->resizeOutputBuffer(static_cast(channelCount)); + node->resizeOutputBuffer(static_cast(outputChannelNumber)); } node->enqueueBuffer(copiedBuffer, bufferId, tailBuffer); }; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferQueueSourceNodeHostObject.h b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferQueueSourceNodeHostObject.h index 7ea347e97..f13e7c966 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferQueueSourceNodeHostObject.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferQueueSourceNodeHostObject.h @@ -43,7 +43,8 @@ class AudioBufferQueueSourceNodeHostObject : public AudioBufferBaseSourceNodeHos size_t bufferId_ = 0; bool stretchHasBeenInit_ = false; - bool channelCountSet_ = false; + /// Width of the first enqueued buffer; later buffers are mixed to it. 0 until then. + size_t outputChannelNumber_ = 0; }; } // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/MediaElementAudioSourceNodeHostObject.h b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/MediaElementAudioSourceNodeHostObject.h index edf4b76a7..067505b6e 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/MediaElementAudioSourceNodeHostObject.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/MediaElementAudioSourceNodeHostObject.h @@ -21,7 +21,7 @@ class MediaElementAudioSourceNodeHostObject : public AudioNodeHostObject { context, fileSource, MediaElementAudioSourceOptions( - static_cast(fileSource->getOutputChannelNumber())))) {} + static_cast(fileSource->getOutputChannelNumber().value())))) {} }; } // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/AudioNode.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/AudioNode.cpp index c6afd8255..9c2fa50bd 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/AudioNode.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/AudioNode.cpp @@ -48,10 +48,6 @@ size_t AudioNode::getChannelCount() const { return static_cast(channelCount_); } -size_t AudioNode::getOutputChannelNumber() const { - return static_cast(outputChannelNumber_.load(std::memory_order_acquire)); -} - bool AudioNode::requiresTailProcessing() const { return requiresTailProcessing_; } 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 cc0f7c040..2bb8d138d 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 @@ -14,6 +14,7 @@ #include #include #include +#include #include #include @@ -31,19 +32,16 @@ class AudioNode : public utils::graph::GraphObject, public std::enable_shared_fr /// @brief Returns this node's `channelCount` attribute: the width inputs are /// mixed to. - /// @note Read only on the host thread (channel-count negotiation), so + /// @note Read only on the host thread (channel negotiation), so /// `setChannelCount` from the JS thread is race-free with audio processing. [[nodiscard]] size_t getChannelCount() const; - /// @brief Returns how many channels this node emits on its output. - [[nodiscard]] size_t getOutputChannelNumber() const; - void setOutputChannelNumber(size_t outputChannelNumber) { outputChannelNumber_.store(static_cast(outputChannelNumber), std::memory_order_release); } /// @brief Returns this node's `channelCountMode` attribute. - /// @note Read only on the host thread (channel-count negotiation) — never on + /// @note Read only on the host thread (channel negotiation) — never on /// the audio thread — so mutating it from the JS thread via /// `setChannelCountMode` is race-free with audio processing. [[nodiscard]] ChannelCountMode getChannelCountMode() const { @@ -54,7 +52,7 @@ class AudioNode : public utils::graph::GraphObject, public std::enable_shared_fr return channelInterpretation_; } - /// @brief Sets `channelCount`. Drives channel-count negotiation, which reads + /// @brief Sets `channelCount`. Drives channel negotiation, which reads /// this value on the host thread. Callers must trigger a renegotiation so /// the change propagates to buffer layouts. /// @note Host (JS) thread only. Overridable for node-specific constraints. @@ -129,12 +127,12 @@ class AudioNode : public utils::graph::GraphObject, public std::enable_shared_fr setOutputBuffer(buffer); } - /// @brief Channel count this node presents on upstream connections (toward - /// AudioDestinationNode) after negotiation. Default: the negotiated channel - /// count, except for sources (no inputs, nothing to negotiate), which - /// present `getOutputChannelNumber`. StereoPanner always outputs stereo. - [[nodiscard]] virtual size_t getUpstreamChannelCount(size_t negotiatedChannelCount) const { - return numberOfInputs_ == 0 ? getOutputChannelNumber() : negotiatedChannelCount; + /// @brief Number of channels this node emits toward AudioDestinationNode + /// when that does not follow its `computedNumberOfChannels`. Default: + /// nullopt, the output is as wide as the inputs are mixed to. Sources + /// present `outputChannelNumber_`; StereoPanner always outputs stereo. + [[nodiscard]] virtual std::optional getOutputChannelNumber() const { + return std::nullopt; } /// @note JS Thread only @@ -218,11 +216,11 @@ class AudioNode : public utils::graph::GraphObject, public std::enable_shared_fr int channelCount_ = 2; /// @brief Number of channels this node emits; the width of `audioBuffer_` /// - /// Atomic because it is read on the JS thread during channel-count - /// negotiation (`HostGraph`/`getOutputChannelNumber`) while source - /// subclasses (AudioBufferSource, AudioFileSource, RecorderAdapter, - /// AudioBufferQueueSource) write it on the audio thread once they learn the - /// decoded/buffer channel count. + /// Atomic because it is read on the JS thread during channel negotiation + /// (`getOutputChannelNumber` overrides) while AudioBufferQueueSource and + /// RecorderAdapter write it from other threads once they learn their + /// channel count. Every other writer is the host thread, which must + /// renegotiate after a change. std::atomic outputChannelNumber_ = 2; ChannelCountMode channelCountMode_ = ChannelCountMode::MAX; ChannelInterpretation channelInterpretation_ = ChannelInterpretation::SPEAKERS; 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 0cc3e1447..66fbf4b27 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 @@ -18,7 +18,7 @@ StereoPannerNode::StereoPannerNode( outputBuffer_( std::make_shared( RENDER_QUANTUM_SIZE, - channelCount_, + kOutputChannelNumber, context->getSampleRate())) {} std::shared_ptr StereoPannerNode::getPanParam() const { @@ -37,8 +37,8 @@ void StereoPannerNode::setNegotiatedBuffer(const std::shared_ptr audioBuffer_ = buffer; } -size_t StereoPannerNode::getUpstreamChannelCount(size_t /*negotiatedChannelCount*/) const { - return outputBuffer_->getNumberOfChannels(); +std::optional StereoPannerNode::getOutputChannelNumber() const { + return kOutputChannelNumber; } void StereoPannerNode::processNode(int framesToProcess) { 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 d37c37eec..319bef894 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 @@ -6,6 +6,7 @@ #include #include +#include namespace audioapi { @@ -21,7 +22,7 @@ class StereoPannerNode : public AudioNode { [[nodiscard]] std::shared_ptr getOutputBuffer() const override; [[nodiscard]] std::shared_ptr getNegotiatedBuffer() const override; void setNegotiatedBuffer(const std::shared_ptr &buffer) override; - [[nodiscard]] size_t getUpstreamChannelCount(size_t negotiatedChannelCount) const override; + [[nodiscard]] std::optional getOutputChannelNumber() const override; protected: void processNode(int framesToProcess) override; @@ -30,6 +31,8 @@ class StereoPannerNode : public AudioNode { } private: + static constexpr size_t kOutputChannelNumber = 2; + const std::shared_ptr panParam_; const std::shared_ptr outputBuffer_; }; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferSourceNode.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferSourceNode.cpp index 47ff9b904..fc151b1d5 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferSourceNode.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferSourceNode.cpp @@ -75,7 +75,6 @@ void AudioBufferSourceNode::setBuffer( if (buffer == nullptr) { loopEnd_ = 0; - outputChannelNumber_ = AudioBufferSourceOptions::kDefaultOutputChannelNumber; buffer_ = nullptr; processor_->setBuffer(nullptr); @@ -85,7 +84,6 @@ void AudioBufferSourceNode::setBuffer( buffer_ = buffer; audioBuffer_ = audioBuffer; - outputChannelNumber_ = static_cast(buffer_->getNumberOfChannels()); loopEnd_ = buffer_->getDuration(); processor_->setBuffer(buffer_); } diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioScheduledSourceNode.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioScheduledSourceNode.cpp index 705d30a0c..d425f4306 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioScheduledSourceNode.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioScheduledSourceNode.cpp @@ -68,6 +68,10 @@ bool AudioScheduledSourceNode::canBeDestructed() const { return isUnscheduled() || isFinished(); } +std::optional AudioScheduledSourceNode::getOutputChannelNumber() const { + return outputChannelNumber_.load(std::memory_order_acquire); +} + void AudioScheduledSourceNode::updatePlaybackInfo( const std::shared_ptr &processingBuffer, int framesToProcess, diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioScheduledSourceNode.h b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioScheduledSourceNode.h index f2dc88ae9..1bfd84303 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioScheduledSourceNode.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioScheduledSourceNode.h @@ -8,6 +8,7 @@ #include #include #include +#include namespace audioapi { @@ -53,6 +54,8 @@ class AudioScheduledSourceNode : public AudioNode { bool canBeDestructed() const override; + [[nodiscard]] std::optional getOutputChannelNumber() const override; + protected: double startTime_; double stopTime_; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/MediaElementAudioSourceNode.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/MediaElementAudioSourceNode.cpp index 20909bec1..3e51653c2 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/MediaElementAudioSourceNode.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/MediaElementAudioSourceNode.cpp @@ -33,6 +33,10 @@ bool MediaElementAudioSourceNode::canBeDestructed() const { return fileSourceNodePaused(); } +std::optional MediaElementAudioSourceNode::getOutputChannelNumber() const { + return outputChannelNumber_.load(std::memory_order_acquire); +} + void MediaElementAudioSourceNode::onOutputsDisconnected() { if (fileSource_ != nullptr) { fileSource_->releaseMediaElementSource(bindingId_); diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/MediaElementAudioSourceNode.h b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/MediaElementAudioSourceNode.h index a38cc3645..ad388c498 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/MediaElementAudioSourceNode.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/MediaElementAudioSourceNode.h @@ -3,6 +3,7 @@ #include #include #include +#include namespace audioapi { @@ -25,6 +26,7 @@ class MediaElementAudioSourceNode : public AudioNode { size_t getFileSourceNodeUseCount() const; bool fileSourceNodePaused() const; bool canBeDestructed() const override; + [[nodiscard]] std::optional getOutputChannelNumber() const override; /// @note Audio Thread only — called after graph disconnects are applied. void onOutputsDisconnected(); diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/RecorderAdapterNode.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/RecorderAdapterNode.cpp index 927b81b5f..2287176bc 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/RecorderAdapterNode.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/RecorderAdapterNode.cpp @@ -21,6 +21,10 @@ RecorderAdapterNode::RecorderAdapterNode(const std::shared_ptr isInitialized_.store(false, std::memory_order_release); } +std::optional RecorderAdapterNode::getOutputChannelNumber() const { + return outputChannelNumber_.load(std::memory_order_acquire); +} + void RecorderAdapterNode::init(size_t bufferSize, int channelCount, float sampleRate) { std::shared_ptr context = context_.lock(); if (isInitialized_.load(std::memory_order_acquire) || context == nullptr) { diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/RecorderAdapterNode.h b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/RecorderAdapterNode.h index b7ea6f954..64305d7ab 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/RecorderAdapterNode.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/RecorderAdapterNode.h @@ -10,6 +10,7 @@ #include #include #include +#include #include namespace audioapi { @@ -31,6 +32,8 @@ class RecorderAdapterNode : public AudioNode { void init(size_t bufferSize, int channelCount, float sampleRate); void adapterCleanup(); + [[nodiscard]] std::optional getOutputChannelNumber() const override; + // TODO: CircularOverflowableAudioBuffer std::vector> buff_; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/Graph.h b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/Graph.h index 5e6726d39..b79f914d6 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/Graph.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/Graph.h @@ -150,7 +150,7 @@ class Graph { /// @brief Removes all outgoing edges from `from`. Res removeAllEdges(HNode *from); - /// @brief Recomputes channel-count negotiation for `node` (cascading + /// @brief Recomputes channel negotiation for `node` (cascading /// downstream) after its `channelCount` / `channelCountMode` changed. Sends /// the resulting buffer-swap event through Channel A. Res renegotiateNodeChannels(HNode *node); 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 4a05ccf24..e24526671 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 @@ -8,6 +8,7 @@ #include #include #include +#include #include #include @@ -46,68 +47,45 @@ inline audioapi::AudioNode *audioNodeOf(const HostGraph::Node *node) { return node->handle->audioNode->asAudioNode(); } -/// @brief Returns how many channels `audio` presents on upstream connections -/// (toward AudioDestinationNode). -/// -/// Reads the atomic output channel number rather than the output buffer: the -/// buffer's `shared_ptr` is swapped on the audio thread (setBuffer / -/// applyChannelNegotiations), so reading it here on the JS thread would race. -/// This helper is only the fallback for inputs not yet resolved in the current -/// negotiation pass (traversals resolve every input first via -/// `resolveChannelCountForNode`). -size_t outputChannelNumberOf(const audioapi::AudioNode *audio) { - if (audio == nullptr) { - return 0; - } - return audio->getOutputChannelNumber(); -} - -/// @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`: +/// @brief Computes the spec's `computedNumberOfChannels` for `dest`: the width +/// its inputs (`dest->inputs`) are mixed to, which is also the width of its +/// negotiated buffer. Follows the Web Audio rules for `channelCountMode`: /// - EXPLICIT -> `channelCount` attribute (inputs ignored) -/// - MAX -> max over inputs' computed output channel counts +/// - MAX -> max over inputs' output channel numbers /// - 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). /// -/// `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) { +/// `term` identifies the current negotiation pass. +/// @pre Every input is resolved for `term` +/// (`resolveOutputChannelNumberForNode`); its pending output channel number is +/// read from `channelLayout`, never from the live node. +size_t computeNumberOfChannels(const HostGraph::Node *dest, size_t term) { auto *destAudio = audioNodeOf(dest); if (destAudio == nullptr) { return 0; } - const auto attr = destAudio->getChannelCount(); + const auto channelCount = destAudio->getChannelCount(); const auto mode = destAudio->getChannelCountMode(); if (mode == audioapi::ChannelCountMode::EXPLICIT || dest->inputs.empty()) { - return attr; + return channelCount; } size_t maxInputChannels = 0; for (const HostGraph::Node *input : dest->inputs) { - auto *inAudio = audioNodeOf(input); - if (inAudio == nullptr) { - continue; - } - size_t c = 0; - if (input->channelLayout.isResolvedFor(term)) { - c = input->channelLayout.upstreamChannelCount; - } else { - c = outputChannelNumberOf(inAudio); - } - maxInputChannels = std::max(c, maxInputChannels); + assert(input->channelLayout.isResolvedFor(term)); + maxInputChannels = std::max(input->channelLayout.outputChannelNumber, maxInputChannels); } if (maxInputChannels == 0) { - return attr; + return channelCount; } if (mode == audioapi::ChannelCountMode::CLAMPED_MAX) { - return std::min(attr, maxInputChannels); + return std::min(channelCount, maxInputChannels); } return maxInputChannels; } @@ -118,23 +96,25 @@ size_t negotiateChannelCount(const HostGraph::Node *dest, size_t term) { /// converged, or context gone). std::shared_ptr buildNegotiatedBufferIfNeeded( const HostGraph::Node *dest, - size_t desired) { + size_t computedNumberOfChannels) { auto *destAudio = audioNodeOf(dest); if (destAudio == nullptr) { return nullptr; } - if (desired == 0) { + if (computedNumberOfChannels == 0) { return nullptr; } const auto current = destAudio->getNegotiatedBuffer(); - if (current != nullptr && current->getNumberOfChannels() == desired) { + if (current != nullptr && current->getNumberOfChannels() == computedNumberOfChannels) { return nullptr; } return std::make_shared( - audioapi::RENDER_QUANTUM_SIZE, static_cast(desired), destAudio->getContextSampleRate()); + audioapi::RENDER_QUANTUM_SIZE, + static_cast(computedNumberOfChannels), + destAudio->getContextSampleRate()); } struct ChannelNegotiation { @@ -144,15 +124,15 @@ struct ChannelNegotiation { using NegotiationBatch = std::vector; -/// @brief Recursively resolves upstream channel counts for `node` and every +/// @brief Recursively resolves output channel numbers for `node` and every /// downstream ancestor (`node->inputs`) in negotiation pass `term`. -void resolveChannelCountForNode(HostGraph::Node *node, size_t term) { +void resolveOutputChannelNumberForNode(HostGraph::Node *node, size_t term) { if (node == nullptr || node->channelLayout.isResolvedFor(term)) { return; } for (HostGraph::Node *input : node->inputs) { - resolveChannelCountForNode(input, term); + resolveOutputChannelNumberForNode(input, term); } auto *audio = audioNodeOf(node); @@ -163,8 +143,9 @@ void resolveChannelCountForNode(HostGraph::Node *node, size_t term) { return; } - const size_t desired = negotiateChannelCount(node, term); - node->channelLayout.setResolved(term, audio->getUpstreamChannelCount(desired)); + const size_t computedNumberOfChannels = computeNumberOfChannels(node, term); + node->channelLayout.setResolved( + term, audio->getOutputChannelNumber().value_or(computedNumberOfChannels)); } /// @brief Starting at `dest` (the connect `to` node), negotiates channel @@ -176,14 +157,15 @@ void collectChannelNegotiations(HostGraph::Node *dest, size_t term, NegotiationB } for (HostGraph::Node *input : dest->inputs) { - resolveChannelCountForNode(input, term); + resolveOutputChannelNumberForNode(input, term); } if (auto *destAudio = audioNodeOf(dest)) { - const size_t desired = negotiateChannelCount(dest, term); - dest->channelLayout.setResolved(term, destAudio->getUpstreamChannelCount(desired)); + const size_t computedNumberOfChannels = computeNumberOfChannels(dest, term); + dest->channelLayout.setResolved( + term, destAudio->getOutputChannelNumber().value_or(computedNumberOfChannels)); - if (auto negotiatedBuffer = buildNegotiatedBufferIfNeeded(dest, desired)) { + if (auto negotiatedBuffer = buildNegotiatedBufferIfNeeded(dest, computedNumberOfChannels)) { out.push_back({.node = dest, .buffer = std::move(negotiatedBuffer)}); } } else { @@ -360,7 +342,7 @@ auto HostGraph::addEdge(Node *from, Node *to) -> Res { auto reservedInputs = std::make_unique>(); reservedInputs->reserve(to->inputs.size()); - // Channel-count negotiation: computed + allocated on the host thread, + // Channel negotiation: computed + allocated on the host thread, // applied on the audio thread by the AGEvent below. Cascade upstream // (toward AudioDestinationNode) so late downstream connects still propagate. auto negotiations = collectNegotiations(++channelLayoutTerm_, to); @@ -408,7 +390,7 @@ auto HostGraph::removeEdge(Node *from, Node *to) -> Res { notifyMediaElementOutputsDisconnected(fromAudio, from); } - // Channel-count negotiation: computed + allocated on the host thread, + // Channel negotiation: computed + allocated on the host thread, // applied on the audio thread by the AGEvent below. Cascade upstream // (toward AudioDestinationNode) so disconnects still propagate. auto negotiations = collectNegotiations(++channelLayoutTerm_, to); @@ -448,7 +430,7 @@ auto HostGraph::removeAllEdges(Node *from) -> Res { notifyMediaElementOutputsDisconnected(fromAudio, from); } - // Channel-count negotiation: computed + allocated on the host thread, + // Channel negotiation: computed + allocated on the host thread, // applied on the audio thread by the AGEvent below. Cascade upstream from // each former output (toward AudioDestinationNode) so every downstream node // that lost this input re-derives its layout. 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 33b0e0ed4..d4e815fe9 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 @@ -65,7 +65,7 @@ class HostGraph { /// 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 + /// During the pass, `outputChannelNumber` 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 @@ -74,15 +74,15 @@ class HostGraph { /// consistent layouts. Values are never read on the audio thread. struct ChannelLayoutState { size_t term = 0; - size_t upstreamChannelCount = 0; + size_t outputChannelNumber = 0; [[nodiscard]] bool isResolvedFor(size_t currentTerm) const { return term == currentTerm; } - void setResolved(size_t currentTerm, size_t count) { + void setResolved(size_t currentTerm, size_t resolvedOutputChannelNumber) { term = currentTerm; - upstreamChannelCount = count; + outputChannelNumber = resolvedOutputChannelNumber; } }; @@ -161,7 +161,7 @@ class HostGraph { /// @return single AGEvent that removes all inputs on the AudioGraph side, or NODE_NOT_FOUND. Res removeAllEdges(Node *from); - /// @brief Recomputes channel-count negotiation starting at `node` (and + /// @brief Recomputes channel negotiation starting at `node` (and /// cascading downstream toward AudioDestinationNode), without any structural /// change. Used when a node's `channelCount` / `channelCountMode` attribute /// changes after construction. The returned AGEvent applies the negotiated diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostNode.h b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostNode.h index 85e9c8c1e..a507cf00a 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostNode.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostNode.h @@ -83,7 +83,7 @@ class HostNode { /// @return Ok on success, Err on not-found Res disconnect(); - /// @brief Recomputes channel-count negotiation for this node (cascading + /// @brief Recomputes channel negotiation for this node (cascading /// downstream) after a `channelCount` / `channelCountMode` change. /// @return Ok on success, Err on not-found Res renegotiate(); diff --git a/packages/react-native-audio-api/common/cpp/test/src/core/sources/AudioBufferSourceNodeTest.cpp b/packages/react-native-audio-api/common/cpp/test/src/core/sources/AudioBufferSourceNodeTest.cpp index 7b34f7b6f..67f854006 100644 --- a/packages/react-native-audio-api/common/cpp/test/src/core/sources/AudioBufferSourceNodeTest.cpp +++ b/packages/react-native-audio-api/common/cpp/test/src/core/sources/AudioBufferSourceNodeTest.cpp @@ -253,4 +253,20 @@ TEST_F(AudioBufferSourceNodeTest, BufferAssignedAfterNullBufferSourceEndedIsIgno EXPECT_TRUE(node->isEmpty()); } +/// Only the host thread publishes the output channel number. Applying an older +/// buffer on the audio thread must not overwrite the width the host published +/// for a newer one, or the host would skip the renegotiation for the next change. +TEST_F(AudioBufferSourceNodeTest, BufferSwapKeepsHostPublishedOutputChannelNumber) { + auto *node = addNode(makeNodeWithoutBuffer()); + node->setOutputChannelNumber(1); + + auto olderStereoBuffer = + std::make_shared(QUANTUM, 2, static_cast(SAMPLE_RATE)); + node->setBuffer( + olderStereoBuffer, + std::make_shared(QUANTUM, 2, static_cast(SAMPLE_RATE))); + + EXPECT_EQ(node->getOutputChannelNumber(), 1u); +} + // NOLINTEND 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 30eb8148e..9fc641929 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 @@ -48,12 +48,10 @@ TEST_F(ConstantSourceTest, ConstantSourceRendersMonoRegardlessOfChannelCountAttr EXPECT_EQ(constantSource->getOutputBuffer()->getNumberOfChannels(), 1u); EXPECT_EQ(constantSource->getOutputChannelNumber(), 1u); EXPECT_EQ(constantSource->getChannelCount(), 2u); - EXPECT_EQ(constantSource->getUpstreamChannelCount(2), 1u); constantSource->setChannelCount(4); EXPECT_EQ(constantSource->getChannelCount(), 4u); EXPECT_EQ(constantSource->getOutputChannelNumber(), 1u); - EXPECT_EQ(constantSource->getUpstreamChannelCount(4), 1u); } TEST_F(ConstantSourceTest, ConstantSourceCanBeCreated) { diff --git a/packages/react-native-audio-api/common/cpp/test/src/core/sources/MediaElementAudioSourceNodeTest.cpp b/packages/react-native-audio-api/common/cpp/test/src/core/sources/MediaElementAudioSourceNodeTest.cpp index 6c666e8eb..d477c64ff 100644 --- a/packages/react-native-audio-api/common/cpp/test/src/core/sources/MediaElementAudioSourceNodeTest.cpp +++ b/packages/react-native-audio-api/common/cpp/test/src/core/sources/MediaElementAudioSourceNodeTest.cpp @@ -128,7 +128,8 @@ TEST_F(MediaElementAudioSourceNodeTest, DisconnectingLastOutputReleasesMediaBind auto media = std::make_shared( context, fileSource.get(), - MediaElementAudioSourceOptions(static_cast(fileSource->getOutputChannelNumber()))); + MediaElementAudioSourceOptions( + static_cast(fileSource->getOutputChannelNumber().value()))); ASSERT_TRUE(fileSource->isRoutedThroughMediaElement()); @@ -144,11 +145,13 @@ TEST_F(MediaElementAudioSourceNodeTest, StaleMediaNodeOutputsSilence) { auto mediaA = std::make_shared( context, fileSource.get(), - MediaElementAudioSourceOptions(static_cast(fileSource->getOutputChannelNumber()))); + MediaElementAudioSourceOptions( + static_cast(fileSource->getOutputChannelNumber().value()))); auto mediaB = std::make_shared( context, fileSource.get(), - MediaElementAudioSourceOptions(static_cast(fileSource->getOutputChannelNumber()))); + MediaElementAudioSourceOptions( + static_cast(fileSource->getOutputChannelNumber().value()))); ASSERT_TRUE(fileSource->isCurrentMediaElementSource(mediaB->getBindingId())); 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 3a2a80003..19f164e48 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 @@ -31,12 +31,10 @@ TEST_F(OscillatorTest, OscillatorRendersMonoRegardlessOfChannelCountAttribute) { EXPECT_EQ(osc->getOutputBuffer()->getNumberOfChannels(), 1u); EXPECT_EQ(osc->getOutputChannelNumber(), 1u); EXPECT_EQ(osc->getChannelCount(), 2u); - EXPECT_EQ(osc->getUpstreamChannelCount(2), 1u); osc->setChannelCount(4); EXPECT_EQ(osc->getChannelCount(), 4u); EXPECT_EQ(osc->getOutputChannelNumber(), 1u); - EXPECT_EQ(osc->getUpstreamChannelCount(4), 1u); } TEST_F(OscillatorTest, OscillatorCanBeCreated) { 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 ef8145de7..b6780dd2a 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 @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -58,6 +59,22 @@ class ChannelCountTestNode : public audioapi::AudioNode { void processNode(int /*framesToProcess*/) override {} }; +/// Source stand-in: presents its output channel number regardless of +/// negotiation, like AudioScheduledSourceNode. +class SourceTestNode : public audioapi::AudioNode { + public: + SourceTestNode( + const std::shared_ptr &context, + const audioapi::AudioNodeOptions &options) + : AudioNode(context, options) {} + + [[nodiscard]] std::optional getOutputChannelNumber() const override { + return outputChannelNumber_.load(std::memory_order_acquire); + } + + void processNode(int /*framesToProcess*/) override {} +}; + struct ChannelOpts { int channelCount; audioapi::ChannelCountMode mode; @@ -96,12 +113,13 @@ addSourceNode(Graph &graph, int channelCountAttribute, int outputChannelNumber) audioNodeOpts.channelCount = channelCountAttribute; audioNodeOpts.outputChannelNumber = outputChannelNumber; - auto audioNode = std::make_unique(getGraphTestContext(), audioNodeOpts); + auto audioNode = std::make_unique(getGraphTestContext(), audioNodeOpts); return graph.addNode(std::move(audioNode)); } -inline HostGraph::Node *addStereoPannerNode(Graph &graph) { +inline HostGraph::Node *addStereoPannerNode(Graph &graph, int channelCount = 2) { audioapi::StereoPannerOptions options; + options.channelCount = channelCount; auto audioNode = std::make_unique(getGraphTestContext(), options); return graph.addNode(std::move(audioNode)); } @@ -235,14 +253,14 @@ TEST_F(GraphTest, ThreadRaceConcurrency) { } } -// ─── Channel-count negotiation on connect/disconnect ───────────────────── +// ─── Channel negotiation on connect/disconnect ─────────────────────────── // -// These tests assert the Web Audio contract: the computed number of -// channels on a node's output buffer must follow `channelCountMode` -// - MAX -> max(computed output channel count of each connected input) +// These tests assert the Web Audio contract: a node's negotiated buffer is +// `computedNumberOfChannels` wide, which follows `channelCountMode` +// - MAX -> max(output channel number of each connected input) // (the node's channelCount attribute is ignored) // - CLAMPED_MAX -> min(channelCount attribute, -// max(computed output channel count of each connected input)) +// max(output channel number of each connected input)) // - EXPLICIT -> always the channelCount attribute // // The computation must happen on the HostGraph side at addEdge/removeEdge @@ -250,7 +268,7 @@ TEST_F(GraphTest, ThreadRaceConcurrency) { // applied on the AudioGraph side — therefore each test calls // `graph->processEvents()` before inspecting `channelsOf(...)`. -TEST_F(GraphTest, ChannelCountNegotiation_MaxMode_SingleInput) { +TEST_F(GraphTest, ComputedNumberOfChannels_MaxMode_SingleInput) { auto *source = addChannelCountNode(*graph, {.channelCount = 4, .mode = ChannelCountMode::EXPLICIT}); auto *dest = addChannelCountNode(*graph, {.channelCount = 2, .mode = ChannelCountMode::MAX}); @@ -264,7 +282,7 @@ TEST_F(GraphTest, ChannelCountNegotiation_MaxMode_SingleInput) { "must be resized to 4 channels (channelCount attribute is ignored)"; } -TEST_F(GraphTest, ChannelCountNegotiation_SourceOutputChannelNumberIgnoresItsChannelCount) { +TEST_F(GraphTest, ComputedNumberOfChannels_SourceOutputChannelNumberIgnoresItsChannelCount) { auto *monoSource = addSourceNode(*graph, /*channelCountAttribute=*/2, /*outputChannelNumber=*/1); auto *dest = addChannelCountNode(*graph, {.channelCount = 2, .mode = ChannelCountMode::MAX}); graph->processEvents(); @@ -279,7 +297,7 @@ TEST_F(GraphTest, ChannelCountNegotiation_SourceOutputChannelNumberIgnoresItsCha "constant source) must negotiate a 1-channel downstream buffer"; } -TEST_F(GraphTest, ChannelCountNegotiation_MaxMode_MultipleInputsTakeMax) { +TEST_F(GraphTest, ComputedNumberOfChannels_MaxMode_MultipleInputsTakeMax) { auto *mono = addChannelCountNode(*graph, {.channelCount = 1, .mode = ChannelCountMode::EXPLICIT}); auto *six = addChannelCountNode(*graph, {.channelCount = 6, .mode = ChannelCountMode::EXPLICIT}); auto *dest = addChannelCountNode(*graph, {.channelCount = 2, .mode = ChannelCountMode::MAX}); @@ -293,7 +311,7 @@ TEST_F(GraphTest, ChannelCountNegotiation_MaxMode_MultipleInputsTakeMax) { << "MAX mode: the downstream buffer must follow the largest connected input"; } -TEST_F(GraphTest, ChannelCountNegotiation_ClampedMaxMode_ClampsAboveAttribute) { +TEST_F(GraphTest, ComputedNumberOfChannels_ClampedMaxMode_ClampsAboveAttribute) { auto *source = addChannelCountNode(*graph, {.channelCount = 6, .mode = ChannelCountMode::EXPLICIT}); auto *dest = @@ -307,7 +325,7 @@ TEST_F(GraphTest, ChannelCountNegotiation_ClampedMaxMode_ClampsAboveAttribute) { << "CLAMPED_MAX should clamp a 6-channel input down to channelCount=2"; } -TEST_F(GraphTest, ChannelCountNegotiation_ClampedMaxMode_FollowsInputWhenBelowAttribute) { +TEST_F(GraphTest, ComputedNumberOfChannels_ClampedMaxMode_FollowsInputWhenBelowAttribute) { auto *source = addChannelCountNode(*graph, {.channelCount = 1, .mode = ChannelCountMode::EXPLICIT}); auto *dest = @@ -321,7 +339,7 @@ TEST_F(GraphTest, ChannelCountNegotiation_ClampedMaxMode_FollowsInputWhenBelowAt << "CLAMPED_MAX: mono input with channelCount=4 must still produce a mono buffer"; } -TEST_F(GraphTest, ChannelCountNegotiation_ExplicitMode_IgnoresInput) { +TEST_F(GraphTest, ComputedNumberOfChannels_ExplicitMode_IgnoresInput) { auto *source = addChannelCountNode(*graph, {.channelCount = 6, .mode = ChannelCountMode::EXPLICIT}); auto *dest = addChannelCountNode(*graph, {.channelCount = 4, .mode = ChannelCountMode::EXPLICIT}); @@ -333,7 +351,7 @@ TEST_F(GraphTest, ChannelCountNegotiation_ExplicitMode_IgnoresInput) { EXPECT_EQ(channelsOf(dest), 4u) << "EXPLICIT must always produce exactly channelCount channels"; } -TEST_F(GraphTest, ChannelCountNegotiation_MaxMode_RecomputesOnSecondConnection) { +TEST_F(GraphTest, ComputedNumberOfChannels_MaxMode_RecomputesOnSecondConnection) { auto *stereoSource = addChannelCountNode(*graph, {.channelCount = 2, .mode = ChannelCountMode::EXPLICIT}); auto *quadSource = @@ -352,7 +370,7 @@ TEST_F(GraphTest, ChannelCountNegotiation_MaxMode_RecomputesOnSecondConnection) << "MAX: connecting a 4-channel source must grow the buffer to 4 channels"; } -TEST_F(GraphTest, ChannelCountNegotiation_MaxMode_RecomputesOnDisconnection) { +TEST_F(GraphTest, ComputedNumberOfChannels_MaxMode_RecomputesOnDisconnection) { auto *stereoSource = addChannelCountNode(*graph, {.channelCount = 2, .mode = ChannelCountMode::EXPLICIT}); auto *quadSource = @@ -372,7 +390,7 @@ 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) { +TEST_F(GraphTest, ComputedNumberOfChannels_MaxMode_ChainedNodes_ConnectDownstreamFirst) { auto *source = addChannelCountNode(*graph, {.channelCount = 1, .mode = ChannelCountMode::EXPLICIT}); auto *gain1 = addChannelCountNode(*graph, {.channelCount = 2, .mode = ChannelCountMode::MAX}); @@ -388,7 +406,7 @@ TEST_F(GraphTest, ChannelCountNegotiation_MaxMode_ChainedNodes_ConnectDownstream EXPECT_EQ(channelsOf(gain2), 1u); } -TEST_F(GraphTest, ChannelCountNegotiation_MaxMode_ChainedNodes_ConnectUpstreamFirst) { +TEST_F(GraphTest, ComputedNumberOfChannels_MaxMode_ChainedNodes_ConnectUpstreamFirst) { auto *source = addChannelCountNode(*graph, {.channelCount = 1, .mode = ChannelCountMode::EXPLICIT}); auto *gain1 = addChannelCountNode(*graph, {.channelCount = 2, .mode = ChannelCountMode::MAX}); @@ -403,7 +421,7 @@ TEST_F(GraphTest, ChannelCountNegotiation_MaxMode_ChainedNodes_ConnectUpstreamFi EXPECT_EQ(channelsOf(gain2), 1u); } -TEST_F(GraphTest, ChannelCountNegotiation_ClampedMaxMode_ChainedNodes) { +TEST_F(GraphTest, ComputedNumberOfChannels_ClampedMaxMode_ChainedNodes) { auto *source = addChannelCountNode(*graph, {.channelCount = 6, .mode = ChannelCountMode::EXPLICIT}); auto *gain1 = @@ -421,7 +439,7 @@ TEST_F(GraphTest, ChannelCountNegotiation_ClampedMaxMode_ChainedNodes) { EXPECT_EQ(channelsOf(gain2), 2u); } -TEST_F(GraphTest, ChannelCountNegotiation_MaxMode_CascadeOnLateUpstreamConnect) { +TEST_F(GraphTest, ComputedNumberOfChannels_MaxMode_CascadeOnLateUpstreamConnect) { auto *quadSource = addChannelCountNode(*graph, {.channelCount = 4, .mode = ChannelCountMode::EXPLICIT}); auto *monoSource = @@ -442,7 +460,7 @@ TEST_F(GraphTest, ChannelCountNegotiation_MaxMode_CascadeOnLateUpstreamConnect) EXPECT_EQ(channelsOf(gain2), 4u); } -TEST_F(GraphTest, ChannelCountNegotiation_MaxMode_CascadeOnUpstreamDisconnect) { +TEST_F(GraphTest, ComputedNumberOfChannels_MaxMode_CascadeOnUpstreamDisconnect) { auto *quadSource = addChannelCountNode(*graph, {.channelCount = 4, .mode = ChannelCountMode::EXPLICIT}); auto *monoSource = @@ -463,7 +481,7 @@ TEST_F(GraphTest, ChannelCountNegotiation_MaxMode_CascadeOnUpstreamDisconnect) { EXPECT_EQ(channelsOf(gain2), 1u); } -TEST_F(GraphTest, ChannelCountNegotiation_StereoPanner_MonoInputKeepsStereoOutput) { +TEST_F(GraphTest, ComputedNumberOfChannels_StereoPanner_MonoInputKeepsStereoOutput) { auto *source = addChannelCountNode(*graph, {.channelCount = 1, .mode = ChannelCountMode::EXPLICIT}); auto *panner = addStereoPannerNode(*graph); @@ -476,7 +494,7 @@ TEST_F(GraphTest, ChannelCountNegotiation_StereoPanner_MonoInputKeepsStereoOutpu EXPECT_EQ(channelsOf(panner), 2u); } -TEST_F(GraphTest, ChannelCountNegotiation_StereoPanner_DownstreamSeesStereoOutput) { +TEST_F(GraphTest, ComputedNumberOfChannels_StereoPanner_DownstreamSeesStereoOutput) { auto *source = addChannelCountNode(*graph, {.channelCount = 1, .mode = ChannelCountMode::EXPLICIT}); auto *panner = addStereoPannerNode(*graph); @@ -490,6 +508,22 @@ TEST_F(GraphTest, ChannelCountNegotiation_StereoPanner_DownstreamSeesStereoOutpu EXPECT_EQ(channelsOf(dest), 2u); } +TEST_F(GraphTest, ComputedNumberOfChannels_StereoPanner_MonoChannelCountKeepsStereoOutput) { + auto *source = + addChannelCountNode(*graph, {.channelCount = 2, .mode = ChannelCountMode::EXPLICIT}); + auto *panner = addStereoPannerNode(*graph, /*channelCount=*/1); + auto *dest = addChannelCountNode(*graph, {.channelCount = 1, .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(inputChannelsOf(panner), 1u) << "CLAMPED_MAX(1) mixes the stereo input to mono"; + EXPECT_EQ(channelsOf(panner), 2u) << "StereoPannerNode always outputs stereo"; + EXPECT_EQ(channelsOf(dest), 2u); +} + // ─── Renegotiation after a channelCount / channelCountMode change ───────── // // `renegotiateNodeChannels` recomputes the channel layout for a node whose attributes diff --git a/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.mm b/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.mm index 2c09c1490..21bebf91b 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.mm +++ b/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.mm @@ -104,7 +104,7 @@ static void cleanupStartedRecorder( if (isConnected()) { if (auto lock = Locker::tryLock(adapterNodeMutex_)) { auto *adapterNode = static_cast(adapterNodeHandle_->audioNode.get()); - for (size_t channel = 0; channel < adapterNode->getOutputChannelNumber(); ++channel) { + for (size_t channel = 0; channel < adapterNode->buff_.size(); ++channel) { auto *data = static_cast(inputBuffer->mBuffers[channel].mData); adapterNode->buff_[channel]->write(data, numFrames); }