Artifact d5406d99eb7e14f6df21f65ad867564fe0e5f7e9a55e11464751eabc3b43bd15:

  • File src/audio-formats/opusfile.cr — part of check-in [5fa4b2c584] at 2024-09-05 06:54:25 on branch library-consolidation — Migrate to using RACodecs (user: alexa size: 6771) [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 "./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