diff --git a/README.md b/README.md index 953aef30a..c6b69fbc8 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ ![PyPI - Downloads](https://img.shields.io/pypi/dm/pedalboard) ![GitHub Repo stars](https://img.shields.io/github/stars/spotify/pedalboard?style=social) -`pedalboard` is a Python library for adding effects to audio. It supports a number of common audio effects out of the box, and also allows the use of [VST3®](https://www.steinberg.net/en/company/technologies/vst3.html) and [Audio Unit](https://en.wikipedia.org/wiki/Audio_Units) plugin formats for third-party effects. It was built by [Spotify's Audio Intelligence Lab](https://research.atspotify.com/audio-intelligence/) to enable using studio-quality audio effects from within Python and TensorFlow. +`pedalboard` is a Python library for manipulating audio: adding effects, reading, writing, and more. It supports a number of common audio effects out of the box, and also allows the use of [VST3®](https://www.steinberg.net/en/company/technologies/vst3.html) and [Audio Unit](https://en.wikipedia.org/wiki/Audio_Units) plugin formats for third-party effects. It was built by [Spotify's Audio Intelligence Lab](https://research.atspotify.com/audio-intelligence/) to enable using studio-quality audio effects from within Python and TensorFlow. Internally at Spotify, `pedalboard` is used for [data augmentation](https://en.wikipedia.org/wiki/Data_augmentation) to improve machine learning models. `pedalboard` also helps in the process of content creation, making it possible to add effects to audio without using a Digital Audio Workstation. @@ -27,11 +27,16 @@ Internally at Spotify, `pedalboard` is used for [data augmentation](https://en.w - Quality reduction: `Resample`, `Bitcrush` - Supports VST3® plugins on macOS, Windows, and Linux (`pedalboard.load_plugin`) - Supports Audio Units on macOS + - Built-in audio I/O utilities (`pedalboard.io.AudioFile`) + - Support for reading AIFF, FLAC, MP3, OGG, and WAV files on all platforms with no dependencies + - Support for writing AIFF, FLAC, OGG, and WAV on all platforms with no dependencies + - Additional support for reading AAC, AC3, WMA, and other formats depending on platform - Strong thread-safety, memory usage, and speed guarantees - Releases Python's Global Interpreter Lock (GIL) to allow use of multiple CPU cores - No need to use `multiprocessing`! - Even when only using one thread: - Processes audio up to **300x** faster than [pySoX](https://github.com/rabitt/pysox) for single transforms, and 2-5x faster[1](https://github.com/iCorv/pedalboard_with_tfdata) than [SoxBindings](https://github.com/pseeth/soxbindings) + - Reads audio files up to **4x** faster than [`librosa.load`](https://librosa.org/doc/main/generated/librosa.load.html) (in many cases) - Tested compatibility with TensorFlow - can be used in `tf.data` pipelines! ## Installation @@ -88,30 +93,35 @@ to the next in an undesired fashion, try: ### Quick Start ```python -import soundfile as sf from pedalboard import Pedalboard, Chorus, Reverb +from pedalboard.io import AudioFile -# Read in an audio file: -audio, sample_rate = sf.read('some-file.wav') +# Read in a whole audio file: +with AudioFile('some-file.wav', 'r') as f: + audio = f.read(f.frames) + samplerate = f.samplerate # Make a Pedalboard object, containing multiple plugins: board = Pedalboard([Chorus(), Reverb(room_size=0.25)]) # Run the audio through this pedalboard! -effected = board(audio, sample_rate) +effected = board(audio, samplerate) # Write the audio back as a wav file: -sf.write('./processed-output.wav', effected, sample_rate) +with AudioFile('processed-output.wav', 'w', samplerate) as f: + f.write(effected) ``` ### Making a guitar-style pedalboard ```python -import soundfile as sf # Don't do import *! (It just makes this example smaller) from pedalboard import * +from pedalboard.io import AudioFile -audio, sample_rate = sf.read('./guitar-input.wav') +with AudioFile('guitar-input.wav', 'r') as f: + audio = f.read(f.frames) + samplerate = f.samplerate # Make a pretty interesting sounding guitar pedalboard: board = Pedalboard([ @@ -133,17 +143,18 @@ board.append(Limiter()) board[0].threshold_db = -40 # Run the audio through this pedalboard! -effected = board(audio, sample_rate) +effected = board(audio, samplerate) # Write the audio back as a wav file: -sf.write('./guitar-output.wav', effected, sample_rate) +with AudioFile('processed-output.wav', 'w', samplerate) as f: + f.write(effected) ``` ### Using VST3® or Audio Unit plugins ```python -import soundfile as sf from pedalboard import Pedalboard, Reverb, load_plugin +from pedalboard.io import AudioFile # Load a VST3 or Audio Unit plugin from a known path on disk: vst = load_plugin("./VSTs/RoughRider3.vst3") @@ -160,13 +171,15 @@ print(vst.parameters.keys()) vst.ratio = 15 # Use this VST to process some audio: -audio, sample_rate = sf.read('some-file.wav') -effected = vst(audio, sample_rate) +with AudioFile('some-file.wav', 'r') as f: + audio = f.read(f.frames) + samplerate = f.samplerate +effected = vst(audio, samplerate) # ...or put this VST into a chain with other plugins: board = Pedalboard([vst, Reverb()]) # ...and run that pedalboard with the same VST instance! -effected = board(audio, sample_rate) +effected = board(audio, samplerate) ``` ### Creating parallel effects chains @@ -177,7 +190,6 @@ objects are themselves `Plugin` objects, so you can nest them as much as you like: ```python -import soundfile as sf from pedalboard import Pedalboard, Compressor, Delay, Distortion, Gain, PitchShift, Reverb, Mix passthrough = Gain(gain_db=0) diff --git a/pedalboard/BufferUtils.h b/pedalboard/BufferUtils.h new file mode 100644 index 000000000..d5f2e40b4 --- /dev/null +++ b/pedalboard/BufferUtils.h @@ -0,0 +1,175 @@ +/* + * pedalboard + * 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. + */ + +#pragma once +#include "JuceHeader.h" + +#include +#include + +namespace Pedalboard { +enum class ChannelLayout { + Interleaved, + NotInterleaved, +}; + +template +ChannelLayout +detectChannelLayout(const py::array_t inputArray) { + py::buffer_info inputInfo = inputArray.request(); + + if (inputInfo.ndim == 1) { + return ChannelLayout::NotInterleaved; + } else if (inputInfo.ndim == 2) { + // Try to auto-detect the channel layout from the shape + if (inputInfo.shape[1] < inputInfo.shape[0]) { + return ChannelLayout::Interleaved; + } else if (inputInfo.shape[0] < inputInfo.shape[1]) { + return ChannelLayout::NotInterleaved; + } else { + throw std::runtime_error( + "Unable to determine channel layout from shape!"); + } + } else { + throw std::runtime_error("Number of input dimensions must be 1 or 2 (got " + + std::to_string(inputInfo.ndim) + ")."); + } +} + +template +juce::AudioBuffer +copyPyArrayIntoJuceBuffer(const py::array_t inputArray) { + // Numpy/Librosa convention is (num_samples, num_channels) + py::buffer_info inputInfo = inputArray.request(); + + unsigned int numChannels = 0; + unsigned int numSamples = 0; + ChannelLayout inputChannelLayout = detectChannelLayout(inputArray); + + if (inputInfo.ndim == 1) { + numSamples = inputInfo.shape[0]; + numChannels = 1; + } else if (inputInfo.ndim == 2) { + // Try to auto-detect the channel layout from the shape + if (inputInfo.shape[1] < inputInfo.shape[0]) { + numSamples = inputInfo.shape[0]; + numChannels = inputInfo.shape[1]; + } else if (inputInfo.shape[0] < inputInfo.shape[1]) { + numSamples = inputInfo.shape[1]; + numChannels = inputInfo.shape[0]; + } else { + throw std::runtime_error("Unable to determine shape of audio input!"); + } + } else { + throw std::runtime_error("Number of input dimensions must be 1 or 2 (got " + + std::to_string(inputInfo.ndim) + ")."); + } + + if (numChannels == 0) { + throw std::runtime_error("No channels passed!"); + } else if (numChannels > 2) { + throw std::runtime_error("More than two channels received!"); + } + + juce::AudioBuffer ioBuffer(numChannels, numSamples); + + // Depending on the input channel layout, we need to copy data + // differently. This loop is duplicated here to move the if statement + // outside of the tight loop, as we don't need to re-check that the input + // channel is still the same on every iteration of the loop. + switch (inputChannelLayout) { + case ChannelLayout::Interleaved: + for (unsigned int i = 0; i < numChannels; i++) { + T *channelBuffer = ioBuffer.getWritePointer(i); + // We're de-interleaving the data here, so we can't use copyFrom. + for (unsigned int j = 0; j < numSamples; j++) { + channelBuffer[j] = static_cast(inputInfo.ptr)[j * numChannels + i]; + } + } + break; + case ChannelLayout::NotInterleaved: + for (unsigned int i = 0; i < numChannels; i++) { + ioBuffer.copyFrom( + i, 0, static_cast(inputInfo.ptr) + (numSamples * i), numSamples); + } + break; + default: + throw std::runtime_error("Internal error: got unexpected channel layout."); + } + + return ioBuffer; +} + +template +py::array_t copyJuceBufferIntoPyArray(const juce::AudioBuffer juceBuffer, + ChannelLayout channelLayout, + int offsetSamples, int ndim = 2) { + unsigned int numChannels = juceBuffer.getNumChannels(); + unsigned int numSamples = juceBuffer.getNumSamples(); + unsigned int outputSampleCount = + std::max((int)numSamples - (int)offsetSamples, 0); + + // TODO: Avoid the need to copy here if offsetSamples is 0! + py::array_t outputArray; + if (ndim == 2) { + switch (channelLayout) { + case ChannelLayout::Interleaved: + outputArray = py::array_t({outputSampleCount, numChannels}); + break; + case ChannelLayout::NotInterleaved: + outputArray = py::array_t({numChannels, outputSampleCount}); + break; + default: + throw std::runtime_error( + "Internal error: got unexpected channel layout."); + } + } else { + outputArray = py::array_t(outputSampleCount); + } + + py::buffer_info outputInfo = outputArray.request(); + + // Depending on the input channel layout, we need to copy data + // differently. This loop is duplicated here to move the if statement + // outside of the tight loop, as we don't need to re-check that the input + // channel is still the same on every iteration of the loop. + T *outputBasePointer = static_cast(outputInfo.ptr); + + switch (channelLayout) { + case ChannelLayout::Interleaved: + for (unsigned int i = 0; i < numChannels; i++) { + const T *channelBuffer = juceBuffer.getReadPointer(i, offsetSamples); + // We're interleaving the data here, so we can't use copyFrom. + for (unsigned int j = 0; j < outputSampleCount; j++) { + outputBasePointer[j * numChannels + i] = channelBuffer[j]; + } + } + break; + case ChannelLayout::NotInterleaved: + for (unsigned int i = 0; i < numChannels; i++) { + const T *channelBuffer = juceBuffer.getReadPointer(i, offsetSamples); + std::copy(channelBuffer, channelBuffer + outputSampleCount, + &outputBasePointer[outputSampleCount * i]); + } + break; + default: + throw std::runtime_error("Internal error: got unexpected channel layout."); + } + + return outputArray; +} +} // namespace Pedalboard \ No newline at end of file diff --git a/pedalboard/io/AudioFile.h b/pedalboard/io/AudioFile.h new file mode 100644 index 000000000..4d0591e1f --- /dev/null +++ b/pedalboard/io/AudioFile.h @@ -0,0 +1,26 @@ +/* + * 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. + */ + +#pragma once + +namespace Pedalboard { + +static constexpr const unsigned int DEFAULT_AUDIO_BUFFER_SIZE_FRAMES = 8192; + +class AudioFile {}; + +} // namespace Pedalboard \ No newline at end of file diff --git a/pedalboard/io/AudioFileInit.h b/pedalboard/io/AudioFileInit.h new file mode 100644 index 000000000..ef6333e7c --- /dev/null +++ b/pedalboard/io/AudioFileInit.h @@ -0,0 +1,161 @@ +/* + * 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. + */ + +#pragma once + +#include +#include + +#include +#include + +#include "../JuceHeader.h" +#include "AudioFile.h" + +#include "ReadableAudioFile.h" +#include "WriteableAudioFile.h" + +namespace py = pybind11; + +namespace Pedalboard { + +inline void init_audio_file(py::module &m) { + /** + * Important note: any changes made to the function signatures here should + * also be made to the constructor signatures of ReadableAudioFile and + * WriteableAudioFile to keep a consistent interface! + */ + py::class_>( + m, "AudioFile", "A base class for readable and writeable audio files.") + .def(py::init<>()) // Make this class effectively abstract; we can only + // instantiate subclasses via __new__. + .def_static( + "__new__", + [](const py::object *, std::string filename, std::string mode) { + if (mode == "r") { + return std::make_shared(filename); + } else if (mode == "w") { + throw py::type_error("Opening an audio file for writing requires " + "samplerate and num_channels arguments."); + } else { + throw py::type_error("AudioFile instances can only be opened in " + "read mode (\"r\") or write mode (\"w\")."); + } + }, + py::arg("cls"), py::arg("filename"), py::arg("mode") = "r") + .def_static( + "__new__", + [](const py::object *, py::object filelike, std::string mode) { + if (mode == "r") { + if (!isReadableFileLike(filelike)) { + throw py::type_error( + "Expected either a filename or a file-like object (with " + "read, seek, seekable, and tell methods), but received: " + + filelike.attr("__repr__")().cast()); + } + + return std::make_shared( + std::make_unique(filelike)); + } else if (mode == "w") { + throw py::type_error( + "Opening an audio file-like object for writing requires " + "samplerate and num_channels arguments."); + } else { + throw py::type_error("AudioFile instances can only be opened in " + "read mode (\"r\") or write mode (\"w\")."); + } + }, + py::arg("cls"), py::arg("file_like"), py::arg("mode") = "r") + .def_static( + "__new__", + [](const py::object *, std::string filename, std::string mode, + std::optional sampleRate, int numChannels, int bitDepth, + std::optional> quality) { + if (mode == "r") { + throw py::type_error( + "Opening an audio file for reading does not require " + "samplerate, num_channels, bit_depth, or quality arguments - " + "these parameters " + "will be read from the file."); + } else if (mode == "w") { + if (!sampleRate) { + throw py::type_error( + "Opening an audio file for writing requires a samplerate " + "argument to be provided."); + } + + return std::make_shared( + filename, sampleRate.value(), numChannels, bitDepth, quality); + } else { + throw py::type_error("AudioFile instances can only be opened in " + "read mode (\"r\") or write mode (\"w\")."); + } + }, + py::arg("cls"), py::arg("filename"), py::arg("mode") = "w", + py::arg("samplerate") = py::none(), py::arg("num_channels") = 1, + py::arg("bit_depth") = 16, py::arg("quality") = py::none()) + .def_static( + "__new__", + [](const py::object *, py::object filelike, std::string mode, + std::optional sampleRate, int numChannels, int bitDepth, + std::optional> quality, + std::optional format) { + if (mode == "r") { + throw py::type_error( + "Opening a file-like object for reading does not require " + "samplerate, num_channels, bit_depth, or quality arguments - " + "these parameters " + "will be read from the file-like object."); + } else if (mode == "w") { + if (!sampleRate) { + throw py::type_error("Opening a file-like object for writing " + "requires a samplerate " + "argument to be provided."); + } + + if (!isWriteableFileLike(filelike)) { + throw py::type_error( + "Expected either a filename or a file-like object (with " + "write, seek, seekable, and tell methods), but received: " + + filelike.attr("__repr__")().cast()); + } + + auto stream = std::make_unique(filelike); + if (!format && !stream->getFilename()) { + throw py::type_error( + "Unable to infer audio file format for writing. Expected " + "either a \".name\" property on the provided file-like " + "object (" + + filelike.attr("__repr__")().cast() + + ") or an explicit file format passed as the \"format=\" " + "argument."); + } + + return std::make_shared( + format.value_or(""), std::move(stream), sampleRate.value(), + numChannels, bitDepth, quality); + } else { + throw py::type_error("AudioFile instances can only be opened in " + "read mode (\"r\") or write mode (\"w\")."); + } + }, + py::arg("cls"), py::arg("file_like"), py::arg("mode") = "w", + py::arg("samplerate") = py::none(), py::arg("num_channels") = 1, + py::arg("bit_depth") = 16, py::arg("quality") = py::none(), + py::arg("format") = py::none()); +} +} // namespace Pedalboard \ No newline at end of file diff --git a/pedalboard/io/PythonFileLike.h b/pedalboard/io/PythonFileLike.h new file mode 100644 index 000000000..1689c0c85 --- /dev/null +++ b/pedalboard/io/PythonFileLike.h @@ -0,0 +1,132 @@ +/* + * pedalboard + * 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. + */ + +#pragma once + +#include +#include + +namespace py = pybind11; + +#include "../JuceHeader.h" + +namespace Pedalboard { + +namespace PythonException { +// Check if there's a Python exception pending in the interpreter. +inline bool isPending() { + py::gil_scoped_acquire acquire; + return PyErr_Occurred() != nullptr; +} + +// If an exception is pending, raise it as a C++ exception to break the current +// control flow and result in an error being thrown in Python later. +inline void raise() { + py::gil_scoped_acquire acquire; + + if (PyErr_Occurred()) { + py::error_already_set existingError; + throw existingError; + } +} +}; // namespace PythonException + +/** + * A base class for file-like Python object wrappers. + */ +class PythonFileLike { +public: + PythonFileLike(py::object fileLike) : fileLike(fileLike) {} + + std::string getRepresentation() { + py::gil_scoped_acquire acquire; + if (PythonException::isPending()) + return "<__repr__ failed>"; + return py::repr(fileLike).cast(); + } + + std::optional getFilename() noexcept { + // Some Python file-like objects expose a ".name" property. + // If this object has that property, return its value; + // otherwise return an empty optional. + py::gil_scoped_acquire acquire; + + if (!PythonException::isPending() && py::hasattr(fileLike, "name")) { + return py::str(fileLike.attr("name")).cast(); + } else { + return {}; + } + } + + bool isSeekable() noexcept { + py::gil_scoped_acquire acquire; + + if (!PythonException::isPending()) { + try { + return fileLike.attr("seekable")().cast(); + } catch (py::error_already_set e) { + e.restore(); + } catch (const py::builtin_exception &e) { + e.set_error(); + } + } + + return false; + } + + juce::int64 getPosition() noexcept { + py::gil_scoped_acquire acquire; + + if (!PythonException::isPending()) { + try { + return fileLike.attr("tell")().cast(); + } catch (py::error_already_set e) { + e.restore(); + } catch (const py::builtin_exception &e) { + e.set_error(); + } + } + + return -1; + } + + bool setPosition(juce::int64 pos) noexcept { + py::gil_scoped_acquire acquire; + + if (!PythonException::isPending()) { + try { + if (fileLike.attr("seekable")().cast()) { + fileLike.attr("seek")(pos); + } + + return fileLike.attr("tell")().cast() == pos; + } catch (py::error_already_set e) { + e.restore(); + } catch (const py::builtin_exception &e) { + e.set_error(); + } + } + + return false; + } + + py::object getFileLikeObject() { return fileLike; } + +protected: + py::object fileLike; +}; +}; // namespace Pedalboard \ No newline at end of file diff --git a/pedalboard/io/PythonInputStream.h b/pedalboard/io/PythonInputStream.h new file mode 100644 index 000000000..1e75c7e07 --- /dev/null +++ b/pedalboard/io/PythonInputStream.h @@ -0,0 +1,221 @@ +/* + * pedalboard + * 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. + */ + +#pragma once + +#include +#include + +namespace py = pybind11; + +#include "../JuceHeader.h" +#include "PythonFileLike.h" + +namespace Pedalboard { + +bool isReadableFileLike(py::object fileLike) { + return py::hasattr(fileLike, "read") && py::hasattr(fileLike, "seek") && + py::hasattr(fileLike, "tell") && py::hasattr(fileLike, "seekable"); +} + +/** + * A juce::InputStream subclass that fetches its + * data from a provided Python file-like object. + */ +class PythonInputStream : public juce::InputStream, public PythonFileLike { +public: + PythonInputStream(py::object fileLike) : PythonFileLike(fileLike) { + if (!isReadableFileLike(fileLike)) { + throw py::type_error("Expected a file-like object (with read, seek, " + "seekable, and tell methods)."); + } + } + + bool isSeekable() noexcept { + py::gil_scoped_acquire acquire; + + if (PythonException::isPending()) + return false; + + try { + return fileLike.attr("seekable")().cast(); + } catch (py::error_already_set e) { + e.restore(); + return false; + } catch (const py::builtin_exception &e) { + e.set_error(); + return false; + } + } + + juce::int64 getTotalLength() noexcept { + py::gil_scoped_acquire acquire; + + if (PythonException::isPending()) + return -1; + + // TODO: Try reading a couple of Python properties that may contain the + // total length: urllib3.response.HTTPResponse provides `length_remaining`, + // for instance + + try { + if (!fileLike.attr("seekable")().cast()) { + return -1; + } + + if (totalLength == -1) { + juce::int64 pos = fileLike.attr("tell")().cast(); + fileLike.attr("seek")(0, 2); + totalLength = fileLike.attr("tell")().cast(); + fileLike.attr("seek")(pos, 0); + } + } catch (py::error_already_set e) { + e.restore(); + return -1; + } catch (const py::builtin_exception &e) { + e.set_error(); + return -1; + } + + return totalLength; + } + + int read(void *buffer, int bytesToRead) noexcept { + // The buffer should never be null, and a negative size is probably a + // sign that something is broken! + jassert(buffer != nullptr && bytesToRead >= 0); + + if (PythonException::isPending()) + return 0; + + py::gil_scoped_acquire acquire; + try { + auto readResult = fileLike.attr("read")(bytesToRead); + + if (!py::isinstance(readResult)) { + std::string message = + "File-like object passed to AudioFile was expected to return " + "bytes from its read(...) method, but " + "returned " + + py::str(readResult.get_type().attr("__name__")) + .cast() + + "."; + + if (py::hasattr(fileLike, "mode") && + py::str(fileLike.attr("mode")).cast() == "r") { + message += " (Try opening the stream in \"rb\" mode instead of " + "\"r\" mode if possible.)"; + } + + throw py::type_error(message); + return 0; + } + + py::bytes bytesObject = readResult.cast(); + char *pythonBuffer = nullptr; + py::ssize_t pythonLength = 0; + + if (PYBIND11_BYTES_AS_STRING_AND_SIZE(bytesObject.ptr(), &pythonBuffer, + &pythonLength)) { + throw py::buffer_error( + "Internal error: failed to read bytes from bytes object!"); + } + + if (!buffer && pythonLength > 0) { + throw py::buffer_error("Internal error: bytes pointer is null, but a " + "non-zero number of bytes were returned!"); + } + + if (buffer && pythonLength) { + std::memcpy(buffer, pythonBuffer, pythonLength); + } + + lastReadWasSmallerThanExpected = bytesToRead > pythonLength; + return pythonLength; + } catch (py::error_already_set e) { + e.restore(); + return 0; + } catch (const py::builtin_exception &e) { + e.set_error(); + return 0; + } + } + + bool isExhausted() noexcept { + py::gil_scoped_acquire acquire; + + if (PythonException::isPending()) + return true; + + if (lastReadWasSmallerThanExpected) { + return true; + } + + try { + return fileLike.attr("tell")().cast() == getTotalLength(); + } catch (py::error_already_set e) { + e.restore(); + return true; + } catch (const py::builtin_exception &e) { + e.set_error(); + return true; + } + } + + juce::int64 getPosition() noexcept { + py::gil_scoped_acquire acquire; + + if (PythonException::isPending()) + return -1; + + try { + return fileLike.attr("tell")().cast(); + } catch (py::error_already_set e) { + e.restore(); + return -1; + } catch (const py::builtin_exception &e) { + e.set_error(); + return -1; + } + } + + bool setPosition(juce::int64 pos) noexcept { + py::gil_scoped_acquire acquire; + + if (PythonException::isPending()) + return false; + + try { + if (fileLike.attr("seekable")().cast()) { + fileLike.attr("seek")(pos); + } + + return fileLike.attr("tell")().cast() == pos; + } catch (py::error_already_set e) { + e.restore(); + return false; + } catch (const py::builtin_exception &e) { + e.set_error(); + return false; + } + } + +private: + juce::int64 totalLength = -1; + bool lastReadWasSmallerThanExpected = false; +}; +}; // namespace Pedalboard \ No newline at end of file diff --git a/pedalboard/io/PythonOutputStream.h b/pedalboard/io/PythonOutputStream.h new file mode 100644 index 000000000..035f8ff65 --- /dev/null +++ b/pedalboard/io/PythonOutputStream.h @@ -0,0 +1,124 @@ +/* + * pedalboard + * 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. + */ + +#pragma once + +#include +#include + +namespace py = pybind11; + +#include "../JuceHeader.h" +#include "PythonFileLike.h" + +namespace Pedalboard { + +bool isWriteableFileLike(py::object fileLike) { + return py::hasattr(fileLike, "write") && py::hasattr(fileLike, "seek") && + py::hasattr(fileLike, "tell") && py::hasattr(fileLike, "seekable"); +} + +/** + * A juce::OutputStream subclass that writes its + * data to a provided Python file-like object. + */ +class PythonOutputStream : public juce::OutputStream, public PythonFileLike { +public: + PythonOutputStream(py::object fileLike) : PythonFileLike(fileLike) { + if (!isWriteableFileLike(fileLike)) { + throw py::type_error("Expected a file-like object (with write, seek, " + "seekable, and tell methods)."); + } + } + + virtual void flush() noexcept override { + py::gil_scoped_acquire acquire; + + try { + if (py::hasattr(fileLike, "flush")) { + fileLike.attr("flush")(); + } + } catch (py::error_already_set e) { + e.restore(); + return; + } catch (const py::builtin_exception &e) { + e.set_error(); + return; + } + } + + virtual juce::int64 getPosition() noexcept override { + return PythonFileLike::getPosition(); + } + + virtual bool setPosition(juce::int64 pos) noexcept override { + return PythonFileLike::setPosition(pos); + } + + virtual bool write(const void *ptr, size_t numBytes) noexcept override { + py::gil_scoped_acquire acquire; + + try { + int bytesWritten = + fileLike.attr("write")(py::bytes((const char *)ptr, numBytes)) + .cast(); + + if (bytesWritten < numBytes) { + return false; + } + } catch (py::error_already_set e) { + e.restore(); + return false; + } catch (const py::builtin_exception &e) { + e.set_error(); + return false; + } + return true; + } + + virtual bool writeRepeatedByte(juce::uint8 byte, + size_t numTimesToRepeat) noexcept override { + py::gil_scoped_acquire acquire; + + try { + const size_t maxEffectiveSize = std::min(numTimesToRepeat, (size_t)8192); + std::vector buffer(maxEffectiveSize, byte); + + for (size_t i = 0; i < numTimesToRepeat; i += buffer.size()) { + const size_t chunkSize = std::min(numTimesToRepeat - i, buffer.size()); + + int bytesWritten = fileLike + .attr("write")(py::bytes( + (const char *)buffer.data(), chunkSize)) + .cast(); + + if (bytesWritten != chunkSize) { + return false; + } + } + } catch (py::error_already_set e) { + e.restore(); + return false; + } catch (const py::builtin_exception &e) { + e.set_error(); + return false; + } + + return true; + } +}; +}; // namespace Pedalboard \ No newline at end of file diff --git a/pedalboard/io/ReadableAudioFile.h b/pedalboard/io/ReadableAudioFile.h new file mode 100644 index 000000000..fe9fe53f9 --- /dev/null +++ b/pedalboard/io/ReadableAudioFile.h @@ -0,0 +1,616 @@ +/* + * 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. + */ + +#pragma once + +#include +#include + +#include +#include + +#include "../BufferUtils.h" +#include "../JuceHeader.h" +#include "AudioFile.h" +#include "PythonInputStream.h" + +namespace py = pybind11; + +namespace Pedalboard { + +class ReadableAudioFile + : public AudioFile, + public std::enable_shared_from_this { +public: + ReadableAudioFile(std::string filename) : filename(filename) { + formatManager.registerBasicFormats(); + juce::File file(filename); + + if (!file.existsAsFile()) { + throw std::domain_error( + "Failed to open audio file: file does not exist: " + filename); + } + + // createReaderFor(juce::File) is fast, as it only looks at file extension: + reader.reset(formatManager.createReaderFor(file)); + if (!reader) { + // This is slower but more thorough: + reader.reset(formatManager.createReaderFor(file.createInputStream())); + + // Known bug: the juce::MP3Reader class will parse formats that are not + // MP3 and pretend like they are, producing garbage output. For now, if we + // parse MP3 from an input stream that's not explicitly got ".mp3" on the + // end, ignore it. + if (reader && reader->getFormatName() == "MP3 file") { + throw std::domain_error( + "Failed to open audio file: file \"" + filename + + "\" does not seem to be of a known or supported format. (If trying " + "to open an MP3 file, ensure the filename ends with '.mp3'.)"); + } + } + + if (!reader) + throw std::domain_error( + "Failed to open audio file: file \"" + filename + + "\" does not seem to be of a known or supported format."); + } + + ReadableAudioFile(std::unique_ptr inputStream) { + formatManager.registerBasicFormats(); + + if (!inputStream->isSeekable()) { + PythonException::raise(); + throw std::domain_error("Failed to open audio file-like object: input " + "stream must be seekable."); + } + + if (!reader) { + auto originalStreamPosition = inputStream->getPosition(); + + for (int i = 0; i < formatManager.getNumKnownFormats(); i++) { + auto *af = formatManager.getKnownFormat(i); + + if (auto *r = af->createReaderFor(inputStream.get(), false)) { + inputStream.release(); + reader.reset(r); + break; + } + + // createReaderFor may have thrown a Python exception, under the hood + // which we need to check for before blindly continuing: + PythonException::raise(); + + inputStream->setPosition(originalStreamPosition); + if (inputStream->getPosition() != originalStreamPosition) { + throw std::runtime_error( + "Input file-like object did not seek to the expected position. " + "The provided file-like object must be fully seekable to allow " + "reading audio files."); + } + } + + // Known bug: the juce::MP3Reader class will parse formats that are not + // MP3 and pretend like they are, producing garbage output. For now, if we + // parse MP3 from an input stream that's not explicitly got ".mp3" on the + // end, ignore it. + if (reader && reader->getFormatName() == "MP3 file") { + bool fileLooksLikeAnMP3 = false; + if (auto filename = getPythonInputStream()->getFilename()) { + fileLooksLikeAnMP3 = + juce::File(filename.value()).hasFileExtension("mp3"); + } + + if (!fileLooksLikeAnMP3) { + PythonException::raise(); + throw std::domain_error( + "Failed to open audio file-like object: stream does not seem to " + "contain a known or supported format. (If trying to open an MP3 " + "file, pass a file-like with a \"name\" attribute ending with " + "\".mp3\".)"); + } + } + } + + PythonException::raise(); + + if (!reader) + throw std::domain_error( + "Failed to open audio file-like object: " + + inputStream->getRepresentation() + + " does not seem to contain a known or supported format."); + + PythonException::raise(); + } + + double getSampleRate() const { + if (!reader) + throw std::runtime_error("I/O operation on a closed file."); + return reader->sampleRate; + } + + long getLengthInSamples() const { + if (!reader) + throw std::runtime_error("I/O operation on a closed file."); + return reader->lengthInSamples; + } + + double getDuration() const { + if (!reader) + throw std::runtime_error("I/O operation on a closed file."); + return reader->lengthInSamples / reader->sampleRate; + } + + long getNumChannels() const { + if (!reader) + throw std::runtime_error("I/O operation on a closed file."); + return reader->numChannels; + } + + std::string getFileFormat() const { + if (!reader) + throw std::runtime_error("I/O operation on a closed file."); + + return reader->getFormatName().toStdString(); + } + + std::string getFileDatatype() const { + if (!reader) + throw std::runtime_error("I/O operation on a closed file."); + + if (reader->usesFloatingPointData) { + switch (reader->bitsPerSample) { + case 16: // OGG returns 16-bit int data, but internally stores floats + case 32: + return "float32"; + case 64: + return "float64"; + default: + return "unknown"; + } + } else { + switch (reader->bitsPerSample) { + case 8: + return "int8"; + case 16: + return "int16"; + case 24: + return "int24"; + case 32: + return "int32"; + case 64: + return "int64"; + default: + return "unknown"; + } + } + } + + py::array_t read(long long numSamples) { + if (numSamples == 0) + throw std::domain_error( + "ReadableAudioFile will not read an entire file at once, due to the " + "possibility that a file may be larger than available memory. Please " + "pass a number of frames to read (available from the 'frames' " + "attribute)."); + + const juce::ScopedLock scopedLock(objectLock); + if (!reader) + throw std::runtime_error("I/O operation on a closed file."); + + // Allocate a buffer to return of up to numSamples: + int numChannels = reader->numChannels; + numSamples = + std::min(numSamples, reader->lengthInSamples - currentPosition); + py::array_t buffer = + py::array_t({(int)numChannels, (int)numSamples}); + + py::buffer_info outputInfo = buffer.request(); + + { + py::gil_scoped_release release; + + // If the file being read does not have enough content, it _should_ pad + // the rest of the array with zeroes. Unfortunately, this does not seem to + // be true in practice, so we pre-zero the array to be returned here: + std::memset((void *)outputInfo.ptr, 0, + numChannels * numSamples * sizeof(float)); + + float **channelPointers = (float **)alloca(numChannels * sizeof(float *)); + for (int c = 0; c < numChannels; c++) { + channelPointers[c] = ((float *)outputInfo.ptr) + (numSamples * c); + } + + if (reader->usesFloatingPointData || reader->bitsPerSample == 32) { + auto readResult = reader->read(channelPointers, numChannels, + currentPosition, numSamples); + PythonException::raise(); + + if (!readResult) { + throw std::runtime_error("Failed to read from file."); + } + } else { + // If the audio is stored in an integral format, read it as integers + // and do the floating-point conversion ourselves to work around + // floating-point imprecision in JUCE when reading formats smaller than + // 32-bit (i.e.: 16-bit audio is off by about 0.003%) + auto readResult = + reader->readSamples((int **)channelPointers, numChannels, 0, + currentPosition, numSamples); + PythonException::raise(); + if (!readResult) { + throw std::runtime_error("Failed to read from file."); + } + + // When converting 24-bit, 16-bit, or 8-bit data from int to float, + // the values provided by the above read() call are shifted left + // (such that the least significant bits are all zero) + // JUCE will then divide these values by 0x7FFFFFFF, even though + // the least significant bits are zero, effectively losing precision. + // Instead, here we set the scale factor appropriately. + int maxValueAsInt; + switch (reader->bitsPerSample) { + case 24: + maxValueAsInt = 0x7FFFFF00; + break; + case 16: + maxValueAsInt = 0x7FFF0000; + break; + case 8: + maxValueAsInt = 0x7F000000; + break; + default: + throw std::runtime_error("Not sure how to convert data from " + + std::to_string(reader->bitsPerSample) + + " bits per sample to floating point!"); + } + float scaleFactor = 1.0f / static_cast(maxValueAsInt); + + for (int c = 0; c < numChannels; c++) { + juce::FloatVectorOperations::convertFixedToFloat( + channelPointers[c], (const int *)channelPointers[c], scaleFactor, + numSamples); + } + } + } + + currentPosition += numSamples; + return buffer; + } + + py::handle readRaw(long long numSamples) { + if (numSamples == 0) + throw std::domain_error( + "ReadableAudioFile will not read an entire file at once, due to the " + "possibility that a file may be larger than available memory. Please " + "pass a number of frames to read (available from the 'frames' " + "attribute)."); + + const juce::ScopedLock scopedLock(objectLock); + if (!reader) + throw std::runtime_error("I/O operation on a closed file."); + + if (reader->usesFloatingPointData) { + return read(numSamples).release(); + } else { + switch (reader->bitsPerSample) { + case 32: + return readInteger(numSamples).release(); + case 16: + return readInteger(numSamples).release(); + case 8: + return readInteger(numSamples).release(); + default: + throw std::runtime_error("Not sure how to read " + + std::to_string(reader->bitsPerSample) + + "-bit audio data!"); + } + } + } + + template + py::array_t readInteger(long long numSamples) { + if (reader->usesFloatingPointData) { + throw std::runtime_error( + "Can't call readInteger with a floating point file!"); + } + + // Allocate a buffer to return of up to numSamples: + int numChannels = reader->numChannels; + numSamples = + std::min(numSamples, reader->lengthInSamples - currentPosition); + py::array_t buffer = + py::array_t({(int)numChannels, (int)numSamples}); + + py::buffer_info outputInfo = buffer.request(); + + { + py::gil_scoped_release release; + if (reader->bitsPerSample > 16) { + if (sizeof(SampleType) < 4) { + throw std::runtime_error("Output array not wide enough to store " + + std::to_string(reader->bitsPerSample) + + "-bit integer data."); + } + + std::memset((void *)outputInfo.ptr, 0, + numChannels * numSamples * sizeof(SampleType)); + + int **channelPointers = (int **)alloca(numChannels * sizeof(int *)); + for (int c = 0; c < numChannels; c++) { + channelPointers[c] = ((int *)outputInfo.ptr) + (numSamples * c); + } + + auto readResult = reader->readSamples(channelPointers, numChannels, 0, + currentPosition, numSamples); + PythonException::raise(); + if (!readResult) { + throw std::runtime_error("Failed to read from file."); + } + } else { + // Read the file in smaller chunks, converting from int32 to the + // appropriate output format as we go: + std::vector> intBuffers; + intBuffers.resize(numChannels); + + int **channelPointers = (int **)alloca(numChannels * sizeof(int *)); + for (long long startSample = 0; startSample < numSamples; + startSample += DEFAULT_AUDIO_BUFFER_SIZE_FRAMES) { + int samplesToRead = + std::min(numSamples - startSample, + (long long)DEFAULT_AUDIO_BUFFER_SIZE_FRAMES); + + for (int c = 0; c < numChannels; c++) { + intBuffers[c].resize(samplesToRead); + channelPointers[c] = intBuffers[c].data(); + } + + auto readResult = + reader->readSamples(channelPointers, numChannels, 0, + currentPosition + startSample, samplesToRead); + + PythonException::raise(); + + if (!readResult) { + throw std::runtime_error("Failed to read from file."); + } + + // Convert the data in intBuffers to the output format: + char shift = 32 - reader->bitsPerSample; + for (int c = 0; c < numChannels; c++) { + SampleType *outputChannelPointer = + (((SampleType *)outputInfo.ptr) + (c * numSamples)); + for (int i = 0; i < samplesToRead; i++) { + outputChannelPointer[startSample + i] = intBuffers[c][i] >> shift; + } + } + } + } + } + + currentPosition += numSamples; + return buffer; + } + + void seek(long long targetPosition) { + const juce::ScopedLock scopedLock(objectLock); + if (!reader) + throw std::runtime_error("I/O operation on a closed file."); + if (targetPosition > reader->lengthInSamples) + throw std::domain_error("Cannot seek beyond end of file (" + + std::to_string(reader->lengthInSamples) + + " frames)."); + if (targetPosition < 0) + throw std::domain_error("Cannot seek before start of file."); + currentPosition = targetPosition; + } + + long long tell() const { + const juce::ScopedLock scopedLock(objectLock); + if (!reader) + throw std::runtime_error("I/O operation on a closed file."); + return currentPosition; + } + + void close() { + const juce::ScopedLock scopedLock(objectLock); + reader.reset(); + } + + bool isClosed() const { + const juce::ScopedLock scopedLock(objectLock); + return !reader; + } + + bool isSeekable() const { + const juce::ScopedLock scopedLock(objectLock); + + // At the moment, ReadableAudioFile instances are always seekable, as + // they're backed by files. + return !isClosed(); + } + + std::string getFilename() const { return filename; } + + PythonInputStream *getPythonInputStream() const { + if (!filename.empty()) { + return nullptr; + } + if (!reader) { + return nullptr; + } + + // the AudioFormatReader retains exclusive ownership over the input stream, + // so we have to cast here instead of holding a shared_ptr: + return (PythonInputStream *)reader->input; + } + + std::shared_ptr enter() { return shared_from_this(); } + + void exit(const py::object &type, const py::object &value, + const py::object &traceback) { + close(); + } + +private: + juce::AudioFormatManager formatManager; + std::string filename; + std::unique_ptr reader; + juce::CriticalSection objectLock; + + int currentPosition = 0; +}; + +inline void init_readable_audio_file(py::module &m) { + py::class_>( + m, "ReadableAudioFile", + "An audio file reader interface, with native support for Ogg Vorbis, " + "MP3, WAV, FLAC, and AIFF files on all operating systems. On some " + "platforms, other formats may also be readable. (Use " + "pedalboard.io.get_supported_read_formats() to see which formats are " + "supported on the current platform.)") + .def(py::init([](std::string filename) -> ReadableAudioFile * { + // This definition is only here to provide nice docstrings. + throw std::runtime_error( + "Internal error: __init__ should never be called, as this " + "class implements __new__."); + }), + py::arg("filename")) + .def(py::init([](py::object filelike) -> ReadableAudioFile * { + // This definition is only here to provide nice docstrings. + throw std::runtime_error( + "Internal error: __init__ should never be called, as this " + "class implements __new__."); + }), + py::arg("file_like")) + .def_static( + "__new__", + [](const py::object *, std::string filename) { + return std::make_shared(filename); + }, + py::arg("cls"), py::arg("filename")) + .def_static( + "__new__", + [](const py::object *, py::object filelike) { + if (!isReadableFileLike(filelike)) { + throw py::type_error( + "Expected either a filename or a file-like object (with " + "read, seek, seekable, and tell methods), but received: " + + py::repr(filelike).cast()); + } + + return std::make_shared( + std::make_unique(filelike)); + }, + py::arg("cls"), py::arg("file_like")) + .def( + "read", &ReadableAudioFile::read, py::arg("num_frames") = 0, + "Read the given number of frames (samples in each channel) from this " + "audio file at the current position. Audio samples are returned in " + "the shape (channels, samples); i.e.: a stereo audio file will have " + "shape (2, ). Returned data is always in float32 format.") + .def( + "read_raw", &ReadableAudioFile::readRaw, py::arg("num_frames") = 0, + "Read the given number of frames (samples in each channel) from this " + "audio file at the current position. Audio samples are returned in " + "the shape (channels, samples); i.e.: a stereo audio file will have " + "shape (2, ). Returned data is in the raw format stored by " + "the underlying file (one of int8, int16, int32, or float32).") + .def("seekable", &ReadableAudioFile::isSeekable, + "Returns True if this file is currently open and calls to seek() " + "will work.") + .def("seek", &ReadableAudioFile::seek, py::arg("position"), + "Seek this file to the provided location in frames.") + .def("tell", &ReadableAudioFile::tell, + "Fetch the position in this audio file, in frames.") + .def("close", &ReadableAudioFile::close, + "Close this file, rendering this object unusable.") + .def("__enter__", &ReadableAudioFile::enter) + .def("__exit__", &ReadableAudioFile::exit) + .def("__repr__", + [](const ReadableAudioFile &file) { + std::ostringstream ss; + ss << ""; + return ss.str(); + }) + .def_property_readonly("name", &ReadableAudioFile::getFilename, + "The name of this file.") + .def_property_readonly( + "closed", &ReadableAudioFile::isClosed, + "If this file has been closed, this property will be True.") + .def_property_readonly("samplerate", &ReadableAudioFile::getSampleRate, + "The sample rate of this file in samples " + "(per channel) per second (Hz).") + .def_property_readonly("num_channels", &ReadableAudioFile::getNumChannels, + "The number of channels in this file.") + .def_property_readonly("frames", &ReadableAudioFile::getLengthInSamples, + "The total number of frames (samples per " + "channel) in this file.") + .def_property_readonly( + "duration", &ReadableAudioFile::getDuration, + "The duration of this file (frames divided by sample rate).") + .def_property_readonly( + "file_dtype", &ReadableAudioFile::getFileDatatype, + "The data type stored natively by this file. Note that read(...) " + "will always return a float32 array, regardless of the value of this " + "property."); + + m.def("get_supported_read_formats", []() { + juce::AudioFormatManager manager; + manager.registerBasicFormats(); + + std::vector formatNames(manager.getNumKnownFormats()); + juce::StringArray extensions; + for (int i = 0; i < manager.getNumKnownFormats(); i++) { + auto *format = manager.getKnownFormat(i); + extensions.addArray(format->getFileExtensions()); + } + + extensions.trim(); + extensions.removeEmptyStrings(); + extensions.removeDuplicates(true); + + std::vector output; + for (juce::String s : extensions) { + output.push_back(s.toStdString()); + } + + std::sort( + output.begin(), output.end(), + [](const std::string lhs, const std::string rhs) { return lhs < rhs; }); + + return output; + }); +} +} // namespace Pedalboard \ No newline at end of file diff --git a/pedalboard/io/WriteableAudioFile.h b/pedalboard/io/WriteableAudioFile.h new file mode 100644 index 000000000..0b58a7283 --- /dev/null +++ b/pedalboard/io/WriteableAudioFile.h @@ -0,0 +1,920 @@ +/* + * 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. + */ + +#pragma once + +#include +#include + +#include +#include + +#include "../BufferUtils.h" +#include "../JuceHeader.h" +#include "AudioFile.h" +#include "PythonOutputStream.h" + +namespace py = pybind11; + +namespace Pedalboard { + +bool isInteger(double value) { + double intpart; + return modf(value, &intpart) == 0.0; +} + +int determineQualityOptionIndex(juce::AudioFormat *format, + const std::string inputString) { + // Detect the quality level to use based on the string passed in: + juce::StringArray possibleQualityOptions = format->getQualityOptions(); + int qualityOptionIndex = -1; + + std::string qualityString = juce::String(inputString).trim().toStdString(); + + if (!qualityString.empty()) { + if (!possibleQualityOptions.size()) { + throw std::domain_error("Unable to parse provided quality value (" + + qualityString + "). " + + format->getFormatName().toStdString() + + "s do not accept quality settings."); + } + + // Try to match the string against the available options. An exact match + // is preferred (ignoring case): + if (qualityOptionIndex == -1 && + possibleQualityOptions.contains(qualityString, true)) { + qualityOptionIndex = possibleQualityOptions.indexOf(qualityString, true); + } + + // And if no exact match was found, try casting to an integer: + if (qualityOptionIndex == -1) { + int numLeadingDigits = 0; + for (int i = 0; i < qualityString.size(); i++) { + if (juce::CharacterFunctions::isDigit(qualityString[i])) { + numLeadingDigits++; + } + } + + if (numLeadingDigits) { + std::string leadingIntValue = qualityString.substr(0, numLeadingDigits); + + // Check to see if any of the valid options start with this option, + // but make sure we don't select only the prefix of a number + // (i.e.: if someone gives us "32", don't select "320 kbps") + for (int i = 0; i < possibleQualityOptions.size(); i++) { + const juce::String &option = possibleQualityOptions[i]; + if (option.startsWith(leadingIntValue) && + option.length() > leadingIntValue.size() && + !juce::CharacterFunctions::isDigit( + option[leadingIntValue.size()])) { + qualityOptionIndex = i; + break; + } + } + } else { + // If our search string doesn't start with leading digits, + // check for a substring: + for (int i = 0; i < possibleQualityOptions.size(); i++) { + if (possibleQualityOptions[i].containsIgnoreCase(qualityString)) { + qualityOptionIndex = i; + break; + } + } + } + } + + // If we get here, we received a string we were unable to parse, + // so the user should probably know about it: + if (qualityOptionIndex == -1) { + throw std::domain_error( + "Unable to parse provided quality value (" + qualityString + + "). Valid values for " + format->getFormatName().toStdString() + + "s are: " + + possibleQualityOptions.joinIntoString(", ").toStdString()); + } + } + + if (qualityOptionIndex == -1) { + if (possibleQualityOptions.size()) { + // Choose the best quality by default if possible: + qualityOptionIndex = possibleQualityOptions.size() - 1; + } else { + qualityOptionIndex = 0; + } + } + + return qualityOptionIndex; +} + +/** + * A tiny RAII wrapper around juce::FileOutputStream that + * deletes the file on destruction if it was not written to. + */ +class AutoDeleteFileOutputStream : public juce::FileOutputStream { +public: + AutoDeleteFileOutputStream(const juce::File &fileToWriteTo, + size_t bufferSizeToUse = 16384, + bool deleteFileOnDestruction = true) + : juce::FileOutputStream(fileToWriteTo, bufferSizeToUse), + deleteFileOnDestruction(deleteFileOnDestruction) {} + + static std::unique_ptr + createOutputStream(const juce::File &fileToWriteTo, + size_t bufferSizeToUse = 16384) { + return std::make_unique( + fileToWriteTo, bufferSizeToUse, !fileToWriteTo.existsAsFile()); + }; + + juce::Result truncate() { + deleteFileOnDestruction = false; + return juce::FileOutputStream::truncate(); + } + + virtual bool write(const void *bytes, size_t len) override { + if (!hasWrittenToFile) { + setPosition(0); + truncate(); + hasWrittenToFile = true; + } + + deleteFileOnDestruction = false; + return juce::FileOutputStream::write(bytes, len); + } + + virtual bool writeRepeatedByte(juce::uint8 byte, + size_t numTimesToRepeat) override { + if (!hasWrittenToFile) { + setPosition(0); + truncate(); + hasWrittenToFile = true; + } + + deleteFileOnDestruction = false; + return juce::FileOutputStream::writeRepeatedByte(byte, numTimesToRepeat); + } + + ~AutoDeleteFileOutputStream() override { + if (deleteFileOnDestruction) { + getFile().deleteFile(); + } + } + +private: + bool deleteFileOnDestruction = false; + bool hasWrittenToFile = false; +}; + +class WriteableAudioFile + : public AudioFile, + public std::enable_shared_from_this { +public: + WriteableAudioFile( + std::string filename, double writeSampleRate, int numChannels = 1, + int bitDepth = 16, + std::optional> qualityInput = {}) + : WriteableAudioFile(filename, nullptr, writeSampleRate, numChannels, + bitDepth, qualityInput) {} + + WriteableAudioFile( + std::string filename, + std::unique_ptr pythonOutputStream, + double writeSampleRate, int numChannels = 1, int bitDepth = 16, + std::optional> qualityInput = {}) { + pybind11::gil_scoped_release release; + + if (!isInteger(writeSampleRate)) { + throw std::domain_error( + "Opening an audio file for writing requires an integer sample rate."); + } + + if (writeSampleRate == 0) { + throw std::domain_error( + "Opening an audio file for writing requires a non-zero sample rate."); + } + + if (numChannels == 0) { + throw py::type_error("Opening an audio file for writing requires a " + "non-zero num_channels."); + } + + formatManager.registerBasicFormats(); + std::unique_ptr outputStream; + juce::AudioFormat *format = nullptr; + std::string extension; + + if (pythonOutputStream) { + // Use the pythonOutputStream's filename if possible, falling back to the + // provided filename string (which should contain an extension) if + // necessary. + if (!filename.empty()) { + extension = filename; + } else if (auto streamName = pythonOutputStream->getFilename()) { + // Dummy-stream-filename added here to avoid a JUCE assertion + // if the stream name doesn't start with a slash. + juce::File file( + juce::String(juce::File::getSeparatorString()).toStdString() + + "dummy-stream-filename-" + streamName.value()); + extension = file.getFileExtension().toStdString(); + } + + format = formatManager.findFormatForFileExtension(extension); + + if (!format) { + if (pythonOutputStream->getFilename()) { + throw std::domain_error("Unable to detect audio format to use for " + "file-like object with filename: " + + pythonOutputStream->getFilename().value()); + } else { + throw std::domain_error( + "Provided format argument (\"" + filename + + "\") does not correspond to a supported file type."); + } + } + + unsafeOutputStream = pythonOutputStream.get(); + outputStream = std::move(pythonOutputStream); + } else { + juce::File file(filename); + extension = file.getFileExtension().toStdString(); + + outputStream = AutoDeleteFileOutputStream::createOutputStream(file); + if (!static_cast(outputStream.get()) + ->openedOk()) { + throw std::domain_error("Unable to open audio file for writing: " + + filename); + } + + format = formatManager.findFormatForFileExtension(extension); + + if (!format) { + if (extension.empty()) { + throw std::domain_error("No file extension provided - cannot detect " + "audio format to write with for filename: " + + filename); + } + + throw std::domain_error( + "Unable to detect audio format for file extension: " + extension); + } + } + + // Normalize the input to a string here, as we need to do parsing anyways: + std::string qualityString; + if (qualityInput) { + if (auto *q = std::get_if(&qualityInput.value())) { + qualityString = *q; + } else if (auto *q = std::get_if(&qualityInput.value())) { + if (isInteger(*q)) { + qualityString = std::to_string((int)*q); + } else { + qualityString = std::to_string(*q); + } + } else { + throw std::runtime_error("Unknown quality type!"); + } + } + + int qualityOptionIndex = determineQualityOptionIndex(format, qualityString); + if (format->getQualityOptions().size() > qualityOptionIndex) { + quality = format->getQualityOptions()[qualityOptionIndex].toStdString(); + } + + juce::StringPairArray emptyMetadata; + writer.reset(format->createWriterFor(outputStream.get(), writeSampleRate, + numChannels, bitDepth, emptyMetadata, + qualityOptionIndex)); + if (!writer) { + PythonException::raise(); + + // Check common errors first: + juce::Array possibleSampleRates = format->getPossibleSampleRates(); + + if (possibleSampleRates.isEmpty()) { + throw std::domain_error( + extension + " audio files are not writable with Pedalboard."); + } + + if (!possibleSampleRates.contains((int)writeSampleRate)) { + std::ostringstream sampleRateString; + for (int i = 0; i < possibleSampleRates.size(); i++) { + sampleRateString << possibleSampleRates[i]; + if (i < possibleSampleRates.size() - 1) + sampleRateString << ", "; + } + throw std::domain_error( + format->getFormatName().toStdString() + + " audio files do not support the provided sample rate of " + + std::to_string(writeSampleRate) + + "Hz. Supported sample rates: " + sampleRateString.str()); + } + + juce::Array possibleBitDepths = format->getPossibleBitDepths(); + + if (possibleBitDepths.isEmpty()) { + throw std::domain_error( + extension + " audio files are not writable with Pedalboard."); + } + + if (!possibleBitDepths.contains((int)bitDepth)) { + std::ostringstream bitDepthString; + for (int i = 0; i < possibleBitDepths.size(); i++) { + bitDepthString << possibleBitDepths[i]; + if (i < possibleBitDepths.size() - 1) + bitDepthString << ", "; + } + throw std::domain_error( + format->getFormatName().toStdString() + + " audio files do not support the provided bit depth of " + + std::to_string(bitDepth) + + " bits. Supported bit depths: " + bitDepthString.str()); + } + + std::string humanReadableQuality; + if (qualityString.empty()) { + humanReadableQuality = "None"; + } else { + humanReadableQuality = qualityString; + } + + throw std::domain_error( + "Unable to create " + format->getFormatName().toStdString() + + " writer with samplerate=" + std::to_string(writeSampleRate) + + ", num_channels=" + std::to_string(numChannels) + ", bit_depth=" + + std::to_string(bitDepth) + ", and quality=" + humanReadableQuality); + } else { + // If we have a writer object, it now owns the OutputStream we passed in + // - so we need to release it before possibly throwing an exception, or + // the stream will leak. + outputStream.release(); + PythonException::raise(); + } + } + + template + void write(py::array_t inputArray) { + const juce::ScopedLock scopedLock(objectLock); + + if (!writer) + throw std::runtime_error("I/O operation on a closed file."); + + py::buffer_info inputInfo = inputArray.request(); + + unsigned int numChannels = 0; + unsigned int numSamples = 0; + ChannelLayout inputChannelLayout = detectChannelLayout(inputArray); + + // Release the GIL when we do the writing, after we + // already have a reference to the input array: + pybind11::gil_scoped_release release; + + if (inputInfo.ndim == 1) { + numSamples = inputInfo.shape[0]; + numChannels = 1; + } else if (inputInfo.ndim == 2) { + // Try to auto-detect the channel layout from the shape + if (inputInfo.shape[0] == getNumChannels() && + inputInfo.shape[1] == getNumChannels()) { + throw std::runtime_error( + "Unable to determine shape of audio input! Both dimensions have " + "the same shape. Expected " + + std::to_string(getNumChannels()) + + "-channel audio, with one dimension larger than the other."); + } else if (inputInfo.shape[1] == getNumChannels()) { + numSamples = inputInfo.shape[0]; + numChannels = inputInfo.shape[1]; + } else if (inputInfo.shape[0] == getNumChannels()) { + numSamples = inputInfo.shape[1]; + numChannels = inputInfo.shape[0]; + } else { + throw std::runtime_error( + "Unable to determine shape of audio input! Expected " + + std::to_string(getNumChannels()) + "-channel audio."); + } + } else { + throw std::runtime_error( + "Number of input dimensions must be 1 or 2 (got " + + std::to_string(inputInfo.ndim) + ")."); + } + + if (numChannels == 0) { + // No work to do. + return; + } else if (numChannels != getNumChannels()) { + throw std::runtime_error( + "WriteableAudioFile was opened with num_channels=" + + std::to_string(getNumChannels()) + + ", but was passed an array containing " + + std::to_string(numChannels) + "-channel audio!"); + } + + // Depending on the input channel layout, we need to copy data + // differently. This loop is duplicated here to move the if statement + // outside of the tight loop, as we don't need to re-check that the input + // channel is still the same on every iteration of the loop. + switch (inputChannelLayout) { + case ChannelLayout::Interleaved: { + std::vector> deinterleaveBuffers; + + // Use a temporary buffer to chunk the audio input + // and pass it into the writer, chunk by chunk, rather + // than de-interleaving the entire buffer at once: + deinterleaveBuffers.resize(numChannels); + + const SampleType **channelPointers = + (const SampleType **)alloca(numChannels * sizeof(SampleType *)); + for (int startSample = 0; startSample < numSamples; + startSample += DEFAULT_AUDIO_BUFFER_SIZE_FRAMES) { + int samplesToWrite = std::min(numSamples - startSample, + DEFAULT_AUDIO_BUFFER_SIZE_FRAMES); + + for (int c = 0; c < numChannels; c++) { + deinterleaveBuffers[c].resize(samplesToWrite); + channelPointers[c] = deinterleaveBuffers[c].data(); + + // We're de-interleaving the data here, so we can't use copyFrom. + for (unsigned int i = 0; i < samplesToWrite; i++) { + deinterleaveBuffers[c][i] = + ((SampleType + *)(inputInfo.ptr))[((i + startSample) * numChannels) + c]; + } + } + + if (!write(channelPointers, numChannels, samplesToWrite)) { + throw std::runtime_error("Unable to write data to audio file."); + } + PythonException::raise(); + } + + break; + } + case ChannelLayout::NotInterleaved: { + // We can just pass all the data to write: + const SampleType **channelPointers = + (const SampleType **)alloca(numChannels * sizeof(SampleType *)); + for (int c = 0; c < numChannels; c++) { + channelPointers[c] = ((SampleType *)inputInfo.ptr) + (numSamples * c); + } + if (!write(channelPointers, numChannels, numSamples)) { + throw std::runtime_error("Unable to write data to audio file."); + } + PythonException::raise(); + break; + } + default: + throw std::runtime_error( + "Internal error: got unexpected channel layout."); + } + + framesWritten += numSamples; + } + + template + bool writeConvertingTo(const InputType **channels, int numChannels, + unsigned int numSamples) { + std::vector> targetTypeBuffers; + targetTypeBuffers.resize(numChannels); + + const TargetType **channelPointers = + (const TargetType **)alloca(numChannels * sizeof(TargetType *)); + for (unsigned int startSample = 0; startSample < numSamples; + startSample += bufferSize) { + int samplesToWrite = std::min(numSamples - startSample, bufferSize); + + for (int c = 0; c < numChannels; c++) { + targetTypeBuffers[c].resize(samplesToWrite); + channelPointers[c] = targetTypeBuffers[c].data(); + + if constexpr (std::is_integral::value) { + if constexpr (std::is_integral::value) { + for (unsigned int i = 0; i < samplesToWrite; i++) { + // Left-align the samples to use all 32 bits, as JUCE requires: + targetTypeBuffers[c][i] = + ((int)channels[c][startSample + i]) + << (std::numeric_limits::digits - + std::numeric_limits::digits); + } + } else if constexpr (std::is_same::value) { + constexpr auto scaleFactor = + 1.0f / static_cast(std::numeric_limits::max()); + juce::FloatVectorOperations::convertFixedToFloat( + targetTypeBuffers[c].data(), channels[c] + startSample, + scaleFactor, samplesToWrite); + } else { + // We should never get here - this would only be true + // if converting to double, which no formats require: + static_assert(std::is_integral::value && + std::is_same::value, + "Can't convert to double"); + } + } else { + if constexpr (std::is_integral::value) { + // We should never get here - this would only be true + // if converting float to int, which JUCE handles for us: + static_assert(std::is_integral::value && + !std::is_integral::value, + "Can't convert float to int"); + } else { + // Converting double to float: + for (unsigned int i = 0; i < samplesToWrite; i++) { + targetTypeBuffers[c][i] = channels[c][startSample + i]; + } + } + } + } + + if (!write(channelPointers, numChannels, samplesToWrite)) { + return false; + } + } + + return true; + } + + template + bool write(const SampleType **channels, int numChannels, + unsigned int numSamples) { + if constexpr (std::is_integral::value) { + if constexpr (std::is_same::value) { + if (writer->isFloatingPoint()) { + return writeConvertingTo(channels, numChannels, numSamples); + } else { + return writer->write(channels, numSamples); + } + } else { + return writeConvertingTo(channels, numChannels, numSamples); + } + } else if constexpr (std::is_same::value) { + if (writer->isFloatingPoint()) { + // Just pass the floating point data into the writer as if it were + // integer data. If the writer requires floating-point input data, this + // works (and is documented!) + return writer->write((const int **)channels, numSamples); + } else { + // Convert floating-point to fixed point, but let JUCE do that for us: + return writer->writeFromFloatArrays(channels, numChannels, numSamples); + } + } else { + // We must have double-format data: + return writeConvertingTo(channels, numChannels, numSamples); + } + } + + void flush() { + if (!writer) + throw std::runtime_error("I/O operation on a closed file."); + const juce::ScopedLock scopedLock(objectLock); + pybind11::gil_scoped_release release; + + if (!writer->flush()) { + throw std::runtime_error( + "Unable to flush audio file; is the underlying file seekable?"); + } + } + + void close() { + if (!writer) + throw std::runtime_error("Cannot close closed file."); + const juce::ScopedLock scopedLock(objectLock); + writer.reset(); + } + + bool isClosed() const { + const juce::ScopedLock scopedLock(objectLock); + return !writer; + } + + double getSampleRate() const { + if (!writer) + throw std::runtime_error("I/O operation on a closed file."); + return writer->getSampleRate(); + } + + std::string getFilename() const { return filename; } + + long getFramesWritten() const { return framesWritten; } + + std::optional getQuality() const { return quality; } + + long getNumChannels() const { + if (!writer) + throw std::runtime_error("I/O operation on a closed file."); + return writer->getNumChannels(); + } + + std::string getFileDatatype() const { + if (!writer) + throw std::runtime_error("I/O operation on a closed file."); + + if (writer->isFloatingPoint()) { + switch (writer->getBitsPerSample()) { + case 16: // OGG returns 16-bit int data, but internally stores floats + case 32: + return "float32"; + case 64: + return "float64"; + default: + return "unknown"; + } + } else { + switch (writer->getBitsPerSample()) { + case 8: + return "int8"; + case 16: + return "int16"; + case 24: + return "int24"; + case 32: + return "int32"; + case 64: + return "int64"; + default: + return "unknown"; + } + } + } + + std::shared_ptr enter() { return shared_from_this(); } + + void exit(const py::object &type, const py::object &value, + const py::object &traceback) { + close(); + } + + PythonOutputStream *getPythonOutputStream() const { + if (!filename.empty()) { + return nullptr; + } + if (!writer) { + return nullptr; + } + + // the AudioFormatWriter retains exclusive ownership over the output stream, + // and doesn't expose it - so we keep our own reference (which may be + // deallocated out from under us!) + return unsafeOutputStream; + } + +private: + juce::AudioFormatManager formatManager; + std::string filename; + std::optional quality; + std::unique_ptr writer; + PythonOutputStream *unsafeOutputStream = nullptr; + juce::CriticalSection objectLock; + int framesWritten = 0; +}; + +inline void init_writeable_audio_file(py::module &m) { + py::class_>( + m, "WriteableAudioFile", + "An audio file writer interface, with native support for Ogg Vorbis, " + "WAV, FLAC, and AIFF files on all operating systems. (Use " + "pedalboard.io.get_supported_write_formats() to see which additional " + "formats are supported on the current platform.)") + .def(py::init([](std::string filename, double sampleRate, int numChannels, + int bitDepth, + std::optional> quality) + -> WriteableAudioFile * { + // This definition is only here to provide nice docstrings. + throw std::runtime_error( + "Internal error: __init__ should never be called, as this " + "class implements __new__."); + }), + py::arg("filename"), py::arg("samplerate"), + py::arg("num_channels") = 1, py::arg("bit_depth") = 16, + py::arg("quality") = py::none()) + .def(py::init( + [](py::object filelike, double sampleRate, int numChannels, + int bitDepth, + std::optional> quality, + std::optional format) -> WriteableAudioFile * { + // This definition is only here to provide nice docstrings. + throw std::runtime_error( + "Internal error: __init__ should never be called, as this " + "class implements __new__."); + }), + py::arg("file_like"), py::arg("samplerate"), + py::arg("num_channels") = 1, py::arg("bit_depth") = 16, + py::arg("quality") = py::none(), py::arg("format") = py::none()) + .def_static( + "__new__", + [](const py::object *, std::string filename, + std::optional sampleRate, int numChannels, int bitDepth, + std::optional> quality) { + if (!sampleRate) { + throw py::type_error( + "Opening an audio file for writing requires a samplerate " + "argument to be provided."); + } + return std::make_shared( + filename, sampleRate.value(), numChannels, bitDepth, quality); + }, + py::arg("cls"), py::arg("filename"), + py::arg("samplerate") = py::none(), py::arg("num_channels") = 1, + py::arg("bit_depth") = 16, py::arg("quality") = py::none()) + .def_static( + "__new__", + [](const py::object *, py::object filelike, + std::optional sampleRate, int numChannels, int bitDepth, + std::optional> quality, + std::optional format) { + if (!sampleRate) { + throw py::type_error( + "Opening an audio file for writing requires a samplerate " + "argument to be provided."); + } + if (!isWriteableFileLike(filelike)) { + throw py::type_error( + "Expected either a filename or a file-like object (with " + "write, seek, seekable, and tell methods), but received: " + + py::repr(filelike).cast()); + } + + auto stream = std::make_unique(filelike); + if (!format && !stream->getFilename()) { + throw py::type_error( + "Unable to infer audio file format for writing. Expected " + "either a \".name\" property on the provided file-like " + "object (" + + py::repr(filelike).cast() + + ") or an explicit file format passed as the \"format=\" " + "argument."); + } + + return std::make_shared( + format.value_or(""), std::move(stream), sampleRate.value(), + numChannels, bitDepth, quality); + }, + py::arg("cls"), py::arg("file_like"), + py::arg("samplerate") = py::none(), py::arg("num_channels") = 1, + py::arg("bit_depth") = 16, py::arg("quality") = py::none(), + py::arg("format") = py::none()) + .def( + "write", + [](WriteableAudioFile &file, py::array_t samples) { + file.write(samples); + }, + py::arg("samples").noconvert(), + "Encode an array of int8 (8-bit signed integer) audio data and write " + "it to this file. The number of channels in the array must match the " + "number of channels used to open the file. The array may contain " + "audio in any shape. If the file's bit depth or format does not " + "match this data type, the audio will be automatically converted.") + .def( + "write", + [](WriteableAudioFile &file, py::array_t samples) { + file.write(samples); + }, + py::arg("samples").noconvert(), + "Encode an array of int16 (16-bit signed integer) audio data and " + "write it to this file. The number of channels in the array must " + "match the number of channels used to open the file. The array may " + "contain audio in any shape. If the file's bit depth or format does " + "not match this data type, the audio will be automatically " + "converted.") + .def( + "write", + [](WriteableAudioFile &file, py::array_t samples) { + file.write(samples); + }, + py::arg("samples").noconvert(), + "Encode an array of int32 (32-bit signed integer) audio data and " + "write it to this file. The number of channels in the array must " + "match the number of channels used to open the file. The array may " + "contain audio in any shape. If the file's bit depth or format does " + "not match this data type, the audio will be automatically " + "converted.") + .def( + "write", + [](WriteableAudioFile &file, py::array_t samples) { + file.write(samples); + }, + py::arg("samples").noconvert(), + "Encode an array of float32 (32-bit floating-point) audio data and " + "write it to this file. The number of channels in the array must " + "match the number of channels used to open the file. The array may " + "contain audio in any shape. If the file's bit depth or format does " + "not match this data type, the audio will be automatically " + "converted.") + .def( + "write", + [](WriteableAudioFile &file, py::array_t samples) { + file.write(samples); + }, + py::arg("samples").noconvert(), + "Encode an array of float64 (64-bit floating-point) audio data and " + "write it to this file. The number of channels in the array must " + "match the number of channels used to open the file. The array may " + "contain audio in any shape. No supported formats support float64 " + "natively, so the audio will be converted automatically.") + .def("flush", &WriteableAudioFile::flush, + "Attempt to flush this audio file's contents to disk. Not all " + "formats support flushing, so this may throw a RuntimeError. (If " + "this happens, closing the file will reliably force a flush to " + "occur.)") + .def("close", &WriteableAudioFile::close, + "Close this file, flushing its contents to disk and rendering this " + "object unusable for further writing.") + .def("__enter__", &WriteableAudioFile::enter) + .def("__exit__", &WriteableAudioFile::exit) + .def("__repr__", + [](const WriteableAudioFile &file) { + std::ostringstream ss; + ss << ""; + return ss.str(); + }) + .def_property_readonly( + "closed", &WriteableAudioFile::isClosed, + "If this file has been closed, this property will be True.") + .def_property_readonly("samplerate", &WriteableAudioFile::getSampleRate, + "The sample rate of this file in samples " + "(per channel) per second (Hz).") + .def_property_readonly("num_channels", + &WriteableAudioFile::getNumChannels, + "The number of channels in this file.") + .def_property_readonly("frames", &WriteableAudioFile::getFramesWritten, + "The total number of frames (samples per " + "channel) written to this file so far.") + .def_property_readonly( + "file_dtype", &WriteableAudioFile::getFileDatatype, + "The data type stored natively by this file. Note that write(...) " + "will accept multiple datatypes, regardless of the value of this " + "property.") + .def_property_readonly( + "quality", &WriteableAudioFile::getQuality, + "The quality setting used to write this file. For many " + "formats, this may be None."); + + m.def("get_supported_read_formats", []() { + juce::AudioFormatManager manager; + manager.registerBasicFormats(); + + std::vector formatNames(manager.getNumKnownFormats()); + juce::StringArray extensions; + for (int i = 0; i < manager.getNumKnownFormats(); i++) { + auto *format = manager.getKnownFormat(i); + extensions.addArray(format->getFileExtensions()); + } + + extensions.trim(); + extensions.removeEmptyStrings(); + extensions.removeDuplicates(true); + + std::vector output; + for (juce::String s : extensions) { + output.push_back(s.toStdString()); + } + + std::sort( + output.begin(), output.end(), + [](const std::string lhs, const std::string rhs) { return lhs < rhs; }); + + return output; + }); + + m.def("get_supported_write_formats", []() { + // JUCE doesn't support writing other formats out-of-the-box on all + // platforms, and there's no easy way to tell which formats are supported + // without attempting to create an AudioFileWriter object - so this list is + // hardcoded for now. + const std::vector formats = {".aiff", ".flac", ".ogg", ".wav"}; + return formats; + }); +} +} // namespace Pedalboard \ No newline at end of file diff --git a/pedalboard/io/__init__.py b/pedalboard/io/__init__.py new file mode 100644 index 000000000..a40dee2e5 --- /dev/null +++ b/pedalboard/io/__init__.py @@ -0,0 +1 @@ +from pedalboard_native.io import * # noqa: F403, F401 diff --git a/pedalboard/process.h b/pedalboard/process.h index c48ea4e4b..855867323 100644 --- a/pedalboard/process.h +++ b/pedalboard/process.h @@ -21,15 +21,12 @@ #include #include +#include "BufferUtils.h" #include "Plugin.h" namespace py = pybind11; namespace Pedalboard { -enum class ChannelLayout { - Interleaved, - NotInterleaved, -}; /** * Non-float32 overload. @@ -57,152 +54,6 @@ processSingle(const py::array_t inputArray, reset); } -template -ChannelLayout -detectChannelLayout(const py::array_t inputArray) { - py::buffer_info inputInfo = inputArray.request(); - - if (inputInfo.ndim == 1) { - return ChannelLayout::Interleaved; - } else if (inputInfo.ndim == 2) { - // Try to auto-detect the channel layout from the shape - if (inputInfo.shape[1] < inputInfo.shape[0]) { - return ChannelLayout::Interleaved; - } else if (inputInfo.shape[0] < inputInfo.shape[1]) { - return ChannelLayout::NotInterleaved; - } else { - throw std::runtime_error( - "Unable to determine channel layout from shape!"); - } - } else { - throw std::runtime_error("Number of input dimensions must be 1 or 2 (got " + - std::to_string(inputInfo.ndim) + ")."); - } -} - -template -juce::AudioBuffer -copyPyArrayIntoJuceBuffer(const py::array_t inputArray) { - // Numpy/Librosa convention is (num_samples, num_channels) - py::buffer_info inputInfo = inputArray.request(); - - unsigned int numChannels = 0; - unsigned int numSamples = 0; - ChannelLayout inputChannelLayout = detectChannelLayout(inputArray); - - if (inputInfo.ndim == 1) { - numSamples = inputInfo.shape[0]; - numChannels = 1; - } else if (inputInfo.ndim == 2) { - // Try to auto-detect the channel layout from the shape - if (inputInfo.shape[1] < inputInfo.shape[0]) { - numSamples = inputInfo.shape[0]; - numChannels = inputInfo.shape[1]; - } else if (inputInfo.shape[0] < inputInfo.shape[1]) { - numSamples = inputInfo.shape[1]; - numChannels = inputInfo.shape[0]; - } else { - throw std::runtime_error("Unable to determine shape of audio input!"); - } - } else { - throw std::runtime_error("Number of input dimensions must be 1 or 2 (got " + - std::to_string(inputInfo.ndim) + ")."); - } - - if (numChannels == 0) { - throw std::runtime_error("No channels passed!"); - } else if (numChannels > 2) { - throw std::runtime_error("More than two channels received!"); - } - - juce::AudioBuffer ioBuffer(numChannels, numSamples); - - // Depending on the input channel layout, we need to copy data - // differently. This loop is duplicated here to move the if statement - // outside of the tight loop, as we don't need to re-check that the input - // channel is still the same on every iteration of the loop. - switch (inputChannelLayout) { - case ChannelLayout::Interleaved: - for (unsigned int i = 0; i < numChannels; i++) { - T *channelBuffer = ioBuffer.getWritePointer(i); - // We're de-interleaving the data here, so we can't use copyFrom. - for (unsigned int j = 0; j < numSamples; j++) { - channelBuffer[j] = static_cast(inputInfo.ptr)[j * numChannels + i]; - } - } - break; - case ChannelLayout::NotInterleaved: - for (unsigned int i = 0; i < numChannels; i++) { - ioBuffer.copyFrom( - i, 0, static_cast(inputInfo.ptr) + (numSamples * i), numSamples); - } - break; - default: - throw std::runtime_error("Internal error: got unexpected channel layout."); - } - - return ioBuffer; -} - -template -py::array_t copyJuceBufferIntoPyArray(const juce::AudioBuffer juceBuffer, - ChannelLayout channelLayout, - int offsetSamples, int ndim = 2) { - unsigned int numChannels = juceBuffer.getNumChannels(); - unsigned int numSamples = juceBuffer.getNumSamples(); - unsigned int outputSampleCount = - std::max((int)numSamples - (int)offsetSamples, 0); - - // TODO: Avoid the need to copy here if offsetSamples is 0! - py::array_t outputArray; - if (ndim == 2) { - switch (channelLayout) { - case ChannelLayout::Interleaved: - outputArray = py::array_t({outputSampleCount, numChannels}); - break; - case ChannelLayout::NotInterleaved: - outputArray = py::array_t({numChannels, outputSampleCount}); - break; - default: - throw std::runtime_error( - "Internal error: got unexpected channel layout."); - } - } else { - outputArray = py::array_t(outputSampleCount); - } - - py::buffer_info outputInfo = outputArray.request(); - - // Depending on the input channel layout, we need to copy data - // differently. This loop is duplicated here to move the if statement - // outside of the tight loop, as we don't need to re-check that the input - // channel is still the same on every iteration of the loop. - T *outputBasePointer = static_cast(outputInfo.ptr); - - switch (channelLayout) { - case ChannelLayout::Interleaved: - for (unsigned int i = 0; i < numChannels; i++) { - const T *channelBuffer = juceBuffer.getReadPointer(i, offsetSamples); - // We're interleaving the data here, so we can't use copyFrom. - for (unsigned int j = 0; j < outputSampleCount; j++) { - outputBasePointer[j * numChannels + i] = channelBuffer[j]; - } - } - break; - case ChannelLayout::NotInterleaved: - for (unsigned int i = 0; i < numChannels; i++) { - const T *channelBuffer = juceBuffer.getReadPointer(i, offsetSamples); - std::copy(channelBuffer, channelBuffer + outputSampleCount, - &outputBasePointer[outputSampleCount * i]); - } - break; - default: - throw std::runtime_error("Internal error: got unexpected channel layout."); - } - - return outputArray; -} - inline int process(juce::AudioBuffer &ioBuffer, juce::dsp::ProcessSpec spec, const std::vector> &plugins, diff --git a/pedalboard/python_bindings.cpp b/pedalboard/python_bindings.cpp index 26ba37332..f33aacad3 100644 --- a/pedalboard/python_bindings.cpp +++ b/pedalboard/python_bindings.cpp @@ -61,6 +61,10 @@ namespace py = pybind11; #include "plugins/PitchShift.h" #include "plugins/Reverb.h" +#include "io/AudioFileInit.h" +#include "io/ReadableAudioFile.h" +#include "io/WriteableAudioFile.h" + using namespace Pedalboard; PYBIND11_MODULE(pedalboard_native, m) { @@ -186,4 +190,10 @@ PYBIND11_MODULE(pedalboard_native, m) { init_resample_with_latency(internal); init_fixed_size_block_test_plugin(internal); init_force_mono_test_plugin(internal); + + // I/O helpers and utilities: + py::module io = m.def_submodule("io"); + init_audio_file(io); + init_readable_audio_file(io); + init_writeable_audio_file(io); }; diff --git a/setup.py b/setup.py index c43ecae01..0b5d24368 100644 --- a/setup.py +++ b/setup.py @@ -72,6 +72,7 @@ "-DJUCE_DISABLE_JUCE_VERSION_PRINTING=1", "-DJUCE_WEB_BROWSER=0", "-DJUCE_USE_CURL=0", + "-DJUCE_USE_MP3AUDIOFORMAT=1", # "-DJUCE_USE_FREETYPE=0", "-DJUCE_MODAL_LOOPS_PERMITTED=1", ] diff --git a/tests/audio/correct/empty.fake b/tests/audio/correct/empty.fake new file mode 100644 index 000000000..e69de29bb diff --git a/tests/audio/correct/empty_44100.fake b/tests/audio/correct/empty_44100.fake new file mode 100644 index 000000000..e69de29bb diff --git a/tests/audio/correct/empty_48000.fake b/tests/audio/correct/empty_48000.fake new file mode 100644 index 000000000..e69de29bb diff --git a/tests/audio/correct/mono_sine_at_44100Hz.ac3 b/tests/audio/correct/mono_sine_at_44100Hz.ac3 new file mode 100644 index 000000000..efdcbca93 Binary files /dev/null and b/tests/audio/correct/mono_sine_at_44100Hz.ac3 differ diff --git a/tests/audio/correct/mono_sine_at_44100Hz.adts b/tests/audio/correct/mono_sine_at_44100Hz.adts new file mode 100644 index 000000000..e805dedec Binary files /dev/null and b/tests/audio/correct/mono_sine_at_44100Hz.adts differ diff --git a/tests/audio/correct/mono_sine_at_44100Hz.aifc b/tests/audio/correct/mono_sine_at_44100Hz.aifc new file mode 100644 index 000000000..a5e9b7e2b Binary files /dev/null and b/tests/audio/correct/mono_sine_at_44100Hz.aifc differ diff --git a/tests/audio/correct/mono_sine_at_44100Hz.aiff b/tests/audio/correct/mono_sine_at_44100Hz.aiff new file mode 100644 index 000000000..f62718247 Binary files /dev/null and b/tests/audio/correct/mono_sine_at_44100Hz.aiff differ diff --git a/tests/audio/correct/mono_sine_at_44100Hz.caf b/tests/audio/correct/mono_sine_at_44100Hz.caf new file mode 100644 index 000000000..927737497 Binary files /dev/null and b/tests/audio/correct/mono_sine_at_44100Hz.caf differ diff --git a/tests/audio/correct/mono_sine_at_44100Hz.flac b/tests/audio/correct/mono_sine_at_44100Hz.flac new file mode 100644 index 000000000..987f56690 Binary files /dev/null and b/tests/audio/correct/mono_sine_at_44100Hz.flac differ diff --git a/tests/audio/correct/mono_sine_at_44100Hz.m4a b/tests/audio/correct/mono_sine_at_44100Hz.m4a new file mode 100644 index 000000000..3d8f64173 Binary files /dev/null and b/tests/audio/correct/mono_sine_at_44100Hz.m4a differ diff --git a/tests/audio/correct/mono_sine_at_44100Hz.mp3 b/tests/audio/correct/mono_sine_at_44100Hz.mp3 new file mode 100644 index 000000000..f23ccaadf Binary files /dev/null and b/tests/audio/correct/mono_sine_at_44100Hz.mp3 differ diff --git a/tests/audio/correct/mono_sine_at_44100Hz.mp4 b/tests/audio/correct/mono_sine_at_44100Hz.mp4 new file mode 100644 index 000000000..37a434b7b Binary files /dev/null and b/tests/audio/correct/mono_sine_at_44100Hz.mp4 differ diff --git a/tests/audio/correct/mono_sine_at_44100Hz.ogg b/tests/audio/correct/mono_sine_at_44100Hz.ogg new file mode 100644 index 000000000..c63b9a957 Binary files /dev/null and b/tests/audio/correct/mono_sine_at_44100Hz.ogg differ diff --git a/tests/audio/correct/mono_sine_at_44100Hz.wav b/tests/audio/correct/mono_sine_at_44100Hz.wav new file mode 100644 index 000000000..794ecc23e Binary files /dev/null and b/tests/audio/correct/mono_sine_at_44100Hz.wav differ diff --git a/tests/audio/correct/mono_sine_at_48000Hz.ac3 b/tests/audio/correct/mono_sine_at_48000Hz.ac3 new file mode 100644 index 000000000..960cf642f Binary files /dev/null and b/tests/audio/correct/mono_sine_at_48000Hz.ac3 differ diff --git a/tests/audio/correct/mono_sine_at_48000Hz.adts b/tests/audio/correct/mono_sine_at_48000Hz.adts new file mode 100644 index 000000000..85e4cc7e9 Binary files /dev/null and b/tests/audio/correct/mono_sine_at_48000Hz.adts differ diff --git a/tests/audio/correct/mono_sine_at_48000Hz.aifc b/tests/audio/correct/mono_sine_at_48000Hz.aifc new file mode 100644 index 000000000..a8f9630c2 Binary files /dev/null and b/tests/audio/correct/mono_sine_at_48000Hz.aifc differ diff --git a/tests/audio/correct/mono_sine_at_48000Hz.aiff b/tests/audio/correct/mono_sine_at_48000Hz.aiff new file mode 100644 index 000000000..7dd012298 Binary files /dev/null and b/tests/audio/correct/mono_sine_at_48000Hz.aiff differ diff --git a/tests/audio/correct/mono_sine_at_48000Hz.caf b/tests/audio/correct/mono_sine_at_48000Hz.caf new file mode 100644 index 000000000..fde5e6981 Binary files /dev/null and b/tests/audio/correct/mono_sine_at_48000Hz.caf differ diff --git a/tests/audio/correct/mono_sine_at_48000Hz.flac b/tests/audio/correct/mono_sine_at_48000Hz.flac new file mode 100644 index 000000000..ad9478a0d Binary files /dev/null and b/tests/audio/correct/mono_sine_at_48000Hz.flac differ diff --git a/tests/audio/correct/mono_sine_at_48000Hz.m4a b/tests/audio/correct/mono_sine_at_48000Hz.m4a new file mode 100644 index 000000000..85dc41110 Binary files /dev/null and b/tests/audio/correct/mono_sine_at_48000Hz.m4a differ diff --git a/tests/audio/correct/mono_sine_at_48000Hz.mp3 b/tests/audio/correct/mono_sine_at_48000Hz.mp3 new file mode 100644 index 000000000..fb61440ac Binary files /dev/null and b/tests/audio/correct/mono_sine_at_48000Hz.mp3 differ diff --git a/tests/audio/correct/mono_sine_at_48000Hz.mp4 b/tests/audio/correct/mono_sine_at_48000Hz.mp4 new file mode 100644 index 000000000..dc7ce489b Binary files /dev/null and b/tests/audio/correct/mono_sine_at_48000Hz.mp4 differ diff --git a/tests/audio/correct/mono_sine_at_48000Hz.ogg b/tests/audio/correct/mono_sine_at_48000Hz.ogg new file mode 100644 index 000000000..c63993cb4 Binary files /dev/null and b/tests/audio/correct/mono_sine_at_48000Hz.ogg differ diff --git a/tests/audio/correct/mono_sine_at_48000Hz.wav b/tests/audio/correct/mono_sine_at_48000Hz.wav new file mode 100644 index 000000000..26cd9037e Binary files /dev/null and b/tests/audio/correct/mono_sine_at_48000Hz.wav differ diff --git a/tests/test_io.py b/tests/test_io.py new file mode 100644 index 000000000..b52ae0216 --- /dev/null +++ b/tests/test_io.py @@ -0,0 +1,810 @@ +#! /usr/bin/env python +# +# 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. + + +import io +import os +import glob +import wave +import pathlib +import shutil +import pytest +import platform +import pedalboard +from typing import Optional + +import numpy as np + +from .utils import generate_sine_at + +EXPECTED_DURATION_SECONDS = 5 +EXPECT_LENGTH_TO_BE_EXACT = {"wav", "aiff", "caf", "ogg", "m4a", "mp4"} + +TEST_AUDIO_FILES = { + 44100: glob.glob(os.path.join(os.path.dirname(__file__), "audio", "correct", "*44100*")), + 48000: glob.glob(os.path.join(os.path.dirname(__file__), "audio", "correct", "*48000*")), +} + +FILENAMES_AND_SAMPLERATES = [ + (filename, samplerate) + for samplerate, filenames in TEST_AUDIO_FILES.items() + for filename in filenames + # On some platforms, not all extensions will be available. + if any(filename.endswith(extension) for extension in pedalboard.io.get_supported_read_formats()) +] + +UNSUPPORTED_FILENAMES = [ + filename + for filename in sum(TEST_AUDIO_FILES.values(), []) + if not any( + filename.endswith(extension) for extension in pedalboard.io.get_supported_read_formats() + ) +] + + +def get_tolerance_for_format_and_bit_depth(extension: str, input_format, file_dtype: str) -> float: + if not extension.startswith("."): + extension = "." + extension + if extension in {".wav", ".aiff", ".flac"}: + file_bit_depth = int(file_dtype.replace("float", "").replace("int", "")) + if np.issubdtype(input_format, np.signedinteger): + input_bit_depth = np.dtype(input_format).itemsize * 8 + return 4 / (2 ** min(file_bit_depth, input_bit_depth)) + return 4 / (2 ** file_bit_depth) + + # These formats offset the waveform substantially, and these tests don't do any realignment. + if extension in {".m4a", ".ac3", ".adts", ".mp4", ".mp2"}: + return 3.0 + + if extension in {".mp3"}: + return 0.8 + + return 0.12 + + +def test_read_constructor_dispatch(): + filename, _samplerate = FILENAMES_AND_SAMPLERATES[0] + + # Support reading a file with just its filename: + assert isinstance(pedalboard.io.AudioFile(filename), pedalboard.io.ReadableAudioFile) + + # Support reading a file with just its filename and an explicit "r" (read) flag: + assert isinstance(pedalboard.io.AudioFile(filename, "r"), pedalboard.io.ReadableAudioFile) + + # Support reading a file by using the appropriate subclass constructor just its filename: + assert isinstance(pedalboard.io.ReadableAudioFile(filename), pedalboard.io.ReadableAudioFile) + + # Don't support reading a file by passing a mode to the + # subclass constructor (which would be redundant): + with pytest.raises(TypeError) as e: + pedalboard.io.ReadableAudioFile(filename, "r") + assert "incompatible function arguments" in str(e) + + +def test_write_constructor_dispatch(tmp_path: pathlib.Path): + filename = str(tmp_path / "temp.wav") + + # Don't support writing to a file with just its filename and write args: + with pytest.raises(TypeError): + pedalboard.io.AudioFile(filename, 44100, 1) + + # Support writing to a file with just its filename and an explicit "w" (write) flag: + assert isinstance( + pedalboard.io.AudioFile(filename, "w", 44100, 1), pedalboard.io.WriteableAudioFile + ) + + # Support writing to a file by using the appropriate subclass constructor just its filename: + assert isinstance( + pedalboard.io.WriteableAudioFile(filename, 44100, 1), pedalboard.io.WriteableAudioFile + ) + + # Don't support writing to a file by passing a mode + # to the subclass constructor (which would be redundant): + with pytest.raises(TypeError) as e: + pedalboard.io.WriteableAudioFile(filename, "w", 44100, 1) + assert "incompatible function arguments" in str(e) + + # Support writing to a file by omitting num_channels to WriteableAudioFile: + assert isinstance( + pedalboard.io.WriteableAudioFile(filename, samplerate=44100), + pedalboard.io.WriteableAudioFile, + ) + + # but not if samplerate is missing: + with pytest.raises(TypeError) as e: + pedalboard.io.WriteableAudioFile(filename, num_channels=1) + assert "samplerate" in str(e) + + # ... or to regular AudioFile with a "w" flag: + assert isinstance( + pedalboard.io.AudioFile(filename, "w", samplerate=44100), + pedalboard.io.WriteableAudioFile, + ) + + # but not if samplerate is missing: + with pytest.raises(TypeError) as e: + pedalboard.io.AudioFile(filename, "w", num_channels=1) + assert "samplerate" in str(e) + + +@pytest.mark.parametrize("extension", [".mp3", ".wav", ".ogg", ".flac"]) +def test_basic_formats_available_on_all_platforms(extension: str): + assert extension in pedalboard.io.get_supported_read_formats() + + +@pytest.mark.parametrize("audio_filename,samplerate", FILENAMES_AND_SAMPLERATES) +def test_basic_read(audio_filename: str, samplerate: float): + af = pedalboard.io.AudioFile(audio_filename) + assert af.samplerate == samplerate + assert af.num_channels == 1 + if any(ext in audio_filename for ext in EXPECT_LENGTH_TO_BE_EXACT): + assert af.frames == int(samplerate * EXPECTED_DURATION_SECONDS) + else: + assert af.frames >= int(samplerate * EXPECTED_DURATION_SECONDS) + + samples = af.read(int(samplerate * EXPECTED_DURATION_SECONDS)) + assert samples.shape == (1, int(samplerate * EXPECTED_DURATION_SECONDS)) + + # File should no longer be useful: + af.read(1).nbytes == 0 + + # Seeking back to the start of the file should work: + assert af.seekable() + af.seek(0) + samples = af.read(int(samplerate * EXPECTED_DURATION_SECONDS)) + assert samples.shape == (1, int(samplerate * EXPECTED_DURATION_SECONDS)) + + # Seeking to an arbitrary point should also work + af.seek(int(samplerate * EXPECTED_DURATION_SECONDS / 2)) + samples = af.read(int(samplerate * EXPECTED_DURATION_SECONDS / 2)) + assert samples.shape == (1, int(samplerate * EXPECTED_DURATION_SECONDS / 2)) + + assert f"samplerate={int(af.samplerate)}" in repr(af) + assert f"num_channels={af.num_channels}" in repr(af) + assert f"file_dtype={af.file_dtype}" in repr(af) + + af.seek(0) + actual = af.read(af.frames) + expected = generate_sine_at( + af.samplerate, num_channels=af.num_channels, num_seconds=af.duration + ) + # Crop the ends of the file, as lossy formats sometimes don't encode the whole file: + actual = actual[:, : len(expected)] + tolerance = get_tolerance_for_format_and_bit_depth( + audio_filename.split(".")[-1], np.int16, af.file_dtype + ) + np.testing.assert_allclose(np.squeeze(expected), np.squeeze(actual), atol=tolerance) + + af.close() + + with pytest.raises(RuntimeError): + af.num_channels + + with pytest.raises(RuntimeError): + af.read(1) + + +@pytest.mark.parametrize("audio_filename,samplerate", FILENAMES_AND_SAMPLERATES) +def test_read_raw(audio_filename: str, samplerate: float): + with pedalboard.io.AudioFile(audio_filename) as af: + num_samples = int(samplerate * EXPECTED_DURATION_SECONDS) + raw_samples = af.read_raw(num_samples) + assert raw_samples.shape == (1, num_samples) + assert af.file_dtype in str(raw_samples.dtype) + + +@pytest.mark.parametrize("audio_filename,samplerate", FILENAMES_AND_SAMPLERATES) +def test_use_reader_as_context_manager(audio_filename: str, samplerate: float): + with pedalboard.io.AudioFile(audio_filename) as af: + assert af.samplerate == samplerate + assert af.num_channels == 1 + if any(ext in audio_filename for ext in EXPECT_LENGTH_TO_BE_EXACT): + assert af.frames == int(samplerate * EXPECTED_DURATION_SECONDS) + else: + assert af.frames >= int(samplerate * EXPECTED_DURATION_SECONDS) + + samples = af.read(int(samplerate * EXPECTED_DURATION_SECONDS)) + assert samples.shape == (1, int(samplerate * EXPECTED_DURATION_SECONDS)) + + # File should no longer be useful: + af.read(1).nbytes == 0 + + # Seeking back to the start of the file should work: + assert af.seekable() + af.seek(0) + samples = af.read(int(samplerate * EXPECTED_DURATION_SECONDS)) + assert samples.shape == (1, int(samplerate * EXPECTED_DURATION_SECONDS)) + + # Seeking to an arbitrary point should also work + af.seek(int(samplerate * EXPECTED_DURATION_SECONDS / 2)) + samples = af.read(int(samplerate * EXPECTED_DURATION_SECONDS / 2)) + assert samples.shape == (1, int(samplerate * EXPECTED_DURATION_SECONDS / 2)) + + assert f"samplerate={int(af.samplerate)}" in repr(af) + assert f"num_channels={af.num_channels}" in repr(af) + + with pytest.raises(RuntimeError): + af.num_channels + + with pytest.raises(RuntimeError): + af.read(1) + + +def test_context_manager_allows_exceptions(): + with pytest.raises(AssertionError): + with pedalboard.io.AudioFile(FILENAMES_AND_SAMPLERATES[0][0]) as af: + assert False + + assert af.closed + + +@pytest.mark.parametrize("audio_filename,samplerate", FILENAMES_AND_SAMPLERATES) +def test_read_okay_without_extension( + tmp_path: pathlib.Path, audio_filename: str, samplerate: float +): + dest_path = str(tmp_path / "no_extension") + shutil.copyfile(audio_filename, dest_path) + try: + with pedalboard.io.AudioFile(dest_path) as af: + assert af.samplerate == samplerate + assert af.num_channels == 1 + except Exception: + if ".mp3" in audio_filename: + # Skip this test - due to a bug in JUCE's MP3 reader on Linux/Windows, + # we throw an exception when trying to read MP3 files without a known + # extension. + pass + else: + raise + + +@pytest.mark.parametrize("audio_filename,samplerate", FILENAMES_AND_SAMPLERATES) +def test_read_from_seekable_stream(audio_filename: str, samplerate: float): + with open(audio_filename, "rb") as f: + stream = io.BytesIO(f.read()) + + try: + af = pedalboard.io.AudioFile(stream) + except Exception: + if ".mp3" in audio_filename: + # Skip this test - due to a bug in JUCE's MP3 reader on Linux/Windows, + # we throw an exception when trying to read MP3 files without a known + # extension. + return + else: + raise + + with af: + assert af.samplerate == samplerate + assert af.num_channels == 1 + if any(ext in audio_filename for ext in EXPECT_LENGTH_TO_BE_EXACT): + assert af.frames == int(samplerate * EXPECTED_DURATION_SECONDS) + else: + assert af.frames >= int(samplerate * EXPECTED_DURATION_SECONDS) + + samples = af.read(int(samplerate * EXPECTED_DURATION_SECONDS)) + assert samples.shape == (1, int(samplerate * EXPECTED_DURATION_SECONDS)) + + # File should no longer be useful: + af.read(1).nbytes == 0 + + # Seeking back to the start of the file should work: + assert af.seekable() + af.seek(0) + samples = af.read(int(samplerate * EXPECTED_DURATION_SECONDS)) + assert samples.shape == (1, int(samplerate * EXPECTED_DURATION_SECONDS)) + + # Seeking to an arbitrary point should also work + af.seek(int(samplerate * EXPECTED_DURATION_SECONDS / 2)) + samples = af.read(int(samplerate * EXPECTED_DURATION_SECONDS / 2)) + assert samples.shape == (1, int(samplerate * EXPECTED_DURATION_SECONDS / 2)) + + assert f"samplerate={int(af.samplerate)}" in repr(af) + assert f"num_channels={af.num_channels}" in repr(af) + assert repr(stream) in repr(af) + + with pytest.raises(RuntimeError): + af.num_channels + + with pytest.raises(RuntimeError): + af.read(1) + + +@pytest.mark.parametrize( + "mp3_filename", + [f for f in sum(TEST_AUDIO_FILES.values(), []) if f.endswith("mp3")], +) +def test_read_mp3_from_named_stream(mp3_filename: str): + with pedalboard.io.AudioFile(open(mp3_filename, "rb")) as af: + assert af is not None + + +def test_file_like_exceptions_propagate(): + audio_filename = FILENAMES_AND_SAMPLERATES[0][0] + stream = open(audio_filename, "rb") + stream_read = stream.read + + should_throw = [False] + + def eventually_throw_exception(*args, **kwargs): + if should_throw[0]: + raise ValueError("Some kinda error!") + return stream_read(*args, **kwargs) + + stream.read = eventually_throw_exception + + with pedalboard.io.AudioFile(stream) as af: + assert af.read(1).nbytes > 0 + should_throw[0] = True + with pytest.raises(ValueError) as e: + for _ in range(af.frames - 1): + af.read(1) + assert "Some kinda error!" in str(e) + + +def test_file_like_must_be_seekable(): + audio_filename = FILENAMES_AND_SAMPLERATES[0][0] + + with open(audio_filename, "rb") as f: + stream = io.BytesIO(f.read()) + stream.seekable = lambda: False + + with pytest.raises(ValueError) as e: + with pedalboard.io.AudioFile(stream): + pass + + assert "seekable" in str(e) + + +def test_no_crash_if_type_error_on_file_like(): + audio_filename = FILENAMES_AND_SAMPLERATES[0][0] + + with open(audio_filename, "rb") as f: + stream = io.BytesIO(f.read()) + + # Seekable should be a method, not a property: + stream.seekable = False + + with pytest.raises(TypeError) as e: + with pedalboard.io.AudioFile(stream): + pass + + assert "bool" in str(e) + + +def test_write_fails_without_extension(tmp_path: pathlib.Path): + dest_path = str(tmp_path / "no_extension") + with pytest.raises(ValueError): + pedalboard.io.AudioFile(dest_path, "w", 44100, 1) + + +@pytest.mark.parametrize("extension", pedalboard.io.get_supported_write_formats()) +def test_write_to_stream_supports_format(extension: str): + assert pedalboard.io.AudioFile(io.BytesIO(), "w", 44100, 2, format=extension) is not None + assert pedalboard.io.AudioFile(io.BytesIO(), "w", 44100, 2, format=extension[1:]) is not None + assert pedalboard.io.WriteableAudioFile(io.BytesIO(), 44100, 2, format=extension) is not None + assert ( + pedalboard.io.WriteableAudioFile(io.BytesIO(), 44100, 2, format=extension[1:]) is not None + ) + + with pytest.raises(ValueError): + pedalboard.io.AudioFile(io.BytesIO(), "w", 44100, 2, format="txt") + + +@pytest.mark.parametrize("extension", pedalboard.io.get_supported_write_formats()) +def test_write_to_stream_prefers_format_over_stream_name(extension: str): + stream = io.BytesIO() + stream.name = "foo.txt" + assert pedalboard.io.AudioFile(stream, "w", 44100, 2, format=extension) is not None + assert pedalboard.io.AudioFile(stream, "w", 44100, 2, format=extension[1:]) is not None + assert pedalboard.io.WriteableAudioFile(stream, 44100, 2, format=extension) is not None + assert pedalboard.io.WriteableAudioFile(stream, 44100, 2, format=extension[1:]) is not None + + with pytest.raises(ValueError): + pedalboard.io.AudioFile(stream, "w", 44100, 2) + + +@pytest.mark.parametrize("extension", pedalboard.io.get_supported_write_formats()) +def test_read_from_non_bytes_stream(extension: str): + stream = io.StringIO() + stream.name = f"foo{extension}" + + with pytest.raises(TypeError) as e: + pedalboard.io.AudioFile(stream, "r") + + assert "expected to return bytes" in str(e) + assert "returned str" in str(e) + + with pytest.raises(TypeError) as e: + pedalboard.io.ReadableAudioFile(stream) + + assert "expected to return bytes" in str(e) + assert "returned str" in str(e) + + +@pytest.mark.parametrize("extension", pedalboard.io.get_supported_write_formats()) +def test_write_to_non_bytes_stream(extension: str): + stream = io.StringIO() + + try: + stream.write(b"") + except TypeError as e: + expected_message = e.args[0] + + with pytest.raises(TypeError) as e: + pedalboard.io.AudioFile(stream, "w", 44100, 2, format=extension) + + assert expected_message in str(e) + + with pytest.raises(TypeError) as e: + pedalboard.io.WriteableAudioFile(stream, 44100, 2, format=extension) + + assert expected_message in str(e) + + +def test_fails_gracefully(): + with pytest.raises(ValueError): + pedalboard.io.AudioFile(__file__) + + with pytest.raises(ValueError): + with pedalboard.io.AudioFile(__file__): + pass + + +@pytest.mark.parametrize("audio_filename", UNSUPPORTED_FILENAMES) +def test_fails_on_unsupported_format(audio_filename: str): + with pytest.raises(ValueError): + af = pedalboard.io.AudioFile(audio_filename) + assert not af + + +@pytest.mark.parametrize("extension", pedalboard.io.get_supported_write_formats()) +@pytest.mark.parametrize( + "samplerate", [8000, 11025, 12000, 16000, 22050, 32000, 44100, 48000, 88200, 96000] +) +@pytest.mark.parametrize("num_channels", [1, 2, 3]) +@pytest.mark.parametrize("transposed", [False, True]) +@pytest.mark.parametrize("input_format", [np.float32, np.float64, np.int8, np.int16, np.int32]) +def test_basic_write( + tmp_path: pathlib.Path, + extension: str, + samplerate: float, + num_channels: int, + transposed: bool, + input_format, +): + filename = str(tmp_path / f"test{extension}") + original_audio = generate_sine_at(samplerate, num_channels=num_channels) + + write_bit_depth = 16 + + # Not all formats support full 32-bit depth: + if extension in {".wav"} and np.issubdtype(input_format, np.signedinteger): + write_bit_depth = np.dtype(input_format).itemsize * 8 + + # Handle integer audio types by scaling the floating-point data to the full integer range: + if np.issubdtype(input_format, np.signedinteger): + _max = np.iinfo(input_format).max + audio = (original_audio * _max).astype(input_format) + else: + _max = 1.0 + audio = original_audio.astype(input_format) + + # Before writing, assert that the data we're about to write is what we expect: + tolerance = get_tolerance_for_format_and_bit_depth(".wav", input_format, "int16") + np.testing.assert_allclose(original_audio, audio.astype(np.float32) / _max, atol=tolerance) + + num_samples = audio.shape[-1] + + with pedalboard.io.WriteableAudioFile( + filename, + samplerate=samplerate, + num_channels=num_channels, + bit_depth=write_bit_depth, + ) as af: + if transposed: + af.write(audio.T) + else: + af.write(audio) + + assert os.path.exists(filename) + assert os.path.getsize(filename) > 0 + with pedalboard.io.ReadableAudioFile(filename) as af: + assert af.samplerate == samplerate + assert af.num_channels == num_channels + tolerance = get_tolerance_for_format_and_bit_depth(extension, input_format, af.file_dtype) + as_written = af.read(num_samples) + np.testing.assert_allclose(original_audio, np.squeeze(as_written), atol=tolerance) + + +def test_write_exact_int32_to_16_bit_wav(tmp_path: pathlib.Path): + filename = str(tmp_path / "test.wav") + original = np.array([1, 2, 3, -1, -2, -3]).astype(np.int32) + signal = (original << 16).astype(np.int32) + + with pedalboard.io.WriteableAudioFile(filename, samplerate=1) as af: + af.write(signal) + + assert os.path.exists(filename) + + # Read the exact wave values out with the `wave` package: + with wave.open(filename) as f: + assert f.getsampwidth() == 2 + encoded = np.frombuffer(f.readframes(len(signal)), dtype=np.int16) + np.testing.assert_allclose(encoded, original) + + +def test_read_16_bit_wav_matches_stdlib(tmp_path: pathlib.Path): + filename = str(tmp_path / "test-16bit.wav") + original = np.array([1, 2, 3, -1, -2, -3]).astype(np.int16) + signal = original.astype(np.int32) << 16 + + with pedalboard.io.WriteableAudioFile(filename, samplerate=1, bit_depth=16) as af: + af.write(signal) + + # Read the exact wave values out with the `wave` package: + with wave.open(filename) as f: + assert f.getsampwidth() == 2 + stdlib_result = np.frombuffer(f.readframes(len(signal)), dtype=np.int16) + np.testing.assert_allclose(stdlib_result, original) + + float_signal = original / np.iinfo(np.int16).max + with pedalboard.io.AudioFile(filename) as af: + np.testing.assert_allclose(float_signal, af.read(af.frames)[0]) + + +def test_basic_write_int32_to_16_bit_wav(tmp_path: pathlib.Path): + samplerate = 44100 + num_channels = 1 + filename = str(tmp_path / "test.wav") + original = np.linspace(0, 1, 11) + + # As per AES17: the integer value -(2^31) should never show up in the stream. + signal = (original * (2 ** 31 - 1)).astype(np.int32) + + with pedalboard.io.WriteableAudioFile( + filename, + samplerate=samplerate, + num_channels=num_channels, + bit_depth=16, + ) as af: + af.write(signal) + + # Read the exact wave values out with the `wave` package: + with wave.open(filename) as f: + assert f.getsampwidth() == 2 + stdlib_result = np.frombuffer(f.readframes(len(signal)), dtype=np.int16) + assert np.all(np.equal(stdlib_result, signal >> 16)) + + with pedalboard.io.ReadableAudioFile(filename) as af: + as_written = af.read(len(signal))[0] + np.testing.assert_allclose(original, as_written, atol=2 / (2 ** 15)) + + +@pytest.mark.parametrize("extension", pedalboard.io.get_supported_write_formats()) +@pytest.mark.parametrize( + "samplerate", [8000, 11025, 12000, 16000, 22050, 32000, 44100, 48000, 88200, 96000] +) +@pytest.mark.parametrize("num_channels", [1, 2, 3]) +@pytest.mark.parametrize("transposed", [False, True]) +@pytest.mark.parametrize("input_format", [np.float32, np.float64, np.int8, np.int16, np.int32]) +def test_write_to_seekable_stream( + extension: str, samplerate: float, num_channels: int, transposed: bool, input_format +): + original_audio = generate_sine_at(samplerate, num_channels=num_channels) + + write_bit_depth = 16 + + # Not all formats support full 32-bit depth: + if extension in {".wav"} and np.issubdtype(input_format, np.signedinteger): + write_bit_depth = np.dtype(input_format).itemsize * 8 + + # Handle integer audio types by scaling the floating-point data to the full integer range: + if np.issubdtype(input_format, np.signedinteger): + _max = np.iinfo(input_format).max + audio = (original_audio * _max).astype(input_format) + else: + _max = 1.0 + audio = original_audio.astype(input_format) + + # Before writing, assert that the data we're about to write is what we expect: + tolerance = get_tolerance_for_format_and_bit_depth(".wav", input_format, "int16") + np.testing.assert_allclose(original_audio, audio.astype(np.float32) / _max, atol=tolerance) + + num_samples = audio.shape[-1] + + stream = io.BytesIO() + stream.name = f"my_file{extension}" + + with pedalboard.io.WriteableAudioFile( + stream, + samplerate=samplerate, + num_channels=num_channels, + bit_depth=write_bit_depth, + ) as af: + if transposed: + af.write(audio.T) + else: + af.write(audio) + + assert stream.tell() > 0 + stream.seek(0) + + with pedalboard.io.AudioFile(stream) as af: + assert af.samplerate == samplerate + assert af.num_channels == num_channels + tolerance = get_tolerance_for_format_and_bit_depth(extension, input_format, af.file_dtype) + as_written = af.read(num_samples) + np.testing.assert_allclose(original_audio, np.squeeze(as_written), atol=tolerance) + + +@pytest.mark.parametrize("extension", pedalboard.io.get_supported_write_formats()) +@pytest.mark.parametrize("samplerate", [1234.5, 23.0000000001]) +def test_fractional_sample_rates(tmp_path: pathlib.Path, extension: str, samplerate): + filename = str(tmp_path / f"test{extension}") + with pytest.raises(ValueError): + pedalboard.io.WriteableAudioFile(filename, samplerate=samplerate, num_channels=1) + + +@pytest.mark.parametrize("extension", pedalboard.io.get_supported_write_formats()) +@pytest.mark.parametrize("samplerate", [123, 999, 48001]) +def test_uncommon_sample_rates(tmp_path: pathlib.Path, extension: str, samplerate): + filename = str(tmp_path / f"test{extension}") + with pedalboard.io.WriteableAudioFile(filename, samplerate=samplerate, num_channels=1): + pass + with pedalboard.io.ReadableAudioFile(filename) as af: + assert af.samplerate == samplerate + + +@pytest.mark.parametrize("extension", [".flac"]) +@pytest.mark.parametrize("samplerate", [123456, 234567]) +def test_unusable_sample_rates(tmp_path: pathlib.Path, extension: str, samplerate): + filename = str(tmp_path / f"test{extension}") + with pytest.raises(ValueError) as e: + pedalboard.io.WriteableAudioFile(filename, samplerate=samplerate, num_channels=1) + assert "44100" in str(e), "Expected exception to include details about supported sample rates." + + +@pytest.mark.parametrize("dtype", [np.uint8, np.uint16, np.uint32, np.uint64]) +def test_fail_to_write_unsigned(tmp_path: pathlib.Path, dtype): + filename = str(tmp_path / "test.wav") + with pytest.raises(TypeError): + with pedalboard.io.WriteableAudioFile(filename, samplerate=44100) as af: + af.write(np.array([1, 2, 3, 4], dtype=dtype)) + + +@pytest.mark.parametrize( + "extension,quality,expected", + [ + ("ogg", "64 kbps", "64 kbps"), + ("ogg", "80 kbps", "80 kbps"), + ("ogg", "96 kbps", "96 kbps"), + ("ogg", "112 kbps", "112 kbps"), + ("ogg", "128 kbps", "128 kbps"), + ("ogg", "160 kbps", "160 kbps"), + ("ogg", "192 kbps", "192 kbps"), + ("ogg", "224 kbps", "224 kbps"), + ("ogg", "256 kbps", "256 kbps"), + ("ogg", "320 kbps", "320 kbps"), + ("ogg", "500 kbps", "500 kbps"), + ("ogg", 64, "64 kbps"), + ("ogg", 80, "80 kbps"), + ("ogg", 96, "96 kbps"), + ("ogg", 112, "112 kbps"), + ("ogg", 128, "128 kbps"), + ("ogg", 160, "160 kbps"), + ("ogg", 192, "192 kbps"), + ("ogg", 224, "224 kbps"), + ("ogg", 256, "256 kbps"), + ("ogg", 320, "320 kbps"), + ("ogg", 500, "500 kbps"), + ("ogg", 64.0, "64 kbps"), + ("ogg", 80.0, "80 kbps"), + ("ogg", 96.0, "96 kbps"), + ("ogg", 112.0, "112 kbps"), + ("ogg", 128.0, "128 kbps"), + ("ogg", 160.0, "160 kbps"), + ("ogg", 192.0, "192 kbps"), + ("ogg", 224.0, "224 kbps"), + ("ogg", 256.0, "256 kbps"), + ("ogg", 320.0, "320 kbps"), + ("ogg", 500.0, "500 kbps"), + ("ogg", "64", "64 kbps"), + ("ogg", "80", "80 kbps"), + ("ogg", "96", "96 kbps"), + ("ogg", "112", "112 kbps"), + ("ogg", "128", "128 kbps"), + ("ogg", "160", "160 kbps"), + ("ogg", "192", "192 kbps"), + ("ogg", "224", "224 kbps"), + ("ogg", "256", "256 kbps"), + ("ogg", "320", "320 kbps"), + ("ogg", "500", "500 kbps"), + ("ogg", " 500 ", "500 kbps"), + ("ogg", "", "500 kbps"), + ("ogg", " ", "500 kbps"), + ("ogg", None, "500 kbps"), + ("flac", "0 (Fastest)", "0 (Fastest)"), + ("flac", "0", "0 (Fastest)"), + ("flac", "fastest", "0 (Fastest)"), + ("flac", "1", "1"), + ("flac", "2", "2"), + ("flac", "3", "3"), + ("flac", "4", "4"), + ("flac", "default", "5 (Default)"), + ("flac", "5 (Default)", "5 (Default)"), + ("flac", "5", "5 (Default)"), + ("flac", "6", "6"), + ("flac", "7", "7"), + ("flac", "8 (Highest quality)", "8 (Highest quality)"), + ("flac", "8", "8 (Highest quality)"), + ("flac", "", "8 (Highest quality)"), + ("flac", " ", "8 (Highest quality)"), + ("flac", None, "8 (Highest quality)"), + ("flac", "high", "8 (Highest quality)"), + ("flac", 0, "0 (Fastest)"), + ("flac", 1, "1"), + ("flac", 2, "2"), + ("flac", 3, "3"), + ("flac", 4, "4"), + ("flac", 5, "5 (Default)"), + ("flac", 6, "6"), + ("flac", 7, "7"), + ("flac", 8, "8 (Highest quality)"), + ("wav", None, None), + ("wav", "", None), + ("aiff", None, None), + ("aiff", "", None), + ], +) +def test_write_quality(tmp_path: pathlib.Path, extension: str, quality, expected: Optional[str]): + filename = str(tmp_path / f"test.{extension}") + with pedalboard.io.WriteableAudioFile(filename, samplerate=44100, quality=quality) as af: + assert af.quality == expected + + +@pytest.mark.parametrize( + "extension,quality", + [ + ("ogg", "63 kbps"), + ("ogg", 63), + ("ogg", 63.5), + ("ogg", -500), + ("flac", "slowest"), + ("flac", 11), + ("flac", -1), + ("wav", "128"), + ("wav", 128), + ("aiff", "128"), + ("aiff", 128), + ], +) +def test_bad_write_quality(tmp_path: pathlib.Path, extension: str, quality): + filename = str(tmp_path / f"test.{extension}") + with pytest.raises(ValueError): + pedalboard.io.WriteableAudioFile(filename, samplerate=44100, quality=quality) + + +@pytest.mark.skipif( + platform.system() == "Windows", + reason="Windows file handling behaves differently, for some reason", +) +def test_file_not_created_if_constructor_error_thrown(tmp_path: pathlib.Path): + filename = str(tmp_path / "test.wav") + assert not os.path.exists(filename) + with pytest.raises(ValueError): + pedalboard.io.WriteableAudioFile(filename, samplerate=44100, quality="break") + assert not os.path.exists(filename) diff --git a/tests/utils.py b/tests/utils.py index 55ba88b35..61cc6e684 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -18,8 +18,8 @@ def generate_sine_at( fade_duration = int(sample_rate * 0.1) sine_wave[:fade_duration] *= np.linspace(0, 1, fade_duration) sine_wave[-fade_duration:] *= np.linspace(1, 0, fade_duration) - if num_channels == 2: - TEST_SINE_WAVE_CACHE[cache_key] = np.stack([sine_wave, sine_wave]) + if num_channels != 1: + TEST_SINE_WAVE_CACHE[cache_key] = np.stack([sine_wave] * num_channels) else: TEST_SINE_WAVE_CACHE[cache_key] = sine_wave return TEST_SINE_WAVE_CACHE[cache_key]