Skip to content

Commit 0ba5f9e

Browse files
sarmentowpsobot
andauthored
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 <psobot@gmail.com> Co-authored-by: Peter Sobot <psobot@spotify.com>
1 parent 372741b commit 0ba5f9e

File tree

8 files changed

+95
-56
lines changed

8 files changed

+95
-56
lines changed

.github/workflows/all.yml

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,8 @@ jobs:
7474
&& sudo apt-get install -y pkg-config libsndfile1 \
7575
libx11-dev libxrandr-dev libxinerama-dev \
7676
libxrender-dev libxcomposite-dev libxcb-xinerama0-dev \
77-
libxcursor-dev libfreetype6 libfreetype6-dev
77+
libxcursor-dev libfreetype6 libfreetype6-dev \
78+
libasound2-dev
7879
# We depend on ccache features that are only present in 4.8.0 and later, but installing from apt-get gives us v3.
7980
- name: Install ccache on Linux
8081
if: runner.os == 'Linux'
@@ -257,7 +258,8 @@ jobs:
257258
&& sudo apt-get install -y pkg-config libsndfile1 \
258259
libx11-dev libxrandr-dev libxinerama-dev \
259260
libxrender-dev libxcomposite-dev libxcb-xinerama0-dev \
260-
libxcursor-dev libfreetype6 libfreetype6-dev
261+
libxcursor-dev libfreetype6 libfreetype6-dev \
262+
libasound2-dev
261263
# We depend on ccache features that are only present in 4.8.0 and later, but installing from apt-get gives us v3.
262264
- name: Install ccache on Linux
263265
if: runner.os == 'Linux'
@@ -363,7 +365,8 @@ jobs:
363365
&& sudo apt-get install -y pkg-config libsndfile1 \
364366
libx11-dev libxrandr-dev libxinerama-dev \
365367
libxrender-dev libxcomposite-dev libxcb-xinerama0-dev \
366-
libxcursor-dev libfreetype6 libfreetype6-dev
368+
libxcursor-dev libfreetype6 libfreetype6-dev \
369+
libasound2-dev
367370
# We depend on ccache features that are only present in 4.8.0 and later, but installing from apt-get gives us v3.
368371
- name: Install ccache on Linux
369372
if: runner.os == 'Linux'
@@ -431,6 +434,11 @@ jobs:
431434
GCS_ASSET_BUCKET_NAME: ${{ secrets.GCS_ASSET_BUCKET_NAME }}
432435
GCS_READER_SERVICE_ACCOUNT_KEY: ${{ secrets.GCS_READER_SERVICE_ACCOUNT_KEY }}
433436
run: python ./tests/download_test_plugins.py
437+
- name: Setup dummy soundcard for testing
438+
if: runner.os == 'Linux'
439+
run: |
440+
sudo apt-get install -y linux-modules-extra-$(uname -r)
441+
sudo modprobe snd-dummy
434442
- name: Run tests
435443
env:
436444
TEST_WORKER_INDEX: ${{ matrix.runner_index }}
@@ -482,7 +490,8 @@ jobs:
482490
&& sudo apt-get install -y pkg-config libsndfile1 \
483491
libx11-dev libxrandr-dev libxinerama-dev \
484492
libxrender-dev libxcomposite-dev libxcb-xinerama0-dev \
485-
libxcursor-dev libfreetype6 libfreetype6-dev
493+
libxcursor-dev libfreetype6 libfreetype6-dev \
494+
libasound2-dev
486495
# We depend on ccache features that are only present in 4.8.0 and later, but installing from apt-get gives us v3.
487496
- name: Install ccache on Linux
488497
if: runner.os == 'Linux'
@@ -613,7 +622,8 @@ jobs:
613622
&& sudo apt-get install -y pkg-config libsndfile1 \
614623
libx11-dev libxrandr-dev libxinerama-dev \
615624
libxrender-dev libxcomposite-dev libxcb-xinerama0-dev \
616-
libxcursor-dev libfreetype6 libfreetype6-dev
625+
libxcursor-dev libfreetype6 libfreetype6-dev \
626+
libasound2-dev
617627
# We depend on ccache features that are only present in 4.8.0 and later, but installing from apt-get gives us v3.
618628
- name: Install ccache on Linux
619629
if: runner.os == 'Linux'

examples/add_reverb_to_file.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
def get_num_frames(f: sf.SoundFile) -> int:
3232
# On some platforms and formats, f.frames == -1L.
3333
# Check for this bug and work around it:
34-
if f.frames > 2 ** 32:
34+
if len(f) > 2 ** 32:
3535
f.seek(0)
3636
last_position = f.tell()
3737
while True:
@@ -45,7 +45,7 @@ def get_num_frames(f: sf.SoundFile) -> int:
4545
else:
4646
last_position = new_position
4747
else:
48-
return f.frames
48+
return len(f)
4949

5050

5151
def main():
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
from pedalboard import Pedalboard, Compressor, Gain, Reverb
2+
from pedalboard.io import AudioStream
3+
4+
# Open up an audio stream:
5+
stream = AudioStream(
6+
input_device_name=AudioStream.input_device_names[0],
7+
output_device_name=AudioStream.output_device_names[0],
8+
num_input_channels=2,
9+
num_output_channels=2,
10+
allow_feedback=True,
11+
buffer_size=128,
12+
sample_rate=44100,
13+
)
14+
15+
stream.plugins = Pedalboard([
16+
Reverb(wet_level=0.2),
17+
Gain(1.0),
18+
Compressor(),
19+
])
20+
21+
stream.run()

pedalboard/JuceHeader.h

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,12 @@
2323
#pragma once
2424

2525
#include <juce_audio_basics/juce_audio_basics.h>
26+
#include <juce_audio_devices/juce_audio_devices.h>
2627
#include <juce_audio_formats/juce_audio_formats.h>
2728
#include <juce_audio_processors/juce_audio_processors.h>
2829
#include <juce_core/juce_core.h>
2930
#include <juce_data_structures/juce_data_structures.h>
3031
#include <juce_dsp/juce_dsp.h>
3132
#include <juce_events/juce_events.h>
3233
#include <juce_graphics/juce_graphics.h>
33-
#include <juce_gui_basics/juce_gui_basics.h>
34-
35-
#ifndef JUCE_LINUX
36-
#include <juce_audio_devices/juce_audio_devices.h>
37-
#endif
34+
#include <juce_gui_basics/juce_gui_basics.h>

pedalboard/io/AudioStream.h

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,10 @@ class AudioStream : public std::enable_shared_from_this<AudioStream>
8181
"`allow_feedback=True` to the AudioStream constructor.");
8282
}
8383

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

278-
if (numChannelsNeeded > 0 && info.name.isEmpty())
279-
return {
281+
if (numChannelsNeeded > 0 && info.name.isEmpty()) {
282+
std::string deviceName =
280283
type->getDeviceNames(isInput)[type->getDefaultDeviceIndex(isInput)]
281-
.toStdString()};
284+
.toStdString();
285+
if (!deviceName.empty()) {
286+
return {deviceName};
287+
}
288+
}
282289
}
283290
#endif
284291
return {};
@@ -290,7 +297,7 @@ class AudioStream : public std::enable_shared_from_this<AudioStream>
290297
float **outputChannelData,
291298
int numOutputChannels, int numSamples) {
292299
// Live processing mode: run the input audio through a Pedalboard object.
293-
if (!playBufferFifo && !recordBufferFifo) {
300+
if (playBufferFifo && recordBufferFifo) {
294301
for (int i = 0; i < numOutputChannels; i++) {
295302
const float *inputChannel = inputChannelData[i % numInputChannels];
296303
std::memcpy((char *)outputChannelData[i], (char *)inputChannel,
@@ -314,9 +321,7 @@ class AudioStream : public std::enable_shared_from_this<AudioStream>
314321
}
315322
}
316323
}
317-
}
318-
319-
if (recordBufferFifo) {
324+
} else if (recordBufferFifo) {
320325
// If Python wants audio input, then copy the audio into the record
321326
// buffer:
322327
for (int attempt = 0; attempt < 2; attempt++) {
@@ -356,13 +361,12 @@ class AudioStream : public std::enable_shared_from_this<AudioStream>
356361
break;
357362
}
358363
}
359-
}
360-
361-
for (int i = 0; i < numOutputChannels; i++) {
362-
std::memset((char *)outputChannelData[i], 0, numSamples * sizeof(float));
363-
}
364+
} else if (playBufferFifo) {
365+
for (int i = 0; i < numOutputChannels; i++) {
366+
std::memset((char *)outputChannelData[i], 0,
367+
numSamples * sizeof(float));
368+
}
364369

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

368372
if (scope.blockSize1 > 0)
@@ -378,6 +382,11 @@ class AudioStream : public std::enable_shared_from_this<AudioStream>
378382
(char *)playBuffer->getReadPointer(i, scope.startIndex2),
379383
scope.blockSize2 * sizeof(float));
380384
}
385+
} else {
386+
for (int i = 0; i < numOutputChannels; i++) {
387+
std::memset((char *)outputChannelData[i], 0,
388+
numSamples * sizeof(float));
389+
}
381390
}
382391
}
383392

@@ -832,7 +841,7 @@ Or use :py:meth:`AudioStream.write` to stream audio in chunks::
832841
#ifdef JUCE_MODULE_AVAILABLE_juce_audio_devices
833842
return stream.getAudioDeviceSetup().bufferSize;
834843
#else
835-
return 0;
844+
return 0;
836845
#endif
837846
},
838847
"The size (in frames) of the buffer used between the audio "

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,10 @@ build-backend = "setuptools.build_meta"
1414

1515
# See: https://cibuildwheel.readthedocs.io/en/stable/options/#examples
1616
[tool.cibuildwheel.linux]
17-
before-all = "yum install -y libsndfile libX11-devel libXrandr-devel libXinerama-devel libXrender-devel libXcomposite-devel libXinerama-devel libXcursor-devel freetype-devel"
17+
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"
1818

1919
[[tool.cibuildwheel.overrides]]
2020
# Use apk instead of yum when building on Alpine Linux
2121
# (Note: this is experimental, as most VSTs require glibc and thus Alpine Linux isn't that useful)
2222
select = "*-musllinux*"
23-
before-all = "apk add libsndfile libx11-dev libxrandr-dev libxinerama-dev libxrender-dev libxcomposite-dev libxinerama-dev libxcursor-dev freetype-dev"
23+
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"

setup.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
"-DJUCE_MODULE_AVAILABLE_juce_graphics=1",
5353
"-DJUCE_MODULE_AVAILABLE_juce_gui_basics=1",
5454
"-DJUCE_MODULE_AVAILABLE_juce_gui_extra=1",
55+
"-DJUCE_MODULE_AVAILABLE_juce_audio_devices=1",
5556
"-DJUCE_GLOBAL_MODULE_SETTINGS_INCLUDED=1",
5657
"-DJUCE_STRICT_REFCOUNTEDPOINTER=1",
5758
"-DJUCE_STANDALONE_APPLICATION=1",
@@ -260,7 +261,6 @@ def ignore_files_matching(files, *matches):
260261
ALL_CPPFLAGS.append("-flto=thin")
261262
ALL_LINK_ARGS.append("-flto=thin")
262263
ALL_LINK_ARGS.append("-fvisibility=hidden")
263-
ALL_CPPFLAGS.append("-DJUCE_MODULE_AVAILABLE_juce_audio_devices=1")
264264
ALL_CFLAGS += ["-Wno-comment"]
265265
elif platform.system() == "Linux":
266266
ALL_CPPFLAGS.append("-DLINUX=1")
@@ -272,7 +272,6 @@ def ignore_files_matching(files, *matches):
272272
ALL_CFLAGS += ["-Wno-comment"]
273273
elif platform.system() == "Windows":
274274
ALL_CPPFLAGS.append("-DWINDOWS=1")
275-
ALL_CPPFLAGS.append("-DJUCE_MODULE_AVAILABLE_juce_audio_devices=1")
276275
else:
277276
raise NotImplementedError(
278277
"Not sure how to build JUCE on platform: {}!".format(platform.system())
@@ -356,6 +355,7 @@ def ignore_files_matching(files, *matches):
356355
include_paths = [flag[2:] for flag in flags]
357356
ALL_INCLUDES += include_paths
358357
ALL_LINK_ARGS += ["-lfreetype"]
358+
ALL_LINK_ARGS += ["-lasound"]
359359

360360
ALL_RESOLVED_SOURCE_PATHS = [str(p.resolve()) for p in ALL_SOURCE_PATHS]
361361
elif platform.system() == "Windows":

tests/test_audio_stream.py

Lines changed: 27 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
# See the License for the specific language governing permissions and
1515
# limitations under the License.
1616

17-
import platform
1817
import time
1918

2019
import numpy as np
@@ -36,10 +35,9 @@
3635

3736

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

7068

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

9595

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

119121

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

142146

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

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

169177
# ...but we should still know how many frames were dropped:
170178
assert stream.dropped_input_frame_count == dropped_count
171-
172-
173-
@pytest.mark.skipif(platform.system() != "Linux", reason="Test platform is not Linux.")
174-
def test_create_stream_fails_on_linux():
175-
with pytest.raises(RuntimeError):
176-
pedalboard.io.AudioStream("input", "output")

0 commit comments

Comments
 (0)