Skip to content

Commit

Permalink
Merge pull request #72 from spotify/psobot/gsm-compressor
Browse files Browse the repository at this point in the history
Add GSMFullRateCompressor plugin.
  • Loading branch information
psobot authored Feb 8, 2022
2 parents b4878a0 + f000972 commit 03eba78
Show file tree
Hide file tree
Showing 10 changed files with 296 additions and 17 deletions.
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
Expand Up @@ -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
23 changes: 9 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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._
7 changes: 5 additions & 2 deletions pedalboard/plugin_templates/PrimeWithSilence.h
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ namespace Pedalboard {
* A dummy plugin that buffers audio data internally, used to test Pedalboard's
* automatic latency compensation.
*/
template <typename T, typename SampleType = float>
template <typename T, typename SampleType = float,
int DefaultSilenceLengthSamples = 0>
class PrimeWithSilence
: public JucePlugin<juce::dsp::DelayLine<
SampleType, juce::dsp::DelayLineInterpolationTypes::None>> {
Expand All @@ -39,6 +40,8 @@ class PrimeWithSilence
JucePlugin<juce::dsp::DelayLine<
SampleType,
juce::dsp::DelayLineInterpolationTypes::None>>::prepare(spec);
this->getDSP().setMaximumDelayInSamples(silenceLengthSamples);
this->getDSP().setDelay(silenceLengthSamples);
plugin.prepare(spec);
}

Expand Down Expand Up @@ -82,7 +85,7 @@ class PrimeWithSilence
private:
T plugin;
int samplesOutput = 0;
int silenceLengthSamples = 0;
int silenceLengthSamples = DefaultSilenceLengthSamples;
};

/**
Expand Down
2 changes: 2 additions & 0 deletions pedalboard/plugins/AddLatency.h
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
*/
#pragma once

#pragma once

#include "../JucePlugin.h"

namespace Pedalboard {
Expand Down
179 changes: 179 additions & 0 deletions pedalboard/plugins/GSMFullRateCompressor.h
Original file line number Diff line number Diff line change
@@ -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 <gsm.h>
}

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<float> &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<Resample<
PrimeWithSilence<
FixedBlockSize<GSMFullRateCompressorInternal,
GSMFullRateCompressorInternal::GSM_FRAME_SIZE_SAMPLES>,
float, GSMFullRateCompressorInternal::GSM_FRAME_SIZE_SAMPLES>,
float, GSMFullRateCompressorInternal::GSM_SAMPLE_RATE>>;

inline void init_gsm_full_rate_compressor(py::module &m) {
py::class_<GSMFullRateCompressor, Plugin>(
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<GSMFullRateCompressor>();
plugin->getNestedPlugin().setQuality(quality);
return plugin;
}),
py::arg("quality") = ResamplingQuality::WindowedSinc)
.def("__repr__",
[](const GSMFullRateCompressor &plugin) {
std::ostringstream ss;
ss << "<pedalboard.GSMFullRateCompressor";
ss << " at " << &plugin;
ss << ">";
return ss.str();
})
.def_property(
"quality",
[](GSMFullRateCompressor &plugin) {
return plugin.getNestedPlugin().getQuality();
},
[](GSMFullRateCompressor &plugin, ResamplingQuality quality) {
return plugin.getNestedPlugin().setQuality(quality);
});
}

}; // namespace Pedalboard
7 changes: 6 additions & 1 deletion pedalboard/python_bindings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down
5 changes: 5 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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

Expand Down
86 changes: 86 additions & 0 deletions tests/test_gsm_compressor.py
Original file line number Diff line number Diff line change
@@ -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)
File renamed without changes.
1 change: 1 addition & 0 deletions vendors/libgsm
Submodule libgsm added at 98f170

0 comments on commit 03eba78

Please sign in to comment.