diff --git a/.github/workflows/permanent.yml b/.github/workflows/permanent.yml new file mode 100644 index 0000000..9a9e483 --- /dev/null +++ b/.github/workflows/permanent.yml @@ -0,0 +1,16 @@ +name: permanent +on: + push: + branches: + - 'master' +jobs: + document: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: julia-actions/setup-julia@latest + with: + version: '1.6' + - uses: julia-actions/julia-docdeploy@releases/v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5362a77..89b826a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ *.o deps/deps.jl deps/build.log +docs/build *.wav *.flac *.cov diff --git a/Project.toml b/Project.toml index e675284..3cc5d47 100644 --- a/Project.toml +++ b/Project.toml @@ -15,11 +15,11 @@ julia = "1.3" alsa_plugins_jll = "1.2.2" libportaudio_jll = "19.6.0" SampledSignals = "2.1.1" -Suppressor = "0.2.0" [extras] -Logging = "56ddb016-857b-54e1-b83d-db4d58db5568" +Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" +LibSndFile = "b13ce0c6-77b0-50c6-a2db-140568b8d1a5" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [targets] -test = ["Logging", "Test"] +test = ["Documenter", "LibSndFile", "Test"] diff --git a/README.md b/README.md index 4c220c7..0d7cb2e 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,12 @@ PortAudio.jl ============ +[![Dev](https://img.shields.io/badge/docs-dev-blue.svg)](https://JuliaAudio.github.io/PortAudio.jl/dev) [![Tests](https://github.com/JuliaAudio/PortAudio.jl/actions/workflows/Tests.yml/badge.svg)](https://github.com/JuliaAudio/PortAudio.jl/actions/workflows/Tests.yml) [![codecov](https://codecov.io/gh/JuliaAudio/PortAudio.jl/branch/master/graph/badge.svg?token=mgDAi8ulPY)](https://codecov.io/gh/JuliaAudio/PortAudio.jl) -PortAudio.jl is a wrapper for [libportaudio](http://www.portaudio.com/), which gives cross-platform access to audio devices. It is compatible with the types defined in [SampledSignals.jl](https://github.com/JuliaAudio/SampledSignals.jl). It provides a `PortAudioStream` type, which can be read from and written to. - -## Opening a stream - -The easiest way to open a source or sink is with the default `PortAudioStream()` constructor, which will open a 2-in, 2-out stream to your system's default device(s). The constructor can also take the input and output channel counts as positional arguments, or a variety of other keyword arguments. - -```julia -PortAudioStream(inchans=2, outchans=2; eltype=Float32, samplerate=48000Hz, latency=0.1, synced=false) -``` - -You can open a specific device by adding it as the first argument, either as a `PortAudioDevice` instance or by name. You can also give separate names or devices if you want different input and output devices - -```julia -PortAudioStream(device::PortAudioDevice, args...; kwargs...) -PortAudioStream(devname::AbstractString, args...; kwargs...) -``` - -You can get a list of your system's devices with the `PortAudio.devices()` function: - -```julia -julia> PortAudio.devices() -6-element Array{PortAudio.PortAudioDevice,1}: - PortAudio.PortAudioDevice("AirPlay","Core Audio",0,2,0) - PortAudio.PortAudioDevice("Built-in Microph","Core Audio",2,0,1) - PortAudio.PortAudioDevice("Built-in Output","Core Audio",0,2,2) - PortAudio.PortAudioDevice("JackRouter","Core Audio",2,2,3) - PortAudio.PortAudioDevice("After Effects 13.5","Core Audio",0,0,4) - PortAudio.PortAudioDevice("Built-In Aggregate","Core Audio",2,2,5) -``` - -### Input/Output Synchronization - -The `synced` keyword argument to `PortAudioStream` controls whether the input and output ringbuffers are kept synchronized or not, which only effects duplex streams. It should be set to `true` if you need consistent input-to-output latency. In a synchronized stream, the underlying PortAudio callback will only read and write to the buffers an equal number of frames. In a synchronized stream, the user must also read and write an equal number of frames to the stream. If it is only written to or read from, it will eventually block. This is why it is `false` by default. - - -## Reading and Writing - -The `PortAudioStream` type has `source` and `sink` fields which are of type `PortAudioSource <: SampleSource` and `PortAudioSink <: SampleSink`, respectively. are subtypes of `SampleSource` and `SampleSink`, respectively (from [SampledSignals.jl](https://github.com/JuliaAudio/SampledSignals.jl)). This means they support all the stream and buffer features defined there. For example, if you load SampledSignals with `using SampledSignals` you can read 5 seconds to a buffer with `buf = read(stream.source, 5s)`, regardless of the sample rate of the device. - -PortAudio.jl also provides convenience wrappers around the `PortAudioStream` type so you can read and write to it directly, e.g. `write(stream, stream)` will set up a loopback that will read from the input and play it back on the output. +PortAudio.jl is a wrapper for [libportaudio](http://www.portaudio.com/), which gives cross-platform access to audio devices. +It provides a `PortAudioStream` type, which can be read from and written to. ## Debugging @@ -54,53 +17,3 @@ ENV["JULIA_DEBUG"] = :PortAudio ``` before using the package. - -## Examples - -### Set up an audio pass-through from microphone to speaker - -```julia -stream = PortAudioStream(2, 2) -try - # cancel with Ctrl-C - write(stream, stream) -finally - close(stream) -end -``` - -### Use `do` syntax to auto-close the stream -```julia -PortAudioStream(2, 2) do stream - write(stream, stream) -end -``` - -### Open your built-in microphone and speaker by name -```julia -PortAudioStream("Built-in Microph", "Built-in Output") do stream - write(stream, stream) -end -``` - -### Record 10 seconds of audio and save to an ogg file - -```julia -julia> using PortAudio, SampledSignals, LibSndFile - -julia> stream = PortAudioStream("Built-in Microph", 2, 0) -PortAudio.PortAudioStream{Float32,SIUnits.SIQuantity{Int64,0,0,-1,0,0,0,0,0,0}} - Samplerate: 48000 s⁻¹ - Buffer Size: 4096 frames - 2 channel source: "Built-in Microph" - -julia> buf = read(stream, 10s) -480000-frame, 2-channel SampleBuf{Float32, 2, SIUnits.SIQuantity{Int64,0,0,-1,0,0,0,0,0,0}} -10.0 s at 48000 s⁻¹ -▁▄▂▃▅▃▂▄▃▂▂▁▁▂▂▁▁▄▃▁▁▄▂▁▁▁▄▃▁▁▃▃▁▁▁▁▁▁▁▁▄▄▄▄▄▂▂▂▁▃▃▁▃▄▂▁▁▁▁▃▃▂▁▁▁▁▁▁▃▃▂▂▁▃▃▃▁▁▁▁ -▁▄▂▃▅▃▂▄▃▂▂▁▁▂▂▁▁▄▃▁▁▄▂▁▁▁▄▃▁▁▃▃▁▁▁▁▁▁▁▁▄▄▄▄▄▂▂▂▁▃▃▁▃▄▂▁▁▁▁▃▃▂▁▁▁▁▁▁▃▃▂▂▁▃▃▃▁▁▁▁ - -julia> close(stream) - -julia> save(joinpath(homedir(), "Desktop", "myvoice.ogg"), buf) -``` diff --git a/docs/Project.toml b/docs/Project.toml new file mode 100644 index 0000000..9311ade --- /dev/null +++ b/docs/Project.toml @@ -0,0 +1,2 @@ +[deps] +Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" \ No newline at end of file diff --git a/docs/make.jl b/docs/make.jl new file mode 100644 index 0000000..edca21e --- /dev/null +++ b/docs/make.jl @@ -0,0 +1,12 @@ +using PortAudio +using Documenter: deploydocs, makedocs + +makedocs( + sitename = "PortAudio.jl", + modules = [PortAudio], + pages = [ + "Public interface" => "index.md", + "Internals" => "internals.md" + ] +) +deploydocs(repo = "github.com/JuliaAudio/PortAudio.jl.git") \ No newline at end of file diff --git a/docs/src/index.md b/docs/src/index.md new file mode 100644 index 0000000..991a8c7 --- /dev/null +++ b/docs/src/index.md @@ -0,0 +1,10 @@ +# Public interface + +```@index +Pages = ["index.md"] +``` + +```@autodocs +Modules = [PortAudio] +Private = false +``` \ No newline at end of file diff --git a/docs/src/internals.md b/docs/src/internals.md new file mode 100644 index 0000000..f934695 --- /dev/null +++ b/docs/src/internals.md @@ -0,0 +1,10 @@ +# Internals + +```@index +Pages = ["internals.md"] +``` + +```@autodocs +Modules = [PortAudio] +Public = false +``` \ No newline at end of file diff --git a/gen/README.md b/gen/README.md new file mode 100644 index 0000000..11b973e --- /dev/null +++ b/gen/README.md @@ -0,0 +1 @@ +The clang generators will automatically generate wrappers for a C library based on its headers. So everything you see in libportaudio.jl is automatically generated from the C library. If a newer version of portaudio adds more features, we won't have to add new wrappers: clang will handle it for us. It is easy to use currently unused features: the wrappers have already been written for us. Even though it does an admirable job, clang doesn't handle errors and set locks. Fortunately, it's very easy to add secondary wrappers, or just do it at point of use. \ No newline at end of file diff --git a/gen/generator.jl b/gen/generator.jl new file mode 100644 index 0000000..75f0b75 --- /dev/null +++ b/gen/generator.jl @@ -0,0 +1,16 @@ +using Clang.Generators +using libportaudio_jll + +cd(@__DIR__) + +include_dir = joinpath(libportaudio_jll.artifact_dir, "include") |> normpath +portaudio_h = joinpath(include_dir, "portaudio.h") + +options = load_options(joinpath(@__DIR__, "generator.toml")) + +args = get_default_args() +push!(args, "-I$include_dir") + +ctx = create_context(portaudio_h, args, options) + +build!(ctx) diff --git a/gen/generator.toml b/gen/generator.toml new file mode 100644 index 0000000..88855bd --- /dev/null +++ b/gen/generator.toml @@ -0,0 +1,9 @@ +[general] +library_name = "libportaudio" +output_file_path = "../src/LibPortAudio.jl" +module_name = "LibPortAudio" +jll_pkg_name = "libportaudio_jll" +export_symbol_prefixes = ["Pa", "pa"] + +use_julia_native_enum_type = true +auto_mutability = true \ No newline at end of file diff --git a/src/PortAudio.jl b/src/PortAudio.jl index 3de9446..fe0a052 100644 --- a/src/PortAudio.jl +++ b/src/PortAudio.jl @@ -1,350 +1,941 @@ module PortAudio using alsa_plugins_jll: alsa_plugins_jll +import Base: + close, + eltype, + getindex, + getproperty, + IteratorSize, + isopen, + iterate, + length, + read, + read!, + show, + showerror, + write +using Base.Iterators: flatten, repeated +using Base.Threads: @spawn using libportaudio_jll: libportaudio -using SampledSignals +using LinearAlgebra: transpose! +import SampledSignals: nchannels, samplerate, unsafe_read!, unsafe_write +using SampledSignals: SampleSink, SampleSource using Suppressor: @capture_err -import Base: eltype, getproperty, show -import Base: close, isopen -import Base: read, read!, write - -using LinearAlgebra: LinearAlgebra -import LinearAlgebra: transpose! - -export PortAudioStream +export devices, PortAudioStream include("libportaudio.jl") -macro stderr_as_debug(expression) - quote - local result - debug_message = @capture_err result = $(esc(expression)) - @debug debug_message - result +using .LibPortAudio: + paBadStreamPtr, + Pa_CloseStream, + PaDeviceIndex, + PaDeviceInfo, + PaError, + PaErrorCode, + Pa_GetDefaultInputDevice, + Pa_GetDefaultOutputDevice, + Pa_GetDeviceCount, + Pa_GetDeviceInfo, + Pa_GetErrorText, + Pa_GetHostApiInfo, + Pa_GetStreamReadAvailable, + Pa_GetStreamWriteAvailable, + Pa_GetVersion, + Pa_GetVersionText, + PaHostApiTypeId, + Pa_Initialize, + paInputOverflowed, + Pa_IsStreamStopped, + paNoFlag, + Pa_OpenStream, + paOutputUnderflowed, + Pa_ReadStream, + PaSampleFormat, + Pa_StartStream, + Pa_StopStream, + PaStream, + PaStreamParameters, + Pa_Terminate, + Pa_WriteStream + +# for structs and strings, PortAudio will return C_NULL instead of erroring +# so we need to handle these errors +function safe_load(result, an_error) + if result == C_NULL + throw(an_error) + end + unsafe_load(result) +end + +# for functions that retrieve an index, throw a key error if it doesn't exist +function safe_key(a_function, an_index) + safe_load(a_function(an_index), KeyError(an_index)) +end + +# error numbers are integers, while error codes are an @enum +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) + if error_number < 0 + error_code = PaErrorCode(error_number) + if error_code == paOutputUnderflowed || error_code == paInputOverflowed + # warn instead of error after an xrun + # allow users to disable these warnings + if warn_xruns + @warn("libportaudio: " * get_error_text(error_number)) + end + else + throw(PortAudioException(error_code)) + end + end + error_number +end + +function initialize() + # ALSA will throw extraneous warnings on start-up + # send them to debug instead + log = @capture_err handle_status(Pa_Initialize()) + if !isempty(log) + @debug log end end -# This size is in frames +function terminate() + handle_status(Pa_Terminate()) +end -# data is passed to and from portaudio in chunks with this many frames, because -# we need to interleave the samples -const CHUNKFRAMES = 128 +# alsa needs to know where the configure file is +function seek_alsa_conf(folders) + for folder in folders + if isfile(joinpath(folder, "alsa.conf")) + return folder + end + end + throw(ArgumentError("Could not find alsa.conf in $folders")) +end + +function __init__() + if Sys.islinux() + config_folder = "ALSA_CONFIG_DIR" + if config_folder ∉ keys(ENV) + ENV[config_folder] = + seek_alsa_conf(("/usr/share/alsa", "/usr/local/share/alsa", "/etc/alsa")) + end + # the plugin folder will contain plugins for, critically, PulseAudio + plugin_folder = "ALSA_PLUGIN_DIR" + if plugin_folder ∉ keys(ENV) && alsa_plugins_jll.is_available() + ENV[plugin_folder] = joinpath(alsa_plugins_jll.artifact_dir, "lib", "alsa-lib") + end + end + initialize() + atexit(() -> terminate()) +end function versioninfo(io::IO = stdout) - println(io, Pa_GetVersionText()) + println(io, unsafe_string(Pa_GetVersionText())) println(io, "Version: ", Pa_GetVersion()) end +# bounds for when a device is used as an input or output +struct Bounds + max_channels::Int + low_latency::Float64 + high_latency::Float64 +end + struct PortAudioDevice name::String - hostapi::String - maxinchans::Int - maxoutchans::Int - defaultsamplerate::Float64 - idx::PaDeviceIndex - lowinputlatency::Float64 - lowoutputlatency::Float64 - highinputlatency::Float64 - highoutputlatency::Float64 + host_api::String + default_sample_rate::Float64 + index::PaDeviceIndex + input_bounds::Bounds + output_bounds::Bounds end -function PortAudioDevice(info::PaDeviceInfo, idx) +function PortAudioDevice(info::PaDeviceInfo, index) PortAudioDevice( unsafe_string(info.name), - unsafe_string(Pa_GetHostApiInfo(info.host_api).name), - info.max_input_channels, - info.max_output_channels, - info.default_sample_rate, - idx, - info.default_low_input_latency, - info.default_low_output_latency, - info.default_high_input_latency, - info.default_high_output_latency, + # replace host api code with its name + unsafe_string(safe_key(Pa_GetHostApiInfo, info.hostApi).name), + info.defaultSampleRate, + index, + Bounds( + info.maxInputChannels, + info.defaultLowInputLatency, + info.defaultHighInputLatency, + ), + Bounds( + info.maxOutputChannels, + info.defaultLowOutputLatency, + info.defaultHighInputLatency, + ), ) end -function devices() - ndevices = Pa_GetDeviceCount() - infos = PaDeviceInfo[Pa_GetDeviceInfo(i) for i in 0:(ndevices - 1)] - PortAudioDevice[PortAudioDevice(info, idx - 1) for (idx, info) in enumerate(infos)] +name(device::PortAudioDevice) = device.name +function show(io::IO, device::PortAudioDevice) + print(io, repr(name(device))) + print(io, ' ') + print(io, device.input_bounds.max_channels) + print(io, '→') + print(io, device.output_bounds.max_channels) end -# not for external use, used in error message printing -devnames() = join(["\"$(dev.name)\"" for dev in devices()], "\n") +function get_default_input_index() + handle_status(Pa_GetDefaultInputDevice()) +end -struct Buffer{T} - device::PortAudioDevice - chunkbuf::Array{T, 2} - nchannels::Int +function get_default_output_index() + handle_status(Pa_GetDefaultOutputDevice()) +end + +# we can look up devices by index or name +function get_device(index::Integer) + PortAudioDevice(safe_key(Pa_GetDeviceInfo, index), index) +end + +function get_device(device_name::AbstractString) + for device in devices() + potential_match = name(device) + if potential_match == device_name + return device + end + end + throw(KeyError(device_name)) +end + +""" + devices() + +List the devices available on your system. +Devices will be shown with their internal name, and maximum input and output channels. +""" +function devices() + # need to use 0 indexing for C + map(get_device, 0:(handle_status(Pa_GetDeviceCount()) - 1)) +end + +# we can handle reading and writing from buffers in a similar way +function read_or_write(a_function, buffer, use_frames = buffer.frames_per_buffer) + handle_status( + # because we're calling Pa_ReadStream and Pa_WriteStream from separate threads, + # we put a lock around these calls + lock( + let a_function = a_function, + pointer_to = buffer.pointer_to, + data = buffer.data, + use_frames = use_frames + () -> a_function(pointer_to, data, use_frames) + end, + buffer.stream_lock, + ), + warn_xruns = buffer.warn_xruns, + ) +end + +""" + abstract type PortAudio.Scribe end + +A scribe must implement the following: + + - A method for [`PortAudio.get_input_type`](@ref) + - A method for [`PortAudio.get_output_type`](@ref) + - A method to call itself on two arguments: a [`PortAudio.Buffer`](@ref) and an input of the input type. + This method must return an output of the output type. + This method should make use of [`PortAudio.read_buffer!`](@ref) and [`PortAudio.write_buffer`](@ref). +""" +abstract type Scribe end + +abstract type SampledSignalsScribe <: Scribe end + +""" + struct PortAudio.SampledSignalsReader + +A [`PortAudio.Scribe`](@ref) that will use the `SampledSignals` package to manage reading data from PortAudio. +""" +struct SampledSignalsReader <: SampledSignalsScribe end + +""" + struct PortAudio.SampledSignalsReader + +A [`PortAudio.Scribe`](@ref) that will use the `SampledSignals` package to manage writing data to PortAudio. +""" +struct SampledSignalsWriter <: SampledSignalsScribe end + +""" + PortAudio.get_input_type(scribe::PortAudio.Scribe, Sample) + +Get the input type of a [`PortAudio.Scribe`](@ref) for samples of type `Sample`. +""" +function get_input_type(::SampledSignalsScribe, Sample) + # SampledSignals input_channel will be a triple of the last 3 arguments to unsafe_read/write + # we will already have access to the stream itself + Tuple{Array{Sample, 2}, Int, Int} +end + +""" + PortAudio.get_input_type(scribe::PortAudio.Scribe, Sample) + +Get the output type of a [`PortAudio.Scribe`](@ref) for samples of type `Sample`. +""" +function get_output_type(::SampledSignalsScribe, Sample) + # output is the number of frames read/written + Int +end + +# the julia buffer is bigger than the port audio buffer +# so we need to split it up into chunks +# we do this the same way for both reading and writing +function split_up( + buffer, + julia_buffer, + already, + frame_count, + whole_function, + partial_function, +) + frames_per_buffer = buffer.frames_per_buffer + # when we're done, we'll have written this many frames + goal = already + frame_count + # this is what we'll have left after doing all complete chunks + left = frame_count % frames_per_buffer + # this is how many we'll have written after doing all complete chunks + even = goal - left + foreach( + let whole_function = whole_function, + buffer = buffer, + julia_buffer = julia_buffer, + frames_per_buffer = frames_per_buffer + already -> whole_function( + buffer, + frames_per_buffer, + julia_buffer, + (already + 1):(already + frames_per_buffer) + ) + end, + # start at the already, keep going until there is less than a chunk left + already:frames_per_buffer:(even - frames_per_buffer), + # each time we loop, add chunk frames to already + # after the last loop, we'll reach "even" + ) + # now we just have to read/write what's left + if left > 0 + partial_function(buffer, left, julia_buffer, (even + 1):goal) + end + frame_count +end + +# the full version doesn't have to make a view, but the partial version does +function full_write!(buffer, count, julia_buffer, julia_range) + @inbounds transpose!(buffer.data, view(julia_buffer, julia_range, :)) + write_buffer(buffer, count) +end + +function partial_write!(buffer, count, julia_buffer, julia_range) + @inbounds transpose!(view(buffer.data, :, 1:count), view(julia_buffer, julia_range, :)) + write_buffer(buffer, count) +end + +function (writer::SampledSignalsWriter)(buffer, arguments) + split_up(buffer, arguments..., full_write!, partial_write!) +end + +# similar to above +function full_read!(buffer, count, julia_buffer, julia_range) + read_buffer!(buffer, count) + @inbounds transpose!(view(julia_buffer, julia_range, :), buffer.data) +end + +function partial_read!(buffer, count, julia_buffer, julia_range) + read_buffer!(buffer, count) + @inbounds transpose!(view(julia_buffer, julia_range, :), view(buffer.data, :, 1:count)) +end + +function (reader::SampledSignalsReader)(buffer, arguments) + split_up(buffer, arguments..., full_read!, partial_read!) +end + +""" + struct PortAudio.Buffer{Sample} + +A `PortAudio.Buffer` contains everything you might need to read or write data from or to PortAudio. +The `data` field contains the raw data in the buffer. +Use [`PortAudio.write_buffer`](@ref) to write data to PortAudio, and [`PortAudio.read_buffer!`](@ref) to read data from PortAudio. +""" +struct Buffer{Sample} + stream_lock::ReentrantLock + pointer_to::Ptr{PaStream} + data::Array{Sample} + number_of_channels::Int + frames_per_buffer::Int + warn_xruns::Bool +end + +function Buffer( + stream_lock, + pointer_to, + number_of_channels; + Sample = Float32, + frames_per_buffer = 128, + warn_xruns = true, +) + Buffer{Sample}( + stream_lock, + pointer_to, + zeros(Sample, number_of_channels, frames_per_buffer), + number_of_channels, + frames_per_buffer, + warn_xruns, + ) +end + +eltype(::Type{Buffer{Sample}}) where {Sample} = Sample +nchannels(buffer::Buffer) = buffer.number_of_channels + +""" + PortAudio.write_buffer(buffer, use_frames = buffer.frames_per_buffer) + +Write a number of frames (`use_frames`) from a [`PortAudio.Buffer`](@ref) to PortAudio. +""" +function write_buffer(buffer::Buffer, use_frames = buffer.frames_per_buffer) + read_or_write(Pa_WriteStream, buffer, use_frames) +end + +""" + PortAudio.read_buffer!(buffer::Buffer, use_frames = buffer.frames_per_buffer) + +Read a number of frames (`use_frames`) from PortAudio to a [`PortAudio.Buffer`](@ref). +""" +function read_buffer!(buffer, use_frames = buffer.frames_per_buffer) + read_or_write(Pa_ReadStream, buffer, use_frames) +end + +# the messanger will send tasks to the scribe +# the scribe will read/write from the buffer +struct Messanger{Sample, Scribe, Input, Output} + device_name::String + buffer::Buffer{Sample} + scribe::Scribe + input_channel::Channel{Input} + output_channel::Channel{Output} +end + +eltype(::Type{Messanger{Sample}}) where {Sample} = Sample +name(messanger::Messanger) = messanger.device_name +nchannels(messanger::Messanger) = nchannels(messanger.buffer) + +# the scribe will be running on a separate thread in the background +# alternating transposing and +# waiting to pass inputs and outputs back and forth to PortAudio +function send(messanger) + buffer = messanger.buffer + scribe = messanger.scribe + input_channel = messanger.input_channel + output_channel = messanger.output_channel + while true + input = try + take!(input_channel) + catch an_error + # if the input channel is closed, the scribe knows its done + if an_error isa InvalidStateException && an_error.state === :closed + break + else + rethrow(an_error) + end + end + put!(output_channel, scribe(buffer, input)) + end +end + +# convenience method +has_channels(something) = nchannels(something) > 0 + +# create the messanger, and start the scribe on a separate task +function messanger_task( + device_name, + buffer::Buffer{Sample}, + scribe::Scribe +) where {Sample, Scribe} + Input = get_input_type(scribe, Sample) + Output = get_output_type(scribe, Sample) + input_channel = Channel{Input}(0) + output_channel = Channel{Output}(0) + # unbuffered channels so putting and taking will block till everyone's ready + messanger = Messanger{Sample, Scribe, Input, Output}( + device_name, + buffer, + scribe, + input_channel, + output_channel, + ) + # we will spawn new threads to read from and write to port audio + # while the reading thread is talking to PortAudio, the writing thread can be setting up, and vice versa + # start the scribe thread when its created + # if there's channels at all + # we can't make the task a field of the buffer, because the task uses the buffer + task = Task(let messanger = messanger + () -> begin + # xruns will return an error code and send a duplicate warning to stderr + # since we handle the error codes, we don't need the duplicate warnings + # so we send them to a debug log + log = @capture_err send(messanger) + if !isempty(log) + @debug log + end + end + end) + # makes it able to run on a separate thread + task.sticky = false + if has_channels(buffer) + schedule(task) + # output channel will close when the task ends + bind(output_channel, task) + else + close(input_channel) + close(output_channel) + end + messanger, task +end + +function fetch_messanger(messanger, task) + if has_channels(messanger) + # this will shut down the channels, which will shut down the thread + close(messanger.input_channel) + # wait for tasks to finish to make sure any errors get caught + wait(task) + # output channel will close because it is bound to the task + else + "" + end end # # PortAudioStream # -struct PortAudioStream{T} - samplerate::Float64 - latency::Float64 - pointer_ref::Ref{PaStream} - warn_xruns::Bool - recover_xruns::Bool - sink_buffer::Buffer{T} - source_buffer::Buffer{T} - - # this inner constructor is generally called via the top-level outer - # constructor below - - # TODO: pre-fill outbut buffer on init - # TODO: recover from xruns - currently with low latencies (e.g. 0.01) it - # will run fine for a while and then fail with the first xrun. - # TODO: figure out whether we can get deterministic latency... - function PortAudioStream{T}( - indev::PortAudioDevice, - outdev::PortAudioDevice, - inchans, - outchans, - sr, - latency, - warn_xruns, - recover_xruns, - ) where {T} - inchans = inchans == -1 ? indev.maxinchans : inchans - outchans = outchans == -1 ? outdev.maxoutchans : outchans - inparams = if (inchans == 0) - Ptr{Pa_StreamParameters}(0) - else - Ref(Pa_StreamParameters(indev.idx, inchans, type_to_fmt[T], latency, C_NULL)) - end - outparams = if (outchans == 0) - Ptr{Pa_StreamParameters}(0) - else - Ref(Pa_StreamParameters(outdev.idx, outchans, type_to_fmt[T], latency, C_NULL)) - end - # finalizer(close, this) - pointer_ref = @stderr_as_debug Pa_OpenStream( - inparams, - outparams, - sr, - 0, - paNoFlag, - nothing, - nothing, - ) - sink_buffer = Buffer{T}(outdev, outchans) - source_buffer = Buffer{T}(indev, inchans) - Pa_StartStream(pointer_ref[]) - this = new( - sr, - latency, - pointer_ref, - warn_xruns, - recover_xruns, - sink_buffer, - source_buffer - ) - # pre-fill the output stream so we're less likely to underrun - prefill_output(this.sink) - - this - end +struct PortAudioStream{SinkMessanger, SourceMessanger} + sample_rate::Float64 + # pointer to the c object + pointer_to::Ptr{PaStream} + sink_messanger::SinkMessanger + sink_task::Task + source_messanger::SourceMessanger + source_task::Task end -function recover_xrun(stream::PortAudioStream) - playback = nchannels(stream.sink) > 0 - capture = nchannels(stream.source) > 0 - if playback && capture - # the best we can do to avoid further xruns is to fill the playback buffer and - # discard the capture buffer. Really there's a fundamental problem with our - # read/write-based API where you don't know whether we're currently in a state - # when the reads and writes should be balanced. In the future we should probably - # move to some kind of transaction API that forces them to be balanced, and also - # gives a way for the application to signal that the same number of samples - # should have been read as written. - discard_input(stream.source) - prefill_output(stream.sink) - end -end +# portaudio uses codes instead of types for the sample format +const TYPE_TO_FORMAT = Dict{Type, PaSampleFormat}( + Float32 => 1, + Int32 => 2, + # Int24 => 4, + Int16 => 8, + Int8 => 16, + UInt8 => 3, +) -function defaultlatency(devices...) - maximum(d -> max(d.highoutputlatency, d.highinputlatency), devices) -end - -function combine_default_sample_rates(inchans, sampleratein, outchans, samplerateout) - if inchans > 0 && outchans > 0 && sampleratein != samplerateout - error( - """ - Can't open duplex stream with mismatched samplerates (in: $sampleratein, out: $samplerateout). - Try changing your sample rate in your driver settings or open separate input and output - streams. - """, - ) - elseif inchans > 0 - sampleratein +function make_parameters( + device, + channels, + latency; + Sample = Float32, + host_api_specific_stream_info = C_NULL, +) + if channels == 0 + # if we don't need any channels, we don't need the source/sink at all + C_NULL else - samplerateout + Ref( + PaStreamParameters( + device.index, + channels, + TYPE_TO_FORMAT[Sample], + latency, + host_api_specific_stream_info, + ), + ) end end +function fill_max_channels(kind, device, bounds, channels; adjust_channels = false) + max_channels = bounds.max_channels + if channels === maximum + max_channels + elseif channels > max_channels + if adjust_channels + max_channels + else + throw( + DomainError( + channels, + "$channels exceeds maximum $kind channels for $(name(device))", + ), + ) + end + else + channels + end +end + +function combine_default_sample_rates( + input_device, + input_sample_rate, + output_device, + output_sample_rate, +) + if input_sample_rate != output_sample_rate + throw( + ArgumentError( + """ +Default sample rate $input_sample_rate for input $(name(input_device)) disagrees with +default sample rate $output_sample_rate for output $(name(output_device)). +Please specify a sample rate. +""", + ), + ) + end + input_sample_rate +end + # this is the top-level outer constructor that all the other outer constructors end up calling """ - PortAudioStream(inchannels=2, outchannels=2; options...) - PortAudioStream(duplexdevice, inchannels=2, outchannels=2; options...) - PortAudioStream(indevice, outdevice, inchannels=2, outchannels=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 -operating system. If a single `duplexdevice` is given it will be used for both -input and output. If no devices are given the system default devices will be -used. +Audio devices can either be `PortAudioDevice` instances as returned by [`devices`](@ref), or strings with the device name as reported by the operating system. +Set `input_channels` to `0` for an output only stream; set `output_channels` to `0` for an input only steam. +If you pass the function `maximum` instead of a number of channels, use the maximum channels allowed by the corresponding device. +If a single `duplex_device` is given, it will be used for both input and output. +If no devices are given, the system default devices will be used. + +The `PortAudioStream` type supports all the stream and buffer features defined [SampledSignals.jl](https://github.com/JuliaAudio/SampledSignals.jl) by default. +For example, if you load SampledSignals with `using SampledSignals` you can read 5 seconds to a buffer with `buf = read(stream, 5s)`, regardless of the sample rate of the device. +`write(stream, stream)` will set up a loopback that will read from the input and play it back on the output. Options: - - `eltype`: Sample type of the audio stream (defaults to Float32) - - `samplerate`: Sample rate (defaults to device sample rate) - - `latency`: Requested latency. Stream could underrun when too low, consider - using provided device defaults - - `warn_xruns`: Display a warning if there is a stream overrun or underrun, which - often happens when Julia is compiling, or with a particularly large - GC run. This can be quite verbose so is false by default. - - `recover_xruns`: Attempt to recover from overruns and underruns by emptying and - filling the input and output buffers, respectively. Should result in - fewer xruns but could make each xrun more audible. True by default. - Only effects duplex streams. + - `adjust_channels = false`: If set to `true`, if either `input_channels` or `output_channels` exceeds the corresponding device maximum, adjust down to the maximum. + - `call_back = C_NULL`: The PortAudio call-back function. + Currently, passing anything except `C_NULL` is unsupported. + - `eltype = Float32`: Sample type of the audio stream + - `flags = PortAudio.paNoFlag`: PortAudio flags + - `frames_per_buffer = 128`: the number of frames per buffer + - `input_info = C_NULL`: host API specific stream info for the input device. + Currently, passing anything except `C_NULL` is unsupported. + - `latency = nothing`: Requested latency. Stream could underrun when too low, consider using the defaults. If left as `nothing`, use the defaults below: + - For input/output only streams, use the corresponding device's default high latency. + - For duplex streams, use the max of the default high latency of the input and output devices. + - `output_info = C_NULL`: host API specific stream info for the output device. + Currently, passing anything except `C_NULL` is unsupported. + - `reader = PortAudio.SampledSignalsReader()`: the scribe that will read input. + Defaults to a [`PortAudio.SampledSignalsReader`](@ref). + Users can pass custom scribes; see [`PortAudio.Scribe`](@ref). + - `samplerate = nothing`: Sample rate. If left as `nothing`, use the defaults below: + - For input/output only streams, use the corresponding device's default sample rate. + - For duplex streams, use the default sample rate if the default sample rates for the input and output devices match, otherwise throw an error. + - `warn_xruns = true`: Display a warning if there is a stream overrun or underrun, which often happens when Julia is compiling, or with a particularly large GC run. + Only affects duplex streams. + - `writer = PortAudio.SampledSignalsWriter()`: the scribe that will write output. + Defaults to a [`PortAudio.SampledSignalsReader`](@ref). + Users can pass custom scribes; see [`PortAudio.Scribe`](@ref). + +## Examples: + +Set up an audio pass-through from microphone to speaker + +```julia +julia> using PortAudio, SampledSignals + +julia> stream = PortAudioStream(2, 2; warn_xruns = false); + +julia> try + # cancel with Ctrl-C + write(stream, stream, 2s) + finally + close(stream) + end +``` + +Use `do` syntax to auto-close the stream + +```julia +julia> using PortAudio, SampledSignals + +julia> PortAudioStream(2, 2; warn_xruns = false) do stream + write(stream, stream, 2s) + end +``` + +Open devices by name + +```julia +using PortAudio, SampledSignals +PortAudioStream("Built-in Microph", "Built-in Output"; warn_xruns = false) do stream + write(stream, stream, 2s) +end +2 s +``` + +Record 10 seconds of audio and save to an ogg file + +```julia +julia> using PortAudio, SampledSignals, LibSndFile + +julia> PortAudioStream(2, 0; warn_xruns = false) do stream + buf = read(stream, 10s) + save(joinpath(tempname(), ".ogg"), buf) + end +2 s +``` """ function PortAudioStream( - indev::PortAudioDevice, - outdev::PortAudioDevice, - inchans = 2, - outchans = 2; + input_device::PortAudioDevice, + output_device::PortAudioDevice, + input_channels = 2, + output_channels = 2; eltype = Float32, - samplerate = -1, - latency = defaultlatency(indev, outdev), - warn_xruns = false, - recover_xruns = true, + adjust_channels = false, + call_back = C_NULL, + flags = paNoFlag, + frames_per_buffer = 128, + input_info = C_NULL, + latency = nothing, + output_info = C_NULL, + reader = SampledSignalsReader(), + samplerate = nothing, + stream_lock = ReentrantLock(), + user_data = C_NULL, + warn_xruns = true, + writer = SampledSignalsWriter(), ) - if samplerate == -1 - samplerate = combine_default_sample_rates( - inchans, - indev.defaultsamplerate, - outchans, - outdev.defaultsamplerate, - ) + input_channels_filled = fill_max_channels( + "input", + input_device, + input_device.input_bounds, + input_channels; + adjust_channels = adjust_channels, + ) + output_channels_filled = fill_max_channels( + "output", + output_device, + output_device.output_bounds, + output_channels; + adjust_channels = adjust_channels, + ) + if input_channels_filled > 0 + if output_channels_filled > 0 + if latency === nothing + latency = max( + input_device.input_bounds.high_latency, + output_device.output_bounds.high_latency, + ) + end + if samplerate === nothing + samplerate = combine_default_sample_rates( + input_device, + input_device.default_sample_rate, + output_device, + output_device.default_sample_rate, + ) + end + else + if latency === nothing + latency = input_device.input_bounds.high_latency + end + if samplerate === nothing + samplerate = input_device.default_sample_rate + end + end + else + if output_channels_filled > 0 + if latency === nothing + latency = output_device.output_bounds.high_latency + end + if samplerate === nothing + samplerate = output_device.default_sample_rate + end + else + throw(ArgumentError("Input or output must have at least 1 channel")) + end end - PortAudioStream{eltype}( - indev, - outdev, - inchans, - outchans, + # 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( + input_device, + input_channels_filled, + latency; + host_api_specific_stream_info = input_info, + ), + make_parameters( + output_device, + output_channels_filled, + latency; + Sample = eltype, + host_api_specific_stream_info = output_info, + ), + samplerate, + frames_per_buffer, + flags, + call_back, + user_data, + ), + ) + pointer_to = mutable_pointer[] + handle_status(Pa_StartStream(pointer_to)) + PortAudioStream( samplerate, - latency, - warn_xruns, - recover_xruns, + pointer_to, + # we need to keep track of the tasks so we can wait for them to finish and catch errors + messanger_task( + output_device.name, + Buffer( + stream_lock, + pointer_to, + output_channels_filled; + Sample = eltype, + frames_per_buffer = frames_per_buffer, + warn_xruns = warn_xruns, + ), + writer, + )..., + messanger_task( + input_device.name, + Buffer( + stream_lock, + pointer_to, + input_channels_filled; + Sample = eltype, + frames_per_buffer = frames_per_buffer, + warn_xruns = warn_xruns, + ), + reader, + )..., ) end # handle device names given as streams function PortAudioStream( - indevname::AbstractString, - outdevname::AbstractString, - args...; - kwargs..., + in_device_name::AbstractString, + out_device_name::AbstractString, + arguments...; + keywords..., ) - indev = nothing - outdev = nothing - for d in devices() - if d.name == indevname - indev = d - end - if d.name == outdevname - outdev = d - end - end - if indev == nothing - error("No device matching \"$indevname\" found.\nAvailable Devices:\n$(devnames())") - end - if outdev == nothing - error( - "No device matching \"$outdevname\" found.\nAvailable Devices:\n$(devnames())", - ) - end - - PortAudioStream(indev, outdev, args...; kwargs...) + PortAudioStream( + get_device(in_device_name), + get_device(out_device_name), + arguments...; + keywords..., + ) end -# if one device is given, use it for input and output, but set inchans=0 so we -# end up with an output-only stream +# if one device is given, use it for input and output function PortAudioStream( device::Union{PortAudioDevice, AbstractString}, - inchans = 2, - outchans = 2; - kwargs..., + input_channels = 2, + output_channels = 2; + keywords..., ) - PortAudioStream(device, device, inchans, outchans; kwargs...) + PortAudioStream(device, device, input_channels, output_channels; keywords...) end # use the default input and output devices -function PortAudioStream(inchans = 2, outchans = 2; kwargs...) - inidx = Pa_GetDefaultInputDevice() - indevice = PortAudioDevice(Pa_GetDeviceInfo(inidx), inidx) - outidx = Pa_GetDefaultOutputDevice() - outdevice = PortAudioDevice(Pa_GetDeviceInfo(outidx), outidx) - PortAudioStream(indevice, outdevice, inchans, outchans; kwargs...) +function PortAudioStream(input_channels = 2, output_channels = 2; keywords...) + in_index = get_default_input_index() + out_index = get_default_output_index() + PortAudioStream( + get_device(in_index), + get_device(out_index), + input_channels, + output_channels; + keywords..., + ) end # handle do-syntax -function PortAudioStream(fn::Function, args...; kwargs...) - str = PortAudioStream(args...; kwargs...) +function PortAudioStream(do_function::Function, arguments...; keywords...) + stream = PortAudioStream(arguments...; keywords...) try - fn(str) + do_function(stream) finally - close(str) + close(stream) end end function close(stream::PortAudioStream) - if stream.pointer_ref[] != C_NULL - Pa_StopStream(stream.pointer_ref[]) - Pa_CloseStream(stream.pointer_ref[]) - stream.pointer_ref[] = C_NULL + # closing is tricky, because we want to make sure we've read exactly as much as we've written + # but we have don't know exactly what the tasks are doing + # for now, just close one and then the other + fetch_messanger(stream.source_messanger, stream.source_task) + fetch_messanger(stream.sink_messanger, stream.sink_task) + pointer_to = stream.pointer_to + # only stop if it's not already stopped + if !Bool(handle_status(Pa_IsStreamStopped(pointer_to))) + handle_status(Pa_StopStream(pointer_to)) end - nothing + handle_status(Pa_CloseStream(pointer_to)) end -isopen(stream::PortAudioStream) = stream.pointer_ref[] != C_NULL +function isopen(pointer_to::Ptr{PaStream}) + # we aren't actually interested if the stream is stopped or not + # 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 + else + PaErrorCode(error_number) != paBadStreamPtr + end +end +isopen(stream::PortAudioStream) = isopen(stream.pointer_to) -SampledSignals.samplerate(stream::PortAudioStream) = stream.samplerate -eltype(stream::PortAudioStream{T}) where {T} = T +samplerate(stream::PortAudioStream) = stream.sample_rate +function eltype( + ::Type{<:PortAudioStream{<:Messanger{Sample}, <:Messanger{Sample}}}, +) where {Sample} + Sample +end -read(stream::PortAudioStream, args...) = read(stream.source, args...) -read!(stream::PortAudioStream, args...) = read!(stream.source, args...) -write(stream::PortAudioStream, args...) = write(stream.sink, args...) -function write(sink::PortAudioStream, source::PortAudioStream, args...) - write(sink.sink, source.source, args...) +# these defaults will error for non-sampledsignal scribes +# which is probably ok; we want these users to define new methods +read(stream::PortAudioStream, arguments...) = read(stream.source, arguments...) +read!(stream::PortAudioStream, arguments...) = read!(stream.source, arguments...) +write(stream::PortAudioStream, arguments...) = write(stream.sink, arguments...) +function write(sink::PortAudioStream, source::PortAudioStream, arguments...) + write(sink.sink, source.source, arguments...) end function show(io::IO, stream::PortAudioStream) - println(io, typeof(stream)) - println(io, " Samplerate: ", samplerate(stream), "Hz") - if nchannels(stream.sink) > 0 - print( - io, - "\n ", - nchannels(stream.sink), - " channel sink: \"", - name(stream.sink), - "\"", - ) + # just show the first type parameter (eltype) + print(io, "PortAudioStream{") + print(io, eltype(stream)) + println(io, "}") + print(io, " Samplerate: ", samplerate(stream), "Hz") + # show source or sink if there's any channels + sink = stream.sink + if has_channels(sink) + print(io, "\n ") + show(io, sink) end - if nchannels(stream.source) > 0 - print( - io, - "\n ", - nchannels(stream.source), - " channel source: \"", - name(stream.source), - "\"", - ) + source = stream.source + if has_channels(source) + print(io, "\n ") + show(io, source) end end @@ -353,15 +944,24 @@ end # # Define our source and sink types +# If we had multiple inheritance, then PortAudioStreams could be both a sink and source +# Since we don't, we have to make wrappers instead for (TypeName, Super) in ((:PortAudioSink, :SampleSink), (:PortAudioSource, :SampleSource)) - @eval struct $TypeName{T} <: $Super - stream::PortAudioStream{T} + @eval struct $TypeName{InputMessanger, OutputMessanger} <: $Super + stream::PortAudioStream{InputMessanger, OutputMessanger} end end # provided for backwards compatibility -function getproperty(stream::PortAudioStream, property::Symbol) - if property === :sink +# only defined for SampledSignals scribes +function getproperty( + stream::PortAudioStream{ + <:Messanger{<:Any, <:SampledSignalsWriter}, + <:Messanger{<:Any, <:SampledSignalsReader}, + }, + property::Symbol, +) + if property === :sink PortAudioSink(stream) elseif property === :source PortAudioSource(stream) @@ -370,177 +970,72 @@ function getproperty(stream::PortAudioStream, property::Symbol) end end -function Buffer{T}(device, channels) where T - # portaudio data comes in interleaved, so we'll end up transposing - # it back and forth to julia column-major - chunkbuf = zeros(T, channels, CHUNKFRAMES) - Buffer(device, chunkbuf, channels) +function nchannels(source_or_sink::PortAudioSource) + nchannels(source_or_sink.stream.source_messanger) +end +function nchannels(source_or_sink::PortAudioSink) + nchannels(source_or_sink.stream.sink_messanger) +end +function samplerate(source_or_sink::Union{PortAudioSink, PortAudioSource}) + samplerate(source_or_sink.stream) +end +function eltype( + ::Type{ + <:Union{ + <:PortAudioSink{<:Messanger{Sample}, <:Messanger{Sample}}, + <:PortAudioSource{<:Messanger{Sample}, <:Messanger{Sample}}, + }, + }, +) where {Sample} + Sample +end +function isopen(source_or_sink::Union{PortAudioSink, PortAudioSource}) + isopen(source_or_sink.stream) +end +name(source_or_sink::PortAudioSink) = name(source_or_sink.stream.sink_messanger) +name(source_or_sink::PortAudioSource) = name(source_or_sink.stream.source_messanger) + +# could show full type name, but the PortAudio part is probably redundant +# because these will usually only get printed as part of show for PortAudioStream +kind(::PortAudioSink) = "sink" +kind(::PortAudioSource) = "source" +function show(io::IO, sink_or_source::Union{PortAudioSink, PortAudioSource}) + print( + io, + nchannels(sink_or_source), + " channel ", + kind(sink_or_source), + ": ", + # put in quotes + repr(name(sink_or_source)), + ) end -SampledSignals.nchannels(s::PortAudioSource) = s.stream.source_buffer.nchannels -SampledSignals.nchannels(s::PortAudioSink) = s.stream.sink_buffer.nchannels -SampledSignals.samplerate(s::Union{PortAudioSink, PortAudioSource}) = samplerate(s.stream) -eltype(::Union{PortAudioSink{T}, PortAudioSource{T}}) where {T} = T -function close(s::Union{PortAudioSink, PortAudioSource}) - throw(ErrorException(""" - Attempted to close PortAudioSink or PortAudioSource. - Close the containing PortAudioStream instead - """)) -end -isopen(s::Union{PortAudioSink, PortAudioSource}) = isopen(s.stream) -name(s::PortAudioSink) = s.stream.sink_buffer.device.name -name(s::PortAudioSource) = s.stream.source_buffer.device.name - -function show(io::IO, ::Type{PortAudioSink{T}}) where {T} - print(io, "PortAudioSink{$T}") +# both reading and writing will outsource to the readers or writers +# so we just need to pass inputs in and take outputs out +# SampledSignals can take care of this feeding for us +function exchange(messanger, arguments...) + put!(messanger.input_channel, arguments) + take!(messanger.output_channel) end -function show(io::IO, ::Type{PortAudioSource{T}}) where {T} - print(io, "PortAudioSource{$T}") -end - -function show(io::IO, stream::T) where {T <: Union{PortAudioSink, PortAudioSource}} - print(io, nchannels(stream), "-channel ", T, "(\"", name(stream), "\")") -end - -function SampledSignals.unsafe_write( - sink::PortAudioSink, - buf::Array, - frameoffset, - framecount, +# these will only work with sampledsignals scribes +function unsafe_write( + sink::PortAudioSink{<:Messanger{<:Any, <:SampledSignalsWriter}}, + julia_buffer::Array, + already, + frame_count, ) - nwritten = 0 - sink_buffer = sink.stream.sink_buffer - while nwritten < framecount - n = min(framecount - nwritten, CHUNKFRAMES) - # make a buffer of interleaved samples - transpose!( - view(sink_buffer.chunkbuf, :, 1:n), - view(buf, (1:n) .+ nwritten .+ frameoffset, :), - ) - # TODO: if the stream is closed we just want to return a - # shorter-than-requested frame count instead of throwing an error - err = Pa_WriteStream(sink.stream.pointer_ref[], sink_buffer.chunkbuf, n, sink.stream.warn_xruns) - if err ∈ (PA_OUTPUT_UNDERFLOWED, PA_INPUT_OVERFLOWED) && sink.stream.recover_xruns - recover_xrun(sink.stream) - end - nwritten += n - end - - nwritten + exchange(sink.stream.sink_messanger, julia_buffer, already, frame_count) end -function SampledSignals.unsafe_read!( - source::PortAudioSource, - buf::Array, - frameoffset, - framecount, +function unsafe_read!( + source::PortAudioSource{<:Any, <:Messanger{<:Any, <:SampledSignalsReader}}, + julia_buffer::Array, + already, + frame_count, ) - source_buffer = source.stream.source_buffer - nread = 0 - while nread < framecount - n = min(framecount - nread, CHUNKFRAMES) - # TODO: if the stream is closed we just want to return a - # shorter-than-requested frame count instead of throwing an error - err = Pa_ReadStream( - source.stream.pointer_ref[], - source_buffer.chunkbuf, - n, - source.stream.warn_xruns, - ) - if err ∈ (PA_OUTPUT_UNDERFLOWED, PA_INPUT_OVERFLOWED) && source.stream.recover_xruns - recover_xrun(source.stream) - end - # de-interleave the samples - transpose!( - view(buf, (1:n) .+ nread .+ frameoffset, :), - view(source_buffer.chunkbuf, :, 1:n), - ) - - nread += n - end - - nread + exchange(source.stream.source_messanger, julia_buffer, already, frame_count) end -""" - prefill_output(sink::PortAudioSink) - -Fill the playback buffer of the given sink. -""" -function prefill_output(sink::PortAudioSink) - if nchannels(sink) > 0 - towrite = Pa_GetStreamWriteAvailable(sink.stream.pointer_ref[]) - sink_buffer = sink.stream.sink_buffer - while towrite > 0 - n = min(towrite, CHUNKFRAMES) - fill!(sink_buffer.chunkbuf, zero(eltype(sink_buffer.chunkbuf))) - Pa_WriteStream(sink.stream.pointer_ref[], sink_buffer.chunkbuf, n, false) - towrite -= n - end - end -end - -""" - discard_input(source::PortAudioSource) - -Read and discard data from the capture buffer. -""" -function discard_input(source::PortAudioSource) - toread = Pa_GetStreamReadAvailable(source.stream.pointer_ref[]) - source_buffer = source.stream.source_buffer - while toread > 0 - n = min(toread, CHUNKFRAMES) - Pa_ReadStream(source.stream.pointer_ref[], source_buffer.chunkbuf, n, false) - toread -= n - end -end - -function seek_alsa_conf(searchdirs) - confdir_idx = findfirst(searchdirs) do d - isfile(joinpath(d, "alsa.conf")) - end - if confdir_idx === nothing - throw( - ErrorException(""" - Could not find ALSA config directory. Searched: - $(join(searchdirs, "\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. - """), - ) - end - searchdirs[confdir_idx] -end - -function __init__() - if Sys.islinux() - envkey = "ALSA_CONFIG_DIR" - if envkey ∉ keys(ENV) - ENV[envkey] = - seek_alsa_conf(["/usr/share/alsa", "/usr/local/share/alsa", "/etc/alsa"]) - end - - plugin_key = "ALSA_PLUGIN_DIR" - if plugin_key ∉ keys(ENV) && alsa_plugins_jll.is_available() - ENV[plugin_key] = joinpath(alsa_plugins_jll.artifact_dir, "lib", "alsa-lib") - end - end - # initialize PortAudio on module load. libportaudio prints a bunch of - # junk to STDOUT on initialization, so we swallow it. - # TODO: actually check the junk to make sure there's nothing in there we - # don't expect - @stderr_as_debug Pa_Initialize() - - atexit() do - Pa_Terminate() - end -end - -end # module PortAudio \ No newline at end of file +end # module PortAudio diff --git a/src/libportaudio.jl b/src/libportaudio.jl index 930912a..38574d1 100644 --- a/src/libportaudio.jl +++ b/src/libportaudio.jl @@ -1,180 +1,328 @@ -# Low-level wrappers for Portaudio calls +module LibPortAudio -# General type aliases -const PaTime = Cdouble -const PaError = Cint -const PaSampleFormat = Culong -const PaDeviceIndex = Cint -const PaHostApiIndex = Cint -const PaHostApiTypeId = Cint -# PaStream is always used as an opaque type, so we're always dealing -# with the pointer -const PaStream = Ptr{Cvoid} -const PaStreamCallback = Cvoid -const PaStreamFlags = Culong +using libportaudio_jll +export libportaudio_jll -const paNoFlag = PaStreamFlags(0x00) - -const PA_NO_ERROR = 0 -const PA_INPUT_OVERFLOWED = -10000 + 19 -const PA_OUTPUT_UNDERFLOWED = -10000 + 20 - -# sample format types -const paFloat32 = PaSampleFormat(0x01) -const paInt32 = PaSampleFormat(0x02) -const paInt24 = PaSampleFormat(0x04) -const paInt16 = PaSampleFormat(0x08) -const paInt8 = PaSampleFormat(0x10) -const paUInt8 = PaSampleFormat(0x20) -const paNonInterleaved = PaSampleFormat(0x80000000) - -const type_to_fmt = Dict{Type, PaSampleFormat}( - Float32 => 1, - Int32 => 2, - # Int24 => 4, - Int16 => 8, - Int8 => 16, - UInt8 => 3, -) - -const PaStreamCallbackResult = Cint -# Callback return values -const paContinue = PaStreamCallbackResult(0) -const paComplete = PaStreamCallbackResult(1) -const paAbort = PaStreamCallbackResult(2) - -""" -Call the given expression in a separate thread, waiting on the result. This is -useful when running code that would otherwise block the Julia process (like a -`ccall` into a function that does IO). -""" -macro tcall(ex) - :(fetch(Base.Threads.@spawn $(esc(ex)))) +function Pa_GetVersion() + ccall((:Pa_GetVersion, libportaudio), Cint, ()) end -# because we're calling Pa_ReadStream and PA_WriteStream from separate threads, -# we put a mutex around libportaudio calls -const pamutex = ReentrantLock() +function Pa_GetVersionText() + ccall((:Pa_GetVersionText, libportaudio), Ptr{Cchar}, ()) +end -macro locked(ex) - quote - lock(pamutex) do - $(esc(ex)) - end - end +mutable struct PaVersionInfo + versionMajor::Cint + versionMinor::Cint + versionSubMinor::Cint + versionControlRevision::Ptr{Cchar} + versionText::Ptr{Cchar} +end + +# no prototype is found for this function at portaudio.h:114:22, please use with caution +function Pa_GetVersionInfo() + ccall((:Pa_GetVersionInfo, libportaudio), Ptr{PaVersionInfo}, ()) +end + +const PaError = Cint + +@enum PaErrorCode::Int32 begin + paNoError = 0 + paNotInitialized = -10000 + paUnanticipatedHostError = -9999 + paInvalidChannelCount = -9998 + paInvalidSampleRate = -9997 + paInvalidDevice = -9996 + paInvalidFlag = -9995 + paSampleFormatNotSupported = -9994 + paBadIODeviceCombination = -9993 + paInsufficientMemory = -9992 + paBufferTooBig = -9991 + paBufferTooSmall = -9990 + paNullCallback = -9989 + paBadStreamPtr = -9988 + paTimedOut = -9987 + paInternalError = -9986 + paDeviceUnavailable = -9985 + paIncompatibleHostApiSpecificStreamInfo = -9984 + paStreamIsStopped = -9983 + paStreamIsNotStopped = -9982 + paInputOverflowed = -9981 + paOutputUnderflowed = -9980 + paHostApiNotFound = -9979 + paInvalidHostApi = -9978 + paCanNotReadFromACallbackStream = -9977 + paCanNotWriteToACallbackStream = -9976 + paCanNotReadFromAnOutputOnlyStream = -9975 + paCanNotWriteToAnInputOnlyStream = -9974 + paIncompatibleStreamHostApi = -9973 + paBadBufferPtr = -9972 +end + +function Pa_GetErrorText(errorCode) + ccall((:Pa_GetErrorText, libportaudio), Ptr{Cchar}, (PaError,), errorCode) end function Pa_Initialize() - err = @locked ccall((:Pa_Initialize, libportaudio), PaError, ()) - handle_status(err) + ccall((:Pa_Initialize, libportaudio), PaError, ()) end function Pa_Terminate() - err = @locked ccall((:Pa_Terminate, libportaudio), PaError, ()) - handle_status(err) + ccall((:Pa_Terminate, libportaudio), PaError, ()) end -Pa_GetVersion() = @locked ccall((:Pa_GetVersion, libportaudio), Cint, ()) +const PaDeviceIndex = Cint -function Pa_GetVersionText() - versionPtr = @locked ccall((:Pa_GetVersionText, libportaudio), Ptr{Cchar}, ()) - unsafe_string(versionPtr) +const PaHostApiIndex = Cint + +function Pa_GetHostApiCount() + ccall((:Pa_GetHostApiCount, libportaudio), PaHostApiIndex, ()) end -# Host API Functions +function Pa_GetDefaultHostApi() + ccall((:Pa_GetDefaultHostApi, libportaudio), PaHostApiIndex, ()) +end -# A Host API is the top-level of the PortAudio hierarchy. Each host API has a -# unique type ID that tells you which native backend it is (JACK, ALSA, ASIO, -# etc.). On a given system you can identify each backend by its index, which -# will range between 0 and Pa_GetHostApiCount() - 1. You can enumerate through -# all the host APIs on the system by iterating through those values. - -# PaHostApiTypeId values -const pa_host_api_names = Dict{PaHostApiTypeId, String}( - 0 => "In Development", # use while developing support for a new host API - 1 => "Direct Sound", - 2 => "MME", - 3 => "ASIO", - 4 => "Sound Manager", - 5 => "Core Audio", - 7 => "OSS", - 8 => "ALSA", - 9 => "AL", - 10 => "BeOS", - 11 => "WDMKS", - 12 => "Jack", - 13 => "WASAPI", - 14 => "AudioScience HPI", -) +@enum PaHostApiTypeId::UInt32 begin + paInDevelopment = 0 + paDirectSound = 1 + paMME = 2 + paASIO = 3 + paSoundManager = 4 + paCoreAudio = 5 + paOSS = 7 + paALSA = 8 + paAL = 9 + paBeOS = 10 + paWDMKS = 11 + paJACK = 12 + paWASAPI = 13 + paAudioScienceHPI = 14 +end mutable struct PaHostApiInfo - struct_version::Cint - api_type::PaHostApiTypeId + structVersion::Cint + type::PaHostApiTypeId name::Ptr{Cchar} deviceCount::Cint defaultInputDevice::PaDeviceIndex defaultOutputDevice::PaDeviceIndex end -function Pa_GetHostApiInfo(i) - result = @locked ccall( +function Pa_GetHostApiInfo(hostApi) + ccall( (:Pa_GetHostApiInfo, libportaudio), Ptr{PaHostApiInfo}, (PaHostApiIndex,), - i, + hostApi, ) - if result == C_NULL - throw(BoundsError(Pa_GetHostApiInfo, i)) - end - unsafe_load(result) end -# Device Functions - -mutable struct PaDeviceInfo - struct_version::Cint - name::Ptr{Cchar} - host_api::PaHostApiIndex - max_input_channels::Cint - max_output_channels::Cint - default_low_input_latency::PaTime - default_low_output_latency::PaTime - default_high_input_latency::PaTime - default_high_output_latency::PaTime - default_sample_rate::Cdouble +function Pa_HostApiTypeIdToHostApiIndex(type) + ccall( + (:Pa_HostApiTypeIdToHostApiIndex, libportaudio), + PaHostApiIndex, + (PaHostApiTypeId,), + type, + ) end -Pa_GetDeviceCount() = @locked ccall((:Pa_GetDeviceCount, libportaudio), PaDeviceIndex, ()) - -function Pa_GetDeviceInfo(i) - result = @locked ccall( - (:Pa_GetDeviceInfo, libportaudio), - Ptr{PaDeviceInfo}, - (PaDeviceIndex,), - i, +function Pa_HostApiDeviceIndexToDeviceIndex(hostApi, hostApiDeviceIndex) + ccall( + (:Pa_HostApiDeviceIndexToDeviceIndex, libportaudio), + PaDeviceIndex, + (PaHostApiIndex, Cint), + hostApi, + hostApiDeviceIndex, ) - if result == C_NULL - throw(BoundsError(Pa_GetDeviceInfo, i)) - end - unsafe_load(result) +end + +mutable struct PaHostErrorInfo + hostApiType::PaHostApiTypeId + errorCode::Clong + errorText::Ptr{Cchar} +end + +function Pa_GetLastHostErrorInfo() + ccall((:Pa_GetLastHostErrorInfo, libportaudio), Ptr{PaHostErrorInfo}, ()) +end + +function Pa_GetDeviceCount() + ccall((:Pa_GetDeviceCount, libportaudio), PaDeviceIndex, ()) end function Pa_GetDefaultInputDevice() - @locked ccall((:Pa_GetDefaultInputDevice, libportaudio), PaDeviceIndex, ()) + ccall((:Pa_GetDefaultInputDevice, libportaudio), PaDeviceIndex, ()) end function Pa_GetDefaultOutputDevice() - @locked ccall((:Pa_GetDefaultOutputDevice, libportaudio), PaDeviceIndex, ()) + ccall((:Pa_GetDefaultOutputDevice, libportaudio), PaDeviceIndex, ()) end -# Stream Functions +const PaTime = Cdouble -mutable struct Pa_StreamParameters +const PaSampleFormat = Culong + +mutable struct PaDeviceInfo + structVersion::Cint + name::Ptr{Cchar} + hostApi::PaHostApiIndex + maxInputChannels::Cint + maxOutputChannels::Cint + defaultLowInputLatency::PaTime + defaultLowOutputLatency::PaTime + defaultHighInputLatency::PaTime + defaultHighOutputLatency::PaTime + defaultSampleRate::Cdouble +end + +function Pa_GetDeviceInfo(device) + ccall((:Pa_GetDeviceInfo, libportaudio), Ptr{PaDeviceInfo}, (PaDeviceIndex,), device) +end + +struct PaStreamParameters device::PaDeviceIndex channelCount::Cint sampleFormat::PaSampleFormat suggestedLatency::PaTime - hostAPISpecificStreamInfo::Ptr{Cvoid} + hostApiSpecificStreamInfo::Ptr{Cvoid} +end + +function Pa_IsFormatSupported(inputParameters, outputParameters, sampleRate) + ccall( + (:Pa_IsFormatSupported, libportaudio), + PaError, + (Ptr{PaStreamParameters}, Ptr{PaStreamParameters}, Cdouble), + inputParameters, + outputParameters, + sampleRate, + ) +end + +const PaStream = Cvoid + +const PaStreamFlags = Culong + +mutable struct PaStreamCallbackTimeInfo + inputBufferAdcTime::PaTime + currentTime::PaTime + outputBufferDacTime::PaTime +end + +const PaStreamCallbackFlags = Culong + +@enum PaStreamCallbackResult::UInt32 begin + paContinue = 0 + paComplete = 1 + paAbort = 2 +end + +# typedef int PaStreamCallback ( const void * input , void * output , unsigned long frameCount , const PaStreamCallbackTimeInfo * timeInfo , PaStreamCallbackFlags statusFlags , void * userData ) +const PaStreamCallback = Cvoid + +function Pa_OpenStream( + stream, + inputParameters, + outputParameters, + sampleRate, + framesPerBuffer, + streamFlags, + streamCallback, + userData, +) + ccall( + (:Pa_OpenStream, libportaudio), + PaError, + ( + Ptr{Ptr{PaStream}}, + Ptr{PaStreamParameters}, + Ptr{PaStreamParameters}, + Cdouble, + Culong, + PaStreamFlags, + Ptr{Cvoid}, + Ptr{Cvoid}, + ), + stream, + inputParameters, + outputParameters, + sampleRate, + framesPerBuffer, + streamFlags, + streamCallback, + userData, + ) +end + +function Pa_OpenDefaultStream( + stream, + numInputChannels, + numOutputChannels, + sampleFormat, + sampleRate, + framesPerBuffer, + streamCallback, + userData, +) + ccall( + (:Pa_OpenDefaultStream, libportaudio), + PaError, + ( + Ptr{Ptr{PaStream}}, + Cint, + Cint, + PaSampleFormat, + Cdouble, + Culong, + Ptr{Cvoid}, + Ptr{Cvoid}, + ), + stream, + numInputChannels, + numOutputChannels, + sampleFormat, + sampleRate, + framesPerBuffer, + streamCallback, + userData, + ) +end + +function Pa_CloseStream(stream) + ccall((:Pa_CloseStream, libportaudio), PaError, (Ptr{PaStream},), stream) +end + +# typedef void PaStreamFinishedCallback ( void * userData ) +const PaStreamFinishedCallback = Cvoid + +function Pa_SetStreamFinishedCallback(stream, streamFinishedCallback) + ccall( + (:Pa_SetStreamFinishedCallback, libportaudio), + PaError, + (Ptr{PaStream}, Ptr{Cvoid}), + stream, + streamFinishedCallback, + ) +end + +function Pa_StartStream(stream) + ccall((:Pa_StartStream, libportaudio), PaError, (Ptr{PaStream},), stream) +end + +function Pa_StopStream(stream) + ccall((:Pa_StopStream, libportaudio), PaError, (Ptr{PaStream},), stream) +end + +function Pa_AbortStream(stream) + ccall((:Pa_AbortStream, libportaudio), PaError, (Ptr{PaStream},), stream) +end + +function Pa_IsStreamStopped(stream) + ccall((:Pa_IsStreamStopped, libportaudio), PaError, (Ptr{PaStream},), stream) +end + +function Pa_IsStreamActive(stream) + ccall((:Pa_IsStreamActive, libportaudio), PaError, (Ptr{PaStream},), stream) end mutable struct PaStreamInfo @@ -184,152 +332,108 @@ mutable struct PaStreamInfo sampleRate::Cdouble end -# function Pa_OpenDefaultStream(inChannels, outChannels, -# sampleFormat::PaSampleFormat, -# sampleRate, framesPerBuffer) -# streamPtr = Ref{PaStream}(0) -# err = ccall((:Pa_OpenDefaultStream, libportaudio), -# PaError, (Ref{PaStream}, Cint, Cint, -# PaSampleFormat, Cdouble, Culong, -# Ref{Cvoid}, Ref{Cvoid}), -# streamPtr, inChannels, outChannels, sampleFormat, sampleRate, -# framesPerBuffer, C_NULL, C_NULL) -# handle_status(err) -# -# streamPtr[] -# end -# -function Pa_OpenStream( - inParams, - outParams, - sampleRate, - framesPerBuffer, - flags::PaStreamFlags, - callback, - userdata, -) - streamPtr = Ref{PaStream}(0) - err = @locked ccall( - (:Pa_OpenStream, libportaudio), +function Pa_GetStreamInfo(stream) + ccall((:Pa_GetStreamInfo, libportaudio), Ptr{PaStreamInfo}, (Ptr{PaStream},), stream) +end + +function Pa_GetStreamTime(stream) + ccall((:Pa_GetStreamTime, libportaudio), PaTime, (Ptr{PaStream},), stream) +end + +function Pa_GetStreamCpuLoad(stream) + ccall((:Pa_GetStreamCpuLoad, libportaudio), Cdouble, (Ptr{PaStream},), stream) +end + +function Pa_ReadStream(stream, buffer, frames) + ccall( + (:Pa_ReadStream, libportaudio), PaError, - ( - Ref{PaStream}, - Ref{Pa_StreamParameters}, - Ref{Pa_StreamParameters}, - Cdouble, - Culong, - PaStreamFlags, - Ref{Cvoid}, - # it seems like we should be able to use Ref{T} here, with - # userdata::T above, and avoid the `pointer_from_objref` below. - # that's not working on 0.6 though, and it shouldn't really - # matter because userdata should be GC-rooted anyways - Ptr{Cvoid}, - ), - streamPtr, - inParams, - outParams, - float(sampleRate), - framesPerBuffer, - flags, - callback === nothing ? C_NULL : callback, - userdata === nothing ? C_NULL : pointer_from_objref(userdata), - ) - handle_status(err) - streamPtr -end - -function Pa_StartStream(stream::PaStream) - err = @locked ccall((:Pa_StartStream, libportaudio), PaError, (PaStream,), stream) - handle_status(err) -end - -function Pa_StopStream(stream::PaStream) - err = @locked ccall((:Pa_StopStream, libportaudio), PaError, (PaStream,), stream) - handle_status(err) -end - -function Pa_CloseStream(stream::PaStream) - err = @locked ccall((:Pa_CloseStream, libportaudio), PaError, (PaStream,), stream) - handle_status(err) -end - -function Pa_GetStreamReadAvailable(stream::PaStream) - avail = @locked ccall( - (:Pa_GetStreamReadAvailable, libportaudio), - Clong, - (PaStream,), + (Ptr{PaStream}, Ptr{Cvoid}, Culong), stream, + buffer, + frames, ) - avail >= 0 || handle_status(avail) - avail end -function Pa_GetStreamWriteAvailable(stream::PaStream) - avail = @locked ccall( - (:Pa_GetStreamWriteAvailable, libportaudio), - Clong, - (PaStream,), +function Pa_WriteStream(stream, buffer, frames) + ccall( + (:Pa_WriteStream, libportaudio), + PaError, + (Ptr{PaStream}, Ptr{Cvoid}, Culong), stream, + buffer, + frames, ) - avail >= 0 || handle_status(avail) - avail end -function Pa_ReadStream(stream::PaStream, buf::Array, frames::Integer, show_warnings = true) - # without disable_sigint I get a segfault with the error: - # "error thrown and no exception handler available." - # if the user tries to ctrl-C. Note I've still had some crash problems with - # ctrl-C within `pasuspend`, so for now I think either don't use `pasuspend` or - # don't use ctrl-C. - err = disable_sigint() do - @tcall @locked ccall( - (:Pa_ReadStream, libportaudio), - PaError, - (PaStream, Ptr{Cvoid}, Culong), - stream, - buf, - frames, - ) - end - handle_status(err, show_warnings) - err +function Pa_GetStreamReadAvailable(stream) + ccall((:Pa_GetStreamReadAvailable, libportaudio), Clong, (Ptr{PaStream},), stream) end -function Pa_WriteStream(stream::PaStream, buf::Array, frames::Integer, show_warnings = true) - err = disable_sigint() do - @tcall @locked ccall( - (:Pa_WriteStream, libportaudio), - PaError, - (PaStream, Ptr{Cvoid}, Culong), - stream, - buf, - frames, - ) - end - handle_status(err, show_warnings) - err +function Pa_GetStreamWriteAvailable(stream) + ccall((:Pa_GetStreamWriteAvailable, libportaudio), Clong, (Ptr{PaStream},), stream) end -# function Pa_GetStreamInfo(stream::PaStream) -# infoptr = ccall((:Pa_GetStreamInfo, libportaudio), Ptr{PaStreamInfo}, -# (PaStream, ), stream) -# if infoptr == C_NULL -# error("Error getting stream info. Is the stream already closed?") -# end -# unsafe_load(infoptr) -# end -# -# General utility function to handle the status from the Pa_* functions -function handle_status(err::Integer, show_warnings::Bool = true) - if err == PA_OUTPUT_UNDERFLOWED || err == PA_INPUT_OVERFLOWED - if show_warnings - msg = - @locked ccall((:Pa_GetErrorText, libportaudio), Ptr{Cchar}, (PaError,), err) - @warn("libportaudio: " * unsafe_string(msg)) - end - elseif err != PA_NO_ERROR - msg = @locked ccall((:Pa_GetErrorText, libportaudio), Ptr{Cchar}, (PaError,), err) - throw(ErrorException("libportaudio: " * unsafe_string(msg))) +function Pa_GetSampleSize(format) + ccall((:Pa_GetSampleSize, libportaudio), PaError, (PaSampleFormat,), format) +end + +function Pa_Sleep(msec) + ccall((:Pa_Sleep, libportaudio), Cvoid, (Clong,), msec) +end + +const paNoDevice = PaDeviceIndex(-1) + +const paUseHostApiSpecificDeviceSpecification = PaDeviceIndex(-2) + +const paFloat32 = PaSampleFormat(0x00000001) + +const paInt32 = PaSampleFormat(0x00000002) + +const paInt24 = PaSampleFormat(0x00000004) + +const paInt16 = PaSampleFormat(0x00000008) + +const paInt8 = PaSampleFormat(0x00000010) + +const paUInt8 = PaSampleFormat(0x00000020) + +const paCustomFormat = PaSampleFormat(0x00010000) + +const paNonInterleaved = PaSampleFormat(0x80000000) + +const paFormatIsSupported = 0 + +const paFramesPerBufferUnspecified = 0 + +const paNoFlag = PaStreamFlags(0) + +const paClipOff = PaStreamFlags(0x00000001) + +const paDitherOff = PaStreamFlags(0x00000002) + +const paNeverDropInput = PaStreamFlags(0x00000004) + +const paPrimeOutputBuffersUsingStreamCallback = PaStreamFlags(0x00000008) + +const paPlatformSpecificFlags = PaStreamFlags(0xffff0000) + +const paInputUnderflow = PaStreamCallbackFlags(0x00000001) + +const paInputOverflow = PaStreamCallbackFlags(0x00000002) + +const paOutputUnderflow = PaStreamCallbackFlags(0x00000004) + +const paOutputOverflow = PaStreamCallbackFlags(0x00000008) + +const paPrimingOutput = PaStreamCallbackFlags(0x00000010) + +# exports +const PREFIXES = ["Pa", "pa"] +for name in names(@__MODULE__; all = true), prefix in PREFIXES + if startswith(string(name), prefix) + @eval export $name end end + +end # module diff --git a/test/runtests.jl b/test/runtests.jl index 39a5b88..ee2e5ac 100755 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1,32 +1,64 @@ #!/usr/bin/env julia - -using Logging: Debug -using PortAudio +using Base.Sys: iswindows +using Documenter: doctest using PortAudio: combine_default_sample_rates, + devices, + get_default_input_index, + get_default_output_index, + get_device, + get_input_type, + get_output_type, handle_status, - Pa_GetDefaultInputDevice, - Pa_GetDefaultOutputDevice, - Pa_GetDeviceInfo, - Pa_GetHostApiInfo, - Pa_Initialize, - PA_OUTPUT_UNDERFLOWED, - Pa_Terminate, + initialize, + PortAudioException, + PortAudio, PortAudioDevice, - recover_xrun, + PortAudioStream, + safe_load, seek_alsa_conf, - @stderr_as_debug -using SampledSignals -using Test + terminate, + name +using PortAudio.LibPortAudio: + Pa_AbortStream, + PaError, + PaErrorCode, + paFloat32, + Pa_GetDefaultHostApi, + Pa_GetDeviceInfo, + Pa_GetHostApiCount, + Pa_GetLastHostErrorInfo, + Pa_GetSampleSize, + Pa_GetStreamCpuLoad, + Pa_GetStreamInfo, + Pa_GetStreamReadAvailable, + Pa_GetStreamTime, + Pa_GetStreamWriteAvailable, + Pa_GetVersionInfo, + Pa_HostApiDeviceIndexToDeviceIndex, + paHostApiNotFound, + Pa_HostApiTypeIdToHostApiIndex, + PaHostErrorInfo, + paInDevelopment, + paInvalidDevice, + Pa_IsFormatSupported, + Pa_IsStreamActive, + paNoError, + paNoFlag, + paNotInitialized, + Pa_OpenDefaultStream, + paOutputUnderflowed, + Pa_SetStreamFinishedCallback, + Pa_Sleep, + Pa_StopStream, + PaStream, + PaStreamInfo, + PaStreamParameters, + PaVersionInfo +using SampledSignals: nchannels, s, SampleBuf, samplerate, SinSource +using Test: @test, @test_logs, @test_nowarn, @testset, @test_throws -@testset "Debug messages" begin - @test_logs (:debug, "hi") min_level = Debug @test_nowarn @stderr_as_debug begin - print(stderr, "hi") - true - end -end - -@testset "PortAudio Tests" begin +@testset "Tests without sound" begin @testset "Reports version" begin io = IOBuffer() PortAudio.versioninfo(io) @@ -36,151 +68,185 @@ end end @testset "Can list devices without crashing" begin - PortAudio.devices() + display(devices()) + println() end - @testset "Null errors" begin - @test_throws BoundsError Pa_GetDeviceInfo(-1) - @test_throws BoundsError Pa_GetHostApiInfo(-1) + @testset "libortaudio without sound" begin + @test handle_status(Pa_GetHostApiCount()) >= 0 + @test handle_status(Pa_GetDefaultHostApi()) >= 0 + # version info not available on windows? + if !Sys.iswindows() + @test safe_load(Pa_GetVersionInfo(), ErrorException("no info")) isa + PaVersionInfo + end + @test safe_load(Pa_GetLastHostErrorInfo(), ErrorException("no info")) isa + PaHostErrorInfo + @test PaErrorCode(Pa_IsFormatSupported(C_NULL, C_NULL, 0.0)) == paInvalidDevice + @test PaErrorCode( + Pa_OpenDefaultStream(Ref(C_NULL), 0, 0, paFloat32, 0.0, 0, C_NULL, C_NULL), + ) == paInvalidDevice end + + @testset "Errors without sound" begin + @test sprint(showerror, PortAudioException(paNotInitialized)) == + "PortAudioException: PortAudio not initialized" + @test_throws KeyError("foobarbaz") get_device("foobarbaz") + @test_throws KeyError(-1) get_device(-1) + @test_throws ArgumentError("Could not find alsa.conf in ()") 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 + + # make sure we can terminate, then reinitialize + terminate() + initialize() end -if !isempty(PortAudio.devices()) - # make sure we can terminate, then reinitialize - Pa_Terminate() - @stderr_as_debug Pa_Initialize() +if !isempty(devices()) + @testset "Tests with sound" begin + # these default values are specific to local machines + input_name = get_device(get_default_input_index()).name + output_name = get_device(get_default_output_index()).name - # these default values are specific to my machines - inidx = Pa_GetDefaultInputDevice() - default_indev = PortAudioDevice(Pa_GetDeviceInfo(inidx), inidx).name - outidx = Pa_GetDefaultOutputDevice() - default_outdev = PortAudioDevice(Pa_GetDeviceInfo(outidx), outidx).name - - @testset "Local Tests" begin - @testset "Open Default Device" begin + @testset "Interactive tests" begin println("Recording...") - stream = PortAudioStream(2, 0) - buf = read(stream, 5s) - close(stream) - @test size(buf) == + stream = PortAudioStream(input_name, output_name, 2, 0; adjust_channels = true) + buffer = read(stream, 5s) + @test size(buffer) == (round(Int, 5 * samplerate(stream)), nchannels(stream.source)) + close(stream) + sleep(1) println("Playing back recording...") - PortAudioStream(0, 2) do stream - write(stream, buf) + PortAudioStream(input_name, output_name, 0, 2; adjust_channels = true) do stream + write(stream, buffer) end + sleep(1) println("Testing pass-through") - stream = PortAudioStream(2, 2) + stream = PortAudioStream(input_name, output_name, 2, 2; adjust_channels = true) sink = stream.sink source = stream.source - @test sprint(show, typeof(sink)) == "PortAudioSink{Float32}" - @test sprint(show, typeof(source)) == "PortAudioSource{Float32}" - @test sprint(show, sink) == - "2-channel PortAudioSink{Float32}($(repr(default_indev)))" - @test sprint(show, source) == - "2-channel PortAudioSource{Float32}($(repr(default_outdev)))" + @test sprint(show, stream) == """ + PortAudioStream{Float32} + Samplerate: 44100.0Hz + 2 channel sink: $(repr(input_name)) + 2 channel source: $(repr(output_name))""" + @test sprint(show, source) == "2 channel source: $(repr(output_name))" + @test sprint(show, sink) == "2 channel sink: $(repr(input_name))" write(stream, stream, 5s) - recover_xrun(stream) - @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) + @test PaErrorCode(handle_status(Pa_StopStream(stream.pointer_to))) == paNoError + @test isopen(stream) close(stream) + sleep(1) @test !isopen(stream) @test !isopen(sink) @test !isopen(source) 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) - 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: "$default_outdev" - 2 channel source: "$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( - PA_OUTPUT_UNDERFLOWED, - ) - @test_throws ErrorException("libportaudio: PortAudio not initialized") handle_status( - -10000, - ) - @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(input_name, output_name, 0, 2; adjust_channels = true) do stream + write( + stream, + SinSource(eltype(stream), samplerate(stream) * 0.8, [220, 330]), + 3s, + ) + println("expected blip") + write( + stream, + SinSource(eltype(stream), samplerate(stream) * 1.2, [220, 330]), + 3s, + ) + end end + sleep(1) # 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(input_name, output_name, 0, 2; adjust_channels = true) 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 + println("expected blip") + @test fetch(frame_count_2) == 48000 + end + sleep(1) end @testset "Queued Reading" begin - stream = PortAudioStream(2, 0) - buf = SampleBuf( - rand(eltype(stream), 48000, nchannels(stream.source)) * 0.1, - samplerate(stream), + PortAudioStream(input_name, output_name, 2, 0; adjust_channels = true) 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 + sleep(1) + end + @testset "Constructors" begin + PortAudioStream(2, maximum; adjust_channels = true) do stream + @test isopen(stream) + end + PortAudioStream(output_name; adjust_channels = true) do stream + @test isopen(stream) + end + PortAudioStream(input_name, output_name; adjust_channels = true) do stream + @test isopen(stream) + end + end + @testset "Errors with sound" begin + big = typemax(Int) + @test_throws DomainError( + typemax(Int), + "$big exceeds maximum input channels for $output_name", + ) PortAudioStream(input_name, output_name, big, 0) + @test_throws ArgumentError("Input or output must have at least 1 channel") PortAudioStream( + input_name, + output_name, + 0, + 0; + adjust_channels = true, ) - t1 = @async read!(stream, buf) - t2 = @async read!(stream, buf) - @test fetch(t1) == 48000 - @test fetch(t2) == 48000 - close(stream) + @test_throws ArgumentError(""" + Default sample rate 0 for input $output_name disagrees with + default sample rate 1 for output $input_name. + Please specify a sample rate. + """) combine_default_sample_rates( + get_device(input_name), + 0, + get_device(output_name), + 1, + ) + end + @testset "libportaudio with sound" begin + @test PaErrorCode(Pa_HostApiTypeIdToHostApiIndex(paInDevelopment)) == + paHostApiNotFound + @test Pa_HostApiDeviceIndexToDeviceIndex(paInDevelopment, 0) == 0 + stream = PortAudioStream(input_name, output_name, 2, 2; adjust_channels = true) + pointer_to = stream.pointer_to + @test handle_status(Pa_GetStreamReadAvailable(pointer_to)) >= 0 + @test handle_status(Pa_GetStreamWriteAvailable(pointer_to)) >= 0 + @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 + doctest(PortAudio) end