This commit is contained in:
Brandon Taylor 2021-06-22 20:31:23 -04:00
parent 3860fa5f25
commit f1cc45e85f
2 changed files with 226 additions and 207 deletions

View file

@ -1,7 +1,7 @@
module PortAudio
using alsa_plugins_jll: alsa_plugins_jll
import Base: close, eltype, getproperty, isopen, read, read!, show, write
import Base: close, eltype, getproperty, isopen, read, read!, show, showerror, write
using Base.Threads: @spawn
using libportaudio_jll: libportaudio
using LinearAlgebra: transpose!
@ -60,6 +60,14 @@ function get_error_text(error_number)
unsafe_string(Pa_GetErrorText(error_number))
end
struct PortAudioException <: Exception
code::PaErrorCode
end
function showerror(io::IO, exception::PortAudioException)
print(io, "PortAudioException: ")
print(io, get_error_text(PaError(exception.code)))
end
# for integer results, PortAudio will return a negative number instead of erroring
# so we need to handle these errors
function handle_status(error_number; warn_xruns = true)
@ -72,7 +80,7 @@ function handle_status(error_number; warn_xruns = true)
@warn("libportaudio: " * get_error_text(error_number))
end
else
throw(ErrorException("libportaudio: " * get_error_text(error_number)))
throw(PortAudioException(error_code))
end
end
error_number
@ -96,18 +104,7 @@ function seek_alsa_conf(folders)
return folder
end
end
throw(ErrorException("""
Could not find ALSA config directory. Searched:
$(join(folders, "\n"))
If ALSA is installed, set the "ALSA_CONFIG_DIR" environment
variable. The given directory should have a file "alsa.conf".
If it would be useful to others, please file an issue at
https://github.com/JuliaAudio/PortAudio.jl/issues
with your alsa config directory so we can add it to the search
paths.
"""))
throw(ArgumentError("Could not find ALSA config"))
end
function __init__()
@ -188,10 +185,21 @@ function get_default_output_device()
handle_status(Pa_GetDefaultOutputDevice())
end
function get_device_info(index)
function get_device_info(index::Integer)
safe_load((Pa_GetDeviceInfo(index)), BoundsError(Pa_GetDeviceInfo, index))
end
function get_device_info(device_name::AbstractString)
# maintain an error message with avaliable devices while we look to save time
for device in devices()
potential_match = name(device)
if potential_match == device_name
return device
end
end
throw(KeyError(device_name))
end
function devices()
[
PortAudioDevice(get_device_info(index), index) for
@ -283,38 +291,83 @@ end
# if users pass max as the number of channels, we fill it in for them
# this is currently undocumented
function fill_max_channels(channels, bounds)
max_channels = bounds.max_channels
if channels === max
bounds.max_channels
max_channels
elseif channels > max_channels
throw(DomainError(channels, "max channels exceeded"))
else
channels
end
end
const AT_LEAST_ONE = ArgumentError("Input or output must have at least 1 channel")
function fill_both_channels(input_channels, input_device, output_channels, output_device)
input_channels_filled = fill_max_channels(input_channels, input_device.input_bounds)
output_channels_filled = fill_max_channels(output_channels, output_device.output_bounds)
if input_channels_filled == 0 && output_channels_filled == 0
throw(AT_LEAST_ONE)
else
input_channels_filled, output_channels_filled
end
end
function input_output_or_both(
combine_function,
input_channels_filled,
output_channels_filled,
input,
output,
)
if input_channels_filled > 0
if output_channels_filled > 0
combine_function(input, output)
else
input
end
else
if output_channels_filled > 0
output
else
throw(AT_LEAST_ONE)
end
end
end
# worst case scenario
function get_default_latency(input_device, output_device)
max(input_device.input_bounds.high_latency, output_device.output_bounds.high_latency)
function get_default_latency(input_channels, input_device, output_channels, output_device)
input_channels_filled, output_channels_filled =
fill_both_channels(input_channels, input_device, output_channels, output_device)
input_output_or_both(
max,
input_channels_filled,
output_channels_filled,
input_device.input_bounds.high_latency,
output_device.output_bounds.high_latency,
)
end
# we can only have one sample rate
# so if the default sample rates differ, throw an error
function combine_default_sample_rates(
in_channels,
sample_rate_in,
out_channels,
sample_rate_out,
input_channels,
input_device,
output_channels,
output_device,
)
if in_channels > 0 && out_channels > 0 && sample_rate_in != sample_rate_out
error(
"""
Can't open duplex stream with mismatched samplerates (in: $sample_rate_in, out: $sample_rate_out).
Try changing your sample rate in your driver settings or open separate input and output
streams.
""",
)
elseif in_channels > 0
sample_rate_in
else
sample_rate_out
input_channels_filled, output_channels_filled =
fill_both_channels(input_channels, input_device, output_channels, output_device)
input_output_or_both(
input_channels_filled,
output_channels_filled,
input_device.default_sample_rate,
output_device.default_sample_rate,
) do input_sample_rate, output_sample_rate
if input_sample_rate != output_sample_rate
throw(ArgumentError("Default input and output sample rates disagree"))
end
input_sample_rate
end
end
@ -440,9 +493,9 @@ end
# this is the top-level outer constructor that all the other outer constructors end up calling
"""
PortAudioStream(in_channels = 2, out_channels = 2; options...)
PortAudioStream(duplex_device, in_channels = 2, out_channels = 2; options...)
PortAudioStream(in_device, out_device, in_channels = 2, out_channels = 2; options...)
PortAudioStream(input_channels = 2, output_channels = 2; options...)
PortAudioStream(duplex_device, input_channels = 2, output_channels = 2; options...)
PortAudioStream(input_device, output_device, input_channels = 2, output_channels = 2; options...)
Audio devices can either be `PortAudioDevice` instances as returned
by `PortAudio.devices()`, or strings with the device name as reported by the
@ -462,18 +515,23 @@ Options:
Only effects duplex streams.
"""
function PortAudioStream(
in_device::PortAudioDevice,
out_device::PortAudioDevice,
in_channels = 2,
out_channels = 2;
input_device::PortAudioDevice,
output_device::PortAudioDevice,
input_channels = 2,
output_channels = 2;
Sample = Float32,
sample_rate = combine_default_sample_rates(
in_channels,
in_device.default_sample_rate,
out_channels,
out_device.default_sample_rate,
input_channels,
input_device,
output_channels,
output_device,
),
latency = get_default_latency(
input_channels,
input_device,
output_channels,
output_device,
),
latency = get_default_latency(in_device, out_device),
frames_per_buffer = 0,
# these defaults are currently undocumented
flags = paNoFlag,
@ -483,15 +541,27 @@ function PortAudioStream(
output_info = nothing,
warn_xruns = true,
)
in_channels = fill_max_channels(in_channels, in_device.input_bounds)
out_channels = fill_max_channels(out_channels, out_device.output_bounds)
input_channels_filled, output_channels_filled =
fill_both_channels(input_channels, input_device, output_channels, output_device)
# we need a mutable pointer so portaudio can set it for us
mutable_pointer = Ref{Ptr{PaStream}}(0)
handle_status(
Pa_OpenStream(
mutable_pointer,
make_parameters(in_device, in_channels, Sample, latency, input_info),
make_parameters(out_device, out_channels, Sample, latency, output_info),
make_parameters(
input_device,
input_channels_filled,
Sample,
latency,
input_info,
),
make_parameters(
output_device,
output_channels_filled,
Sample,
latency,
output_info,
),
float(sample_rate),
frames_per_buffer,
flags,
@ -508,38 +578,21 @@ function PortAudioStream(
real_write!,
Sample,
pointer_to,
out_device,
out_channels;
output_device,
output_channels_filled;
warn_xruns = warn_xruns,
),
start_messanger(
real_read!,
Sample,
pointer_to,
in_device,
in_channels;
input_device,
input_channels_filled;
warn_xruns = warn_xruns,
),
)
end
function device_by_name(device_name)
# maintain an error message with avaliable devices while we look to save time
error_message = IOBuffer()
write(error_message, "No device matching ")
write(error_message, repr(device_name))
write(error_message, " found.\nAvailable Devices:\n")
for device in devices()
potential_match = name(device)
if potential_match == device_name
return device
end
write(error_message, repr(potential_match))
write(error_message, '\n')
end
error(String(take!(error_message)))
end
# handle device names given as streams
function PortAudioStream(
in_device_name::AbstractString,
@ -548,33 +601,33 @@ function PortAudioStream(
keywords...,
)
PortAudioStream(
device_by_name(in_device_name),
device_by_name(out_device_name),
get_device_info(in_device_name),
get_device_info(out_device_name),
arguments...;
keywords...,
)
end
# if one device is given, use it for input and output, but set in_channels=0 so we
# if one device is given, use it for input and output, but set input_channels=0 so we
# end up with an output-only stream
function PortAudioStream(
device::Union{PortAudioDevice, AbstractString},
in_channels = 0,
out_channels = 2;
input_channels = 0,
output_channels = 2;
keywords...,
)
PortAudioStream(device, device, in_channels, out_channels; keywords...)
PortAudioStream(device, device, input_channels, output_channels; keywords...)
end
# use the default input and output devices
function PortAudioStream(in_channels = 2, out_channels = 2; keywords...)
function PortAudioStream(input_channels = 2, output_channels = 2; keywords...)
in_index = get_default_input_device()
out_index = get_default_output_device()
PortAudioStream(
PortAudioDevice(get_device_info(in_index), in_index),
PortAudioDevice(get_device_info(out_index), out_index),
in_channels,
out_channels;
input_channels,
output_channels;
keywords...,
)
end
@ -599,7 +652,7 @@ end
function isopen(pointer_to::Ptr{PaStream})
# we aren't actually interested if the stream is stopped or not
# instea, we are looking for the error which comes from checking on a closed stream
# instead, we are looking for the error which comes from checking on a closed stream
error_number = Pa_IsStreamStopped(pointer_to)
if error_number >= 0
true
@ -670,12 +723,6 @@ function eltype(
) where {Sample}
Sample
end
function close(::Union{PortAudioSink, PortAudioSource})
throw(ErrorException("""
Attempted to close PortAudioSink or PortAudioSource.
Close the containing PortAudioStream instead
"""))
end
function isopen(source_or_sink::Union{PortAudioSink, PortAudioSource})
isopen(source_or_sink.stream)
end

View file

@ -8,6 +8,7 @@ using PortAudio:
get_device_info,
handle_status,
initialize,
PortAudioException,
PortAudio,
PortAudioDevice,
PortAudioStream,
@ -20,6 +21,7 @@ using PortAudio.LibPortAudio:
PaErrorCode,
paFloat32,
Pa_GetDefaultHostApi,
Pa_GetDeviceInfo,
Pa_GetHostApiCount,
Pa_GetLastHostErrorInfo,
Pa_GetSampleSize,
@ -47,7 +49,7 @@ using PortAudio.LibPortAudio:
using SampledSignals: nchannels, s, SampleBuf, samplerate, SinSource
using Test: @test, @test_logs, @test_nowarn, @testset, @test_throws
@testset "PortAudio Tests" begin
@testset "Tests without sound" begin
@testset "Reports version" begin
io = IOBuffer()
PortAudio.versioninfo(io)
@ -60,17 +62,7 @@ using Test: @test, @test_logs, @test_nowarn, @testset, @test_throws
devices()
end
@testset "Null errors" begin
@test_throws BoundsError get_device_info(-1)
end
end
if !isempty(devices())
# make sure we can terminate, then reinitialize
terminate()
initialize()
@testset "libportaudio" begin
@testset "libortaudio without sound" begin
@test handle_status(Pa_GetHostApiCount()) >= 0
@test handle_status(Pa_GetDefaultHostApi()) >= 0
@test PaErrorCode(Pa_HostApiTypeIdToHostApiIndex(paInDevelopment)) ==
@ -82,54 +74,60 @@ if !isempty(devices())
@test PaErrorCode(
Pa_OpenDefaultStream(Ref(C_NULL), 0, 0, paFloat32, 0.0, 0, C_NULL, C_NULL),
) == paInvalidDevice
stream = PortAudioStream(2, 2)
pointer_to = stream.pointer_to
@test Bool(handle_status(Pa_IsStreamActive(pointer_to)))
@test safe_load(Pa_GetStreamInfo(pointer_to), ErrorException("no info")) isa
PaStreamInfo
@test Pa_GetStreamTime(pointer_to) >= 0
@test Pa_GetStreamCpuLoad(pointer_to) >= 0
@test PaErrorCode(handle_status(Pa_AbortStream(pointer_to))) == paNoError
@test PaErrorCode(
handle_status(Pa_SetStreamFinishedCallback(pointer_to, C_NULL)),
) == paNoError
end
@testset "Errors without sound" begin
wrong = "foobarbaz"
@test_throws KeyError(wrong) get_device_info(wrong)
@test_throws BoundsError(Pa_GetDeviceInfo, -1) get_device_info(-1)
@test_throws ArgumentError("Could not find ALSA config") seek_alsa_conf([])
@test_logs (:warn, "libportaudio: Output underflowed") handle_status(
PaError(paOutputUnderflowed),
)
@test_throws PortAudioException(paNotInitialized) handle_status(
PaError(paNotInitialized)
)
Pa_Sleep(1)
@test Pa_GetSampleSize(paFloat32) == 4
end
end
if !isempty(devices())
# make sure we can terminate, then reinitialize
terminate()
initialize()
# these default values are specific to my machines
inidx = get_default_input_device()
default_indev = PortAudioDevice(get_device_info(inidx), inidx).name
outidx = get_default_output_device()
default_outdev = PortAudioDevice(get_device_info(outidx), outidx).name
input_index = get_default_input_device()
default_input_device = PortAudioDevice(get_device_info(input_index), input_index).name
output_index = get_default_output_device()
default_output_device =
PortAudioDevice(get_device_info(output_index), output_index).name
@testset "Local Tests" begin
@testset "Open Default Device" begin
@testset "Tests with sound" begin
@testset "Interactive tests" begin
println("Recording...")
stream = PortAudioStream(2, 0)
buf = read(stream, 5s)
close(stream)
@test size(buf) ==
buffer = read(stream, 5s)
@test size(buffer) ==
(round(Int, 5 * samplerate(stream)), nchannels(stream.source))
close(stream)
println("Playing back recording...")
PortAudioStream(0, 2) do stream
write(stream, buf)
write(stream, buffer)
end
println("Testing pass-through")
stream = PortAudioStream(2, 2)
sink = stream.sink
source = stream.source
@test sprint(show, sink) == "2 channel sink: $(repr(default_indev))"
@test sprint(show, source) == "2 channel source: $(repr(default_outdev))"
@test sprint(show, stream) == """
PortAudioStream{Float32}
Samplerate: 44100.0Hz
2 channel sink: $(repr(default_output_device))
2 channel source: $(repr(default_input_device))"""
@test sprint(show, sink) == "2 channel sink: $(repr(default_input_device))"
@test sprint(show, source) == "2 channel source: $(repr(default_output_device))"
write(stream, stream, 5s)
@test_throws ErrorException("""
Attempted to close PortAudioSink or PortAudioSource.
Close the containing PortAudioStream instead
""") close(sink)
@test_throws ErrorException("""
Attempted to close PortAudioSink or PortAudioSource.
Close the containing PortAudioStream instead
""") close(source)
close(stream)
@test !isopen(stream)
@test !isopen(sink)
@ -137,91 +135,65 @@ if !isempty(devices())
println("done")
end
@testset "Samplerate-converting writing" begin
stream = PortAudioStream(0, 2)
write(
stream,
SinSource(eltype(stream), samplerate(stream) * 0.8, [220, 330]),
3s,
)
write(
stream,
SinSource(eltype(stream), samplerate(stream) * 1.2, [220, 330]),
3s,
)
close(stream)
PortAudioStream(0, 2) do stream
write(
stream,
SinSource(eltype(stream), samplerate(stream) * 0.8, [220, 330]),
3s,
)
write(
stream,
SinSource(eltype(stream), samplerate(stream) * 1.2, [220, 330]),
3s,
)
end
end
@testset "Open Device by name" begin
stream = PortAudioStream(default_indev, default_outdev)
buf = read(stream, 0.001s)
@test size(buf) ==
(round(Int, 0.001 * samplerate(stream)), nchannels(stream.source))
write(stream, buf)
io = IOBuffer()
show(io, stream)
@test occursin(
"""
PortAudioStream{Float32}
Samplerate: 44100.0Hz
2 channel sink: $(repr(default_outdev))
2 channel source: $(repr(default_indev))""",
String(take!(io)),
)
close(stream)
end
@testset "Error handling" begin
@test_throws ErrorException PortAudioStream("foobarbaz")
@test_throws ErrorException PortAudioStream(default_indev, "foobarbaz")
@test_logs (:warn, "libportaudio: Output underflowed") handle_status(
PaError(paOutputUnderflowed),
)
@test_throws ErrorException("libportaudio: PortAudio not initialized") handle_status(
PaError(paNotInitialized),
)
@test_throws ErrorException("""
Could not find ALSA config directory. Searched:
If ALSA is installed, set the "ALSA_CONFIG_DIR" environment
variable. The given directory should have a file "alsa.conf".
If it would be useful to others, please file an issue at
https://github.com/JuliaAudio/PortAudio.jl/issues
with your alsa config directory so we can add it to the search
paths.
""") seek_alsa_conf([])
@test_throws ErrorException(
"""
Can't open duplex stream with mismatched samplerates (in: 0, out: 1).
Try changing your sample rate in your driver settings or open separate input and output
streams.
""",
) combine_default_sample_rates(1, 0, 1, 1)
PortAudioStream(default_input_device, default_output_device) do stream
end
end
# no way to check that the right data is actually getting read or written here,
# but at least it's not crashing.
@testset "Queued Writing" begin
stream = PortAudioStream(0, 2)
buf = SampleBuf(
rand(eltype(stream), 48000, nchannels(stream.sink)) * 0.1,
samplerate(stream),
)
t1 = @async write(stream, buf)
t2 = @async write(stream, buf)
@test fetch(t1) == 48000
@test fetch(t2) == 48000
close(stream)
PortAudioStream(0, 2) do stream
buffer = SampleBuf(
rand(eltype(stream), 48000, nchannels(stream.sink)) * 0.1,
samplerate(stream),
)
frame_count_1 = @async write(stream, buffer)
frame_count_2 = @async write(stream, buffer)
@test fetch(frame_count_1) == 48000
@test fetch(frame_count_2) == 48000
end
end
@testset "Queued Reading" begin
stream = PortAudioStream(2, 0)
buf = SampleBuf(
rand(eltype(stream), 48000, nchannels(stream.source)) * 0.1,
samplerate(stream),
)
t1 = @async read!(stream, buf)
t2 = @async read!(stream, buf)
@test fetch(t1) == 48000
@test fetch(t2) == 48000
close(stream)
PortAudioStream(2, 0) do stream
buffer = SampleBuf(
rand(eltype(stream), 48000, nchannels(stream.source)) * 0.1,
samplerate(stream),
)
frame_count_1 = @async read!(stream, buffer)
frame_count_2 = @async read!(stream, buffer)
@test fetch(frame_count_1) == 48000
@test fetch(frame_count_2) == 48000
end
end
@testset "Errors with sound" begin
@test_throws DomainError(typemax(Int), "max channels exceeded") PortAudioStream(typemax(Int), 0)
@test_throws ArgumentError("Input or output must have at least 1 channel") PortAudioStream(0, 0)
end
@testset "libportaudio with sound" begin
stream = PortAudioStream(2, 2)
pointer_to = stream.pointer_to
@test Bool(handle_status(Pa_IsStreamActive(pointer_to)))
@test safe_load(Pa_GetStreamInfo(pointer_to), ErrorException("no info")) isa
PaStreamInfo
@test Pa_GetStreamTime(pointer_to) >= 0
@test Pa_GetStreamCpuLoad(pointer_to) >= 0
@test PaErrorCode(handle_status(Pa_AbortStream(pointer_to))) == paNoError
@test PaErrorCode(
handle_status(Pa_SetStreamFinishedCallback(pointer_to, C_NULL)),
) == paNoError
end
end
end