#### 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
abstract class Job
getter outFilename : Path
getter doneChan : JobChannel
getter maxLoops : UInt32 = 1
getter nonLoopingExtraSamples : UInt64 = 0u64
getter? errored : Bool = false
getter source : PlayableFile
private def initialize(@source)
raise NotImplementedError.new("Not implemented")
@outFilename = Path["."]
@doneChan = JobChannel.new
end
abstract def generateCue(cue : RemiAudio::Cue, cuePath : Path, idx : Int) : Nil
end
# A render job that converts a VGM into a WAV/Au.
class VgmJob < Job
# For shorter typing.
alias AudioFile = RemiAudio::Formats::AudioFile
@min : Float64 = Float64::MAX
@max : Float64 = Float64::MIN
@inFilename : Path
@eq : RemiAudio::DSP::ParaEQStereo
@totalSampleSize : UInt32? = nil
@ditherer : RemiAudio::DSP::Ditherer?
# Our settings that we use for playback for this render job.
@settings : ConfigManager::EphemeralConfig
def initialize(vgm : Yuno::VgmFile, @inFilename : Path, @outFilename : Path, @doneChan : JobChannel)
@source = vgm
@settings = Benben.config.settings.dup
@reverb = Reverb.new(BUFFER_SIZE_DEF)
#
# Some settings need to be setup now so that the Renderer can calculate
# samples correctly, and simply because they don't need to be done in the
# render method.
#
@settings.maybeApplySongConfig(@inFilename)
@settings.apply(Benben.args) # Command line overrides everything
@eq = @settings.makeEQ # Setup the EQ. We always have an EQ in memory.
@eq.active = false if @settings.equalizerDisabledDuringRendering?
# Also calculate how long we'll need to play a bit of additional silence
# after a non-looping track.
@nonLoopingExtraSamples = Benben.config.sampleRate.to_u64! * SECONDS_AFTER_NON_LOOPING_TRACK
# Setup the number of times we're going to loop.
@maxLoops = @settings.vgm.maxLoops
if vgm.header.loopOffset == 0
# VGM has no loop information, so there never will be more than one loop
@maxLoops = 1
end
# Consistency check, this should have been prevented by earlier command
# line checks.
if @maxLoops <= 0
raise "maxLoops should not be zero or less"
end
end
def render(statusChan : Channel(UInt32|Bool)) : Nil
# Create the player.
RemiLib.assert(@source.is_a?(Yuno::VgmFile))
player : Yuno::VgmPlayer = createPlayer(@source.as(Yuno::VgmFile))
# Setup buffer sizes.
bufSize : Int32 = bufferSize
bigBufSize : Int32 = bufSize * 2
buf : Array(Float64) = Array(Float64).new(bigBufSize, 0.0) # multiplied by 2 for 2 channels
smpIdx : UInt32 = 0
newMax : Float64 = 0.0
# Calculate some things for fadeouts.
#
# Get the total loops we'll need to do. We use half of the buffer size
# here since the buffer is holding interleaved data.
fadeSamples : UInt64 = calcFade
# Calculate our fade coefficient.
targetDB : Float64 = -80.0 # in decibels
fadeCoeff : Float64 = 10 ** (0.05 * (targetDB / fadeSamples)) # in linear
# Start the player.
player.play
# Send settings to other things.
@settings.send(player)
@settings.send(@reverb)
# Get the function we'll use to convert the samples.
convFn = getConversionFn
# Get the output stream and start it.
stream : AudioFile = getStream
begin
# Main rendering loop
loop do
# Render the buffer, then interleave. This is a purposely unhygenic
# macro.
renderBuffers
# Write samples to the file, mapping to the correct format as-needed.
bigBufSize.times do |idx|
stream.writeSample(convFn.call(buf.get!(idx)))
end
statusChan.send(bufSize.to_u32!)
# Are we looping still?
break if player.atEnd? || player.timesPlayed >= @maxLoops
Fiber.yield
end
# Possibly render the fadeout.
unless player.atEnd?
volAdjust : Float64 = 1.0 # Initial starting multiplier
shouldBreak : Bool = false
# Continue rendering, fading out as we go.
until shouldBreak
# This is a purposely unhygenic macro.
renderBuffers
# Determine how many samples we need to write. This may be less
# than the buffer size if the fade out time doesn't happen on an
# exact buffer size.
toWrite : UInt64 = if smpIdx + bigBufSize > fadeSamples
shouldBreak = true
fadeSamples - smpIdx
else
bigBufSize.to_u64!
end
# Fade out while writing samples to the file, mapping to the correct
# format as-needed.
toWrite.times do |idx|
volAdjust *= fadeCoeff if idx.odd?
stream.writeSample(convFn.call(buf.get!(idx) * (volAdjust * (2 - volAdjust))))
Fiber.yield
end
smpIdx += toWrite
statusChan.send(toWrite.to_u32!) #.tdiv(2))
Fiber.yield
end
else
# Track is not looping, play just a bit longer to add some silence
# or let some instrument tails play.
smpIdx = 0
while smpIdx < @nonLoopingExtraSamples * 2
# This is a purposely unhygenic macro.
renderBuffers
bigBufSize.times do |idx|
stream.writeSample(convFn.call(buf.get!(idx)))
smpIdx += 1
break if smpIdx >= @nonLoopingExtraSamples * 2
Fiber.yield
end
Fiber.yield
end
end
rescue err : Exception
@errored = true
doneChan.send(RenderingError.new(@outFilename, "Failed to render file: #{err}", err))
return
ensure
stream.close
end
doneChan.send(true)
end
# Given a buffer size, calculates the number of samples the fade will take
# per channel, then returns that and the total number of loops needed for
# the fade.
private def calcFade : UInt64
2u64 * @settings.fadeoutSeconds.to_u64! * Benben.config.sampleRate.to_u64!
end
# Calculates the total number of samples that this job will produce. These
# are stereo samples, so one sample = left and right. The value is
# memoized.
protected def calcTotalSampleSize : UInt32
@totalSampleSize.try { |x| return x }
bufSize : Int32 = self.bufferSize
maxLoops : UInt32 = self.maxLoops
vgm = @source.as(Yuno::VgmFile)
# Convert the VGM sample counts to PCM sample counts
totalSamples : UInt32 = vgm.header.totalSamples
loopSamples : UInt32 = vgm.header.loopSamples
if vgm.header.loopOffset == 0
# VGM has no loop information, so there never will be more than one loop
unless totalSamples % bufSize == 0
totalSamples += bufSize - (totalSamples % bufSize)
end
totalSamples += self.nonLoopingExtraSamples
else
# We need to account for the loops plus the fade.
fadeSamples : UInt64 = calcFade
# Finally, calculate the total number of samples we expect to play.
# The first loop through playes totalSamples. After that, it plays
# loopSamples each loop.
totalSamples += loopSamples * (maxLoops - 1)
unless totalSamples % bufSize == 0
totalSamples += bufSize - (totalSamples % bufSize)
end
totalSamples += fadeSamples
end
@totalSampleSize = totalSamples
end
protected def bufferSize : Int32
RENDER_BUFFER_SIZE
end
private def createPlayer(vgm : Yuno::VgmFile) : Yuno::VgmPlayer
Yuno::VgmPlayer.new(vgm, self.bufferSize, @settings.vgmSettings)
end
private def getStream : AudioFile
getStream(@outFilename)
end
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: Benben.config.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: Benben.config.sampleRate,
bitDepth: Benben.args["bit-depth"].str.to_u8,
encoding: wavFmt)
end
ret
end
# NOTE: Purposely unhygenic
private macro renderBuffers
player.render(buf)
if @settings.enableStereoEnhancer?
RemiAudio::DSP::StereoEnhancer.process(buf, @settings.stereoEnhancementAmount)
end
@eq.process(buf)
@reverb.process(buf)
if @settings.enableSoftClipping?
buf.size.times do |idx|
# About -0.3db
buf.unsafe_put(idx, RemiAudio::DSP::SoftClipping.process(0.9660508789898133, buf.unsafe_fetch(idx)))
end
end
@max = newMax if (newMax = buf.max) > @max
@min = newMax if (newMax = buf.min) < @min
end
def min : Float64
RemiAudio.linearToDecibels(@min.abs)
end
def max : Float64
RemiAudio.linearToDecibels(@max.abs)
end
def normalizeGain : Float64
gain = Math.max(self.min, self.max).abs
RemiAudio.decibelsToLinear(gain)
end
###
### Sample Conversions
###
private 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]
private def ditherer
if @ditherer
@ditherer.not_nil!
else
@ditherer = RemiAudio::DSP::Ditherer.new
end
end
private def convIdentity(sample : Float64)
sample
end
private def convToFloat32(sample : Float64)
sample.to_f32!
end
private def convToInt8(sample : Float64)
ditherer.ditherOrConvert(sample, RemiAudio::SampleFormat::F64, RemiAudio::SampleFormat::I8).to_i8!
end
private def convToUInt8(sample : Float64)
ditherer.ditherOrConvert(sample, RemiAudio::SampleFormat::F64, RemiAudio::SampleFormat::U8).to_u8!
end
private def convToInt16(sample : Float64)
# It was originally Int16, so no need to dither.
(sample * Int16::MAX).to_i16!
end
private def convToInt24(sample : Float64)
# It was originally Int16, so no need to dither.
(sample * 8388607).to_i32!
end
private def convToInt32(sample : Float64)
# It was originally Int16, so no need to dither.
(sample * Int32::MAX).to_i32!
end
private def convToInt64(sample : Float64)
# It was originally Int16, so no need to dither.
(sample * Int64::MAX).to_i64!
end
# Gets a suitable title string for a CUE file, or `nil` if there isn't one.
private def getCueTitle(gd3 : Yuno::Gd3Tag) : String?
case Benben.config.vgm.preferredGd3Lang
in .japanese?, .toggle_prefer_japanese?
if !gd3.trackNameJp.empty? && gd3.trackNameJp.size < RemiAudio::Cue::MAX_TITLE_LEN
return gd3.trackNameJp
end
# Fallback to English
if !gd3.trackNameEn.empty? && gd3.trackNameEn.size < RemiAudio::Cue::MAX_TITLE_LEN
return gd3.trackNameEn
end
in .english?, .toggle_prefer_english?
if !gd3.trackNameEn.empty? && gd3.trackNameEn.size < RemiAudio::Cue::MAX_TITLE_LEN
return gd3.trackNameEn
end
# Fallback to Japanese
if !gd3.trackNameJp.empty? && gd3.trackNameJp.size < RemiAudio::Cue::MAX_TITLE_LEN
return gd3.trackNameJp
end
end
# Can't get a good track name.
nil
end
# Gets a suitable artist string for a CUE file, or `nil` if there isn't one.
private def getCueArtist(gd3 : Yuno::Gd3Tag) : String?
case Benben.config.vgm.preferredGd3Lang
in .japanese?, .toggle_prefer_japanese?
if !gd3.authorNameJp.empty? && gd3.authorNameJp.size < RemiAudio::Cue::MAX_SONGWRITER_LEN
return gd3.authorNameJp
end
# Fallback to English
if !gd3.authorNameEn.empty? && gd3.authorNameEn.size < RemiAudio::Cue::MAX_SONGWRITER_LEN
return gd3.authorNameEn
end
in .english?, .toggle_prefer_english?
if !gd3.authorNameEn.empty? && gd3.authorNameEn.size < RemiAudio::Cue::MAX_SONGWRITER_LEN
return gd3.authorNameEn
end
# Fallback to Japanese
if !gd3.authorNameJp.empty? && gd3.authorNameJp.size < RemiAudio::Cue::MAX_SONGWRITER_LEN
return gd3.authorNameJp
end
end
# Can't get a good track name.
nil
end
def generateCue(cue : RemiAudio::Cue, cuePath : Path, idx : Int) : Nil
gd3 : Yuno::Gd3Tag = @source.as(Yuno::VgmFile).gd3Tag
cue.addFile do |file|
# Construct the filename
file.filename = self.outFilename.relative_to(cuePath).to_s
# Set the type, add a single track
file.type = RemiAudio::Cue::File::Type::Wave
file.addTrack do |trk|
trk.title = getCueTitle(gd3) || self.outFilename.basename
trk.performer = getCueArtist(gd3)
trk.songwriter = trk.performer
trk.trackNumber = idx.to_u32 + 1
trk.setIndex(0, RemiAudio::Cue::Timestamp.new)
trk.pregap = RemiAudio::Cue::Timestamp.new(0, 2, 0)
end
end
end
end
end