diff --git a/pedalboard/plugins/DelayLine.h b/pedalboard/plugins/AddLatency.h similarity index 77% rename from pedalboard/plugins/DelayLine.h rename to pedalboard/plugins/AddLatency.h index 01741794..15df4fcc 100644 --- a/pedalboard/plugins/DelayLine.h +++ b/pedalboard/plugins/AddLatency.h @@ -21,12 +21,12 @@ namespace Pedalboard { /** * A dummy plugin that buffers audio data internally, used to test Pedalboard's - * automatic delay compensation. + * automatic latency compensation. */ -class DelayLine : public JucePlugin> { +class AddLatency : public JucePlugin> { public: - virtual ~DelayLine(){}; + virtual ~AddLatency(){}; virtual void reset() override { getDSP().reset(); @@ -49,18 +49,18 @@ class DelayLine : public JucePlugin( - m, "DelayLine", +inline void init_add_latency(py::module &m) { + py::class_( + m, "AddLatency", "A dummy plugin that delays input audio for the given number of samples " "before passing it back to the output. Used internally to test " "Pedalboard's automatic latency compensation. Probably not useful as a " "real effect.") .def(py::init([](int samples) { - auto dl = new DelayLine(); - dl->getDSP().setMaximumDelayInSamples(samples); - dl->getDSP().setDelay(samples); - return dl; + auto al = new AddLatency(); + al->getDSP().setMaximumDelayInSamples(samples); + al->getDSP().setDelay(samples); + return al; }), py::arg("samples") = 44100); } diff --git a/pedalboard/plugins/Delay.h b/pedalboard/plugins/Delay.h new file mode 100644 index 00000000..4fe60a12 --- /dev/null +++ b/pedalboard/plugins/Delay.h @@ -0,0 +1,133 @@ +/* + * pedalboard + * Copyright 2022 Spotify AB + * + * Licensed under the GNU Public License, Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0.html + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "../JucePlugin.h" + +namespace Pedalboard { +template +class Delay : public JucePlugin> { +public: + SampleType getDelaySeconds() const { return delaySeconds; } + void setDelaySeconds(const SampleType value) { + if (value < 0.0 || value > MAXIMUM_DELAY_TIME_SECONDS) { + throw std::range_error("Delay (in seconds) must be between 0.0s and " + + std::to_string(MAXIMUM_DELAY_TIME_SECONDS) + "s."); + } + delaySeconds = value; + }; + SampleType getFeedback() const { return feedback; } + void setFeedback(const SampleType value) { + if (value < 0.0 || value > 1.0) { + throw std::range_error("Feedback must be between 0.0 and 1.0."); + } + feedback = value; + }; + SampleType getMix() const { return mix; } + void setMix(const SampleType value) { + if (value < 0.0 || value > 1.0) { + throw std::range_error("Mix must be between 0.0 and 1.0."); + } + mix = value; + }; + + virtual void prepare(const juce::dsp::ProcessSpec &spec) override { + if (this->lastSpec.sampleRate != spec.sampleRate || + this->lastSpec.maximumBlockSize < spec.maximumBlockSize || + spec.numChannels != this->lastSpec.numChannels) { + this->getDSP().setMaximumDelayInSamples( + (int)(MAXIMUM_DELAY_TIME_SECONDS * spec.sampleRate)); + this->getDSP().prepare(spec); + this->lastSpec = spec; + } + + this->getDSP().setDelay((int)(delaySeconds * spec.sampleRate)); + } + + virtual void reset() override { this->getDSP().reset(); } + + virtual int process( + const juce::dsp::ProcessContextReplacing &context) override { + // TODO: More advanced mixing rules than "linear?" + SampleType dryVolume = 1.0f - getMix(); + SampleType wetVolume = getMix(); + + if (delaySeconds == 0.0f) { + // Special case where DelayLine doesn't do anything for us. + // Regardless of the mix or feedback parameters, the input will sound + // identical. + return context.getInputBlock().getNumSamples(); + } + + this->getDSP().setDelay((int)(delaySeconds * this->lastSpec.sampleRate)); + + // Pass samples through the delay line with feedback: + for (size_t c = 0; c < context.getInputBlock().getNumChannels(); c++) { + jassert(context.getInputBlock().getChannelPointer(c) == + context.getOutputBlock().getChannelPointer(c)); + SampleType *channelBuffer = context.getOutputBlock().getChannelPointer(c); + + for (size_t i = 0; i < context.getInputBlock().getNumSamples(); i++) { + SampleType delayOutput = this->getDSP().popSample(c); + this->getDSP().pushSample(c, channelBuffer[i] + + (getFeedback() * delayOutput)); + channelBuffer[i] = + (channelBuffer[i] * dryVolume) + (wetVolume * delayOutput); + } + } + return context.getInputBlock().getNumSamples(); + } + +private: + SampleType delaySeconds = 1.0f; + SampleType feedback = 0.0f; + SampleType mix = 1.0f; + static constexpr int MAXIMUM_DELAY_TIME_SECONDS = 30; +}; + +inline void init_delay(py::module &m) { + py::class_, Plugin>( + m, "Delay", + "A digital delay plugin with controllable delay time, feedback " + "percentage, and dry/wet mix.") + .def(py::init([](float delaySeconds, float feedback, float mix) { + auto delay = new Delay(); + delay->setDelaySeconds(delaySeconds); + delay->setFeedback(feedback); + delay->setMix(mix); + return delay; + }), + py::arg("delay_seconds") = 0.5, py::arg("feedback") = 0.0, + py::arg("mix") = 0.5) + .def("__repr__", + [](const Delay &plugin) { + std::ostringstream ss; + ss << ""; + return ss.str(); + }) + .def_property("delay_seconds", &Delay::getDelaySeconds, + &Delay::setDelaySeconds) + .def_property("feedback", &Delay::getFeedback, + &Delay::setFeedback) + .def_property("mix", &Delay::getMix, &Delay::setMix); +} +}; // namespace Pedalboard diff --git a/pedalboard/python_bindings.cpp b/pedalboard/python_bindings.cpp index f9b6e41b..61bf1156 100644 --- a/pedalboard/python_bindings.cpp +++ b/pedalboard/python_bindings.cpp @@ -32,10 +32,11 @@ namespace py = pybind11; #include "Plugin.h" #include "process.h" +#include "plugins/AddLatency.h" #include "plugins/Chorus.h" #include "plugins/Compressor.h" #include "plugins/Convolution.h" -#include "plugins/DelayLine.h" +#include "plugins/Delay.h" #include "plugins/Distortion.h" #include "plugins/Gain.h" #include "plugins/HighpassFilter.h" @@ -136,6 +137,7 @@ PYBIND11_MODULE(pedalboard_native, m) { init_chorus(m); init_compressor(m); init_convolution(m); + init_delay(m); init_distortion(m); init_gain(m); init_highpass(m); @@ -151,5 +153,5 @@ PYBIND11_MODULE(pedalboard_native, m) { // Internal plugins for testing, debugging, etc: py::module internal = m.def_submodule("_internal"); - init_delay_line(internal); + init_add_latency(internal); }; diff --git a/tests/test_latency_compensation.py b/tests/test_latency_compensation.py index 1af267ac..d65d0fe3 100644 --- a/tests/test_latency_compensation.py +++ b/tests/test_latency_compensation.py @@ -17,7 +17,7 @@ import pytest import numpy as np -from pedalboard_native._internal import DelayLine +from pedalboard_native._internal import AddLatency @pytest.mark.parametrize("sample_rate", [22050, 44100, 48000]) @@ -26,6 +26,6 @@ def test_latency_compensation(sample_rate, buffer_size, latency_seconds): num_seconds = 10.0 noise = np.random.rand(int(num_seconds * sample_rate)) - plugin = DelayLine(int(latency_seconds * sample_rate)) + plugin = AddLatency(int(latency_seconds * sample_rate)) output = plugin.process(noise, sample_rate, buffer_size=buffer_size) np.testing.assert_allclose(output, noise) diff --git a/tests/test_native_module.py b/tests/test_native_module.py index 4df3863b..43be12ae 100644 --- a/tests/test_native_module.py +++ b/tests/test_native_module.py @@ -18,7 +18,7 @@ import os import pytest import numpy as np -from pedalboard import process, Distortion, Gain, Compressor, Convolution, Reverb +from pedalboard import process, Delay, Distortion, Gain, Compressor, Convolution, Reverb IMPULSE_RESPONSE_PATH = os.path.join(os.path.dirname(__file__), "impulse_response.wav") @@ -91,6 +91,29 @@ def test_distortion(gain_db, shape, sr=44100): np.testing.assert_allclose(np.tanh(full_scale_noise * gain_scale), result, rtol=4e-7, atol=2e-7) +def test_delay(): + delay_seconds = 2.5 + feedback = 0.0 + mix = 0.5 + duration = 10.0 + sr = 44100 + + full_scale_noise = np.random.rand(int(sr * duration)).astype(np.float32) + result = Delay(delay_seconds, feedback, mix)(full_scale_noise, sr) + + # Manually do what a delay plugin would do: + dry_volume = 1.0 - mix + wet_volume = mix + + delayed_line = np.concatenate([np.zeros(int(delay_seconds * sr)), full_scale_noise])[ + : len(result) + ] + expected = (dry_volume * full_scale_noise) + (wet_volume * delayed_line) + + np.testing.assert_equal(result.shape, expected.shape) + np.testing.assert_allclose(expected, result, rtol=4e-7, atol=2e-7) + + @pytest.mark.parametrize("reset", (True, False)) def test_plugin_state_not_cleared_between_invocations(reset: bool): """