Artifact ef334699feeeca61c77493b9e7dcb5081fa94f3cb3abd71b2307fa69f590bad1:

  • File src/players/flacplayer.cr — part of check-in [5fa4b2c584] at 2024-09-05 06:54:25 on branch library-consolidation — Migrate to using RACodecs (user: alexa size: 8111) [more...]

#### 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/>.
require "remiaudio"

####
#### FLAC File Playback (via RemiAudio and libsamplerate)
####

module Benben
  class FlacPlayer < Player
    protected getter! ctx : FlacFile?
    @samplesRendered : Int32 = 0
    @sampleFormat : RemiAudio::SampleFormat = RemiAudio::SampleFormat::F32
    @tempBuf : Array(Float32) = [] of Float32
    @rva : Float32 = 1.0f32

    def initialize
      Benben.dlog!("FlacPlayer starting up")
      @bufSize = Benben.config.bufferSize.to_u32
      @bufRealSize = @bufSize * 2 # multiplied by 2 for stereo
      @buf = Array(Float32).new(@bufRealSize, 0.0f32)
    end

    protected def resetInternalVars : Nil
      super
      @maxLoops.set(Benben.config.maxLoops.to_i32 - 1)
    end

    def play(file : PlayableFile) : Nil
      Benben.dlog!("FlacPlayer is going to play a file")
      raise "FlacPlayer received something that wasn't a FlacFile" unless file.is_a?(FlacFile)

      @ctx = file
      @sampleFormat = self.ctx.sampleFormat
      self.infoLine = "FLAC File"
      self.state = PlayerState::Frame

      # Reset some variables
      resetInternalVars

      # Set the total number of samples
      @totalSamples.set(self.ctx.totalSamples)

      # Send settings to other things.
      Benben.ui.apply(Benben.config)
      Benben.config.send(@effects)

      # Setup resampling
      if file.flac.sampleRate != Benben.config.sampleRate
        Benben.dlog!("FlacPlayer: Resampling needed, using #{Benben.config.resampler} resampling")
        @tempBuf = Array(Float32).new(@bufRealSize, 0.0f32)
        initResampler(file.flac.sampleRate, Benben.config.resampler)
      else
        Benben.dlog!("FlacPlayer: Resampling is not needed")
        @resampler = nil
      end

      @rva = case Benben.config.replayGain
             in .disabled? then 1.0f32
             in .album? then file.taginfo.as(VorbisTagInfo).albumGainLinear.to_f32!
             in .mix? then file.taginfo.as(VorbisTagInfo).trackGainLinear.to_f32!
             end
      Benben.dlog!("FlacPlayer has an RVA of #{@rva}")

      Benben.dlog!("FlacPlayer is ready to render frames")
    rescue err : RemiAudio::RemiAudioError
      {% if flag?(:benben_debug) %}
        raise BenbenError.new("Module error: #{err}\nStack trace: #{err.backtrace.join("\n  => ")}")
      {% else %}
        raise BenbenError.new("Module error: #{err}")
      {% end %}
    end

    protected def renderCb : Tuple(Slice(Float32), Int64)
      rendered = self.ctx.decode(@tempBuf)
      if rendered > 0
        if rendered < @tempBuf.size
          # Fill remaining buffer with silence
          @tempBuf.fill(0.0f32, rendered..)
        end
      end

      # Divide by 2 since that's the number of channels we have.
      {renderBufSlice(@tempBuf), rendered.tdiv(2).to_i64!}
    rescue err : Exception
      # On error, just return zero
      {renderBufSlice(@tempBuf), 0i64}
    end

    @[AlwaysInline]
    private def renderBuffers
      if resamp = @resampler
        # We're resampling, so use that to get data
        @samplesRendered = resamp.read(@resampRatio, @buf).to_i32!
      else
        # Not resampling.  Render the audio using our context directly
        @samplesRendered = self.ctx.decode(@buf)
        if @samplesRendered > 0
          if @samplesRendered < @buf.size
            # Fill remaining buffer with silence
            @buf.fill(0.0f32, @samplesRendered..)
          end
        end
      end

      # Apply volume
      @bufRealSize.times do |idx|
        @buf.unsafe_put(idx, @buf.unsafe_fetch(idx) * @volume)
      end

      # Process with effects
      @effects.process(@buf)

      # Apply RVA if necessary
      unless @rva == 1.0f32
        @bufRealSize.times do |idx|
          @buf.unsafe_put(idx, @buf.unsafe_fetch(idx) * @rva)
        end
      end
    end

    def playFrame : Bool
      case @state
      in .frame?
        # Render the buffer, update position info, then send the data to the
        # audio device.
        renderBuffers
        @samplesPlayed.add(@bufSize.to_u64!)
        Benben.driver.writeBuffer(@buf)

        # Did we finish?
        if @samplesRendered == 0 || @samplesPlayed.get > @totalSamples.get
          if @maxLoops.get >= 0 && (@currentLoop.get >= @maxLoops.get)
            self.state = PlayerState::Tails
          else
            self.ctx.rewind
            @currentLoop.add(1)
            @samplesPlayed.set(0)
          end
        end
        true

      in .fadeout?
        raise "FLACs don't have fadeout capabilities"

      in .tails?
        # Track is not looping, play just a bit longer to add some silence
        # or let some instrument tails play.
        @buf.fill(0.0f32)
        @effects.channelVolL.set(SILENCE)
        @effects.channelVolR.set(SILENCE)
        if @nonLoopingExtraLoops > 0
          @nonLoopingExtraLoops -= 1
          Benben.driver.writeBuffer(@buf)
          true
        else
          self.state = PlayerState::Done
          false
        end

      in .paused?
        @buf.fill(0.0f32)
        Benben.driver.writeBuffer(@buf) # Do not cause a buffer underrun with PortAudio
        true

      in .done?
        self.state = PlayerState::Done
        false
      end
    end

    def state=(newState : PlayerState) : Nil
      Benben.dlog!("FlacPlayer: Changing state to #{newState} (current state: #{@state})")
      @lastState = @state
      @state = newState
    end

    def togglePause : Nil
      if @state.paused?
        self.state = @lastState
      else
        self.state = PlayerState::Paused
      end
    end

    def toggleEq : Nil
      if @effects.toggleEq
        Benben.config.equalizerEnabled = true
        Benben.config.equalizerEnabledFromSongConf = false
      else
        Benben.config.equalizerEnabled = false
        Benben.config.equalizerEnabledFromSongConf = false
      end
    end

    def toggleSoftClipping : Nil
      Benben.config.enableSoftClipping = @effects.toggleSoftClipping
    end

    def toggleStereoEnhancer : Nil
      Benben.config.enableStereoEnhancer = @effects.toggleStereoEnhancer
    end

    def toggleReverb : Nil
      Benben.config.reverbEnabled = @effects.toggleReverb
    end

    def volUp : Float64
      @volume = (@volume + VOL_STEP).clamp(0.0, 3.0).to_f32!
      Benben.dlog!("FlacPlayer volume changed to #{@volume}")
      @volume.to_f64!
    end

    def volDown : Float64
      @volume = (@volume - VOL_STEP).clamp(0.0, 3.0).to_f32!
      Benben.dlog!("FlacPlayer volume changed to #{@volume}")
      @volume.to_f64!
    end

    def loopUp : Nil
      unless @maxLoops == Int32::MAX
        wasIndefinite = @maxLoops.get < 0
        @maxLoops.add(1)

        if wasIndefinite
          @currentLoop.set(Math.max(0u32, @maxLoops.get - 1).to_u32)
        end
      end
    end

    def loopDown : Nil
      unless @maxLoops.get < 0
        @maxLoops.sub(1)

        # If the new maxLoops would cause the song to end, just put our current
        # loop as the final loop.
        if @maxLoops.get >= 0 && @currentLoop.get > @maxLoops.get
          @currentLoop.set(Math.max(0, @maxLoops.get - 1).to_u32)
        end
      end
    end

    def stop : Nil
      Benben.dlog!("Stopping FlacPlayer")
      @skipFadeout = true
      self.state = PlayerState::Done
      @ctx.try(&.unload)
    end

    def curFile : PlayableFile?
      @ctx
    end
  end
end