#### Benben
#### Copyright (C) 2023-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/>.
####
#### Rendering Job
####
#### Rendering goes through these steps:
#### 1. Render to an interleaved float64 buffer.
#### 2. Process the main buffer with the effects (EQ, reverb, etc.)
#### 3. For each element in main buffer, call a conversion function to convert
#### it to the correct sample format, write to a stream.
####
module Benben::Rendering
# For shorter typing.
alias AudioFile = RemiAudio::Formats::AudioFile
abstract class Job
protected getter outFilename : Path
protected getter? errored : Bool = false
protected getter source : PlayableFile
@doneChan : JobChannel
@nonLoopingExtraSamples : UInt64 = 0u64
@maxLoops : UInt32 = 1
@ditherer : RemiAudio::DSP::Ditherer?
@min : Float64 = Float64::MAX
@max : Float64 = Float64::MIN
@inFilename : Path
@totalSampleSize : UInt32? = nil
@totalSamples : UInt64 = 0u64
@eq : RemiAudio::DSP::ParaEQStereo
# Our settings that we use for playback for this render job.
@settings : EphemeralConfig
# For those files that need to have resampling done by us.
@resampler : RemiAudio::Resampler::CallbackResampler?
@resampRatio : Float64 = 1.0
# Keep the compiler happy.
private def initialize(@source, @inFilename, @settings, @eq, @doneChan, @outFilename)
raise NotImplementedError.new("Not implemented")
end
###
### Protected Methods
###
protected def getStream(filename : Path|String|IO) : AudioFile
if Benben.args["au"].called?
# Get the encoding we need for the stream.
auFmt : RemiAudio::Formats::AuEncoding =
if Benben.args["float"].called?
if Benben.args["bit-depth"].str.to_i32 == 32
RemiAudio::Formats::AuEncoding::Float32
else
RemiAudio::Formats::AuEncoding::Float64
end
else
case Benben.args["bit-depth"].str.to_i32
when 8 then RemiAudio::Formats::AuEncoding::Lpcm8bit
when 16 then RemiAudio::Formats::AuEncoding::Lpcm16bit
when 24 then RemiAudio::Formats::AuEncoding::Lpcm24bit
when 32 then RemiAudio::Formats::AuEncoding::Lpcm32bit
else RemiLib.log.fatal("Unsupported bit depth for Au rendering")
end
end
# Create the stream. The total samples are multiplied by 2 since we use
# two channels.
ret : AudioFile = RemiAudio::Formats::AuFile.create(filename,
sampleRate: @settings.sampleRate,
encoding: auFmt,
note: "Created with Benben v#{VERSION}")
else
# Get the encoding we need for the stream.
wavFmt : RemiAudio::Formats::WavEncoding =
if Benben.args["float"].called?
RemiAudio::Formats::WavEncoding::Float
else
RemiAudio::Formats::WavEncoding::Lpcm
end
# Create the stream. The total samples are multiplied by 2 since we use
# two channels.
ret = RemiAudio::Formats::WavFile.create(filename,
sampleRate: @settings.sampleRate,
bitDepth: Benben.args["bit-depth"].str.to_u8,
encoding: wavFmt)
end
ret
end
protected def min : Float64
RemiAudio.linearToDecibels(@min.abs)
end
protected def max : Float64
RemiAudio.linearToDecibels(@max.abs)
end
protected def normalizeGain : Float64
gain = Math.max(self.min, self.max).abs
RemiAudio.decibelsToLinear(gain)
end
protected def renderCb : Tuple(Slice(Float32), Int64)
{Slice(Float32).empty, 0i64}
end
@[AlwaysInline]
protected def renderBufSlice(buf : Array(Float32)) : Slice(Float32)
Slice.new(buf.to_unsafe, buf.size)
end
protected def initResampler(srcSampleRate, resampType : ResamplerType) : Nil
@resampRatio = @settings.sampleRate / srcSampleRate
case resampType
in .linear?
@resampler = RemiAudio::Resampler::LinearResamplerCb.new(2, @resampRatio, ->renderCb) # 2 = channels
in .zero_order_hold?
@resampler = RemiAudio::Resampler::ZohResamplerCb.new(2, @resampRatio, ->renderCb) # 2 = channels
in .sinc_fastest?
@resampler = RemiAudio::Resampler::SincResamplerStereoCb.new(:fast, @resampRatio, ->renderCb)
in .sinc_medium?
@resampler = RemiAudio::Resampler::SincResamplerStereoCb.new(:medium, @resampRatio, ->renderCb)
in .sinc_best?
@resampler = RemiAudio::Resampler::SincResamplerStereoCb.new(:best, @resampRatio, ->renderCb)
end
# Account for the sample rate difference
@totalSamples = (@totalSamples * (@settings.sampleRate / srcSampleRate) + 0.5).to_u64!
end
def normalizeFile(buf : Array(Float64), errors : Array(RenderingError), bar : RemiLib::Console::ProgressBar(UInt64)?) : Nil
gain : Float64 = self.normalizeGain # The gain we need to reach 0dBFS
tempFile : File? = nil
# We'll read from the originally rendered file, and write to a
# temporary file.
tempFile = File.tempfile("BENBEN-", "-NORM.wav") do |file|
RemiAudio::Formats::AudioFile.open(self.outFilename) do |orig|
aud = self.getStream(file)
begin
pos : UInt64 = 0
numRead : Int32 = 0
# Normalize all samples
while pos < orig.numSamples
numRead = orig.read(buf)
break if numRead == 0
buf.map!(&.*(gain))
if numRead == buf.size
aud.write(buf)
else
aud.write(buf[...numRead])
end
pos += numRead
bar.try { |pb| pb.step = Math.min(pb.step + numRead, pb.max) }
end
ensure
aud.close
end
end
end
# Copy the temporary file to the destination name overwriting the
# originally rendered file.
tempFile.try do |tf|
File.copy(tf.path, self.outFilename)
end
rescue err : Exception
errors << NormalizationError.new(self.outFilename, "Could not normalize (#{err.class}): #{err} #{err.backtrace}", err)
ensure
# Delete any existing temporary file for cleanup
tempFile.try { |tf| File.delete(tf.path) }
end
def self.adjustOutputPathForFile(outputPath : String, pfile : PlayableFile) : String
case pfile
in VgmFile? then VgmJob.adjustOutputPathForFile(outputPath, pfile)
in OpusFile then outputPath
in VorbisFile then outputPath
in Mpeg1File then outputPath
in FlacFile then outputPath
in ModuleFile then outputPath
in PcmFile then outputPath
in MidiFile then outputPath
in PlayableFile then raise "Case statement was not updated to take care of files of class #{typeof(pfile)} / #{pfile.class}"
end
end
###
### Sample Conversions/Dithering
###
protected def getConversionFn
if Benben.args["float"].called?
Benben.args.withIntArg("bit-depth") do |arg|
if arg.value == 32
->convToFloat32(Float64)
else
->convIdentity(Float64)
end
end
else
Benben.args.withIntArg("bit-depth") do |arg|
case arg.value
when 8
if Benben.args["au"].called?
->convToInt8(Float64)
else
->convToUInt8(Float64)
end
when 16 then ->convToInt16(Float64)
when 24 then ->convToInt24(Float64)
when 32 then ->convToInt32(Float64)
when 64 then ->convToInt64(Float64)
else raise "Bad bit depth argument: #{arg.value}"
end
end
end
end
@[AlwaysInline]
protected def ditherer
if @ditherer
@ditherer.not_nil!
else
@ditherer = RemiAudio::DSP::Ditherer.new
end
end
protected def convIdentity(sample : Float64)
sample
end
protected def convToFloat32(sample : Float64)
sample.to_f32!
end
protected def convToInt8(sample : Float64)
ditherer.ditherOrConvert(sample, RemiAudio::SampleFormat::F64, RemiAudio::SampleFormat::I8).to_i8!
end
protected def convToUInt8(sample : Float64)
ditherer.ditherOrConvert(sample, RemiAudio::SampleFormat::F64, RemiAudio::SampleFormat::U8).to_u8!
end
protected def convToInt16(sample : Float64)
# It was originally Int16, so no need to dither.
(sample * Int16::MAX).to_i16!
end
protected def convToInt24(sample : Float64)
# It was originally Int16, so no need to dither.
(sample * 8388607).to_i32!
end
protected def convToInt32(sample : Float64)
# It was originally Int16, so no need to dither.
(sample * Int32::MAX).to_i32!
end
protected def convToInt64(sample : Float64)
# It was originally Int16, so no need to dither.
(sample * Int64::MAX).to_i64!
end
###
### Abstract Methods
###
abstract def generateCue(cue : RemiAudio::Cue, cuePath : Path, idx : Int) : Nil
abstract def calcTotalSampleSize : UInt32
abstract def render(statusChan : ProgressChan) : Nil
end
end
require "./vgmjob"
require "./modulejob"
require "./flacjob"
require "./vorbisjob"
require "./opusjob"
require "./mpeg1job"
require "./midijob"