Artifact 474a0d961caa18b31e2994f51a3cae55a6ce0b23a76edd4cb5e49f12f8aa4eaf:

  • File src/rendering/job.cr — part of check-in [dd7b37a004] at 2024-04-25 23:26:40 on branch trunk — Make the Job itself responsible for generating cue information (user: alexa size: 16939)

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