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:
- simple and customisable -- ordinary Reaper project
- many plugins available
- easy to alter
Cons:
- Repeatable work with mouse-clicking -- "new track, solo, new recording"
- No way to record system audio
- High memory usage: around 300MB RSS and 90% CPU (1 core) while recording.
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
- Set up two recording inputs, one from system sink (output handling all volume in system), one from microphone.
- Add reverb plugin, LV2 or VST, to microphone recording so the volume wouldn't sound dry.
- 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 thanv1.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!