diff --git a/README.md b/README.md
index 953aef30a..c6b69fbc8 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@


-`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]