#### RemiAudio
#### Copyright (C) 2024 Remilia Scarlet <remilia@posteo.jp>
####
#### This program is free software: you can redistribute it and/or modify it
#### under the terms of the GNU Affero General Public License as published by
#### the Free Software Foundation, either version 3 of the License, or (at your
#### option) any later version.
####
#### This program is distributed in the hope that it will be useful, but WITHOUT
#### ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
#### FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
#### License for more details.
####
#### You should have received a copy of the GNU Affero General Public License
#### along with this program. If not, see <https://www.gnu.org/licenses/>.
require "./port-lib"
module RemiAudio::Drivers::PortAudio
# :nodoc:
module PaMappable(T)
def initialize(ptr : T)
{% begin %}
{% for ivar in @type.instance_vars %}
@{{ivar.id}} = {% if ivar.type.resolve < Enum %}
{{ivar.type.id}}.new(ptr.value.{{ivar.id}}.value)
{% elsif ivar.type == String %}
String.new(ptr.value.{{ivar.id}})
{% else %}
ptr.value.{{ivar.id}}
{% end %}
{% end %}
{% end %}
end
end
# :nodoc:
protected def self.raiseIfError(&)
err = yield
if err.is_a?(Number)
if err >= -10000 && err <= -9972
raise PaError.new("#{LibPortAudio::PaError.new(err)}", err.to_i64)
else
return err
end
elsif err != LibPortAudio::PaError::NoError
raise PaError.new("PortAudio error: #{err}", err.to_i64)
end
0
end
class PaError < AudioDeviceError
getter errCode : Int64 = 0
def initialize(@message : String?)
end
def initialize(@message : String?, @errCode)
end
end
enum HostApiType
InDevelopment = 0
DirectSound
Mme
Asio
SoundManager
CoreAudio
Oss
Alsa
Al
BeOs
Wdmks
Jack
Wasapi
AudioScienceHpi
# Returns the runtine host API index associated with the `HostApiType`.
def index : Int
PortAudio.raiseIfError do
LibPortAudio.Pa_HostApiTypeIdToHostApiIndex(self.value)
end
end
end
@[Flags]
enum SampleFormat : UInt64
Float32 = 0x00000001
Int32 = 0x00000002
Int24 = 0x00000004
Int16 = 0x00000008
Int8 = 0x00000010
UInt8 = 0x00000020
#CustomFormat = 0x00010000 # Not supported
#NonInterleaved = 0x80000000 # Not supported
# Retrieves the size of a single sample for this `SampleFormat`.
def sampleSize : Int
LibPortAudio.Pa_GetSampleSize(self.value)
end
end
alias SampleRate = Int8|Int16|Int32|Int64|Int128|
UInt8|UInt16|UInt32|UInt64|UInt128|
Float32|Float64
alias SampleBuffer = Slice(Float32)|Slice(Int32)|Slice(Int16)|Slice(Int8)|Slice(UInt8)|
Array(Float32)|Array(Int32)|Array(Int16)|Array(Int8)|Array(UInt8)
alias StreamCallbackTimeInfo = LibPortAudio::StreamCallbackTimeInfo
alias StreamCallbackFlags = LibPortAudio::StreamCallbackFlags
alias PaCallback = LibPortAudio::PaCallback
@[Flags]
enum StreamFlags
NoFlag = 0
ClipOff = 0x00000001
DitherOff = 0x00000002
end
struct HostErrorInfo
include PaMappable(LibPortAudio::PHostErrorInfo)
getter hostApiTypeID : HostApiType
getter errorCode : Int64
getter errorText : String
end
struct DeviceInfo
include PaMappable(LibPortAudio::PDeviceInfo)
getter structVersion : Int32
getter name : String
getter hostApi : Int32
getter maxInputChannels : Int32
getter maxOutputChannels : Int32
getter defaultLowInputLatency : Float64
getter defaultLowOutputLatency : Float64
getter defaultHighInputLatency : Float64
getter defaultHighOutputLatency : Float64
getter defaultSampleRate : Float64
end
struct StreamParameters
property device : Int32 = 0
property channelCount : Int32 = 0
property sampleFormat : SampleFormat = SampleFormat::Float32
property suggestedLatency : Float64 = 0.0
def initialize
end
def initialize(@device : Int32, @channelCount : Int32, @sampleFormat : SampleFormat, @suggestedLatency : Float64)
end
protected def toForeign : LibPortAudio::StreamParameters
ret = LibPortAudio::StreamParameters.new
ret.device = @device
ret.channelCount = @channelCount
ret.sampleFormat = @sampleFormat.value
ret.suggestedLatency = @suggestedLatency
ret.hostApiSpecificStreamInfo = nil
ret
end
protected def toForeignPtr : Pointer(LibPortAudio::StreamParameters)
ret = Pointer(LibPortAudio::StreamParameters).malloc(1)
ret.value.device = @device
ret.value.channelCount = @channelCount
ret.value.sampleFormat = @sampleFormat.value
ret.value.suggestedLatency = @suggestedLatency
ret.value.hostApiSpecificStreamInfo = nil
ret
end
def self.checkFormatSupported(inputParams : StreamParameters?, outputParams : StreamParameters?,
sampleRate : SampleRate) : Bool
finput = inputParams ? inputParams.toForeignPtr : Pointer(LibPortAudio::StreamParameters).null
foutput = outputParams ? outputParams.toForeignPtr : Pointer(LibPortAudio::StreamParameters).null
ret = PortAudio.raiseIfError do
LibPortAudio.Pa_IsFormatSupported(finput, foutput, sampleRate.to_f64)
end
ret == LibPortAudio::FORMAT_IS_SUPPORTED
end
def self.isFormatSupported?(inputParams : StreamParameters?, outputParams : StreamParameters?,
sampleRate : SampleRate) : Bool
# ameba:disable Style/RedundantBegin
begin
self.checkFormatSupported(inputParams, outputParams, sampleRate)
rescue PaError
false
end
end
def self.isInputFormatSupported?(params : StreamParameters, sampleRate : SampleRate) : Bool
self.isFormatSupported?(params, nil, sampleRate)
end
def self.isOutputFormatSupported?(params : StreamParameters, sampleRate : SampleRate) : Bool
self.isFormatSupported?(nil, params, sampleRate)
end
end
struct HostApiInfo
include PaMappable(LibPortAudio::PHostApiInfo)
getter structVersion : Int32
getter typeId : HostApiType
getter name : String
getter deviceCount : Int32
getter defaultInputDevice : Int32
getter defaultOutputDevice : Int32
end
def self.getLastHostErrorInfo : HostErrorInfo
HostErrorInfo.new(LibPortAudio.Pa_GetLastHostErrorInfo())
end
def self.version : Int
LibPortAudio.Pa_GetVersion()
end
def self.versionStr : String
String.new(LibPortAudio.Pa_GetVersionText())
end
def self.deviceCount : Int
PortAudio.raiseIfError { LibPortAudio.Pa_GetDeviceCount() }.not_nil!
end
def self.defaultOutputDevice : Int
PortAudio.raiseIfError { LibPortAudio.Pa_GetDefaultOutputDevice() }.not_nil!
end
def self.getDeviceInfo(device : Int) : DeviceInfo
DeviceInfo.new(LibPortAudio.Pa_GetDeviceInfo(device))
end
def self.defaultInputDevice() : Int
PortAudio.raiseIfError { LibPortAudio.Pa_GetDefaultInputDevice() }.not_nil!
end
def self.hostApiInfo(apiIndex : Int) : HostApiInfo
HostApiInfo.new(LibPortAudio.Pa_GetHostApiInfo(apiIndex))
end
def self.init
PortAudio.raiseIfError { LibPortAudio.Pa_Initialize() }
end
def self.terminate
PortAudio.raiseIfError { LibPortAudio.Pa_Terminate() }
end
def self.defaultHostApi : Int
PortAudio.raiseIfError { LibPortAudio.Pa_GetDefaultHostApi() }
end
def self.hostApiDeviceIndexToDeviceIndex(hostApi : Int, hostApiDeviceIndex : Int) : Int
PortAudio.raiseIfError { LibPortAudio.Pa_HostApiDeviceIndexToDeviceIndex(hostApi, hostApiDeviceIndex) }
end
def self.sleep(msec : Int64) : Nil
LibPortAudio.Pa_Sleep(msec)
end
# Initializes PortAudio using `PortAudio.start`, then yields. This
# ensures that `PortAudio.terminate` is called before this returns.
def self.withPA(&) : Nil
init
yield
ensure
terminate
end
struct StreamInfo
include PaMappable(LibPortAudio::PStreamInfo)
getter structVersion : Int32
getter inputLatency : Float64
getter outputLatency : Float64
getter sampleRate : Float64
end
class PaStream
@handle : LibPortAudio::PPaStream = LibPortAudio::PPaStream.null
getter inputSampleFormat : SampleFormat?
getter inputChannels : Int32?
getter outputSampleFormat : SampleFormat?
getter outputChannels : Int32?
getter framesPerBuffer : UInt64
protected def initialize(@handle, @inputSampleFormat, @inputChannels, @outputSampleFormat, @outputChannels,
@framesPerBuffer)
end
protected def initialize(@inputSampleFormat, @inputChannels, @outputSampleFormat, @outputChannels,
@framesPerBuffer)
end
protected def handle : LibPortAudio::PPaStream
@handle
end
def self.openDefaultStream(numInput : Int?, numOutput : Int?, sampleFormat : SampleFormat,
sampleRate : SampleRate, framesPerBuffer : Int) : PaStream
ret : LibPortAudio::PPaStream = LibPortAudio::PPaStream.null
PortAudio.raiseIfError do
LibPortAudio.Pa_OpenDefaultStream(pointerof(ret), numInput || 0, numOutput || 0,
sampleFormat.value,
sampleRate.to_f64, framesPerBuffer.to_u64, nil, nil)
end
PaStream.new(ret, sampleFormat,
(numInput.nil? ? nil : (numInput == 0 ? nil : numInput.to_i32)),
sampleFormat,
(numOutput.nil? ? nil : (numOutput == 0 ? nil : numOutput.to_i32)),
framesPerBuffer.to_u64)
end
def self.openDefaultStream(numInput : Int?, numOutput : Int?, sampleFormat : SampleFormat,
sampleRate : SampleRate, framesPerBuffer : Int, callback : PaCallback,
userData : Pointer(Void)? = nil) : PaStream
ret : LibPortAudio::PPaStream = LibPortAudio::PPaStream.null
PortAudio.raiseIfError do
LibPortAudio.Pa_OpenDefaultStream(pointerof(ret), numInput || 0, numOutput || 0,
sampleFormat.value,
sampleRate.to_f64, framesPerBuffer.to_u64,
callback, userData)
end
PaStream.new(ret, sampleFormat,
(numInput.nil? ? nil : (numInput == 0 ? nil : numInput)),
sampleFormat,
(numOutput.nil? ? nil : (numOutput == 0 ? nil : numOutput)),
framesPerBuffer.to_u64)
end
def self.openDefaultStream(numInput : Int?, numOutput : Int?, sampleFormat : SampleFormat,
sampleRate : SampleRate, framesPerBuffer : Int,
userData : Pointer(Void)?, &block : PaCallback) : PaStream
openDefaultStream(numInput, numOutput, sampleFormat, sampleRate, framesPerBuffer, block, userData)
end
def self.openStream(inputParams : StreamParameters?, outputParams : StreamParameters?,
sampleRate : SampleRate, framesPerBuffer : Int,
streamFlags : StreamFlags) : PaStream
ret = uninitialized LibPortAudio::PaStream
finput = inputParams ? inputParams.toForeignPtr : Pointer(LibPortAudio::StreamParameters).null
foutput = outputParams ? outputParams.toForeignPtr : Pointer(LibPortAudio::StreamParameters).null
PortAudio.raiseIfError do
LibPortAudio.Pa_OpenStream(out ret, finput, foutput, sampleRate.to_f64,
framesPerBuffer.to_u64,
LibPortAudio::StreamFlags.new(streamFlags.value), nil, nil)
end
input = finput.nil? ? nil : inputParams.new(finput)
output = foutput.nil? ? nil : outputParams.new(foutput)
PaStream.new(pointerof(ret),
input.nil? ? nil : input.sampleFormat,
input.nil? ? nil : input.channelCount,
output.nil? ? nil : output.sampleFormat,
output.nil? ? nil : output.channelCount,
framesPerBuffer)
end
# Opens the default audio stream using the given options and starts it with
# `#start`. This then yields the stream. This ensures that the stream is
# both stopped with `#stop` and closed with `#close` before returning.
def self.withDefaultStream(numInput : Int?, numOutput : Int?,
*, sampleFormat : SampleFormat = SampleFormat::Float32,
sampleRate : SampleRate = 44100, framesPerBuffer : Int = 1024, &) : Nil
strm = self.openDefaultStream(numInput, numOutput, sampleFormat, sampleRate, framesPerBuffer.to_u64)
begin
strm.start
yield strm
ensure
strm.stop
strm.close
end
end
# Opens an audio stream using the given options and starts it with `#start`.
# This then yields the stream. This ensures that the stream is both stopped
# with `#stop` and closed with `#close` before returning.
def self.withStream(inputParams : StreamParameters?, outputParams : StreamParameters?,
*, sampleRate : SampleRate = 44100, framesPerBuffer : Int = 1024,
streamFlags : StreamFlags = StreamFlags::NoFlag, &) : Nil
strm = self.openStream(inputParams, outputParams, sampleRate, framesPerBuffer.to_u64, streamFlags)
begin
strm.start
yield strm
ensure
strm.stop
strm.close
end
end
# Commences audio processing
def start : Nil
PortAudio.raiseIfError { LibPortAudio.Pa_StartStream(@handle) }
end
# Terminates audio processing immediately without waiting for pending
# buffers to complete.
def abort : Nil
PortAudio.raiseIfError { LibPortAudio.Pa_AbortStream(@handle) }
end
# Terminates audio processing. It will wait until all pending audio buffers
# have been played before returning.
def stop : Nil
if @handle != LibPortAudio::PPaStream.null
PortAudio.raiseIfError { LibPortAudio.Pa_StopStream(@handle) }
end
end
def close : Nil
PortAudio.raiseIfError { LibPortAudio.Pa_CloseStream(@handle) }
@handle = LibPortAudio::PPaStream.null
end
def stopped? : Bool
case PortAudio.raiseIfError { LibPortAudio.Pa_IsStreamStopped(@handle) }
when 0 then false
when 1 then true
else raise "Unexpected PulseAudio error"
end
end
def active? : Bool
case PortAudio.raiseIfError { LibPortAudio.Pa_IsStreamActive(@handle) }
when 0 then false
when 1 then true
else raise "Unexpected PulseAudio error"
end
end
def time : Float64
LibPortAudio.Pa_GetStreamTime(@handle)
end
def writeAvailable : Int64
PortAudio.raiseIfError { LibPortAudio.Pa_GetStreamWriteAvailable(@handle) }
end
def readAvailable : Int64
PortAudio.raiseIfError { LibPortAudio.Pa_GetStreamReadAvailable(@handle) }
end
private macro defineReadFn(dataType, sampleType)
# Attempts to read samples from an input stream into the destination. The
# method doesn't return until the entire buffer has been filled - this may
# involve waiting for the operating system to supply the data.
#
# The size of the destination must be less than or equal to the number of
# frames per buffer multiplied by the channel count.
#
# On success, this returns `true`. If input data was discarded by PortAudio
# after the previous call and before this call, this returns `false`.
# Otherwise this will raise a `PaError` if another error occurs.
def read(dest : {{dataType}}) : Bool
unless @inputSampleFormat == {{sampleType}}
raise PaError.new("Attempt to read #{ {{sampleType}} } samples from an stream that does not provide #{ {{sampleType}} } input")
end
unless @inputChannels > 0
raise PaError.new("Attempt to read samples from a stream with no input channels")
end
unless dest.size < (@framesPerBuffer * @inputChannels)
raise PaError.new("Attempt to read samples into a slice that is not large enough")
end
begin
PortAudio.raiseIfError do
LibPortAudio.Pa_ReadStream(@handle, dest.to_unsafe, @framesPerBuffer)
end
true
rescue err : PaError
if err.errCode == LibPortAudio::PaError::InputOverflowed
return false
else
raise err
end
end
end
# Attempts to read samples from an input stream. The method doesn't
# return until one entire buffer has been filled - this may involve
# waiting for the operating system to supply the data. The size of
# returned data equal to the number of frames per buffer multiplied by the
# channel count.
#
# On success, this returns the batch of data. Otherwise this will raise a
# `PaError` if another error occurs.
def read(destType : {{dataType}}.class) : {{dataType}}
dest = {{dataType}}.new(@framesPerBuffer * @inputChannels, 0)
read(dest)
dest
end
end
defineReadFn(Slice(Float32), SampleFormat::Float32)
defineReadFn(Array(Float32), SampleFormat::Float32)
defineReadFn(Slice(Int32), SampleFormat::Int32)
defineReadFn(Array(Int32), SampleFormat::Int32)
defineReadFn(Slice(Int32), SampleFormat::Int24)
defineReadFn(Array(Int32), SampleFormat::Int24)
defineReadFn(Slice(Int16), SampleFormat::Int16)
defineReadFn(Array(Int16), SampleFormat::Int16)
defineReadFn(Slice(Int8), SampleFormat::Int8)
defineReadFn(Array(Int8), SampleFormat::Int8)
defineReadFn(Slice(UInt8), SampleFormat::UInt8)
defineReadFn(Array(UInt8), SampleFormat::UInt8)
private macro defineSeparationFn(dataType, innerType)
# A utility function that separates interleaved data into individual
# channels.
def self.separateArray(channelCount : Int, data : {{dataType}}) : Array({{innerType}})
if data.size != channelCount * @framesPerBuffer
raise PaError.new("Data of size #{data.size} cannot be separated into #{channelCount} channel#{channelCount == 1 ? "" : "s"} when the frames per buffer is #{framesPerBuffer}")
end
ret = [] of Array({{innerType}})
channelCount.times do |chan|
ret << Array({{innerType}}).new(@framesPerBuffer.size) do |frame|
data[(frame * channelCount) + channel]
end
end
ret
end
end
defineSeparationFn(Slice(Float32), Float32)
defineSeparationFn(Slice(Int32), Int32)
defineSeparationFn(Slice(Int16), Int16)
defineSeparationFn(Slice(Int8), Int8)
defineSeparationFn(Slice(UInt8), UInt8)
defineSeparationFn(Array(Float32), Float32)
defineSeparationFn(Array(Int32), Int32)
defineSeparationFn(Array(Int16), Int16)
defineSeparationFn(Array(Int8), Int8)
defineSeparationFn(Array(UInt8), UInt8)
private macro defineMergeFn(dataType)
# A utility function that merges individual channels into interleaved
# data.
def self.mergeArrays(channelCount : Int, *data : {{dataType}}) : {{dataType}}
if data.size != channelCount * @framesPerBuffer
raise PaError.new("Data of size #{data.size} cannot be separated into #{channelCount} channel#{channelCount == 1 ? "" : "s"} when the frames per buffer is #{framesPerBuffer}")
end
frame = 0
channel = 0
{{dataType}}.new(channelCount * @framesPerBuffer) do |_|
ret = data[channel][frame]
frame += 1
if frame == @framesPerBuffer
frame = 0
channel += 1
end
ret
end
end
end
defineMergeFn(Slice(Float32))
defineMergeFn(Slice(Int32))
defineMergeFn(Slice(Int16))
defineMergeFn(Slice(Int8))
defineMergeFn(Slice(UInt8))
defineMergeFn(Array(Float32))
defineMergeFn(Array(Int32))
defineMergeFn(Array(Int16))
defineMergeFn(Array(Int8))
defineMergeFn(Array(UInt8))
private macro defineWriteFn(dataType, sampleType)
# Writes sample data to an output stream. This function doesn't return
# until the entire buffer has been consumed - this may involve waiting for
# the operating system to consume the data. The size of the data must be
# equal to the frames per buffer multiplied by the number of output
# channels.
#
# This returns `self`.
def <<(data : {{dataType}}) : PaStream
unless @outputSampleFormat == {{sampleType}}
raise PaError.new("Cannot write #{ {{sampleType}} } data to a stream that does not accept #{ {{sampleType}} } data")
end
if data.size != (@outputChannels.not_nil! * @framesPerBuffer)
raise PaError.new("Not enough data to write to to the stream (need #{@outputChannels.not_nil! * @framesPerBuffer}, got #{data.size})")
end
PortAudio.raiseIfError do
LibPortAudio.Pa_WriteStream(@handle, data.to_unsafe, @framesPerBuffer)
end
self
end
end
defineWriteFn(Slice(Float32), SampleFormat::Float32)
defineWriteFn(Array(Float32), SampleFormat::Float32)
defineWriteFn(Slice(Int32), SampleFormat::Int32)
defineWriteFn(Array(Int32), SampleFormat::Int32)
defineWriteFn(Slice(Int32), SampleFormat::Int24)
defineWriteFn(Array(Int32), SampleFormat::Int24)
defineWriteFn(Slice(Int16), SampleFormat::Int16)
defineWriteFn(Array(Int16), SampleFormat::Int16)
defineWriteFn(Slice(Int8), SampleFormat::Int8)
defineWriteFn(Array(Int8), SampleFormat::Int8)
defineWriteFn(Slice(UInt8), SampleFormat::UInt8)
defineWriteFn(Array(UInt8), SampleFormat::UInt8)
def info : StreamInfo
StreamInfo.new(LibPortAudio.Pa_GetStreamInfo(@handle))
end
end
end