Artifact 74266921bd6d4060890a18e6c9fc03d210d65856e1076930139195cd6b0746e2:

  • File src/remiaudio/dsp/paraeq.cr — part of check-in [07ea71a9b8] at 2024-02-13 21:31:05 on branch trunk — Always return the sample (user: alexa size: 6847)

#### RemiAudio
#### Copyright (C) 2022-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/>.

module RemiAudio::DSP
  # A mono parametric EQ.
  #
  # The first band is always a low shelf, and the last band is always a high
  # shelf.  The others are all normal peaking bands.
  class ParaEQ
    @bands : Array(BiQuadFilter)

    # When true, this equalizer will process audio, otherwise it will pass any
    # audio through unmodified.
    property? active : Bool = true

    # The sample rate of the equalizer.
    getter sampleRate : Float64

    # This internal value is a linear value, not decibels.
    @postGain : Float64 = 1.0

    # The default cutoff frequency for low shelf, in Hertz.
    DEFAULT_LOW_SHELF_FREQ = 80.0

    # The default bandwidth for band 0, in octaves.
    DEFAULT_LOW_SHELF_WIDTH = 0.7071067811865475 # Butterworth-y, 1 / sqrt(2)

    # The default cutoff frequency for the high shelf, in Hertz.
    DEFAULT_HIGH_SHELF_FREQ = 15000.0

    # The default bandwidth for the high shelf, in octaves.
    DEFAULT_HIGH_SHELF_WIDTH = 0.7071067811865475 # Butterworth-y, 1 / sqrt(2)

    # The default cutoff frequency for the peaking bands, in Hertz.
    DEFAULT_PEAKING_FREQ = 500.0

    # The default bandwidth for the peaking bands, as a Q value.
    DEFAULT_PEAKING_Q = 1.0

    # Creates a new parametric EQ.  Bands 1 through numBands-2 are peaking
    # bands, while bands 0 and numBands-1 are the shelves.  numBands must be at
    # least 2, and includes the lowshelf and highshelf.
    def initialize(numBands, newSampleRate)
      raise RemiAudioError.new("Invalid number of parametric EQ bands") if numBands < 2
      @sampleRate = newSampleRate.to_f64!

      @bands = Array(BiQuadFilter).new(numBands) { |_| BiQuadFilter.new(@sampleRate) }

      # Low shelf
      @bands[0].setLowShelf(DEFAULT_LOW_SHELF_FREQ, 0.001, DEFAULT_LOW_SHELF_WIDTH)

      # Normal bands
      (1...(numBands - 1)).each do |i|
        @bands[i].setPeakingEQ(DEFAULT_PEAKING_FREQ, 0.001, DEFAULT_PEAKING_Q)
      end

      # High shelf
      @bands[numBands - 1].setLowShelf(DEFAULT_HIGH_SHELF_FREQ, 0.001, DEFAULT_HIGH_SHELF_WIDTH)
    end

    # The amount of gain to apply after the EQ, in decibels.
    def postGain : Float64
      RemiAudio.linearToDecibels(@postGain)
    end

    # The amount of gain to apply after the EQ, in decibels.
    def postGain=(value : Float64) : Nil
      @postGain = RemiAudio.decibelsToLinear(value)
      self.postGain
    end

    # Sets the frequency/band/width for the given band.
    #
    # If `freq` is greater than 49.9% of the sampling frequency, that band will
    # be disabled.
    def setBand(band : Int, freq : Float64, gain : Float64, width : Float64) : Nil
      # ameba:disable Style/RedundantBegin
      begin
        case band
        when 0 then @bands[band].setLowShelf(freq, gain, width)
        when @bands.size - 1 then @bands[band].setHighShelf(freq, gain, width)
        else @bands[band].setPeakingEQ(freq, gain, width)
        end
      rescue IndexError
        raise RemiAudioError.new("Invalid parametric EQ band: #{band}")
      end
    end

    # Processes a single sample of audio with the equalizer, returning a
    # processed sample.
    def process(sample : Float64) : Float64
      if @active
        @bands.each do |band|
          sample = band.process(sample)
        end
        sample *= @postGain
      end
      sample
    end

    # :ditto:
    @[AlwaysInline]
    def process(sample : Float32) : Float32
      self.process(sample.to_f64!).to_f32!
    end

    # Processes a block of audio with the equalizer.
    def process(block : Array(Float64)|Slice(Float64)) : Nil
      if @active
        @bands.each do |band|
          band.process(block) unless band.gain == 0
        end
        block.map!(&.*(@postGain))
      end
    end

    # Resets all bands in the equalizer.
    def reset : Nil
      @bands.each &.reset
    end

    # Generates a string that can be passed to a program to plot this equalizer
    # on a graph.  The `PlotType` dictates what kind of script is generated.
    def plot(pt : PlotType) : String
      case pt
      in .gnu_plot?
        String.build do |str|
          str << "# gnuplot file\n"
          str << "set title '5-band Parametric Equalizer, sample rate: #{@sampleRate}'\n"
          str << "set xlabel 'Frequency (Hz)'\n"
          str << "set ylabel 'Amplitude Response (dB)'\n"
          str << sprintf("Fs = %g\n", 44100.0)
          str << "o = 2 * pi / Fs\n"
          str << "set logscale x\n"
          str << "set samples 250\n"
          str << "set grid xtics ytics\n"
          str << "set key off\n"

          @bands.each_with_index do |band, idx|
            # Run the plot method.  We're only interested in the coefficients,
            # and we're going to adjust them.
            str << band.plot(RemiAudio::PlotType::GnuPlot, coeffsOnly: true).gsub(/([ab][012])/, "\\1_#{idx}")
            str << '\n'

            # We create a function for Gnuplot now.  Normally the #plot method does this
            # for when plotting a single filter.  But we're combining filters, so we
            # need to do it differently than #plot does.
            str << "H_#{idx}(f) = "
            str << "sqrt((b0_#{idx} * b0_#{idx} + b1_#{idx} * b1_#{idx} + b2_#{idx} * b2_#{idx} + 2.0 * " <<
              "(b0_#{idx} * b1_#{idx} + b1_#{idx} * b2_#{idx}) * cos(f * o) + 2.0 * " <<
              "(b0_#{idx} * b2_#{idx}) * cos(2.0 * f * o)) / " <<
              "(1.0 + a1_#{idx} * a1_#{idx} + a2_#{idx} * a2_#{idx} + 2.0 * (a1_#{idx} + a1_#{idx} * a2_#{idx}) * " <<
              "cos(f * o)+2.0 * a2_#{idx} * cos(2.0 * f * o)))\n"
          end


          # Now we make the main function.  Again, this isn't needed when you just plot
          # a single filter.
          str << "H(f) = "
          @bands.size.times { |x| str << "H_#{x}(f)#{x == @bands.size - 1 ? "\n" : " * "}" }

          # Finish up
          str << "plot [f=10 : Fs/2] [-35:25] 20 * log10(H(f))"
        end

      in .octave?
        raise RemiAudioError.new("Plotting equalizers to GNU Octave not yet supported")
      end
    end
  end
end