Skip to content

Commit

Permalink
Add pedalboard.io.AudioStream support for Linux (#368)
Browse files Browse the repository at this point in the history
* pedalboard.io: Add Linux support for AudioStream class

Problem

AudioStream is currently not supported on Linux due to macro definitions
in the build script that disable AudioStream functionalities

Solution

Add the JUCE_MODULE_AVAILABLE_juce_audio_devices macro to ALL_CPPFLAGS,
add link flag with the alsa-lib JUCE dependency for interacting with sound
devices in Linux, fix audioDeviceIOCallback to support the live audio playback
feature

Result

AudioStream is now supported on Linux as well as Windows and MacOS

* Fix reverb example to use len instead of .frames for SoundFile class

* Add example for audio monitoring with Pedalboard effects

* Fix formatting

* Add alsa-lib package in wheel builder for static linking

* Add libasound2-dev dependency for the pre-build on ubuntu-20.04

* Add libasound2-dev dependency to Linux actions

* Comment out "delete existing cache" step.

* Include Linux in AudioStream tests, remove create_stream_fails_on_linux test

* Update test_audio_stream.py

* Update test_audio_stream.py

* Update test_audio_stream.py

* Add step to remove libasound before running tests.

* Update all.yml

* Add empty string handling in AudioStream constructor

* Add snd-dummy kernel module for testing AudioStream on linux

* Remove uninstallation of libasound

* Handle None audio devices.

* Is the default device name empty?

* Return None for an audio device name if the device name is the empty string.

---------

Co-authored-by: Peter Sobot <psobot@gmail.com>
Co-authored-by: Peter Sobot <psobot@spotify.com>
  • Loading branch information
3 people authored Aug 23, 2024
1 parent 372741b commit 0ba5f9e
Show file tree
Hide file tree
Showing 8 changed files with 95 additions and 56 deletions.
20 changes: 15 additions & 5 deletions .github/workflows/all.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@ jobs:
&& sudo apt-get install -y pkg-config libsndfile1 \
libx11-dev libxrandr-dev libxinerama-dev \
libxrender-dev libxcomposite-dev libxcb-xinerama0-dev \
libxcursor-dev libfreetype6 libfreetype6-dev
libxcursor-dev libfreetype6 libfreetype6-dev \
libasound2-dev
# We depend on ccache features that are only present in 4.8.0 and later, but installing from apt-get gives us v3.
- name: Install ccache on Linux
if: runner.os == 'Linux'
Expand Down Expand Up @@ -257,7 +258,8 @@ jobs:
&& sudo apt-get install -y pkg-config libsndfile1 \
libx11-dev libxrandr-dev libxinerama-dev \
libxrender-dev libxcomposite-dev libxcb-xinerama0-dev \
libxcursor-dev libfreetype6 libfreetype6-dev
libxcursor-dev libfreetype6 libfreetype6-dev \
libasound2-dev
# We depend on ccache features that are only present in 4.8.0 and later, but installing from apt-get gives us v3.
- name: Install ccache on Linux
if: runner.os == 'Linux'
Expand Down Expand Up @@ -363,7 +365,8 @@ jobs:
&& sudo apt-get install -y pkg-config libsndfile1 \
libx11-dev libxrandr-dev libxinerama-dev \
libxrender-dev libxcomposite-dev libxcb-xinerama0-dev \
libxcursor-dev libfreetype6 libfreetype6-dev
libxcursor-dev libfreetype6 libfreetype6-dev \
libasound2-dev
# We depend on ccache features that are only present in 4.8.0 and later, but installing from apt-get gives us v3.
- name: Install ccache on Linux
if: runner.os == 'Linux'
Expand Down Expand Up @@ -431,6 +434,11 @@ jobs:
GCS_ASSET_BUCKET_NAME: ${{ secrets.GCS_ASSET_BUCKET_NAME }}
GCS_READER_SERVICE_ACCOUNT_KEY: ${{ secrets.GCS_READER_SERVICE_ACCOUNT_KEY }}
run: python ./tests/download_test_plugins.py
- name: Setup dummy soundcard for testing
if: runner.os == 'Linux'
run: |
sudo apt-get install -y linux-modules-extra-$(uname -r)
sudo modprobe snd-dummy
- name: Run tests
env:
TEST_WORKER_INDEX: ${{ matrix.runner_index }}
Expand Down Expand Up @@ -482,7 +490,8 @@ jobs:
&& sudo apt-get install -y pkg-config libsndfile1 \
libx11-dev libxrandr-dev libxinerama-dev \
libxrender-dev libxcomposite-dev libxcb-xinerama0-dev \
libxcursor-dev libfreetype6 libfreetype6-dev
libxcursor-dev libfreetype6 libfreetype6-dev \
libasound2-dev
# We depend on ccache features that are only present in 4.8.0 and later, but installing from apt-get gives us v3.
- name: Install ccache on Linux
if: runner.os == 'Linux'
Expand Down Expand Up @@ -613,7 +622,8 @@ jobs:
&& sudo apt-get install -y pkg-config libsndfile1 \
libx11-dev libxrandr-dev libxinerama-dev \
libxrender-dev libxcomposite-dev libxcb-xinerama0-dev \
libxcursor-dev libfreetype6 libfreetype6-dev
libxcursor-dev libfreetype6 libfreetype6-dev \
libasound2-dev
# We depend on ccache features that are only present in 4.8.0 and later, but installing from apt-get gives us v3.
- name: Install ccache on Linux
if: runner.os == 'Linux'
Expand Down
4 changes: 2 additions & 2 deletions examples/add_reverb_to_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
def get_num_frames(f: sf.SoundFile) -> int:
# On some platforms and formats, f.frames == -1L.
# Check for this bug and work around it:
if f.frames > 2 ** 32:
if len(f) > 2 ** 32:
f.seek(0)
last_position = f.tell()
while True:
Expand All @@ -45,7 +45,7 @@ def get_num_frames(f: sf.SoundFile) -> int:
else:
last_position = new_position
else:
return f.frames
return len(f)


def main():
Expand Down
21 changes: 21 additions & 0 deletions examples/audio_monitoring_with_effects.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from pedalboard import Pedalboard, Compressor, Gain, Reverb
from pedalboard.io import AudioStream

# Open up an audio stream:
stream = AudioStream(
input_device_name=AudioStream.input_device_names[0],
output_device_name=AudioStream.output_device_names[0],
num_input_channels=2,
num_output_channels=2,
allow_feedback=True,
buffer_size=128,
sample_rate=44100,
)

stream.plugins = Pedalboard([
Reverb(wet_level=0.2),
Gain(1.0),
Compressor(),
])

stream.run()
7 changes: 2 additions & 5 deletions pedalboard/JuceHeader.h
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,12 @@
#pragma once

#include <juce_audio_basics/juce_audio_basics.h>
#include <juce_audio_devices/juce_audio_devices.h>
#include <juce_audio_formats/juce_audio_formats.h>
#include <juce_audio_processors/juce_audio_processors.h>
#include <juce_core/juce_core.h>
#include <juce_data_structures/juce_data_structures.h>
#include <juce_dsp/juce_dsp.h>
#include <juce_events/juce_events.h>
#include <juce_graphics/juce_graphics.h>
#include <juce_gui_basics/juce_gui_basics.h>

#ifndef JUCE_LINUX
#include <juce_audio_devices/juce_audio_devices.h>
#endif
#include <juce_gui_basics/juce_gui_basics.h>
39 changes: 24 additions & 15 deletions pedalboard/io/AudioStream.h
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,10 @@ class AudioStream : public std::enable_shared_from_this<AudioStream>
"`allow_feedback=True` to the AudioStream constructor.");
}

if (!inputDeviceName && !outputDeviceName) {
if ((!inputDeviceName ||
(inputDeviceName.has_value() && inputDeviceName.value().empty())) &&
(!outputDeviceName ||
(outputDeviceName.has_value() && outputDeviceName.value().empty()))) {
throw std::runtime_error("At least one of `input_device_name` or "
"`output_device_name` must be provided.");
}
Expand Down Expand Up @@ -275,10 +278,14 @@ class AudioStream : public std::enable_shared_from_this<AudioStream>
if (auto *type = deviceManager.getCurrentDeviceTypeObject()) {
const auto info = getSetupInfo(setup, isInput);

if (numChannelsNeeded > 0 && info.name.isEmpty())
return {
if (numChannelsNeeded > 0 && info.name.isEmpty()) {
std::string deviceName =
type->getDeviceNames(isInput)[type->getDefaultDeviceIndex(isInput)]
.toStdString()};
.toStdString();
if (!deviceName.empty()) {
return {deviceName};
}
}
}
#endif
return {};
Expand All @@ -290,7 +297,7 @@ class AudioStream : public std::enable_shared_from_this<AudioStream>
float **outputChannelData,
int numOutputChannels, int numSamples) {
// Live processing mode: run the input audio through a Pedalboard object.
if (!playBufferFifo && !recordBufferFifo) {
if (playBufferFifo && recordBufferFifo) {
for (int i = 0; i < numOutputChannels; i++) {
const float *inputChannel = inputChannelData[i % numInputChannels];
std::memcpy((char *)outputChannelData[i], (char *)inputChannel,
Expand All @@ -314,9 +321,7 @@ class AudioStream : public std::enable_shared_from_this<AudioStream>
}
}
}
}

if (recordBufferFifo) {
} else if (recordBufferFifo) {
// If Python wants audio input, then copy the audio into the record
// buffer:
for (int attempt = 0; attempt < 2; attempt++) {
Expand Down Expand Up @@ -356,13 +361,12 @@ class AudioStream : public std::enable_shared_from_this<AudioStream>
break;
}
}
}

for (int i = 0; i < numOutputChannels; i++) {
std::memset((char *)outputChannelData[i], 0, numSamples * sizeof(float));
}
} else if (playBufferFifo) {
for (int i = 0; i < numOutputChannels; i++) {
std::memset((char *)outputChannelData[i], 0,
numSamples * sizeof(float));
}

if (playBufferFifo) {
const auto scope = playBufferFifo->read(numSamples);

if (scope.blockSize1 > 0)
Expand All @@ -378,6 +382,11 @@ class AudioStream : public std::enable_shared_from_this<AudioStream>
(char *)playBuffer->getReadPointer(i, scope.startIndex2),
scope.blockSize2 * sizeof(float));
}
} else {
for (int i = 0; i < numOutputChannels; i++) {
std::memset((char *)outputChannelData[i], 0,
numSamples * sizeof(float));
}
}
}

Expand Down Expand Up @@ -832,7 +841,7 @@ Or use :py:meth:`AudioStream.write` to stream audio in chunks::
#ifdef JUCE_MODULE_AVAILABLE_juce_audio_devices
return stream.getAudioDeviceSetup().bufferSize;
#else
return 0;
return 0;
#endif
},
"The size (in frames) of the buffer used between the audio "
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@ build-backend = "setuptools.build_meta"

# See: https://cibuildwheel.readthedocs.io/en/stable/options/#examples
[tool.cibuildwheel.linux]
before-all = "yum install -y libsndfile libX11-devel libXrandr-devel libXinerama-devel libXrender-devel libXcomposite-devel libXinerama-devel libXcursor-devel freetype-devel"
before-all = "yum install -y libsndfile libX11-devel libXrandr-devel libXinerama-devel libXrender-devel libXcomposite-devel libXinerama-devel libXcursor-devel freetype-devel alsa-lib-devel"

[[tool.cibuildwheel.overrides]]
# Use apk instead of yum when building on Alpine Linux
# (Note: this is experimental, as most VSTs require glibc and thus Alpine Linux isn't that useful)
select = "*-musllinux*"
before-all = "apk add libsndfile libx11-dev libxrandr-dev libxinerama-dev libxrender-dev libxcomposite-dev libxinerama-dev libxcursor-dev freetype-dev"
before-all = "apk add libsndfile libx11-dev libxrandr-dev libxinerama-dev libxrender-dev libxcomposite-dev libxinerama-dev libxcursor-dev freetype-dev libexecinfo-dev alsa-lib-dev"
4 changes: 2 additions & 2 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"-DJUCE_MODULE_AVAILABLE_juce_graphics=1",
"-DJUCE_MODULE_AVAILABLE_juce_gui_basics=1",
"-DJUCE_MODULE_AVAILABLE_juce_gui_extra=1",
"-DJUCE_MODULE_AVAILABLE_juce_audio_devices=1",
"-DJUCE_GLOBAL_MODULE_SETTINGS_INCLUDED=1",
"-DJUCE_STRICT_REFCOUNTEDPOINTER=1",
"-DJUCE_STANDALONE_APPLICATION=1",
Expand Down Expand Up @@ -260,7 +261,6 @@ def ignore_files_matching(files, *matches):
ALL_CPPFLAGS.append("-flto=thin")
ALL_LINK_ARGS.append("-flto=thin")
ALL_LINK_ARGS.append("-fvisibility=hidden")
ALL_CPPFLAGS.append("-DJUCE_MODULE_AVAILABLE_juce_audio_devices=1")
ALL_CFLAGS += ["-Wno-comment"]
elif platform.system() == "Linux":
ALL_CPPFLAGS.append("-DLINUX=1")
Expand All @@ -272,7 +272,6 @@ def ignore_files_matching(files, *matches):
ALL_CFLAGS += ["-Wno-comment"]
elif platform.system() == "Windows":
ALL_CPPFLAGS.append("-DWINDOWS=1")
ALL_CPPFLAGS.append("-DJUCE_MODULE_AVAILABLE_juce_audio_devices=1")
else:
raise NotImplementedError(
"Not sure how to build JUCE on platform: {}!".format(platform.system())
Expand Down Expand Up @@ -356,6 +355,7 @@ def ignore_files_matching(files, *matches):
include_paths = [flag[2:] for flag in flags]
ALL_INCLUDES += include_paths
ALL_LINK_ARGS += ["-lfreetype"]
ALL_LINK_ARGS += ["-lasound"]

ALL_RESOLVED_SOURCE_PATHS = [str(p.resolve()) for p in ALL_SOURCE_PATHS]
elif platform.system() == "Windows":
Expand Down
52 changes: 27 additions & 25 deletions tests/test_audio_stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
# See the License for the specific language governing permissions and
# limitations under the License.

import platform
import time

import numpy as np
Expand All @@ -36,10 +35,9 @@


# Note: this test may do nothing on CI, because we don't have mock audio devices available.
# This will run on macOS and probably Windows as long as at least one audio device is available.
# This will run on Linux, macOS and probably Windows as long as at least one audio device is available.
@pytest.mark.parametrize("input_device_name", INPUT_DEVICE_NAMES)
@pytest.mark.parametrize("output_device_name", pedalboard.io.AudioStream.output_device_names)
@pytest.mark.skipif(platform.system() == "Linux", reason="AudioStream not supported on Linux yet.")
def test_create_stream(input_device_name: str, output_device_name: str):
try:
stream = pedalboard.io.AudioStream(
Expand Down Expand Up @@ -69,11 +67,13 @@ def test_create_stream(input_device_name: str, output_device_name: str):


# Note: this test may do nothing on CI, because we don't have mock audio devices available.
# This will run on macOS and probably Windows as long as at least one audio device is available.
@pytest.mark.skipif(platform.system() == "Linux", reason="AudioStream not supported on Linux yet.")
# This will run on Linux, macOS and probably Windows as long as at least one audio device is available.
@pytest.mark.skipif(
pedalboard.io.AudioStream.default_output_device_name == "Null Audio Device",
reason="Tests do not work with a null audio device.",
(
pedalboard.io.AudioStream.default_output_device_name == "Null Audio Device"
or pedalboard.io.AudioStream.default_output_device_name is None
),
reason="Test requires a working audio device.",
)
def test_write_to_stream():
try:
Expand All @@ -94,11 +94,13 @@ def test_write_to_stream():


# Note: this test may do nothing on CI, because we don't have mock audio devices available.
# This will run on macOS and probably Windows as long as at least one audio device is available.
@pytest.mark.skipif(platform.system() == "Linux", reason="AudioStream not supported on Linux yet.")
# This will run on Linux, macOS and probably Windows as long as at least one audio device is available.
@pytest.mark.skipif(
pedalboard.io.AudioStream.default_output_device_name == "Null Audio Device",
reason="Tests do not work with a null audio device.",
(
pedalboard.io.AudioStream.default_output_device_name == "Null Audio Device"
or pedalboard.io.AudioStream.default_output_device_name is None
),
reason="Test requires a working audio device.",
)
def test_write_to_stream_without_opening():
try:
Expand All @@ -118,11 +120,13 @@ def test_write_to_stream_without_opening():


# Note: this test may do nothing on CI, because we don't have mock audio devices available.
# This will run on macOS and probably Windows as long as at least one audio device is available.
@pytest.mark.skipif(platform.system() == "Linux", reason="AudioStream not supported on Linux yet.")
# This will run on Linux, macOS and probably Windows as long as at least one audio device is available.
@pytest.mark.skipif(
pedalboard.io.AudioStream.default_output_device_name == "Null Audio Device",
reason="Tests do not work with a null audio device.",
(
pedalboard.io.AudioStream.default_input_device_name == "Null Audio Device"
or pedalboard.io.AudioStream.default_input_device_name is None
),
reason="Test requires a working audio device.",
)
def test_read_from_stream():
try:
Expand All @@ -141,11 +145,13 @@ def test_read_from_stream():


# Note: this test may do nothing on CI, because we don't have mock audio devices available.
# This will run on macOS and probably Windows as long as at least one audio device is available.
@pytest.mark.skipif(platform.system() == "Linux", reason="AudioStream not supported on Linux yet.")
# This will run on Linux, macOS and probably Windows as long as at least one audio device is available.
@pytest.mark.skipif(
pedalboard.io.AudioStream.default_output_device_name == "Null Audio Device",
reason="Tests do not work with a null audio device.",
(
pedalboard.io.AudioStream.default_input_device_name == "Null Audio Device"
or pedalboard.io.AudioStream.default_input_device_name is None
),
reason="Test requires a working audio device.",
)
def test_read_from_stream_measures_dropped_frames():
try:
Expand All @@ -157,6 +163,8 @@ def test_read_from_stream_measures_dropped_frames():

assert stream is not None
with stream:
if stream.sample_rate == 0:
raise pytest.skip("Sample rate of default audio device is 0")
assert stream.running
assert stream.dropped_input_frame_count == 0
time.sleep(5 * stream.buffer_size / stream.sample_rate)
Expand All @@ -168,9 +176,3 @@ def test_read_from_stream_measures_dropped_frames():

# ...but we should still know how many frames were dropped:
assert stream.dropped_input_frame_count == dropped_count


@pytest.mark.skipif(platform.system() != "Linux", reason="Test platform is not Linux.")
def test_create_stream_fails_on_linux():
with pytest.raises(RuntimeError):
pedalboard.io.AudioStream("input", "output")

0 comments on commit 0ba5f9e

Please sign in to comment.