#### Based on libsamplerate
####
#### Copyright (c) 2002-2021, Erik de Castro Lopo <erikd@mega-nerd.com>
#### Copyright (c) 2024, Remilia Scarlet <remilia@posteo.jp>
#### All rights reserved.
####
#### This code is released under 2-clause BSD license. Please see the
#### file at : https://github.com/libsndfile/libsamplerate/blob/master/COPYING
module RemiAudio::Resampler
# Used to process a block of audio data using `#process`.
private class Data
# The input audio data samples.
property dataIn : Slice(Float32)
# Where processed audio samples will be stored.
property dataOut : Slice(Float32)
# The number of frames of `#dataIn` to process.
property inputFrames : Int64 = 0i64
# The maximum number of frames to store in `#dataOut`.
property outputFrames : Int64 = 0i64
# The number of frames from `dataIn` that were actually used during
# processing. This is set by the
# `RemiAudio::Resampler::Resampler#process` method.
getter inputFramesUsed : Int64 = 0i64
protected setter inputFramesUsed : Int64
# The actual number of frames written to `dataOutn` during processing.
# This is set by the `RemiAudio::Resampler::Resampler#process` method.
getter outputFramesGen : Int64 = 0i64
protected setter outputFramesGen : Int64
# When `true`, then all of the input data was used, otherwise some of it
# went unused. This is set by the
# `RemiAudio::Resampler::Resampler#process` method.
getter? endOfInput : Bool = false
protected setter endOfInput : Bool
# The resampling ratio (`targetRate / sourceRate`).
getter srcRatio : Float64 = 0.0
# Creates a new `Data` instance that will be used to convert audio data
# from *dataIn* and store the results into *dataOut*.
def initialize(@dataIn : Slice(Float32), @dataOut : Slice(Float32))
end
protected def initialize
@dataIn = Slice(Float32).empty
@dataOut = Slice(Float32).empty
end
# Sets the ratio used for resampling (`targetRate / sourceRate`).
def srcRatio=(value : Float64) : Nil
unless RemiAudio::Resampler.validRatio?(value)
raise BadRatioError.new("Invalid ratio")
end
@srcRatio = value
end
# Resets this instance to its initial state.
@[AlwaysInline]
protected def reset : Nil
@inputFrames = 0i64
@outputFrames = 0i64
@inputFramesUsed = 0i64
@outputFramesGen = 0i64
@endOfInput = false
@srcRatio = 0.0
end
end
# Base class for all resamplers.
abstract class Resampler
@lastRatio : Float64 = 0.0
@lastPosition : Float64 = 0.0
@dataToProcess : Data?
# The number of channels this resampler works on.
getter channels : Int32
@[AlwaysInline]
protected def fmodOne(x : Float64) : Float64
res : Float64 = x - x.round
if res < 0.0
res + 1.0
else
res
end
end
# Keep the compiler happy
private def initialize(@channels : Int32)
raise "What?"
end
# Sets the ratio of the resampler. This can be changed between calls to
# process/read data and the library will try to smoothly transition between
# the conversion ratio of the last call and the conversion ratio of the
# current call.
#
# If the user wants to bypass this smooth transition and achieve a step
# response in the conversion ratio, the `ratio=` method can be used to set
# the starting conversion ratio of the next call to process/read data.
#
# This will raise a `BadRatioError` if the ratio is out of range.
def ratio=(value : Float64) : Nil
unless RemiAudio::Resampler.validRatio?(value)
raise BadRatioError.new("Invalid ratio")
end
@lastRatio = value
end
# Resets the resampler.
def reset : Nil
@lastPosition = 0.0
@lastRatio = 0.0
end
protected abstract def variProcess(data : Data) : Nil
protected def constProcess(data : Data) : Nil
variProcess(data)
end
# Resamples the audio data in *data*.
protected def process(data : Data) : Nil
data.inputFrames = 0 if data.inputFrames < 0
data.outputFrames = 0 if data.outputFrames < 0
# Check for data overlap
inStart = data.dataIn.to_unsafe.address
inEnd = (data.dataIn.to_unsafe + (data.inputFrames * @channels)).address
outStart = data.dataOut.to_unsafe.address
outEnd = (data.dataOut.to_unsafe + (data.outputFrames * @channels)).address
if inStart == outStart
raise Error.new("Input and output data are the same")
elsif inStart < outStart
if inEnd > outStart
raise Error.new("Input and output data overlaps in memory")
end
elsif outEnd > inStart
raise Error.new("Output and input data overlaps in memory")
end
# Set the input and output counts to zero.
data.inputFramesUsed = 0
data.outputFramesGen = 0
# Special case for when last_ratio has not been set.
@lastRatio = data.srcRatio if @lastRatio < (1.0 / SRC_MAX_RATIO)
# Now process
if (@lastRatio - data.srcRatio).abs < 1e-15
constProcess(data)
else
variProcess(data)
end
end
@[AlwaysInline]
protected def dataToProcess : Data
if ret = @dataToProcess
ret.reset
ret
else
@dataToProcess = Data.new
end
end
# Resamples the audio data in *source*, writing the results into *dest*,
# using the given ratio (`targetRate / sourceRate`).
#
# Note that this method is not implemented in `CallbackResampler` classes
# and will raise a `NotImplementedError` if you attempt to use it with one.
def process(source : Array(Float32)|Slice(Float32), dest : Array(Float32)|Slice(Float32),
ratio : Float64) : Tuple(Int64, Int64, Bool)
data = dataToProcess
case source
in Slice(Float32) then data.dataIn = source
in Array(Float32) then data.dataIn = Slice(Float32).new(source.to_unsafe, source.size)
end
case dest
in Slice(Float32) then data.dataOut = dest
in Array(Float32) then data.dataOut = Slice(Float32).new(dest.to_unsafe, dest.size)
end
data.inputFrames = source.size.tdiv(@channels).to_i64!
data.outputFrames = dest.size.tdiv(@channels).to_i64!
data.srcRatio = ratio
process(data)
{data.inputFramesUsed, data.outputFramesGen, data.endOfInput?}
end
# :ditto:
def process(source : Array(Float64)|Slice(Float64), dest : Array(Float64)|Slice(Float64),
ratio : Float64) : Tuple(Int64, Int64, Bool)
src32 = Slice(Float32).new(source.size) do |idx|
source.unsafe_fetch(idx).to_f32!
end
dest32 = Slice(Float32).new(dest.size, 0.0f32)
ret = process(src32, dest32, ratio)
dest32.each_with_index do |smp, idx|
dest[idx] = smp.to_f64!
end
ret
end
end
# The `CallbackResampler` mixin is used in `Resampler` subclasses that call a
# callback function to read source audio data as-needed.
module CallbackResampler
# The method called when new input data is needed.
getter callbackFunc : CallbackProc
protected property savedFrames : Int64 = 0i64
protected property savedData : Slice(Float32) = Slice(Float32).empty
@data : Data = Data.new
@tempFloat32Buf : Slice(Float32)?
protected abstract def processData : Nil
@[AlwaysInline]
def tempFloat32Buf(size : Int) : Slice(Float32)
if (ret = @tempFloat32Buf) && ret.size == size
ret
else
@tempFloat32Buf = Slice(Float32).new(size, 0.0f32)
end
end
# Reads audio data into *data* at the given ratio, returning the actual
# number of samples that were read into *data*.
#
# Note that the returned value is samples _per channel_.
def read(ratio : Float64, data : Array(Float32)|Slice(Float32)) : Int64
return 0i64 if data.empty?
frames = data.size.tdiv(@channels)
@data.reset
@data.srcRatio = ratio
@data.dataOut = case data
in Array(Float32)
Slice(Float32).new(data.to_unsafe, data.size)
in Slice(Float32)
data
end
@data.outputFrames = frames.to_i64!
@data.dataIn = @savedData
@data.inputFrames = @savedFrames
outputFramesGen : Int64 = 0i64
while outputFramesGen < frames
if @data.inputFrames == 0
@data.dataIn, @data.inputFrames = @callbackFunc.call
if @data.inputFrames == 0
@data.endOfInput = true
end
end
# Now call process function.
processData
newIdx = @data.inputFramesUsed * @channels
if newIdx < @data.dataIn.size
@data.dataIn = @data.dataIn[newIdx..]
else
@data.dataIn = Slice(Float32).empty
end
@data.inputFrames -= @data.inputFramesUsed
newIdx = @data.outputFramesGen * @channels
if newIdx < @data.dataOut.size
@data.dataOut = @data.dataOut[newIdx..]
else
@data.dataOut = Slice(Float32).empty
end
@data.outputFrames -= @data.outputFramesGen
outputFramesGen += @data.outputFramesGen
break if @data.endOfInput? && @data.outputFramesGen == 0
end
@savedData = @data.dataIn
@savedFrames = @data.inputFrames
outputFramesGen
end
# :ditto:
def read(ratio : Float64, data : Array(Float64)|Slice(Float64)) : Int64
return 0i64 if data.empty?
buf = tempFloat32Buf(data.size)
ret = read(ratio, buf)
buf.each_with_index do |smp, idx|
data[idx] = smp.to_f64!
end
ret
end
end
end