Artifact cf8bcf699c1cd486cb4fc03a07ab9a345cd86b32cb6c17b78f66dc8234fbad36:

  • File src/remiaudio/drivers/ao.cr — part of check-in [9b3d491963] at 2024-10-29 05:09:17 on branch trunk — When using libao, send Int16 buffers straight to it. Also expand the convenience methods to UInt8 and Int24 samples. (user: alexa size: 11259)

#### 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