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