A loudness-aware volume knob with pipewire

In most software, the "volume knob" adjusts gain, not loudness. It's a flat multiplication of the input signal by some constant. Smarter programs use decibels (and I wrote a mpv script to do just that), others insist on using arbitrary (and meaningless) percentage figures.

Our ears are complicated and don't have equal sensitivity to all frequencies. This is a well-known phenomenon that led to ISO 226:2003 defining equal-loudness contours. By applying a flat gain to the input signal, you distort the perceived loudness of frequencies relative to each other. That's why most music just "sounds right" at a certain volume, but doesn't when you make it louder or quieter.

Equal-loudness contours

The "loudness" button (or knob) in your old-school AV receiver accounted for that, to some degree. Some did it better than others. On a computer, it's all digital and there's no excuse for not doing proper equal-loudness attenuation.

On pure ALSA setups, there's the amazing alsaloudness plug-in. I have used it for a long time and consider it a must-use.

This article is about replicating alsaloudness, but for pipewire setups. Pipewire has no builtin support for equal-loudness attenuation, but thankfully, it's easy enough to use a LADSPA plugin as part of a DSP filterchain:

(in context.modules of your ~/.config/pipewire/pipewire.conf)

{ name = libpipewire-module-filter-chain
  args = { node.name = "main" node.description = "Speakers DSP Sink" media.name = "Speakers DSP Sink"
    filter.graph = {
      nodes = [
        { type = ladspa plugin = "/usr/lib/ladspa/lsp-plugins-ladspa.so" label = "http://lsp-plug.in/plugins/ladspa/loud_comp_stereo" name = loudComp control = { "Output volume (dB)" = -30.0 } }
        { type = builtin label = convolver name = convL config = { filename = "/home/romain/.config/alsa/drc/filter-L.wav" channel = 0 } }
        { type = builtin label = convolver name = convR config = { filename = "/home/romain/.config/alsa/drc/filter-R.wav" channel = 1 } }
      ]
      inputs = [ "loudComp:Input L" "loudComp:Input R" ]
      links = [ { output = "loudComp:Output L" input = "convL:In" }
                { output = "loudComp:Output R" input = "convR:In" } ]
      outputs = [ "convL:Out" "convR:Out" ]
    }
    capture.props { node.name = "owo.capture" media.class = Audio/Sink audio.channels = 2 audio.position = [ FL FR ] }
    playback.props { node.name = "owo.playback" node.passive = true node.target = "alsa_output.usb-Yamaha_Corporation_Steinberg_UR22-00.analog-stereo" }
  }
}

In the example above, I set up a filterchain with a LADSPA plugin for loudness compensation (in Arch, part of the lsp-plugins package), and I use the builtin convolver for room compensation (see packages like DRC-FIR).

In pavucontrol, I set this newly created sink as my default playback device, and that's it.

When using loudness attenuation, the idea is to set your gain (the volume output of your sound device, the "volume knob" of your preamps, etc) to a reference level, usually around 90 dB(A) SPL at your listening position. Then it never gets touched again. Instead, you make things quieter (or louder) by changing the attenuation value in the loud_comp LADSPA plugin. (There is a more detailed guide in the alsaloudness README.)

Unfortunately, pipewire doesn't make that easy, so I wrote a script to make it easy to bind to a global hotkey:

(in ~/.local/bin/set-pw-volume)
(you will have to change the hardcoded paths in lines 4 and 5)

#!/usr/bin/env php
<?php

const STATE_FILE = '/home/romain/.local/state/pipewire/set-pw-volume.state';
const LOCK_FILE = '/run/user/1000/set-pw-volume.restoring';

if($argc === 1) {
        fprintf(STDERR, "Usage: %s restore|toggleLoudness|attenuate|gain [val]\n", $argv[0]);
        die(1);
}

if($argv[1] === "gain") {
        $vol = round(floatval(trim(shell_exec('pactl get-sink-volume main | head -n1 | cut -d/ -f3 | cut -dd -f1'))));
        $vol += $change = floatval($argv[2]);
        passthru(sprintf('pactl set-sink-volume main %+.2fdB', $change));
        printf("Gain: %+.2f dB\n", $vol);
        die(0);
}

$tries = 0;
$id = null;
$fParams = [];
while($id === null && $tries < 5) {
        if($argv[1] !== 'restore' && file_exists(LOCK_FILE)) {
                ++$tries;
                usleep(200000);
                continue;
        }

        $dump = json_decode(shell_exec('pw-dump'), true);
        foreach($dump as $node) {
                if(!isset($node['info']['params']['Props'])) continue;
                foreach($node['info']['params']['Props'] as $k => $props) {
                        if(!isset($props['params'])) continue;
                        $params = $props['params'];
                        if(substr($params[0], 0, 9) !== 'loudComp:') continue;
                        $id = $node['id'];
                        while($params !== []) {
                                $fParams[array_shift($params)] = array_shift($params);
                        }
                        break 3;
                }
        }

        ++$tries;
        usleep(100000);
}

if($id === null) {
        fprintf(STDERR, "dsp node not found\n");
        die(1);
}

if($argv[1] === 'restore') {
        if(file_exists(STATE_FILE)) {
                $fParams = json_decode(file_get_contents(STATE_FILE), true);
                $params = '';
                foreach($fParams as $k => $v) {
                        $params .= sprintf(' "%s" %.3f ', $k, $v);
                }
                shell_exec('pw-cli set-param '.escapeshellarg($id).' Props '.escapeshellarg('{ params = [ '.$params.' ] }'));
        }
        @unlink(LOCK_FILE);
} else if($argv[1] === 'toggleLoudness') {
        $p =& $fParams['loudComp:Loudness contour standard'];
        $p = 1 - $p;
        shell_exec('pw-cli set-param '.escapeshellarg($id).' Props '.escapeshellarg(
                sprintf('{ params = [ "loudComp:Loudness contour standard" %.2f ] }', $p)
        ));
        printf("Loudness contour standard: %d\n", $p);
        unset($p);
} else if($argv[1] === 'attenuate') {
        $p =& $fParams['loudComp:Output volume (dB)'];
        $p += floatval($argv[2]);
        shell_exec('pw-cli set-param '.escapeshellarg($id).' Props '.escapeshellarg(
                sprintf('{ params = [ "loudComp:Output volume (dB)" %.2f ] }', $p)
        ));
        printf("Attenuation: %+.2f Phon\n", $p);
        unset($p);
}

file_put_contents(STATE_FILE, json_encode($fParams));

You can now bind the commands "set-pw-volume attenuate 1" or "set-pw-volume attenuate -1" to your volume up/down keys, using eg xbindkeys or similar from your desktop environment. For maximum awesomeness, pipe the output of that script to osd_cat.

In order to restore the saved volume after a reboot, you can add the following to your pipewire.conf (again, change the hardcoded paths as required):

context.exec = [
  { path = "/usr/bin/touch" args = "/run/user/1000/set-pw-volume.restoring" }
  { path = "/home/romain/.bin/set-pw-volume" args = "restore" }
]