File src/remiaudio/formats/sampleinfo.cr from the latest check-in


     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
   100
   101
   102
   103
   104
   105
   106
   107
   108
   109
   110
   111
   112
   113
   114
   115
   116
   117
   118
   119
   120
   121
   122
   123
   124
   125
   126
   127
   128
   129
   130
   131
   132
   133
   134
   135
   136
   137
   138
   139
   140
   141
   142
   143
   144
   145
   146
   147
   148
   149
   150
   151
   152
   153
   154
   155
   156
   157
   158
   159
   160
   161
   162
   163
   164
   165
   166
   167
   168
#### RemiAudio
#### Copyright (C) 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 "math"
require "libremiliacr"
require "../common"
require "./au"
require "./wav"

# The `SampleInfo` structure is used to get various information about the
# samples in an `AudioFile`.
struct RemiAudio::Formats::SampleInfo
  class FormatError < Exception
  end

  # The minimum sample value, as a `::Float64` sample values.
  getter min : Float64 = 0.0

  # The maximum sample value, as a `::Float64` sample values.  This is the
  # maximum among all channels.
  getter max : Float64 = 0.0

  # The peak amplitude of this file, in decibels.  This is the peak among all
  # channels.
  getter peak : Float64 = 0.0
  # The average (RMS) amplitude of this file, in decibels.  This is the
  # average between all channels.
  getter rms : Float64 = 0.0

  # The total number of samples across all channels.
  getter numSamples : UInt64 = 0u64

  # The total number of samples per channels.
  getter samplesPerChan : UInt64 = 0u64

  # The minimum sample value, as a `::Float64` sample values, for each
  # channel.
  getter minPerChan : Array(Float64) = [] of Float64

  # The maximum sample value, as a `::Float64` sample values, for each
  # channel.
  getter maxPerChan : Array(Float64) = [] of Float64

  # The peak amplitude of this file per channel, in decibels.
  getter peakPerChan : Array(Float64) = [] of Float64

  # The average (RMS) amplitude of this file for each channel, in decibels.
  getter rmsPerChan : Array(Float64) = [] of Float64

  # The sample rate of the source audio.
  getter sampleRate : UInt32 = 0u32

  # The bit depth of the source audio.
  getter bitDepth : UInt32 = 0u32

  # Creates a new `SampleInfo` by reading the currently available samples in
  # *aud*
  def initialize(aud : AudioFile)
    @sampleRate = aud.sampleRate
    @bitDepth = aud.bitDepth.to_u32

    # Nothing to do?
    return if aud.channels == 0 || aud.numSamples == 0

    @maxPerChan = Array(Float64).new(aud.channels, Float64::MIN)
    @minPerChan = Array(Float64).new(aud.channels, Float64::MAX)
    @peakPerChan = Array(Float64).new(aud.channels, Float64.zero)
    @rmsPerChan = Array(Float64).new(aud.channels, Float64.zero)
    ch : UInt32 = 0u32

    aud.withExcursion(0) do
      while smp = aud.read?
        # Gather min/max
        @maxPerChan[ch] = smp if smp > @maxPerChan[ch]
        @minPerChan[ch] = smp if smp < @minPerChan[ch]

        # Gather RMS
        @rmsPerChan[ch] += (smp ** 2)

        # Update other info
        ch = (ch + 1) % aud.channels
        @numSamples += 1
      end
    end

    # Consistency check.
    unless @samplesPerChan % aud.channels == 0 && @numSamples == aud.numSamples
      raise RemiAudioError.new("The number of samples (#{@numSamples}) does not match the expected value")
    end

    # Gather peaks
    aud.channels.times do |ch|
      @peakPerChan[ch] = if @maxPerChan[ch].abs > @minPerChan[ch].abs
                           RemiAudio.linearToDecibels(@maxPerChan[ch].abs)
                         else
                           RemiAudio.linearToDecibels(@minPerChan[ch].abs)
                         end
    end

    # Determine the number of samples per channel.
    @samplesPerChan = @numSamples.tdiv(aud.channels)

    # Calculate RMS for each channel
    @rmsPerChan.map! do |val|
      val /= @samplesPerChan
      RemiAudio.linearToDecibels(Math.sqrt(val))
    end

    # Get the other overall values
    @max = @maxPerChan.max
    @min = @minPerChan.min
    @peak = @peakPerChan.max
    @rms = @rmsPerChan.sum / @rmsPerChan.size
  end

  # Creates a new `SampleInfo` instance containing the information for the
  # given Au file.
  def self.fromAu(filename : String|Path) : SampleInfo
    AuFile.open(filename) { |wav| SampleInfo.new(wav) }
  end

  # Creates a new `SampleInfo` instance containing the information for the
  # given RIFF WAVE file.
  def self.fromWav(filename : String|Path) : SampleInfo
    WavFile.open(filename) { |wav| SampleInfo.new(wav) }
  end

  # Attempts to determine the format of *filename*, then creates a new
  # `SampleInfo` instance containing the information for that file.
  #
  # If this cannot determine the format, it will raise a `FormatError`.
  def self.from(filename : String|Path) : SampleInfo
    typ = File.open(filename, "rb") do |f|
      str = f.read_string(4)
      case str
      when AU_MAGIC
        :au
      when "RIFF"
        f.readUInt32
        str = f.read_string(4)
        if str == "WAVE"
          :wav
        else
          nil
        end
      else nil
      end
    end

    case typ
    when :au then fromAu(filename)
    when :wav then fromWav(filename)
    else raise FormatError.new("Cannot determine the file format")
    end
  end
end