Customizing WirePlumber's configuration for embedded systems

Recently, I had the opportunity to work with a customer that required customization of WirePlumber’s configuration for use in their embedded Linux system. Oftentimes, people are confused about how to customize WirePlumber for their use case. I thought I’d take this opportunity to discuss how it was configured in this particular case and why.

Configuration structure

Let me first give you some context on how WirePlumber’s configuration is structured. Starting with WirePlumber 0.5, the loading process is governed by the configuration file’s wireplumber.components and wireplumber.profiles sections. The components section defines all the available modules and scripts and the profiles section defines which of those components are going to be loaded.

Components

A typical component description looks like this:

  {
    name = monitors/bluez.lua, type = script/lua
    provides = monitor.bluez
    requires = [ support.export-core,
                 pw.client-device,
                 pw.client-node,
                 pw.node-factory.adapter ]
    wants = [ monitor.bluez.seat-monitoring ]
  }

As you can tell, each component description starts with a name, which specifies which script or module to load, followed by a type that indicates what kind of component it is. This one is a Lua script, designated as script/lua. In addition, each component provides a certain feature (in this case, monitor.bluez) and may optionally require or want other features in order to load. This is a dependency mechanism, allowing components to depend on, or be depended on by, other components during the loading process. Components listed in the requires field will need to be loaded successfully before loading this component and ones listed in the wants field will be attempted to be loaded, but they are optional if they fail (or if they are explicitly disabled, see below).

Profiles

Having an idea of what components look like, now let’s look at profiles. Here’s a typical profile description:

  main = {
    inherits = [ base ]

    metadata.sm-settings = required
    metadata.sm-objects = required

    policy.standard = required

    hardware.audio = required
    hardware.bluetooth = required
    hardware.video-capture = required
  }

A profile always has a name, in this case main, which can be used to select which profile to load in the WirePlumber command line (with the -p / --profile switch). If none is specified, WirePlumber loads the main profile by default. Inside the profile description, we can list component features, setting them to either required or disabled, to designate if they must be loaded or if they must not. If a component feature is not listed here, it is assumed to be optional, which means it will be loaded only if it’s pulled by another component via the dependency mechanism. Finally, a profile may have an inherits field, which is an array listing other profiles from which it inherits. In this case, base is another profile that lists common features that are required by many profiles and the inheritance avoids copy-pasting them all over the place:

  base = {
    check.no-media-session = required
    support.settings = required
    support.log-settings = required
    support.session-services = required
  }

Upstream defaults

The default wireplumber.conf configuration file, which is included with WirePlumber, lists all the upstream provided components together with a set of profiles which can be used as a starting point for any customized configuration. For instance, apart from the main profile, the default configuration also includes the main-systemwide, main-embedded and video-only profiles, defined as:

  # Profile for running on a systemwide level
  main-systemwide = {
    inherits = [ main, mixin.systemwide-session ]
  }

  # Typical profile for embedded use cases, systemwide without maintaining state
  main-embedded = {
    inherits = [ main, mixin.systemwide-session, mixin.stateless ]
  }

  # Profile for video-only use cases (camera & screen sharing)
  video-only = {
    inherits = [ main ]
    hardware.audio = disabled
    hardware.bluetooth = disabled
  }

The mixin.* profiles that you see there are also profiles, but they are not meant to be used standalone; they are only meant to be inherited. In this case, you can see that they are inherited together with the main profile. You can see their full description in the repository.

Something noteworthy to point out here is that when features are listed more than once, each new listing shadows the previous one and overrides its value. In the above example, you may have noticed that the video-only profile inherits main, which was listing hardware.audio and hardware.bluetooth as required. However, the video-only profile redefines them as disabled. In this case, the latter definition overrides the previous one in main, effectively disabling those components.

Profile overrides

There’s one more thing to note about profile inheritance. While it is possible to inherit other profiles when creating our own using the inherits keyword, it is also possible to override existing profiles by redefining them in a configuration fragment file. Doing that effectively uses the built-in file merging mechanism that the configuration loader uses, which merges arrays and objects together, overriding previous values. For instance, the video-only profile shown above could also be defined like this in a fragment file (ex. in wireplumber.conf.d/video-only.conf):

  main = {
    hardware.audio = disabled
    hardware.bluetooth = disabled
  }

This is effectively the same as the video-only profile, but it removes the requirement that you load WirePlumber with the -p switch. So, if you want your system to permanently have this configuration, you can add this fragment file instead of modifying the systemd units to load WirePlumber with -p video-only.

Settings

Apart from components and profiles, there’s also a way to configure variables that are used by various components to tune their operation. These are called settings and can be set in the wireplumber.settings section. The default configuration file lists all the available settings with their default values and, like with profiles, it is possible to re-define the wireplumber.settings section in any configuration fragment and override values in the same way that it works for profiles.

The customer’s configuration

Without any further delay, here’s the configuration fragment that was used in this customer’s system to customize WirePlumber, installed as /etc/wireplumber/wireplumber.conf.d/90-custom-wireplumber.conf

wireplumber.profiles = {
    # component overrides for the "main" profile
    main = {
        # disable v4l2 and libcamera sources, we use pipewire only for audio
        hardware.video-capture = disabled

        # disable MIDI device monitors
        monitor.alsa-midi = disabled
        monitor.bluez-midi = disabled

        # we don't ship pipewire-media-session, this check is redundant
        check.no-media-session = disabled

        # disable unneeded desktop features
        support.portal-permissionstore = disabled
        support.reserve-device = disabled
        monitor.bluez.seat-monitoring = disabled

        # disable all hooks that save/restore state information from ~/.local/state
        hooks.device.profile.state = disabled
        hooks.device.routes.state = disabled
        hooks.default-nodes.state = disabled
        hooks.stream.state = disabled

        # disable flatpak & snap access rules - not needed
        script.client.access-portal = disabled
        script.client.access-snap = disabled
    }
}

wireplumber.settings = {
    # disable bluetooth profile auto-switching and state storage
    bluetooth.use-persistent-storage = false
    bluetooth.autoswitch-to-headset-profile = false

    device.routes.default-sink-volume = 1.0
}

The first thing you may notice is that this file follows the approach of overriding the main profile instead of defining a new one. This is to avoid changing the systemd units, since this is the only profile that will ever be used on this system.

Second, you see that the hardware.video-capture feature, as well as the two MIDI-related device monitors are disabled. This reduces the available devices to just audio ones, with the remaining enabled monitors being the ALSA and Bluetooth (BlueZ) ones, aligning with the use cases of this system. Note that disabling the video capture and MIDI device monitors does not mean that PipeWire is no more able to carry video or MIDI data. It is perfectly capable of doing so and in fact, the screensharing functionality, being a video stream between two applications, is still available on this system.

Moving forward, there are some more features that are being disabled:

  • The check.no-media-session feature is a plugin that double-checks your system is not running the old pipewire-media-session at the same time, as that would clash with WirePlumber. That’s definitely not needed on a controlled system where we know this program is not present and there is no way for users to install it.

  • The support.portal-permissionstore, support.reserve-device and monitor.bluez.seat-monitoring features are all desktop-related and also unnecessary here. The “portal permission store” plugin allows WirePlumber to communicate with the permission store of the XDG Desktop Portal to figure out permissions for Flatpak applications. The “reserve device” plugin allows device monitors to register devices on D-Bus and arbitrate their exclusive access, which is needed if you want to allow other audio daemons (ex. JACK) to take over ALSA devices from PipeWire. Finally, the “seat monitoring” plugin ensures that Bluetooth devices are released when you switch between users, so that they are always grabbed by and available to the active user in a multi-user system. None of that is necessary to our embedded system.

  • The hooks.*.state features control Lua scripts that contain event hooks to manage state storage in ~/.local/state. This state includes things such as the user’s preferred default devices, preferred profiles and routes to use for devices, volume levels for various applications that were changed manually, etc… This is all unwanted on an embedded system that is meant to always boot into the same clean state, and if there’s any state at all, it needs to be controlled by upper layers (i.e. the customer’s middleware & UI).

  • The script.client.access-* features contain access rules for use with Flatpak and Snap. Since this system doesn’t use either Flatpak or Snap, these are also useless.

Last but not least, the wireplumber.settings section lists changes to three of the settings variables. The first two are related to the Bluetooth headset autoswitch functionality, which implements automatic switching of Bluetooth headsets between the A2DP and HFP profiles depending on the use case. This is not really necessary here, but unfortunately, for historical reasons, the code that implements this is not easy to disable via a component feature. This is something to be improved in the future. The last setting instructs WirePlumber to configure audio sinks to 100% volume when they are initialized, instead of the 40% default that is used on desktops.

Remarks

You may ask at this point: is it harmful to leave all those features enabled if they are not being used? The answer is, some features are harmless if left enabled, but some others may have side-effects. For instance, the check.no-media-session and script.client.access-* features are pretty much harmless if they are left enabled; they may just add some processing overhead. However, the hooks.*.state features actively manage state and have visible side-effects.

Now, the desktop-related features are somewhat special. On one hand, they are harmless when left enabled and unused, but on the other hand they introduce significant dependencies. The support.portal-permissionstore and support.reserve-device plugins both depend on support.dbus, which is the plugin that enables WirePlumber to talk to D-Bus and requires an active user session bus. This cannot work on systems where PipeWire and WirePlumber are deployed system-wide and there is no such bus. Similarly, the monitor.bluez.seat-monitoring depends on support.logind, which enables communication with systemd-logind, allowing WirePlumber to track the active user session. This may also be problematic on system-wide deployments or on systems that don’t use systemd (although alternatives exist to make this work if needed).

Finally, you may have noticed that this override of the main profile is not using any inherits. The truth is, it would make a lot of sense to inherit some of the mixin.* profiles. For instance, disabling the hooks.*.state features can be achieved by inheriting mixin.stateless, and disabling the support features that can be problematic on system-wide deployments, by inheriting mixin.systemwide-session. This would avoid repetition and also make the configuration more future-proof in case additional upstream components are added in these categories. However, the version of WirePlumber used on this system was older than the version that added the inherits mechanism, therefore it was not possible to use it. In fact, this project served as inspiration for implementing the inheritance mechanism in the first place!

Conclusion

WirePlumber is a highly modular daemon whose behavior fully depends on the components that it loads, based on a profile description and the dependencies between those components. Furthermore, the behavior of the loaded components can be tuned via settings. All of those can be described in configuration fragments, which allow easy customization on embedded systems, as shown in this example. You can read more about WirePlumber’s configuration process in the official documentation