Skip to content

Commit

Permalink
Revert "Revert "Allow processing square buffers. (#347)" (#361)" (#362)
Browse files Browse the repository at this point in the history
This reverts commit 6029b2b.
  • Loading branch information
psobot authored Jul 29, 2024
1 parent 0beae3c commit 63ab054
Show file tree
Hide file tree
Showing 13 changed files with 401 additions and 33 deletions.
Binary file modified docs/objects.inv
Binary file not shown.
145 changes: 145 additions & 0 deletions docs/reference/pedalboard.html

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/searchindex.js

Large diffs are not rendered by default.

24 changes: 7 additions & 17 deletions pedalboard/BufferUtils.h
Original file line number Diff line number Diff line change
Expand Up @@ -47,15 +47,17 @@ detectChannelLayout(const py::array_t<T, py::array::c_style> inputArray,
} else if (inputInfo.shape[1] == *channelCountHint) {
return ChannelLayout::Interleaved;
} else {
throw std::runtime_error(
"Unable to determine channel layout from shape: (" +
std::to_string(inputInfo.shape[0]) + ", " +
std::to_string(inputInfo.shape[1]) + ").");
// The hint was not used; fall through to the next case.
}
}

// Try to auto-detect the channel layout from the shape
if (inputInfo.shape[1] < inputInfo.shape[0]) {
if (inputInfo.shape[0] == 0 && inputInfo.shape[1] > 0) {
// Zero channels doesn't make sense; but zero samples does.
return ChannelLayout::Interleaved;
} else if (inputInfo.shape[1] == 0 && inputInfo.shape[0] > 0) {
return ChannelLayout::NotInterleaved;
} else if (inputInfo.shape[1] < inputInfo.shape[0]) {
return ChannelLayout::Interleaved;
} else if (inputInfo.shape[0] < inputInfo.shape[1]) {
return ChannelLayout::NotInterleaved;
Expand Down Expand Up @@ -113,12 +115,6 @@ juce::AudioBuffer<T> copyPyArrayIntoJuceBuffer(
std::to_string(inputInfo.ndim) + ").");
}

if (numChannels == 0) {
throw std::runtime_error("No channels passed!");
} else if (numChannels > 2) {
throw std::runtime_error("More than two channels received!");
}

juce::AudioBuffer<T> ioBuffer(numChannels, numSamples);

// Depending on the input channel layout, we need to copy data
Expand Down Expand Up @@ -195,12 +191,6 @@ const juce::AudioBuffer<T> convertPyArrayIntoJuceBuffer(
std::to_string(inputInfo.ndim) + ").");
}

if (numChannels == 0) {
throw std::runtime_error("No channels passed!");
} else if (numChannels > 2) {
throw std::runtime_error("More than two channels received!");
}

T **channelPointers = (T **)alloca(numChannels * sizeof(T *));
for (int c = 0; c < numChannels; c++) {
channelPointers[c] = static_cast<T *>(inputInfo.ptr) + (c * numSamples);
Expand Down
42 changes: 42 additions & 0 deletions pedalboard/Plugin.h
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

#pragma once

#include "BufferUtils.h"
#include "JuceHeader.h"
#include <mutex>

Expand Down Expand Up @@ -53,6 +54,15 @@ class Plugin {
*/
virtual void reset() = 0;

/**
* Reset this plugin's memory of the last channel layout and/or last channel
* count. This should usually not be called directly.
*/
void resetLastChannelLayout() {
lastSpec = {0};
lastChannelLayout = {};
};

/**
* Get the number of samples of latency introduced by this plugin.
* This is the number of samples that must be provided to the plugin
Expand Down Expand Up @@ -82,7 +92,39 @@ class Plugin {
// plugins to avoid deadlocking.
std::mutex mutex;

template <typename T>
ChannelLayout parseAndCacheChannelLayout(
const py::array_t<T, py::array::c_style> inputArray,
std::optional<int> channelCountHint = {}) {

if (!channelCountHint && lastSpec.numChannels != 0) {
channelCountHint = {lastSpec.numChannels};
}

if (lastChannelLayout) {
try {
lastChannelLayout = detectChannelLayout(inputArray, channelCountHint);
} catch (...) {
// Use the last cached layout.
}
} else {
// We have no cached layout; detect it now and raise if necessary:
try {
lastChannelLayout = detectChannelLayout(inputArray, channelCountHint);
} catch (const std::exception &e) {
throw std::runtime_error(
std::string(e.what()) +
" Provide a non-square array first to allow Pedalboard to "
"determine which dimension corresponds with the number of channels "
"and which dimension corresponds with the number of samples.");
}
}

return *lastChannelLayout;
}

protected:
juce::dsp::ProcessSpec lastSpec = {0};
std::optional<ChannelLayout> lastChannelLayout = {};
};
} // namespace Pedalboard
12 changes: 8 additions & 4 deletions pedalboard/plugin_templates/PrimeWithSilence.h
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ class PrimeWithSilence
}

virtual void reset() override {
JucePlugin<juce::dsp::DelayLine<
SampleType, juce::dsp::DelayLineInterpolationTypes::None>>::reset();
this->getDSP().reset();
this->getDSP().setMaximumDelayInSamples(silenceLengthSamples);
this->getDSP().setDelay(silenceLengthSamples);
Expand Down Expand Up @@ -73,11 +75,13 @@ class PrimeWithSilence
T &getNestedPlugin() { return plugin; }

void setSilenceLengthSamples(int newSilenceLengthSamples) {
this->getDSP().setMaximumDelayInSamples(newSilenceLengthSamples);
this->getDSP().setDelay(newSilenceLengthSamples);
silenceLengthSamples = newSilenceLengthSamples;
if (silenceLengthSamples != newSilenceLengthSamples) {
this->getDSP().setMaximumDelayInSamples(newSilenceLengthSamples);
this->getDSP().setDelay(newSilenceLengthSamples);
silenceLengthSamples = newSilenceLengthSamples;

reset();
reset();
}
}

int getSilenceLengthSamples() const { return silenceLengthSamples; }
Expand Down
2 changes: 2 additions & 0 deletions pedalboard/plugins/AddLatency.h
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ class AddLatency : public JucePlugin<juce::dsp::DelayLine<
virtual ~AddLatency(){};

virtual void reset() override {
JucePlugin<juce::dsp::DelayLine<
float, juce::dsp::DelayLineInterpolationTypes::None>>::reset();
getDSP().reset();
samplesProvided = 0;
}
Expand Down
6 changes: 5 additions & 1 deletion pedalboard/plugins/Delay.h
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,11 @@ class Delay : public JucePlugin<juce::dsp::DelayLine<
this->getDSP().setDelay((int)(delaySeconds * spec.sampleRate));
}

virtual void reset() override { this->getDSP().reset(); }
virtual void reset() override {
JucePlugin<juce::dsp::DelayLine<
SampleType, juce::dsp::DelayLineInterpolationTypes::None>>::reset();
this->getDSP().reset();
}

virtual int process(
const juce::dsp::ProcessContextReplacing<SampleType> &context) override {
Expand Down
38 changes: 36 additions & 2 deletions pedalboard/process.h
Original file line number Diff line number Diff line change
Expand Up @@ -163,8 +163,42 @@ py::array_t<float>
processFloat32(const py::array_t<float, py::array::c_style> inputArray,
double sampleRate, std::vector<std::shared_ptr<Plugin>> plugins,
unsigned int bufferSize, bool reset) {
const ChannelLayout inputChannelLayout = detectChannelLayout(inputArray);
juce::AudioBuffer<float> ioBuffer = copyPyArrayIntoJuceBuffer(inputArray);

ChannelLayout inputChannelLayout;
if (!plugins.empty()) {
inputChannelLayout = plugins[0]->parseAndCacheChannelLayout(inputArray);
} else {
inputChannelLayout = detectChannelLayout(inputArray);
}

juce::AudioBuffer<float> ioBuffer =
copyPyArrayIntoJuceBuffer(inputArray, {inputChannelLayout});

if (ioBuffer.getNumChannels() == 0) {
unsigned int numChannels = 0;
unsigned int numSamples = ioBuffer.getNumSamples();
// We have no channels to process; just return an empty output array with
// the same shape. Passing zero channels into JUCE breaks some assumptions
// all over the place.
py::array_t<float> outputArray;
if (inputArray.request().ndim == 2) {
switch (inputChannelLayout) {
case ChannelLayout::Interleaved:
outputArray = py::array_t<float>({numSamples, numChannels});
break;
case ChannelLayout::NotInterleaved:
outputArray = py::array_t<float>({numChannels, numSamples});
break;
default:
throw std::runtime_error(
"Internal error: got unexpected channel layout.");
}
} else {
outputArray = py::array_t<float>(0);
}
return outputArray;
}

int totalOutputLatencySamples;

{
Expand Down
24 changes: 18 additions & 6 deletions pedalboard/python_bindings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -121,10 +121,17 @@ or buffer, set ``reset`` to ``False``.
// type inference.
return nullptr;
}))
.def("reset", &Plugin::reset,
"Clear any internal state stored by this plugin (e.g.: reverb "
"tails, delay lines, LFO state, etc). The values of plugin "
"parameters will remain unchanged. ")
.def(
"reset",
[](std::shared_ptr<Plugin> self) {
self->reset();
// Only reset the last channel layout if the user explicitly calls
// reset from the Python side:
self->resetLastChannelLayout();
},
"Clear any internal state stored by this plugin (e.g.: reverb "
"tails, delay lines, LFO state, etc). The values of plugin "
"parameters will remain unchanged. ")
.def(
"process",
[](std::shared_ptr<Plugin> self, const py::array inputArray,
Expand Down Expand Up @@ -154,12 +161,17 @@ processing begins, clearing any state from previous calls to ``process``.
If calling ``process`` multiple times while processing the same audio file
or buffer, set ``reset`` to ``False``.
The layout of the provided ``input_array`` will be automatically detected,
assuming that the smaller dimension corresponds with the number of channels.
If the number of samples and the number of channels are the same, each
:py:class:`Plugin` object will use the last-detected channel layout until
:py:meth:`reset` is explicitly called (as of v0.9.9).
.. note::
The :py:meth:`process` method can also be used via :py:meth:`__call__`;
i.e.: just calling this object like a function (``my_plugin(...)``) will
automatically invoke :py:meth:`process` with the same arguments.
)",
)",
py::arg("input_array"), py::arg("sample_rate"),
py::arg("buffer_size") = DEFAULT_BUFFER_SIZE, py::arg("reset") = true)
.def(
Expand Down
8 changes: 6 additions & 2 deletions pedalboard_native/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -114,12 +114,16 @@ class Plugin:
If calling ``process`` multiple times while processing the same audio file
or buffer, set ``reset`` to ``False``.
The layout of the provided ``input_array`` will be automatically detected,
assuming that the smaller dimension corresponds with the number of channels.
If the number of samples and the number of channels are the same, each
:py:class:`Plugin` object will use the last-detected channel layout until
:py:meth:`reset` is explicitly called (as of v0.9.9).
.. note::
The :py:meth:`process` method can also be used via :py:meth:`__call__`;
i.e.: just calling this object like a function (``my_plugin(...)``) will
automatically invoke :py:meth:`process` with the same arguments.
"""

def reset(self) -> None:
Expand Down
103 changes: 103 additions & 0 deletions tests/test_native_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,26 @@
import pytest

from pedalboard import (
Bitcrush,
Chorus,
Clipping,
Compressor,
Convolution,
Delay,
Distortion,
Gain,
GSMFullRateCompressor,
HighpassFilter,
HighShelfFilter,
Invert,
LowpassFilter,
LowShelfFilter,
MP3Compressor,
NoiseGate,
PeakFilter,
Phaser,
PitchShift,
Resample,
Reverb,
process,
)
Expand All @@ -35,6 +49,31 @@
IMPULSE_RESPONSE_PATH = os.path.join(os.path.dirname(__file__), "impulse_response.wav")


ALL_BUILTIN_PLUGINS = [
Compressor,
Delay,
Distortion,
Gain,
Invert,
Reverb,
Bitcrush,
Chorus,
Clipping,
# Convolution, # Not instantiable with zero arguments
HighpassFilter,
LowpassFilter,
HighShelfFilter,
LowShelfFilter,
PeakFilter,
NoiseGate,
Phaser,
PitchShift,
MP3Compressor,
GSMFullRateCompressor,
Resample,
]


@pytest.mark.parametrize("shape", [(44100,), (44100, 1), (44100, 2), (1, 44100), (2, 44100)])
def test_no_transforms(shape, sr=44100):
_input = np.random.rand(*shape).astype(np.float32)
Expand Down Expand Up @@ -208,3 +247,67 @@ def test_plugin_state_not_cleared_if_passed_smaller_buffer():
effected_silence_noise_floor = np.amax(np.abs(effected_silence))

assert effected_silence_noise_floor > 0.25


@pytest.mark.parametrize("plugin_class", ALL_BUILTIN_PLUGINS)
def test_process_differently_shaped_empty_buffers(plugin_class):
plugin = plugin_class()
sr = 44100
assert plugin(np.zeros((0, 1), dtype=np.float32), sr).shape == (0, 1)
assert plugin(np.zeros((1, 0), dtype=np.float32), sr).shape == (1, 0)
assert plugin(np.zeros((0,), dtype=np.float32), sr).shape == (0,)


@pytest.mark.parametrize("plugin_class", ALL_BUILTIN_PLUGINS)
def test_process_one_by_one_buffer(plugin_class):
plugin = plugin_class()
sr = 44100
# Processing a single sample at a time should work:
assert plugin(np.zeros((1, 1), dtype=np.float32), sr).shape == (1, 1)
# Processing that same sample as a flat 1D array should work too:
assert plugin(np.zeros((1,), dtype=np.float32), sr).shape == (1,)


@pytest.mark.parametrize("plugin_class", ALL_BUILTIN_PLUGINS)
def test_process_two_by_two_buffer(plugin_class):
plugin = plugin_class()
sr = 44100

# Writing a 2x2 buffer should not work right off the bat, as we
# can't tell which dimension is channels and which dimension is
# samples:
with pytest.raises(RuntimeError) as e:
plugin(np.zeros((2, 2), dtype=np.float32), sr).shape
assert "Provide a non-square array first" in str(e)

# ...but if we write a non-square buffer, it should work:
output = plugin(np.zeros((2, 1024), dtype=np.float32), sr)
assert output.shape[0] == 2
# ...and now square buffers are interpreted as having the same channel layout:
assert plugin(np.zeros((2, 2), dtype=np.float32), sr).shape[0] == 2


@pytest.mark.parametrize("channel_dimension", [0, 1])
@pytest.mark.parametrize("plugin_class", ALL_BUILTIN_PLUGINS)
def test_process_two_by_two_buffer_with_hint(plugin_class, channel_dimension: int):
plugin = plugin_class()
sr = 44100

empty_shape = (2, 0) if channel_dimension == 0 else (0, 2)

# ...if we pass an empty array of the right shape, that shape hint should be saved:
assert plugin(np.zeros(empty_shape, dtype=np.float32), sr).shape == empty_shape
# ...and now square buffers are interpreted as having the same channel layout:
output = plugin(np.zeros((2, 2), dtype=np.float32), sr)
assert output.shape[channel_dimension] == 2

# Some plugins buffer their output, so make sure we eventually do get something out in the right shape:
while output.shape[1 if channel_dimension == 0 else 0] == 0:
output = plugin(np.zeros((2, 2), dtype=np.float32), sr, reset=False)
assert output.shape[channel_dimension] == 2

# ...but not if we call reset():
plugin.reset()
with pytest.raises(RuntimeError) as e:
plugin(np.zeros((2, 2), dtype=np.float32), sr).shape
assert "Provide a non-square array first" in str(e)
Loading

0 comments on commit 63ab054

Please sign in to comment.