diff --git a/modules/misc/news/2026/03/2026-04-11_09-06-17.nix b/modules/misc/news/2026/03/2026-04-11_09-06-17.nix new file mode 100644 index 000000000..1caab6c16 --- /dev/null +++ b/modules/misc/news/2026/03/2026-04-11_09-06-17.nix @@ -0,0 +1,15 @@ +{ pkgs, ... }: +{ + time = "2026-04-11T09:06:17+00:00"; + condition = pkgs.stdenv.hostPlatform.isLinux; + message = '' + A new module is available: `services.pipewire`. + + The module provides options for configuring the PipeWire server, the + client library, the PulseAudio and JACK emulators, the WirePlumber session + manager, and the LV2 plugins for use in filter chains. + + The module does *not* provide a way to install PipeWire, as that should be + done through your NixOS config (or system package manager). + ''; +} diff --git a/modules/services/pipewire.nix b/modules/services/pipewire.nix new file mode 100644 index 000000000..3f76831cc --- /dev/null +++ b/modules/services/pipewire.nix @@ -0,0 +1,462 @@ +{ + config, + lib, + pkgs, + ... +}: + +let + inherit (lib) + attrByPath + concatLists + concatMap + escapeShellArgs + flatten + hasSuffix + hm + literalExpression + maintainers + mapAttrsToList + mkEnableOption + mkIf + mkOption + optional + optionals + path + platforms + types + ; + inherit (pkgs) + buildEnv + formats + runCommand + symlinkJoin + writeTextDir + ; + + jsonFormat = formats.json { }; + jsonAttrs = types.attrsOf jsonFormat.type; + + cfg = config.services.pipewire; + systemdCfg = config.systemd.user; +in + +{ + meta.maintainers = with maintainers; [ mikaeladev ]; + + options.services.pipewire = { + enable = mkEnableOption "PipeWire configurations"; + + configs = mkOption { + type = jsonAttrs; + default = { }; + example = { + "10-clock-rate" = { + "context.properties" = { + "default.clock.rate" = 44100; + }; + }; + "11-no-upmixing" = { + "stream.properties" = { + "channelmix.upmix" = false; + }; + }; + }; + description = '' + Set of configuration files for the PipeWire server. + + Every item in this attrset becomes a separate drop-in file in + {file}`$XDG_CONFIG_HOME/pipewire/pipewire.conf.d/`. + + See `man pipewire.conf` for details, and the [PipeWire wiki] for + examples. + + [pipewire wiki]: https://gitlab.freedesktop.org/pipewire/pipewire/-/wikis/Config-PipeWire + ''; + }; + + clientConfigs = mkOption { + type = jsonAttrs; + default = { }; + example = { + "10-no-resample" = { + "stream.properties" = { + "resample.disable" = true; + }; + }; + }; + description = '' + Set of configuration files for the PipeWire client library. + + Every item in this attrset becomes a separate drop-in file in + {file}`$XDG_CONFIG_HOME/pipewire/client.conf.d/`. + + See the [PipeWire wiki][wiki] for examples. + + [wiki]: https://gitlab.freedesktop.org/pipewire/pipewire/-/wikis/Config-client + ''; + }; + + jackConfigs = mkOption { + type = jsonAttrs; + default = { }; + example = { + "20-hide-midi" = { + "jack.properties" = { + "jack.show-midi" = false; + }; + }; + }; + description = '' + Set of configuration files for the PipeWire JACK server and client + library. + + Every item in this attrset becomes a separate drop-in file in + {file}`$XDG_CONFIG_HOME/pipewire/jack.conf.d/`. + + See the [PipeWire wiki] for examples. + + [pipewire wiki]: https://gitlab.freedesktop.org/pipewire/pipewire/-/wikis/Config-JACK + ''; + }; + + pulseConfigs = mkOption { + type = jsonAttrs; + default = { }; + example = { + "15-force-s16-info" = { + "pulse.rules" = [ + { + matches = [ { "application.process.binary" = "my-broken-app"; } ]; + actions = { + quirks = [ "force-s16-info" ]; + }; + } + ]; + }; + }; + description = '' + Set of configuration files for the PipeWire PulseAudio server. + + Every item in this attrset becomes a separate drop-in file in + {file}`$XDG_CONFIG_HOME/pipewire/pipewire-pulse.conf.d/`. + + See `man pipewire-pulse.conf` for details, and the [PipeWire wiki] for + examples. + + [pipewire wiki]: https://gitlab.freedesktop.org/pipewire/pipewire/-/wikis/Config-PulseAudio + ''; + }; + + configPackages = mkOption { + type = with types; listOf package; + default = [ ]; + example = literalExpression '' + [ + (pkgs.writeTextDir "share/pipewire/pipewire.conf.d/10-loopback.conf" ''' + context.modules = [ + { + name = libpipewire-module-loopback + args = { + node.description = "Scarlett Focusrite Line 1" + capture.props = { + audio.position = [ FL ] + stream.dont-remix = true + node.target = "alsa_input.usb-Focusrite_Scarlett_Solo_USB_Y7ZD17C24495BC-00.analog-stereo" + node.passive = true + } + playback.props = { + node.name = "SF_mono_in_1" + media.class = "Audio/Source" + audio.position = [ MONO ] + } + } + } + ] + ''') + ]''; + description = '' + List of packages that provide PipeWire configurations, in the form of + {file}`share/pipewire/*/*.conf` files. + + LV2 dependencies will be picked up from config packages automatically + via `passthru.requiredLv2Packages`. + ''; + }; + + extraLv2Packages = mkOption { + type = with types; listOf package; + default = [ ]; + example = literalExpression "[ pkgs.lsp-plugins ]"; + description = '' + List of packages that provide LV2 plugins, in the form of + {file}`lib/lv2/*` files. + + LV2 dependencies will be picked up from config packages automatically + via `passthru.requiredLv2Packages`, so they don't need to be set here. + ''; + }; + + wireplumber = { + enable = mkEnableOption "WirePlumber configurations"; + + configs = mkOption { + type = jsonAttrs; + default = { }; + example = { + log-level-debug = { + "context.properties" = { + "log.level" = "D"; + }; + }; + wh-1000xm3-ldac-hq = { + "monitor.bluez.rules" = [ + { + matches = [ + { + "device.name" = "~bluez_card.*"; + "device.product.id" = "0x0cd3"; + "device.vendor.id" = "usb:054c"; + } + ]; + actions = { + update-props = { + "bluez5.a2dp.ldac.quality" = "hq"; + }; + }; + } + ]; + }; + }; + description = '' + Set of configuration files for the WirePlumber daemon. + + Every item in this attrset becomes a separate drop-in file in + {file}`$XDG_CONFIG_HOME/wireplumber/wireplumber.conf.d/`. + + See the [NixOS option] for details. + + [nixos option]: https://search.nixos.org/options?channel=25.11&type=options&show=services.pipewire.wireplumber.extraConfig + ''; + }; + + configPackages = mkOption { + type = with types; listOf package; + default = [ ]; + example = literalExpression '' + [ + (pkgs.writeTextDir "share/wireplumber/wireplumber.conf.d/10-bluez.conf" ''' + monitor.bluez.properties = { + bluez5.roles = [ a2dp_sink a2dp_source bap_sink bap_source hsp_hs hsp_ag hfp_hf hfp_ag ] + bluez5.codecs = [ sbc sbc_xq aac ] + bluez5.enable-sbc-xq = true + bluez5.hfphsp-backend = "native" + } + ''') + ]''; + description = '' + List of packages that provide WirePlumber configurations, in the form + of {file}`share/wireplumber/*/*.conf` files. + + LV2 dependencies will be picked up from config packages automatically + via `passthru.requiredLv2Packages`. + ''; + }; + + scripts = mkOption { + type = with types; attrsOf lines; + default = { }; + example = { + "test/hello-world.lua" = '' + print("Hello, world!") + ''; + }; + description = '' + Set of lua scripts to be used by WirePlumber configuration files. + + Every item in this attrset becomes a separate drop-in file in + {file}`$XDG_DATA_HOME/wireplumber/scripts/`. + + See the [NixOS option] for details. + + [nixos option]: https://search.nixos.org/options?channel=25.11&type=options&show=services.pipewire.wireplumber.extraScripts + ''; + }; + + scriptPackages = mkOption { + type = with types; listOf package; + default = [ ]; + example = literalExpression '' + [ + (pkgs.writeTextDir "share/wireplumber/scripts/test/hello-world.lua" ''' + print("Hello, world!") + ''') + ]''; + description = '' + List of packages that provide WirePlumber scripts, in the form of + {file}`share/wireplumber/scripts/*/*.lua` files. + ''; + }; + }; + }; + + config = mkIf cfg.enable { + assertions = [ + (hm.assertions.assertPlatform "services.pipewire" pkgs platforms.linux) + ]; + + xdg = + let + withSuffix = suffix: value: if (hasSuffix suffix value) then value else (value + suffix); + + mapConfigsToPaths = + parent: subdir: configs: + mapAttrsToList ( + name: value: + let + filename = withSuffix ".conf" name; + filepath = path.subpath.join [ + "share/${parent}/${subdir}.conf.d" + filename + ]; + in + runCommand "pipewire-${name}-config" { } '' + mkdir -p $out/${dirOf filepath} + ln -s ${jsonFormat.generate name value} $out/${filepath} + '' + ) configs; + + condMapConfigsToPaths = + parent: subdir: configs: + optional (configs != { }) (mapConfigsToPaths parent subdir configs); + + pipewireConfigPaths = + cfg.configPackages + ++ concatLists [ + (condMapConfigsToPaths "pipewire" "pipewire" cfg.configs) + (condMapConfigsToPaths "pipewire" "client" cfg.clientConfigs) + (condMapConfigsToPaths "pipewire" "jack" cfg.jackConfigs) + (condMapConfigsToPaths "pipewire" "pipewire-pulse" cfg.pulseConfigs) + ]; + + wireplumberConfigPaths = + cfg.wireplumber.configPackages + ++ (condMapConfigsToPaths "wireplumber" "wireplumber" cfg.wireplumber.configs); + + wireplumberScriptPaths = + cfg.wireplumber.scriptPackages + ++ (optional (cfg.wireplumber.scripts != { }) ( + mapAttrsToList ( + name: content: + let + filename = withSuffix ".lua" name; + filepath = path.subpath.join [ + "share/wireplumber/scripts" + filename + ]; + in + writeTextDir filepath content + ) cfg.wireplumber.scripts + )); + + onChange = '' + if [[ ! -v PIPEWIRE_RELOAD ]]; then + PIPEWIRE_RELOAD=1 + fi + ''; + in + { + configFile = { + "pipewire" = { + inherit onChange; + enable = pipewireConfigPaths != [ ]; + source = symlinkJoin { + name = "pipewire-configs"; + paths = pipewireConfigPaths; + stripPrefix = "/share/pipewire"; + }; + }; + "wireplumber" = mkIf cfg.wireplumber.enable { + inherit onChange; + enable = wireplumberConfigPaths != [ ]; + source = symlinkJoin { + name = "wireplumber-configs"; + paths = wireplumberConfigPaths; + stripPrefix = "/share/wireplumber"; + }; + }; + }; + + dataFile = { + "wireplumber" = mkIf cfg.wireplumber.enable { + inherit onChange; + enable = wireplumberScriptPaths != [ ]; + source = symlinkJoin { + name = "wireplumber-scripts"; + paths = wireplumberScriptPaths; + stripPrefix = "/share/wireplumber"; + }; + }; + }; + }; + + systemd.user.sessionVariables = + let + lv2PluginPaths = + cfg.extraLv2Packages + ++ flatten ( + concatMap (p: attrByPath [ "passthru" "requiredLv2Packages" ] [ ] p) ( + cfg.configPackages ++ (optionals cfg.wireplumber.enable cfg.wireplumber.configPackages) + ) + ); + + lv2Plugins = buildEnv { + name = "pipewire-lv2-plugins"; + paths = lv2PluginPaths; + pathsToLink = [ "/lib/lv2" ]; + }; + in + { + LV2_PATH = mkIf (lv2PluginPaths != [ ]) "${lv2Plugins}/lib/lv2\${LV2_PATH:+:$LV2_PATH}"; + }; + + home.activation.reloadPipewire = + let + pipewireUnits = escapeShellArgs ( + [ + "pipewire" + "pipewire-pulse" + ] + ++ optional cfg.wireplumber.enable "wireplumber" + ); + + ensureSystemd = '' + env XDG_RUNTIME_DIR="''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" \ + PATH="${dirOf systemdCfg.systemctlPath}:$PATH" \ + ''; + + systemctl = "${ensureSystemd} systemctl"; + in + mkIf (systemdCfg.enable && config.home.username != "root") ( + hm.dag.entryAfter [ "onFilesChange" "reloadSystemd" ] '' + if [[ -v PIPEWIRE_RELOAD ]]; then + if [[ -v DRY_RUN ]]; then + echo 'systemctl --user restart ${pipewireUnits}' + else + systemdStatus=$(${systemctl} --user is-system-running 2>&1 || true) + + if [[ $systemdStatus == 'running' ]]; then + ${systemctl} --user restart ${pipewireUnits} + else + echo "User systemd daemon not running. Skipping pipewire reload." + fi + + unset systemdStatus + fi + + unset PIPEWIRE_RELOAD + fi + '' + ); + }; +} diff --git a/tests/modules/services/pipewire/configs.nix b/tests/modules/services/pipewire/configs.nix new file mode 100644 index 000000000..3dd92e484 --- /dev/null +++ b/tests/modules/services/pipewire/configs.nix @@ -0,0 +1,76 @@ +{ pkgs, lib, ... }: + +let + jsonFormat = pkgs.formats.json { }; + + expectedValue = { + "foo.bar" = { + "baz.qux" = true; + }; + "baz.qux" = { + "foo.bar" = false; + }; + }; + + expectedValuePath = jsonFormat.generate "expected-pipewire-config" expectedValue; + expectedValueContent = lib.readFile expectedValuePath; +in + +{ + services.pipewire = rec { + enable = true; + + configs = { + "10-test" = expectedValue; + "11-test" = expectedValue; + }; + + clientConfigs = configs; + jackConfigs = configs; + pulseConfigs = configs; + + configPackages = [ + (pkgs.writeTextDir "share/pipewire/pipewire.conf.d/12-test.conf" expectedValueContent) + (pkgs.writeTextDir "share/pipewire/client.conf.d/12-test.conf" expectedValueContent) + (pkgs.writeTextDir "share/pipewire/jack.conf.d/12-test.conf" expectedValueContent) + (pkgs.writeTextDir "share/pipewire/pipewire-pulse.conf.d/12-test.conf" expectedValueContent) + ]; + + wireplumber = { + enable = true; + inherit configs; + configPackages = [ + (pkgs.writeTextDir "share/wireplumber/wireplumber.conf.d/12-test.conf" expectedValueContent) + ]; + }; + }; + + nmt.script = '' + assertPathNotExists 'home-files/.local/share/wireplumber' + + local expected=${expectedValuePath} + + local names=( + '10-test.conf' + '11-test.conf' + '12-test.conf' + ) + + local subdirs=( + 'pipewire/pipewire.conf.d' + 'pipewire/client.conf.d' + 'pipewire/jack.conf.d' + 'pipewire/pipewire-pulse.conf.d' + 'wireplumber/wireplumber.conf.d' + ) + + for subdir in $subdirs; do + for name in $names; do + local file="home-files/.config/$subdir/$name" + + assertFileExists "$file" + assertFileContent "$file" "$expected" + done + done + ''; +} diff --git a/tests/modules/services/pipewire/default.nix b/tests/modules/services/pipewire/default.nix new file mode 100644 index 000000000..97b912c68 --- /dev/null +++ b/tests/modules/services/pipewire/default.nix @@ -0,0 +1,8 @@ +{ lib, pkgs, ... }: + +lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux { + pipewire-configs = ./configs.nix; + pipewire-empty = ./empty.nix; + pipewire-plugins = ./plugins.nix; + pipewire-scripts = ./scripts.nix; +} diff --git a/tests/modules/services/pipewire/empty.nix b/tests/modules/services/pipewire/empty.nix new file mode 100644 index 000000000..20d83d9cb --- /dev/null +++ b/tests/modules/services/pipewire/empty.nix @@ -0,0 +1,12 @@ +{ + services.pipewire = { + enable = true; + wireplumber.enable = true; + }; + + nmt.script = '' + assertPathNotExists 'home-files/.config/pipewire' + assertPathNotExists 'home-files/.config/wireplumber' + assertPathNotExists 'home-files/.local/share/wireplumber' + ''; +} diff --git a/tests/modules/services/pipewire/plugins.nix b/tests/modules/services/pipewire/plugins.nix new file mode 100644 index 000000000..bbc0b3141 --- /dev/null +++ b/tests/modules/services/pipewire/plugins.nix @@ -0,0 +1,25 @@ +{ pkgs, ... }: + +{ + services.pipewire = { + enable = true; + + extraLv2Packages = [ + (pkgs.writeTextDir "lib/lv2/test" '' + bing bong + '') + ]; + }; + + nmt.script = '' + assertPathNotExists 'home-files/.config/pipewire' + assertPathNotExists 'home-files/.config/wireplumber' + assertPathNotExists 'home-files/.local/share/wireplumber' + + file='home-files/.config/environment.d/10-home-manager.conf' + regex='^LV2_PATH=/nix/store/.*/lib/lv2\''${LV2_PATH:+:\$LV2_PATH}$' + + assertFileExists "$file" + assertFileRegex "$file" "$regex" + ''; +} diff --git a/tests/modules/services/pipewire/scripts.nix b/tests/modules/services/pipewire/scripts.nix new file mode 100644 index 000000000..6e4254ade --- /dev/null +++ b/tests/modules/services/pipewire/scripts.nix @@ -0,0 +1,47 @@ +{ pkgs, ... }: + +let + inherit (pkgs) writeText; + + expectedValue = '' + print("Hello, world!") + ''; + + expectedValuePath = writeText "expected-wireplumber-script" expectedValue; +in + +{ + services.pipewire = { + enable = true; + wireplumber = { + enable = true; + scripts = { + "foo/hello-world.lua" = expectedValue; + "bar/hello-world.lua" = expectedValue; + }; + scriptPackages = [ + (pkgs.writeTextDir "share/wireplumber/scripts/baz/hello-world.lua" expectedValue) + ]; + }; + }; + + nmt.script = '' + assertPathNotExists 'home-files/.config/pipewire' + assertPathNotExists 'home-files/.config/wireplumber' + + local expected=${expectedValuePath} + + local scripts=( + 'foo/hello-world.lua' + 'bar/hello-world.lua' + 'baz/hello-world.lua' + ) + + for script in $scripts; do + local file="home-files/.local/share/wireplumber/scripts/$script" + + assertFileExists "$file" + assertFileContent "$file" "$expected" + done + ''; +}