#### 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 "./ao-lib"
####
#### libao Device
####
module RemiAudio::Drivers::Ao
# The `AoDevice` class is used to playback audio using libao.
#
# As libao does not support output to Float32 directly, this class will
# convert the audio to the requested bit rate on-the-fly. It does this
# without any dithering. Additionally, only 8-bit, 16-bit, and 24-bit
# output is supported by this device.
class AoDevice < RemiAudio::Drivers::AudioDevice
# The output type of the underlying libao driver.
enum DriverType
# Live audio output.
Live = 1
# Output to a file.
File = 2
end
# The endianness of the samples.
enum Format
# Little endian samples.
Little = 1
# Big endian samples.
Big = 2
# Native endianness samples.
Native = 4
end
##
## Fields that can be set
##
# The driver ID that will be requested when `#start` is called. This
# cannot be changed once `#start` has been called. You can use
# `AoDevice.getDriverId` to get the numeric ID for a driver.
#
# # You can read more about libao drivers
# [here](https://www.xiph.org/ao/doc/drivers.html).
getter driverID : Int32 = 0
# The layout of the channels. This cannot be changed once `#start` has
# been called.
getter matrix : String = "L,R"
##
## Informational Fields
##
# The output type of the underlying libao driver.
getter type : DriverType = DriverType::Live
# The short name of the driver. This is populated after calling `#start`.
getter name : String = ""
# The short name of the driver. This is populated after calling `#start`.
getter shortName : String = ""
# The author of the driver. This is populated after calling `#start`.
getter author : String = ""
# A comment about the driver. This is populated after calling `#start`.
getter comment : String = ""
# The preferred byte ordering (endianness) for samples. Ideally,
# samples sent to `#writeBuffer` will be in the same format, but they
# don't have to be. Using samples of a different endianness may result
# in slightly higher CPU and memory usage by libao. This is populated
# after calling `#start`.
getter preferredByteFormat : Format = Format::Native
# A positive integer ranking how likely it is for this driver to be the default.
getter priority : Int32 = 0
# The possible options for thsi driver. This isn't used directly by
# RemiAudio at the moment, and is purely informational. This is
# populated after calling `#start`.
getter availableOptions : Array(String) = [] of String
##
## Internal Fields
##
@devicePtr : Libao::PDevice = Libao::PDevice.null
@foreignBuf : Array(UInt8)|Array(Int16) = Array(UInt8).new(0, 0u8)
@sampleMult : Int32 = 0
# Creates a new `AoDevice` instance.
#
# Note that only 8-bit, 16-bit, and 24-bit samples are supported, so
# **newBitDepth** must be one of these values.
def initialize(newSampleRate : Int, newBitDepth : Int, newChannels : Int)
@sampleRate = newSampleRate.to_u32.as(UInt32)
@bitDepth = newBitDepth.to_u8.as(UInt8)
@channels = newChannels.to_u8.as(UInt8)
@expectedBufferSize = @bufferSize * @channels
@outputSize = @expectedBufferSize * sizeof(Float32)
if @expectedBufferSize > Int32::MAX - 128
raise AudioDeviceError.new("Buffer size way too big")
end
end
# Attempts to lookup the numeric driver ID for a given driver. **name**
# should be the driver's short name. If the driver is not known, this
# returns nil.
#
# You can read more about libao drivers
# [here](https://www.xiph.org/ao/doc/drivers.html).
def self.getDriverID(name : String) : Int32?
ret = Libao.ao_driver_id(name.to_unsafe)
ret == -1 ? nil : ret
end
# Sets the numeric driver ID that will be used when `#start` is called.
# This cannot be changed after `#start` is called.
#
# # You can read more about libao drivers
# [here](https://www.xiph.org/ao/doc/drivers.html).
def driverID=(id : Int32) : Nil
if @started
raise AudioDeviceError.new("Cannot change the driver ID after the device has been started")
end
@driverID = value
end
# Sets the channel layout. This cannot be changed once `#start` has
# been called.
#
# Some common examples of channel orderings:
#
# * `"L,R"`: Stereo ordering in virtually all file formats
# * `"L,R,BL,BR"`: Quadraphonic ordering for most file formats
# * `"L,R,C,LFE,BR,BL"`: channel order of a 5.1 WAV or FLAC file
# * `"L,R,C,LFE,BR,BL,SL,SR"`: channel order of a 7.1 WAV or FLAC file
# * `"L,C,R,BR,BL,LFE"`: channel order of a six channel (5.1) Vorbis I file
# * `"L,C,R,BR,BL,SL,SR,LFE"`: channel order of an eight channel (7.1) Vorbis file
# * `"L,CL,C,R,RC,BC"`: channel order of a six channel AIFF[-C] file
#
# See also: [https://www.xiph.org/ao/doc/ao_sample_format.html]()
def matrix=(value : String) : Nil
if @started
raise AudioDeviceError.new("Cannot change the matrix after the device has been started")
end
@matrix = value
end
# :inherit:
def start : Nil
raise AudioDeviceError.new("Attempted to start a device twice") if @started
Libao.init
id = driverID || Libao.defaultDriverID
info : Pointer(Libao::AoInfo) = Libao.driverInfo(id)
@driverID = id
@type = DriverType.from_value?(info[0].typ) ||
raise AudioDeviceError.new("Could not determine the driver type for driver ID #{id}")
@name = String.new(info[0].name)
@shortName = String.new(info[0].shortName)
@author = String.new(info[0].author)
@comment = String.new(info[0].comment)
@preferredByteFormat = Format.from_value?(info[0].preferredByteFormat) ||
raise AudioDeviceError.new("Could not determine preferred byte format for " \
"driver ID #{id}")
@priority = info[0].priority
info[0].optionCount.times do |idx|
name = String.new(info[0].options[idx])
@availableOptions << name
end
case @bitDepth
when 8
@sampleMult = Int8::MAX.to_i32!
when 16
@sampleMult = Int16::MAX.to_i32!
when 24
@sampleMult = 8388607 # (1- (expt 2 23))
else raise AudioDeviceError.new("AoDevice: Bit depth is unsupported for this library: #{@bitDepth}")
end
spec : Libao::AoSampleFormat = Libao::AoSampleFormat.new
spec.bits = @bitDepth
spec.rate = @sampleRate.to_i32
spec.channels = @channels
spec.byteFormat = @preferredByteFormat.value
spec.matrix = @matrix.to_unsafe
@devicePtr = Libao.openLive(@driverID, pointerof(spec), Pointer(Libao::AoOption).null)
@started = true
end
# :inherit:
def stop : Nil
unless @devicePtr.null?
Libao.close(@devicePtr)
@devicePtr = Libao::PDevice.null
end
Libao.shutdown if @started
@started = false
end
# Fills the internal buffer with audio from *buf* by converting it to the
# proper format.
@[AlwaysInline]
private def fillBuf(buf : Array(Float32)|Slice(Float32)) : UInt32
len : Int32 = buf.size
foreignLen : Int32 = len
neededLen : Int32 = len
i : Int32 = 0
case @bitDepth
when 8
if @foreignBuf.size != neededLen
@foreignBuf = Array(UInt8).new(neededLen, 0u8)
end
fbuf = @foreignBuf.to_unsafe.unsafe_as(Pointer(UInt8))
while i < len
fbuf[i] = (buf.unsafe_fetch(i) * @sampleMult).to_u8!
i = i &+ 1
end
when 16
foreignLen = len * 2
if @foreignBuf.size != neededLen
@foreignBuf = Array(Int16).new(neededLen, 0i16)
end
fbuf16 = @foreignBuf.to_unsafe.unsafe_as(Pointer(Int16))
while i < len
fbuf16[i] = (buf.unsafe_fetch(i) * @sampleMult).to_i16!
i = i &+ 1
end
when 24
foreignLen = len * 3
neededLen = foreignLen
if @foreignBuf.size != neededLen
@foreignBuf = Array(UInt8).new(neededLen, 0u8)
end
di : Int32 = 0
smp : Float32 = 0.0f32
fbuf = @foreignBuf.to_unsafe.unsafe_as(Pointer(UInt8))
while i < len
smp = buf.unsafe_fetch(i)
fbuf[di ] = ((smp * @sampleMult).to_i32! & 0x0000FF).to_u8!
fbuf[di + 1] = ((smp * @sampleMult).to_i32! & 0x00FF00).unsafe_shr(8).to_u8!
fbuf[di + 2] = ((smp * @sampleMult).to_i32! & 0xFF0000).unsafe_shr(16).to_u8!
di = di &+ 3
i = i &+ 1
end
else raise AudioDeviceError.new("Bad bit depth")
end
foreignLen.to_u32!
end
# Plays back the audio in *buf* by sending it to the underlying backend.
#
# You MUST ALWAYS pass the correct buffer size to `#writeBuffer`, as defined
# by the value of `#bufferSize` multiplied by the number of `#channels`.
#
# Note: if `-Dremiaudio_wd40` is used at compile time, then the size of
# *buf* is not checked for a valid size, and it is not checked if the
# device has been started.
def writeBuffer(buf : Array(Float32)|Slice(Float32)) : Nil
{% unless flag?(:remiaudio_wd40) %}
raise AudioDeviceError.new("Device not started") unless @started
unless buf.size == @expectedBufferSize
raise AudioDeviceError.new("Buffer was the incorrect size: #{buf.size} != #{@expectedBufferSize}")
end
{% end %}
len : UInt32 = fillBuf(buf)
Libao.play(@devicePtr, @foreignBuf.to_unsafe.unsafe_as(Pointer(UInt8)), len)
end
# :ditto:
def writeBuffer(buf : Array(Int16)|Slice(Int16)) : Nil
if @bitDepth == 16
{% unless flag?(:remiaudio_wd40) %}
raise AudioDeviceError.new("Device not started") unless @started
unless buf.size == @expectedBufferSize
raise AudioDeviceError.new("Buffer was the incorrect size: #{buf.size} != #{@expectedBufferSize}")
end
{% end %}
# We can just send the buffer straight to libao.
Libao.play(@devicePtr, buf.to_unsafe.unsafe_as(Pointer(UInt8)), buf.size * sizeof(Int16))
else
super(buf)
end
end
end
end