Artifact 0c7aba66af1234b5314e104dac7ae97ce73b502e68b8d084c43333e1f662b209:

  • File src/rendering/job.cr — part of check-in [346b3a60ad] at 2024-06-03 08:49:49 on branch trunk — Refactor normalization code into one place since it's the same for each format. Add stub functions to adjust output path for each format. (user: alexa size: 10377)

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