From 7d51ae23f28461873c7d1d87075b3ee2a99092d7 Mon Sep 17 00:00:00 2001 From: Steven Atkinson Date: Fri, 18 Sep 2026 12:58:49 -0700 Subject: [PATCH 1/4] test: cover issue #334 --- tools/run_tests.cpp | 2 + tools/test/test_linear.cpp | 123 +++++++++++++++++++++++++++++++++++-- 2 files changed, 121 insertions(+), 4 deletions(-) diff --git a/tools/run_tests.cpp b/tools/run_tests.cpp index 1bdc844a..94cb1090 100644 --- a/tools/run_tests.cpp +++ b/tools/run_tests.cpp @@ -97,6 +97,8 @@ int main() test_dsp::test_reset_and_prewarm_restores_prewarm_on_throw(); test_dsp::test_scoped_prewarm_on_reset_default(); + test_linear::test_channel_mappings(); + test_linear::test_channel_validation(); test_linear::test_arbitrary_sample_rate_capability(); test_linear::test_sample_rate_known_values(); test_linear::test_sample_rate_fractional_and_fft(); diff --git a/tools/test/test_linear.cpp b/tools/test/test_linear.cpp index bf74965f..61dc2be8 100644 --- a/tools/test/test_linear.cpp +++ b/tools/test/test_linear.cpp @@ -354,13 +354,13 @@ void test_sample_rate_multichannel_realtime_safe() { std::vector weights(2048, 0.0f); weights[300] = 1.0f; - nam::Linear model(2, 3, 2048, false, weights, 48000.0, implementation); + nam::Linear model(2, 2, 2048, false, weights, 48000.0, implementation); model.SetPrewarmOnReset(false); model.Reset(96000.0, 64); std::vector input0(64, 0.0), input1(64, 0.0); - std::vector output0(64), output1(64), output2(64); + std::vector output0(64), output1(64); NAM_SAMPLE* inputs[] = {input0.data(), input1.data()}; - NAM_SAMPLE* outputs[] = {output0.data(), output1.data(), output2.data()}; + NAM_SAMPLE* outputs[] = {output0.data(), output1.data()}; allocation_tracking::run_allocation_test_no_allocations( nullptr, [&]() { @@ -381,7 +381,6 @@ void test_sample_rate_multichannel_realtime_safe() expected = -0.03125; assert_near(output0[i], expected, 1.0e-6); assert_near(output1[i], 2.0 * expected, 1.0e-6); - assert_near(output2[i], 0.0, 1.0e-7); } } }, @@ -407,4 +406,120 @@ void test_sample_rate_multichannel_realtime_safe() } } + +void test_channel_mappings() +{ + for (const auto implementation : {nam::LinearImplementation::Direct, nam::LinearImplementation::FFT}) + for (const auto shape : {std::pair{1, 1}, {2, 2}, {1, 3}, {3, 1}}) + for (const int taps : {3, 1536}) + for (const bool bias : {false, true}) + { + const int inputs = shape.first, outputs = shape.second; + const int paths = std::max(inputs, outputs); + const int kernels = inputs == outputs ? 1 : paths; + std::vector weights; + for (int k = 0; k < kernels; ++k) + { + auto kernel = make_weights(taps, false); + for (auto& value : kernel) + value *= k % 2 == 0 ? k + 1.0f : -k - 1.0f; + weights.insert(weights.end(), kernel.begin(), kernel.end()); + } + if (bias) + for (int ch = 0; ch < (inputs == outputs ? 1 : outputs); ++ch) + weights.push_back(0.125f * (ch + 1)); + auto config = + nam::linear::parse_config_json({{"receptive_field", taps}, + {"bias", bias}, + {"in_channels", inputs}, + {"out_channels", outputs}, + {"implementation", nam::linear::implementation_to_string(implementation)}}); + auto model = config.create(weights, 48000.0); + model->SetPrewarmOnReset(false); + for (const double rate : {48000.0, 96000.0, 48000.0}) + { + model->Reset(rate, 127); + const int frames = 4096; + std::vector> input(inputs, make_input(frames)); + // Isolate later inputs first, then exercise simultaneous summation. + for (int ch = 0; ch < inputs; ++ch) + for (int i = 0; i < frames; ++i) + input[ch][i] *= ch == 0 && inputs > 1 && i < 512 ? 0.0 : ch + 1.0; + std::vector> expected(outputs, std::vector(frames)); + for (int ch = 0; ch < outputs; ++ch) + std::fill( + expected[ch].begin(), expected[ch].end(), bias ? 0.125 * (inputs == outputs ? 1 : ch + 1) : 0.0); + for (int path = 0; path < paths; ++path) + { + const int k = kernels == 1 ? 0 : path; + std::vector kernel(weights.begin() + k * taps, weights.begin() + (k + 1) * taps); + nam::Linear reference(1, 1, taps, false, kernel, 48000.0, nam::LinearImplementation::Direct); + reference.SetPrewarmOnReset(false); + reference.Reset(rate, 127); + const auto result = process_model(reference, input[inputs == 1 ? 0 : path], {127, 1, 13}); + for (int i = 0; i < frames; ++i) + expected[outputs == 1 ? 0 : path][i] += result[i]; + } + std::vector> output(outputs, std::vector(frames)); + std::vector in_ptrs(inputs), out_ptrs(outputs); + allocation_tracking::run_allocation_test_no_allocations( + nullptr, + [&]() { + int offset = 0; + while (offset < frames) + { + const int count = std::min(offset % 127 + 1, frames - offset); + for (int ch = 0; ch < inputs; ++ch) + in_ptrs[ch] = input[ch].data() + offset; + for (int ch = 0; ch < outputs; ++ch) + out_ptrs[ch] = output[ch].data() + offset; + model->process(in_ptrs.data(), out_ptrs.data(), count); + offset += count; + } + }, + nullptr, "Linear channel mapping process real-time safe"); + for (int ch = 0; ch < outputs; ++ch) + for (int i = 0; i < frames; ++i) + assert_near(output[ch][i], expected[ch][i], 5.0e-5); + } + } +} + +void test_channel_validation() +{ + const auto defaults = nam::linear::parse_config_json({{"receptive_field", 3}, {"bias", false}}); + assert(defaults.in_channels == 1 && defaults.out_channels == 1); + for (const auto shape : {std::pair{2, 3}, {3, 2}, {0, 1}, {1, 0}, {-1, 1}, {1, -1}}) + { + bool threw = false; + try + { + nam::Linear model(shape.first, shape.second, 3, false, {1, 0, 0}); + } + catch (const std::runtime_error& e) + { + threw = true; + assert(std::string(e.what()).find("channel") != std::string::npos + || std::string(e.what()).find("Channel") != std::string::npos); + } + assert(threw); + } + for (const auto shape : {std::pair{1, 2}, {2, 1}}) + for (const bool bias : {false, true}) + for (const int delta : {-1, 1}) + { + bool threw = false; + try + { + nam::Linear model( + shape.first, shape.second, 3, bias, std::vector(6 + (bias ? shape.second : 0) + delta)); + } + catch (const std::runtime_error&) + { + threw = true; + } + assert(threw); + } +} + } // namespace test_linear From 16e68e9f4c04c252cc6785f9a9f04f687042bbe1 Mon Sep 17 00:00:00 2001 From: Steven Atkinson Date: Fri, 18 Sep 2026 13:02:02 -0700 Subject: [PATCH 2/4] feat: resolve issue #334 with Linear channel mappings --- NAM/linear.cpp | 140 +++++++++++++++++++-------------- NAM/linear.h | 20 +++-- docs/index.rst | 1 + docs/linear.rst | 52 ++++++++++++ tools/run_tests.cpp | 1 + tools/test/test_linear.cpp | 47 +++++++++++ tools/test/test_sequential.cpp | 2 +- 7 files changed, 198 insertions(+), 65 deletions(-) create mode 100644 docs/linear.rst diff --git a/NAM/linear.cpp b/NAM/linear.cpp index c97704a9..3df2f4f6 100644 --- a/NAM/linear.cpp +++ b/NAM/linear.cpp @@ -101,7 +101,7 @@ struct nam::LinearFFTState int spectrum_size = 0; int num_partitions = 0; bool runs_inline = false; - std::vector> kernel_spectra; + std::vector>> kernel_spectra; std::vector channels; }; @@ -124,18 +124,24 @@ nam::Linear::Linear(const int in_channels, const int out_channels, const int rec , _requested_implementation(implementation) , _active_implementation(LinearImplementation::Direct) { - if ((int)weights.size() != (receptive_field + (_bias ? 1 : 0))) - throw std::runtime_error( - "Params vector does not match expected size based " - "on architecture parameters"); - - this->_impulse_response.assign(weights.begin(), weights.begin() + receptive_field); + if (in_channels != out_channels && in_channels != 1 && out_channels != 1) + throw std::runtime_error("Linear requires equal channel counts or one input/output channel"); + const int kernels = in_channels == out_channels ? 1 : std::max(in_channels, out_channels); + const int biases = in_channels == out_channels ? 1 : out_channels; + const size_t coefficient_count = (size_t)receptive_field * kernels; + if (weights.size() != coefficient_count + (_bias ? biases : 0)) + throw std::runtime_error("Linear parameter count does not match impulse responses and output biases"); + + this->_impulse_response.resize(kernels); + for (int k = 0; k < kernels; ++k) + this->_impulse_response[k].assign( + weights.begin() + (size_t)k * receptive_field, weights.begin() + (size_t)(k + 1) * receptive_field); this->_original_impulse_response = this->_impulse_response; - this->_weight.resize(this->_receptive_field); - // Pass in in reverse order so that dot products work out of the box. - for (int i = 0; i < this->_receptive_field; i++) - this->_weight(i) = weights[receptive_field - 1 - i]; - this->_bias = _bias ? weights[receptive_field] : (float)0.0; + this->_configure_weights(); + this->_bias.resize(out_channels, 0.0f); + if (_bias) + for (int ch = 0; ch < out_channels; ++ch) + this->_bias[ch] = weights[coefficient_count + (biases == 1 ? 0 : ch)]; this->_configure_implementation(); } @@ -166,16 +172,15 @@ void nam::Linear::Reset(const double sampleRate, const int maxBufferSize) if (maxBufferSize < 0) throw std::invalid_argument("Linear maximum buffer size must be non-negative"); - auto impulse_response = training_rate == NAM_UNKNOWN_EXPECTED_SAMPLE_RATE || training_rate == sampleRate - ? this->_original_impulse_response - : _resample_impulse_response(this->_original_impulse_response, training_rate, sampleRate); - if (impulse_response.size() + 32LL * maxBufferSize > std::numeric_limits::max()) + auto impulse_responses = this->_original_impulse_response; + if (training_rate != NAM_UNKNOWN_EXPECTED_SAMPLE_RATE && training_rate != sampleRate) + for (auto& response : impulse_responses) + response = _resample_impulse_response(response, training_rate, sampleRate); + if (impulse_responses.front().size() + 32LL * maxBufferSize > std::numeric_limits::max()) throw std::length_error("Linear input buffer is too large"); - this->_impulse_response = std::move(impulse_response); - this->_receptive_field = (int)this->_impulse_response.size(); - this->_weight.resize(this->_receptive_field); - for (int i = 0; i < this->_receptive_field; ++i) - this->_weight(i) = this->_impulse_response[this->_receptive_field - 1 - i]; + this->_impulse_response = std::move(impulse_responses); + this->_receptive_field = (int)this->_impulse_response.front().size(); + this->_configure_weights(); // SetMaxBufferSize rebuilds history and FFT state before any prewarming. nam::DSP::Reset(sampleRate, maxBufferSize); } @@ -193,6 +198,17 @@ void nam::Linear::SetMaxBufferSize(const int maxBufferSize) this->_configure_implementation(); } +void nam::Linear::_configure_weights() +{ + this->_weight.resize(this->_impulse_response.size()); + for (size_t k = 0; k < this->_weight.size(); ++k) + { + this->_weight[k].resize(this->_receptive_field); + for (int i = 0; i < this->_receptive_field; ++i) + this->_weight[k](i) = this->_impulse_response[k][this->_receptive_field - 1 - i]; + } +} + void nam::Linear::_configure_implementation() { if (this->_requested_implementation == LinearImplementation::Direct) @@ -216,9 +232,13 @@ void nam::Linear::_configure_fft_state() state.direct_taps = std::min(this->_receptive_field, plan.direct_taps); state.sample_index = 0; - this->_fft_direct_weight.resize(state.direct_taps); - for (int i = 0; i < state.direct_taps; i++) - this->_fft_direct_weight(i) = this->_impulse_response[state.direct_taps - 1 - i]; + this->_fft_direct_weight.resize(this->_impulse_response.size()); + for (size_t k = 0; k < this->_impulse_response.size(); ++k) + { + this->_fft_direct_weight[k].resize(state.direct_taps); + for (int i = 0; i < state.direct_taps; ++i) + this->_fft_direct_weight[k](i) = this->_impulse_response[k][state.direct_taps - 1 - i]; + } // The inline tier covers [head, 4 * head). Every subsequent power-of-two // tier starts at twice its block size, which gives it one full block of @@ -242,16 +262,20 @@ void nam::Linear::_configure_fft_state() tier.spectrum_size = block_size + 1; tier.num_partitions = partitions; tier.runs_inline = first_tier; - tier.kernel_spectra.assign(partitions, std::vector(tier.spectrum_size)); - + tier.kernel_spectra.resize(this->_impulse_response.size()); std::vector kernel_time(tier.fft_size, 0.0f); - for (int partition = 0; partition < partitions; partition++) + for (size_t k = 0; k < this->_impulse_response.size(); ++k) { - std::fill(kernel_time.begin(), kernel_time.end(), 0.0f); - const int start = offset + partition * block_size; - const int partition_size = std::min(block_size, this->_receptive_field - start); - std::copy_n(this->_impulse_response.begin() + start, partition_size, kernel_time.begin()); - tier.fft.fwd(tier.kernel_spectra[partition].data(), kernel_time.data(), tier.fft_size); + auto& spectra = tier.kernel_spectra[k]; + spectra.assign(partitions, std::vector(tier.spectrum_size)); + for (int partition = 0; partition < partitions; partition++) + { + std::fill(kernel_time.begin(), kernel_time.end(), 0.0f); + const int start = offset + partition * block_size; + const int partition_size = std::min(block_size, this->_receptive_field - start); + std::copy_n(this->_impulse_response[k].begin() + start, partition_size, kernel_time.begin()); + tier.fft.fwd(spectra[partition].data(), kernel_time.data(), tier.fft_size); + } } offset += partitions * block_size; @@ -259,7 +283,7 @@ void nam::Linear::_configure_fft_state() block_size = std::min(2 * block_size, plan.max_partition_size); } - const int channels_to_process = std::min(NumInputChannels(), NumOutputChannels()); + const int channels_to_process = std::max(NumInputChannels(), NumOutputChannels()); int largest_block_size = state.direct_taps; for (auto& tier : state.tiers) { @@ -300,28 +324,23 @@ void nam::Linear::_process_direct(NAM_SAMPLE** input, NAM_SAMPLE** output, const const int in_channels = NumInputChannels(); const int out_channels = NumOutputChannels(); - // For now, Linear processes each input channel independently to corresponding output channel - // This is a simple implementation - can be extended later for cross-channel mixing - const int channelsToProcess = std::min(in_channels, out_channels); - - // Main computation! - for (int ch = 0; ch < channelsToProcess; ch++) + const int paths = std::max(in_channels, out_channels); + for (int ch = 0; ch < out_channels; ++ch) + std::fill_n(output[ch], num_frames, this->_bias[ch]); + for (int path = 0; path < paths; ++path) { - for (int i = 0; i < num_frames; i++) + const int input_channel = in_channels == 1 ? 0 : path; + const int output_channel = out_channels == 1 ? 0 : path; + const auto& weight = this->_weight[this->_kernel_index(path)]; + for (int i = 0; i < num_frames; ++i) { - const long offset = this->_input_buffer_offset - this->_weight.size() + i + 1; - auto input_vec = Eigen::Map(&this->_input_buffers[ch][offset], this->_receptive_field); - output[ch][i] = this->_bias + this->_weight.dot(input_vec); + const long offset = this->_input_buffer_offset - this->_receptive_field + i + 1; + auto input_vec = + Eigen::Map(&this->_input_buffers[input_channel][offset], this->_receptive_field); + output[output_channel][i] += weight.dot(input_vec); } } - // Zero out any extra output channels - for (int ch = channelsToProcess; ch < out_channels; ch++) - { - for (int i = 0; i < num_frames; i++) - output[ch][i] = (NAM_SAMPLE)0.0; - } - // Prepare for next call: nam::Buffer::_advance_input_buffer_(num_frames); } @@ -332,29 +351,36 @@ void nam::Linear::_process_fft(NAM_SAMPLE** input, NAM_SAMPLE** output, const in const int in_channels = NumInputChannels(); const int out_channels = NumOutputChannels(); - const int channels_to_process = std::min(in_channels, out_channels); + const int channels_to_process = std::max(in_channels, out_channels); auto& state = *this->_fft_state; const int direct_taps = state.direct_taps; for (int i = 0; i < num_frames; i++) { + for (int ch = 0; ch < out_channels; ++ch) + output[ch][i] = this->_bias[ch]; const long direct_offset = this->_input_buffer_offset - direct_taps + i + 1; for (int ch = 0; ch < channels_to_process; ch++) { + const int input_channel = in_channels == 1 ? 0 : ch; + const int output_channel = out_channels == 1 ? 0 : ch; this->_advance_fft_jobs(ch); const int ring_index = (int)(state.sample_index % state.output_ring_size); const float tail = state.output_channels[ch].output_ring[ring_index]; state.output_channels[ch].output_ring[ring_index] = 0.0f; - auto input_vec = Eigen::Map(&this->_input_buffers[ch][direct_offset], direct_taps); - output[ch][i] = this->_bias + this->_fft_direct_weight.dot(input_vec) + tail; + auto input_vec = + Eigen::Map(&this->_input_buffers[input_channel][direct_offset], direct_taps); + output[output_channel][i] += this->_fft_direct_weight[this->_kernel_index(ch)].dot(input_vec); + output[output_channel][i] += tail; for (size_t tier_index = 0; tier_index < state.tiers.size(); ++tier_index) { auto& tier = state.tiers[tier_index]; auto& channel = tier.channels[ch]; - channel.input_time[channel.input_pos] = (float)input[ch][i]; + // Read the buffered input so output writes cannot corrupt aliased input. + channel.input_time[channel.input_pos] = this->_input_buffers[input_channel][this->_input_buffer_offset + i]; channel.input_pos++; if (channel.input_pos == tier.block_size) { @@ -365,9 +391,6 @@ void nam::Linear::_process_fft(NAM_SAMPLE** input, NAM_SAMPLE** output, const in } } - for (int ch = channels_to_process; ch < out_channels; ch++) - output[ch][i] = (NAM_SAMPLE)0.0; - state.sample_index++; } @@ -399,7 +422,8 @@ void nam::Linear::_advance_fft_job(const int tier_index, const int channel_index int input_spectrum_index = channel.job_spectrum_write_index - partition; if (input_spectrum_index < 0) input_spectrum_index += tier.num_partitions; - channel.accumulator[bin] += channel.input_spectra[input_spectrum_index][bin] * tier.kernel_spectra[partition][bin]; + channel.accumulator[bin] += channel.input_spectra[input_spectrum_index][bin] + * tier.kernel_spectra[this->_kernel_index(channel_index)][partition][bin]; channel.job_work_index++; } channel.job_ticks_remaining--; diff --git a/NAM/linear.h b/NAM/linear.h index b7eb75ac..26721aa5 100644 --- a/NAM/linear.h +++ b/NAM/linear.h @@ -23,7 +23,12 @@ enum class LinearImplementation /// \brief Basic linear model /// -/// Implements a simple linear convolution, (i.e. an impulse response). +/// Implements linear convolution with equal channel counts or one input/output channel. +/// Equal-channel models share one impulse response and optional bias. Otherwise, +/// weights contain max(in_channels, out_channels) consecutive impulse responses +/// in channel order, followed by out_channels biases when enabled. One-to-many +/// filters the input for each output; many-to-one sums filtered inputs and adds +/// the output bias once. Unequal channel counts greater than one are rejected. class Linear : public Buffer { public: @@ -67,18 +72,21 @@ class Linear : public Buffer void SetMaxBufferSize(const int maxBufferSize) override; protected: - Eigen::VectorXf _weight; - Eigen::VectorXf _fft_direct_weight; - float _bias; + std::vector _weight; + std::vector _fft_direct_weight; + std::vector _bias; private: // Keep the trained coefficients so repeated rate changes never compound interpolation error. - std::vector _original_impulse_response; - std::vector _impulse_response; + std::vector> _original_impulse_response; + std::vector> _impulse_response; LinearImplementation _requested_implementation; LinearImplementation _active_implementation; std::unique_ptr _fft_state; + // Equal-channel models share one kernel; unequal supported shapes use one per path. + int _kernel_index(int path) const { return _impulse_response.size() == 1 ? 0 : path; } + void _configure_weights(); void _configure_implementation(); void _configure_fft_state(); void _process_direct(NAM_SAMPLE** input, NAM_SAMPLE** output, const int num_frames); diff --git a/docs/index.rst b/docs/index.rst index 68c7795b..bd5382fc 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -9,6 +9,7 @@ Welcome to the NeuralAmpModelerCore documentation. This library provides a core wavenet_walkthrough nam_file_version + linear api/index Overview diff --git a/docs/linear.rst b/docs/linear.rst new file mode 100644 index 00000000..56e5d905 --- /dev/null +++ b/docs/linear.rst @@ -0,0 +1,52 @@ +Linear convolution and channel mapping +====================================== + +``Linear`` supports positive ``in_channels`` and ``out_channels`` counts when +those counts are equal or either count is one. Both fields default to one when +omitted from the model configuration. Unequal counts greater than one (for +example, 2 inputs and 3 outputs) are rejected when constructing/loading the +model. They no longer silently discard inputs or silence extra outputs. + +Channel mapping +--------------- + +* **1 input, 1 output:** the existing mono convolution. +* **N inputs, N outputs:** each input feeds its corresponding output. All + channels share the same impulse response and, if enabled, bias, preserving + existing equal-channel models. There is no cross-channel mixing. +* **1 input, M outputs:** each output has its own impulse response applied to + the single input: ``y[j] = convolve(h[j], x[0]) + b[j]``. +* **N inputs, 1 output:** each input has its own impulse response, and the + filtered signals sum: ``y[0] = sum(convolve(h[i], x[i])) + b[0]``. + There is no averaging or normalization. The output bias is added once. + +Serialized weights +------------------ + +The existing configuration fields ``receptive_field`` and ``bias`` retain their +meaning. All impulse responses have ``receptive_field`` taps at the training +sample rate. No additional configuration fields are required. + +For equal input/output counts, the existing layout is unchanged: one impulse +response followed by one optional shared bias. The parameter count is +``receptive_field + (bias ? 1 : 0)``. + +For unequal supported counts, concatenate the impulse responses in channel +order, then append one bias per output if ``bias`` is true. For 1-to-M mapping, +the responses are in output-channel order; for N-to-1 mapping, they are in +input-channel order. Taps within each response are in causal order, starting +with the coefficient applied to the current sample. The parameter count is +``max(in_channels, out_channels) * receptive_field + (bias ? out_channels : 0)``. +An incorrect parameter count is rejected; a single shared response is not +implicitly broadcast for unequal counts. + +For example, with two taps per response and bias enabled: + +* **1 to 2:** ``[h0[0], h0[1], h1[0], h1[1], b0, b1]``. +* **2 to 1:** ``[h0[0], h0[1], h1[0], h1[1], b0]``. +* **2 to 2 (legacy):** ``[h[0], h[1], b]``. + +Both direct and FFT convolution use these rules. Resetting to a new processing +sample rate independently resamples each response from its original training +coefficients, leaves biases unchanged, and clears all convolution history. +Processing uses preallocated state; construction and reset may allocate. diff --git a/tools/run_tests.cpp b/tools/run_tests.cpp index 94cb1090..e3c640c1 100644 --- a/tools/run_tests.cpp +++ b/tools/run_tests.cpp @@ -99,6 +99,7 @@ int main() test_linear::test_channel_mappings(); test_linear::test_channel_validation(); + test_linear::test_channel_mapping_in_place(); test_linear::test_arbitrary_sample_rate_capability(); test_linear::test_sample_rate_known_values(); test_linear::test_sample_rate_fractional_and_fft(); diff --git a/tools/test/test_linear.cpp b/tools/test/test_linear.cpp index 61dc2be8..50da1043 100644 --- a/tools/test/test_linear.cpp +++ b/tools/test/test_linear.cpp @@ -485,6 +485,53 @@ void test_channel_mappings() } } + +void test_channel_mapping_in_place() +{ + for (const auto implementation : {nam::LinearImplementation::Direct, nam::LinearImplementation::FFT}) + for (const auto shape : {std::pair{1, 2}, {2, 1}, {2, 2}}) + { + const int taps = 1536, frames = 4096; + const int kernels = shape.first == shape.second ? 1 : 2; + std::vector weights(kernels * taps, 0.0f); + weights[0] = 0.5f; + weights[500] = 0.25f; + if (kernels == 2) + { + weights[taps] = -0.25f; + weights[taps + 900] = 0.125f; + } + nam::Linear model(shape.first, shape.second, taps, false, weights, 48000.0, implementation); + model.SetPrewarmOnReset(false); + model.Reset(48000.0, 64); + auto first = make_input(frames), second = make_input(frames); + for (auto& x : second) + x *= 2; + const auto original_first = first, original_second = second; + for (int offset = 0; offset < frames; offset += 64) + { + NAM_SAMPLE* buffers[] = {first.data() + offset, second.data() + offset}; + model.process(buffers, buffers, 64); + } + for (int ch = 0; ch < shape.second; ++ch) + for (int i = 0; i < frames; ++i) + { + double expected = 0.0; + for (int path = 0; path < 2; ++path) + { + if (shape.second > 1 && ch != path) + continue; + const auto& source = shape.first == 1 || path == 0 ? original_first : original_second; + const int k = kernels == 1 ? 0 : path; + for (int tap : {0, 500, 900}) + if (i >= tap) + expected += weights[k * taps + tap] * source[i - tap]; + } + assert_near(ch == 0 ? first[i] : second[i], expected, 1.0e-6); + } + } +} + void test_channel_validation() { const auto defaults = nam::linear::parse_config_json({{"receptive_field", 3}, {"bias", false}}); diff --git a/tools/test/test_sequential.cpp b/tools/test/test_sequential.cpp index c3cdf2c5..4783a2e6 100644 --- a/tools/test/test_sequential.cpp +++ b/tools/test/test_sequential.cpp @@ -284,7 +284,7 @@ void test_sequential_rejects_top_level_sample_rate_mismatch() void test_sequential_rejects_channel_mismatch() { const auto model = - make_sequential_model({make_linear_model({1.0f}, 1, 48000.0, 1, 2), make_linear_model({1.0f}, 1, 48000.0, 1, 1)}); + make_sequential_model({make_linear_model({1.0f, 1.0f}, 1, 48000.0, 1, 2), make_linear_model({1.0f}, 1, 48000.0, 1, 1)}); assert(throws_runtime_error_containing([&]() { auto dsp = nam::get_dsp(model); }, "channel mismatch")); } From 8fba1df34ca3644e66f1dfc17388ae0756ade162 Mon Sep 17 00:00:00 2001 From: Steven Atkinson Date: Fri, 18 Sep 2026 14:11:08 -0700 Subject: [PATCH 3/4] Update linear.rst --- docs/linear.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/linear.rst b/docs/linear.rst index 56e5d905..d57f814e 100644 --- a/docs/linear.rst +++ b/docs/linear.rst @@ -5,7 +5,8 @@ Linear convolution and channel mapping those counts are equal or either count is one. Both fields default to one when omitted from the model configuration. Unequal counts greater than one (for example, 2 inputs and 3 outputs) are rejected when constructing/loading the -model. They no longer silently discard inputs or silence extra outputs. +model. As of v0.5.5, they do not silently discard inputs or silence extra +outputs. Channel mapping --------------- From 4fc743d8d5b7960b2bde6ce05a7de0af704aeb13 Mon Sep 17 00:00:00 2001 From: Steven Atkinson Date: Fri, 18 Sep 2026 14:14:53 -0700 Subject: [PATCH 4/4] docs: describe current Linear behavior and v0.5.5 changes --- docs/linear.rst | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/docs/linear.rst b/docs/linear.rst index d57f814e..9fe908bb 100644 --- a/docs/linear.rst +++ b/docs/linear.rst @@ -5,16 +5,20 @@ Linear convolution and channel mapping those counts are equal or either count is one. Both fields default to one when omitted from the model configuration. Unequal counts greater than one (for example, 2 inputs and 3 outputs) are rejected when constructing/loading the -model. As of v0.5.5, they do not silently discard inputs or silence extra -outputs. +model. + +.. versionchanged:: 0.5.5 + + Added one-to-many and many-to-one convolution with separate impulse + responses. Unequal channel counts greater than one are rejected. Channel mapping --------------- -* **1 input, 1 output:** the existing mono convolution. +* **1 input, 1 output:** mono-to-mono convolution. * **N inputs, N outputs:** each input feeds its corresponding output. All - channels share the same impulse response and, if enabled, bias, preserving - existing equal-channel models. There is no cross-channel mixing. + channels share the same impulse response and, if enabled, bias. There is no + cross-channel mixing. * **1 input, M outputs:** each output has its own impulse response applied to the single input: ``y[j] = convolve(h[j], x[0]) + b[j]``. * **N inputs, 1 output:** each input has its own impulse response, and the @@ -24,12 +28,12 @@ Channel mapping Serialized weights ------------------ -The existing configuration fields ``receptive_field`` and ``bias`` retain their -meaning. All impulse responses have ``receptive_field`` taps at the training -sample rate. No additional configuration fields are required. +The configuration field ``receptive_field`` specifies the number of taps in +each impulse response at the training sample rate. The boolean field ``bias`` +controls whether bias parameters are included. -For equal input/output counts, the existing layout is unchanged: one impulse -response followed by one optional shared bias. The parameter count is +For equal input/output counts, the weights contain one impulse response +followed by one optional shared bias. The parameter count is ``receptive_field + (bias ? 1 : 0)``. For unequal supported counts, concatenate the impulse responses in channel @@ -45,7 +49,7 @@ For example, with two taps per response and bias enabled: * **1 to 2:** ``[h0[0], h0[1], h1[0], h1[1], b0, b1]``. * **2 to 1:** ``[h0[0], h0[1], h1[0], h1[1], b0]``. -* **2 to 2 (legacy):** ``[h[0], h[1], b]``. +* **2 to 2:** ``[h[0], h[1], b]``. Both direct and FFT convolution use these rules. Resetting to a new processing sample rate independently resamples each response from its original training