Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature request: add Fuzz plugin for Pedalboard (#402) #403

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
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
119 changes: 119 additions & 0 deletions pedalboard/plugins/Fuzz.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/*
* pedalboard
* Copyright 2025 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 <cmath>
#include <pybind11/pybind11.h>
#include <pybind11/stl.h>

namespace py = pybind11;

#include "../JucePlugin.h"
#include <juce_dsp/juce_dsp.h>

namespace Pedalboard {

template <typename SampleType>
class Fuzz
: public JucePlugin<juce::dsp::ProcessorChain<
juce::dsp::Gain<SampleType>, // Drive stage
juce::dsp::WaveShaper<SampleType>, // Hard diode clipping stage
juce::dsp::WaveShaper<SampleType>, // Soft clipping stage (tanh)
juce::dsp::IIR::Filter<SampleType> // Tone control (low-pass filter)
>> {
public:
Fuzz()
: driveDecibels(static_cast<SampleType>(25)),
toneHz(static_cast<SampleType>(800)) {}

void setDriveDecibels(const float f) noexcept { driveDecibels = f; }
float getDriveDecibels() const noexcept { return driveDecibels; }

void setToneHz(const float f) noexcept { toneHz = f; }
float getToneHz() const noexcept { return toneHz; }

virtual void prepare(const juce::dsp::ProcessSpec &spec) override {
// Prepare the four-stage DSP chain
JucePlugin<juce::dsp::ProcessorChain<
juce::dsp::Gain<SampleType>, juce::dsp::WaveShaper<SampleType>,
juce::dsp::WaveShaper<SampleType>,
juce::dsp::IIR::Filter<SampleType>>>::prepare(spec);

// First stage (1st): apply gain to drive
this->getDSP().template get<gainIndex>().setGainDecibels(
getDriveDecibels());

// First stage (2nd): diode hard clipping with threshold 0.25
this->getDSP().template get<clipperIndex>().functionToUse =
[](SampleType x) -> SampleType {
constexpr SampleType threshold = static_cast<SampleType>(0.25);
if (x > threshold)
return threshold;
if (x < -threshold)
return -threshold;
return x;
};

// Second stage: soft clipping via tanh
this->getDSP().template get<shaperIndex>().functionToUse =
[](SampleType x) -> SampleType {
return static_cast<SampleType>(std::tanh(x));
};

// Third stage: Tone control via low-pass filter
auto coeffs = juce::dsp::IIR::Coefficients<SampleType>::makeLowPass(
spec.sampleRate, toneHz);
this->getDSP().template get<filterIndex>().coefficients = coeffs;
}

private:
SampleType driveDecibels;
SampleType toneHz;

enum { gainIndex, clipperIndex, shaperIndex, filterIndex };
};

inline void init_fuzz(py::module &m) {
py::class_<Fuzz<float>, Plugin, std::shared_ptr<Fuzz<float>>>(
m, "Fuzz",
"A Fuzz effect emulating a classic fuzz pedal.\\n\\n"
"It features a two-stage clipping process: first a hard diode clipping "
"(threshold=0.25),"
"then a soft clipping via tanh, followed by a tone control stage "
"implemented as a low-pass filter.\\n")
.def(py::init([](float drive_db, float tone_hz) {
auto plugin = std::make_unique<Fuzz<float>>();
plugin->setDriveDecibels(drive_db);
plugin->setToneHz(tone_hz);
return plugin;
}),
py::arg("drive_db") = 25, py::arg("tone_hz") = 800)
.def("__repr__",
[](const Fuzz<float> &plugin) {
std::ostringstream ss;
ss << "<pedalboard.Fuzz";
ss << " drive_db=" << plugin.getDriveDecibels();
ss << " tone_hz=" << plugin.getToneHz();
ss << " at " << &plugin;
ss << ">";
return ss.str();
})
.def_property("drive_db", &Fuzz<float>::getDriveDecibels,
&Fuzz<float>::setDriveDecibels)
.def_property("tone_hz", &Fuzz<float>::getToneHz,
&Fuzz<float>::setToneHz);
}
}; // namespace Pedalboard
2 changes: 2 additions & 0 deletions pedalboard/python_bindings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ namespace py = pybind11;
#include "plugins/Convolution.h"
#include "plugins/Delay.h"
#include "plugins/Distortion.h"
#include "plugins/Fuzz.h"
#include "plugins/GSMFullRateCompressor.h"
#include "plugins/Gain.h"
#include "plugins/HighpassFilter.h"
Expand Down Expand Up @@ -209,6 +210,7 @@ If the number of samples and the number of channels are the same, each
init_convolution(m);
init_delay(m);
init_distortion(m);
init_fuzz(m);
init_gain(m);

// Init Resample before GSMFullRateCompressor, which uses Resample::Quality:
Expand Down
27 changes: 27 additions & 0 deletions pedalboard_native/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ __all__ = [
"Convolution",
"Delay",
"Distortion",
"Fuzz",
"ExternalPlugin",
"GSMFullRateCompressor",
"Gain",
Expand Down Expand Up @@ -394,6 +395,32 @@ class Distortion(Plugin):
pass
pass

class Fuzz(Plugin):
"""
A Fuzz effect emulating a classic fuzz pedal.
It features a two-stage clipping process: first a hard diode clipping (threshold=0.25),
then a soft clipping via tanh, followed by a tone control stage implemented as a low-pass filter.
"""

def __init__(self, drive_db: float = 25, tone_hz: float = 800) -> None: ...
def __repr__(self) -> str: ...
@property
def drive_db(self) -> float:
""" """

@drive_db.setter
def drive_db(self, arg1: float) -> None:
pass

@property
def tone_hz(self) -> float:
""" """

@tone_hz.setter
def tone_hz(self, arg1: float) -> None:
pass
pass

class ExternalPlugin(Plugin):
"""
A wrapper around a third-party effect plugin.
Expand Down
73 changes: 73 additions & 0 deletions tests/test_fuzz.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import pytest
import numpy as np
from pedalboard import Fuzz, Pedalboard

NUM_SECONDS = 1
MAX_SAMPLE_RATE = 96000
NOISE = np.random.rand(int(NUM_SECONDS * MAX_SAMPLE_RATE)).astype(np.float32)


def test_default_values():
# Creates the Fuzz instance with default values ​​(drive_db=25, tone_hz=800)
fuzz = Fuzz()
assert fuzz.drive_db == 25, "The default drive_db value should be 25"
assert fuzz.tone_hz == 800, "The default tone_hz value should be 800"


def test_custom_initialization():
# Create an instance with custom parameters and verify that they are set correctly
drive_db = 30
tone_hz = 1200
fuzz = Fuzz(drive_db, tone_hz)
assert fuzz.drive_db == drive_db, "The value of drive_db does not match the initialized one"
assert fuzz.tone_hz == tone_hz, "The value of tone_hz does not match the initialized one"


def test_property_setters():
# Edit the properties and check that the setters are working
fuzz = Fuzz()
fuzz.drive_db = 40
fuzz.tone_hz = 900
assert fuzz.drive_db == 40, "The setter for drive_db did not update the value correctly"
assert fuzz.tone_hz == 900, "The setter for tone_hz did not update the value correctly"


def test_repr():
# Check if __repr__ method returns a string containing the expected information
fuzz = Fuzz(35, 950)
rep = repr(fuzz)
assert "drive_db=35" in rep, "__repr__ must contains drive_db value"
assert "tone_hz=950" in rep, "__repr__ must contains tone_hz value"
assert "pedalboard.Fuzz" in rep, "__repr__ must indicate plugin type"


def test_fuzz_process_silence():
fuzz = Fuzz()
sample_rate = 44100
num_samples = sample_rate # mono audio - 1 sec
audio_in = np.zeros((num_samples, 1), dtype=np.float32)

audio_out = fuzz.process(audio_in, sample_rate)
np.testing.assert_array_almost_equal(audio_out, audio_in, decimal=5)


def test_fuzz_process_signal():
drive_db = 30
tone_hz = 800
sample_rate = 44100
_input = NOISE[: int(NUM_SECONDS * sample_rate)]
fuzz = Fuzz(drive_db, tone_hz)
audio_in = np.column_stack((_input, _input))

audio_out = fuzz.process(audio_in, sample_rate)
assert audio_out.shape == audio_in.shape, "The shape of the output must be the same as the input"
assert not np.allclose(audio_out, audio_in, atol=0.00001)


@pytest.mark.parametrize("drive_db", [25.0, 35.0, 45.0])
@pytest.mark.parametrize("tone_hz", [800, 1000, 4000, 12000])
def test_fuzz_in_pedalboard(drive_db, tone_hz):
fuzz_effect = Fuzz(drive_db=drive_db, tone_hz=tone_hz)
pb = Pedalboard([fuzz_effect])

assert pb[0] == fuzz_effect
Loading