Automatic 7.1 volume normalization with PipeWire (EndeavourOS)

Last modified: 2025-05-13 11:13

2025-05-11 Initial release
2025-05-13 Fixed a spot in peaklim.cc that was still clamping the release time to a maximum of 1 s.  Oops.

Filter kit for EndeavourOS

tl;dr:  I wrote a script that mostly automates the installation of a dynamic range destroying filter.  Download filter_kit_EOS.tar.xz, unpack it, cd into the directory, and run install.sh.  It requires internet access because it downloads an audio plugin from github.

The following sections provide details and notes on what the script does.  Everything was accurate for EndeavourOS as of 2025-05-11.

0.  Why am I doing this?

Searching the net for how to do automatic volume normalization with PipeWire, everybody says "use EasyEffects" or "use JamesDSP."  The problem is, these apps and every other existing solution I could find were limited to stereo, and I have 8 channels (7.1).

The alimiter filter in libavfilter supports an unlimited number of channels, but PipeWire doesn't link with libavfilter.

So, what I ended up doing was modifying an existing LV2 filter to support 8 channels and then figuring out how to make PipeWire use it.  This took about a week of flailing with incomplete documentation that sent me in circles, cryptic examples, misleading or totally absent error messages, and forum threads without solutions.  I even tried ChatGPT.  It knew nothing and made up solutions that were completely fictitious, nonworking slop.

1.  Filter-Chain

The PipeWire Filter-Chain module supports LADSPA, LV2, and builtin filters.

With the stock configuration of EndeavourOS, the module was present, but it failed obscurely every time I tried to use it.

It turns out you need to do this:

systemctl --user enable filter-chain.service

2.  LV2 filter

The filter I started with was dpl.lv2, the Digital Peak Limiter plugin by Robin Gareus, which was based on zita-jacktools-1.0.0 by Fons Adriaensen.  filter_kit_EOS.tar.xz applies a patch to make the following modifications to it:

The original dpl.lv2 comes with a GUI app that lets you adjust settings.  I did not complete the modifications that would have allowed the app to work with the modified plugin.  It needs header files that are generated by an lv2ttl2c utility that I was unable to locate.

3.  LV2 path

With the filter installed to prefix $HOME/lv2, you need

export LV2_PATH=$HOME/lv2/lib/lv2

Installation can be verified with lv2ls:

$ lv2ls
http://gareus.org/oss/lv2/dpl#6ch
http://gareus.org/oss/lv2/dpl#8ch
http://gareus.org/oss/lv2/dpl#mono
http://gareus.org/oss/lv2/dpl#stereo

4.  PipeWire config

To get PipeWire to fire up the filter chain, a conf file has to be installed in $HOME/.config/pipewire/filter-chain.conf.d/.  filter_kit_EOS.tar.xz provides stereo, 6ch, and 8ch versions.  The 8ch version is shown below.

context.modules = [
  {
    name = libpipewire-module-filter-chain
    args = {
      node.description = "DPL 8ch"
      media.name       = "DPL 8ch"
      audio.channels   = 8
      audio.position   = [ FL FR FC LFE RL RR SL SR ]
      filter.graph = {
        nodes = [
          {
            type   = lv2
            name   = DPL
	    plugin = "http://gareus.org/oss/lv2/dpl#8ch"
            # control = {
            #   "gain" = 50
            #   "release" = 3
            # }
          }
        ]
	# The port names must match the symbols in dpl.ttl
        inputs  = [ "DPL:inFL"  "DPL:inFR"  "DPL:inFC"  "DPL:inLFE"
	            "DPL:inRL"  "DPL:inRR"  "DPL:inSL"  "DPL:inSR"  ]
        outputs = [ "DPL:outFL" "DPL:outFR" "DPL:outFC" "DPL:outLFE"
	            "DPL:outRL" "DPL:outRR" "DPL:outSL" "DPL:outSR" ]
      }
      capture.props = {
        node.name         = "DPL_8ch_input"
        media.class       = "Audio/Sink"
      }
      playback.props = {
        node.name         = "DPL_8ch_output"
        stream.dont-remix = true
        node.passive      = true
      }
    }
  }
]

5.  WirePlumber config

If you restarted at this point, the new sink would appear in System Settings > Sound (if you're using KDE) and you could select it as your playback device.  But, how does the output of the LV2 filter get connected to your real playback device?  System Settings has no control for it.

What happens is WirePlumber chooses among the available devices using a priority system.  Normally, audio devices are assigned priorities close to 1000.  (My original playback device got 1009 somehow.)

To make WirePlumber choose the right output, you put a conf file in $HOME/.config/wireplumber/wireplumber.conf.d/ to elevate its priority (higher numbers are better).  The output is identified by the name shown in pactl list sinks.

For example, this was installed as 66-preferred-sink.conf:

monitor.alsa.rules = [
  {
    matches = [
      {
        node.name = "alsa_output.pci-0000_00_03.0.hdmi-surround71"
      }
    ]
    actions = {
      update-props = {
        priority.driver = 2000
        priority.session = 2000
      }
    }
  }
]

The resulting routing is not shown by wpctl status, but it can be verified with pw-link -il:

$ pw-link -il
Midi-Bridge:Midi Through Port-0 (playback)
bluez_midi.server:in
DPL_8ch_input:playback_FL
DPL_8ch_input:playback_FR
DPL_8ch_input:playback_FC
DPL_8ch_input:playback_LFE
DPL_8ch_input:playback_RL
DPL_8ch_input:playback_RR
DPL_8ch_input:playback_SL
DPL_8ch_input:playback_SR
alsa_output.pci-0000_00_1b.0.iec958-stereo:playback_FL
alsa_output.pci-0000_00_1b.0.iec958-stereo:playback_FR
alsa_output.pci-0000_01_00.1.hdmi-stereo-extra3:playback_FL
alsa_output.pci-0000_01_00.1.hdmi-stereo-extra3:playback_FR
alsa_output.pci-0000_00_03.0.hdmi-surround71:playback_FL
  |<- DPL_8ch_output:output_FL
alsa_output.pci-0000_00_03.0.hdmi-surround71:playback_FR
  |<- DPL_8ch_output:output_FR
alsa_output.pci-0000_00_03.0.hdmi-surround71:playback_RL
  |<- DPL_8ch_output:output_RL
alsa_output.pci-0000_00_03.0.hdmi-surround71:playback_RR
  |<- DPL_8ch_output:output_RR
alsa_output.pci-0000_00_03.0.hdmi-surround71:playback_FC
  |<- DPL_8ch_output:output_FC
alsa_output.pci-0000_00_03.0.hdmi-surround71:playback_LFE
  |<- DPL_8ch_output:output_LFE
alsa_output.pci-0000_00_03.0.hdmi-surround71:playback_SL
  |<- DPL_8ch_output:output_SL
alsa_output.pci-0000_00_03.0.hdmi-surround71:playback_SR
  |<- DPL_8ch_output:output_SR
plasmashell:input_1
  |<- kwin_wayland:output_1

6.  System playback device

Restart to get all the previous changes applied, then go into System Settings > Sound, select DPL as the new playback device, and test.

If it's necessary to bypass the filter at some point, you can use this screen to change the default playback device or the playback device for a specific app back to the original output.

Bonus round:  upmixing

I previously installed this PipeWire conf fragment to make stereo and 5.1 sources do something reasonable with my 7.1 audio:

stream.properties = {
    # 2024-07-24
    # Changes to this file take effect without a reboot.
    # The next two lines activate the rear surround channels.
    channelmix.upmix        = true
    channelmix.upmix-method = simple
    # Front channels are upmixed into SW only if lfe-cutoff is nonzero.
    # My subwoofer has a built-in filter that maxes out at 180 Hz.
    channelmix.lfe-cutoff   = 180
    # According to the documentation, upmix = true should already have
    # enabled upmixing of the front channels into FC, and fc-cutoff is
    # optional.  In reality, omitting fc-cutoff disables FC just like
    # omitting lfe-cutoff disables SW.
    # I'm leaving this off on purpose.
    #channelmix.fc-cutoff    = 12000
}

With the new filter chain, it's not obvious where the upmixing would occur.  https://docs.pipewire.org/page_man_pipewire-props_7.html says:

Source, sinks, capture and playback streams can apply channel mixing on the incoming signal.

Normally the channel mixer is not used for devices, the device channels are usually exposed as they are.  This policy is usually enforced by the session manager, so we refer to its documentation there.

Playback and capture streams are usually configured to the channel layout of the sink/source they connect to and will thus perform channel mixing.

I guess that means it goes upstream of the LV2 plugin to match its channel layout?

Anyway, it's working.


KB
Home