#### RemiAudio
#### Copyright (C) 2022-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 "libremiliacr"
require "../common"
# The Formats module contains routines and types for reading and writing various
# formats of audio. This does not include audio codecs.
module RemiAudio::Formats
# Base class for all errors related to `AudioFile` descendents.
class AudioFileError < RemiAudioError
end
# Represents an error that occurs when attempting to use an encoding that is
# not supported by this library.
class UnsupportedEncodingError < AudioFileError
end
# Base class for all PCM- and IEEE Float-based audio file formats.
abstract class AudioFile
enum Type
Au
Wav
end
# The bit depth of this instance.
getter bitDepth : UInt8 = 16u8
# The sample rate of this instance.
getter sampleRate : UInt32 = 44100u32
# The number of channels this instance has.
getter channels : UInt32 = 2u32
# The current number of samples.
getter numSamples : UInt64 = 0u64
# The underlying IO.
getter io : IO
@changed : Bool = false
@origIoPos : UInt64 = 0
@blockSize : UInt16 = 0
@dataStartsAt : UInt64 = 0
@dataSizeInBytes : UInt64 = 0
@sampleReadFn : Proc(Sample) = ->AudioFile.cannotReadSample
@samplesReadFn : Proc(SampleData, Int32) = ->AudioFile.cannotReadSamplesInto(SampleData)
@sampleWriteFn : Proc(Sample, Nil) = ->AudioFile.cannotWriteSample(Sample)
@samplesWriteFn : Proc(SampleData, Nil) = ->AudioFile.cannotWriteSamples(SampleData)
@sampleToF64Fn : Proc(Sample, Float64) = ->AudioFile.cannotConvertToFloat64(Sample)
@f64ToSampleFn : Proc(Float64, Sample) = ->AudioFile.cannotConvertToSample(Float64)
@sampleToF32Fn : Proc(Sample, Float32) = ->AudioFile.cannotConvertToFloat32(Sample)
@f32ToSampleFn : Proc(Float32, Sample) = ->AudioFile.cannotConvertToSample(Float32)
private def self.determineFormat(io : IO)
str = io.read_string(4)
case str
when "RIFF"
io.readUInt32 # Skip chunk size
str = io.read_string(4)
if str == "WAVE"
:wav
else
nil
end
when ".snd" then :au
else nil
end
rescue Exception
nil
end
def self.open(io : IO) : AudioFile
oldPos = io.pos
format = determineFormat(io)
io.pos = oldPos
case format
when :wav then WavFile.open(io)
when :au then AuFile.open(io)
else raise AudioFileError.new("Not a recognized audio format")
end
end
def self.open(io : IO, &) : Nil
file = self.open(io)
yield file
end
def self.open(filename : Path|String) : AudioFile
format = File.open(filename, "rb") { |io| determineFormat(io) }
case format
when :wav then WavFile.open(filename)
when :au then AuFile.open(filename)
else raise AudioFileError.new("Not a recognized audio format")
end
end
def self.open(filename : Path|String, &) : Nil
file = self.open(filename)
yield file
end
def self.test(filename : Path|String) : Type?
file = self.open(filename)
case file
in WavFile then Type::Wav
in AuFile then Type::Au
in AudioFile then raise "Unknown type"
end
rescue Exception
nil
end
private def initialize
@io = IO::Memory.new
@origIoPos = 0u64
@blockSize = 0u16
raise "Somehow triggered a private #initialize in an abstract class"
end
# Returns a `RemiAudio::SampleFormat` appropriate for this file.
abstract def sampleFormat : SampleFormat
# Returns `true` if this is a stereo audio file, or `false` otherwise.
def stereo? : Bool
self.channels == 2
end
# Returns `true` if this is a monaural audio file, or `false` otherwise.
def mono? : Bool
self.channels == 1
end
# Returns the current sample position. This is different than calling
# `io.pos` in that this is based on samples, not necessarily bytes.
@[AlwaysInline]
def pos : UInt64
(@io.pos.to_u64! - @dataStartsAt).tdiv(@blockSize)
end
# Sets the current sample position. This is different than calling `io.pos
# = value` in that this is based on samples, not necessarily bytes.
#
# This will raise an `::ArgumentError` if *value* is less than zero.
@[AlwaysInline]
def pos=(value : Int) : Nil
if value < 0
raise ::ArgumentError.new("pos cannot be negative")
end
actualPos : UInt64 = @dataStartsAt + (value * @blockSize)
if actualPos == (@dataStartsAt + @dataSizeInBytes)
actualPos -= 1
elsif actualPos > (@dataStartsAt + @dataSizeInBytes)
raise "Unexpected position"
end
@io.pos = actualPos
end
# Rewinds the internal read cursor to the beginning of the sample data.
def rewind : Nil
self.pos = 0
end
# Temporary seeks to the given sample number, yields the block, then returns
# to the previous sample number. This returns the final value in the block.
def withExcursion(newSamplePos : Int32, &)
oldPos = self.pos
begin
self.pos = newSamplePos
yield
ensure
self.pos = oldPos
end
end
# Seeks to the end of the samples. Writing a sample after this will add a
# new sample into this instance.
def toEnd : Nil
self.pos = @numSamples
end
# Loops over all of the samples within this instance, yielding each one to
# the block as a `::Float64` sample value.
#
# This will call `#withExcursion` to set the position to the first sample,
# so once the block finishes, the internal read cursor will be back at its
# original position.
def each(& : Float64 ->) : Nil
withExcursion(0) do
while smp = read?
yield smp
end
end
end
# Loops over all of the samples within this instance, yielding each one to
# the block as a `::Float32` sample value.
#
# This will call `#withExcursion` to set the position to the first sample,
# so once the block finishes, the internal read cursor will be back at its
# original position.
def eachF32(& : Float32 ->) : Nil
withExcursion(0) do
while smp = readF32?
yield smp
end
end
end
# Loops over all of the samples within this instance, yielding each one to
# the block as a `RemiAudio::Sample` value.
#
# This will call `#withExcursion` to set the position to the first sample,
# so once the block finishes, the internal read cursor will be back at its
# original position.
def eachSample(& : Sample ->) : Nil
withExcursion(0) do
while smp = readSample?
yield smp
end
end
end
# Reads a single sample and returns it as a `::Float64` sample value. If no
# more samples are available, this raises an `::IO::EOFError`.
def read : Float64
self.read? || raise ::IO::EOFError.new("No more samples")
end
# Attempts to read a single sample, returning it as a `::Float64` sample
# value on success, or `nil` if no more samples can be read.
@[AlwaysInline]
def read? : Float64?
readSample?.try do |smp|
@sampleToF64Fn.call(smp)
end
end
# Reads a single sample and returns it as a `::Float32` sample value. If no
# more samples are available, this raises an `::IO::EOFError`.
def readF32 : Float32
self.readF32? || raise ::IO::EOFError.new("No more samples")
end
# Attempts to read a single sample, returning it as a `::Float32` sample
# value on success, or `nil` if no more samples can be read.
@[AlwaysInline]
def readF32? : Float32?
readSample?.try do |smp|
@sampleToF32Fn.call(smp)
end
end
# Reads a single sample and returns it as a `RemiAudio::Sample` sample
# value. If no more samples are available, this raises an `::IO::EOFError`.
def readSample : Sample
self.readSample? || raise ::IO::EOFError.new("No more samples")
end
# Attempts to read a single sample, returning it as a `RemiAudio::Sample`
# sample value on success, or `nil` if no more samples can be read.
@[AlwaysInline]
def readSample? : Sample?
@sampleReadFn.call
rescue IO::EOFError
nil
end
# Reads a single sample from an arbitrary point in the file and returns it
# as a `::Float64` sample value. This does not change the current read
# position, thus allowing random access reading.
def read(pos : Int) : Float64
withExcursion(pos.to_i32) { read }
end
# Reads up to `dest.size` samples as `::Float64` sample values into *dest*.
# The actual number of samples read is then returned.
abstract def read(dest : Array(Float64)) : Int32
# Reads up to `dest.size` samples as `::Float32` sample values into *dest*.
# The actual number of samples read is then returned.
abstract def read(dest : Array(Float32)) : Int32
# Reads a single sample from an arbitrary point in the file. This does not
# change the current read position, thus allowing random access reading.
def readSample(pos : Int) : Sample
withExcursion(pos.to_i32) { readSample }
end
# Reads up to `dest.size` samples as `RemiAudio::Sample` values into *dest*.
# The actual number of samples read is then returned.
def readSamples(dest : SampleData) : Int32
@samplesReadFn.call(dest)
end
# Reads all remaining samples, returning them as `::Float64` sample values.
def readToEnd : Array(Float64)
curSample = self.pos
toRead = @numSamples - curSample
Array(Float64).new(toRead) { |_| read }
end
# Reads all remaining samples, returning them as `::Float32` sample values.
def readToEndF32 : Array(Float32)
curSample = self.pos
toRead = @numSamples - curSample
Array(Float32).new(toRead) { |_| readF32 }
end
# Reads all remaining samples, returning them as `RemiAudio::SampleData`.
abstract def readSamplesToEnd : SampleData
# Writes a single sample, then returns `self`.
@[AlwaysInline]
def write(value : Float64) : self
writeSample(@f64ToSampleFn.call(value))
@changed = true
self
end
# Writes a single sample, then returns `self`.
@[AlwaysInline]
def write(value : Float32) : self
writeSample(@f32ToSampleFn.call(value))
@changed = true
self
end
# :ditto:
@[AlwaysInline]
def writeSample(value : Sample) : self
@sampleWriteFn.call(value)
@changed = true
self
end
# Writes *samples*, then returns `self`.
@[AlwaysInline]
def write(samples : Array(Float64)) : self
samples.each { |smp| @sampleWriteFn.call(@f64ToSampleFn.call(smp)) }
@changed = true
self
end
# :ditto:
@[AlwaysInline]
def write(samples : Array(Float32)) : self
samples.each { |smp| @sampleWriteFn.call(@f32ToSampleFn.call(smp)) }
@changed = true
self
end
# :ditto:
@[AlwaysInline]
def writeSamples(samples : SampleData) : self
samples.each { |smp| @sampleWriteFn.call(smp) }
@changed = true
self
end
# Flushes the underlying `IO` and updates any header fields.
abstract def flush : Nil
# Flushes the underlying `IO` then closes it.
def close : Nil
unless @io.closed?
self.flush
@io.close
end
end
# Same as `#write`.
@[AlwaysInline]
def <<(sample : Float64) : self
write(sample)
end
# :ditto:
@[AlwaysInline]
def <<(sample : Float32) : self
write(sample)
end
# Writes the current state of this file to *dest*. This will automatically
# call `#flush` before writing.
abstract def copyTo(dest : IO) : Nil
############################################################################
# Attempts to read a "four CC" string, as used in RIFF formats. If four
# bytes cannot be read, this returns `nil`.
protected def readFourCC : String?
data = Bytes.new(4)
numRead = @io.read(data)
return nil unless numRead == 4
data.map! do |val|
unless (32 <= val) && (val <= 126)
'?'.ord.to_u8
else
val
end
end
String.new(data)
end
# Attempts to read a RIFF Chunk ID. The chunk ID must be equal to
# `chunkType`. If it is, this returns the chunk that was read, otherwise
# this raises an `AudioFileError`.
#
# If `message` is specified, then the error uses that as the message,
# otherwise it uses a more generic error message that lists what it found
# and the expected chunk name.
protected def expectChunkType(chunkType : String, message : String? = nil) : String?
chunk = readFourCC
unless chunk == chunkType
raise AudioFileError.new(message || "The chunk type must be '#{chunkType}', not '#{chunk}'")
end
chunk
end
# Attempts to read a RIFF Chunk ID. The chunk ID must be one of the chunk
# names listed in `chunkTypes`. If it is, this returns the chunk that was
# read, otherwise this raises an `AudioFileError`.
#
# If `message` is specified, then the error uses that as the message,
# otherwise it uses a more generic error message that lists what it found
# and the expected chunk name.
protected def expectChunkType(chunkTypes : Array(String), message : String? = nil) : String?
chunk = readFourCC
unless chunkTypes.includes?(chunk)
raise AudioFileError.new(message || "The chunk type must be one of '#{chunkTypes}', not '#{chunk}'")
end
chunk
end
############################################################################
{% begin %}
{% for fn in [[:int8ToF64, Int8, Int8::MAX],
[:int16ToF64, Int16, Int16::MAX],
[:int24ToF64, Int32, (2.0 ** 23) - 1],
[:int32ToF64, Int32, Int32::MAX],
[:int64ToF64, Int64, Int64::MAX]] %}
protected def {{fn[0].id}}(smp : Sample) : Float64
smp.as({{fn[1].id}}) / {{fn[2]}}
end
{% end %}
{% end %}
protected def uint8ToF64(smp : Sample) : Float64
(smp.as(UInt8).to_i16 - 128) / Int8::MAX
end
protected def float32ToF64(smp : Sample) : Float64
smp.as(Float32).to_f64!
end
protected def float64ToF64(smp : Sample) : Float64
smp.as(Float64)
end
############################################################################
{% begin %}
{% for fn in [[:int8ToF32, Int8, Int8::MAX],
[:int16ToF32, Int16, Int16::MAX],
[:int24ToF32, Int32, (2.0 ** 23) - 1],
[:int32ToF32, Int32, Int32::MAX],
[:int64ToF32, Int64, Int64::MAX]] %}
protected def {{fn[0].id}}(smp : Sample) : Float32
(smp.as({{fn[1].id}}) / {{fn[2]}}).to_f32!
end
{% end %}
{% end %}
protected def uint8ToF32(smp : Sample) : Float32
((smp.as(UInt8).to_i16 - 128) / Int8::MAX).to_f32!
end
protected def float32ToF32(smp : Sample) : Float32
smp.as(Float32)
end
protected def float64ToF32(smp : Sample) : Float32
smp.as(Float64).to_f32!
end
############################################################################
{% begin %}
{% for fn in [[:f64ToInt8, :to_i8!, Int8::MAX],
[:f64ToInt16, :to_i16!, Int16::MAX],
[:f64ToInt24, :to_i32!, (2.0 ** 23) - 1],
[:f64ToInt32, :to_i32!, Int32::MAX],
[:f64ToInt64, :to_i64!, Int64::MAX]] %}
protected def {{fn[0].id}}(smp : Float64) : Sample
(smp * {{fn[2]}}).{{fn[1].id}}.as(Sample)
end
{% end %}
{% end %}
protected def f64ToFloat32(smp : Float64) : Sample
smp.to_f32!.as(Sample)
end
protected def f64ToFloat64(smp : Float64) : Sample
smp.as(Sample)
end
protected def f64ToUInt8(smp : Sample) : Sample
(smp * Int8::MAX + 128).to_u8!.as(Sample)
end
############################################################################
{% begin %}
{% for fn in [[:f32ToInt8, :to_i8!, Int8::MAX],
[:f32ToInt16, :to_i16!, Int16::MAX],
[:f32ToInt24, :to_i32!, (2.0 ** 23) - 1],
[:f32ToInt32, :to_i32!, Int32::MAX],
[:f32ToInt64, :to_i64!, Int64::MAX]] %}
protected def {{fn[0].id}}(smp : Float32) : Sample
(smp * {{fn[2]}}).{{fn[1].id}}.as(Sample)
end
{% end %}
{% end %}
protected def f64ToFloat32(smp : Float64) : Sample
smp.to_f32!.as(Sample)
end
protected def f32ToFloat32(smp : Float32) : Sample
smp.as(Sample)
end
protected def f32ToUInt8(smp : Sample) : Sample
(smp * Int8::MAX + 128).to_u8!.as(Sample)
end
############################################################################
{% begin %}
{% for fn in [[:fastReadSampleUInt8, :readUInt8],
[:fastReadSampleInt8, :readInt8],
[:fastReadSampleInt16, :readInt16],
[:fastReadSampleInt24, :readInt24],
[:fastReadSampleInt32, :readInt32],
[:fastReadSampleInt64, :readInt64],
[:fastReadSampleFloat32, :readFloat32],
[:fastReadSampleFloat64, :readFloat64],
[:fastReadSampleInt16BE, :readInt16BE],
[:fastReadSampleInt32BE, :readInt32BE],
[:fastReadSampleInt64BE, :readInt64BE],
[:fastReadSampleFloat32BE, :readFloat32BE],
[:fastReadSampleFloat64BE, :readFloat64BE]] %}
{% name = fn[0] %}
{% readFn = fn[1] %}
@[AlwaysInline]
protected def {{name.id}} : Sample
@io.{{readFn.id}}.as(Sample)
end
{% end %}
{% end %}
@[AlwaysInline]
protected def fastReadSampleInt24BEFourByte : Sample
(@io.readInt32BE & 0xFFFFFF).as(Sample)
end
############################################################################
{% begin %}
{% for fn in [[:fastReadSamplesUInt8, UInt8, :fastReadSampleUInt8],
[:fastReadSamplesInt8, Int8, :fastReadSampleInt8],
[:fastReadSamplesInt16, Int16, :fastReadSampleInt16],
[:fastReadSamplesInt24, Int32, :fastReadSampleInt24],
[:fastReadSamplesInt32, Int32, :fastReadSampleInt32],
[:fastReadSamplesInt64, Int64, :fastReadSampleInt64],
[:fastReadSamplesFloat32, Float32, :fastReadSampleFloat32],
[:fastReadSamplesFloat64, Float64, :fastReadSampleFloat64],
[:fastReadSamplesInt16BE, Int16, :fastReadSampleInt16BE],
[:fastReadSamplesInt24BEFourByte, Int32, :fastReadSampleInt24BEFourByte],
[:fastReadSamplesInt32BE, Int32, :fastReadSampleInt32BE],
[:fastReadSamplesInt64BE, Int64, :fastReadSampleInt64BE],
[:fastReadSamplesFloat32BE, Float32, :fastReadSampleFloat32BE],
[:fastReadSamplesFloat64BE, Float64, :fastReadSampleFloat64BE]] %}
{% name = fn[0] %}
{% typ = fn[1] %}
{% readFn = fn[2] %}
protected def {{name.id}}(dest : SampleData) : Int32
i : Int32 = 0
smp : Sample?
destPtr = dest.as(Array({{typ.id}})).to_unsafe
begin
until i == dest.size
if smp = {{readFn.id}}
destPtr[i] = smp.as({{typ.id}})
else
break
end
i += 1
end
rescue IO::EOFError
end
i
end
{% end %}
{% end %}
protected def fastReadSamplesInt24BEFourByte(dest : SampleData) : Int32
i : Int32 = 0
smp : Int32?
destPtr = dest.as(Array(Int32)).to_unsafe
begin
until i == dest.size
smp = @io.readInt24
if smp && @io.read_byte
destPtr[i] = smp.as(Int32)
else
break
end
i += 1
end
rescue IO::EOFError
end
i
end
############################################################################
{% begin %}
{% for fn in [[:fastWriteSampleUInt8, UInt8, :write_byte],
[:fastWriteSampleInt8, Int8, :writeInt8],
[:fastWriteSampleInt16, Int16, :writeInt16],
[:fastWriteSampleInt24, Int32, :writeInt24],
[:fastWriteSampleInt32, Int32, :writeInt32],
[:fastWriteSampleInt64, Int64, :writeInt64],
[:fastWriteSampleFloat32, Float32, :writeFloat32],
[:fastWriteSampleFloat64, Float64, :writeFloat64],
[:fastWriteSampleInt16BE, Int16, :writeInt16BE],
[:fastWriteSampleInt32BE, Int32, :writeInt32BE],
[:fastWriteSampleInt64BE, Int64, :writeInt64BE],
[:fastWriteSampleFloat32BE, Float32, :writeFloat32BE],
[:fastWriteSampleFloat64BE, Float64, :writeFloat64BE]] %}
{% name = fn[0] %}
{% typ = fn[1] %}
{% writeFn = fn[2] %}
@[AlwaysInline]
protected def {{name.id}}(smp : Sample) : Nil
@io.{{writeFn.id}}(smp.as({{typ.id}}))
@numSamples += 1
end
{% end %}
{% end %}
@[AlwaysInline]
protected def fastWriteSampleInt24BEFourByte(smp : Sample) : Nil
@io.writeInt24BE(smp.as(Int32) & 0xFFFFFF)
@io.write_byte(0u8)
@numSamples += 1
end
############################################################################
{% begin %}
{% for fn in [[:fastWriteSamplesUInt8, UInt8, :write_byte],
[:fastWriteSamplesInt8, Int8, :writeInt8],
[:fastWriteSamplesInt16, Int16, :writeInt16],
[:fastWriteSamplesInt24, Int32, :writeInt24],
[:fastWriteSamplesInt32, Int32, :writeInt32],
[:fastWriteSamplesInt64, Int64, :writeInt64],
[:fastWriteSamplesFloat32, Float32, :writeFloat32],
[:fastWriteSamplesFloat64, Float64, :writeFloat64],
[:fastWriteSamplesInt16BE, Int16, :writeInt16BE],
[:fastWriteSamplesInt32BE, Int32, :writeInt32BE],
[:fastWriteSamplesInt64BE, Int64, :writeInt64BE],
[:fastWriteSamplesFloat32BE, Float32, :writeFloat32BE],
[:fastWriteSamplesFloat64BE, Float64, :writeFloat64BE]] %}
{% name = fn[0] %}
{% typ = fn[1] %}
{% writeFn = fn[2] %}
@[AlwaysInline]
protected def {{name.id}}(samples : SampleData) : Nil
samples.each { |smp| @io.{{writeFn.id}}(smp.as({{typ.id}})) }
@numSamples += samples.size
end
{% end %}
{% end %}
@[AlwaysInline]
protected def fastWriteSamplesInt24BEFourByte(samples : SampleData) : Nil
samples.each do |smp|
@io.writeInt24BE(smp.as(Int32) & 0xFFFFFF)
@io.write_byte(0u8)
end
@numSamples += samples.size
end
############################################################################
protected def self.cannotConvertToFloat64(smp : Sample) : Float64
raise "Cannot convert this encoding to Float64 natively"
end
protected def self.cannotConvertToFloat32(smp : Sample) : Float32
raise "Cannot convert this encoding to Float32 natively"
end
protected def self.cannotConvertToSample(smp : Float64) : Sample
raise "Cannot convert a Float64 to this encoding natively"
end
protected def self.cannotConvertToSample(smp : Float32) : Sample
raise "Cannot convert a Float32 to this encoding natively"
end
protected def self.cannotReadSample : Sample
raise "Cannot read a sample"
end
protected def self.cannotWriteSample(smp : Sample) : Nil
raise "Cannot write a sample"
end
protected def self.cannotReadSamplesInto(data : SampleData) : Int32
raise "Cannot read samples"
end
protected def self.cannotWriteSamples(smps : SampleData) : Nil
raise "Cannot write samples"
end
end
end