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

Add GSMFullRateCompressor plugin. #72

Merged
merged 23 commits into from
Feb 8, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
f773947
Add first pass at a GSM compression plugin.
psobot Jan 29, 2022
dd828bf
First test of GSM compressor with built-in resampler.
psobot Jan 30, 2022
d29179c
Working, click-free, properly aligned GSM compression except at 11.02…
psobot Jan 30, 2022
812ed28
Working, click-free, correct GSM compressor at all sample rates.
psobot Jan 30, 2022
fbee204
Re-ran black.
psobot Jan 30, 2022
ed9f839
Added GSM compressor to the README.
psobot Jan 30, 2022
f38aaaa
Clang-format.
psobot Jan 31, 2022
75ed90c
Temp: working resampler, but broken delay
psobot Feb 4, 2022
b4e4db2
TEMP: Working except for slight boundary condition at end
psobot Feb 4, 2022
3463563
Got resampler working at any sample rate.
psobot Feb 5, 2022
844fcdd
Add proper Resampler plugin interface.
psobot Feb 5, 2022
07e6d6f
Add different quality levels.
psobot Feb 5, 2022
3def00e
Move most GSMCompressor buffering logic to utility classes.
psobot Feb 6, 2022
c1b3d18
Fix bugs in FixedBlockSize.
psobot Feb 6, 2022
57cf05a
Fix remaining GSMFullRateCompressor issues.
psobot Feb 6, 2022
55a1e1b
Fix MSVC incompatibility by removing toast command-line.
psobot Feb 6, 2022
5c5e41b
Rename to FixedBlockSize.h.
psobot Feb 6, 2022
8581318
Rename fixed block size test.
psobot Feb 6, 2022
35b963a
Rename to GSMFullRateCompressor in README.
psobot Feb 8, 2022
1e163d6
Merge remote-tracking branch 'origin/master' into psobot/gsm-compressor
psobot Feb 8, 2022
176c3d1
Fixed renamed import.
psobot Feb 8, 2022
9ba99b9
Whoops.
psobot Feb 8, 2022
f000972
Remove redundant resample initialization.
psobot Feb 8, 2022
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
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