Digital piano: Systemd user service to start low-latency synth and bind to MIDI controller

For years, I’ve had an M-Audio Evolution MK449C MIDI controller. It has plenty of nice controls: 8 rotary, 9 sliders, 10 toggleable buttons, 1 wheel, 1 return-to-centre wheel, and obviously also four octaves keys.

While (for the ~£50 I paid for it back in 2008) I can’t find any reason to fault it massively, I have finally decided to replace/augment it with a Roland A-88. That’s a full 88 weighted keys with piano mechanisms to add to the immersion. But also 3x the weight and with less controls.

Back when I used a desktop PC, I used the ultra-low latency JACK audio environment as standard, with the more friendly PulseAudio running on top of it. This way, I could plug the inputs and outputs of any programs together, for chaining effects, mixing, monitoring, etc. Like what KX Studio did for Creative sound cards, but on steroids.

On a laptop though, this isn’t so practical. JACK (and also PulseAudio with low fragment sizes) consumes quite a bit of CPU and battery, which isn’t ideal on a portable device. So I needed a convenient way to switch between PulseAudio→ALSA for normal use, and PulseAudio→JACK→ALSA for real-time use. Ideally in such a way that plugging in a MIDI controller causes switching to the JACK-enabled stack where PulseAudio has low fragment sizes (for low latency), and removing all MIDI controllers causes switching back to the (boot-default) PulseAudio with large fragment sizes (for low CPU usage).

Systemd to the rescue!

In addition to general system administration, systemd can also be used on a per-user basis (therefore not requiring root either). I chose to use systemd user services to orchestrate the audio configuration, since this is the exact kind of task that systemd excels at.

Simplified configuration is provided here:

Preparation

# Create directory in home folder for user-systemd units
mkdir -p ~/.config/systemd/user

# Enter the directory
cd ~/.config/systemd/user

# Create the unit files (use your preferred editor, e.g. gedit, nano, ed)
vim pulse.service jack.service synth@.service keyboard.service

pulse.service

[Unit]
Description=Pulseaudio
Conflicts=jack.service

[Service]
Type=simple
Environment="DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus"
ExecStart=/usr/bin/pulseaudio -nF /etc/pulse/default.pa

[Install]
WantedBy=multi-user.target

jack.service

[Unit]
Description=Jack
Conflicts=pulse.service

[Service]
# Grant your self the right to use such limits in /etc/security/limits.d/
LimitNICE=-15
LimitRTPRIO=99
# Configure DEVICE environment variable to the ALSA name of your output device
Type=simple
Environment=DEVICE=hw:0
Environment=JACK_NO_AUDIO_RESERVATION=1
ExecStartPre=-/usr/bin/pulseaudio -k
# Configure appropriate period size/count and sample-rate
ExecStart=/usr/bin/jackd -d alsa -P ${DEVICE} -n 2 -p 128 -r 48000 -X raw

[Install]
WantedBy=multi-user.target

synth@.service

[Unit]
Description=Fluidsynth with %I soundfont

BindsTo=jack.service
After=jack.service

Before=keyboard.service
Wants=keyboard.service

[Service]
LimitNICE=-15
LimitRTPRIO=99

# Configure path to your soundfonts
Environment=SOUNDFONT=/opt/soundfonts/%I.sf2

Type=simple
# Wait for JACK to start
ExecStartPre=/usr/bin/sleep 1
# Configure appropriate sample rate & buffer size
ExecStart=/usr/bin/fluidsynth --server --no-shell --audio-driver=jack --audio-bufcount=2 --midi-driver=jack --sample-rate=48000 --audio-bufsize=64 --connect-jack-outputs --gain 1 ${SOUNDFONT}

[Install]
WantedBy=multi-user.target

keyboard.service

[Unit]
Description=Connect MIDI keyboard to Fluidsynth

[Service]
Type=oneshot
# Wait for synth to start
ExecStartPre=/usr/bin/sleep 2
# Configure regexes as appropriate
ExecStart=/usr/bin/sh -c "/usr/bin/jack_connect $(jack_lsp | grep -xP 'system:midi_capture_\\d+' | tail -n1) $(jack_lsp | grep -xP 'fluidsynth:midi_\\d+' | tail -n1)"

[Install]
WantedBy=multi-user.target

Helper script

Helper script, placed in my user bin folder (~/.bin, or whatever you decided to call yours):

#!/bin/bash

set -euo pipefail

declare -r patch="${1:---help}"

if [ "$patch" == '--help' ]; then
        echo "Patches:"
        ls -1d /opt/soundfonts/*.sf2 | xargs -n1 -I{} echo ' * {}'
        exit 1
fi

systemctl --user stop "synth@*"

if [ "$patch" == '--stop' ]; then
        systemctl --user start pulse
        echo 'Synth stopped'
        exit 0
fi

systemctl --user start "synth@${patch}"

Usage

With soundfonts in the corresponding directory, it’s just a case of using udev rules to run the helper script with the name of the default soundfont as the argument when the controller is plugged in, and then to run the script with –stop argument when the last controller is removed.

The synth@.service and keyboard.service files aren’t necessary if you always use some other program which can connect things up for you (e.g. Ardour). I use them for convenience to give a quick and simple plug’n’play experience for when I’m feeling lazy.

I called the helper script “synth”, and can use it to run FluidSynth with a specific soundfont:

# Loads bosendorfer.sf2
synth bosendorfer