Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 82 additions & 58 deletions NAM/linear.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ struct nam::LinearFFTState
int spectrum_size = 0;
int num_partitions = 0;
bool runs_inline = false;
std::vector<std::vector<Complex>> kernel_spectra;
std::vector<std::vector<std::vector<Complex>>> kernel_spectra;
std::vector<TierChannelState> channels;
};

Expand All @@ -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();
}
Expand Down Expand Up @@ -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<int>::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<int>::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);
}
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -242,24 +262,28 @@ 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<LinearFFTState::Complex>(tier.spectrum_size));

tier.kernel_spectra.resize(this->_impulse_response.size());
std::vector<float> 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<LinearFFTState::Complex>(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;
if (block_size < plan.max_partition_size)
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)
{
Expand Down Expand Up @@ -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<const Eigen::VectorXf>(&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<const Eigen::VectorXf>(&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);
}
Expand All @@ -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<const Eigen::VectorXf>(&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<const Eigen::VectorXf>(&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)
{
Expand All @@ -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++;
}

Expand Down Expand Up @@ -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--;
Expand Down
20 changes: 14 additions & 6 deletions NAM/linear.h
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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<Eigen::VectorXf> _weight;
std::vector<Eigen::VectorXf> _fft_direct_weight;
std::vector<float> _bias;

private:
// Keep the trained coefficients so repeated rate changes never compound interpolation error.
std::vector<float> _original_impulse_response;
std::vector<float> _impulse_response;
std::vector<std::vector<float>> _original_impulse_response;
std::vector<std::vector<float>> _impulse_response;
LinearImplementation _requested_implementation;
LinearImplementation _active_implementation;
std::unique_ptr<LinearFFTState> _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);
Expand Down
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Welcome to the NeuralAmpModelerCore documentation. This library provides a core

wavenet_walkthrough
nam_file_version
linear
api/index

Overview
Expand Down
57 changes: 57 additions & 0 deletions docs/linear.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
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.

.. 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:** 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. 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 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 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
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:** ``[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.
3 changes: 3 additions & 0 deletions tools/run_tests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,9 @@ 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_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();
Expand Down
Loading
Loading