I usually watch YouTube videos while I wash dishes, and if videos have very different volume levels it can be either annoying to not understand what’s being said or painful to endure loud sounds while I dry my hands so that I can adjust the volume. So I’ve been delighted over the past couple years using a compressor filter from Steve Harris’ LADSPA plugin suite with PulseAudio to normalize volume for all audio output from my laptop. I’m quite willing to trade dynamic range for listening comfort, and for me even music doesn’t sound noticeably worse with the compressor filter.
In case you’re wondering, LADSPA is the Linux Audio Developer’s Simple Plugin API, and basically it’s an interface for shared libraries that allows plugins to define some control ports/params and give callbacks to process samples. It’s like a simpler version of VST, if you’ve ever used synth or effects plugins in a DAW and have heard of that.
For configuring PulseAudio I referred to this answer on the Ask Ubuntu StackExchange. On Arch I installed the swh-plugins
package. I then added a PulseAudio config drop-in under /etc/pulse/defaultpa.d
, although you could instead add it to a per-user config in $XDG_CONFIG_HOME/pulse/client.conf
, as described on the Arch wiki. For PulseAudio CLI syntax and commands, see the pulse-cli-syntax(5)
manpage.
Sorry about the long lines in this and several other snippets, but neither PulseAudio CLI syntax or udev rules support line continuations and the likely confusion over including “fake” ones doesn’t seem worth it.
/etc/pulse/defaultpa.d/compressor.pa
:
.ifexists module-ladspa-sink.so
.nofail
load-module module-ladspa-sink sink_name=compressor plugin=sc4_1882 label=sc4 control=1,1.5,400,-30,20,5,24
set-default-sink compressor
.fail
.endif
module-ladspa-sink
takes a sink_master
param (previously named master
) to define where the processed audio goes, but I’m leaving it as the default. See the PulseAudio builtin module docs. If you wanted to route the output to a different sink, you can find its name with pactl list short sinks
.
I’m still using the SC4 filter settings from that Ask Ubuntu answer, except that I’ve increased the makeup gain so there’s a smaller volume difference between having the filter on/off, to avoid having sound blaring if I ever accidentally turn off the filter. Mostly the knobs are cranked to always be compressing as soon and hard as possible, except knee radius, which is set in the middle of the allowed values, but when adjusting it I can’t notice a difference. With these settings it feels like the volume is always being reduced, which seems good to me.
If you want to get a better sense of how the filter params work and see their allowed ranges, here’s the code for the SC4 filter. And if you’re looking at the code you might also want to peek at the little LADSPA spec.
After writing the PulseAudio config, I use pulseaudio -k
to kill the server, and then the stock systemd
config for my system immediately restarts it for me with the new settings. I can see any errors with my PulseAudio config by following syslog with journalctl -f
.
The above config might be all you need, but I needed to do a bit more to have things work with my Bluetooth headphones.
When you connect Bluetooth headphones a new sink gets added to PulseAudio, but unless we do something our compressor is going to stay routed to our ALSA output or wherever we pointed it at PulseAudio startup, and the output to the headphones won’t be filtered. For a few years I was keeping a pavucontrol
window open and manually rerouting the compressor’s output to my headphones after connecting them, but when I set up my new laptop pavucontrol
no longer gave me a dropdown to do manual routing for LADSPA sinks, and I haven’t figured out why yet. So, I started running a little script every time I connected my headphones, which adds another LADSPA sink with the output routed to my headphones. The sink name is based on the headphones’ MAC address, and is constant, so I just needed to look it up once using pactl list short sinks
and provide it as the sink_master
param.
pacmd <<EOF
load-module module-ladspa-sink sink_name=bluetooth_compressor plugin=sc4_1882 label=sc4 sink_master=bluez_sink.00_1B_66_A1_45_12.a2dp_sink control=1,1.5,400,-30,20,5,24
set-default-sink bluetooth_compressor
EOF
After disconnecting the headphones the LADSPA sink gets destroyed, so back when I was doing the routing in pavucontrol
I would either need to change the LADSPA sink’s master back to my soundcard before disconnecting my headphones to avoid it getting destroyed, or I’d need to recreate the sink by restarting pulseaudio. With my new laptop my problem was that after the LADSPA sink for my headphones was destroyed, PulseAudio would choose my soundcard as the default sink instead of the compressor that outputs to it. I could run another script after disconnecting my headphones, but instead I finally had the motivation to automate the routing. I couldn’t figure out how to fix this with the builtin PulseAudio modules, and I considered writing a PulseAudio module to allow prioritizing sinks, but running shell scripts on Bluetooth connection/disconnection events seemed easier and more flexible.
udev is part of systemd and lets us run shell scripts or start systemd services on hardware events. udev(7)
is a useful reference when writing rules, and the Arch wiki page on it has some good info, too. Before writing the rule I needed to find some way to identify my headphones. By running udevadm monitor
before connecting my headphones, I could see the related devices the kernel makes on connection:
$ udevadm monitor
...
KERNEL[80470.275026] add /devices/pci0000:00/0000:00:08.1/0000:c1:00.3/usb1/1-5/1-5:1.0/bluetooth/hci0/hci0:50 (bluetooth)
UDEV [80470.276714] add /devices/pci0000:00/0000:00:08.1/0000:c1:00.3/usb1/1-5/1-5:1.0/bluetooth/hci0/hci0:50 (bluetooth)
KERNEL[80472.139125] add /devices/virtual/input/input28 (input)
KERNEL[80472.139222] add /devices/virtual/input/input28/event13 (input)
UDEV [80472.140796] add /devices/virtual/input/input28 (input)
UDEV [80472.172162] add /devices/virtual/input/input28/event13 (input)
And I could get more information about devices with udevadm info -ap
:
$ udevadm info -ap /devices/virtual/input/input28
...
looking at device '/devices/virtual/input/input28':
KERNEL=="input28"
SUBSYSTEM=="input"
DRIVER==""
...
ATTR{id/bustype}=="0005"
ATTR{id/product}=="004b"
ATTR{id/vendor}=="0082"
ATTR{id/version}=="0103"
ATTR{inhibited}=="0"
ATTR{name}=="HD1 M2 AEBT (AVRCP)"
ATTR{phys}=="14:ac:60:46:87:9e"
...
I don’t know how to write a udev rule that would match all Bluetooth headphones, but this is enough information to write a rule for my specific ones, at least. An important thing to note now, though, is that the pulseaudio daemon is per-user, so I need to run pacmd
as my user. I could use the RUN
operator in the udev rule along with sudo -u <user>
and have separate rules for ACTION=="add"
and ACTION=="remove"
, but hard-coding my username feels kinda bad, so instead I went with using ENV{SYSTEMD_USER_WANTS}
and writing a device-bound systemd user service that’ll automatically be stopped when the device goes away. To make that work, I need to TAG+="systemd"
the device so systemd picks it up, and somehow give the device name that systemd uses to a parametrized service (“instantiated” in systemd parlance), so it can be bound to it.
With a udev rule like this in /etc/udev/rules.d/99-sennheiser.rules
:
ACTION=="add", ATTR{name}=="HD1 M2 AEBT (AVRCP)", TAG+="systemd"
And after reloading the udev rules:
sudo udevadm control --reload
When I connect the headphones I see a corresponding device in my systemctl
output:
UNIT LOAD ACTIVE SUB DESCRIPTION
sys-devices-virtual-input-input28.device loaded active plugged /sys/devices/virtual/input/input28
Which is cool, but not terribly useful yet. We’ll come back to writing the udev rule. First I need to figure out what the systemd user service will be like, so I know what parameters it needs.
Instantiated systemd services are named such that there’s an @
before the unit extension and then, when being started, they can be given an argument in their name after the @
. For example, if you run systemctl
you’ll probably see a getty@tty1.service
, where getty@.service
is the unit name and tty1
is the argument. I put my service file at $XDG_CONFIG_HOME/systemd/user/bt-compress@.service
. After modifying that file I need to run systemctl --user daemon-reload
to load the changes and use journalctl
when connecting my headphones to look for errors in syslog. There’s not much point “installing” our service in the systemd sense, since we can’t start it unless the headphones are connected, so our service file doesn’t have an [Install]
section, and it won’t show up in systemctl --user
output unless it’s running or failed.
$XDG_CONFIG_HOME/systemd/user/bt-compress@.service
:
[Unit]
BindTo=%i.device
After=%i.device
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=%h/bin/bt-compress start
ExecStop=%h/bin/bt-compress stop
By default a systemd service is Type=simple
and the ExecStart
option will run a long-running process, but we just need to run some short shell scripts at the beginning and end of the device’s life. Looking through systemd.service(5)
, we see that for Type=oneshot
the service is considered up after the ExecStart
program exits for the purpose of starting dependencies, but it will soon transition to the “dead” state unless we also give RemainAfterExit=yes
. We need it to “remain after exit” so that systemd has an active service to stop when the device goes away.
The escapes available in systemd units can be found in systemd.unit(5)
. %h
expands to the user’s home directory and %i
expands to the string between @
and .service
in the service name. So, we’d like to pass the extension-less systemd device unit name in there, which we saw in the last section was sys-devices-virtual-input-input28
. But if I reconnect my headphones I see that the number at the end of the name increments each time I connect, so it seems like we’ll need to get the device path and convert it to the systemd device unit name in the udev rule somehow.
But before we get to the udev rule, here’s the script that our systemd service calls. It’s pretty straightforward:
~/bin/bt-compress
:
#!/bin/bash
set -euo pipefail
log() {
local lvl=${1:?No level given}; shift
local msg=${1:?No message given}; shift
logger -p "$lvl" -t bt-compress "$msg"
}
usage="bt-compress start|stop"
if [[ $# -ne 1 ]]; then
echo "$usage" >&2
exit 1
fi
cmd=${1}; shift
case "$cmd" in
start)
log info 'set bluetooth_compressor as default sink'
pacmd <<EOF
load-module module-ladspa-sink sink_name=bluetooth_compressor plugin=sc4_1882 label=sc4 sink_master=bluez_sink.00_1B_66_A1_45_12.a2dp_sink control=1,1.5,400,-30,20,5,12
set-default-sink bluetooth_compressor
EOF
;;
stop)
log info 'set compressor as default sink'
pacmd 'set-default-sink compressor'
;;
*)
echo "unexpected cmd: $cmd" >&2
exit 1
;;
esac
Finally, let’s wire up udev and systemd. According to systemd.device(5)
, the SYSTEMD_USER_WANTS
udev device property adds a Wants=
dependency from a device to a service, and is read by user service manager instances. And according to udev(7)
, we can set device properties using ENV{<key>}=<value>
. And if we use udevadm test
to inspect the device properties, we can see that the DEVPATH
property is included:
$ sudo udevadm test --action="add" /devices/virtual/input/input28
...
DEVPATH=/devices/virtual/input/input28
PRODUCT=5/82/4b/103
NAME="HD1 M2 AEBT (AVRCP)"
PHYS="14:ac:60:46:87:9e"
PROP=0
EV=100007
KEY=...
REL=0
MODALIAS=...
ACTION=add
SUBSYSTEM=input
TAGS=:seat:systemd:
ID_INPUT=1
ID_INPUT_KEY=1
ID_BUS=bluetooth
CURRENT_TAGS=:seat:systemd:
SYSTEMD_USER_WANTS=bt-compress@.service
USEC_INITIALIZED=259580475046
Apparently we’re doing what systemd considers the usual thing, since if we just set SYSTEMD_USER_WANTS
to our base service name, bt-compress@.service
, it’ll automatically pick up the DEVPATH
from the udev event, prefix it with the sysfs mount point, escape it, and give the escaped device path as the argument to our service. So our udev rule can just be this:
ACTION=="add", ATTR{name}=="HD1 M2 AEBT (AVRCP)", TAG+="systemd", ENV{SYSTEMD_USER_WANTS}="bt-compress@.service"
If you have trouble getting this to work, using udevadm test
as above can be useful, as it prints a bunch of diagnostics, which I have elided here.
But if we needed to do the escaping ourselves, we could use systemd-escape
:
$ systemd-escape --template bt-compress@.service --path /sys/devices/virtual/input/input28
bt-compress@sys-devices-virtual-input-input28.service
And surprisingly to me, there’s a udev key for running shell commands and capturing their output: the PROGRAM
key. The results of PROGRAM
are then available via the %c
or $result
escape, and while the manpage doesn’t currently include the ENV
key in the list of keys where escapes are available, it apparently works. Note that $devpath
doesn’t include the sysfs mount point, so we need to add it when running systemd-escape
.
ACTION=="add", ATTR{name}=="HD1 M2 AEBT (AVRCP)", TAG+="systemd", PROGRAM="/usr/bin/systemd-escape -p --template=bt-compress@.service /sys$devpath", ENV{SYSTEMD_USER_WANTS}+="$result"
I doubt you got this far unless you’re motivated enough to set something similar up on your own machine. Good luck, and I hope this helped.