diff --git a/.gitmodules b/.gitmodules index dc118c22..0f442f8a 100644 --- a/.gitmodules +++ b/.gitmodules @@ -7,3 +7,6 @@ [submodule "vendors/lame"] path = vendors/lame url = https://github.com/lameproject/lame.git +[submodule "vendors/libgsm"] + path = vendors/libgsm + url = git@github.com:timothytylee/libgsm.git diff --git a/README.md b/README.md index 42b2b1fe..6671ab69 100644 --- a/README.md +++ b/README.md @@ -17,20 +17,14 @@ Internally at Spotify, `pedalboard` is used for [data augmentation](https://en.w ## Features - - Built-in support for a number of basic audio transformations: - - `Convolution` - - `Compressor` - - `Chorus` - - `Distortion` - - `Gain` - - `HighpassFilter` - - `LadderFilter` - - `Limiter` - - `LowpassFilter` - - `Phaser` - - `PitchShift` (provided by Chris Cannam's [Rubber Band Library](https://github.com/breakfastquay/rubberband)) - - `Resample` - - `Reverb` + - Built-in support for a number of basic audio transformations, including: + - Guitar-style effects: `Chorus`, `Distortion`, `Phaser` + - Loudness and dynamic range effects: `Compressor`, `Gain`, `Limiter` + - Equalizers and filters: `HighpassFilter`, `LadderFilter`, `LowpassFilter` + - Spatial effects: `Convolution`, `Delay`, `Reverb` + - Pitch effects: `PitchShift` + - Lossy compression: `GSMFullRateCompressor`, `MP3Compressor` + - Quality reduction: `Resample` - Supports VST3® plugins on macOS, Windows, and Linux (`pedalboard.load_plugin`) - Supports Audio Units on macOS - Strong thread-safety, memory usage, and speed guarantees @@ -243,5 +237,6 @@ Not yet, either - although the underlying framework (JUCE) supports passing MIDI - The [VST3 SDK](https://github.com/steinbergmedia/vst3sdk), bundled with JUCE, is owned by [Steinberg® Media Technologies GmbH](https://www.steinberg.net/en/home.html) and licensed under the GPLv3. - The `PitchShift` plugin uses [the Rubber Band Library](https://github.com/breakfastquay/rubberband), which is [dual-licensed under a commercial license](https://breakfastquay.com/technology/license.html) and the GPLv2 (or newer). - The `MP3Compressor` plugin uses [`libmp3lame` from the LAME project](https://lame.sourceforge.io/), which is [licensed under the LGPLv2](https://github.com/lameproject/lame/blob/master/README) and [upgraded to the GPLv3 for inclusion in this project (as permitted by the LGPLv2)](https://www.gnu.org/licenses/gpl-faq.html#AllCompatibility). + - The `GSMFullRateCompressor` plugin uses [`libgsm`](http://quut.com/gsm/), which is [licensed under the ISC license](https://github.com/timothytylee/libgsm/blob/master/COPYRIGHT) and [compatible with the GPLv3](https://www.gnu.org/licenses/license-list.en.html#ISC). _VST is a registered trademark of Steinberg Media Technologies GmbH._ diff --git a/pedalboard/plugin_templates/PrimeWithSilence.h b/pedalboard/plugin_templates/PrimeWithSilence.h index 043ba6aa..ac02b319 100644 --- a/pedalboard/plugin_templates/PrimeWithSilence.h +++ b/pedalboard/plugin_templates/PrimeWithSilence.h @@ -28,7 +28,8 @@ namespace Pedalboard { * A dummy plugin that buffers audio data internally, used to test Pedalboard's * automatic latency compensation. */ -template +template class PrimeWithSilence : public JucePlugin> { @@ -39,6 +40,8 @@ class PrimeWithSilence JucePlugin>::prepare(spec); + this->getDSP().setMaximumDelayInSamples(silenceLengthSamples); + this->getDSP().setDelay(silenceLengthSamples); plugin.prepare(spec); } @@ -82,7 +85,7 @@ class PrimeWithSilence private: T plugin; int samplesOutput = 0; - int silenceLengthSamples = 0; + int silenceLengthSamples = DefaultSilenceLengthSamples; }; /** diff --git a/pedalboard/plugins/AddLatency.h b/pedalboard/plugins/AddLatency.h index fa993c80..041f8696 100644 --- a/pedalboard/plugins/AddLatency.h +++ b/pedalboard/plugins/AddLatency.h @@ -16,6 +16,8 @@ */ #pragma once +#pragma once + #include "../JucePlugin.h" namespace Pedalboard { diff --git a/pedalboard/plugins/GSMFullRateCompressor.h b/pedalboard/plugins/GSMFullRateCompressor.h new file mode 100644 index 00000000..2c43c0f8 --- /dev/null +++ b/pedalboard/plugins/GSMFullRateCompressor.h @@ -0,0 +1,179 @@ +/* + * 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 "../Plugin.h" +#include "../plugin_templates/FixedBlockSize.h" +#include "../plugin_templates/ForceMono.h" +#include "../plugin_templates/PrimeWithSilence.h" +#include "../plugin_templates/Resample.h" + +extern "C" { +#include +} + +namespace Pedalboard { + +/* + * A small C++ wrapper around the C-based libgsm object. + * Used mostly to avoid leaking memory. + */ +class GSMWrapper { +public: + GSMWrapper() {} + ~GSMWrapper() { reset(); } + + operator bool() const { return _gsm != nullptr; } + + void reset() { + gsm_destroy(_gsm); + _gsm = nullptr; + } + + gsm getContext() { + if (!_gsm) + _gsm = gsm_create(); + return _gsm; + } + +private: + gsm _gsm = nullptr; +}; + +class GSMFullRateCompressorInternal : public Plugin { +public: + virtual ~GSMFullRateCompressorInternal(){}; + + virtual void prepare(const juce::dsp::ProcessSpec &spec) override { + bool specChanged = lastSpec.sampleRate != spec.sampleRate || + lastSpec.maximumBlockSize < spec.maximumBlockSize || + lastSpec.numChannels != spec.numChannels; + if (!encoder || specChanged) { + reset(); + + if (spec.sampleRate != GSM_SAMPLE_RATE) { + throw std::runtime_error("GSMCompressor plugin must be run at " + + std::to_string(GSM_SAMPLE_RATE) + "Hz!"); + } + + if (!encoder.getContext()) { + throw std::runtime_error("Failed to initialize GSM encoder."); + } + if (!decoder.getContext()) { + throw std::runtime_error("Failed to initialize GSM decoder."); + } + + lastSpec = spec; + } + } + + int process( + const juce::dsp::ProcessContextReplacing &context) override final { + auto ioBlock = context.getOutputBlock(); + + if (ioBlock.getNumSamples() != GSM_FRAME_SIZE_SAMPLES) { + throw std::runtime_error("GSMCompressor plugin must be passed exactly " + + std::to_string(GSM_FRAME_SIZE_SAMPLES) + + " at a time."); + } + + if (ioBlock.getNumChannels() != 1) { + throw std::runtime_error( + "GSMCompressor plugin must be passed mono input!"); + } + + // Convert samples to signed 16-bit integer first, + // then pass to the GSM Encoder, then immediately back + // around to the GSM decoder. + short frame[GSM_FRAME_SIZE_SAMPLES]; + + juce::AudioDataConverters::convertFloatToInt16LE( + ioBlock.getChannelPointer(0), frame, GSM_FRAME_SIZE_SAMPLES); + + // Actually do the GSM processing! + gsm_frame encodedFrame; + + gsm_encode(encoder.getContext(), frame, encodedFrame); + if (gsm_decode(decoder.getContext(), encodedFrame, frame) < 0) { + throw std::runtime_error("GSM decoder could not decode frame!"); + } + + juce::AudioDataConverters::convertInt16LEToFloat( + frame, ioBlock.getChannelPointer(0), GSM_FRAME_SIZE_SAMPLES); + + return GSM_FRAME_SIZE_SAMPLES; + } + + void reset() override final { + encoder.reset(); + decoder.reset(); + } + + static constexpr size_t GSM_FRAME_SIZE_SAMPLES = 160; + static constexpr int GSM_SAMPLE_RATE = 8000; + +private: + GSMWrapper encoder; + GSMWrapper decoder; +}; + +/** + * Use the GSMFullRateCompressorInternal plugin, but: + * - ensure that it only ever sees fixed-size blocks of 160 samples + * - prime the input with a single block of silence + * - resample whatever input sample rate is provided down to 8kHz + * - only provide mono input to the plugin, and copy the mono signal + * back to stereo if necessary + */ +using GSMFullRateCompressor = ForceMono, + float, GSMFullRateCompressorInternal::GSM_FRAME_SIZE_SAMPLES>, + float, GSMFullRateCompressorInternal::GSM_SAMPLE_RATE>>; + +inline void init_gsm_full_rate_compressor(py::module &m) { + py::class_( + m, "GSMFullRateCompressor", + "Apply an GSM Full Rate compressor to emulate the sound of a GSM Full " + "Rate (\"2G\") cellular " + "phone connection. This plugin internally resamples the input audio to " + "8kHz.") + .def(py::init([](ResamplingQuality quality) { + auto plugin = std::make_unique(); + plugin->getNestedPlugin().setQuality(quality); + return plugin; + }), + py::arg("quality") = ResamplingQuality::WindowedSinc) + .def("__repr__", + [](const GSMFullRateCompressor &plugin) { + std::ostringstream ss; + ss << ""; + return ss.str(); + }) + .def_property( + "quality", + [](GSMFullRateCompressor &plugin) { + return plugin.getNestedPlugin().getQuality(); + }, + [](GSMFullRateCompressor &plugin, ResamplingQuality quality) { + return plugin.getNestedPlugin().setQuality(quality); + }); +} + +}; // namespace Pedalboard \ No newline at end of file diff --git a/pedalboard/python_bindings.cpp b/pedalboard/python_bindings.cpp index cdbb1b7a..8a3402c1 100644 --- a/pedalboard/python_bindings.cpp +++ b/pedalboard/python_bindings.cpp @@ -43,6 +43,7 @@ namespace py = pybind11; #include "plugins/Convolution.h" #include "plugins/Delay.h" #include "plugins/Distortion.h" +#include "plugins/GSMFullRateCompressor.h" #include "plugins/Gain.h" #include "plugins/HighpassFilter.h" #include "plugins/Invert.h" @@ -148,6 +149,11 @@ PYBIND11_MODULE(pedalboard_native, m) { init_delay(m); init_distortion(m); init_gain(m); + + // Init Resample before GSMFullRateCompressor, which uses Resample::Quality: + init_resample(m); + init_gsm_full_rate_compressor(m); + init_highpass(m); init_invert(m); init_ladderfilter(m); @@ -157,7 +163,6 @@ PYBIND11_MODULE(pedalboard_native, m) { init_noisegate(m); init_phaser(m); init_pitch_shift(m); - init_resample(m); init_reverb(m); init_external_plugins(m); diff --git a/setup.py b/setup.py index 364616c0..ed685343 100644 --- a/setup.py +++ b/setup.py @@ -106,6 +106,10 @@ 'vendors/lame/', ] +# libgsm +ALL_SOURCE_PATHS += [p for p in Path("vendors/libgsm/src").glob("*.c") if 'toast' not in p.name] +ALL_INCLUDES += ['vendors/libgsm/inc'] + # Add platform-specific flags: if platform.system() == "Darwin": @@ -227,6 +231,7 @@ def patch_compile(original_compile): """ On GCC/Clang, we want to pass different arguments when compiling C files vs C++ files. """ + def new_compile(obj, src, ext, cc_args, extra_postargs, *args, **kwargs): _cc_args = cc_args diff --git a/tests/test_gsm_compressor.py b/tests/test_gsm_compressor.py new file mode 100644 index 00000000..b8cd4cc0 --- /dev/null +++ b/tests/test_gsm_compressor.py @@ -0,0 +1,86 @@ +#! /usr/bin/env python +# +# Copyright 2021 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. + + +import pytest +import numpy as np +from pedalboard import GSMFullRateCompressor, Resample +from .utils import generate_sine_at + +# GSM is a _very_ lossy codec: +GSM_ABSOLUTE_TOLERANCE = 0.75 + +# Passing in a full-scale sine wave seems to often make GSM clip: +SINE_WAVE_VOLUME = 0.9 + + +@pytest.mark.parametrize("fundamental_hz", [440.0]) +@pytest.mark.parametrize("sample_rate", [8000, 11025, 32001.2345, 44100, 48000]) +@pytest.mark.parametrize("buffer_size", [1, 32, 160, 1_000_000]) +@pytest.mark.parametrize("duration", [1.0]) +@pytest.mark.parametrize( + "quality", + [ + Resample.Quality.ZeroOrderHold, + Resample.Quality.Linear, + Resample.Quality.Lagrange, + Resample.Quality.CatmullRom, + Resample.Quality.WindowedSinc, + ], +) +@pytest.mark.parametrize("num_channels", [1, 2]) +def test_gsm_compressor( + fundamental_hz: float, + sample_rate: float, + buffer_size: int, + duration: float, + quality: Resample.Quality, + num_channels: int, +): + signal = ( + generate_sine_at(sample_rate, fundamental_hz, duration, num_channels) * SINE_WAVE_VOLUME + ) + output = GSMFullRateCompressor(quality=quality)(signal, sample_rate, buffer_size=buffer_size) + np.testing.assert_allclose(signal, output, atol=GSM_ABSOLUTE_TOLERANCE) + + +@pytest.mark.parametrize("sample_rate", [8000, 44100]) +@pytest.mark.parametrize( + "quality", + [ + Resample.Quality.ZeroOrderHold, + Resample.Quality.Linear, + Resample.Quality.Lagrange, + Resample.Quality.CatmullRom, + Resample.Quality.WindowedSinc, + ], +) +@pytest.mark.parametrize("num_channels", [1, 2]) +def test_gsm_compressor_invariant_to_buffer_size( + sample_rate: float, + quality: Resample.Quality, + num_channels: int, +): + fundamental_hz = 400.0 + duration = 3.0 + signal = generate_sine_at(sample_rate, fundamental_hz, duration, num_channels) + + compressed = [ + GSMFullRateCompressor(quality=quality)(signal, sample_rate, buffer_size=buffer_size) + for buffer_size in (1, 32, 7000, 8192) + ] + for a, b in zip(compressed, compressed[1:]): + np.testing.assert_allclose(a, b) diff --git a/tests/test_prime_with_silence.py b/tests/test_priming.py similarity index 100% rename from tests/test_prime_with_silence.py rename to tests/test_priming.py diff --git a/vendors/libgsm b/vendors/libgsm new file mode 160000 index 00000000..98f1708f --- /dev/null +++ b/vendors/libgsm @@ -0,0 +1 @@ +Subproject commit 98f1708fb5e06a0dfebd58a3b40d610823db9715