From 0ba5f9e278c21a67896b38fcb4b7227c6d6c7912 Mon Sep 17 00:00:00 2001 From: Pedro Lins <48866794+sarmentow@users.noreply.github.com> Date: Fri, 23 Aug 2024 09:17:42 -0300 Subject: [PATCH] Add pedalboard.io.AudioStream support for Linux (#368) * 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 Co-authored-by: Peter Sobot --- .github/workflows/all.yml | 20 ++++++--- examples/add_reverb_to_file.py | 4 +- examples/audio_monitoring_with_effects.py | 21 +++++++++ pedalboard/JuceHeader.h | 7 +-- pedalboard/io/AudioStream.h | 39 ++++++++++------- pyproject.toml | 4 +- setup.py | 4 +- tests/test_audio_stream.py | 52 ++++++++++++----------- 8 files changed, 95 insertions(+), 56 deletions(-) create mode 100644 examples/audio_monitoring_with_effects.py diff --git a/.github/workflows/all.yml b/.github/workflows/all.yml index ef9f864d..0730aa30 100644 --- a/.github/workflows/all.yml +++ b/.github/workflows/all.yml @@ -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' @@ -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' @@ -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' @@ -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 }} @@ -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' @@ -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' diff --git a/examples/add_reverb_to_file.py b/examples/add_reverb_to_file.py index 6a761931..0a0b16d6 100644 --- a/examples/add_reverb_to_file.py +++ b/examples/add_reverb_to_file.py @@ -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: @@ -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(): diff --git a/examples/audio_monitoring_with_effects.py b/examples/audio_monitoring_with_effects.py new file mode 100644 index 00000000..feb291d4 --- /dev/null +++ b/examples/audio_monitoring_with_effects.py @@ -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() \ No newline at end of file diff --git a/pedalboard/JuceHeader.h b/pedalboard/JuceHeader.h index 911785e6..6a23a03b 100644 --- a/pedalboard/JuceHeader.h +++ b/pedalboard/JuceHeader.h @@ -23,6 +23,7 @@ #pragma once #include +#include #include #include #include @@ -30,8 +31,4 @@ #include #include #include -#include - -#ifndef JUCE_LINUX -#include -#endif \ No newline at end of file +#include \ No newline at end of file diff --git a/pedalboard/io/AudioStream.h b/pedalboard/io/AudioStream.h index e2c8fe4d..3b155fd0 100644 --- a/pedalboard/io/AudioStream.h +++ b/pedalboard/io/AudioStream.h @@ -81,7 +81,10 @@ class AudioStream : public std::enable_shared_from_this "`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."); } @@ -275,10 +278,14 @@ class AudioStream : public std::enable_shared_from_this 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 {}; @@ -290,7 +297,7 @@ class AudioStream : public std::enable_shared_from_this 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, @@ -314,9 +321,7 @@ class AudioStream : public std::enable_shared_from_this } } } - } - - 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++) { @@ -356,13 +361,12 @@ class AudioStream : public std::enable_shared_from_this 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) @@ -378,6 +382,11 @@ class AudioStream : public std::enable_shared_from_this (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)); + } } } @@ -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 " diff --git a/pyproject.toml b/pyproject.toml index a5cd8148..c2078580 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/setup.py b/setup.py index a3642763..2e671362 100644 --- a/setup.py +++ b/setup.py @@ -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", @@ -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") @@ -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()) @@ -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": diff --git a/tests/test_audio_stream.py b/tests/test_audio_stream.py index 3a5bb7e4..d26847fa 100644 --- a/tests/test_audio_stream.py +++ b/tests/test_audio_stream.py @@ -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 @@ -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( @@ -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: @@ -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: @@ -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: @@ -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: @@ -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) @@ -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")