Mikhail Kot popcat

Recording singing sessions with Pipewire

I hold singing sessions weekly, and others aren't usually with me, so I need a way to record my sound along with system audio. That's how I did it.

Initial setup

Google Meet for video conferencing, Reaper for recording audio.

Pros:

Cons:

I've thought of using Reaninjam, but other people need to hear me instantly for sing along, it's not just a band play. Also, reaninjam was not feasible on other side. I decided to automate it with pipewire.

Plan

  1. Set up two recording inputs, one from system sink (output handling all volume in system), one from microphone.
  2. Add reverb plugin, LV2 or VST, to microphone recording so the volume wouldn't sound dry.
  3. Output to different files.

Then I learned Pipewire doesn't allow you to record audio itself, you should use a separate tool pw-record.

Recording system output

This is an easier task as we don't do any processing:

pw-record -a --volume 0.80 -P '{ stream.capture.sink=true }' -\
    | opusenc --quiet --raw - $out_all

We tell pw-record to - record all sounds - from sound card's output (-P '{ stream.capture.sink = true }') - as a stereo signal with default rate and quality settings, - lower volume to -1.8dB (--volume 0.80, volume is set relatively), - output to stdout (-), and - pass raw audio chunks to opus encoder.

Why Opus? File size is lower compared to mp3 with around same compression speed and quality.

Recording microphone, the script

Script for recording is close to the previous with one exception.

We are recording a specific node's output. First, we need to find its name. One solution is running pw-cli ls Node and filtering its output. Or, as the name doesn't change, we can hardcode it:

src="alsa_input.usb-PreSonus_Studio_24c_SC1E20041556-00.analog-stereo"

Then we need to find node's id for pw-record to capture from, and finally we repeat the recording pattern.

src_id=$(pw-dump | jq -r '.[] | select(.info.props."node.name" == "'$src'") | .id')
pw-record -a --volume 1.18 --target $src_id --channels 1 -\
    | opusenc --quiet --raw --raw-chan 1 - $out_mic

1.18 is +1.5dB volume.

Recording microphone, the graph

We need post-processing, a reverb, so we need a way to write a Pipewire graph file. Pipewire allows two ways to do that: a filter chain file located in ~/.config/pipewire/filter-chain.conf.d (for local user) or an additional graph file in pipewire.conf.d.

I chose the former because filter chain is a separate process pipewire -c filter-chain.conf, launched only on recording. I also thought about creating nodes in main Pipewire process in deactivated state, and linking them to sources and sinks when I need the record, but that turned out to be a more complex solution. So, we go with a filter chain manifest which is a file describing the graph and its properties.

Microphone recording graph

context.modules = [{
    name = libpipewire-module-filter-chain
    args = {
        node.latency = 128/48000
        node.description = "room-filter-chain"
        media.name = "room-filter-chain"
        filter.graph = {
            nodes = [
            {
                type = builtin,
                name = mixer
                label = mixer
                control = { "Gain 1" = 4.0 "Gain 2" = 4.0 }
            }
            {
                type = lv2
                name = room
                label = room
                plugin = "http://moddevices.com/plugins/caps/PlateX2"
                control = { "blend" = 0.25 }
            }]
            links = [
                {input = "room:inl" output = "mixer:Out"}
                {input = "room:inr" output = "mixer:Out"}
            ]
            inputs = [ "mixer:In 1" "mixer:In 2" ]
            outputs = ["room:outl" "room:outr"]
        }
        }
    }
}]

node.latency is a pair of buffer size and sampling rate. Rate is fixed -- we're piping to opusenc which expects 48kHz. Buffer size controls the delay and the latency of sound. Set the buffer too high, and your recorded voice will be delayed too much. Set it too low, and you will hear pops and cracks, and also your CPU usage will rise.

My audio card has a "balance" meter. That means I can hear sound recorded from microphone directly from the sound card (without processing) but the volume is too dry. On the other hand, I want to capture it dry on a recording so I can add some room later should I need it.

Note name = libpipewire-module-filter-chain, this is different in pipewire versions earlier than v1.4.

First node is a 4dB gain as it's more convenient than adjusting sound on a sound card. Nodes themselves are connected using linked array. My LV2 plugin is a stereo effect so we connect mixer's output to both inputs. If we didn't need a gain, we could omit inputs and outputs.

Second node is the reverb. LV2 plugins have a nice property -- all of their controls have meaningful names when serialized, so we can set one of them in control array. Pipewire takes info about plugins from system directories, in my case, /usr/local/lib/lv2.

When I was writing the graph file, I used qpwgraph for debugging inputs and outputs. In simple cases, though, Pipewire connects everything automatically.

Once I finished, I updated pipewire to 1.4 from 1.3, and everything stopped working. Turns out you need to add -a flag, not included in the manual, to record raw audio chunks.

Final performance

# ps auxf
0.0   2676  1524 /bin/sh /home/myrrc/.local/bin/vox-session
0.9  38348 12568  \_ pipewire -c filter-chain.conf
0.2  20528  8144  \_ pw-record -a --volume 1.18 --target 53 --channels 1 -
0.2   7992  3224  \_ opusenc --quiet --raw --raw-chan 1 - /home/myrrc/15.03.25-all.opus
0.3  20576  8064  \_ pw-record -a --volume 0.85 -P { stream.capture.sink=true } -
0.5   8460  3660  \_ opusenc --quiet --raw - /home/myrrc/15.03.25-mic.opus

We've won 88% CPU of one core and 200MB RAM!