#### 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 "./bindings/opus"
require "./playablefile"
####
#### Simple wrapper for Opus files.
####
module Benben
# A simple wrapper for Opus files.
class OpusFile < PlayableFile
OPUS_ID_HEADER = "OpusHead".to_slice
OPUS_COMMENT_HEADER = "OpusTags".to_slice
OPUS_VERSION = 1
@io : IO?
@demuxer : RemiAudio::Demuxers::Ogg?
@decoder : Opus::Decoder?
@preSkip : UInt16 = 0u16
@inputSampleRate : UInt32 = 0u32
@outputGain : Float32 = 0.0f32
@channels : UInt8 = 0u8
@tagPacket : Bytes = Bytes.new(0)
getter? needResampling : Bool = false
@sampleRate : UInt32 = 48000
@totalSamples : UInt64 = 0u64
protected def initialize(@filename : String)
ensureFile || raise PlayableFileError.new("Cannot load file: #{@filename}")
@taginfo = TagInfo.create(self)
_ = self.timeLength
end
# Tests to see if the file at *filename* is a supported Opus file. Returns
# `true` if it is, or `false` otherwise.
#
# This only checks up to the first 8192 bytes of the file.
def self.test(filename : Path|String) : Bool
Benben.dlog!("Testing #{filename} to see if it's an Opus file")
ret = false
File.open(filename, "rb") do |file|
demux = RemiAudio::Demuxers::Ogg.new(file, 8192)
rawPacket = demux.nextPacket
ret = (rawPacket[0..7] == OPUS_ID_HEADER &&
rawPacket[8] == OPUS_VERSION &&
rawPacket[9] == 2) # We only support 2 channels
end
ret
rescue Exception
false
end
private def loadOgg : Nil
RemiLib.assert(@demuxer.nil?)
if @io.nil?
@io = File.open(@filename, "rb")
else
@io.not_nil!.close
end
@demuxer = RemiAudio::Demuxers::Ogg.new(@io.not_nil!, UInt16::MAX)
end
private def loadOpus : Nil
loadOgg
# This should have already been checked by the FileHandler, but we check
# here as well for consistency.
demux = @demuxer
RemiLib.assert(!demux.nil?)
rawPacket = demux.nextPacket
mem = IO::Memory.new(rawPacket)
RemiLib.assert(mem.readBytes(8) == OPUS_ID_HEADER)
RemiLib.assert(mem.read_byte == OPUS_VERSION)
@channels = mem.read_byte || raise BenbenError.new("Could not read Opus channels")
@preSkip = mem.readUInt16
@inputSampleRate = mem.readUInt32
rawOutputGain = mem.readInt16
@outputGain = Utils.q78ToF64Linear(rawOutputGain).to_f32!
Benben.dlog!("Output gain adjustment for Opus: #{@outputGain} (raw: #{rawOutputGain})")
lastPage = demux.pageNumber
@totalSamples = 0
until (packet = demux.nextPacket).empty?
if packet[0] == 0x4F && packet[1] == 0x70
@tagPacket = packet
next
else
next if lastPage == demux.pageNumber
@totalSamples = demux.granulePos # *NOT* += because the granule contains the cumulative number of samples!
end
end
@totalSamples -= @preSkip
Benben.dlog!("Opus file #{@filename} has #{@totalSamples} samples")
# Go back to the start and re-read the first packet
demux.rewind
demux.nextPacket
end
private def startDecoder : Nil
RemiLib.assert(@demuxer.nil?)
RemiLib.assert(@decoder.nil?)
loadOpus
# TODO Follow RFC 7845 a bit closer here
@decoder = case Benben.config.sampleRate
when 8000
@needResampling = false
@sampleRate = 8000
Opus::Decoder.new(8000, 2)
when 12000
@needResampling = false
@sampleRate = 12000
Opus::Decoder.new(12000, 2)
when 16000
@needResampling = false
@sampleRate = 16000
Opus::Decoder.new(16000, 2)
when 24000
@needResampling = false
@sampleRate = 24000
Opus::Decoder.new(24000, 2)
when 48000
@needResampling = false
@sampleRate = 48000
Opus::Decoder.new(48000, 2)
else
# Any other output sample rate means we always render the
# Opus at 48Khz then resample.
@needResampling = true
@sampleRate = 48000
Opus::Decoder.new(48000, 2)
end
end
def ensureFile : Bool
if @demuxer.nil?
RemiLib.assert(@decoder.nil?)
startDecoder
else
RemiLib.assert(!@decoder.nil?)
end
true
rescue err : Exception
RemiLib.log.error("Cannot load Opus #{@filename}: #{err}")
false
end
def demuxer : RemiAudio::Demuxers::Ogg
ensureFile
@demuxer.not_nil!
end
def decoder : Opus::Decoder
ensureFile
@decoder.not_nil!
end
def totalSamples : UInt64
@totalSamples
end
def preSkip : UInt16
ensureFile
@preSkip
end
def sampleRate : UInt32
@sampleRate
end
def channels : UInt8
ensureFile
@channels
end
def inputSampleRate : UInt32
ensureFile
@inputSampleRate
end
def outputGain : UInt16
ensureFile
@outputGain
end
def tagPacket : Bytes
ensureFile
@tagPacket
end
def rewind : Nil
unload
ensureFile
end
def unload : Nil
Benben.dlog!("Unloading Opus file: #{@filename}")
@decoder = nil
@demuxer = nil
@io.try(&.close)
@io = nil
end
def decode(dest : Array(Float32)) : Int32
# TODO
raise NotImplementedError.new("Use other decode method")
end
@[AlwaysInline]
def decode(packet : Bytes, dest : Array(Float32)) : Int32
ret = self.decoder.decode(packet, dest)
dest.map!(&.*(@outputGain)) unless @outputGain == 1.0f32
ret
end
end
end