Balancing Sinks on Linux

A year or two ago I bought a GameDAC, under the impression that it would allow me to balance the volume between chat and game channels. It was not to be, as it has always shown up as a Chat Input + Chat Output to both pulseaudio and pipewire (audio servers on linux), a fitting tribute to the poor linux support many gaming peripherals offer. Fortunately, I was able to benefit from the flexibility linux offers to solve this problem in a way I think is far more elegant (not to mention free).

Lets get some terminology out of the way. A sink is a fancy word for an output, while an input is called a source. So when you plug your headphones into your computer, it will show up as a sink, and your mic will show up as a source. But you can also create “virtual” sinks, a sort of fake sink that isn’t actually bound to any physical device. In pipewire you can create virtual sinks that route their audio stream to the default sink, i.e your headphones or speakers. So if we create two such sinks, one for games and the other for chat, we can control the volume of each sink relative to the other before it is forwarded to the default sink. The configuration to create these sinks in pipewire is as follows:

# ~/.config/pipewire/pipewire.conf.d/10-game-sinks.conf
context.modules = [ {
    name = libpipewire-module-loopback
    args = {
        audio.position = [ FL FR FC LFE RL RR ]
        capture.props = {
            media.class = Audio/Sink
            node.name = game_sink
            node.description = "Virtual game sink"
        }
    }
} {
    name = libpipewire-module-loopback
    args = {
        audio.position = [ FL FR FC LFE RL RR ]
        capture.props = {
            media.class = Audio/Sink
            node.name = chat_sink
            node.description = "Virtual chat sink"
        }
    }
} ]

this creates two sinks, called “game_sink” and “chat_sink”, that behave in the way described above. However, it’s still not very convenient to have to go into our audio settings each time we want to adjust the volume! A simple script can make this much easier:

#!/usr/bin/env bash

set -e

GAME_SINK='game_sink'
CHAT_SINK='chat_sink'

increase="$1"

function fail() {
    printf '%s\\n' "$\*" >&2
    exit 1
}

function get_id() {
    local name="$1"
    pw-cli list-objects Node \\
        | rg -B5 'node.name = "'"$name"'"' \\
        | head -n 1 \\
        | sd '^\\s+id (?P<id>\\d+),.\*' '$id'
}

function get_volume() {
    local id="$1"

    wpctl get-volume "$id" | awk '{ print $2 }'
}

function get_new_volumes() {
    local left right increase

    increase="$3"

    case "$increase" in
        game)
            left="$1"
            right="$2"
            ;;
        chat)
            right="$1"
            left="$2"
            order='reverse'
            ;;
        *)
            fail "no such sink: $increase"
            ;;
    esac

    awk -v left="$left" -v right="$right" -v order="$order" '
    BEGIN {
        # increase left, or decrease right
        increment = 0.05

        if (left < 1.0) {
            left += increment
            if (left > 1.0) {
                left = 1.0
            }
        }
        else {
            right -= increment
            if (right < 0) {
                right = 0
            }
        }

        # Print the output
        if (order == "reverse") {
            print right, left
        } else {
            print left, right
        }
    }'
}

game_id="$(get_id "$GAME_SINK")"
chat_id="$(get_id "$CHAT_SINK")"

game_volume="$(get_volume "$game_id")"
chat_volume="$(get_volume "$chat_id")"

volumes="$(get_new_volumes "$game_volume" "$chat_volume" "$increase")"

wpctl set-volume "$game_id" "$(printf '%s' "$volumes" | awk '{ print $1 }')" -l 1.0
wpctl set-volume "$chat_id" "$(printf '%s' "$volumes" | awk '{ print $2 }')" -l 1.0

I will likely improve on this over time, as parts of it are pretty brittle if anything ever changes in wireplumber (the default pipewire session manager). You can find the source on sourcehut if you want the most recent version. When called with mix-sinks $sink (when $sink is “game” or “chat”), it will increase the volume of the indicated sink relative to the other. It does this by increasing that sink in increments of 5% until it is at 100%, at which time it will start decreasing the volume of the other sink.

If your window manager supports it, as mine does, you can bind keys to run this script for the different sinks, such as super+< and super+> as I did. I even configured the rotary encoder on my keyboard to send these keys, so I can still get the satisfaction of turning a physical dial to balance the volume as I play. Only now I don’t need to reach over to some external device, it’s right there on my keyboard!